mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-21 03:40:40 +08:00
Compare commits
36
Commits
56ebfdf8e7
...
v1.5.1-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e263aab10d | ||
|
|
9abdfbf684 | ||
|
|
31a0b86cee | ||
|
|
fb34f42744 | ||
|
|
4302ab291c | ||
|
|
4bf92c9e2c | ||
|
|
28de4cf938 | ||
|
|
ce7a3c178e | ||
|
|
d9116200fe | ||
|
|
1d9242515a | ||
|
|
ad62e60b9a | ||
|
|
c4aca303c8 | ||
|
|
57bb91dba3 | ||
|
|
57cab3979e | ||
|
|
d8ccdbaa38 | ||
|
|
f3e67d7d6e | ||
|
|
d648585891 | ||
|
|
e40c7fca98 | ||
|
|
f6324416ec | ||
|
|
0977bd4195 | ||
|
|
f4e094cb35 | ||
|
|
1fe706c5fc | ||
|
|
5fd928ddba | ||
|
|
977899b240 | ||
|
|
7fc1344756 | ||
|
|
73e3bf709d | ||
|
|
6d90c28513 | ||
|
|
c944db6519 | ||
|
|
fc921c9ca1 | ||
|
|
ba2afc982b | ||
|
|
c6635fbdc8 | ||
|
|
3c403f6162 | ||
|
|
b54d32ef41 | ||
|
|
28e9626e0d | ||
|
|
a6e325f190 | ||
|
|
23bf2ff8b8 |
@@ -271,7 +271,7 @@ jobs:
|
||||
|
||||
- name: Build CrossDesk
|
||||
run: |
|
||||
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=false -y
|
||||
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=false -vyD
|
||||
xmake b -vy crossdesk
|
||||
|
||||
- name: Package CrossDesk app
|
||||
|
||||
@@ -87,7 +87,7 @@ if [[ ! -x "$BUILD_BINARY" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command_name in readelf ldd dpkg-deb; do
|
||||
for command_name in readelf ldd dpkg-deb strip; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "Required packaging command is missing: $command_name" >&2
|
||||
exit 1
|
||||
@@ -128,6 +128,9 @@ if [[ -n "$SLINT_NEEDED" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
install -m 0755 "$SLINT_LIBRARY" "$PRIVATE_DIR/$SLINT_NEEDED"
|
||||
# Keep dynamic exports/relocations while removing unneeded symbols from the
|
||||
# staged copy. Never strip Xmake's cached runtime in place.
|
||||
strip --strip-unneeded "$PRIVATE_DIR/$SLINT_NEEDED"
|
||||
fi
|
||||
|
||||
cat > "$BIN_DIR/$PKG_NAME" <<'EOF'
|
||||
@@ -161,7 +164,7 @@ Architecture: $DEBIAN_ARCH
|
||||
Maintainer: $MAINTAINER
|
||||
Description: $DESCRIPTION
|
||||
Depends: libc6 (>= 2.31), libstdc++6 (>= 10), libx11-6, libxext6,
|
||||
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxcb1, libxcb-randr0,
|
||||
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxi6, libxcb1, libxcb-randr0,
|
||||
libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, libxcb-xkb1,
|
||||
libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, libsndio7.0,
|
||||
libxcb-shm0, libpulse0, libdrm2, libdbus-1-3, libgl1
|
||||
|
||||
@@ -11,7 +11,7 @@ EXPECTED_ARCH="$2"
|
||||
APP_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/CrossDesk"
|
||||
FRAMEWORKS_DIR="$APP_BUNDLE/Contents/Frameworks"
|
||||
|
||||
for command_name in codesign install_name_tool lipo otool; do
|
||||
for command_name in codesign install_name_tool lipo otool strip; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "Required bundling command is missing: $command_name" >&2
|
||||
exit 1
|
||||
@@ -123,7 +123,11 @@ if ! otool -l "$APP_EXECUTABLE" | awk '
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP_EXECUTABLE"
|
||||
fi
|
||||
|
||||
# install_name_tool invalidates the linker's ad-hoc signature. Sign nested code
|
||||
# Strip only the staged runtime, retaining exported symbols for Slint's C ABI.
|
||||
# The package cache remains intact for debugging and other consumers.
|
||||
strip -S -x "$FRAMEWORKS_DIR/$SLINT_NAME"
|
||||
|
||||
# install_name_tool and strip invalidate the linker's ad-hoc signature. Sign nested code
|
||||
# first, then seal the complete app bundle so local packages remain launchable.
|
||||
codesign --force --sign - "$FRAMEWORKS_DIR/$SLINT_NAME"
|
||||
codesign --force --deep --sign - "$APP_BUNDLE"
|
||||
|
||||
@@ -22,8 +22,13 @@
|
||||
#include "config_center.h"
|
||||
#include "daemon.h"
|
||||
#include "path_manager.h"
|
||||
#include "rd_log.h"
|
||||
#include "render.h"
|
||||
|
||||
#ifdef __linux__
|
||||
#include "platform/linux/privacy/privacy_guard.h"
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
namespace {
|
||||
|
||||
@@ -175,11 +180,27 @@ int HandleServiceCliCommand(const std::string& command) {
|
||||
#endif
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
#ifdef __linux__
|
||||
// Run before GUI, configuration, logging or daemon initialization. The
|
||||
// guard owns native privacy resources and restores them if this app exits.
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
|
||||
return crossdesk::RunLinuxPrivacyGuard();
|
||||
}
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
|
||||
return crossdesk::RunSlintRendererProbe();
|
||||
}
|
||||
#endif
|
||||
|
||||
// Service operations and config migration can log before the GUI starts.
|
||||
// Configure the directory first, including CLI and daemon child paths.
|
||||
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
|
||||
crossdesk::InitLogger(path_manager->GetLogPath().string());
|
||||
|
||||
#ifdef _WIN32
|
||||
if (argc > 1 && IsServiceCliCommand(argv[1])) {
|
||||
return HandleServiceCliCommand(argv[1]);
|
||||
}
|
||||
@@ -206,7 +227,6 @@ int main(int argc, char* argv[]) {
|
||||
#endif
|
||||
|
||||
bool enable_daemon = false;
|
||||
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
|
||||
if (path_manager) {
|
||||
std::string cache_path = path_manager->GetCachePath().string();
|
||||
crossdesk::ConfigCenter config_center(cache_path + "/config.ini");
|
||||
@@ -233,4 +253,4 @@ int main(int argc, char* argv[]) {
|
||||
crossdesk::Render render;
|
||||
render.Run();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,12 @@ int ConfigCenter::Load() {
|
||||
ini_.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
persist_config_migration = true;
|
||||
}
|
||||
enable_srtp_ = ini_.GetBoolValue(section_, "enable_srtp", enable_srtp_);
|
||||
// Migrate the former user switch. Keep true in the file for older builds,
|
||||
// while the current application always requests encrypted native media.
|
||||
if (!ini_.GetBoolValue(section_, "enable_srtp", false)) {
|
||||
ini_.SetBoolValue(section_, "enable_srtp", true);
|
||||
persist_config_migration = true;
|
||||
}
|
||||
enable_self_hosted_ =
|
||||
ini_.GetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
|
||||
@@ -165,7 +170,7 @@ int ConfigCenter::Save() {
|
||||
ini_.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
ini_.SetBoolValue(section_, "enable_turn",
|
||||
turn_mode_ != TURN_MODE::DISABLED);
|
||||
ini_.SetBoolValue(section_, "enable_srtp", enable_srtp_);
|
||||
ini_.SetBoolValue(section_, "enable_srtp", true);
|
||||
ini_.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
|
||||
// only save when self hosted
|
||||
@@ -301,16 +306,6 @@ int ConfigCenter::SetTurn(bool enable_turn) {
|
||||
return SetTurnMode(turn_mode_);
|
||||
}
|
||||
|
||||
int ConfigCenter::SetSrtp(bool enable_srtp) {
|
||||
enable_srtp_ = enable_srtp;
|
||||
ini_.SetBoolValue(section_, "enable_srtp", enable_srtp_);
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetServerHost(const std::string& signal_server_host) {
|
||||
signal_server_host_ = signal_server_host;
|
||||
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
@@ -450,8 +445,18 @@ ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
|
||||
return video_encode_format_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodecAvailable() {
|
||||
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
|
||||
!defined(__arm__) && USE_CUDA) || \
|
||||
defined(__APPLE__))
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodec() const {
|
||||
return hardware_video_codec_;
|
||||
return IsHardwareVideoCodecAvailable() && hardware_video_codec_;
|
||||
}
|
||||
|
||||
ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
|
||||
@@ -462,7 +467,7 @@ bool ConfigCenter::IsEnableTurn() const {
|
||||
return turn_mode_ != TURN_MODE::DISABLED;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsEnableSrtp() const { return enable_srtp_; }
|
||||
bool ConfigCenter::IsEnableSrtp() const { return true; }
|
||||
|
||||
std::string ConfigCenter::GetSignalServerHost() const {
|
||||
return signal_server_host_;
|
||||
|
||||
@@ -47,7 +47,6 @@ class ConfigCenter {
|
||||
int SetHardwareVideoCodec(bool hardware_video_codec);
|
||||
int SetTurnMode(TURN_MODE turn_mode);
|
||||
int SetTurn(bool enable_turn);
|
||||
int SetSrtp(bool enable_srtp);
|
||||
int SetServerHost(const std::string& signal_server_host);
|
||||
int SetServerPort(int signal_server_port);
|
||||
int SetSelfHosted(bool enable_self_hosted);
|
||||
@@ -65,6 +64,8 @@ class ConfigCenter {
|
||||
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
||||
VIDEO_ADAPTATION_POLICY GetVideoAdaptationPolicy() const;
|
||||
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
||||
// Whether this build includes a hardware video codec backend.
|
||||
static bool IsHardwareVideoCodecAvailable();
|
||||
bool IsHardwareVideoCodec() const;
|
||||
TURN_MODE GetTurnMode() const;
|
||||
bool IsEnableTurn() const;
|
||||
@@ -97,7 +98,6 @@ class ConfigCenter {
|
||||
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
|
||||
bool hardware_video_codec_ = false;
|
||||
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
||||
bool enable_srtp_ = false;
|
||||
std::string signal_server_host_ = "";
|
||||
std::string signal_server_host_default_ = "api.crossdesk.cn";
|
||||
int signal_server_port_ = 0;
|
||||
|
||||
@@ -42,6 +42,9 @@ class KeyboardCapturer : public DeviceController {
|
||||
|
||||
virtual int Hook(OnKeyAction on_key_action, void* user_ptr) = 0;
|
||||
virtual int Unhook() = 0;
|
||||
// Backends with an asynchronous capture loop override this so the UI can
|
||||
// fall back to window events if that loop stops unexpectedly.
|
||||
virtual bool IsHookActive() const { return true; }
|
||||
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code = 0,
|
||||
bool extended = false) = 0;
|
||||
|
||||
@@ -67,7 +67,6 @@ struct InteractionState {
|
||||
bool start_screen_capturer_ = false;
|
||||
bool screen_capturer_is_started_ = false;
|
||||
bool start_keyboard_capturer_ = false;
|
||||
bool show_cursor_ = false;
|
||||
bool keyboard_capturer_is_started_ = false;
|
||||
bool keyboard_capturer_uses_window_events_ = false;
|
||||
bool foucs_on_main_window_ = false;
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
#include "platform/windows/gui/slint_backend.h"
|
||||
#include "platform/windows/gui/tray/win_tray.h"
|
||||
#include "platform/windows/gui/window_icons.h"
|
||||
#elif defined(__APPLE__)
|
||||
#include "platform/macos/gui/tray/mac_tray.h"
|
||||
#include "platform/window_drag.h"
|
||||
@@ -950,10 +951,10 @@ int GuiApplication::Run() {
|
||||
}
|
||||
exec_log_path_ = path_manager_->GetLogPath().string();
|
||||
dll_log_path_ = exec_log_path_;
|
||||
InitializeLogger();
|
||||
cache_path_ = path_manager_->GetCachePath().string();
|
||||
config_center_ = std::make_unique<ConfigCenter>(cache_path_ + "/config.ini");
|
||||
|
||||
InitializeLogger();
|
||||
LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION);
|
||||
|
||||
strncpy(signal_server_ip_self_, config_center_->GetSignalServerHost().c_str(),
|
||||
@@ -1013,6 +1014,9 @@ int GuiApplication::Run() {
|
||||
// when deciding whether the last hidden window should stop the event loop.
|
||||
// Keep the loop alive until the tray's explicit Exit action requests quit.
|
||||
ui_->main->show();
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
|
||||
#endif
|
||||
slint::run_event_loop(slint::EventLoopMode::RunUntilQuit);
|
||||
Cleanup();
|
||||
return 0;
|
||||
@@ -1029,7 +1033,6 @@ void GuiApplication::InitializeSettings() {
|
||||
localization_language_ =
|
||||
static_cast<ConfigCenter::LANGUAGE>(localization_language_index_);
|
||||
privacy_.SetText({
|
||||
localization::privacy_verification[localization_language_index_],
|
||||
localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
}
|
||||
|
||||
@@ -1156,6 +1159,9 @@ void GuiApplication::InitializeSystemTray() {
|
||||
return;
|
||||
}
|
||||
ui_->main->show();
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
|
||||
#endif
|
||||
ui_->main->window().set_minimized(false);
|
||||
#if defined(__APPLE__)
|
||||
MacActivateWindow(ui_->main->window().appkit_view());
|
||||
@@ -1225,9 +1231,12 @@ void GuiApplication::ResetSettingsUi() {
|
||||
ui_->main->set_adaptation_policy_index(
|
||||
video_adaptation_policy_button_value_);
|
||||
ui_->main->set_codec_index(video_encode_format_button_value_);
|
||||
ui_->main->set_hardware_codec_enabled(enable_hardware_video_codec_);
|
||||
const bool hardware_codec_available =
|
||||
ConfigCenter::IsHardwareVideoCodecAvailable();
|
||||
ui_->main->set_hardware_codec_available(hardware_codec_available);
|
||||
ui_->main->set_hardware_codec_enabled(hardware_codec_available &&
|
||||
enable_hardware_video_codec_);
|
||||
ui_->main->set_turn_enabled(enable_turn_);
|
||||
ui_->main->set_srtp_enabled(enable_srtp_);
|
||||
ui_->main->set_self_hosted_enabled(enable_self_hosted_);
|
||||
ui_->main->set_autostart_enabled(enable_autostart_);
|
||||
ui_->main->set_daemon_enabled(enable_daemon_);
|
||||
@@ -1237,6 +1246,8 @@ void GuiApplication::ResetSettingsUi() {
|
||||
ui_->main->set_capture_method_visible(true);
|
||||
ui_->main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
#endif
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
ui_->main->set_privacy_setting_available(true);
|
||||
#else
|
||||
ui_->main->set_privacy_setting_available(false);
|
||||
@@ -1580,6 +1591,10 @@ void GuiApplication::BindStreamCallbacks() {
|
||||
return;
|
||||
}
|
||||
props->selected_display_ = index;
|
||||
{
|
||||
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
||||
props->cursor_presentation_ = {};
|
||||
}
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::display_id;
|
||||
action.d = index;
|
||||
@@ -1640,6 +1655,7 @@ void GuiApplication::BindStreamCallbacks() {
|
||||
props->enable_mouse_control_ = props->control_mouse_;
|
||||
start_keyboard_capturer_ = props->control_mouse_;
|
||||
if (!props->control_mouse_) {
|
||||
devices_.StopKeyboardCapturer();
|
||||
keyboard_.ForceReleasePressedKeys();
|
||||
}
|
||||
});
|
||||
@@ -1878,9 +1894,20 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
}
|
||||
|
||||
CursorState sampled{};
|
||||
if (!cursor_state_provider_.Sample(devices_.display_info_list(),
|
||||
selected_display_, &sampled)) {
|
||||
return;
|
||||
const bool sample_ok = cursor_state_provider_.Sample(
|
||||
devices_.display_info_list(), selected_display_, &sampled);
|
||||
if (!sample_ok) {
|
||||
sampled.render_mode = CursorRenderMode::unknown;
|
||||
if (sampled.hidden_reason == CursorHiddenReason::unspecified) {
|
||||
sampled.hidden_reason = CursorHiddenReason::sampling_failed;
|
||||
}
|
||||
} else if (sampled.render_mode == CursorRenderMode::legacy) {
|
||||
// Providers without platform-specific presentation metadata only need to
|
||||
// sample visible/shape; normalize that common case once for every peer.
|
||||
sampled.render_mode = sampled.visible ? CursorRenderMode::separate
|
||||
: CursorRenderMode::hidden;
|
||||
sampled.hidden_reason = sampled.visible ? CursorHiddenReason::unspecified
|
||||
: CursorHiddenReason::system_hidden;
|
||||
}
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
@@ -1919,21 +1946,23 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
|
||||
const CursorState& previous = delivery.last_sent;
|
||||
const bool shape_changed =
|
||||
!delivery.has_sent || sampled.visible != previous.visible ||
|
||||
sampled.shape != previous.shape ||
|
||||
std::abs(sampled.visual_offset_x - previous.visual_offset_x) >
|
||||
kCursorPositionEpsilon ||
|
||||
std::abs(sampled.visual_offset_y - previous.visual_offset_y) >
|
||||
kCursorPositionEpsilon;
|
||||
!delivery.has_sent || sampled.render_mode != previous.render_mode ||
|
||||
sampled.hidden_reason != previous.hidden_reason ||
|
||||
(sample_ok &&
|
||||
(sampled.visible != previous.visible || sampled.shape != previous.shape ||
|
||||
std::abs(sampled.visual_offset_x - previous.visual_offset_x) >
|
||||
kCursorPositionEpsilon ||
|
||||
std::abs(sampled.visual_offset_y - previous.visual_offset_y) >
|
||||
kCursorPositionEpsilon));
|
||||
// Preserve sub-point cursor motion. At 10x client zoom, the old roughly
|
||||
// one-logical-pixel threshold became several visible phone points.
|
||||
const bool position_changed =
|
||||
!delivery.has_sent ||
|
||||
sampled.position_valid != previous.position_valid ||
|
||||
sampled.display_id != previous.display_id ||
|
||||
(sampled.position_valid && previous.position_valid &&
|
||||
(std::abs(sampled.x - previous.x) > kCursorPositionEpsilon ||
|
||||
std::abs(sampled.y - previous.y) > kCursorPositionEpsilon));
|
||||
sample_ok &&
|
||||
(!delivery.has_sent || sampled.position_valid != previous.position_valid ||
|
||||
sampled.display_id != previous.display_id ||
|
||||
(sampled.position_valid && previous.position_valid &&
|
||||
(std::abs(sampled.x - previous.x) > kCursorPositionEpsilon ||
|
||||
std::abs(sampled.y - previous.y) > kCursorPositionEpsilon)));
|
||||
const bool position_update_due =
|
||||
position_changed &&
|
||||
(delivery.last_sent_time.time_since_epoch().count() == 0 ||
|
||||
@@ -1941,16 +1970,12 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
const bool heartbeat_due =
|
||||
delivery.last_sent_time.time_since_epoch().count() == 0 ||
|
||||
now - delivery.last_sent_time >= kCursorStateHeartbeatInterval;
|
||||
if (suppress_position) {
|
||||
// Cursor appearance is independent of cursor position. Continue sending
|
||||
// shape changes to every controller while withholding only the sampled
|
||||
// position from the connection that originated recent input.
|
||||
if (shape_changed || heartbeat_due) {
|
||||
recipients.push_back({remote_id, false});
|
||||
}
|
||||
} else if (delivery.feedback_pending || shape_changed ||
|
||||
position_update_due || heartbeat_due) {
|
||||
recipients.push_back({remote_id, true});
|
||||
// Position feedback waits for a good sample and the echo-suppression
|
||||
// interval. Appearance changes and heartbeats are independent of both.
|
||||
const bool include_position = sample_ok && !suppress_position;
|
||||
if (shape_changed || heartbeat_due ||
|
||||
(include_position && (delivery.feedback_pending || position_update_due))) {
|
||||
recipients.push_back({remote_id, include_position});
|
||||
}
|
||||
}
|
||||
if (recipients.empty()) return;
|
||||
@@ -1959,6 +1984,18 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
for (const auto& recipient : recipients) {
|
||||
auto& delivery = cursor_delivery_states_[recipient.remote_id];
|
||||
CursorState outgoing = sampled;
|
||||
if (!sample_ok) {
|
||||
// Explicitly report unknown to new receivers, but retain the last
|
||||
// presentation for peers that only understand visible/shape.
|
||||
outgoing = delivery.has_sent ? delivery.last_sent : CursorState{};
|
||||
if (!delivery.has_sent) {
|
||||
outgoing.visible = true;
|
||||
outgoing.shape = RemoteCursorShape::default_cursor;
|
||||
}
|
||||
outgoing.seq = sampled.seq;
|
||||
outgoing.render_mode = sampled.render_mode;
|
||||
outgoing.hidden_reason = sampled.hidden_reason;
|
||||
}
|
||||
outgoing.position_update = recipient.include_position;
|
||||
if (!recipient.include_position) {
|
||||
// Keep legacy receivers at their last acknowledged position as well.
|
||||
@@ -1977,7 +2014,7 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
action.type = ControlType::cursor_state;
|
||||
action.cs = outgoing;
|
||||
const std::string message = action.to_json();
|
||||
const int result = SendDataFrameToPeer(
|
||||
const int result = SendReliableDataFrameToPeer(
|
||||
peer_, message.c_str(), message.size(), mouse_label_.c_str(),
|
||||
recipient.remote_id.c_str(), recipient.remote_id.size());
|
||||
if (result != 0) {
|
||||
@@ -1986,6 +2023,13 @@ void GuiApplication::ShareLocalCursorState() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!delivery.has_sent ||
|
||||
delivery.last_sent.render_mode != outgoing.render_mode ||
|
||||
delivery.last_sent.hidden_reason != outgoing.hidden_reason) {
|
||||
LOG_INFO("Send cursor presentation to [{}]: mode={}, reason={}",
|
||||
recipient.remote_id, static_cast<int>(outgoing.render_mode),
|
||||
static_cast<int>(outgoing.hidden_reason));
|
||||
}
|
||||
delivery.last_sent = outgoing;
|
||||
delivery.has_sent = true;
|
||||
if (recipient.include_position) {
|
||||
@@ -2125,6 +2169,9 @@ void GuiApplication::SyncMainWindow() {
|
||||
if (!ui_) {
|
||||
return;
|
||||
}
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd());
|
||||
#endif
|
||||
#if defined(__APPLE__)
|
||||
if (ui_->main_native_titlebar_attempts > 0) {
|
||||
if (HideDisabledMainWindowZoomButton()) {
|
||||
@@ -2150,14 +2197,6 @@ void GuiApplication::SyncMainWindow() {
|
||||
ui_->main->set_release_name(UiText(release_name_));
|
||||
ui_->main->set_release_date(UiText(release_date_));
|
||||
ui_->main->set_settings_session_active(HasActiveSession());
|
||||
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
|
||||
!defined(__arm__) && USE_CUDA) || \
|
||||
defined(__APPLE__))
|
||||
ui_->main->set_hardware_codec_available(true);
|
||||
#else
|
||||
ui_->main->set_hardware_codec_available(false);
|
||||
#endif
|
||||
|
||||
if (show_offline_warning_window_) {
|
||||
ui_->main->set_offline_warning(UiText(offline_warning_text_));
|
||||
show_offline_warning_window_ = false;
|
||||
@@ -2367,6 +2406,7 @@ void GuiApplication::SetStreamKeyboardFocus(bool focused) {
|
||||
|
||||
focus_on_stream_window_ = focused;
|
||||
if (!focused) {
|
||||
devices_.StopKeyboardCapturer();
|
||||
keyboard_.ForceReleasePressedKeys();
|
||||
}
|
||||
}
|
||||
@@ -2421,8 +2461,10 @@ void GuiApplication::SyncStreamWindow() {
|
||||
if (!ui_->stream) {
|
||||
return;
|
||||
}
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons((*ui_->stream)->window().win32_hwnd());
|
||||
#endif
|
||||
(*ui_->stream)->set_window_maximized((*ui_->stream)->window().is_maximized());
|
||||
(*ui_->stream)->set_srtp_enabled(enable_srtp_);
|
||||
#if defined(__APPLE__)
|
||||
if (ui_->stream_live_resize_configuration_attempts > 0) {
|
||||
if (ConfigureStreamWindowLiveResize(
|
||||
@@ -2569,14 +2611,10 @@ void GuiApplication::SyncStreamWindow() {
|
||||
const int language = localization_language_index_;
|
||||
std::string text = !connected || !props->privacy_status_received_
|
||||
? localization::privacy_screen[language]
|
||||
: controls.timeout || controls.stale ? localization::privacy_timeout[language]
|
||||
: controls.warning ? localization::privacy_paused[language]
|
||||
: controls.unsupported ? localization::privacy_unsupported[language]
|
||||
: controls.enabled ? localization::privacy_on[language]
|
||||
: !status.supported ? localization::privacy_unsupported[language]
|
||||
: controls.off ? localization::privacy_off[language]
|
||||
: localization::privacy_screen[language];
|
||||
if (connected && controls.warning && status.reason[0] != '\0')
|
||||
text += std::string("\n") + status.reason;
|
||||
(*ui_->stream)->set_privacy_status_text(UiText(text));
|
||||
}
|
||||
int remote_cursor_shape =
|
||||
@@ -2589,9 +2627,7 @@ void GuiApplication::SyncStreamWindow() {
|
||||
props->remote_cursor_state_received_;
|
||||
if (remote_cursor_active) {
|
||||
remote_cursor_shape = static_cast<int>(
|
||||
props->remote_cursor_state_.visible
|
||||
? props->remote_cursor_state_.shape
|
||||
: RemoteCursorShape::none);
|
||||
props->cursor_presentation_.Resolve(SDL_GetTicks()));
|
||||
}
|
||||
}
|
||||
(*ui_->stream)->set_remote_cursor_active(remote_cursor_active);
|
||||
@@ -2616,6 +2652,8 @@ void GuiApplication::SyncStreamWindow() {
|
||||
(*ui_->stream)->set_selected_display(props->selected_display_);
|
||||
|
||||
const auto& net = props->net_traffic_stats_;
|
||||
(*ui_->stream)->set_stats_encryption_enabled(
|
||||
status == ConnectionStatus::Connected && net.srtp_active);
|
||||
std::vector<ui::NetworkStatsRow> stats_rows;
|
||||
stats_rows.reserve(4);
|
||||
const auto append_stats_row = [&](const std::string& label,
|
||||
@@ -2822,13 +2860,18 @@ void GuiApplication::SyncServerWindow() {
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the local connection panel out of captured privacy sessions and
|
||||
// transitions; it is unnecessary while the physical screens are covered.
|
||||
// Privacy protects local display output while preserving capture.
|
||||
// Keep the panel alive throughout privacy transitions so remote
|
||||
// controllers can still see and operate it without resetting its UI state.
|
||||
bool show_controller_window = has_connected_controller;
|
||||
#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__linux__)
|
||||
// Preserve the existing panel behavior on other platforms.
|
||||
const auto privacy_status = privacy_.Snapshot();
|
||||
const bool privacy_covering = privacy_status.overlay_active ||
|
||||
privacy_status.state == PrivacyState::starting ||
|
||||
privacy_status.state == PrivacyState::stopping;
|
||||
const bool show_controller_window = has_connected_controller && !privacy_covering;
|
||||
show_controller_window = show_controller_window && !privacy_covering;
|
||||
#endif
|
||||
if (show_controller_window && !ui_->server) {
|
||||
ui_->server.emplace(ui::ServerWindow::create());
|
||||
RegisterFontAwesome((*ui_->server)->window());
|
||||
@@ -2867,6 +2910,10 @@ void GuiApplication::SyncServerWindow() {
|
||||
return;
|
||||
}
|
||||
|
||||
#if _WIN32
|
||||
ConfigureWindowsWindowIcons((*ui_->server)->window().win32_hwnd());
|
||||
#endif
|
||||
|
||||
std::vector<ui::ControllerEntry> controllers;
|
||||
std::vector<slint::SharedString> names;
|
||||
ui_->controller_ids.clear();
|
||||
@@ -2986,9 +3033,9 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
video_adaptation_policy_button_value_ =
|
||||
std::clamp(main->get_adaptation_policy_index(), 0, 2);
|
||||
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
|
||||
enable_hardware_video_codec_ = main->get_hardware_codec_enabled();
|
||||
enable_hardware_video_codec_ = ConfigCenter::IsHardwareVideoCodecAvailable() &&
|
||||
main->get_hardware_codec_enabled();
|
||||
enable_turn_ = main->get_turn_enabled();
|
||||
enable_srtp_ = main->get_srtp_enabled();
|
||||
enable_self_hosted_ = main->get_self_hosted_enabled();
|
||||
enable_autostart_ = main->get_autostart_enabled();
|
||||
enable_daemon_ = main->get_daemon_enabled();
|
||||
@@ -2998,7 +3045,6 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
localization_language_index_ = language_button_value_;
|
||||
config_center_->SetLanguage(localization_language_);
|
||||
privacy_.SetText({
|
||||
localization::privacy_verification[localization_language_index_],
|
||||
localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
config_center_->SetVideoQuality(
|
||||
static_cast<ConfigCenter::VIDEO_QUALITY>(video_quality_button_value_));
|
||||
@@ -3012,7 +3058,6 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
video_encode_format_button_value_));
|
||||
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
|
||||
config_center_->SetTurn(enable_turn_);
|
||||
config_center_->SetSrtp(enable_srtp_);
|
||||
config_center_->SetSelfHosted(enable_self_hosted_);
|
||||
config_center_->SetAutostart(enable_autostart_);
|
||||
config_center_->SetDaemon(enable_daemon_);
|
||||
@@ -3026,6 +3071,8 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
}
|
||||
main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
#endif
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) !=
|
||||
0) {
|
||||
main->set_privacy_on_connect_enabled(
|
||||
@@ -3056,7 +3103,6 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
video_encode_format_button_value_last_ = video_encode_format_button_value_;
|
||||
enable_hardware_video_codec_last_ = enable_hardware_video_codec_;
|
||||
enable_turn_last_ = enable_turn_;
|
||||
enable_srtp_last_ = enable_srtp_;
|
||||
enable_self_hosted_last_ = enable_self_hosted_;
|
||||
enable_autostart_last_ = enable_autostart_;
|
||||
enable_daemon_last_ = enable_daemon_;
|
||||
@@ -3100,6 +3146,7 @@ void GuiApplication::SelectStreamTab(int index) {
|
||||
bool renderer_selection_changed = false;
|
||||
if (!controlled_remote_id_.empty() &&
|
||||
controlled_remote_id_ != selected_remote_id) {
|
||||
devices_.StopKeyboardCapturer();
|
||||
keyboard_.ForceReleasePressedKeys();
|
||||
}
|
||||
focused_remote_id_ = selected_remote_id;
|
||||
@@ -3269,8 +3316,12 @@ void GuiApplication::SendPointerInput(int button, int kind, float x, float y) {
|
||||
|
||||
controlled_remote_id_ = props->remote_id_;
|
||||
const std::string message = action.to_json();
|
||||
SendDataFrame(props->peer_, message.c_str(), message.size(),
|
||||
props->mouse_label_.c_str());
|
||||
{
|
||||
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
||||
props->cursor_presentation_.NoteInput(SDL_GetTicks());
|
||||
}
|
||||
SendReliableDataFrame(props->peer_, message.c_str(), message.size(),
|
||||
props->mouse_label_.c_str());
|
||||
}
|
||||
|
||||
void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x,
|
||||
@@ -3307,10 +3358,14 @@ void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x,
|
||||
if (action.m.s == 0) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
||||
props->cursor_presentation_.NoteInput(SDL_GetTicks());
|
||||
}
|
||||
controlled_remote_id_ = props->remote_id_;
|
||||
const std::string message = action.to_json();
|
||||
SendDataFrame(props->peer_, message.c_str(), message.size(),
|
||||
props->mouse_label_.c_str());
|
||||
SendReliableDataFrame(props->peer_, message.c_str(), message.size(),
|
||||
props->mouse_label_.c_str());
|
||||
}
|
||||
|
||||
void GuiApplication::SendKeyInput(const std::string& text, bool pressed,
|
||||
@@ -3365,7 +3420,7 @@ void GuiApplication::Cleanup() {
|
||||
#if _WIN32 && CROSSDESK_PORTABLE
|
||||
JoinPortableWindowsServiceInstallThread();
|
||||
#endif
|
||||
privacy_.Fail("Application exiting; remote operation paused");
|
||||
privacy_.Disable();
|
||||
clipboard_.Shutdown();
|
||||
keyboard_.ForceReleasePressedKeys();
|
||||
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
|
||||
|
||||
@@ -366,8 +366,8 @@ int GuiApplication::ProcessMouseEvent(const SDL_Event &event) {
|
||||
}
|
||||
if (props->peer_) {
|
||||
std::string msg = remote_action.to_json();
|
||||
SendDataFrame(props->peer_, msg.c_str(), msg.size(),
|
||||
props->mouse_label_.c_str());
|
||||
SendReliableDataFrame(props->peer_, msg.c_str(), msg.size(),
|
||||
props->mouse_label_.c_str());
|
||||
}
|
||||
} else if (SDL_EVENT_MOUSE_WHEEL == event.type &&
|
||||
last_mouse_event.button.x >= render_rect.x &&
|
||||
@@ -412,8 +412,8 @@ int GuiApplication::ProcessMouseEvent(const SDL_Event &event) {
|
||||
}
|
||||
if (props->peer_) {
|
||||
std::string msg = remote_action.to_json();
|
||||
SendDataFrame(props->peer_, msg.c_str(), msg.size(),
|
||||
props->mouse_label_.c_str());
|
||||
SendReliableDataFrame(props->peer_, msg.c_str(), msg.size(),
|
||||
props->mouse_label_.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,6 @@
|
||||
#define ENABLE_HARDWARE_VIDEO_CODEC_CHECKBOX_PADDING_EN 218 * dpi_scale_
|
||||
#define ENABLE_TURN_CHECKBOX_PADDING_CN 171 * dpi_scale_
|
||||
#define ENABLE_TURN_CHECKBOX_PADDING_EN 218 * dpi_scale_
|
||||
#define ENABLE_SRTP_CHECKBOX_PADDING_CN 171 * dpi_scale_
|
||||
#define ENABLE_SRTP_CHECKBOX_PADDING_EN 218 * dpi_scale_
|
||||
#define ENABLE_SELF_HOSTED_SERVER_CHECKBOX_PADDING_CN 171 * dpi_scale_
|
||||
#define ENABLE_SELF_HOSTED_SERVER_CHECKBOX_PADDING_EN 218 * dpi_scale_
|
||||
#define ENABLE_AUTOSTART_PADDING_CN 171 * dpi_scale_
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
#define ENABLE_HARDWARE_VIDEO_CODEC_CHECKBOX_PADDING_EN 218
|
||||
#define ENABLE_TURN_CHECKBOX_PADDING_CN 171
|
||||
#define ENABLE_TURN_CHECKBOX_PADDING_EN 218
|
||||
#define ENABLE_SRTP_CHECKBOX_PADDING_CN 171
|
||||
#define ENABLE_SRTP_CHECKBOX_PADDING_EN 218
|
||||
#define ENABLE_SELF_HOSTED_SERVER_CHECKBOX_PADDING_CN 171
|
||||
#define ENABLE_SELF_HOSTED_SERVER_CHECKBOX_PADDING_EN 218
|
||||
#define ENABLE_AUTOSTART_PADDING_CN 171
|
||||
|
||||
@@ -22,12 +22,10 @@ struct TranslationRow {
|
||||
#define CROSSDESK_LOCALIZATION_ALL(X) \
|
||||
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
|
||||
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
|
||||
X(privacy_disable, u8"关闭隐私屏 / 恢复操作", "Turn privacy off / recover", u8"Выключить / восстановить") \
|
||||
X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \
|
||||
X(privacy_on, u8"隐私屏:已开启", "Privacy screen: on", u8"Приватный экран: включён") \
|
||||
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
|
||||
X(privacy_unsupported, u8"隐私屏:被控端暂不支持", "Privacy screen: unavailable on this host", u8"Приватный экран: недоступен на узле") \
|
||||
X(privacy_timeout, u8"隐私屏状态未知:被控端确认超时,可关闭后重试", "Privacy state unknown: host confirmation timed out; turn off to recover", u8"Статус неизвестен: время ожидания истекло") \
|
||||
X(privacy_paused, u8"隐私保护未就绪,远程操作已暂停。点击隐私屏按钮关闭并恢复。", "Privacy protection is not ready; remote operation paused. Click the privacy screen button to turn it off and recover.", u8"Приватность не готова; нажмите кнопку приватного экрана для выключения и восстановления управления.") \
|
||||
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
|
||||
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
|
||||
X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \
|
||||
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
|
||||
@@ -151,7 +149,6 @@ struct TranslationRow {
|
||||
"Enable Hardware Video Codec:", u8"Использовать аппаратный кодек:") \
|
||||
X(enable_turn, u8"启用中继服务:", \
|
||||
"Enable TURN Service:", u8"Включить TURN-сервис:") \
|
||||
X(enable_srtp, u8"启用SRTP:", "Enable SRTP:", u8"Включить SRTP:") \
|
||||
X(self_hosted_server_config, u8"自托管配置", "Self-Hosted Config", \
|
||||
u8"Конфигурация self-hosted") \
|
||||
X(self_hosted_server_settings, u8"自托管设置", "Self-Hosted Settings", \
|
||||
@@ -236,6 +233,9 @@ struct TranslationRow {
|
||||
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \
|
||||
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
|
||||
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \
|
||||
X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \
|
||||
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
|
||||
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
|
||||
X(online, u8"在线", "Online", u8"Онлайн") \
|
||||
X(offline, u8"离线", "Offline", u8"Офлайн") \
|
||||
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство офлайн") \
|
||||
@@ -250,8 +250,6 @@ struct TranslationRow {
|
||||
u8"Для работы приложения требуются следующие разрешения:") \
|
||||
X(show_main_window, u8"显示主界面", "Show Main Window", \
|
||||
u8"Показать главное окно") \
|
||||
X(privacy_verification, u8"隐私屏验证", "Privacy screen verification", \
|
||||
u8"Проверка приватности") \
|
||||
X(privacy_screen_unlock_hint, u8"按下快捷键,解除隐私屏", \
|
||||
"To turn off the privacy screen, press", \
|
||||
u8"Чтобы отключить приватный экран, нажмите") \
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#include "features/devices/session_device_manager.h"
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "platform.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
@@ -32,7 +33,7 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
||||
SpeakerCapturerFactory().Create());
|
||||
},
|
||||
[this](unsigned char *data, size_t size, const char *) {
|
||||
if (!owner_.peer_ || !owner_.privacy_.RemoteAllowed()) return;
|
||||
if (!owner_.peer_) return;
|
||||
MiniRtcAudioFrame frame{};
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
@@ -59,6 +60,53 @@ bool SessionDeviceManager::ShouldSendCapturedFrame(
|
||||
return true;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::RecordCaptureCadence(
|
||||
std::chrono::steady_clock::time_point now, int fps,
|
||||
bool from_secure_desktop) {
|
||||
std::lock_guard<std::mutex> lock(capture_metrics_mutex_);
|
||||
if (capture_metrics_started_ == std::chrono::steady_clock::time_point{})
|
||||
capture_metrics_started_ = now;
|
||||
const auto elapsed_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
now - capture_metrics_started_)
|
||||
.count();
|
||||
if (elapsed_us >= 2000000) {
|
||||
if (capture_forwarded_) {
|
||||
const char* source = capture_secure_callbacks_ == 0 ? "normal_desktop"
|
||||
: capture_secure_callbacks_ == capture_callbacks_
|
||||
? "secure_desktop"
|
||||
: "mixed";
|
||||
LOG_INFO(
|
||||
"Capture delivery: source={} normal_callbacks={} secure_callbacks={} "
|
||||
"target_fps={} input_fps={:.2f} "
|
||||
"forwarded_fps={:.2f} callbacks={} rate_dropped={} "
|
||||
"forwarded={} send_errors={} max_callback_gap_us={} "
|
||||
"send_avg_us={} send_max_us={} window_ms={}",
|
||||
source, capture_callbacks_ - capture_secure_callbacks_,
|
||||
capture_secure_callbacks_, fps,
|
||||
capture_callbacks_ * 1000000.0 / elapsed_us,
|
||||
capture_forwarded_ * 1000000.0 / elapsed_us, capture_callbacks_,
|
||||
capture_rate_drops_, capture_forwarded_, capture_send_errors_,
|
||||
capture_max_gap_us_, capture_send_us_ / capture_forwarded_,
|
||||
capture_max_send_us_, elapsed_us / 1000);
|
||||
}
|
||||
capture_metrics_started_ = now;
|
||||
capture_callbacks_ = capture_rate_drops_ = capture_forwarded_ = 0;
|
||||
capture_secure_callbacks_ = 0;
|
||||
capture_send_errors_ = 0;
|
||||
capture_max_gap_us_ = capture_send_us_ = capture_max_send_us_ = 0;
|
||||
}
|
||||
if (last_capture_callback_ != std::chrono::steady_clock::time_point{}) {
|
||||
capture_max_gap_us_ =
|
||||
std::max<int64_t>(capture_max_gap_us_,
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
now - last_capture_callback_)
|
||||
.count());
|
||||
}
|
||||
last_capture_callback_ = std::max(last_capture_callback_, now);
|
||||
++capture_callbacks_;
|
||||
if (from_secure_desktop) ++capture_secure_callbacks_;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::Initialize() {
|
||||
InitializeAudioOutput();
|
||||
screen_capturer_factory_ = new ScreenCapturerFactory();
|
||||
@@ -79,15 +127,20 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
screen_capturer_ =
|
||||
static_cast<ScreenCapturer *>(screen_capturer_factory_->Create());
|
||||
}
|
||||
screen_capturer_->SetPrivacyController(&owner_.privacy_);
|
||||
#ifdef _WIN32
|
||||
if (auto windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_)) {
|
||||
windows->SetPrivacyController(&owner_.privacy_);
|
||||
windows->SetCaptureMethod(owner_.config_center_->GetScreenCaptureMethod());
|
||||
}
|
||||
#endif
|
||||
|
||||
last_frame_time_ = {};
|
||||
next_frame_deadline_ = {};
|
||||
capture_metrics_started_ = last_capture_callback_ = {};
|
||||
capture_callbacks_ = capture_rate_drops_ = capture_forwarded_ = 0;
|
||||
capture_secure_callbacks_ = 0;
|
||||
capture_send_errors_ = 0;
|
||||
capture_max_gap_us_ = capture_send_us_ = capture_max_send_us_ = 0;
|
||||
const int fps = owner_.config_center_->GetVideoFrameRate() ==
|
||||
ConfigCenter::VIDEO_FRAME_RATE::FPS_30
|
||||
? 30
|
||||
@@ -102,9 +155,16 @@ 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();
|
||||
bool from_secure_desktop = false;
|
||||
#ifdef _WIN32
|
||||
from_secure_desktop =
|
||||
ScreenCapturerWin::CurrentFrameIsFromSecureDesktop();
|
||||
#endif
|
||||
RecordCaptureCadence(now_time, fps, from_secure_desktop);
|
||||
if (!ShouldSendCapturedFrame(now_time, fps)) {
|
||||
std::lock_guard<std::mutex> lock(capture_metrics_mutex_);
|
||||
++capture_rate_drops_;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,18 +225,30 @@ 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;
|
||||
frame.height = height;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
frame.native_frame = native_frame;
|
||||
const auto send_started = std::chrono::steady_clock::now();
|
||||
uint64_t send_errors = 0;
|
||||
for (const std::string &remote_id : connected_remote_ids) {
|
||||
SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
|
||||
remote_id.data(), remote_id.size());
|
||||
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
|
||||
remote_id.data(), remote_id.size()) != 0)
|
||||
++send_errors;
|
||||
}
|
||||
const auto send_us =
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - send_started)
|
||||
.count();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(capture_metrics_mutex_);
|
||||
++capture_forwarded_;
|
||||
capture_send_errors_ += send_errors;
|
||||
capture_send_us_ += send_us;
|
||||
capture_max_send_us_ =
|
||||
std::max<int64_t>(capture_max_send_us_, send_us);
|
||||
}
|
||||
last_video_frame_stream_id_ = stream_id;
|
||||
last_frame_time_ = now_time;
|
||||
@@ -199,6 +271,15 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::ShouldCaptureCursor() const {
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
return std::any_of(owner_.connection_status_.begin(),
|
||||
owner_.connection_status_.end(), [](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connected &&
|
||||
entry.first.find("web") != std::string::npos;
|
||||
});
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartScreenCapturer() {
|
||||
#ifdef __APPLE__
|
||||
if (!owner_.EnsureMacScreenRecordingPermission()) {
|
||||
@@ -214,8 +295,11 @@ int SessionDeviceManager::StartScreenCapturer() {
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INFO("Start screen capturer, show cursor: {}", owner_.show_cursor_);
|
||||
const int ret = screen_capturer_->Start(owner_.show_cursor_);
|
||||
// Clear before sampling, so a change arriving after the sample remains pending.
|
||||
cursor_capture_dirty_.store(false, std::memory_order_relaxed);
|
||||
const bool show_cursor = ShouldCaptureCursor();
|
||||
LOG_INFO("Start screen capturer, show cursor: {}", show_cursor);
|
||||
const int ret = screen_capturer_->Start(show_cursor);
|
||||
if (ret != 0) {
|
||||
LOG_ERROR("Start screen capturer failed: {}", ret);
|
||||
}
|
||||
@@ -305,6 +389,7 @@ int SessionDeviceManager::StopMouseController() {
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartKeyboardCapturer() {
|
||||
ClearCapturedKeyboardInput();
|
||||
owner_.keyboard_capturer_uses_window_events_ = false;
|
||||
|
||||
#ifdef __APPLE__
|
||||
@@ -340,6 +425,7 @@ int SessionDeviceManager::StartKeyboardCapturer() {
|
||||
},
|
||||
this);
|
||||
if (hook_ret != 0) {
|
||||
keyboard_capturer_->Unhook();
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
LOG_WARN(
|
||||
"Start keyboard capturer failed, falling back to Slint keyboard events");
|
||||
@@ -350,6 +436,9 @@ int SessionDeviceManager::StartKeyboardCapturer() {
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StopKeyboardCapturer() {
|
||||
owner_.keyboard_capturer_is_started_ = false;
|
||||
if (keyboard_capturer_) keyboard_capturer_->Unhook();
|
||||
ClearCapturedKeyboardInput();
|
||||
if (owner_.keyboard_capturer_uses_window_events_) {
|
||||
owner_.keyboard_capturer_uses_window_events_ = false;
|
||||
LOG_INFO("Stop keyboard capturer with Slint keyboard backend");
|
||||
@@ -357,8 +446,6 @@ int SessionDeviceManager::StopKeyboardCapturer() {
|
||||
}
|
||||
|
||||
if (keyboard_capturer_) {
|
||||
keyboard_capturer_->Unhook();
|
||||
ClearCapturedKeyboardInput();
|
||||
LOG_INFO("Stop keyboard capturer");
|
||||
}
|
||||
return 0;
|
||||
@@ -428,6 +515,15 @@ void SessionDeviceManager::PushAudio(const char *data, size_t size) {
|
||||
}
|
||||
|
||||
void SessionDeviceManager::UpdateInteractions() {
|
||||
#ifdef _WIN32
|
||||
if (owner_.screen_capturer_is_started_ &&
|
||||
cursor_capture_dirty_.load(std::memory_order_relaxed)) {
|
||||
if (auto* windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_)) {
|
||||
cursor_capture_dirty_.store(false, std::memory_order_relaxed);
|
||||
windows->SetCursorCapture(ShouldCaptureCursor());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
const bool is_wayland_session = IsWaylandSession();
|
||||
const bool stop_wayland_mouse_before_screen =
|
||||
@@ -478,6 +574,14 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
owner_.keyboard_capturer_is_started_ = true;
|
||||
}
|
||||
if (owner_.keyboard_capturer_is_started_) {
|
||||
if (!owner_.keyboard_capturer_uses_window_events_ &&
|
||||
(!keyboard_capturer_ || !keyboard_capturer_->IsHookActive())) {
|
||||
StopKeyboardCapturer();
|
||||
owner_.keyboard_.ForceReleasePressedKeys();
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
owner_.keyboard_capturer_is_started_ = true;
|
||||
LOG_WARN("Native keyboard capture stopped; using Slint keyboard events");
|
||||
}
|
||||
DrainCapturedKeyboardInput();
|
||||
owner_.keyboard_.SendHeartbeat(false);
|
||||
}
|
||||
@@ -530,7 +634,6 @@ bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
|
||||
|
||||
void SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
|
||||
int selected_display) {
|
||||
if (!owner_.privacy_.RemoteAllowed()) return;
|
||||
if (mouse_controller_) {
|
||||
mouse_controller_->SendMouseCommand(action, selected_display);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -40,6 +41,11 @@ public:
|
||||
|
||||
void Initialize();
|
||||
void UpdateInteractions();
|
||||
// Call after changing the controller map. UpdateInteractions consumes this
|
||||
// notification on the UI thread before reading the map under its mutex.
|
||||
void OnControllerConnectionsChanged() {
|
||||
cursor_capture_dirty_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
void DestroyDevices();
|
||||
void DestroyFactories();
|
||||
|
||||
@@ -84,11 +90,15 @@ private:
|
||||
void ClearCapturedKeyboardInput();
|
||||
bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now,
|
||||
int fps);
|
||||
bool ShouldCaptureCursor() const;
|
||||
void RecordCaptureCadence(std::chrono::steady_clock::time_point now, int fps,
|
||||
bool from_secure_desktop);
|
||||
|
||||
GuiRuntime &owner_;
|
||||
SDL_AudioStream *output_stream_ = nullptr;
|
||||
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
|
||||
ScreenCapturer *screen_capturer_ = nullptr;
|
||||
std::atomic<bool> cursor_capture_dirty_{true};
|
||||
SpeakerCaptureController speaker_capture_;
|
||||
DeviceControllerFactory *device_controller_factory_ = nullptr;
|
||||
MouseController *mouse_controller_ = nullptr;
|
||||
@@ -99,6 +109,14 @@ private:
|
||||
std::mutex captured_keyboard_inputs_mutex_;
|
||||
std::chrono::steady_clock::time_point last_frame_time_{};
|
||||
std::chrono::steady_clock::time_point next_frame_deadline_{};
|
||||
std::mutex capture_metrics_mutex_;
|
||||
std::chrono::steady_clock::time_point capture_metrics_started_{};
|
||||
std::chrono::steady_clock::time_point last_capture_callback_{};
|
||||
uint64_t capture_callbacks_ = 0, capture_rate_drops_ = 0;
|
||||
uint64_t capture_secure_callbacks_ = 0;
|
||||
uint64_t capture_forwarded_ = 0, capture_send_errors_ = 0;
|
||||
int64_t capture_max_gap_us_ = 0;
|
||||
int64_t capture_send_us_ = 0, capture_max_send_us_ = 0;
|
||||
std::string last_video_frame_stream_id_;
|
||||
bool invalid_video_stream_id_logged_ = false;
|
||||
};
|
||||
|
||||
@@ -212,13 +212,11 @@ int KeyboardController::SendKeyCommand(int key_code, bool is_down,
|
||||
|
||||
bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
|
||||
uint32_t scan_code, bool extended) {
|
||||
if (is_down && !owner_.privacy_.RemoteAllowed()) return false;
|
||||
#if _WIN32
|
||||
if (owner_.privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
|
||||
owner_.privacy_.Fail("Secure desktop or locked session; remote key injection paused");
|
||||
return false;
|
||||
owner_.privacy_.Fail("Secure desktop or locked session; privacy screen will turn off");
|
||||
}
|
||||
if (!owner_.privacy_.Engaged() && owner_.local_service_status_received_ &&
|
||||
if (owner_.local_service_status_received_ &&
|
||||
IsSecureDesktopInteractionRequired(owner_.local_interactive_stage_)) {
|
||||
const std::string response = SendCrossDeskSecureDesktopKeyInput(
|
||||
key_code, is_down, scan_code, extended, 1000);
|
||||
@@ -252,7 +250,6 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
|
||||
}
|
||||
#endif
|
||||
// Recheck after the desktop query; key-up cleanup remains allowed while paused.
|
||||
if (is_down && !owner_.privacy_.RemoteAllowed()) return false;
|
||||
return owner_.devices_.SendKeyboardCommand(key_code, is_down, scan_code,
|
||||
extended);
|
||||
}
|
||||
|
||||
@@ -308,7 +308,6 @@ int SettingsManager::Load() {
|
||||
owner_.enable_hardware_video_codec_ =
|
||||
owner_.config_center_->IsHardwareVideoCodec();
|
||||
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
|
||||
owner_.enable_srtp_ = owner_.config_center_->IsEnableSrtp();
|
||||
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
|
||||
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
|
||||
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
|
||||
@@ -335,7 +334,6 @@ int SettingsManager::Load() {
|
||||
owner_.enable_hardware_video_codec_last_ =
|
||||
owner_.enable_hardware_video_codec_;
|
||||
owner_.enable_turn_last_ = owner_.enable_turn_;
|
||||
owner_.enable_srtp_last_ = owner_.enable_srtp_;
|
||||
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
|
||||
owner_.enable_autostart_last_ = owner_.enable_autostart_;
|
||||
|
||||
|
||||
@@ -140,25 +140,23 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
||||
}
|
||||
|
||||
bool has_connected_controller = false;
|
||||
bool has_web_controller = false;
|
||||
std::string remaining_controller_id;
|
||||
{
|
||||
std::unique_lock lock(connection_status_mutex_);
|
||||
connection_status_.erase(remote_id);
|
||||
devices_.OnControllerConnectionsChanged();
|
||||
connection_host_names_.erase(remote_id);
|
||||
privacy_sessions_.Disconnected(remote_id);
|
||||
for (const auto& [id, status] : connection_status_) {
|
||||
if (status != ConnectionStatus::Connected) {
|
||||
continue;
|
||||
}
|
||||
has_connected_controller = true;
|
||||
has_web_controller =
|
||||
has_web_controller || id.find("web") != std::string::npos;
|
||||
if (remaining_controller_id.empty()) {
|
||||
remaining_controller_id = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
show_cursor_ = has_web_controller;
|
||||
if (has_connected_controller) {
|
||||
remote_client_id_ = remaining_controller_id;
|
||||
return;
|
||||
@@ -437,6 +435,7 @@ void GuiRuntime::ResetRemoteSessionResources(
|
||||
std::lock_guard<std::mutex> lock(props->remote_cursor_state_mutex_);
|
||||
props->remote_cursor_state_ = {};
|
||||
props->remote_cursor_state_received_ = false;
|
||||
props->cursor_presentation_ = {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +518,7 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
}
|
||||
AddAudioStream(props->peer_, props->audio_label_.c_str());
|
||||
AddDataStream(props->peer_, props->data_label_.c_str(), false);
|
||||
AddDataStream(props->peer_, props->mouse_label_.c_str(), false);
|
||||
AddDataStream(props->peer_, props->mouse_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->keyboard_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->control_data_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->file_label_.c_str(), true);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-16
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CURSOR_PRESENTATION_H_
|
||||
#define _CURSOR_PRESENTATION_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// All times use one monotonic millisecond clock. The owning session's cursor
|
||||
// mutex protects this policy from the transport and UI threads.
|
||||
class CursorPresentation {
|
||||
public:
|
||||
static constexpr uint64_t kStateTimeoutMs = 1500; // Three 500ms heartbeats.
|
||||
static constexpr uint64_t kInputActivityMs = 1500;
|
||||
|
||||
void Observe(const CursorState& state, uint64_t now) {
|
||||
if (state.render_mode == CursorRenderMode::unknown) {
|
||||
// Repeated unavailable samples must not extend the recovery deadline.
|
||||
if (!sample_unavailable_) stale_at_ = now + kStateTimeoutMs;
|
||||
sample_unavailable_ = true;
|
||||
return;
|
||||
}
|
||||
sample_unavailable_ = false;
|
||||
stale_at_ = now + kStateTimeoutMs;
|
||||
const bool separate =
|
||||
state.render_mode == CursorRenderMode::separate ||
|
||||
(state.render_mode == CursorRenderMode::legacy && state.visible);
|
||||
// Old senders cannot distinguish embedded from hidden. Only explicit
|
||||
// metadata from a new sender can establish the no-device compatibility
|
||||
// case.
|
||||
no_device_ = state.render_mode == CursorRenderMode::hidden &&
|
||||
state.hidden_reason == CursorHiddenReason::no_pointing_device;
|
||||
held_shape_ = separate ? state.shape : RemoteCursorShape::none;
|
||||
if (held_shape_ != RemoteCursorShape::none) {
|
||||
last_visible_shape_ = held_shape_;
|
||||
}
|
||||
if (!no_device_) fallback_engaged_ = false;
|
||||
}
|
||||
|
||||
void NoteInput(uint64_t now) { last_input_ = now; }
|
||||
|
||||
RemoteCursorShape Resolve(uint64_t now) {
|
||||
if (!stale_at_) return RemoteCursorShape::default_cursor;
|
||||
|
||||
const bool recent_input =
|
||||
last_input_ && now - *last_input_ < kInputActivityMs;
|
||||
const bool can_recover =
|
||||
now >= *stale_at_ || (!sample_unavailable_ && no_device_);
|
||||
if (can_recover && recent_input) {
|
||||
fallback_engaged_ = true;
|
||||
}
|
||||
// Once needed, retain the locator while idle until a good non-compatibility
|
||||
// sample arrives. Otherwise it would blink off whenever input pauses.
|
||||
return fallback_engaged_ ? last_visible_shape_ : held_shape_;
|
||||
}
|
||||
|
||||
private:
|
||||
bool sample_unavailable_ = false;
|
||||
bool no_device_ = false;
|
||||
bool fallback_engaged_ = false;
|
||||
std::optional<uint64_t> stale_at_;
|
||||
std::optional<uint64_t> last_input_;
|
||||
RemoteCursorShape held_shape_ = RemoteCursorShape::default_cursor;
|
||||
RemoteCursorShape last_visible_shape_ = RemoteCursorShape::default_cursor;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -6,39 +6,17 @@
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "platform/windows/input/windows_cursor_state.h"
|
||||
#include "platform/windows/screen_capturer/captured_cursor_state.h"
|
||||
#include "platform/windows/screen_capturer/dxgi_cursor_state.h"
|
||||
#include "platform/windows/screen_capturer/secure_desktop_cursor_state.h"
|
||||
#include "runtime/cursor_position.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
bool IsSystemCursor(HCURSOR cursor, LPCWSTR resource) {
|
||||
return cursor != nullptr && cursor == LoadCursorW(nullptr, resource);
|
||||
}
|
||||
|
||||
RemoteCursorShape ShapeFromWindowsCursor(HCURSOR cursor) {
|
||||
if (IsSystemCursor(cursor, IDC_HELP)) return RemoteCursorShape::help;
|
||||
if (IsSystemCursor(cursor, IDC_HAND)) return RemoteCursorShape::pointer;
|
||||
if (IsSystemCursor(cursor, IDC_APPSTARTING))
|
||||
return RemoteCursorShape::progress;
|
||||
if (IsSystemCursor(cursor, IDC_WAIT)) return RemoteCursorShape::wait;
|
||||
if (IsSystemCursor(cursor, IDC_CROSS)) return RemoteCursorShape::crosshair;
|
||||
if (IsSystemCursor(cursor, IDC_IBEAM)) return RemoteCursorShape::text;
|
||||
if (IsSystemCursor(cursor, IDC_NO))
|
||||
return RemoteCursorShape::not_allowed;
|
||||
if (IsSystemCursor(cursor, IDC_SIZEALL)) return RemoteCursorShape::move;
|
||||
if (IsSystemCursor(cursor, IDC_SIZEWE)) return RemoteCursorShape::ew_resize;
|
||||
if (IsSystemCursor(cursor, IDC_SIZENS)) return RemoteCursorShape::ns_resize;
|
||||
if (IsSystemCursor(cursor, IDC_SIZENESW))
|
||||
return RemoteCursorShape::nesw_resize;
|
||||
if (IsSystemCursor(cursor, IDC_SIZENWSE))
|
||||
return RemoteCursorShape::nwse_resize;
|
||||
if (IsSystemCursor(cursor, IDC_UPARROW)) return RemoteCursorShape::n_resize;
|
||||
return RemoteCursorShape::default_cursor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct CursorStateProvider::Impl {};
|
||||
struct CursorStateProvider::Impl {
|
||||
WindowsCursorState cursor;
|
||||
};
|
||||
|
||||
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
|
||||
CursorStateProvider::~CursorStateProvider() = default;
|
||||
@@ -47,16 +25,39 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
|
||||
int preferred_display, CursorState* state) {
|
||||
if (!state) return false;
|
||||
|
||||
SecureDesktopCursorSnapshot secure_cursor{};
|
||||
if (SharedSecureDesktopCursorState().Get(&secure_cursor)) {
|
||||
if (!secure_cursor.valid) {
|
||||
state->hidden_reason = CursorHiddenReason::secure_desktop_pending;
|
||||
return false;
|
||||
}
|
||||
state->seq = 0;
|
||||
NormalizeCursorPosition(secure_cursor.x, secure_cursor.y, displays,
|
||||
preferred_display, state);
|
||||
state->visible = secure_cursor.visible != 0;
|
||||
state->render_mode = static_cast<CursorRenderMode>(secure_cursor.render_mode);
|
||||
state->hidden_reason =
|
||||
static_cast<CursorHiddenReason>(secure_cursor.hidden_reason);
|
||||
state->shape = state->visible
|
||||
? static_cast<RemoteCursorShape>(secure_cursor.shape)
|
||||
: RemoteCursorShape::none;
|
||||
return true;
|
||||
}
|
||||
|
||||
CURSORINFO info{};
|
||||
info.cbSize = sizeof(info);
|
||||
if (!GetCursorInfo(&info)) return false;
|
||||
|
||||
state->seq = 0;
|
||||
state->visible = (info.flags & CURSOR_SHOWING) != 0;
|
||||
state->shape = state->visible ? ShapeFromWindowsCursor(info.hCursor)
|
||||
: RemoteCursorShape::none;
|
||||
NormalizeCursorPosition(info.ptScreenPos.x, info.ptScreenPos.y, displays,
|
||||
preferred_display, state);
|
||||
void* cursor_monitor = state->position_valid
|
||||
? displays[state->display_id].handle
|
||||
: nullptr;
|
||||
const bool embedded = SharedCapturedCursorState().IsEmbedded(cursor_monitor) ||
|
||||
((info.flags & CURSOR_SHOWING) != 0 &&
|
||||
!SharedDxgiCursorState().ShouldDrawCursor(true, cursor_monitor));
|
||||
impl_->cursor.Sample(info, embedded, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
|
||||
AddAudioStream(peer_, audio_label_.c_str());
|
||||
AddDataStream(peer_, data_label_.c_str(), false);
|
||||
AddDataStream(peer_, mouse_label_.c_str(), false);
|
||||
AddDataStream(peer_, mouse_label_.c_str(), true);
|
||||
AddDataStream(peer_, keyboard_label_.c_str(), true);
|
||||
AddDataStream(peer_, control_data_label_.c_str(), true);
|
||||
AddDataStream(peer_, file_label_.c_str(), true);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "privacy_controller.h"
|
||||
#include "runtime/gui_state.h"
|
||||
#include "runtime/peer_event_handler.h"
|
||||
#include "runtime/privacy_session_policy.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -63,6 +64,7 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
void HandleWindowsServiceIntegration();
|
||||
void HandlePrivacy();
|
||||
bool IsAuthorizedController(const std::string& remote_id);
|
||||
void SetControllerPrivacySupport(const std::string& remote_id, bool supported);
|
||||
void QueuePrivacyCommand(const std::string& remote_id, const PrivacyCommand& command);
|
||||
|
||||
void CloseRemoteSession(std::shared_ptr<RemoteSession> props);
|
||||
@@ -94,11 +96,11 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
#endif
|
||||
|
||||
PrivacyController privacy_;
|
||||
PrivacySessionPolicy privacy_sessions_;
|
||||
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,8 +88,6 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
|
||||
std::string source_id = std::string(src_id, src_id_size);
|
||||
if (!runtime->privacy_.RemoteAllowed() &&
|
||||
(source_id == runtime->clipboard_label_ || source_id == runtime->file_label_)) return;
|
||||
if (source_id == runtime->file_label_) {
|
||||
std::string remote_user_id = std::string(user_id, user_id_size);
|
||||
|
||||
@@ -185,11 +183,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
remote_action.type == ControlType::keyboard_state;
|
||||
if (!keyboard_input && runtime->privacy_.Engaged() &&
|
||||
!IsWindowsPrivacyDesktopAvailable()) {
|
||||
runtime->privacy_.Fail("Secure desktop or lock screen; privacy cannot cover system security UI. Remote input paused.");
|
||||
return;
|
||||
runtime->privacy_.Fail("Secure desktop or lock screen; privacy screen will turn off");
|
||||
}
|
||||
#endif
|
||||
if (!runtime->privacy_.RemoteAllowed()) return;
|
||||
}
|
||||
if (remote_action.type == ControlType::service_status) {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id)) {
|
||||
@@ -200,8 +196,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
|
||||
if (remote_action.type == ControlType::service_command) {
|
||||
if (runtime->privacy_.Engaged()) {
|
||||
runtime->privacy_.Fail("Security shortcuts require leaving privacy mode; remote operation paused. Turn privacy off first.");
|
||||
return;
|
||||
runtime->privacy_.Fail("Security shortcut requested; privacy screen will turn off");
|
||||
}
|
||||
#if _WIN32
|
||||
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
|
||||
@@ -239,7 +234,11 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
const bool changed =
|
||||
!props->remote_cursor_state_received_ ||
|
||||
props->remote_cursor_state_.visible != remote_action.cs.visible ||
|
||||
props->remote_cursor_state_.shape != remote_action.cs.shape;
|
||||
props->remote_cursor_state_.shape != remote_action.cs.shape ||
|
||||
props->remote_cursor_state_.render_mode !=
|
||||
remote_action.cs.render_mode ||
|
||||
props->remote_cursor_state_.hidden_reason !=
|
||||
remote_action.cs.hidden_reason;
|
||||
CursorState merged = remote_action.cs;
|
||||
if (!remote_action.cs.position_update &&
|
||||
props->remote_cursor_state_received_) {
|
||||
@@ -250,10 +249,14 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
props->remote_cursor_state_ = merged;
|
||||
props->remote_cursor_state_received_ = true;
|
||||
props->cursor_presentation_.Observe(merged, SDL_GetTicks());
|
||||
if (changed) {
|
||||
LOG_INFO("Received cursor state: seq={}, visible={}, shape={}",
|
||||
LOG_INFO("Received cursor state: seq={}, visible={}, shape={}, "
|
||||
"mode={}, reason={}",
|
||||
remote_action.cs.seq, remote_action.cs.visible,
|
||||
static_cast<int>(remote_action.cs.shape));
|
||||
static_cast<int>(remote_action.cs.shape),
|
||||
static_cast<int>(remote_action.cs.render_mode),
|
||||
static_cast<int>(remote_action.cs.hidden_reason));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -295,6 +298,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
runtime->connection_host_names_[remote_id] = host_name;
|
||||
}
|
||||
LOG_INFO("Remote hostname: [{}]", host_name);
|
||||
if (source_id == runtime->control_data_label_) {
|
||||
runtime->SetControllerPrivacySupport(
|
||||
remote_id, remote_action.i.supports_privacy_screen);
|
||||
}
|
||||
remote_action_codec::Free(remote_action);
|
||||
}
|
||||
} else {
|
||||
@@ -306,7 +313,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
std::chrono::steady_clock::now();
|
||||
}
|
||||
#if _WIN32
|
||||
if (!runtime->privacy_.Engaged() && runtime->local_service_status_received_ &&
|
||||
if (runtime->local_service_status_received_ &&
|
||||
IsSecureDesktopInteractionRequired(runtime->local_interactive_stage_) &&
|
||||
remote_action.type != ControlType::keyboard &&
|
||||
remote_action.type != ControlType::keyboard_state) {
|
||||
|
||||
@@ -262,9 +262,11 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
||||
props->remote_cursor_state_ = {};
|
||||
props->remote_cursor_state_received_ = false;
|
||||
props->cursor_presentation_ = {};
|
||||
}
|
||||
{
|
||||
RemoteAction remote_action;
|
||||
RemoteAction remote_action{};
|
||||
remote_action.i.supports_privacy_screen = true;
|
||||
remote_action.i.display_num =
|
||||
runtime->devices_.display_info_list().size();
|
||||
remote_action.i.display_list =
|
||||
@@ -330,6 +332,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
std::lock_guard lock(props->remote_cursor_state_mutex_);
|
||||
props->remote_cursor_state_ = {};
|
||||
props->remote_cursor_state_received_ = false;
|
||||
props->cursor_presentation_ = {};
|
||||
}
|
||||
|
||||
std::shared_ptr<std::vector<unsigned char>> native_snapshot;
|
||||
@@ -405,21 +408,18 @@ 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();
|
||||
if (status == ConnectionStatus::Connected) {
|
||||
// Wait for explicit desktop capability before enabling privacy or
|
||||
// sending privacy messages. Legacy and web controllers never opt in.
|
||||
runtime->privacy_sessions_.Connected(
|
||||
remote_id, runtime->config_center_->IsEnablePrivacyScreen());
|
||||
if (!runtime->privacy_sessions_.CanEnable() &&
|
||||
runtime->privacy_.Engaged()) {
|
||||
runtime->privacy_.Disable();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
runtime->connection_status_[remote_id] = status;
|
||||
runtime->devices_.OnControllerConnectionsChanged();
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
@@ -428,7 +428,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
runtime->last_windows_service_status_tick_ = 0;
|
||||
#endif
|
||||
{
|
||||
RemoteAction remote_action;
|
||||
RemoteAction remote_action{};
|
||||
remote_action.i.supports_privacy_screen = true;
|
||||
remote_action.i.display_num =
|
||||
runtime->devices_.display_info_list().size();
|
||||
remote_action.i.display_list =
|
||||
@@ -485,16 +486,6 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
runtime->devices_.StartSpeakerCapturer();
|
||||
runtime->remote_client_id_ = remote_id;
|
||||
runtime->start_mouse_controller_ = true;
|
||||
{
|
||||
std::shared_lock lock(runtime->connection_status_mutex_);
|
||||
if (std::all_of(runtime->connection_status_.begin(),
|
||||
runtime->connection_status_.end(),
|
||||
[](const auto& kv) {
|
||||
return kv.first.find("web") != std::string::npos;
|
||||
})) {
|
||||
runtime->show_cursor_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -18,38 +18,33 @@ struct PrivacyControlState {
|
||||
bool enabled = false;
|
||||
bool off = false;
|
||||
bool unsupported = false;
|
||||
bool warning = false;
|
||||
bool stale = false;
|
||||
bool timeout = false;
|
||||
bool can_toggle = false;
|
||||
};
|
||||
|
||||
// Use the same host-confirmed state for presentation and command admission.
|
||||
// An engaged or failed screen can still be disabled when its status goes stale.
|
||||
// Missing confirmation or a failure makes only this toolbar control unavailable.
|
||||
inline PrivacyControlState GetPrivacyControlState(
|
||||
const PrivacyStatus& status, bool received, bool command_pending,
|
||||
uint64_t status_age_ms, uint64_t command_age_ms) {
|
||||
PrivacyControlState result;
|
||||
result.timeout = command_pending && command_age_ms > 15000;
|
||||
result.stale = received && status_age_ms > 5000;
|
||||
const bool request_pending = command_pending && !result.timeout;
|
||||
const bool fresh = received && !result.stale;
|
||||
const bool timeout = command_pending && command_age_ms > 15000;
|
||||
result.unsupported = timeout ||
|
||||
(received && (status_age_ms > 5000 || !status.supported ||
|
||||
status.state == PrivacyState::failed));
|
||||
if (result.unsupported) return result;
|
||||
|
||||
result.active = status.overlay_active || status.remote_paused ||
|
||||
status.state == PrivacyState::on ||
|
||||
status.state == PrivacyState::starting ||
|
||||
status.state == PrivacyState::stopping ||
|
||||
status.state == PrivacyState::failed;
|
||||
// A command in flight only gates repeated clicks; it adds no display state.
|
||||
result.enabled = fresh && !result.timeout &&
|
||||
result.enabled = received &&
|
||||
status.state == PrivacyState::on && status.overlay_active &&
|
||||
!status.remote_paused;
|
||||
result.off = fresh && !result.timeout &&
|
||||
result.off = received &&
|
||||
status.state == PrivacyState::off && !result.active;
|
||||
result.unsupported = received && !status.supported;
|
||||
result.warning = result.timeout || result.stale ||
|
||||
(received && status.state == PrivacyState::failed);
|
||||
result.can_toggle = !request_pending &&
|
||||
(result.active || (fresh && status.supported));
|
||||
result.can_toggle = !command_pending && (result.active || received);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <cstring>
|
||||
#include <shared_mutex>
|
||||
|
||||
#include "rd_log.h"
|
||||
@@ -20,9 +21,29 @@ bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
|
||||
found->second == ConnectionStatus::Connected;
|
||||
}
|
||||
|
||||
void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id,
|
||||
bool supported) {
|
||||
std::unique_lock lock(connection_status_mutex_);
|
||||
const auto found = connection_status_.find(remote_id);
|
||||
if (found == connection_status_.end() ||
|
||||
found->second != ConnectionStatus::Connected) return;
|
||||
|
||||
const bool automatic_enable =
|
||||
privacy_sessions_.SetSupported(remote_id, supported);
|
||||
if (!privacy_sessions_.CanEnable()) {
|
||||
if (privacy_.Engaged()) privacy_.Disable();
|
||||
} else if (automatic_enable && config_center_->IsEnablePrivacyScreen()) {
|
||||
privacy_.EnableOnConnection();
|
||||
}
|
||||
}
|
||||
|
||||
void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id,
|
||||
const PrivacyCommand& command) {
|
||||
if (!IsAuthorizedController(remote_id)) return;
|
||||
std::shared_lock session_lock(connection_status_mutex_);
|
||||
const auto found = connection_status_.find(remote_id);
|
||||
if (found == connection_status_.end() ||
|
||||
found->second != ConnectionStatus::Connected ||
|
||||
!privacy_sessions_.Supports(remote_id)) return;
|
||||
std::lock_guard lock(privacy_commands_mutex_);
|
||||
if (privacy_commands_.size() < 32)
|
||||
privacy_commands_.emplace_back(remote_id, command);
|
||||
@@ -35,29 +56,22 @@ void GuiRuntime::HandlePrivacy() {
|
||||
commands.swap(privacy_commands_);
|
||||
}
|
||||
for (const auto& [remote_id, command] : commands) {
|
||||
if (!IsAuthorizedController(remote_id)) continue;
|
||||
// Keep admission and activation under the connection lock so an unknown
|
||||
// controller cannot join between the capability check and enabling.
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
const auto found = connection_status_.find(remote_id);
|
||||
if (found == connection_status_.end() ||
|
||||
found->second != ConnectionStatus::Connected ||
|
||||
!privacy_sessions_.Supports(remote_id)) continue;
|
||||
if (command.flag == PrivacyCommandFlag::enable) {
|
||||
privacy_.Enable(command.block_local_input);
|
||||
if (privacy_sessions_.CanEnable())
|
||||
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;
|
||||
auto status = privacy_.Snapshot();
|
||||
const uint64_t now = SDL_GetTicks();
|
||||
if (!peer_ || (last_privacy_status_tick_ != 0 &&
|
||||
status.revision == last_privacy_revision_ &&
|
||||
@@ -67,7 +81,15 @@ void GuiRuntime::HandlePrivacy() {
|
||||
{
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
for (const auto& [id, state] : connection_status_)
|
||||
if (state == ConnectionStatus::Connected) controllers.push_back(id);
|
||||
if (state == ConnectionStatus::Connected && privacy_sessions_.Supports(id))
|
||||
controllers.push_back(id);
|
||||
if (!privacy_sessions_.CanEnable()) {
|
||||
status.state = PrivacyState::unsupported;
|
||||
status.supported = false;
|
||||
status.input_block_supported = false;
|
||||
std::strcpy(status.reason,
|
||||
"A connected controller does not support privacy screen");
|
||||
}
|
||||
}
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::privacy_status;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-15
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_SESSION_POLICY_H_
|
||||
#define _PRIVACY_SESSION_POLICY_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Guarded by GuiRuntime's connection_status_mutex_. Privacy covers the shared
|
||||
// desktop, so every connected controller must explicitly support it.
|
||||
class PrivacySessionPolicy {
|
||||
public:
|
||||
void Connected(const std::string& remote_id, bool automatic_privacy) {
|
||||
const auto [entry, inserted] = controllers_.try_emplace(remote_id, false);
|
||||
if (inserted && controllers_.size() == 1 && automatic_privacy) {
|
||||
automatic_controller_ = remote_id;
|
||||
}
|
||||
}
|
||||
|
||||
void Disconnected(const std::string& remote_id) {
|
||||
controllers_.erase(remote_id);
|
||||
if (automatic_controller_ == remote_id) automatic_controller_.clear();
|
||||
}
|
||||
|
||||
// Consume automatic activation once, on the first controller's host info.
|
||||
// Repeated metadata or a later join must not undo an explicit privacy exit.
|
||||
bool SetSupported(const std::string& remote_id, bool supported) {
|
||||
const auto entry = controllers_.find(remote_id);
|
||||
if (entry == controllers_.end()) return false;
|
||||
entry->second = supported && remote_id.find("web") == std::string::npos;
|
||||
if (automatic_controller_ != remote_id) return false;
|
||||
automatic_controller_.clear();
|
||||
return CanEnable();
|
||||
}
|
||||
|
||||
bool Supports(const std::string& remote_id) const {
|
||||
const auto entry = controllers_.find(remote_id);
|
||||
return entry != controllers_.end() && entry->second;
|
||||
}
|
||||
|
||||
bool CanEnable() const {
|
||||
return !controllers_.empty() &&
|
||||
std::all_of(controllers_.begin(), controllers_.end(),
|
||||
[](const auto& entry) { return entry.second; });
|
||||
}
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, bool> controllers_;
|
||||
std::string automatic_controller_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -55,6 +55,7 @@ bool Deserialize(const char *data, size_t size, RemoteAction &out) {
|
||||
out.type = static_cast<ControlType>(data[offset++]);
|
||||
|
||||
if (out.type == ControlType::host_infomation) {
|
||||
out.i.supports_privacy_screen = false;
|
||||
size_t name_len;
|
||||
if (!read(&name_len, sizeof(size_t)) ||
|
||||
name_len >= sizeof(out.i.host_name)) {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <remote_action.h>
|
||||
#include <stream_names.h>
|
||||
#include "display_info.h"
|
||||
#include "cursor_presentation.h"
|
||||
#include "minirtc.h"
|
||||
|
||||
namespace crossdesk { class PeerEventHandler; }
|
||||
@@ -173,6 +174,7 @@ struct RemoteSession {
|
||||
std::mutex remote_cursor_state_mutex_;
|
||||
CursorState remote_cursor_state_{};
|
||||
bool remote_cursor_state_received_ = false;
|
||||
CursorPresentation cursor_presentation_;
|
||||
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
|
||||
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
|
||||
TraversalMode traversal_mode_ = TraversalMode::UnknownMode;
|
||||
|
||||
@@ -136,7 +136,6 @@ struct UserSettingsState {
|
||||
int video_encode_format_button_value_ = 0;
|
||||
bool enable_hardware_video_codec_ = true;
|
||||
bool enable_turn_ = true;
|
||||
bool enable_srtp_ = false;
|
||||
char signal_server_ip_[256] = "api.crossdesk.cn";
|
||||
char signal_server_port_[6] = "9099";
|
||||
bool enable_self_hosted_ = false;
|
||||
@@ -147,7 +146,6 @@ struct UserSettingsState {
|
||||
int video_encode_format_button_value_last_ = 0;
|
||||
bool enable_hardware_video_codec_last_ = false;
|
||||
bool enable_turn_last_ = true;
|
||||
bool enable_srtp_last_ = false;
|
||||
bool enable_self_hosted_last_ = false;
|
||||
bool enable_autostart_ = false;
|
||||
bool enable_autostart_last_ = false;
|
||||
|
||||
@@ -48,7 +48,6 @@ export global UiStrings {
|
||||
in-out property <string> codec: "Video codec";
|
||||
in-out property <string> hardware-codec: "Hardware video codec";
|
||||
in-out property <string> turn-relay: "TURN relay";
|
||||
in-out property <string> srtp: "SRTP encryption";
|
||||
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";
|
||||
@@ -247,7 +246,6 @@ export component MainWindow inherits Window {
|
||||
in-out property <int> codec-index: 0;
|
||||
in-out property <bool> hardware-codec-enabled: true;
|
||||
in-out property <bool> turn-enabled: true;
|
||||
in-out property <bool> srtp-enabled: false;
|
||||
in-out property <bool> self-hosted-enabled: false;
|
||||
in-out property <bool> autostart-enabled: false;
|
||||
in-out property <bool> daemon-enabled: false;
|
||||
@@ -1488,11 +1486,6 @@ export component MainWindow inherits Window {
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.turn-relay; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.turn-enabled; enabled: !root.settings-session-active; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
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; }
|
||||
|
||||
@@ -45,6 +45,14 @@ export global StreamStrings {
|
||||
in-out property <string> stats-loss-rate: "Loss Rate";
|
||||
in-out property <string> stats-resolution: "Res";
|
||||
in-out property <string> stats-connection-mode: "Mode";
|
||||
in-out property <string> stats-encryption: "Encryption";
|
||||
in-out property <string> stats-encryption-enabled: "Enabled";
|
||||
in-out property <string> stats-encryption-disabled: "Disabled";
|
||||
}
|
||||
|
||||
struct ControlBarTooltip {
|
||||
text: string,
|
||||
anchor-x: length,
|
||||
}
|
||||
|
||||
// The legacy ImGui control bar uses 24px buttons with Font Awesome rendered
|
||||
@@ -61,6 +69,10 @@ component ControlBarButton inherits Rectangle {
|
||||
in property <bool> muted: false;
|
||||
in property <length> icon-size: 16px;
|
||||
out property <bool> has-hover: touch.has-hover;
|
||||
out property <ControlBarTooltip> tooltip-info: {
|
||||
text: root.visible && touch.has-hover && !touch.pressed ? root.tooltip : "",
|
||||
anchor-x: root.x + root.width / 2,
|
||||
};
|
||||
callback clicked;
|
||||
|
||||
width: 24px;
|
||||
@@ -151,13 +163,13 @@ export component StreamWindow inherits Window {
|
||||
in property <bool> remote-cursor-active: false;
|
||||
in property <int> remote-cursor-shape: 0;
|
||||
in property <bool> audio-enabled: true;
|
||||
in property <bool> srtp-enabled: false;
|
||||
in property <bool> fullscreen-enabled: false;
|
||||
in property <bool> stats-visible: false;
|
||||
in property <[NetworkStatsRow]> stats-rows;
|
||||
in property <string> stats-fps: "0";
|
||||
in property <string> stats-resolution: "";
|
||||
in property <string> stats-connection-mode: "";
|
||||
in property <bool> stats-encryption-enabled: false;
|
||||
in property <bool> file-transfer-visible: false;
|
||||
in property <[FileTransferEntry]> file-transfers;
|
||||
|
||||
@@ -186,17 +198,35 @@ export component StreamWindow inherits Window {
|
||||
callback keyboard-focus-changed(bool);
|
||||
|
||||
private property <bool> control-expanded: true;
|
||||
private property <length> control-toolbar-height: 38px;
|
||||
private property <length> control-button-step: 32px;
|
||||
private property <length> stats-row-height: 18px;
|
||||
private property <length> stats-header-height: 18px;
|
||||
private property <length> stats-content-height:
|
||||
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 44px;
|
||||
private property <bool> display-menu-open: false;
|
||||
private property <bool> display-menu-animation-enabled: false;
|
||||
private property <float> display-menu-progress: 0.0;
|
||||
private property <bool> shortcut-menu-open: false;
|
||||
private property <bool> privacy-tooltip-visible: false;
|
||||
private property <bool> privacy-hovered: root.control-expanded
|
||||
&& privacy-button.has-hover && !root.control-dragging;
|
||||
private property <length> privacy-tooltip-width: min(320px, video.width);
|
||||
private property <length> privacy-tooltip-x: min(max(0px,
|
||||
control.x + (root.control-docked-left ? 105px : 138px)),
|
||||
video.width - root.privacy-tooltip-width);
|
||||
private property <bool> control-tooltip-visible: false;
|
||||
private property <ControlBarTooltip> hovered-control: {
|
||||
if root.window-active && !root.control-dragging
|
||||
&& !root.display-menu-open && !root.shortcut-menu-open {
|
||||
if collapse-button.has-hover { return collapse-button.tooltip-info; }
|
||||
if root.control-expanded {
|
||||
if display-button.has-hover { return display-button.tooltip-info; }
|
||||
if shortcut-button.has-hover { return shortcut-button.tooltip-info; }
|
||||
if mouse-button.has-hover { return mouse-button.tooltip-info; }
|
||||
if privacy-button.has-hover { return privacy-button.tooltip-info; }
|
||||
if audio-button.has-hover { return audio-button.tooltip-info; }
|
||||
if file-button.has-hover { return file-button.tooltip-info; }
|
||||
if stats-button.has-hover { return stats-button.tooltip-info; }
|
||||
if fullscreen-button.has-hover { return fullscreen-button.tooltip-info; }
|
||||
if close-button.has-hover { return close-button.tooltip-info; }
|
||||
}
|
||||
}
|
||||
return { text: "", anchor-x: 0px };
|
||||
}
|
||||
// A popup can remove itself from the scene in its clicked callback before
|
||||
// the video input layer observes the same pointer release. Remember that
|
||||
// the press belonged to local UI so that release cannot reach the peer.
|
||||
@@ -236,11 +266,18 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
changed privacy-hovered => { root.privacy-tooltip-visible = false; }
|
||||
privacy-tooltip-timer := Timer {
|
||||
changed hovered-control => {
|
||||
root.control-tooltip-visible = false;
|
||||
control-tooltip-timer.running = root.hovered-control.text != "";
|
||||
if root.hovered-control.text != "" { control-tooltip-timer.restart(); }
|
||||
}
|
||||
control-tooltip-timer := Timer {
|
||||
interval: 450ms;
|
||||
running: root.privacy-hovered && !root.privacy-tooltip-visible;
|
||||
triggered => { root.privacy-tooltip-visible = true; }
|
||||
running: false;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
root.control-tooltip-visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
is-local-control-area(pointer-x, pointer-y) => {
|
||||
@@ -256,7 +293,7 @@ export component StreamWindow inherits Window {
|
||||
&& pointer-x <= display-menu-x + 180px
|
||||
&& pointer-y >= display-menu-y
|
||||
&& pointer-y <= display-menu-y + display-menu-height;
|
||||
let shortcut-menu-x = control.x + (root.control-docked-left ? 41px : 74px);
|
||||
let shortcut-menu-x = control.x + shortcut-button.x;
|
||||
let shortcut-menu-y = control.y + 40px;
|
||||
let over-shortcut-menu = root.shortcut-menu-open
|
||||
&& pointer-x >= shortcut-menu-x
|
||||
@@ -478,20 +515,8 @@ export component StreamWindow inherits Window {
|
||||
width: parent.width - 27px;
|
||||
height: parent.height;
|
||||
background: transparent;
|
||||
if root.srtp-enabled: Text {
|
||||
x: 0px;
|
||||
width: 12px;
|
||||
height: parent.height;
|
||||
text: FontAwesomeIcons.shield-halved;
|
||||
color: #30343b;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: ImGuiFontStyle.base;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
x: root.srtp-enabled ? 16px : 0px;
|
||||
x: 0px;
|
||||
width: parent.width - self.x;
|
||||
height: parent.height;
|
||||
text: tab.title;
|
||||
@@ -667,8 +692,10 @@ 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(332px, parent.width) : 20px;
|
||||
height: root.stats-visible && root.control-expanded ? min(180px, parent.height) : 38px;
|
||||
width: root.control-expanded ? min(76px + 8 * root.control-button-step, parent.width) : 20px;
|
||||
height: root.stats-visible && root.control-expanded
|
||||
? min(root.control-toolbar-height + root.stats-content-height + 8px, parent.height)
|
||||
: root.control-toolbar-height;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
@@ -685,7 +712,8 @@ export component StreamWindow inherits Window {
|
||||
background: ImGuiLineStyle.control-separator;
|
||||
}
|
||||
|
||||
if root.control-expanded: display-button := ControlBarButton {
|
||||
display-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 9px : 42px; y: 7px;
|
||||
icon: FontAwesomeIcons.display;
|
||||
badge: root.selected-display + 1;
|
||||
@@ -701,8 +729,9 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.control-expanded: shortcut-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 41px : 74px; y: 7px;
|
||||
shortcut-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + root.control-button-step; y: 7px;
|
||||
icon: FontAwesomeIcons.keyboard; tooltip: StreamStrings.send-shortcut;
|
||||
clicked => {
|
||||
display-menu-open-timer.running = false;
|
||||
@@ -712,47 +741,53 @@ export component StreamWindow inherits Window {
|
||||
root.display-menu-open = false;
|
||||
}
|
||||
}
|
||||
if root.control-expanded: mouse-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 73px : 106px; y: 7px;
|
||||
mouse-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 2 * root.control-button-step; y: 7px;
|
||||
icon: FontAwesomeIcons.computer-mouse; slashed: !root.mouse-control-enabled;
|
||||
tooltip: root.mouse-control-enabled ? StreamStrings.release-mouse : StreamStrings.control-mouse;
|
||||
clicked => { root.toggle-mouse-control(); }
|
||||
}
|
||||
if root.control-expanded: audio-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 137px : 170px; y: 7px;
|
||||
audio-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 4 * root.control-button-step; 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 ? 169px : 202px; y: 7px;
|
||||
file-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 5 * root.control-button-step; 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 ? 201px : 234px; y: 7px;
|
||||
stats-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 6 * root.control-button-step; 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 ? 233px : 266px; y: 7px;
|
||||
fullscreen-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 7 * root.control-button-step; 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 ? 265px : 298px; y: 7px;
|
||||
close-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 8 * root.control-button-step; y: 7px;
|
||||
icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect;
|
||||
clicked => { root.disconnect(); }
|
||||
}
|
||||
|
||||
if root.control-expanded && root.control-docked-left: Rectangle {
|
||||
x: 299px; y: 12px; width: 2px; height: 15px;
|
||||
x: parent.width - 33px; y: 12px; width: 2px; height: 15px;
|
||||
background: ImGuiLineStyle.control-separator;
|
||||
}
|
||||
collapse-button := ControlBarButton {
|
||||
x: root.control-expanded ? (root.control-docked-left ? 307px : 10px) : 2px;
|
||||
x: root.control-expanded ? (root.control-docked-left ? parent.width - 25px : 10px) : 2px;
|
||||
y: 7px;
|
||||
width: 15px;
|
||||
icon: root.control-expanded
|
||||
@@ -764,85 +799,122 @@ export component StreamWindow inherits Window {
|
||||
|
||||
privacy-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 105px : 138px; y: 7px;
|
||||
x: (root.control-docked-left ? 9px : 42px) + 3 * root.control-button-step; 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;
|
||||
tooltip: root.privacy-status-text
|
||||
+ (root.privacy-can-toggle ? "\n" + (root.privacy-active
|
||||
? StreamStrings.privacy-disable : StreamStrings.privacy-enable) : "");
|
||||
clicked => { root.toggle-privacy(); }
|
||||
}
|
||||
|
||||
if root.stats-visible && root.control-expanded: stats-table := Rectangle {
|
||||
x: 14px;
|
||||
y: 38px;
|
||||
width: parent.width - 28px;
|
||||
height: 128px;
|
||||
x: 8px;
|
||||
y: root.control-toolbar-height + 2px;
|
||||
width: parent.width - 16px;
|
||||
height: root.stats-content-height;
|
||||
background: transparent;
|
||||
private property <length> row-height: 16px;
|
||||
private property <length> label-column-width: 60px;
|
||||
private property <length> loss-column-width: 66px;
|
||||
private property <length> row-height: root.stats-row-height;
|
||||
private property <length> label-column-width: 50px;
|
||||
private property <length> loss-column-width:
|
||||
max((self.width - self.label-column-width) / 3,
|
||||
stats-loss-label.preferred-width + 8px);
|
||||
private property <length> value-column-width:
|
||||
(self.width - self.label-column-width - self.loss-column-width) / 2;
|
||||
private property <length> details-right-width:
|
||||
right-label-width-probe.preferred-width + 6px
|
||||
+ max(resolution-width-probe.preferred-width,
|
||||
encryption-width-probe.preferred-width
|
||||
+ (root.stats-encryption-enabled ? 14px : 0px));
|
||||
private property <length> details-right-x: self.width / 2;
|
||||
|
||||
Rectangle {
|
||||
y: 0px;
|
||||
height: 1px;
|
||||
background: ImGuiLineStyle.border;
|
||||
left-label-width-probe := Text {
|
||||
visible: false;
|
||||
text: "FPS\n" + StreamStrings.stats-connection-mode;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
}
|
||||
right-label-width-probe := Text {
|
||||
visible: false;
|
||||
text: StreamStrings.stats-resolution + "\n" + StreamStrings.stats-encryption;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
}
|
||||
resolution-width-probe := Text {
|
||||
visible: false;
|
||||
text: root.stats-resolution;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: 600;
|
||||
}
|
||||
encryption-width-probe := Text {
|
||||
visible: false;
|
||||
text: root.stats-encryption-enabled
|
||||
? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
height: stats-table.row-height;
|
||||
width: parent.width;
|
||||
height: root.stats-header-height;
|
||||
background: #f3f5f8;
|
||||
border-radius: 4px;
|
||||
Text {
|
||||
x: stats-table.label-column-width + 4px;
|
||||
width: stats-table.value-column-width - 8px;
|
||||
text: StreamStrings.stats-in;
|
||||
color: #30343b;
|
||||
color: #667085;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
x: stats-table.label-column-width + stats-table.value-column-width + 4px;
|
||||
width: stats-table.value-column-width - 8px;
|
||||
text: StreamStrings.stats-out;
|
||||
color: #30343b;
|
||||
color: #667085;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
stats-loss-label := Text {
|
||||
x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px;
|
||||
width: stats-table.loss-column-width - 8px;
|
||||
text: StreamStrings.stats-loss-rate;
|
||||
color: #30343b;
|
||||
color: #667085;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: ImGuiLineStyle.border;
|
||||
}
|
||||
}
|
||||
|
||||
for row[index] in root.stats-rows: Rectangle {
|
||||
y: (index + 1) * stats-table.row-height;
|
||||
x: 0px;
|
||||
y: root.stats-header-height + index * stats-table.row-height;
|
||||
width: parent.width;
|
||||
height: stats-table.row-height;
|
||||
background: index == root.stats-rows.length - 1 ? #edf3fc : transparent;
|
||||
border-radius: 4px;
|
||||
Text {
|
||||
x: 4px;
|
||||
width: stats-table.label-column-width - 8px;
|
||||
text: row.label;
|
||||
color: #30343b;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
color: #475467;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
x: stats-table.label-column-width + 4px;
|
||||
width: stats-table.value-column-width - 8px;
|
||||
text: row.inbound;
|
||||
color: #30343b;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
color: #253247;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
@@ -850,8 +922,10 @@ export component StreamWindow inherits Window {
|
||||
x: stats-table.label-column-width + stats-table.value-column-width + 4px;
|
||||
width: stats-table.value-column-width - 8px;
|
||||
text: row.outbound;
|
||||
color: #30343b;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
color: #253247;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
@@ -859,46 +933,73 @@ export component StreamWindow inherits Window {
|
||||
x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px;
|
||||
width: stats-table.loss-column-width - 8px;
|
||||
text: row.loss-rate;
|
||||
color: #30343b;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
color: #475467;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
|
||||
horizontal-alignment: right;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
Rectangle {
|
||||
if index < root.stats-rows.length - 2: Rectangle {
|
||||
x: 4px;
|
||||
y: parent.height - 1px;
|
||||
width: parent.width - 8px;
|
||||
height: 1px;
|
||||
background: ImGuiLineStyle.border;
|
||||
background: #edf0f4;
|
||||
}
|
||||
}
|
||||
|
||||
for metric[index] in [
|
||||
{ label: "FPS:", value: root.stats-fps },
|
||||
{ label: StreamStrings.stats-resolution + ":", value: root.stats-resolution },
|
||||
{ label: StreamStrings.stats-connection-mode + ":", value: root.stats-connection-mode },
|
||||
{ label: "FPS", value: root.stats-fps },
|
||||
{ label: StreamStrings.stats-resolution, value: root.stats-resolution },
|
||||
{ label: StreamStrings.stats-connection-mode, value: root.stats-connection-mode },
|
||||
{ label: StreamStrings.stats-encryption, value: root.stats-encryption-enabled
|
||||
? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled },
|
||||
]: Rectangle {
|
||||
y: (index + 5) * stats-table.row-height;
|
||||
height: stats-table.row-height;
|
||||
Text {
|
||||
x: 4px;
|
||||
width: stats-table.label-column-width - 8px;
|
||||
private property <length> encryption-icon-width:
|
||||
index == 3 && root.stats-encryption-enabled ? 14px : 0px;
|
||||
x: mod(index, 2) == 1 ? stats-table.details-right-x : 6px;
|
||||
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
|
||||
+ 2px + floor(index / 2) * 21px;
|
||||
width: mod(index, 2) == 1 ? stats-table.details-right-width
|
||||
: stats-table.details-right-x - 14px;
|
||||
height: 21px;
|
||||
metric-label := Text {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
width: mod(index, 2) == 1 ? right-label-width-probe.preferred-width
|
||||
: left-label-width-probe.preferred-width;
|
||||
height: parent.height;
|
||||
text: metric.label;
|
||||
color: #30343b;
|
||||
color: #667085;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
x: stats-table.label-column-width + 4px;
|
||||
width: stats-table.value-column-width - 8px;
|
||||
metric-value := Text {
|
||||
x: metric-label.width + 6px;
|
||||
y: 0px;
|
||||
height: parent.height;
|
||||
width: parent.width - self.x - parent.encryption-icon-width;
|
||||
text: metric.value;
|
||||
color: #30343b;
|
||||
font-size: ImGuiFontStyle.small;
|
||||
color: index == 3 && root.stats-encryption-enabled ? #18794e : #253247;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: left;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: ImGuiLineStyle.border;
|
||||
if index == 3 && root.stats-encryption-enabled: Text {
|
||||
x: metric-value.x + min(metric-value.preferred-width, metric-value.width) + 4px;
|
||||
y: metric-value.y + (metric-value.height - self.height) / 2;
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
text: FontAwesomeIcons.shield-halved;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 10px;
|
||||
color: #238653;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -982,7 +1083,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: 180px; height: 68px;
|
||||
x: control.x + shortcut-button.x; 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;
|
||||
@@ -1001,18 +1102,26 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
if root.privacy-hovered && root.privacy-tooltip-visible: Rectangle {
|
||||
x: root.privacy-tooltip-x;
|
||||
width: root.privacy-tooltip-width;
|
||||
height: privacy-tip.preferred-height + 16px;
|
||||
y: control.y + control.height + self.height <= video.height
|
||||
? control.y + control.height : max(0px, control.y - self.height);
|
||||
// Measure unwrapped text so short hints stay compact. Render outside
|
||||
// the clipped control bar, capping longer hints before wrapping.
|
||||
control-tooltip-width-probe := Text {
|
||||
visible: false;
|
||||
text: root.hovered-control.text;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
}
|
||||
if root.hovered-control.text != "" && root.control-tooltip-visible: Rectangle {
|
||||
width: min(240px, video.width - 8px,
|
||||
control-tooltip-width-probe.preferred-width + 16px);
|
||||
x: min(max(4px, control.x + root.hovered-control.anchor-x - self.width / 2),
|
||||
video.width - self.width - 4px);
|
||||
height: control-tip.preferred-height + 12px;
|
||||
// Anchor to the button row so expanded stats stay beneath the tooltip.
|
||||
y: control.y + root.control-toolbar-height + self.height + 8px <= video.height
|
||||
? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px);
|
||||
background: #30343bf5; border-radius: 5px; z: 12;
|
||||
privacy-tip := Text {
|
||||
x: 8px; y: 8px; width: parent.width - 16px;
|
||||
text: root.privacy-status-text
|
||||
+ (root.privacy-can-toggle ? "\n" + (root.privacy-active
|
||||
? StreamStrings.privacy-disable : StreamStrings.privacy-enable) : "");
|
||||
control-tip := Text {
|
||||
x: 8px; y: 6px; width: parent.width - 16px;
|
||||
text: root.hovered-control.text;
|
||||
color: white; font-size: ImGuiFontStyle.body; wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,6 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_hardware_codec(
|
||||
Text(localization::enable_hardware_video_codec[language]));
|
||||
strings.set_turn_relay(Text(localization::enable_turn[language]));
|
||||
strings.set_srtp(Text(localization::enable_srtp[language]));
|
||||
strings.set_self_hosted(
|
||||
Text(localization::self_hosted_server_config[language]));
|
||||
strings.set_autostart(Text(localization::enable_autostart[language]));
|
||||
@@ -167,6 +166,12 @@ inline int ApplyStreamWindowStrings(
|
||||
strings.set_stats_resolution(Text(localization::resolution[language]));
|
||||
strings.set_stats_connection_mode(
|
||||
Text(localization::connection_mode[language]));
|
||||
strings.set_stats_encryption(
|
||||
Text(localization::transport_encryption[language]));
|
||||
strings.set_stats_encryption_enabled(
|
||||
Text(localization::transport_encryption_enabled[language]));
|
||||
strings.set_stats_encryption_disabled(
|
||||
Text(localization::transport_encryption_disabled[language]));
|
||||
return language;
|
||||
}
|
||||
|
||||
|
||||
@@ -268,25 +268,6 @@ int GuiApplication::SettingWindow() {
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
{
|
||||
settings_items_offset += settings_items_padding;
|
||||
ImGui::SetCursorPosY(settings_items_offset);
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text(
|
||||
"%s",
|
||||
localization::enable_srtp[localization_language_index_].c_str());
|
||||
ImGui::SameLine();
|
||||
if (ConfigCenter::LANGUAGE::CHINESE == localization_language_) {
|
||||
ImGui::SetCursorPosX(title_bar_button_width_ * 4.275f);
|
||||
} else {
|
||||
ImGui::SetCursorPosX(title_bar_button_width_ * 5.955f);
|
||||
}
|
||||
|
||||
ImGui::Checkbox("##enable_srtp", &enable_srtp_);
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
{
|
||||
settings_items_offset += settings_items_padding;
|
||||
ImGui::SetCursorPosY(settings_items_offset + 1);
|
||||
@@ -569,14 +550,6 @@ int GuiApplication::SettingWindow() {
|
||||
}
|
||||
enable_turn_last_ = enable_turn_;
|
||||
|
||||
// SRTP
|
||||
if (enable_srtp_) {
|
||||
config_center_->SetSrtp(true);
|
||||
} else {
|
||||
config_center_->SetSrtp(false);
|
||||
}
|
||||
enable_srtp_last_ = enable_srtp_;
|
||||
|
||||
if (enable_self_hosted_) {
|
||||
config_center_->SetSelfHosted(true);
|
||||
} else {
|
||||
|
||||
@@ -136,10 +136,7 @@ int GuiApplication::StreamWindow() {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string tab_label =
|
||||
enable_srtp_
|
||||
? std::string(ICON_FA_SHIELD_HALVED) + " " + props->remote_id_
|
||||
: props->remote_id_;
|
||||
const std::string& tab_label = props->remote_id_;
|
||||
if (ImGui::BeginTabItem(tab_label.c_str(), &props->tab_opened_)) {
|
||||
props->tab_selected_ = true;
|
||||
ImGui::SetWindowFontScale(0.6f);
|
||||
@@ -308,4 +305,4 @@ int GuiApplication::StreamWindow() {
|
||||
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "rd_log.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
|
||||
namespace crossdesk {
|
||||
@@ -9,26 +8,38 @@ namespace {
|
||||
|
||||
std::string g_log_dir = "logs";
|
||||
std::once_flag g_logger_once_flag;
|
||||
std::mutex g_logger_mutex;
|
||||
std::shared_ptr<spdlog::logger> g_logger;
|
||||
std::atomic<bool> g_logger_created{false};
|
||||
|
||||
std::string NormalizeLogDirectory(const std::string& log_dir) {
|
||||
const std::filesystem::path path = log_dir.empty() ? "logs" : log_dir;
|
||||
std::error_code ec;
|
||||
const auto absolute = std::filesystem::absolute(path, ec);
|
||||
return (ec ? path : absolute).lexically_normal().string();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void InitLogger(const std::string& log_dir) {
|
||||
if (g_logger_created.load()) {
|
||||
LOG_WARN(
|
||||
"InitLogger called after logger initialized. Ignoring log_dir: {}, "
|
||||
"using previous log_dir: {}",
|
||||
log_dir, g_log_dir);
|
||||
const auto directory = NormalizeLogDirectory(log_dir);
|
||||
std::lock_guard<std::mutex> lock(g_logger_mutex);
|
||||
if (g_logger) {
|
||||
if (directory != g_log_dir) {
|
||||
g_logger->warn(
|
||||
"InitLogger called after logger initialized. Ignoring log_dir: {}, "
|
||||
"using previous log_dir: {}",
|
||||
directory, g_log_dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
g_log_dir = log_dir;
|
||||
g_log_dir = directory;
|
||||
}
|
||||
|
||||
std::shared_ptr<spdlog::logger> get_logger() {
|
||||
std::call_once(g_logger_once_flag, []() {
|
||||
g_logger_created.store(true);
|
||||
std::lock_guard<std::mutex> lock(g_logger_mutex);
|
||||
g_log_dir = NormalizeLogDirectory(g_log_dir);
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(g_log_dir, ec);
|
||||
@@ -54,10 +65,12 @@ std::shared_ptr<spdlog::logger> get_logger() {
|
||||
sinks.push_back(std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
|
||||
filename, 5 * 1024 * 1024, 3));
|
||||
|
||||
g_logger = std::make_shared<spdlog::logger>(LOGGER_NAME, sinks.begin(),
|
||||
sinks.end());
|
||||
g_logger->flush_on(spdlog::level::info);
|
||||
spdlog::register_logger(g_logger);
|
||||
auto logger = std::make_shared<spdlog::logger>(LOGGER_NAME, sinks.begin(),
|
||||
sinks.end());
|
||||
logger->flush_on(spdlog::level::info);
|
||||
spdlog::register_logger(logger);
|
||||
logger->info("Logger initialized: path={}", filename);
|
||||
g_logger = std::move(logger);
|
||||
});
|
||||
|
||||
return g_logger;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
#include "keyboard_capturer.h"
|
||||
|
||||
#include <X11/XKBlib.h>
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/extensions/XInput2.h>
|
||||
#include <X11/extensions/XTest.h>
|
||||
#include <X11/keysym.h>
|
||||
|
||||
#include <array>
|
||||
#include <exception>
|
||||
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
|
||||
@@ -15,9 +21,6 @@
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
static OnKeyAction g_on_key_action = nullptr;
|
||||
static void* g_user_ptr = nullptr;
|
||||
|
||||
static KeyCode ResolveX11Keycode(Display* display, int key_code,
|
||||
uint32_t scan_code, bool extended) {
|
||||
if (!display) {
|
||||
@@ -59,36 +62,8 @@ static KeySym NormalizeKeySym(KeySym key_sym) {
|
||||
return key_sym;
|
||||
}
|
||||
|
||||
static int KeyboardEventHandler(Display* display, XEvent* event) {
|
||||
(void)display;
|
||||
if (event->xkey.type == KeyPress || event->xkey.type == KeyRelease) {
|
||||
KeySym key_sym = NormalizeKeySym(XLookupKeysym(&event->xkey, 0));
|
||||
auto key_it = x11KeySymToVkCode.find(static_cast<int>(key_sym));
|
||||
if (key_it == x11KeySymToVkCode.end()) {
|
||||
key_sym = NormalizeKeySym(XLookupKeysym(&event->xkey, 1));
|
||||
key_it = x11KeySymToVkCode.find(static_cast<int>(key_sym));
|
||||
}
|
||||
|
||||
if (key_it == x11KeySymToVkCode.end()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int key_code = key_it->second;
|
||||
bool is_key_down = (event->xkey.type == KeyPress);
|
||||
uint32_t scan_code = 0;
|
||||
bool extended = false;
|
||||
LookupWindowsKeyMetadataFromVk(key_code, &scan_code, &extended);
|
||||
|
||||
if (g_on_key_action) {
|
||||
g_on_key_action(key_code, is_key_down, scan_code, extended, g_user_ptr);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
PlatformKeyboardCapturer::PlatformKeyboardCapturer()
|
||||
: display_(nullptr),
|
||||
root_(0),
|
||||
running_(false),
|
||||
use_wayland_portal_(false),
|
||||
wayland_init_attempted_(false),
|
||||
@@ -121,82 +96,253 @@ PlatformKeyboardCapturer::~PlatformKeyboardCapturer() {
|
||||
}
|
||||
}
|
||||
|
||||
bool PlatformKeyboardCapturer::RefreshXTestDevices() {
|
||||
// XTEST identifies injected input. Do not filter by names such as "virtual":
|
||||
// an xrdp keyboard is a legitimate controller input device.
|
||||
const Atom property = XInternAtom(capture_display_, "XTEST Device", True);
|
||||
if (property == None) return false;
|
||||
int count = 0;
|
||||
XIDeviceInfo* devices = XIQueryDevice(capture_display_, XIAllDevices, &count);
|
||||
if (!devices) return false;
|
||||
xtest_devices_.clear();
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (devices[i].use != XISlaveKeyboard) continue;
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long items = 0, remaining = 0;
|
||||
unsigned char* value = nullptr;
|
||||
const int result = XIGetProperty(
|
||||
capture_display_, devices[i].deviceid, property, 0, 1, False,
|
||||
XA_INTEGER, &type, &format, &items, &remaining, &value);
|
||||
if (result == Success && type == XA_INTEGER && format == 8 &&
|
||||
items == 1 && value && value[0] == 1) {
|
||||
xtest_devices_.insert(devices[i].deviceid);
|
||||
}
|
||||
if (value) XFree(value);
|
||||
}
|
||||
XIFreeDeviceInfo(devices);
|
||||
return true;
|
||||
}
|
||||
|
||||
void PlatformKeyboardCapturer::CaptureKey(int source_id, int x11_keycode,
|
||||
bool is_down) {
|
||||
if (!capture_has_focus_ || xtest_devices_.count(source_id) ||
|
||||
x11_keycode < 8 || x11_keycode > 255)
|
||||
return;
|
||||
|
||||
// The GUI stops capture on focus loss, but its event loop may lag behind
|
||||
// the native input thread. Never forward keys typed into another window.
|
||||
Window focus = None;
|
||||
int revert = 0;
|
||||
XGetInputFocus(capture_display_, &focus, &revert);
|
||||
if (focus != capture_focus_) return;
|
||||
|
||||
const uint64_t identity = (static_cast<uint64_t>(source_id) << 32) |
|
||||
static_cast<uint32_t>(x11_keycode);
|
||||
auto found = captured_keys_.find(identity);
|
||||
if (found == captured_keys_.end()) {
|
||||
// A press can have reached the Slint fallback just before native capture
|
||||
// started. Forward its release too, so that handoff cannot leave a key down.
|
||||
CapturedKey key;
|
||||
// Use unshifted key identity, independently of the local input method.
|
||||
// Preserve it until release, even if the keyboard mapping changes.
|
||||
for (int level = 0; level < 2 && key.key_code == 0; ++level) {
|
||||
const KeySym symbol = NormalizeKeySym(XkbKeycodeToKeysym(
|
||||
capture_display_, static_cast<KeyCode>(x11_keycode), 0, level));
|
||||
if (symbol == XK_KP_Enter) {
|
||||
key = {0x0D, 0x1C, true};
|
||||
} else if (symbol == XK_ISO_Level3_Shift) {
|
||||
key.key_code = 0xA5;
|
||||
} else if (symbol == XK_grave) {
|
||||
key.key_code = 0xC0;
|
||||
} else if (const auto vk = x11KeySymToVkCode.find(symbol);
|
||||
vk != x11KeySymToVkCode.end()) {
|
||||
key.key_code = vk->second;
|
||||
}
|
||||
}
|
||||
if (!key.key_code) return;
|
||||
if (!key.scan_code) {
|
||||
LookupWindowsKeyMetadataFromVk(key.key_code, &key.scan_code, &key.extended);
|
||||
if (!key.scan_code) {
|
||||
const int evdev = LinuxEvdevKeycodeFromWindowsVk(key.key_code);
|
||||
for (uint32_t scan = 1; scan <= 0x58; ++scan) {
|
||||
if (evdev >= 0 &&
|
||||
LinuxEvdevKeycodeFromWindowsScanCode(scan, false) == evdev) {
|
||||
key.scan_code = scan;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found = captured_keys_.emplace(identity, key).first;
|
||||
}
|
||||
const CapturedKey key = found->second;
|
||||
if (!is_down) captured_keys_.erase(found);
|
||||
if (on_key_action_) {
|
||||
on_key_action_(key.key_code, is_down, key.scan_code, key.extended, user_ptr_);
|
||||
}
|
||||
}
|
||||
|
||||
void PlatformKeyboardCapturer::RunCapture() {
|
||||
const int fd = ConnectionNumber(capture_display_);
|
||||
while (running_) {
|
||||
while (running_ && XPending(capture_display_) > 0) {
|
||||
XEvent event{};
|
||||
XNextEvent(capture_display_, &event);
|
||||
if (event.type == xkb_event_base_) {
|
||||
auto* xkb = reinterpret_cast<XkbEvent*>(&event);
|
||||
if (xkb->any.xkb_type == XkbMapNotify)
|
||||
XkbRefreshKeyboardMapping(&xkb->map);
|
||||
} else if (event.type == MappingNotify) {
|
||||
XRefreshKeyboardMapping(&event.xmapping);
|
||||
} else if (event.type == FocusOut) {
|
||||
// Keep focus changes in event order too: a fast switch away and back
|
||||
// may finish before either the GUI or XGetInputFocus observes it.
|
||||
capture_has_focus_ = false;
|
||||
for (const auto& [identity, key] : captured_keys_) {
|
||||
if (on_key_action_)
|
||||
on_key_action_(key.key_code, false, key.scan_code, key.extended,
|
||||
user_ptr_);
|
||||
}
|
||||
captured_keys_.clear();
|
||||
raw_presses_.fill({});
|
||||
} else if (event.type == FocusIn) {
|
||||
capture_has_focus_ = true;
|
||||
} else if (event.type == KeyPress && !event.xkey.send_event &&
|
||||
event.xkey.keycode < raw_presses_.size() &&
|
||||
event.xkey.time != raw_presses_[event.xkey.keycode].time) {
|
||||
// Server-generated repeats have no matching raw press. Subscribe to
|
||||
// core events on the focused window so Slint's core/XIM delivery is
|
||||
// preserved (selecting XI_KeyPress here would take precedence over it).
|
||||
const int source = raw_presses_[event.xkey.keycode].source_id;
|
||||
const uint64_t identity = (static_cast<uint64_t>(source) << 32) |
|
||||
event.xkey.keycode;
|
||||
if (captured_keys_.count(identity))
|
||||
CaptureKey(source, event.xkey.keycode, true);
|
||||
} else if (event.type == GenericEvent &&
|
||||
event.xcookie.extension == xi_opcode_ &&
|
||||
XGetEventData(capture_display_, &event.xcookie)) {
|
||||
if (event.xcookie.evtype == XI_RawKeyPress ||
|
||||
event.xcookie.evtype == XI_RawKeyRelease) {
|
||||
const auto* key = static_cast<XIRawEvent*>(event.xcookie.data);
|
||||
const bool is_down = event.xcookie.evtype == XI_RawKeyPress;
|
||||
if (is_down && key->detail >= 0 &&
|
||||
static_cast<size_t>(key->detail) < raw_presses_.size()) {
|
||||
// Include filtered XTEST presses, so their core events cannot be
|
||||
// mistaken for repeats of a physically held key.
|
||||
raw_presses_[key->detail] = {key->time, key->sourceid};
|
||||
}
|
||||
CaptureKey(key->sourceid, key->detail, is_down);
|
||||
} else if (event.xcookie.evtype == XI_HierarchyChanged &&
|
||||
!RefreshXTestDevices()) {
|
||||
LOG_ERROR("Failed to refresh XInput2 keyboard devices");
|
||||
running_ = false;
|
||||
}
|
||||
XFreeEventData(capture_display_, &event.xcookie);
|
||||
}
|
||||
}
|
||||
if (!running_) break;
|
||||
struct pollfd pfd = {fd, POLLIN, 0};
|
||||
const int result = poll(&pfd, 1, 50);
|
||||
if ((result < 0 && errno != EINTR) ||
|
||||
(result > 0 && (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)))) {
|
||||
LOG_ERROR("XInput2 keyboard capture event connection failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
running_ = false;
|
||||
}
|
||||
|
||||
int PlatformKeyboardCapturer::Hook(OnKeyAction on_key_action, void* user_ptr) {
|
||||
if (!display_) {
|
||||
LOG_ERROR("Display not initialized.");
|
||||
if (running_) return 0;
|
||||
Unhook();
|
||||
capture_display_ = XOpenDisplay(nullptr);
|
||||
if (!capture_display_ || !on_key_action) {
|
||||
LOG_ERROR("Cannot open X11 keyboard capture display or callback is empty");
|
||||
Unhook();
|
||||
return -1;
|
||||
}
|
||||
|
||||
g_on_key_action = on_key_action;
|
||||
g_user_ptr = user_ptr;
|
||||
|
||||
if (running_) {
|
||||
return 0;
|
||||
int event = 0, error = 0;
|
||||
// XI 2.1 supplies raw-event source IDs, needed to exclude XTEST devices.
|
||||
int major = 2, minor = 1;
|
||||
if (!XQueryExtension(capture_display_, "XInputExtension", &xi_opcode_,
|
||||
&event, &error) ||
|
||||
XIQueryVersion(capture_display_, &major, &minor) != Success || major < 2 ||
|
||||
(major == 2 && minor < 1)) {
|
||||
LOG_WARN("XInput2 keyboard capture is unavailable");
|
||||
Unhook();
|
||||
return -1;
|
||||
}
|
||||
major = XkbMajorVersion;
|
||||
minor = XkbMinorVersion;
|
||||
int xkb_opcode = 0;
|
||||
if (!XkbQueryExtension(capture_display_, &xkb_opcode, &xkb_event_base_,
|
||||
&error, &major, &minor) || !RefreshXTestDevices()) {
|
||||
LOG_WARN("Cannot initialize XKB or enumerate XInput2 keyboard devices");
|
||||
Unhook();
|
||||
return -1;
|
||||
}
|
||||
|
||||
root_ = DefaultRootWindow(display_);
|
||||
XSelectInput(display_, root_, KeyPressMask | KeyReleaseMask);
|
||||
XFlush(display_);
|
||||
int revert = 0;
|
||||
XGetInputFocus(capture_display_, &capture_focus_, &revert);
|
||||
if (capture_focus_ == None || capture_focus_ == PointerRoot) {
|
||||
LOG_WARN("XInput2 keyboard capture requires a focused application window");
|
||||
Unhook();
|
||||
return -1;
|
||||
}
|
||||
capture_has_focus_ = true;
|
||||
|
||||
running_ = true;
|
||||
const int x11_fd = ConnectionNumber(display_);
|
||||
event_thread_ = std::thread([this, x11_fd]() {
|
||||
while (running_) {
|
||||
while (running_ && XPending(display_) > 0) {
|
||||
XEvent event;
|
||||
XNextEvent(display_, &event);
|
||||
KeyboardEventHandler(display_, &event);
|
||||
}
|
||||
|
||||
if (!running_) {
|
||||
break;
|
||||
}
|
||||
|
||||
struct pollfd pfd = {x11_fd, POLLIN, 0};
|
||||
int poll_ret = poll(&pfd, 1, 50);
|
||||
if (poll_ret < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
LOG_ERROR("poll for X11 events failed.");
|
||||
running_ = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (poll_ret == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
|
||||
LOG_ERROR("poll got invalid X11 event fd state.");
|
||||
running_ = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if ((pfd.revents & POLLIN) == 0) {
|
||||
continue;
|
||||
}
|
||||
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> raw{};
|
||||
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> hierarchy{};
|
||||
XISetMask(raw.data(), XI_RawKeyPress);
|
||||
XISetMask(raw.data(), XI_RawKeyRelease);
|
||||
XISetMask(hierarchy.data(), XI_HierarchyChanged);
|
||||
XIEventMask masks[] = {
|
||||
{XIAllMasterDevices, static_cast<int>(raw.size()), raw.data()},
|
||||
{XIAllDevices, static_cast<int>(hierarchy.size()), hierarchy.data()}};
|
||||
for (int screen = 0; screen < ScreenCount(capture_display_); ++screen) {
|
||||
if (XISelectEvents(capture_display_, RootWindow(capture_display_, screen),
|
||||
masks, 2) != Success) {
|
||||
LOG_WARN("Cannot subscribe to XInput2 keyboard events");
|
||||
Unhook();
|
||||
return -1;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
XSelectInput(capture_display_, capture_focus_, KeyPressMask | FocusChangeMask);
|
||||
XkbSelectEvents(capture_display_, XkbUseCoreKbd, XkbMapNotifyMask,
|
||||
XkbMapNotifyMask);
|
||||
XSync(capture_display_, False);
|
||||
on_key_action_ = on_key_action;
|
||||
user_ptr_ = user_ptr;
|
||||
running_ = true;
|
||||
try {
|
||||
event_thread_ = std::thread([this] { RunCapture(); });
|
||||
} catch (const std::exception& error) {
|
||||
LOG_ERROR("Cannot start XInput2 keyboard capture thread: {}", error.what());
|
||||
Unhook();
|
||||
return -1;
|
||||
}
|
||||
LOG_INFO("XInput2 keyboard capture started");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int PlatformKeyboardCapturer::Unhook() {
|
||||
running_ = false;
|
||||
|
||||
if (event_thread_.joinable()) {
|
||||
event_thread_.join();
|
||||
if (event_thread_.joinable()) event_thread_.join();
|
||||
// Closing the private connection removes every subscription and queued
|
||||
// event, so restarting capture cannot forward keys from the previous focus.
|
||||
if (capture_display_) {
|
||||
XCloseDisplay(capture_display_);
|
||||
capture_display_ = nullptr;
|
||||
}
|
||||
|
||||
g_on_key_action = nullptr;
|
||||
g_user_ptr = nullptr;
|
||||
|
||||
if (display_ && root_ != 0) {
|
||||
XSelectInput(display_, root_, 0);
|
||||
XFlush(display_);
|
||||
}
|
||||
|
||||
on_key_action_ = nullptr;
|
||||
user_ptr_ = nullptr;
|
||||
captured_keys_.clear();
|
||||
raw_presses_.fill({});
|
||||
xtest_devices_.clear();
|
||||
capture_focus_ = None;
|
||||
capture_has_focus_ = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
#ifndef _KEYBOARD_CAPTURER_H_
|
||||
#define _KEYBOARD_CAPTURER_H_
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "device_controller.h"
|
||||
|
||||
@@ -30,11 +33,25 @@ class PlatformKeyboardCapturer final : public KeyboardCapturer {
|
||||
public:
|
||||
virtual int Hook(OnKeyAction on_key_action, void* user_ptr);
|
||||
virtual int Unhook();
|
||||
bool IsHookActive() const override { return running_.load(); }
|
||||
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code = 0,
|
||||
bool extended = false);
|
||||
|
||||
private:
|
||||
struct CapturedKey {
|
||||
int key_code = 0;
|
||||
uint32_t scan_code = 0;
|
||||
bool extended = false;
|
||||
};
|
||||
struct RawKeyPress {
|
||||
unsigned long time = 0;
|
||||
int source_id = 0;
|
||||
};
|
||||
bool RefreshXTestDevices();
|
||||
void CaptureKey(int source_id, int x11_keycode, bool is_down);
|
||||
void RunCapture();
|
||||
|
||||
bool InitWaylandPortal();
|
||||
void CleanupWaylandPortal();
|
||||
int SendWaylandKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
|
||||
@@ -47,7 +64,17 @@ class PlatformKeyboardCapturer final : public KeyboardCapturer {
|
||||
|
||||
private:
|
||||
_XDisplay* display_;
|
||||
unsigned long root_;
|
||||
// Capture owns a separate connection, including its XKB event queue.
|
||||
_XDisplay* capture_display_ = nullptr;
|
||||
unsigned long capture_focus_ = 0;
|
||||
bool capture_has_focus_ = false;
|
||||
int xi_opcode_ = 0;
|
||||
int xkb_event_base_ = 0;
|
||||
OnKeyAction on_key_action_ = nullptr;
|
||||
void* user_ptr_ = nullptr;
|
||||
std::unordered_set<int> xtest_devices_;
|
||||
std::unordered_map<uint64_t, CapturedKey> captured_keys_;
|
||||
std::array<RawKeyPress, 256> raw_presses_{};
|
||||
std::atomic<bool> running_;
|
||||
std::thread event_thread_;
|
||||
std::mutex x11_injection_mutex_;
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_BACKEND_LINUX_H_
|
||||
#define _PRIVACY_BACKEND_LINUX_H_
|
||||
|
||||
#include <poll.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <thread>
|
||||
|
||||
#include "privacy_backend.h"
|
||||
#include "privacy_guard.h"
|
||||
|
||||
extern char** environ;
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
class LinuxPrivacyBackend final : public PrivacyBackend {
|
||||
public:
|
||||
LinuxPrivacyBackend() : wake_(eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK)) {}
|
||||
~LinuxPrivacyBackend() override {
|
||||
// EOF also recovers after a crash, SIGKILL, or a normal shutdown. Never
|
||||
// terminate the guard: it must finish restoring the saved display ramps.
|
||||
if (socket_ >= 0) close(socket_);
|
||||
if (wake_ >= 0) close(wake_);
|
||||
if (child_ > 0) {
|
||||
const pid_t child = child_;
|
||||
std::thread([child] {
|
||||
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
PrivacyCapabilities Query() override {
|
||||
if (wake_ < 0)
|
||||
return {false, false, "Cannot create Linux privacy wake event"};
|
||||
if (!StartGuard()) return {false, false, failure_};
|
||||
if (!Send(LinuxPrivacyCommand::query)) return {false, false, failure_};
|
||||
// The first query also serves automatic privacy on the first connection.
|
||||
// Bound process startup so an unavailable helper cannot stall the GUI.
|
||||
if (!discovered_) {
|
||||
pollfd fd{socket_, POLLIN, 0};
|
||||
poll(&fd, 1, 1000);
|
||||
}
|
||||
ReadReplies();
|
||||
return {reply_.supported, reply_.input_supported,
|
||||
reply_.capability_reason[0] ? reply_.capability_reason
|
||||
: "Checking Linux privacy support"};
|
||||
}
|
||||
|
||||
bool Enable(bool block_input, const PrivacyScreenText&,
|
||||
std::string& error) override {
|
||||
failure_.clear();
|
||||
emergency_ = false;
|
||||
if (!Send(block_input ? LinuxPrivacyCommand::enable_blocking
|
||||
: LinuxPrivacyCommand::enable)) {
|
||||
error = failure_;
|
||||
return false;
|
||||
}
|
||||
engaged_ = true;
|
||||
++generation_;
|
||||
last_reply_ = Clock::now();
|
||||
recovered_ = false;
|
||||
reply_.active = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Recover() override {
|
||||
ReadReplies();
|
||||
if (recovered_) {
|
||||
failure_.clear();
|
||||
return;
|
||||
}
|
||||
if (recovering_) return;
|
||||
if (Send(LinuxPrivacyCommand::recover)) recovering_ = true;
|
||||
}
|
||||
bool IsRecovered() const override { return recovered_; }
|
||||
bool RecoveryPending() const override { return recovering_; }
|
||||
|
||||
PrivacyHealth Poll() override {
|
||||
ReadReplies();
|
||||
if (engaged_ && Clock::now() - last_reply_ > std::chrono::seconds(1))
|
||||
failure_ = "Linux privacy guard stopped responding";
|
||||
if (engaged_ &&
|
||||
Clock::now() - last_heartbeat_ >= std::chrono::milliseconds(100)) {
|
||||
Send(LinuxPrivacyCommand::heartbeat);
|
||||
last_heartbeat_ = Clock::now();
|
||||
}
|
||||
const bool emergency = emergency_;
|
||||
emergency_ = false;
|
||||
return {failure_, emergency, reply_.active};
|
||||
}
|
||||
|
||||
void WaitForEvents(unsigned timeout_ms) override {
|
||||
pollfd fds[] = {{wake_, POLLIN, 0}, {socket_, POLLIN, 0}};
|
||||
poll(fds, 2, static_cast<int>(timeout_ms));
|
||||
uint64_t value;
|
||||
if (fds[0].revents & POLLIN) {
|
||||
while (read(wake_, &value, sizeof(value)) == sizeof(value)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
void Wake() override {
|
||||
const uint64_t value = 1;
|
||||
if (wake_ >= 0) {
|
||||
const auto ignored = write(wake_, &value, sizeof(value));
|
||||
(void)ignored;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
bool StartGuard() {
|
||||
if (socket_ >= 0) return true;
|
||||
if (child_ > 0) {
|
||||
const auto result = waitpid(child_, nullptr, WNOHANG);
|
||||
if (result == 0) return false;
|
||||
child_ = -1;
|
||||
}
|
||||
int sockets[2];
|
||||
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0,
|
||||
sockets) != 0) {
|
||||
failure_ = "Cannot create Linux privacy guard socket";
|
||||
return false;
|
||||
}
|
||||
posix_spawn_file_actions_t actions;
|
||||
int result = posix_spawn_file_actions_init(&actions);
|
||||
if (result == 0) {
|
||||
// Close the parent end first: it may occupy the guard's destination fd.
|
||||
result = posix_spawn_file_actions_addclose(&actions, sockets[0]);
|
||||
if (result == 0)
|
||||
result = posix_spawn_file_actions_adddup2(&actions, sockets[1],
|
||||
kLinuxPrivacyGuardSocket);
|
||||
if (result == 0 && sockets[1] != kLinuxPrivacyGuardSocket)
|
||||
result = posix_spawn_file_actions_addclose(&actions, sockets[1]);
|
||||
if (result == 0) {
|
||||
char executable[] = "/proc/self/exe";
|
||||
char* args[] = {executable,
|
||||
const_cast<char*>(kLinuxPrivacyGuardArgument), nullptr};
|
||||
result =
|
||||
posix_spawn(&child_, executable, &actions, nullptr, args, environ);
|
||||
}
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
}
|
||||
close(sockets[1]);
|
||||
if (result != 0) {
|
||||
close(sockets[0]);
|
||||
child_ = -1;
|
||||
failure_ = std::string("Cannot start Linux privacy guard: ") +
|
||||
std::strerror(result);
|
||||
return false;
|
||||
}
|
||||
socket_ = sockets[0];
|
||||
reply_ = {};
|
||||
discovered_ = false;
|
||||
generation_ = 0;
|
||||
failure_.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
void LostGuard() {
|
||||
if (socket_ >= 0) close(socket_);
|
||||
socket_ = -1;
|
||||
reply_ = {};
|
||||
recovering_ = false;
|
||||
failure_ =
|
||||
"Linux privacy guard disconnected; local protection is unavailable";
|
||||
// Do not claim successful recovery after an unexpected guard failure.
|
||||
// A normal guard reports restoration before closing its socket.
|
||||
}
|
||||
bool Send(LinuxPrivacyCommand command) {
|
||||
if (socket_ < 0) return false;
|
||||
if (send(socket_, &command, sizeof(command), MSG_NOSIGNAL) ==
|
||||
sizeof(command))
|
||||
return true;
|
||||
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) LostGuard();
|
||||
return false;
|
||||
}
|
||||
void ReadReplies() {
|
||||
if (socket_ < 0) return;
|
||||
for (;;) {
|
||||
LinuxPrivacyReply next;
|
||||
const auto bytes = recv(socket_, &next, sizeof(next), 0);
|
||||
if (bytes < 0 &&
|
||||
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
|
||||
return;
|
||||
if (bytes != sizeof(next)) {
|
||||
LostGuard();
|
||||
return;
|
||||
}
|
||||
next.capability_reason[sizeof(next.capability_reason) - 1] = '\0';
|
||||
next.failure[sizeof(next.failure) - 1] = '\0';
|
||||
discovered_ = true;
|
||||
// Ignore queued capability/heartbeat replies from before this enable.
|
||||
// A matching generation can also acknowledge watchdog-driven recovery.
|
||||
if (next.generation != generation_) continue;
|
||||
reply_ = next;
|
||||
last_reply_ = Clock::now();
|
||||
emergency_ = emergency_ || next.emergency;
|
||||
if (next.failure[0]) failure_ = next.failure;
|
||||
recovered_ = next.recovered;
|
||||
if (next.recovered) {
|
||||
recovering_ = false;
|
||||
engaged_ = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int wake_ = -1;
|
||||
int socket_ = -1;
|
||||
pid_t child_ = -1;
|
||||
LinuxPrivacyReply reply_;
|
||||
uint64_t generation_ = 0;
|
||||
bool engaged_ = false;
|
||||
bool discovered_ = false;
|
||||
bool recovering_ = false;
|
||||
bool recovered_ = true;
|
||||
bool emergency_ = false;
|
||||
std::string failure_;
|
||||
Clock::time_point last_heartbeat_{};
|
||||
Clock::time_point last_reply_{};
|
||||
};
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend() {
|
||||
return std::make_unique<LinuxPrivacyBackend>();
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,721 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
#include "privacy_guard.h"
|
||||
|
||||
#include <X11/XKBlib.h>
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/extensions/XInput.h>
|
||||
#include <X11/extensions/XInput2.h>
|
||||
#include <X11/extensions/XTest.h>
|
||||
#include <X11/extensions/Xfixes.h>
|
||||
#include <X11/extensions/Xrandr.h>
|
||||
#include <X11/keysym.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Gamma = std::unique_ptr<XRRCrtcGamma, decltype(&XRRFreeGamma)>;
|
||||
using Resources =
|
||||
std::unique_ptr<XRRScreenResources, decltype(&XRRFreeScreenResources)>;
|
||||
using CrtcInfo = std::unique_ptr<XRRCrtcInfo, decltype(&XRRFreeCrtcInfo)>;
|
||||
volatile std::sig_atomic_t stopping = 0;
|
||||
int x_error = 0;
|
||||
|
||||
void StopGuard(int) { stopping = 1; }
|
||||
int OnXError(Display*, XErrorEvent* event) {
|
||||
x_error = event->error_code;
|
||||
return 0;
|
||||
}
|
||||
|
||||
template <size_t N>
|
||||
void SetText(char (&dest)[N], const std::string& text) {
|
||||
std::strncpy(dest, text.c_str(), N - 1);
|
||||
dest[N - 1] = '\0';
|
||||
}
|
||||
|
||||
struct Output {
|
||||
Window root = 0;
|
||||
RRCrtc crtc = 0;
|
||||
RRMode mode = 0;
|
||||
int x = 0, y = 0;
|
||||
unsigned width = 0, height = 0;
|
||||
Rotation rotation = 0;
|
||||
std::vector<RROutput> connectors;
|
||||
Gamma saved{nullptr, XRRFreeGamma};
|
||||
|
||||
bool SameLayout(const Output& other) const {
|
||||
return std::tie(root, crtc, mode, x, y, width, height, rotation,
|
||||
connectors) == std::tie(other.root, other.crtc, other.mode,
|
||||
other.x, other.y, other.width,
|
||||
other.height, other.rotation,
|
||||
other.connectors);
|
||||
}
|
||||
};
|
||||
|
||||
struct Device {
|
||||
int id = 0, use = 0, attachment = 0;
|
||||
bool keyboard = false, touch = false;
|
||||
std::string name;
|
||||
|
||||
bool operator==(const Device& other) const {
|
||||
return std::tie(id, use, attachment, keyboard, touch, name) ==
|
||||
std::tie(other.id, other.use, other.attachment, other.keyboard,
|
||||
other.touch, other.name);
|
||||
}
|
||||
};
|
||||
|
||||
bool IsBlack(const XRRCrtcGamma* gamma) {
|
||||
if (!gamma || gamma->size <= 0) return false;
|
||||
for (int i = 0; i < gamma->size; ++i)
|
||||
if (gamma->red[i] || gamma->green[i] || gamma->blue[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// This class lives only in the exec'd guard process. Its Xlib error handler is
|
||||
// isolated from the GUI and capture threads' process-global error handlers.
|
||||
class NativePrivacy {
|
||||
public:
|
||||
~NativePrivacy() {
|
||||
Recover();
|
||||
if (display_) XCloseDisplay(display_);
|
||||
}
|
||||
|
||||
void Query() {
|
||||
reply_.supported = reply_.input_supported = false;
|
||||
std::string error;
|
||||
if (!Initialize(error)) {
|
||||
SetText(reply_.capability_reason, error);
|
||||
return;
|
||||
}
|
||||
std::vector<Output> outputs;
|
||||
std::vector<Device> devices;
|
||||
if (!ReadOutputs(outputs, true, error) || !ReadDevices(devices, error)) {
|
||||
SetText(reply_.capability_reason, error);
|
||||
return;
|
||||
}
|
||||
const bool keyboard = std::any_of(devices.begin(), devices.end(),
|
||||
[](const auto& d) { return d.keyboard; });
|
||||
if (!keyboard) {
|
||||
SetText(reply_.capability_reason,
|
||||
"No local keyboard available for privacy recovery");
|
||||
return;
|
||||
}
|
||||
reply_.supported = true;
|
||||
// XIGrabDevice does not suppress XI 2.2 touch delivery. Do not advertise
|
||||
// input protection for touchscreens without a separate touch grab.
|
||||
reply_.input_supported = std::none_of(
|
||||
devices.begin(), devices.end(), [](const auto& d) { return d.touch; });
|
||||
SetText(reply_.capability_reason,
|
||||
reply_.input_supported
|
||||
? "X11 privacy black screen available; local recovery: "
|
||||
"Ctrl+Alt+Shift+F12"
|
||||
: "X11 privacy black screen available; touchscreen input "
|
||||
"blocking is unavailable");
|
||||
}
|
||||
|
||||
void Enable(bool block_input) {
|
||||
++reply_.generation;
|
||||
reply_.failure[0] = '\0';
|
||||
reply_.emergency = false;
|
||||
if (!reply_.recovered) {
|
||||
Fail("Linux privacy resources are still in use");
|
||||
return;
|
||||
}
|
||||
Query();
|
||||
if (!reply_.supported || (block_input && !reply_.input_supported)) {
|
||||
Fail(reply_.capability_reason);
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
keys_.clear();
|
||||
if (!Acquire(error) || !ReadOutputs(outputs_, true, error) ||
|
||||
!ReadDevices(devices_, error) || !ProtectInput(block_input, error)) {
|
||||
Fail(error);
|
||||
return;
|
||||
}
|
||||
// No display ramp is changed until every original ramp has been saved.
|
||||
// Gamma is applied at scanout, after XGetImage/DRM framebuffer capture.
|
||||
// There is deliberately no cover window to contaminate remote frames.
|
||||
x_error = 0;
|
||||
for (const auto& output : outputs_) {
|
||||
Gamma black(XRRAllocGamma(output.saved->size), XRRFreeGamma);
|
||||
if (!black) {
|
||||
Fail("Cannot allocate Linux privacy display ramp");
|
||||
return;
|
||||
}
|
||||
std::fill_n(black->red, black->size, 0);
|
||||
std::fill_n(black->green, black->size, 0);
|
||||
std::fill_n(black->blue, black->size, 0);
|
||||
XRRSetCrtcGamma(display_, output.crtc, black.get());
|
||||
}
|
||||
for (int i = 0; i < ScreenCount(display_); ++i) {
|
||||
XFixesHideCursor(display_, RootWindow(display_, i));
|
||||
hidden_roots_.push_back(RootWindow(display_, i));
|
||||
}
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
Fail("Cannot blank Linux displays or hide the local cursor");
|
||||
return;
|
||||
}
|
||||
// Active slave grabs temporarily detach devices. Snapshot after grabbing,
|
||||
// and monitor subsequent hardware/hierarchy changes during protection.
|
||||
if (!ReadDevices(devices_, error)) {
|
||||
Fail(error);
|
||||
return;
|
||||
}
|
||||
reply_.active = true;
|
||||
Check();
|
||||
}
|
||||
|
||||
void Recover() {
|
||||
reply_.active = false;
|
||||
if (!display_) {
|
||||
reply_.recovered = true;
|
||||
return;
|
||||
}
|
||||
for (int id : grabbed_) XIUngrabDevice(display_, id, CurrentTime);
|
||||
grabbed_.clear();
|
||||
for (Window root : hidden_roots_) XFixesShowCursor(display_, root);
|
||||
hidden_roots_.clear();
|
||||
// Keep failed restorations for retry. Removed CRTCs no longer own a local
|
||||
// output; all still-present CRTCs must acknowledge the original ramp.
|
||||
for (auto it = outputs_.begin(); it != outputs_.end();) {
|
||||
x_error = 0;
|
||||
Resources resources(XRRGetScreenResourcesCurrent(display_, it->root),
|
||||
XRRFreeScreenResources);
|
||||
if (!resources) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
const bool exists =
|
||||
std::find(resources->crtcs, resources->crtcs + resources->ncrtc,
|
||||
it->crtc) != resources->crtcs + resources->ncrtc;
|
||||
if (exists && it->saved) {
|
||||
XRRSetCrtcGamma(display_, it->crtc, it->saved.get());
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
it = outputs_.erase(it);
|
||||
}
|
||||
keys_.clear();
|
||||
if (outputs_.empty() && owner_) {
|
||||
XDestroyWindow(display_, owner_);
|
||||
owner_ = 0;
|
||||
}
|
||||
XSync(display_, False);
|
||||
reply_.recovered = outputs_.empty() && !owner_;
|
||||
}
|
||||
|
||||
void Fail(const std::string& reason) {
|
||||
SetText(reply_.failure, reason);
|
||||
Recover();
|
||||
}
|
||||
|
||||
void Poll() {
|
||||
if (!display_) return;
|
||||
while (XPending(display_)) {
|
||||
XEvent event{};
|
||||
XNextEvent(display_, &event);
|
||||
if (event.type != GenericEvent || event.xcookie.extension != xi_opcode_ ||
|
||||
!XGetEventData(display_, &event.xcookie))
|
||||
continue;
|
||||
if (reply_.active) {
|
||||
const int type = event.xcookie.evtype;
|
||||
if (type == XI_KeyPress || type == XI_KeyRelease) {
|
||||
const auto* key = static_cast<XIDeviceEvent*>(event.xcookie.data);
|
||||
Key(key->sourceid, key->detail, type == XI_KeyPress);
|
||||
} else if (!blocking_ &&
|
||||
(type == XI_RawKeyPress || type == XI_RawKeyRelease)) {
|
||||
const auto* key = static_cast<XIRawEvent*>(event.xcookie.data);
|
||||
Key(key->sourceid, key->detail, type == XI_RawKeyPress);
|
||||
}
|
||||
}
|
||||
XFreeEventData(display_, &event.xcookie);
|
||||
}
|
||||
if (reply_.active &&
|
||||
Clock::now() - checked_ >= std::chrono::milliseconds(100))
|
||||
Check();
|
||||
if (!reply_.active && !reply_.recovered) Recover();
|
||||
}
|
||||
|
||||
const LinuxPrivacyReply& Reply() const { return reply_; }
|
||||
int Connection() const { return display_ ? ConnectionNumber(display_) : -1; }
|
||||
void AcknowledgeEmergency() { reply_.emergency = false; }
|
||||
|
||||
private:
|
||||
bool Initialize(std::string& error) {
|
||||
if (initialized_) return true;
|
||||
const char* session = std::getenv("XDG_SESSION_TYPE");
|
||||
const char* wayland = std::getenv("WAYLAND_DISPLAY");
|
||||
if ((session && std::strcmp(session, "wayland") == 0) ||
|
||||
(wayland && *wayland)) {
|
||||
error =
|
||||
"Linux privacy screen requires an X11 session; Wayland is "
|
||||
"unsupported";
|
||||
return false;
|
||||
}
|
||||
const char* address = std::getenv("DISPLAY");
|
||||
if (!address ||
|
||||
(*address != ':' && std::strncmp(address, "unix:", 5) != 0)) {
|
||||
error = "Linux privacy screen requires a local X11 display";
|
||||
return false;
|
||||
}
|
||||
if (!display_) display_ = XOpenDisplay(nullptr);
|
||||
if (!display_) {
|
||||
error = "Cannot open the local X11 display";
|
||||
return false;
|
||||
}
|
||||
int event = 0, code = 0, opcode = 0;
|
||||
int major = 1, minor = 3;
|
||||
if (XQueryExtension(display_, "XWAYLAND", &opcode, &event, &code)) {
|
||||
error = "XWayland cannot protect native Wayland displays";
|
||||
return false;
|
||||
}
|
||||
if (!XRRQueryExtension(display_, &event, &code) ||
|
||||
!XRRQueryVersion(display_, &major, &minor) || major < 1 ||
|
||||
(major == 1 && minor < 3)) {
|
||||
error = "Linux privacy screen requires RandR 1.3 display gamma control";
|
||||
return false;
|
||||
}
|
||||
major = 4;
|
||||
minor = 0;
|
||||
if (!XFixesQueryExtension(display_, &event, &code) ||
|
||||
!XFixesQueryVersion(display_, &major, &minor) || major < 4) {
|
||||
error = "Linux privacy screen requires XFixes 4 cursor hiding";
|
||||
return false;
|
||||
}
|
||||
major = 2;
|
||||
minor = 2;
|
||||
if (!XQueryExtension(display_, "XInputExtension", &xi_opcode_, &event,
|
||||
&code) ||
|
||||
XIQueryVersion(display_, &major, &minor) != Success || major < 2) {
|
||||
error = "Linux privacy screen requires XInput 2 local input monitoring";
|
||||
return false;
|
||||
}
|
||||
xtest_property_ = XInternAtom(display_, "XTEST Device", True);
|
||||
if (!xtest_property_ ||
|
||||
!XTestQueryExtension(display_, &event, &code, &major, &minor)) {
|
||||
error = "Cannot identify X11 remote input devices";
|
||||
return false;
|
||||
}
|
||||
selection_ = XInternAtom(display_, "_CROSSDESK_PRIVACY_GUARD", False);
|
||||
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
|
||||
XISetMask(bits.data(), XI_RawKeyPress);
|
||||
XISetMask(bits.data(), XI_RawKeyRelease);
|
||||
XIEventMask mask{XIAllDevices, static_cast<int>(bits.size()), bits.data()};
|
||||
x_error = 0;
|
||||
for (int i = 0; i < ScreenCount(display_); ++i)
|
||||
XISelectEvents(display_, RootWindow(display_, i), &mask, 1);
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
error = "Cannot monitor the local privacy recovery shortcut";
|
||||
return false;
|
||||
}
|
||||
initialized_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadOutputs(std::vector<Output>& outputs, bool save,
|
||||
std::string& error) {
|
||||
outputs.clear();
|
||||
x_error = 0;
|
||||
for (int screen = 0; screen < ScreenCount(display_); ++screen) {
|
||||
const Window root = RootWindow(display_, screen);
|
||||
Resources resources(XRRGetScreenResourcesCurrent(display_, root),
|
||||
XRRFreeScreenResources);
|
||||
if (!resources) {
|
||||
error = "Cannot enumerate Linux display outputs";
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < resources->ncrtc; ++i) {
|
||||
CrtcInfo info(
|
||||
XRRGetCrtcInfo(display_, resources.get(), resources->crtcs[i]),
|
||||
XRRFreeCrtcInfo);
|
||||
if (!info) {
|
||||
error = "Cannot inspect Linux display controller";
|
||||
return false;
|
||||
}
|
||||
if (!info->mode || !info->noutput) continue;
|
||||
Output output;
|
||||
output.root = root;
|
||||
output.crtc = resources->crtcs[i];
|
||||
output.mode = info->mode;
|
||||
output.x = info->x;
|
||||
output.y = info->y;
|
||||
output.width = info->width;
|
||||
output.height = info->height;
|
||||
output.rotation = info->rotation;
|
||||
output.connectors.assign(info->outputs, info->outputs + info->noutput);
|
||||
std::sort(output.connectors.begin(), output.connectors.end());
|
||||
if (save) {
|
||||
output.saved.reset(XRRGetCrtcGamma(display_, output.crtc));
|
||||
if (!output.saved || output.saved->size <= 0 ||
|
||||
IsBlack(output.saved.get())) {
|
||||
error =
|
||||
"Display gamma control is unavailable or the display is "
|
||||
"already blank";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
outputs.push_back(std::move(output));
|
||||
}
|
||||
}
|
||||
XSync(display_, False);
|
||||
if (x_error || outputs.empty()) {
|
||||
error = "No controllable Linux display outputs";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadDevices(std::vector<Device>& devices, std::string& error) {
|
||||
devices.clear();
|
||||
x_error = 0;
|
||||
int count = 0;
|
||||
XIDeviceInfo* info = XIQueryDevice(display_, XIAllDevices, &count);
|
||||
if (!info) {
|
||||
error = "Cannot enumerate local X11 input devices";
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const auto& d = info[i];
|
||||
if (!d.enabled || d.use == XIMasterPointer || d.use == XIMasterKeyboard)
|
||||
continue;
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long items = 0, remaining = 0;
|
||||
unsigned char* value = nullptr;
|
||||
const int result =
|
||||
XIGetProperty(display_, d.deviceid, xtest_property_, 0, 1, False,
|
||||
XA_INTEGER, &type, &format, &items, &remaining, &value);
|
||||
const bool injected = result == Success && type == XA_INTEGER &&
|
||||
format == 8 && items == 1 && value && value[0] == 1;
|
||||
if (value) XFree(value);
|
||||
if (injected) continue;
|
||||
Device device;
|
||||
device.id = d.deviceid;
|
||||
device.use = d.use;
|
||||
device.attachment = d.attachment;
|
||||
device.name = d.name ? d.name : "";
|
||||
for (int c = 0; c < d.num_classes; ++c) {
|
||||
device.keyboard |= d.classes[c]->type == XIKeyClass;
|
||||
device.touch |= d.classes[c]->type == XITouchClass;
|
||||
}
|
||||
devices.push_back(std::move(device));
|
||||
}
|
||||
XIFreeDeviceInfo(info);
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
error = "Cannot verify local X11 input devices";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Acquire(std::string& error) {
|
||||
// A second instance must not save the first instance's black ramps as its
|
||||
// originals. Selection ownership and creation are atomic on this server.
|
||||
XGrabServer(display_);
|
||||
if (XGetSelectionOwner(display_, selection_) == None) {
|
||||
owner_ = XCreateSimpleWindow(display_, DefaultRootWindow(display_), 0, 0,
|
||||
1, 1, 0, 0, 0);
|
||||
XSetSelectionOwner(display_, selection_, owner_, CurrentTime);
|
||||
}
|
||||
XUngrabServer(display_);
|
||||
XSync(display_, False);
|
||||
if (!owner_ || XGetSelectionOwner(display_, selection_) != owner_) {
|
||||
error = "Another CrossDesk instance owns Linux privacy protection";
|
||||
return false;
|
||||
}
|
||||
reply_.recovered = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ProtectInput(bool block_input, std::string& error) {
|
||||
blocking_ = block_input;
|
||||
if (!block_input) return true;
|
||||
for (const auto& device : devices_) {
|
||||
if (device.touch) {
|
||||
error = "Touchscreen privacy input blocking is unavailable";
|
||||
return false;
|
||||
}
|
||||
if (device.keyboard) {
|
||||
// Preserve held local modifiers for the emergency chord independently
|
||||
// of the releases sent to the master keyboard below.
|
||||
XDevice* keyboard = XOpenDevice(display_, device.id);
|
||||
XDeviceState* state =
|
||||
keyboard ? XQueryDeviceState(display_, keyboard) : nullptr;
|
||||
if (!state) {
|
||||
if (keyboard) XCloseDevice(display_, keyboard);
|
||||
error = "Cannot inspect held local keyboard input";
|
||||
return false;
|
||||
}
|
||||
XInputClass* input = state->data;
|
||||
for (int i = 0; i < state->num_classes; ++i) {
|
||||
if (input->c_class == KeyClass) {
|
||||
const auto* keys = reinterpret_cast<XKeyState*>(input);
|
||||
for (int key = 0; key < 256; ++key)
|
||||
keys_[device.id][key] = keys->keys[key / 8] & (1 << (key % 8));
|
||||
}
|
||||
input = reinterpret_cast<XInputClass*>(
|
||||
reinterpret_cast<char*>(input) + input->length);
|
||||
}
|
||||
XFreeDeviceState(state);
|
||||
XCloseDevice(display_, keyboard);
|
||||
}
|
||||
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
|
||||
for (int type : {XI_KeyPress, XI_KeyRelease, XI_ButtonPress,
|
||||
XI_ButtonRelease, XI_Motion})
|
||||
XISetMask(bits.data(), type);
|
||||
XIEventMask mask{device.id, static_cast<int>(bits.size()), bits.data()};
|
||||
const int result = XIGrabDevice(
|
||||
display_, device.id, DefaultRootWindow(display_), CurrentTime, None,
|
||||
GrabModeAsync, GrabModeAsync, False, &mask);
|
||||
if (result != GrabSuccess) {
|
||||
error = "Cannot grab every local X11 input device";
|
||||
return false;
|
||||
}
|
||||
grabbed_.push_back(device.id);
|
||||
}
|
||||
return ReleaseHeldInput(error);
|
||||
}
|
||||
|
||||
bool ReleaseHeldInput(std::string& error) {
|
||||
// Slave grabs float physical devices, but their previous presses can
|
||||
// remain on the master. Release them after all grabs are installed so a
|
||||
// held local Ctrl/button cannot alter the remote controller's next input.
|
||||
int original_pointer = 0, count = 0;
|
||||
XIGetClientPointer(display_, None, &original_pointer);
|
||||
XIDeviceInfo* masters = XIQueryDevice(display_, XIAllMasterDevices, &count);
|
||||
if (!masters) {
|
||||
error = "Cannot inspect held X11 input";
|
||||
return false;
|
||||
}
|
||||
x_error = 0;
|
||||
bool ok = true;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (masters[i].use != XIMasterPointer || !masters[i].enabled) continue;
|
||||
XISetClientPointer(display_, None, masters[i].deviceid);
|
||||
char keys[32]{};
|
||||
XQueryKeymap(display_, keys);
|
||||
for (int key = 8; key < 256; ++key)
|
||||
if (keys[key / 8] & (1 << (key % 8)))
|
||||
ok &= XTestFakeKeyEvent(display_, key, False, CurrentTime) != 0;
|
||||
Window root = None, child = None;
|
||||
double root_x = 0, root_y = 0, win_x = 0, win_y = 0;
|
||||
XIButtonState buttons{};
|
||||
XIModifierState modifiers{};
|
||||
XIGroupState group{};
|
||||
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
|
||||
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
|
||||
&modifiers, &group);
|
||||
for (int button = 1; button < buttons.mask_len * 8; ++button)
|
||||
if (XIMaskIsSet(buttons.mask, button))
|
||||
ok &= XTestFakeButtonEvent(display_, button, False, CurrentTime) != 0;
|
||||
if (buttons.mask) XFree(buttons.mask);
|
||||
XSync(display_, False);
|
||||
XQueryKeymap(display_, keys);
|
||||
for (char pressed : keys) ok &= pressed == 0;
|
||||
buttons = {};
|
||||
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
|
||||
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
|
||||
&modifiers, &group);
|
||||
for (int byte = 0; byte < buttons.mask_len; ++byte)
|
||||
ok &= buttons.mask[byte] == 0;
|
||||
if (buttons.mask) XFree(buttons.mask);
|
||||
}
|
||||
XIFreeDeviceInfo(masters);
|
||||
if (original_pointer) XISetClientPointer(display_, None, original_pointer);
|
||||
XSync(display_, False);
|
||||
if (!ok || x_error) {
|
||||
error = "Cannot release held X11 input for privacy";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Key(int source, int keycode, bool down) {
|
||||
if (keycode < 0 || keycode >= 256 ||
|
||||
std::none_of(devices_.begin(), devices_.end(), [source](const auto& d) {
|
||||
return d.id == source && d.keyboard;
|
||||
}))
|
||||
return;
|
||||
keys_[source][keycode] = down;
|
||||
bool ctrl = false, alt = false, shift = false;
|
||||
for (const auto& [id, keys] : keys_) {
|
||||
for (int code = 0; code < 256; ++code) {
|
||||
if (!keys[code]) continue;
|
||||
const KeySym symbol = XkbKeycodeToKeysym(display_, code, 0, 0);
|
||||
ctrl |= symbol == XK_Control_L || symbol == XK_Control_R;
|
||||
alt |= symbol == XK_Alt_L || symbol == XK_Alt_R;
|
||||
shift |= symbol == XK_Shift_L || symbol == XK_Shift_R;
|
||||
}
|
||||
}
|
||||
if (down && ctrl && alt && shift &&
|
||||
XkbKeycodeToKeysym(display_, keycode, 0, 0) == XK_F12) {
|
||||
reply_.emergency = true;
|
||||
Recover();
|
||||
}
|
||||
}
|
||||
|
||||
void Check() {
|
||||
checked_ = Clock::now();
|
||||
std::string error;
|
||||
std::vector<Output> current;
|
||||
std::vector<Device> devices;
|
||||
if (!ReadOutputs(current, false, error) || !ReadDevices(devices, error)) {
|
||||
Fail(error);
|
||||
return;
|
||||
}
|
||||
if (current.size() != outputs_.size() ||
|
||||
!std::equal(
|
||||
current.begin(), current.end(), outputs_.begin(),
|
||||
[](const auto& a, const auto& b) { return a.SameLayout(b); })) {
|
||||
Fail("Display topology changed; Linux privacy has been turned off");
|
||||
return;
|
||||
}
|
||||
if (devices != devices_) {
|
||||
Fail("Local input devices changed; Linux privacy has been turned off");
|
||||
return;
|
||||
}
|
||||
x_error = 0;
|
||||
for (const auto& output : outputs_) {
|
||||
Gamma ramp(XRRGetCrtcGamma(display_, output.crtc), XRRFreeGamma);
|
||||
if (!IsBlack(ramp.get())) {
|
||||
Fail(
|
||||
"Display gamma protection was changed; Linux privacy is "
|
||||
"unavailable");
|
||||
return;
|
||||
}
|
||||
}
|
||||
XSync(display_, False);
|
||||
if (x_error || XGetSelectionOwner(display_, selection_) != owner_)
|
||||
Fail("Cannot verify Linux privacy protection");
|
||||
}
|
||||
|
||||
Display* display_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
bool blocking_ = false;
|
||||
int xi_opcode_ = 0;
|
||||
Atom xtest_property_ = None, selection_ = None;
|
||||
Window owner_ = 0;
|
||||
std::vector<Output> outputs_;
|
||||
std::vector<Device> devices_;
|
||||
std::vector<int> grabbed_;
|
||||
std::vector<Window> hidden_roots_;
|
||||
std::map<int, std::array<bool, 256>> keys_;
|
||||
LinuxPrivacyReply reply_;
|
||||
Clock::time_point checked_{};
|
||||
};
|
||||
} // namespace
|
||||
|
||||
int RunLinuxPrivacyGuard() {
|
||||
const int socket = kLinuxPrivacyGuardSocket;
|
||||
int type = 0;
|
||||
socklen_t length = sizeof(type);
|
||||
if (getsockopt(socket, SOL_SOCKET, SO_TYPE, &type, &length) != 0 ||
|
||||
type != SOCK_SEQPACKET)
|
||||
return 1;
|
||||
fcntl(socket, F_SETFD, FD_CLOEXEC);
|
||||
fcntl(socket, F_SETFL, O_NONBLOCK);
|
||||
struct sigaction action {};
|
||||
action.sa_handler = StopGuard;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(SIGTERM, &action, nullptr);
|
||||
sigaction(SIGINT, &action, nullptr);
|
||||
sigaction(SIGHUP, &action, nullptr);
|
||||
XSetErrorHandler(OnXError);
|
||||
NativePrivacy privacy;
|
||||
auto heartbeat = Clock::now();
|
||||
bool connected = true;
|
||||
bool dirty = false;
|
||||
while (connected && !stopping) {
|
||||
pollfd fds[] = {{socket, POLLIN, 0}, {privacy.Connection(), POLLIN, 0}};
|
||||
poll(fds, 2, privacy.Reply().active ? 25 : 250);
|
||||
if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) break;
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
LinuxPrivacyCommand command;
|
||||
const auto bytes = recv(socket, &command, sizeof(command), 0);
|
||||
if (bytes < 0 &&
|
||||
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
|
||||
break;
|
||||
if (bytes != sizeof(command)) {
|
||||
connected = false;
|
||||
break;
|
||||
}
|
||||
heartbeat = Clock::now();
|
||||
switch (command) {
|
||||
case LinuxPrivacyCommand::query:
|
||||
if (!privacy.Reply().active) privacy.Query();
|
||||
break;
|
||||
case LinuxPrivacyCommand::enable:
|
||||
privacy.Enable(false);
|
||||
break;
|
||||
case LinuxPrivacyCommand::enable_blocking:
|
||||
privacy.Enable(true);
|
||||
break;
|
||||
case LinuxPrivacyCommand::recover:
|
||||
privacy.Recover();
|
||||
break;
|
||||
case LinuxPrivacyCommand::heartbeat:
|
||||
break;
|
||||
default:
|
||||
connected = false;
|
||||
break;
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
if (!connected || stopping) break;
|
||||
const auto before = privacy.Reply();
|
||||
privacy.Poll();
|
||||
if (privacy.Reply().active &&
|
||||
Clock::now() - heartbeat > std::chrono::seconds(3))
|
||||
privacy.Fail(
|
||||
"Privacy controller stopped responding; local desktop and input "
|
||||
"restored");
|
||||
const auto& after = privacy.Reply();
|
||||
dirty |= before.active != after.active ||
|
||||
before.recovered != after.recovered ||
|
||||
before.emergency != after.emergency ||
|
||||
std::strcmp(before.failure, after.failure) != 0;
|
||||
if (dirty) {
|
||||
const auto& reply = privacy.Reply();
|
||||
if (send(socket, &reply, sizeof(reply), MSG_NOSIGNAL) == sizeof(reply)) {
|
||||
dirty = false;
|
||||
privacy.AcknowledgeEmergency();
|
||||
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Keep trying if the X server temporarily rejected restoration, even after
|
||||
// the parent exited. Disconnecting releases XI grabs and cursor hiding too.
|
||||
do {
|
||||
privacy.Recover();
|
||||
if (!privacy.Reply().recovered) poll(nullptr, 0, 250);
|
||||
} while (!privacy.Reply().recovered);
|
||||
const auto& reply = privacy.Reply();
|
||||
const auto ignored = send(socket, &reply, sizeof(reply), MSG_NOSIGNAL);
|
||||
(void)ignored;
|
||||
close(socket);
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_GUARD_H_
|
||||
#define _PRIVACY_GUARD_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
inline constexpr char kLinuxPrivacyGuardArgument[] = "--linux-privacy-guard";
|
||||
inline constexpr int kLinuxPrivacyGuardSocket = 3;
|
||||
|
||||
// Private protocol over an inherited SOCK_SEQPACKET socket. No listening
|
||||
// socket, shell, elevated privileges or separately installed helper is needed.
|
||||
enum class LinuxPrivacyCommand : uint8_t {
|
||||
query,
|
||||
enable,
|
||||
enable_blocking,
|
||||
recover,
|
||||
heartbeat,
|
||||
};
|
||||
|
||||
struct LinuxPrivacyReply {
|
||||
uint64_t generation = 0;
|
||||
bool supported = false;
|
||||
bool input_supported = false;
|
||||
bool active = false;
|
||||
bool recovered = true;
|
||||
bool emergency = false;
|
||||
char capability_reason[256]{};
|
||||
char failure[256]{};
|
||||
};
|
||||
|
||||
int RunLinuxPrivacyGuard();
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "platform.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "rd_log.h"
|
||||
#if defined(CROSSDESK_HAS_DRM) && CROSSDESK_HAS_DRM
|
||||
#include "screen_capturer_drm.h"
|
||||
@@ -64,6 +65,13 @@ int ScreenCapturerLinux::Init(const int fps, cb_desktop_data cb) {
|
||||
return;
|
||||
}
|
||||
invalid_stream_id_logged_.store(false, std::memory_order_relaxed);
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
if (privacy_ && !capture_paused_ && !capture_announced_) {
|
||||
capture_announced_ = true;
|
||||
privacy_->CaptureChanged(true);
|
||||
}
|
||||
}
|
||||
if (callback_orig_) {
|
||||
callback_orig_(data, size, width, height, mapped_stream_id.c_str(),
|
||||
native_frame);
|
||||
@@ -145,6 +153,7 @@ int ScreenCapturerLinux::Init(const int fps, cb_desktop_data cb) {
|
||||
}
|
||||
|
||||
int ScreenCapturerLinux::Destroy() {
|
||||
Stop();
|
||||
if (impl_) {
|
||||
impl_->Destroy();
|
||||
impl_.reset();
|
||||
@@ -169,6 +178,11 @@ int ScreenCapturerLinux::Start(bool show_cursor) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_paused_ = false;
|
||||
}
|
||||
|
||||
// X11 output names, DRM connectors and Wayland stream handles may all change
|
||||
// after an HDMI hotplug, so rebuild the current backend before each session.
|
||||
const int refresh_ret = RefreshCurrentBackend();
|
||||
@@ -226,6 +240,11 @@ int ScreenCapturerLinux::Stop() {
|
||||
return 0;
|
||||
}
|
||||
const int ret = impl_->Stop();
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_announced_ = false;
|
||||
if (privacy_) privacy_->CaptureChanged(false);
|
||||
}
|
||||
UpdateAliasesFromBackend(impl_.get());
|
||||
return ret;
|
||||
}
|
||||
@@ -234,13 +253,25 @@ int ScreenCapturerLinux::Pause(int monitor_index) {
|
||||
if (!impl_) {
|
||||
return -1;
|
||||
}
|
||||
return impl_->Pause(monitor_index);
|
||||
const int ret = impl_->Pause(monitor_index);
|
||||
{
|
||||
// An in-flight frame must not announce running again after a pause.
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_paused_ = true;
|
||||
capture_announced_ = false;
|
||||
if (privacy_) privacy_->CaptureChanged(false);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
int ScreenCapturerLinux::Resume(int monitor_index) {
|
||||
if (!impl_) {
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_paused_ = false;
|
||||
}
|
||||
return impl_->Resume(monitor_index);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class ScreenCapturerLinux : public ScreenCapturer {
|
||||
|
||||
public:
|
||||
int Init(const int fps, cb_desktop_data cb) override;
|
||||
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
|
||||
int Destroy() override;
|
||||
int Start(bool show_cursor) override;
|
||||
int Stop() override;
|
||||
@@ -53,6 +54,10 @@ class ScreenCapturerLinux : public ScreenCapturer {
|
||||
|
||||
private:
|
||||
std::unique_ptr<ScreenCapturer> impl_;
|
||||
PrivacyController* privacy_ = nullptr;
|
||||
std::mutex privacy_mutex_;
|
||||
bool capture_announced_ = false;
|
||||
bool capture_paused_ = false;
|
||||
BackendType backend_ = BackendType::kNone;
|
||||
int fps_ = 60;
|
||||
cb_desktop_data callback_;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/cursor_position.h"
|
||||
#include "../../privacy/privacy_cursor_mac.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
@@ -94,7 +95,7 @@ void AddKnownCursor(std::vector<KnownCursor>* cursors, NSCursor* cursor,
|
||||
|
||||
std::vector<KnownCursor> BuildKnownCursors() {
|
||||
std::vector<KnownCursor> cursors;
|
||||
cursors.reserve(16);
|
||||
cursors.reserve(40);
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
@@ -128,6 +129,55 @@ std::vector<KnownCursor> BuildKnownCursors() {
|
||||
RemoteCursorShape::w_resize);
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 150000
|
||||
if (@available(macOS 15.0, *)) {
|
||||
// Window-frame resize artwork differs from the legacy divider cursors
|
||||
// above. Include every edge/corner and both one-way variants. Inward
|
||||
// arrows point toward the frame's center; outward arrows point away.
|
||||
struct FrameResizeCursorMapping {
|
||||
NSCursorFrameResizePosition position;
|
||||
RemoteCursorShape bidirectional;
|
||||
RemoteCursorShape inward;
|
||||
RemoteCursorShape outward;
|
||||
};
|
||||
constexpr FrameResizeCursorMapping mappings[] = {
|
||||
{NSCursorFrameResizePositionTop, RemoteCursorShape::ns_resize,
|
||||
RemoteCursorShape::s_resize, RemoteCursorShape::n_resize},
|
||||
{NSCursorFrameResizePositionLeft, RemoteCursorShape::ew_resize,
|
||||
RemoteCursorShape::e_resize, RemoteCursorShape::w_resize},
|
||||
{NSCursorFrameResizePositionBottom, RemoteCursorShape::ns_resize,
|
||||
RemoteCursorShape::n_resize, RemoteCursorShape::s_resize},
|
||||
{NSCursorFrameResizePositionRight, RemoteCursorShape::ew_resize,
|
||||
RemoteCursorShape::w_resize, RemoteCursorShape::e_resize},
|
||||
{NSCursorFrameResizePositionTopLeft, RemoteCursorShape::nwse_resize,
|
||||
RemoteCursorShape::se_resize, RemoteCursorShape::nw_resize},
|
||||
{NSCursorFrameResizePositionTopRight, RemoteCursorShape::nesw_resize,
|
||||
RemoteCursorShape::sw_resize, RemoteCursorShape::ne_resize},
|
||||
{NSCursorFrameResizePositionBottomLeft, RemoteCursorShape::nesw_resize,
|
||||
RemoteCursorShape::ne_resize, RemoteCursorShape::sw_resize},
|
||||
{NSCursorFrameResizePositionBottomRight, RemoteCursorShape::nwse_resize,
|
||||
RemoteCursorShape::nw_resize, RemoteCursorShape::se_resize},
|
||||
};
|
||||
for (const FrameResizeCursorMapping& mapping : mappings) {
|
||||
AddKnownCursor(
|
||||
&cursors,
|
||||
[NSCursor frameResizeCursorFromPosition:mapping.position
|
||||
inDirections:NSCursorFrameResizeDirectionsAll],
|
||||
mapping.bidirectional);
|
||||
AddKnownCursor(
|
||||
&cursors,
|
||||
[NSCursor frameResizeCursorFromPosition:mapping.position
|
||||
inDirections:NSCursorFrameResizeDirectionsInward],
|
||||
mapping.inward);
|
||||
AddKnownCursor(
|
||||
&cursors,
|
||||
[NSCursor frameResizeCursorFromPosition:mapping.position
|
||||
inDirections:NSCursorFrameResizeDirectionsOutward],
|
||||
mapping.outward);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return cursors;
|
||||
}
|
||||
|
||||
@@ -161,7 +211,9 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
NSCursor* cursor = NSCursor.currentSystemCursor;
|
||||
const bool visible = CGCursorIsVisible();
|
||||
const bool privacy_hidden = IsMacPrivacyCursorHidden();
|
||||
const bool visible = CGCursorIsVisible() || privacy_hidden;
|
||||
if (!cursor && privacy_hidden) cursor = NSCursor.arrowCursor;
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
state->seq = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <unordered_map>
|
||||
|
||||
#include "keyboard_converter.h"
|
||||
#include "../mac_injected_event.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
@@ -11,7 +12,6 @@ namespace crossdesk {
|
||||
static OnKeyAction g_on_key_action = nullptr;
|
||||
static void* g_user_ptr = nullptr;
|
||||
static std::unordered_map<int, int> g_unmapped_keycode_to_vk;
|
||||
constexpr int64_t kCrossDeskInjectedKeyboardEvent = 0x43524f5353444553;
|
||||
|
||||
static int VkCodeFromUnicode(UniChar ch) {
|
||||
if (ch >= 'a' && ch <= 'z') {
|
||||
@@ -106,7 +106,7 @@ CGEventRef eventCallback(CGEventTapProxy proxy, CGEventType type,
|
||||
CGEventRef event, void* userInfo) {
|
||||
(void)proxy;
|
||||
if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) ==
|
||||
kCrossDeskInjectedKeyboardEvent) {
|
||||
kCrossDeskInjectedEvent) {
|
||||
return event;
|
||||
}
|
||||
if (!g_on_key_action) {
|
||||
@@ -309,7 +309,7 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
|
||||
|
||||
CGEventSetFlags(event, ToCGEventFlags(injected_flags));
|
||||
CGEventSetIntegerValueField(event, kCGEventSourceUserData,
|
||||
kCrossDeskInjectedKeyboardEvent);
|
||||
kCrossDeskInjectedEvent);
|
||||
CGEventPost(kCGHIDEventTap, event);
|
||||
CFRelease(event);
|
||||
|
||||
@@ -323,7 +323,7 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
|
||||
return -1;
|
||||
}
|
||||
CGEventSetIntegerValueField(fn_release_event, kCGEventSourceUserData,
|
||||
kCrossDeskInjectedKeyboardEvent);
|
||||
kCrossDeskInjectedEvent);
|
||||
CGEventPost(kCGHIDEventTap, fn_release_event);
|
||||
CFRelease(fn_release_event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _MAC_INJECTED_EVENT_H_
|
||||
#define _MAC_INJECTED_EVENT_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
// Shared by the keyboard/mouse injectors and the privacy input tap.
|
||||
inline constexpr int64_t kCrossDeskInjectedEvent = 0x43524f5353444553;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "mouse_controller.h"
|
||||
#include "../mac_injected_event.h"
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
@@ -210,6 +211,8 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
}
|
||||
|
||||
if (mouse_event) {
|
||||
CGEventSetIntegerValueField(mouse_event, kCGEventSourceUserData,
|
||||
kCrossDeskInjectedEvent);
|
||||
CGEventPost(kCGHIDEventTap, mouse_event);
|
||||
CFRelease(mouse_event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <cstdlib>
|
||||
#include <libproc.h>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "privacy_backend.h"
|
||||
#include "privacy_capture_state.h"
|
||||
#include "privacy_cover_view.h"
|
||||
#include "privacy_cursor_mac.h"
|
||||
#include "rd_log.h"
|
||||
#include "../input/mac_injected_event.h"
|
||||
|
||||
// These windows never take keyboard focus. Remote clicks pass through them;
|
||||
// physical input is intercepted independently on the controller's run loop.
|
||||
@interface CrossDeskPrivacyPanel : NSPanel
|
||||
@end
|
||||
@implementation CrossDeskPrivacyPanel
|
||||
- (BOOL)canBecomeKeyWindow { return NO; }
|
||||
- (BOOL)canBecomeMainWindow { return NO; }
|
||||
@end
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
std::mutex tasks_mutex;
|
||||
std::deque<std::function<void()>> tasks;
|
||||
std::mutex capture_mutex;
|
||||
MacPrivacyCaptureState capture_state;
|
||||
|
||||
void OnMain(std::function<void()> task) {
|
||||
{
|
||||
std::lock_guard lock(tasks_mutex);
|
||||
tasks.push_back(std::move(task));
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ FlushMacPrivacyTasks(); });
|
||||
}
|
||||
|
||||
void PublishCovers(std::vector<uint32_t> windows, uint32_t cursor_window = 0) {
|
||||
{
|
||||
std::lock_guard lock(capture_mutex);
|
||||
capture_state.excluded_windows = std::move(windows);
|
||||
capture_state.cursor_window = cursor_window;
|
||||
++capture_state.revision;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[NSNotificationCenter.defaultCenter postNotificationName:@"CrossDeskPrivacyCoversChanged"
|
||||
object:nil];
|
||||
});
|
||||
}
|
||||
|
||||
struct Monitor {
|
||||
CGDirectDisplayID id = 0;
|
||||
CGRect bounds{}; // CoreGraphics desktop coordinates (points).
|
||||
NSRect frame{}; // AppKit desktop coordinates (bottom left).
|
||||
CrossDeskPrivacyPanel* __strong cover = nil;
|
||||
};
|
||||
struct WindowState {
|
||||
std::mutex mutex;
|
||||
std::vector<Monitor> monitors;
|
||||
bool recovered = true;
|
||||
bool recovering = false;
|
||||
bool covers_shown = false;
|
||||
bool check_queued = false;
|
||||
MacPrivacyCursor cursor;
|
||||
std::string failure;
|
||||
Clock::time_point checked = Clock::now();
|
||||
};
|
||||
|
||||
CrossDeskPrivacyPanel* MakePanel(NSRect frame, NSInteger level) {
|
||||
auto* panel = [[CrossDeskPrivacyPanel alloc]
|
||||
initWithContentRect:frame
|
||||
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
|
||||
backing:NSBackingStoreBuffered defer:NO];
|
||||
panel.releasedWhenClosed = NO;
|
||||
panel.hidesOnDeactivate = NO;
|
||||
panel.opaque = YES;
|
||||
panel.backgroundColor = NSColor.blackColor;
|
||||
panel.hasShadow = NO;
|
||||
panel.ignoresMouseEvents = YES;
|
||||
panel.level = level;
|
||||
panel.animationBehavior = NSWindowAnimationBehaviorNone;
|
||||
panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary |
|
||||
NSWindowCollectionBehaviorStationary |
|
||||
NSWindowCollectionBehaviorIgnoresCycle;
|
||||
// Exclusion is explicit in ScreenCaptureKit. SharingNone alone does not
|
||||
// exclude windows on recent macOS releases, and can hide them from discovery.
|
||||
panel.sharingType = NSWindowSharingReadOnly;
|
||||
return panel;
|
||||
}
|
||||
|
||||
bool IsSystemCursorPlane(NSDictionary* info, CGRect bounds) {
|
||||
const auto layer = [info[(__bridge NSString*)kCGWindowLayer] intValue];
|
||||
// WindowServer's cursor plane also carries small recording indicators.
|
||||
// Validate both its geometry and executable identity before ignoring it.
|
||||
if (layer != CGWindowLevelForKey(kCGCursorWindowLevelKey) ||
|
||||
bounds.size.width <= 0 || bounds.size.height <= 0 ||
|
||||
bounds.size.width > 512 || bounds.size.height > 512)
|
||||
return false;
|
||||
char executable[PROC_PIDPATHINFO_MAXSIZE]{};
|
||||
const auto pid = [info[(__bridge NSString*)kCGWindowOwnerPID] intValue];
|
||||
static const std::string system_executable = [] {
|
||||
char resolved[PROC_PIDPATHINFO_MAXSIZE]{};
|
||||
return realpath("/System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer",
|
||||
resolved) ? std::string(resolved) : std::string();
|
||||
}();
|
||||
return proc_pidpath(pid, executable, sizeof(executable)) > 0 &&
|
||||
!system_executable.empty() && system_executable == executable;
|
||||
}
|
||||
|
||||
std::string CoverageFailure(const std::vector<Monitor>& monitors) {
|
||||
uint32_t count = 0;
|
||||
if (CGGetActiveDisplayList(0, nullptr, &count) != kCGErrorSuccess || !count)
|
||||
return "No active macOS desktop; privacy screen unavailable";
|
||||
std::vector<CGDirectDisplayID> displays(count);
|
||||
if (CGGetActiveDisplayList(count, displays.data(), &count) != kCGErrorSuccess ||
|
||||
count != monitors.size())
|
||||
return "Display topology changed; turn privacy off to recover";
|
||||
for (const auto& m : monitors) {
|
||||
if (std::find(displays.begin(), displays.end(), m.id) == displays.end() ||
|
||||
!CGRectEqualToRect(CGDisplayBounds(m.id), m.bounds) ||
|
||||
!m.cover.visible || !m.cover.isOnActiveSpace ||
|
||||
!NSEqualRects(m.cover.frame, m.frame) || m.cover.alphaValue != 1.0)
|
||||
return "Privacy cover or display geometry changed; privacy screen unavailable";
|
||||
}
|
||||
NSDictionary* session = CFBridgingRelease(CGSessionCopyCurrentDictionary());
|
||||
if (!session || ![session[(__bridge NSString*)kCGSessionOnConsoleKey] boolValue])
|
||||
return "macOS session is not on console; privacy screen unavailable";
|
||||
|
||||
NSArray* windows = CFBridgingRelease(CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
|
||||
kCGNullWindowID));
|
||||
if (!windows) return "Unable to verify macOS privacy window order";
|
||||
for (const auto& m : monitors) {
|
||||
bool found = false;
|
||||
for (NSDictionary* info in windows) {
|
||||
const auto window = [info[(__bridge NSString*)kCGWindowNumber] unsignedIntValue];
|
||||
if (window == m.cover.windowNumber) { found = true; break; }
|
||||
if ([info[(__bridge NSString*)kCGWindowAlpha] doubleValue] <= 0) continue;
|
||||
CGRect bounds{};
|
||||
if (!CGRectMakeWithDictionaryRepresentation(
|
||||
(__bridge CFDictionaryRef)info[(__bridge NSString*)kCGWindowBounds], &bounds))
|
||||
return "Unable to verify a window above the privacy cover";
|
||||
if (CGRectIntersectsRect(bounds, m.bounds)) {
|
||||
if (IsSystemCursorPlane(info, bounds)) continue;
|
||||
LOG_WARN("macOS privacy overlap: window={}, pid={}, layer={}, alpha={}, "
|
||||
"bounds=({}, {}, {}, {}), cover={}, cover_level={}",
|
||||
window,
|
||||
[info[(__bridge NSString*)kCGWindowOwnerPID] intValue],
|
||||
[info[(__bridge NSString*)kCGWindowLayer] intValue],
|
||||
[info[(__bridge NSString*)kCGWindowAlpha] doubleValue],
|
||||
bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height,
|
||||
m.cover.windowNumber, m.cover.level);
|
||||
return "A system or application window overlaps the privacy cover; privacy screen unavailable";
|
||||
}
|
||||
}
|
||||
if (!found) return "Privacy cover disappeared; privacy screen unavailable";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
class MacPrivacyBackend final : public PrivacyBackend {
|
||||
public:
|
||||
MacPrivacyBackend() : state_(std::make_shared<WindowState>()) {
|
||||
loop_ = CFRunLoopGetCurrent();
|
||||
CFRetain(loop_);
|
||||
CFRunLoopSourceContext context{};
|
||||
wake_source_ = CFRunLoopSourceCreate(nullptr, 0, &context);
|
||||
CFRunLoopAddSource(loop_, wake_source_, kCFRunLoopDefaultMode);
|
||||
}
|
||||
~MacPrivacyBackend() override {
|
||||
Recover();
|
||||
CFRunLoopRemoveSource(loop_, wake_source_, kCFRunLoopDefaultMode);
|
||||
CFRelease(wake_source_);
|
||||
CFRelease(loop_);
|
||||
}
|
||||
PrivacyCapabilities Query() override {
|
||||
if (@available(macOS 14.0, *)) {
|
||||
if (!CGPreflightScreenCaptureAccess())
|
||||
return {false, false, "Grant Screen Recording permission to use privacy screen"};
|
||||
if (!AXIsProcessTrusted())
|
||||
return {false, false, "Grant Accessibility permission to protect local input and enable local recovery"};
|
||||
if (!MacPrivacyCursor::Supported())
|
||||
return {false, false, "Background cursor control unavailable on this macOS version"};
|
||||
return {true, true, "macOS privacy screen available (ScreenCaptureKit)"};
|
||||
}
|
||||
return {false, false, "Privacy screen requires macOS 14 or newer"};
|
||||
}
|
||||
bool Enable(bool block_input, const PrivacyScreenText& text, std::string& error) override {
|
||||
const auto caps = Query();
|
||||
if (!caps.overlay) { error = caps.reason; return false; }
|
||||
block_input_ = block_input;
|
||||
emergency_ = false;
|
||||
tap_failed_ = false;
|
||||
CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp) |
|
||||
CGEventMaskBit(kCGEventFlagsChanged) | CGEventMaskBit(kCGEventLeftMouseDown) |
|
||||
CGEventMaskBit(kCGEventLeftMouseUp) | CGEventMaskBit(kCGEventRightMouseDown) |
|
||||
CGEventMaskBit(kCGEventRightMouseUp) | CGEventMaskBit(kCGEventMouseMoved) |
|
||||
CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) |
|
||||
CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) |
|
||||
CGEventMaskBit(kCGEventOtherMouseDragged) | CGEventMaskBit(kCGEventScrollWheel) |
|
||||
CGEventMaskBit(14); // NX_SYSDEFINED: media/brightness keys.
|
||||
tap_ = CGEventTapCreate(kCGHIDEventTap, kCGHeadInsertEventTap,
|
||||
kCGEventTapOptionDefault, mask, Input, this);
|
||||
if (!tap_) { error = "Cannot install macOS privacy input tap"; return false; }
|
||||
tap_source_ = CFMachPortCreateRunLoopSource(nullptr, tap_, 0);
|
||||
if (!tap_source_) { error = "Cannot create macOS privacy input run loop"; return false; }
|
||||
CFRunLoopAddSource(loop_, tap_source_, kCFRunLoopDefaultMode);
|
||||
CGEventTapEnable(tap_, true);
|
||||
{
|
||||
std::lock_guard lock(state_->mutex);
|
||||
state_->recovered = false;
|
||||
state_->failure.clear();
|
||||
state_->checked = Clock::now();
|
||||
}
|
||||
auto state = state_;
|
||||
OnMain([state, text] {
|
||||
@autoreleasepool {
|
||||
std::lock_guard lock(state->mutex);
|
||||
if (!state->cursor.Prepare()) {
|
||||
state->failure = "Cannot prepare the privacy cursor";
|
||||
return;
|
||||
}
|
||||
std::vector<uint32_t> covers;
|
||||
for (NSScreen* screen in NSScreen.screens) {
|
||||
Monitor m;
|
||||
m.id = [screen.deviceDescription[@"NSScreenNumber"] unsignedIntValue];
|
||||
m.bounds = CGDisplayBounds(m.id);
|
||||
m.frame = screen.frame;
|
||||
NSView* content = CreateMacPrivacyCoverView(
|
||||
NSMakeRect(0, 0, m.frame.size.width, m.frame.size.height),
|
||||
[NSString stringWithUTF8String:text.unlock_hint.c_str()]);
|
||||
if (!content) {
|
||||
state->failure = "Could not load the CrossDesk privacy screen logo";
|
||||
break;
|
||||
}
|
||||
m.cover = MakePanel(m.frame, CGWindowLevelForKey(kCGScreenSaverWindowLevelKey) + 1);
|
||||
m.cover.contentView = content;
|
||||
// Register transparent windows first. Make them opaque only after
|
||||
// SCK has acknowledged exclusion, avoiding a black remote frame.
|
||||
m.cover.alphaValue = 0;
|
||||
[m.cover orderFrontRegardless];
|
||||
covers.push_back(static_cast<uint32_t>(m.cover.windowNumber));
|
||||
state->monitors.push_back(std::move(m));
|
||||
}
|
||||
PublishCovers(std::move(covers), state->cursor.WindowId());
|
||||
if (state->monitors.empty()) state->failure = "No active macOS desktop";
|
||||
state->checked = Clock::now();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
void Recover() override {
|
||||
if (tap_) { CGEventTapEnable(tap_, false); CFMachPortInvalidate(tap_); }
|
||||
if (tap_source_) {
|
||||
CFRunLoopRemoveSource(loop_, tap_source_, kCFRunLoopDefaultMode);
|
||||
CFRelease(tap_source_); tap_source_ = nullptr;
|
||||
}
|
||||
if (tap_) { CFRelease(tap_); tap_ = nullptr; }
|
||||
{
|
||||
std::lock_guard lock(state_->mutex);
|
||||
if (state_->recovered || state_->recovering) return;
|
||||
state_->recovering = true;
|
||||
}
|
||||
auto state = state_;
|
||||
OnMain([state] {
|
||||
std::lock_guard lock(state->mutex);
|
||||
for (auto& m : state->monitors) {
|
||||
[m.cover orderOut:nil]; [m.cover close]; m.cover = nil;
|
||||
}
|
||||
state->monitors.clear();
|
||||
state->covers_shown = false;
|
||||
state->cursor.Restore();
|
||||
PublishCovers({});
|
||||
state->failure.clear();
|
||||
state->recovering = false;
|
||||
state->recovered = true;
|
||||
});
|
||||
}
|
||||
bool IsRecovered() const override {
|
||||
std::lock_guard lock(state_->mutex);
|
||||
return state_->recovered && !tap_;
|
||||
}
|
||||
bool RecoveryPending() const override {
|
||||
std::lock_guard lock(state_->mutex);
|
||||
return state_->recovering;
|
||||
}
|
||||
PrivacyHealth Poll() override {
|
||||
if (emergency_) { emergency_ = false; return {{}, true}; }
|
||||
if (tap_ && (tap_failed_ || !CGEventTapIsEnabled(tap_) || !AXIsProcessTrusted()))
|
||||
return {"Local input protection interrupted; privacy screen unavailable", false};
|
||||
auto state = state_;
|
||||
std::lock_guard lock(state->mutex);
|
||||
if (state->recovered || state->recovering) return {};
|
||||
if (!state->failure.empty()) return {state->failure, false};
|
||||
const auto age = Clock::now() - state->checked;
|
||||
if (age > std::chrono::seconds(1))
|
||||
return {"macOS privacy window checks stopped responding; privacy screen unavailable", false};
|
||||
if (!state->check_queued && age > std::chrono::milliseconds(100)) {
|
||||
state->check_queued = true;
|
||||
OnMain([state] {
|
||||
std::lock_guard lock(state->mutex);
|
||||
if (!state->recovered && !state->recovering && state->failure.empty()) {
|
||||
const auto capture = GetMacPrivacyCaptureState();
|
||||
if (!state->covers_shown && !state->monitors.empty() &&
|
||||
capture.revision == capture.applied_revision) {
|
||||
if (!state->cursor.Hide()) {
|
||||
state->failure = "Cannot hide the local privacy cursor";
|
||||
state->checked = Clock::now();
|
||||
state->check_queued = false;
|
||||
return;
|
||||
}
|
||||
for (auto& m : state->monitors) {
|
||||
m.cover.alphaValue = 1;
|
||||
[m.cover displayIfNeeded];
|
||||
[m.cover orderFrontRegardless];
|
||||
}
|
||||
state->covers_shown = true;
|
||||
}
|
||||
if (state->covers_shown) {
|
||||
state->failure = state->cursor.Healthy() ? CoverageFailure(state->monitors)
|
||||
: "Cannot keep the local privacy cursor hidden";
|
||||
}
|
||||
}
|
||||
state->checked = Clock::now();
|
||||
state->check_queued = false;
|
||||
});
|
||||
}
|
||||
const auto capture = GetMacPrivacyCaptureState();
|
||||
return {{}, false, state->covers_shown &&
|
||||
capture.revision == capture.applied_revision};
|
||||
}
|
||||
void WaitForEvents(unsigned timeout_ms) override {
|
||||
@autoreleasepool {
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout_ms / 1000.0, true);
|
||||
}
|
||||
}
|
||||
void Wake() override { CFRunLoopSourceSignal(wake_source_); CFRunLoopWakeUp(loop_); }
|
||||
private:
|
||||
static CGEventRef Input(CGEventTapProxy, CGEventType type, CGEventRef event, void* context) {
|
||||
auto* self = static_cast<MacPrivacyBackend*>(context);
|
||||
if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) {
|
||||
self->tap_failed_ = true;
|
||||
CGEventTapEnable(self->tap_, true); // Keep the local recovery chord usable.
|
||||
return event;
|
||||
}
|
||||
if (!event) return event;
|
||||
if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) == kCrossDeskInjectedEvent &&
|
||||
CGEventGetIntegerValueField(event, kCGEventSourceUnixProcessID) == getpid()) return event;
|
||||
const auto flags = CGEventGetFlags(event);
|
||||
const auto chord = kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskShift;
|
||||
if (type == kCGEventKeyDown && (flags & chord) == chord &&
|
||||
CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) == 53) {
|
||||
self->emergency_ = true;
|
||||
return nullptr;
|
||||
}
|
||||
return self->block_input_ ? nullptr : event;
|
||||
}
|
||||
std::shared_ptr<WindowState> state_;
|
||||
CFRunLoopRef loop_ = nullptr;
|
||||
CFRunLoopSourceRef wake_source_ = nullptr;
|
||||
CFMachPortRef tap_ = nullptr;
|
||||
CFRunLoopSourceRef tap_source_ = nullptr;
|
||||
bool block_input_ = false;
|
||||
bool emergency_ = false;
|
||||
bool tap_failed_ = false;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
void FlushMacPrivacyTasks() {
|
||||
if (![NSThread isMainThread]) return;
|
||||
std::deque<std::function<void()>> pending;
|
||||
{
|
||||
std::lock_guard lock(tasks_mutex);
|
||||
pending.swap(tasks);
|
||||
}
|
||||
@autoreleasepool { for (auto& task : pending) task(); }
|
||||
}
|
||||
MacPrivacyCaptureState GetMacPrivacyCaptureState() {
|
||||
std::lock_guard lock(capture_mutex);
|
||||
return capture_state;
|
||||
}
|
||||
void AcknowledgeMacPrivacyCapture(uint64_t revision) {
|
||||
std::lock_guard lock(capture_mutex);
|
||||
if (revision == capture_state.revision) capture_state.applied_revision = revision;
|
||||
}
|
||||
std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend() {
|
||||
return std::make_unique<MacPrivacyBackend>();
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
#ifndef _PRIVACY_CAPTURE_STATE_H_
|
||||
#define _PRIVACY_CAPTURE_STATE_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
struct MacPrivacyCaptureState {
|
||||
uint64_t revision = 0;
|
||||
uint64_t applied_revision = 0;
|
||||
std::vector<uint32_t> excluded_windows;
|
||||
uint32_t cursor_window = 0;
|
||||
};
|
||||
MacPrivacyCaptureState GetMacPrivacyCaptureState();
|
||||
void AcknowledgeMacPrivacyCapture(uint64_t revision);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_COVER_VIEW_H_
|
||||
#define _PRIVACY_COVER_VIEW_H_
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
namespace crossdesk {
|
||||
// Returns nil if the CrossDesk icon resource cannot be loaded.
|
||||
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,106 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
|
||||
#include "privacy_cover_view.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
NSImage* LoadPrivacyLogo() {
|
||||
// The macOS installer currently packages the icon as "crossedesk.icns".
|
||||
for (NSString* name in @[@"crossdesk", @"crossedesk"]) {
|
||||
NSString* path = [NSBundle.mainBundle pathForResource:name ofType:@"icns"];
|
||||
if (path) {
|
||||
NSImage* image = [[NSImage alloc] initWithContentsOfFile:path];
|
||||
if (image.valid) return image;
|
||||
}
|
||||
}
|
||||
// Support unbundled development builds, including launches from outside
|
||||
// the repository. No source-tree path is embedded in the executable.
|
||||
NSString* resource = @"apps/desktop/resources/macos/crossdesk.icns";
|
||||
NSString* directory = NSBundle.mainBundle.executablePath.stringByDeletingLastPathComponent;
|
||||
NSMutableArray<NSString*>* paths = [NSMutableArray arrayWithObject:
|
||||
[NSFileManager.defaultManager.currentDirectoryPath stringByAppendingPathComponent:resource]];
|
||||
for (int depth = 0; directory.length && depth <= 4; ++depth) {
|
||||
[paths addObject:[directory stringByAppendingPathComponent:resource]];
|
||||
directory = directory.stringByDeletingLastPathComponent;
|
||||
}
|
||||
for (NSString* path in paths) {
|
||||
NSImage* image = [[NSImage alloc] initWithContentsOfFile:path.stringByStandardizingPath];
|
||||
if (image.valid) return image;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSAttributedString* Text(NSString* value, CGFloat size, NSFontWeight weight, NSColor* color) {
|
||||
NSMutableParagraphStyle* paragraph = [[NSMutableParagraphStyle alloc] init];
|
||||
paragraph.lineBreakMode = NSLineBreakByWordWrapping;
|
||||
return [[NSAttributedString alloc] initWithString:value attributes:@{
|
||||
NSFontAttributeName: [NSFont systemFontOfSize:size weight:weight],
|
||||
NSForegroundColorAttributeName: color,
|
||||
NSParagraphStyleAttributeName: paragraph}];
|
||||
}
|
||||
constexpr NSStringDrawingOptions kTextOptions =
|
||||
NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
|
||||
|
||||
CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
|
||||
return std::ceil([text boundingRectWithSize:NSMakeSize(width, CGFLOAT_MAX)
|
||||
options:kTextOptions].size.height);
|
||||
}
|
||||
}
|
||||
|
||||
@interface CrossDeskPrivacyCoverView : NSView
|
||||
@property(nonatomic, strong) NSImage* logo;
|
||||
@property(nonatomic, copy) NSString* unlockHint;
|
||||
@end
|
||||
|
||||
@implementation CrossDeskPrivacyCoverView
|
||||
- (BOOL)isOpaque { return YES; }
|
||||
- (BOOL)isFlipped { return YES; }
|
||||
- (void)drawRect:(NSRect)dirtyRect {
|
||||
[NSColor.blackColor setFill];
|
||||
NSRectFill(self.bounds);
|
||||
const CGFloat width = NSWidth(self.bounds), height = NSHeight(self.bounds);
|
||||
if (width <= 0 || height <= 0) return;
|
||||
// Match the Windows cover's sizes, colors and bottom-left alignment.
|
||||
// AppKit coordinates are points; Retina backing scale is applied by AppKit.
|
||||
const CGFloat scale = std::min({CGFloat(1), width / 640, height / 200});
|
||||
const CGFloat margin = 32 * scale, text_width = 398 * scale;
|
||||
NSColor* foreground = [NSColor colorWithSRGBRed:245.0 / 255 green:247.0 / 255
|
||||
blue:250.0 / 255 alpha:1];
|
||||
NSColor* muted = [NSColor colorWithSRGBRed:181.0 / 255 green:190.0 / 255
|
||||
blue:204.0 / 255 alpha:1];
|
||||
NSAttributedString* brand = Text(@"CrossDesk", 14 * scale, NSFontWeightSemibold, foreground);
|
||||
NSAttributedString* hint = Text(self.unlockHint, 16 * scale, NSFontWeightRegular, muted);
|
||||
NSAttributedString* shortcut = Text(@"Control + Option + Shift + Esc", 24 * scale,
|
||||
NSFontWeightSemibold, foreground);
|
||||
const CGFloat logo_size = std::ceil(brand.size.width);
|
||||
const CGFloat brand_height = TextHeight(brand, logo_size);
|
||||
const CGFloat hint_height = TextHeight(hint, text_width);
|
||||
const CGFloat shortcut_height = TextHeight(shortcut, text_width);
|
||||
const CGFloat bottom = height - margin;
|
||||
const CGFloat logo_top = bottom - brand_height - 6 * scale - logo_size;
|
||||
[self.logo drawInRect:NSMakeRect(margin, logo_top, logo_size, logo_size)
|
||||
fromRect:NSZeroRect operation:NSCompositingOperationSourceOver
|
||||
fraction:1 respectFlipped:YES hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}];
|
||||
[brand drawWithRect:NSMakeRect(margin, bottom - brand_height, logo_size, brand_height)
|
||||
options:kTextOptions];
|
||||
const CGFloat text_left = margin + logo_size + 24 * scale;
|
||||
[hint drawWithRect:NSMakeRect(text_left, bottom - shortcut_height - 12 * scale - hint_height,
|
||||
text_width, hint_height) options:kTextOptions];
|
||||
[shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height,
|
||||
text_width, shortcut_height) options:kTextOptions];
|
||||
}
|
||||
@end
|
||||
|
||||
namespace crossdesk {
|
||||
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint) {
|
||||
NSImage* logo = LoadPrivacyLogo();
|
||||
if (!logo) return nil;
|
||||
auto* view = [[CrossDeskPrivacyCoverView alloc] initWithFrame:frame];
|
||||
view.logo = logo;
|
||||
view.unlockHint = unlock_hint ?: @"To turn off the privacy screen, press";
|
||||
view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_CURSOR_MAC_H_
|
||||
#define _PRIVACY_CURSOR_MAC_H_
|
||||
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
// Main-thread resource. Hides only for the lifetime of a local privacy cover;
|
||||
// does not take focus, move the pointer, or suppress remote input.
|
||||
class MacPrivacyCursor {
|
||||
public:
|
||||
MacPrivacyCursor();
|
||||
~MacPrivacyCursor();
|
||||
static bool Supported();
|
||||
bool Prepare();
|
||||
bool Hide();
|
||||
bool Healthy() const;
|
||||
uint32_t WindowId() const;
|
||||
void Restore();
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
// Local hiding must not be reported as an invisible cursor to controllers.
|
||||
bool IsMacPrivacyCursorHidden();
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
#include "privacy_cursor_mac.h"
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#include <atomic>
|
||||
#include <dlfcn.h>
|
||||
|
||||
@interface CrossDeskPrivacyCursorPanel : NSPanel
|
||||
@end
|
||||
@implementation CrossDeskPrivacyCursorPanel
|
||||
- (BOOL)canBecomeKeyWindow { return NO; }
|
||||
- (BOOL)canBecomeMainWindow { return NO; }
|
||||
@end
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
std::atomic<bool> hidden_by_privacy{false};
|
||||
struct CursorApi {
|
||||
using Connection = int (*)();
|
||||
using Copy = CGError (*)(int, int, CFStringRef, CFTypeRef*);
|
||||
using Set = CGError (*)(int, int, CFStringRef, CFTypeRef);
|
||||
Connection connection = reinterpret_cast<Connection>(dlsym(RTLD_DEFAULT, "CGSMainConnectionID"));
|
||||
Copy copy = reinterpret_cast<Copy>(dlsym(RTLD_DEFAULT, "CGSCopyConnectionProperty"));
|
||||
Set set = reinterpret_cast<Set>(dlsym(RTLD_DEFAULT, "CGSSetConnectionProperty"));
|
||||
};
|
||||
const CursorApi& Api() { static const CursorApi api; return api; }
|
||||
CFStringRef const kBackgroundCursor = CFSTR("SetsCursorInBackground");
|
||||
}
|
||||
|
||||
struct MacPrivacyCursor::Impl {
|
||||
int connection = 0;
|
||||
bool previous_background = false;
|
||||
bool background_changed = false;
|
||||
unsigned hide_count = 0;
|
||||
unsigned visible_ticks = 0;
|
||||
bool healthy = true;
|
||||
NSTimer* __strong timer = nil;
|
||||
CrossDeskPrivacyCursorPanel* __strong panel = nil;
|
||||
NSImageView* __strong view = nil;
|
||||
|
||||
void UpdateImage() {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
NSCursor* cursor = NSCursor.currentSystemCursor ?: NSCursor.arrowCursor;
|
||||
#pragma clang diagnostic pop
|
||||
NSImage* image = cursor.image;
|
||||
if (!image || image.size.width <= 0 || image.size.height <= 0) return;
|
||||
const NSPoint point = NSEvent.mouseLocation;
|
||||
const NSSize size = image.size;
|
||||
[panel setFrame:NSMakeRect(point.x - cursor.hotSpot.x,
|
||||
point.y - size.height + cursor.hotSpot.y, size.width, size.height) display:NO];
|
||||
view.image = image;
|
||||
[panel displayIfNeeded];
|
||||
}
|
||||
|
||||
bool KeepHidden() {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
if (hide_count && !CGCursorIsVisible()) { visible_ticks = 0; return true; }
|
||||
#pragma clang diagnostic pop
|
||||
if (++visible_ticks > 6 ||
|
||||
CGDisplayHideCursor(kCGDirectMainDisplay) != kCGErrorSuccess) return false;
|
||||
++hide_count;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
MacPrivacyCursor::MacPrivacyCursor() : impl_(std::make_unique<Impl>()) {}
|
||||
MacPrivacyCursor::~MacPrivacyCursor() { Restore(); }
|
||||
bool MacPrivacyCursor::Supported() {
|
||||
return Api().connection && Api().copy && Api().set;
|
||||
}
|
||||
bool MacPrivacyCursor::Prepare() {
|
||||
if (impl_->panel) return true;
|
||||
if (!Supported()) return false;
|
||||
// A cursor sprite below the local cover is captured only for clients that
|
||||
// request an embedded cursor. Desktop clients exclude it and use metadata.
|
||||
impl_->panel = [[CrossDeskPrivacyCursorPanel alloc] initWithContentRect:NSMakeRect(0, 0, 32, 32)
|
||||
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
|
||||
backing:NSBackingStoreBuffered defer:NO];
|
||||
auto* panel = impl_->panel;
|
||||
panel.releasedWhenClosed = NO;
|
||||
panel.hidesOnDeactivate = NO;
|
||||
panel.opaque = NO;
|
||||
panel.backgroundColor = NSColor.clearColor;
|
||||
panel.hasShadow = NO;
|
||||
panel.ignoresMouseEvents = YES;
|
||||
panel.level = CGWindowLevelForKey(kCGScreenSaverWindowLevelKey);
|
||||
panel.animationBehavior = NSWindowAnimationBehaviorNone;
|
||||
panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorStationary |
|
||||
NSWindowCollectionBehaviorIgnoresCycle;
|
||||
panel.sharingType = NSWindowSharingReadOnly;
|
||||
impl_->view = [[NSImageView alloc] initWithFrame:panel.contentView.bounds];
|
||||
impl_->view.imageScaling = NSImageScaleNone;
|
||||
impl_->view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
|
||||
panel.contentView = impl_->view;
|
||||
panel.alphaValue = 0;
|
||||
impl_->UpdateImage();
|
||||
[panel orderFrontRegardless];
|
||||
return panel != nil && panel.windowNumber > 0;
|
||||
}
|
||||
bool MacPrivacyCursor::Hide() {
|
||||
if (impl_->timer) return true;
|
||||
if (!Prepare()) return false;
|
||||
impl_->connection = Api().connection();
|
||||
impl_->previous_background = false;
|
||||
CFTypeRef previous = nullptr;
|
||||
if (Api().copy(impl_->connection, impl_->connection, kBackgroundCursor, &previous) == kCGErrorSuccess && previous)
|
||||
impl_->previous_background = CFEqual(previous, kCFBooleanTrue);
|
||||
if (previous) CFRelease(previous);
|
||||
// This connection-scoped WindowServer property is also used by Deskflow.
|
||||
// Resolve it dynamically: an unavailable API disables optional privacy only.
|
||||
if (Api().set(impl_->connection, impl_->connection, kBackgroundCursor, kCFBooleanTrue) != kCGErrorSuccess)
|
||||
return false;
|
||||
impl_->background_changed = true;
|
||||
impl_->healthy = true;
|
||||
impl_->visible_ticks = 0;
|
||||
hidden_by_privacy = true;
|
||||
if (!impl_->KeepHidden()) { Restore(); return false; }
|
||||
impl_->UpdateImage();
|
||||
impl_->panel.alphaValue = 1;
|
||||
auto* state = impl_.get();
|
||||
impl_->timer = [NSTimer timerWithTimeInterval:1.0 / 60 repeats:YES block:^(NSTimer*) {
|
||||
if (state->healthy) state->healthy = state->KeepHidden();
|
||||
state->UpdateImage();
|
||||
}];
|
||||
[[NSRunLoop mainRunLoop] addTimer:impl_->timer forMode:NSRunLoopCommonModes];
|
||||
return true;
|
||||
}
|
||||
bool MacPrivacyCursor::Healthy() const { return impl_->timer && impl_->healthy; }
|
||||
uint32_t MacPrivacyCursor::WindowId() const {
|
||||
return impl_->panel ? static_cast<uint32_t>(impl_->panel.windowNumber) : 0;
|
||||
}
|
||||
void MacPrivacyCursor::Restore() {
|
||||
[impl_->timer invalidate];
|
||||
impl_->timer = nil;
|
||||
[impl_->panel orderOut:nil];
|
||||
[impl_->panel close];
|
||||
impl_->panel = nil;
|
||||
impl_->view = nil;
|
||||
// Balance only our own successful hide calls, including re-hides after
|
||||
// WindowServer changes cursor ownership when a remote click activates an app.
|
||||
while (impl_->hide_count) {
|
||||
CGDisplayShowCursor(kCGDirectMainDisplay);
|
||||
--impl_->hide_count;
|
||||
}
|
||||
hidden_by_privacy = false;
|
||||
if (impl_->background_changed) {
|
||||
Api().set(impl_->connection, impl_->connection, kBackgroundCursor,
|
||||
impl_->previous_background ? kCFBooleanTrue : kCFBooleanFalse);
|
||||
impl_->background_changed = false;
|
||||
}
|
||||
}
|
||||
bool IsMacPrivacyCursorHidden() { return hidden_by_privacy.load(); }
|
||||
}
|
||||
@@ -7,6 +7,12 @@ namespace crossdesk {
|
||||
ScreenCapturerSck::ScreenCapturerSck() {}
|
||||
ScreenCapturerSck::~ScreenCapturerSck() {}
|
||||
|
||||
void ScreenCapturerSck::SetPrivacyController(PrivacyController* privacy) {
|
||||
privacy_ = privacy;
|
||||
if (screen_capturer_sck_impl_)
|
||||
screen_capturer_sck_impl_->SetPrivacyController(privacy);
|
||||
}
|
||||
|
||||
int ScreenCapturerSck::Init(const int fps, cb_desktop_data cb) {
|
||||
if (cb) {
|
||||
on_data_ = cb;
|
||||
@@ -16,6 +22,7 @@ int ScreenCapturerSck::Init(const int fps, cb_desktop_data cb) {
|
||||
}
|
||||
|
||||
screen_capturer_sck_impl_ = CreateScreenCapturerSck();
|
||||
screen_capturer_sck_impl_->SetPrivacyController(privacy_);
|
||||
const int ret = screen_capturer_sck_impl_->Init(fps, on_data_);
|
||||
if (ret != 0) {
|
||||
screen_capturer_sck_impl_.reset();
|
||||
|
||||
@@ -25,6 +25,7 @@ class ScreenCapturerSck : public ScreenCapturer {
|
||||
|
||||
public:
|
||||
int Init(const int fps, cb_desktop_data cb) override;
|
||||
void SetPrivacyController(PrivacyController* privacy) override;
|
||||
int Destroy() override;
|
||||
int Start(bool show_cursor) override;
|
||||
int Stop() override;
|
||||
@@ -57,6 +58,7 @@ class ScreenCapturerSck : public ScreenCapturer {
|
||||
|
||||
private:
|
||||
std::unique_ptr<ScreenCapturer> screen_capturer_sck_impl_;
|
||||
PrivacyController* privacy_ = nullptr;
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
#include "display_info.h"
|
||||
#include <display_stream_id.h>
|
||||
#include "rd_log.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "../privacy/privacy_capture_state.h"
|
||||
#include <algorithm>
|
||||
|
||||
using namespace crossdesk;
|
||||
|
||||
@@ -110,13 +113,18 @@ API_AVAILABLE(macos(14.0))
|
||||
|
||||
- (instancetype)initWithCapturer:(ScreenCapturerSckImpl *)capturer;
|
||||
|
||||
- (void)onShareableContentCreated:(SCShareableContent *)content;
|
||||
- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation
|
||||
revision:(uint64_t)revision;
|
||||
|
||||
// Called just before the capturer is destroyed. This avoids a dangling pointer,
|
||||
// and prevents any new calls into a deleted capturer. If any method-call on the
|
||||
// capturer is currently running on a different thread, this blocks until it
|
||||
// completes.
|
||||
- (void)releaseCapturer;
|
||||
- (void)privacyCoversChanged:(NSNotification*)notification;
|
||||
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
|
||||
revision:(uint64_t)revision complete:(BOOL)complete
|
||||
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error;
|
||||
@end
|
||||
|
||||
class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
|
||||
@@ -129,6 +137,7 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
|
||||
|
||||
public:
|
||||
int Init(const int fps, cb_desktop_data cb) override;
|
||||
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
|
||||
|
||||
int Start(bool show_cursor) override;
|
||||
|
||||
@@ -156,11 +165,30 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
|
||||
bool capture_requested_ = false;
|
||||
bool invalid_stream_id_logged_ = false;
|
||||
bool native_output_logged_ = false;
|
||||
PrivacyController* privacy_ = nullptr;
|
||||
uint64_t configuration_generation_ = 0;
|
||||
bool configuration_in_flight_ = false;
|
||||
bool reconfigure_pending_ = false;
|
||||
SCStreamConfiguration* __strong applied_configuration_ = nil;
|
||||
|
||||
public:
|
||||
// Called by SckHelper when shareable content is returned by ScreenCaptureKit. `content` will be
|
||||
// nil if an error occurred. May run on an arbitrary thread.
|
||||
void OnShareableContentCreated(SCShareableContent *content);
|
||||
void OnShareableContentCreated(SCShareableContent *content, uint64_t generation,
|
||||
uint64_t revision);
|
||||
void ConfigurationFinished(SCStream* source, uint64_t generation, uint64_t revision,
|
||||
bool complete, SCStreamConfiguration* configuration,
|
||||
NSError* error);
|
||||
void RetryPrivacyFilter();
|
||||
void PrivacyCoversChanged() { StartOrReconfigureCapturer(); }
|
||||
void CaptureStopped(SCStream* source, NSError* error) {
|
||||
{
|
||||
std::lock_guard lock(lock_);
|
||||
if (!capture_requested_ || source != stream_) return;
|
||||
}
|
||||
LOG_ERROR("ScreenCaptureKit stream stopped: {}", NSErrorToString(error));
|
||||
if (privacy_) privacy_->Fail("ScreenCaptureKit stream stopped");
|
||||
}
|
||||
// Called by SckHelper to notify of a newly captured frame. May run on an arbitrary thread.
|
||||
// void OnNewIOSurface(IOSurfaceRef io_surface, CFDictionaryRef attachment);
|
||||
void OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer, CFDictionaryRef attachment);
|
||||
@@ -389,6 +417,7 @@ int ScreenCapturerSckImpl::Start(bool show_cursor) {
|
||||
capture_requested_ = true;
|
||||
invalid_stream_id_logged_ = false;
|
||||
}
|
||||
if (privacy_) privacy_->CaptureChanged(true);
|
||||
StartOrReconfigureCapturer();
|
||||
return 0;
|
||||
}
|
||||
@@ -446,6 +475,7 @@ int ScreenCapturerSckImpl::ResetToInitialMonitor() {
|
||||
}
|
||||
|
||||
int ScreenCapturerSckImpl::Destroy() {
|
||||
if (privacy_) privacy_->CaptureChanged(false);
|
||||
SckHelper *helper_to_release = nil;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(lock_);
|
||||
@@ -455,6 +485,9 @@ int ScreenCapturerSckImpl::Destroy() {
|
||||
stream_ = nil;
|
||||
}
|
||||
capture_requested_ = false;
|
||||
configuration_in_flight_ = reconfigure_pending_ = false;
|
||||
applied_configuration_ = nil;
|
||||
++configuration_generation_;
|
||||
current_display_ = 0;
|
||||
current_stream_id_.clear();
|
||||
permanent_error_ = false;
|
||||
@@ -469,8 +502,12 @@ int ScreenCapturerSckImpl::Destroy() {
|
||||
}
|
||||
|
||||
int ScreenCapturerSckImpl::Stop() {
|
||||
if (privacy_) privacy_->CaptureChanged(false);
|
||||
std::lock_guard<std::mutex> lock(lock_);
|
||||
capture_requested_ = false;
|
||||
configuration_in_flight_ = reconfigure_pending_ = false;
|
||||
applied_configuration_ = nil;
|
||||
++configuration_generation_;
|
||||
if (stream_) {
|
||||
LOG_INFO("Stopping stream");
|
||||
[stream_ stopCaptureWithCompletionHandler:nil];
|
||||
@@ -481,24 +518,36 @@ int ScreenCapturerSckImpl::Stop() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *content) {
|
||||
void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *content,
|
||||
uint64_t generation, uint64_t revision) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(lock_);
|
||||
if (!capture_requested_) {
|
||||
if (!capture_requested_ || generation != configuration_generation_) {
|
||||
LOG_INFO("Ignoring stale ScreenCaptureKit display refresh after stop");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The window list belongs to the privacy revision present when the request
|
||||
// was issued. A newer cover must be enumerated by a new request.
|
||||
if (GetMacPrivacyCaptureState().revision != revision) {
|
||||
StartOrReconfigureCapturer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
LOG_ERROR("getShareableContent failed");
|
||||
permanent_error_ = true;
|
||||
if (privacy_) privacy_->Fail("Could not refresh ScreenCaptureKit privacy content");
|
||||
std::lock_guard lock(lock_);
|
||||
if (!stream_) permanent_error_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.displays || content.displays.count == 0) {
|
||||
LOG_ERROR("getShareableContent returned no displays");
|
||||
permanent_error_ = true;
|
||||
if (privacy_) privacy_->Fail("No shareable displays available for privacy");
|
||||
std::lock_guard lock(lock_);
|
||||
if (!stream_) permanent_error_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -506,7 +555,7 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
|
||||
bool show_cursor = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(lock_);
|
||||
if (!capture_requested_) return;
|
||||
if (!capture_requested_ || generation != configuration_generation_) return;
|
||||
|
||||
int logical_index = current_monitor_index_;
|
||||
if (logical_index < 0 ||
|
||||
@@ -576,8 +625,40 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
|
||||
return;
|
||||
}
|
||||
|
||||
const auto privacy_capture = GetMacPrivacyCaptureState();
|
||||
// The cursor sprite is included only when this stream embeds the cursor.
|
||||
// Other controllers receive the unchanged cursor metadata separately.
|
||||
auto excluded_ids = privacy_capture.excluded_windows;
|
||||
if (!show_cursor && privacy_capture.cursor_window)
|
||||
excluded_ids.push_back(privacy_capture.cursor_window);
|
||||
NSMutableArray<SCWindow*>* excluded = [NSMutableArray array];
|
||||
for (SCWindow* window in content.windows) {
|
||||
if (std::find(excluded_ids.begin(), excluded_ids.end(), window.windowID) != excluded_ids.end())
|
||||
[excluded addObject:window];
|
||||
}
|
||||
// Newly registered transparent covers may need another enumeration. Never
|
||||
// acknowledge a partial exclusion or remove an active cover's filter.
|
||||
const bool privacy_filter_complete = excluded.count == excluded_ids.size();
|
||||
LOG_INFO("macOS privacy filter: generation={}, revision={}, requested={}, excluded={}",
|
||||
generation, privacy_capture.revision, excluded_ids.size(),
|
||||
excluded.count);
|
||||
if (!privacy_filter_complete) {
|
||||
bool have_stream;
|
||||
{ std::lock_guard lock(lock_); have_stream = stream_ != nil; }
|
||||
if (privacy_capture.applied_revision == privacy_capture.revision) {
|
||||
if (privacy_) privacy_->Fail("ScreenCaptureKit could not enumerate every privacy cover");
|
||||
return; // Preserve the existing exclusion until recovery removes covers.
|
||||
}
|
||||
if (have_stream) {
|
||||
RetryPrivacyFilter();
|
||||
return; // Transparent covers are still being registered by WindowServer.
|
||||
}
|
||||
// Start ordinary capture even if a newly created, still-transparent cover
|
||||
// is not enumerable yet. Retry exclusion after start; never acknowledge it.
|
||||
[excluded removeAllObjects];
|
||||
}
|
||||
SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:captured_display
|
||||
excludingWindows:@[]];
|
||||
excludingWindows:excluded];
|
||||
if (!filter) {
|
||||
LOG_ERROR("Failed to create SCContentFilter");
|
||||
permanent_error_ = true;
|
||||
@@ -598,13 +679,39 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
|
||||
config.captureResolution = SCCaptureResolutionAutomatic;
|
||||
config.minimumFrameInterval = CMTimeMake(1, fps_);
|
||||
|
||||
std::lock_guard<std::mutex> lock(lock_);
|
||||
if (!capture_requested_) return;
|
||||
std::unique_lock<std::mutex> lock(lock_);
|
||||
if (!capture_requested_ || generation != configuration_generation_) return;
|
||||
if (configuration_in_flight_) { reconfigure_pending_ = true; return; }
|
||||
if (GetMacPrivacyCaptureState().revision != revision) {
|
||||
lock.unlock();
|
||||
RetryPrivacyFilter();
|
||||
return;
|
||||
}
|
||||
configuration_in_flight_ = true;
|
||||
|
||||
if (stream_) {
|
||||
LOG_INFO("Updating stream configuration");
|
||||
[stream_ updateContentFilter:filter completionHandler:nil];
|
||||
[stream_ updateConfiguration:config completionHandler:nil];
|
||||
SckHelper* helper = helper_;
|
||||
SCStream* source = stream_;
|
||||
auto finished = ^(NSError* error) {
|
||||
[helper configurationFinished:source generation:generation revision:revision
|
||||
complete:privacy_filter_complete configuration:config error:error];
|
||||
};
|
||||
// Apply configuration before its filter and acknowledge the final update.
|
||||
// Privacy-only toggles do not need a configuration change.
|
||||
const bool same_config = applied_configuration_ &&
|
||||
applied_configuration_.width == config.width &&
|
||||
applied_configuration_.height == config.height &&
|
||||
applied_configuration_.showsCursor == config.showsCursor &&
|
||||
applied_configuration_.pixelFormat == config.pixelFormat &&
|
||||
CMTimeCompare(applied_configuration_.minimumFrameInterval, config.minimumFrameInterval) == 0;
|
||||
if (same_config) {
|
||||
[source updateContentFilter:filter completionHandler:finished];
|
||||
} else {
|
||||
[source updateConfiguration:config completionHandler:^(NSError* error) {
|
||||
if (error) finished(error);
|
||||
else [source updateContentFilter:filter completionHandler:finished];
|
||||
}];
|
||||
}
|
||||
} else {
|
||||
stream_ = [[SCStream alloc] initWithFilter:filter configuration:config delegate:helper_];
|
||||
|
||||
@@ -618,28 +725,62 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten
|
||||
error:&add_stream_output_error];
|
||||
|
||||
if (!add_stream_output_result) {
|
||||
configuration_in_flight_ = false;
|
||||
stream_ = nil;
|
||||
LOG_ERROR("addStreamOutput failed: {}", NSErrorToString(add_stream_output_error));
|
||||
permanent_error_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
SckHelper* helper = helper_;
|
||||
SCStream* source = stream_;
|
||||
auto handler = ^(NSError *error) {
|
||||
if (error) {
|
||||
// It should be safe to access `this` here, because the C++ destructor
|
||||
// calls stopCaptureWithCompletionHandler on the stream, which cancels
|
||||
// this handler.
|
||||
permanent_error_ = true;
|
||||
LOG_ERROR("startCaptureWithCompletionHandler failed: {}", NSErrorToString(error));
|
||||
} else {
|
||||
LOG_INFO("Capture started");
|
||||
}
|
||||
[helper configurationFinished:source generation:generation revision:revision
|
||||
complete:privacy_filter_complete configuration:config error:error];
|
||||
};
|
||||
|
||||
[stream_ startCaptureWithCompletionHandler:handler];
|
||||
}
|
||||
}
|
||||
|
||||
void ScreenCapturerSckImpl::RetryPrivacyFilter() {
|
||||
SckHelper* helper;
|
||||
{
|
||||
std::lock_guard lock(lock_);
|
||||
if (!capture_requested_) return;
|
||||
helper = helper_;
|
||||
}
|
||||
// The retained helper safely ignores this callback after Destroy().
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC),
|
||||
dispatch_get_main_queue(), ^{ [helper privacyCoversChanged:nil]; });
|
||||
}
|
||||
|
||||
void ScreenCapturerSckImpl::ConfigurationFinished(SCStream* source, uint64_t generation,
|
||||
uint64_t revision, bool complete,
|
||||
SCStreamConfiguration* configuration,
|
||||
NSError* error) {
|
||||
bool refresh = false;
|
||||
{
|
||||
std::lock_guard lock(lock_);
|
||||
if (!capture_requested_ || source != stream_) return;
|
||||
configuration_in_flight_ = false;
|
||||
const auto current = GetMacPrivacyCaptureState();
|
||||
refresh = reconfigure_pending_ || current.revision != revision;
|
||||
reconfigure_pending_ = false;
|
||||
if (!error) {
|
||||
applied_configuration_ = configuration;
|
||||
if (complete && !refresh && generation == configuration_generation_)
|
||||
AcknowledgeMacPrivacyCapture(revision);
|
||||
refresh = refresh || !complete;
|
||||
}
|
||||
LOG_INFO("macOS privacy filter completed: generation={}, revision={}, complete={}, refresh={}, error={}",
|
||||
generation, revision, complete, refresh, NSErrorToString(error));
|
||||
}
|
||||
if (error && privacy_)
|
||||
privacy_->Fail("ScreenCaptureKit privacy filter: " + NSErrorToString(error));
|
||||
if (refresh) RetryPrivacyFilter();
|
||||
}
|
||||
|
||||
void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer,
|
||||
CFDictionaryRef attachment) {
|
||||
(void)attachment;
|
||||
@@ -727,7 +868,18 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
|
||||
}
|
||||
}
|
||||
|
||||
SckHelper *local_helper = helper_;
|
||||
SckHelper *local_helper;
|
||||
uint64_t generation;
|
||||
uint64_t revision;
|
||||
{
|
||||
std::lock_guard lock(lock_);
|
||||
if (!capture_requested_) return;
|
||||
if (configuration_in_flight_) { reconfigure_pending_ = true; return; }
|
||||
reconfigure_pending_ = false;
|
||||
local_helper = helper_;
|
||||
generation = ++configuration_generation_;
|
||||
revision = GetMacPrivacyCaptureState().revision;
|
||||
}
|
||||
if (!local_helper) {
|
||||
LOG_ERROR("Cannot reconfigure capturer: helper is null");
|
||||
return;
|
||||
@@ -736,12 +888,14 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
|
||||
auto handler = ^(SCShareableContent *content, NSError *error) {
|
||||
if (error) {
|
||||
LOG_ERROR("getShareableContent failed: {}", NSErrorToString(error));
|
||||
[local_helper onShareableContentCreated:nil];
|
||||
[local_helper onShareableContentCreated:nil generation:generation revision:revision];
|
||||
return;
|
||||
}
|
||||
[local_helper onShareableContentCreated:content];
|
||||
[local_helper onShareableContentCreated:content generation:generation revision:revision];
|
||||
};
|
||||
[SCShareableContent getShareableContentWithCompletionHandler:handler];
|
||||
[SCShareableContent getShareableContentExcludingDesktopWindows:NO
|
||||
onScreenWindowsOnly:NO
|
||||
completionHandler:handler];
|
||||
}
|
||||
|
||||
@implementation SckHelper {
|
||||
@@ -755,14 +909,28 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_capturer = capturer;
|
||||
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(privacyCoversChanged:)
|
||||
name:@"CrossDeskPrivacyCoversChanged" object:nil];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)onShareableContentCreated:(SCShareableContent *)content {
|
||||
- (void)privacyCoversChanged:(NSNotification*)notification {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer) _capturer->PrivacyCoversChanged();
|
||||
}
|
||||
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
|
||||
revision:(uint64_t)revision complete:(BOOL)complete
|
||||
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer)
|
||||
_capturer->ConfigurationFinished(stream, generation, revision, complete, configuration, error);
|
||||
}
|
||||
- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation
|
||||
revision:(uint64_t)revision {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer) {
|
||||
_capturer->OnShareableContentCreated(content);
|
||||
_capturer->OnShareableContentCreated(content, generation, revision);
|
||||
} else {
|
||||
LOG_ERROR("Invalid capturer");
|
||||
}
|
||||
@@ -797,10 +965,16 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
|
||||
}
|
||||
|
||||
- (void)releaseCapturer {
|
||||
[NSNotificationCenter.defaultCenter removeObserver:self];
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
_capturer = nullptr;
|
||||
}
|
||||
|
||||
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer) _capturer->CaptureStopped(stream, error);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
std::unique_ptr<ScreenCapturer> ScreenCapturerSck::CreateScreenCapturerSck() {
|
||||
|
||||
@@ -115,8 +115,7 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
||||
bool force_broadcast = false;
|
||||
if (pending_windows_service_sas_.exchange(false, std::memory_order_relaxed)) {
|
||||
if (privacy_.Engaged()) {
|
||||
privacy_.Fail("Queued security shortcut cancelled during privacy mode; remote operation paused");
|
||||
return;
|
||||
privacy_.Fail("Security shortcut requested; privacy screen will turn off");
|
||||
}
|
||||
const std::string response =
|
||||
QueryCrossDeskService("sas", kWindowsServiceSasTimeoutMs);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "window_icons.h"
|
||||
|
||||
#include <commctrl.h>
|
||||
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
constexpr UINT_PTR kIconSubclassId = 1;
|
||||
|
||||
struct WindowIcons {
|
||||
HICON small_icon = nullptr;
|
||||
HICON large_icon = nullptr;
|
||||
|
||||
~WindowIcons() {
|
||||
if (small_icon) DestroyIcon(small_icon);
|
||||
if (large_icon) DestroyIcon(large_icon);
|
||||
}
|
||||
|
||||
bool Load(UINT dpi) {
|
||||
const HMODULE module = GetModuleHandleW(nullptr);
|
||||
small_icon = static_cast<HICON>(LoadImageW(
|
||||
module, L"IDI_ICON1", IMAGE_ICON,
|
||||
GetSystemMetricsForDpi(SM_CXSMICON, dpi),
|
||||
GetSystemMetricsForDpi(SM_CYSMICON, dpi), 0));
|
||||
large_icon = static_cast<HICON>(LoadImageW(
|
||||
module, L"IDI_ICON1", IMAGE_ICON,
|
||||
GetSystemMetricsForDpi(SM_CXICON, dpi),
|
||||
GetSystemMetricsForDpi(SM_CYICON, dpi), 0));
|
||||
return small_icon && large_icon;
|
||||
}
|
||||
|
||||
void Apply(HWND window) const {
|
||||
// Do not destroy the replaced handles: Slint/winit owns those images.
|
||||
SendMessageW(window, WM_SETICON, ICON_SMALL,
|
||||
reinterpret_cast<LPARAM>(small_icon));
|
||||
SendMessageW(window, WM_SETICON, ICON_BIG,
|
||||
reinterpret_cast<LPARAM>(large_icon));
|
||||
}
|
||||
};
|
||||
|
||||
LRESULT CALLBACK IconSubclassProc(HWND window, UINT message, WPARAM wparam,
|
||||
LPARAM lparam, UINT_PTR subclass_id,
|
||||
DWORD_PTR reference_data) {
|
||||
auto* icons = reinterpret_cast<WindowIcons*>(reference_data);
|
||||
if (message == WM_SETICON) {
|
||||
// Slint supplies the same PNG for both sizes, including during deferred
|
||||
// window initialization. Keep using the matching frames from our ICO.
|
||||
if (wparam == ICON_SMALL) {
|
||||
lparam = reinterpret_cast<LPARAM>(icons->small_icon);
|
||||
} else if (wparam == ICON_BIG) {
|
||||
lparam = reinterpret_cast<LPARAM>(icons->large_icon);
|
||||
}
|
||||
return DefSubclassProc(window, message, wparam, lparam);
|
||||
}
|
||||
if (message == WM_DPICHANGED) {
|
||||
WindowIcons next;
|
||||
if (next.Load(HIWORD(wparam))) {
|
||||
std::swap(icons->small_icon, next.small_icon);
|
||||
std::swap(icons->large_icon, next.large_icon);
|
||||
icons->Apply(window);
|
||||
// 'next' releases the previous pair only after both were replaced.
|
||||
}
|
||||
}
|
||||
if (message == WM_NCDESTROY) {
|
||||
const LRESULT result = DefSubclassProc(window, message, wparam, lparam);
|
||||
RemoveWindowSubclass(window, IconSubclassProc, subclass_id);
|
||||
delete icons;
|
||||
return result;
|
||||
}
|
||||
return DefSubclassProc(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ConfigureWindowsWindowIcons(HWND window) {
|
||||
if (!window) {
|
||||
return false;
|
||||
}
|
||||
DWORD_PTR existing = 0;
|
||||
if (GetWindowSubclass(window, IconSubclassProc, kIconSubclassId, &existing)) {
|
||||
return true;
|
||||
}
|
||||
auto icons = std::make_unique<WindowIcons>();
|
||||
const UINT dpi = GetDpiForWindow(window);
|
||||
if (!dpi || !icons->Load(dpi) ||
|
||||
!SetWindowSubclass(window, IconSubclassProc, kIconSubclassId,
|
||||
reinterpret_cast<DWORD_PTR>(icons.get()))) {
|
||||
return false;
|
||||
}
|
||||
// The subclass owns the pair until WM_NCDESTROY, even if Slint hides and
|
||||
// recreates its native window while the component itself stays alive.
|
||||
auto* installed = icons.release();
|
||||
installed->Apply(window);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,14 @@
|
||||
#ifndef CROSSDESK_WINDOWS_WINDOW_ICONS_H_
|
||||
#define CROSSDESK_WINDOWS_WINDOW_ICONS_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Call on the window's UI thread once its native handle exists. Repeated calls
|
||||
// are harmless. Icons follow DPI changes and are released with the window.
|
||||
bool ConfigureWindowsWindowIcons(HWND window);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_WINDOWS_WINDOW_ICONS_H_
|
||||
@@ -0,0 +1,32 @@
|
||||
#ifndef CROSSDESK_WINDOWS_CURSOR_SHAPE_H_
|
||||
#define CROSSDESK_WINDOWS_CURSOR_SHAPE_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <remote_cursor_shape.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
inline RemoteCursorShape ShapeFromWindowsCursor(HCURSOR cursor) {
|
||||
const auto is_system_cursor = [cursor](LPCWSTR resource) {
|
||||
return cursor != nullptr && cursor == LoadCursorW(nullptr, resource);
|
||||
};
|
||||
if (is_system_cursor(IDC_HELP)) return RemoteCursorShape::help;
|
||||
if (is_system_cursor(IDC_HAND)) return RemoteCursorShape::pointer;
|
||||
if (is_system_cursor(IDC_APPSTARTING)) return RemoteCursorShape::progress;
|
||||
if (is_system_cursor(IDC_WAIT)) return RemoteCursorShape::wait;
|
||||
if (is_system_cursor(IDC_CROSS)) return RemoteCursorShape::crosshair;
|
||||
if (is_system_cursor(IDC_IBEAM)) return RemoteCursorShape::text;
|
||||
if (is_system_cursor(IDC_NO)) return RemoteCursorShape::not_allowed;
|
||||
if (is_system_cursor(IDC_SIZEALL)) return RemoteCursorShape::move;
|
||||
if (is_system_cursor(IDC_SIZEWE)) return RemoteCursorShape::ew_resize;
|
||||
if (is_system_cursor(IDC_SIZENS)) return RemoteCursorShape::ns_resize;
|
||||
if (is_system_cursor(IDC_SIZENESW)) return RemoteCursorShape::nesw_resize;
|
||||
if (is_system_cursor(IDC_SIZENWSE)) return RemoteCursorShape::nwse_resize;
|
||||
if (is_system_cursor(IDC_UPARROW)) return RemoteCursorShape::n_resize;
|
||||
return RemoteCursorShape::default_cursor;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_WINDOWS_CURSOR_SHAPE_H_
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-16
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _WINDOWS_CURSOR_STATE_H_
|
||||
#define _WINDOWS_CURSOR_STATE_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <vector>
|
||||
|
||||
#include "windows_cursor_shape.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Used by both ordinary and secure-desktop capture. Device absence is only a
|
||||
// compatibility hint: a hidden cursor with a null handle, or touch/pen
|
||||
// suppression, must retain the application's normal hiding behavior.
|
||||
class WindowsCursorState {
|
||||
public:
|
||||
void Sample(const CURSORINFO& info, bool embedded, CursorState* state) {
|
||||
const bool showing = (info.flags & CURSOR_SHOWING) != 0;
|
||||
state->render_mode = embedded ? CursorRenderMode::embedded
|
||||
: showing ? CursorRenderMode::separate
|
||||
: CursorRenderMode::hidden;
|
||||
state->visible = state->render_mode == CursorRenderMode::separate;
|
||||
state->shape = state->visible ? ShapeFromWindowsCursor(info.hCursor)
|
||||
: RemoteCursorShape::none;
|
||||
state->hidden_reason = CursorHiddenReason::unspecified;
|
||||
if (!showing && !embedded) {
|
||||
state->hidden_reason = (info.flags & CURSOR_SUPPRESSED)
|
||||
? CursorHiddenReason::system_suppressed
|
||||
: info.hCursor && NoPointingDevice()
|
||||
? CursorHiddenReason::no_pointing_device
|
||||
: CursorHiddenReason::system_hidden;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
// Query only for potentially recoverable hidden cursors, at most once per
|
||||
// second. Require two successful absent observations. Query failures, RDP
|
||||
// (whose devices may be absent from Raw Input), and present mice all disallow
|
||||
// fallback and break the sequence of absent observations.
|
||||
bool NoPointingDevice() {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now < next_device_check_) return absence_confirmed_;
|
||||
next_device_check_ = now + std::chrono::seconds(1);
|
||||
const bool absent = DetectDeviceAbsence();
|
||||
absence_confirmed_ = absent && absent_before_;
|
||||
absent_before_ = absent;
|
||||
return absence_confirmed_;
|
||||
}
|
||||
|
||||
static bool DetectDeviceAbsence() {
|
||||
if (GetSystemMetrics(SM_MOUSEPRESENT) ||
|
||||
GetSystemMetrics(SM_REMOTESESSION)) {
|
||||
return false;
|
||||
}
|
||||
UINT count = 0;
|
||||
if (GetRawInputDeviceList(nullptr, &count, sizeof(RAWINPUTDEVICELIST)) ==
|
||||
static_cast<UINT>(-1) ||
|
||||
count > 4096)
|
||||
return false;
|
||||
if (count == 0) return true;
|
||||
std::vector<RAWINPUTDEVICELIST> devices(count);
|
||||
const UINT read = GetRawInputDeviceList(devices.data(), &count,
|
||||
sizeof(RAWINPUTDEVICELIST));
|
||||
if (read == static_cast<UINT>(-1) || read > devices.size()) return false;
|
||||
for (UINT i = 0; i < read; ++i) {
|
||||
if (devices[i].dwType == RIM_TYPEMOUSE) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool absent_before_ = false;
|
||||
bool absence_confirmed_ = false;
|
||||
std::chrono::steady_clock::time_point next_device_check_{};
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -192,7 +192,7 @@ class Windows {
|
||||
GetPrivacyWindowBand get_band_ = nullptr;
|
||||
};
|
||||
|
||||
DWORD WINAPI Run(void*) {
|
||||
DWORD Run() {
|
||||
wchar_t bootstrap[256]{};
|
||||
if (!GetEnvironmentVariableW(kPrivacyBandBootstrap, bootstrap, 256))
|
||||
return ERROR_INVALID_PARAMETER;
|
||||
@@ -239,8 +239,8 @@ DWORD WINAPI Run(void*) {
|
||||
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.
|
||||
// The parent releases privacy 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);
|
||||
@@ -273,21 +273,26 @@ DWORD WINAPI Run(void*) {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
void CALLBACK RunBeforeBrokerEntryPoint(ULONG_PTR) {
|
||||
// The LoadLibraryW startup APC has returned, so the loader lock is no longer
|
||||
// held. Own the primary thread until privacy stops, including startup errors.
|
||||
// Never return into RuntimeBroker's COM server startup: an elevated launch
|
||||
// can fail there with CO_E_WRONG_SERVER_IDENTITY and kill healthy covers.
|
||||
ExitProcess(Run());
|
||||
}
|
||||
} // 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);
|
||||
// Loaded by the startup APC on our dedicated, suspended broker's primary
|
||||
// thread. Queue the window loop after that APC instead of starting a worker
|
||||
// alongside RuntimeBroker's own entry point. Do not run it under loader lock.
|
||||
if (!QueueUserAPC(RunBeforeBrokerEntryPoint, GetCurrentThread(), 0))
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <wtsapi32.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "../windows_thread_dpi.h"
|
||||
@@ -19,8 +18,6 @@
|
||||
#include "privacy_band_guard.h"
|
||||
#include "privacy_cursor_guard.h"
|
||||
#include "privacy_input_guard.h"
|
||||
#include "privacy_probe_pattern.h"
|
||||
#include "privacy_probe_renderer.h"
|
||||
#include "privacy_window_visibility.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
@@ -118,7 +115,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
if (!PrivacyBandGuard::Available(high_band_error))
|
||||
return {false, false, high_band_error};
|
||||
return {true, true,
|
||||
"High-band privacy; live capture exclusion will be verified"};
|
||||
"High-band privacy screen available"};
|
||||
}
|
||||
|
||||
bool Enable(bool block_input, const PrivacyScreenText& text,
|
||||
@@ -169,19 +166,9 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
std::remove(window_events_.begin(), window_events_.end(), nullptr),
|
||||
window_events_.end());
|
||||
if (window_events_.empty()) event_owner_ = nullptr;
|
||||
// Remove verification panels before exposing the local desktop.
|
||||
for (auto& window : windows_) {
|
||||
Destroy(window.probe);
|
||||
}
|
||||
std::string band_error;
|
||||
band_guard_.Stop(band_error);
|
||||
for (auto& window : windows_) {
|
||||
if (band_guard_.stopped()) window.cover = nullptr;
|
||||
}
|
||||
windows_.erase(
|
||||
std::remove_if(windows_.begin(), windows_.end(),
|
||||
[](const auto& w) { return !w.cover && !w.probe; }),
|
||||
windows_.end());
|
||||
if (band_guard_.stopped()) windows_.clear();
|
||||
input_guard_.Stop();
|
||||
block_input_ = false;
|
||||
if (hotkey_) {
|
||||
@@ -231,12 +218,12 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
health_error_ = Error("MsgWaitForMultipleObjectsEx privacy thread");
|
||||
}
|
||||
|
||||
PrivacyHealth Poll(bool force_check = false) override {
|
||||
PrivacyHealth Poll() override {
|
||||
MSG message{};
|
||||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
|
||||
if (message.message == WM_QUIT)
|
||||
health_error_ =
|
||||
"Privacy message thread is exiting; remote operation paused";
|
||||
"Privacy message thread is exiting";
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
@@ -251,7 +238,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
if (windows_.empty()) return {health_error_, false};
|
||||
// Input messages wake the thread immediately; expensive health queries
|
||||
// must retain their own cadence instead of running once per input event.
|
||||
if (!force_check && !layout_changed_ &&
|
||||
if (!layout_changed_ &&
|
||||
GetTickCount64() - last_check_ < 100) {
|
||||
if (coverage_dirty_ && last_health_.failure.empty()) {
|
||||
coverage_dirty_ = false;
|
||||
@@ -273,8 +260,8 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
std::string cursor_error;
|
||||
cursor_guard_.Stop(cursor_error);
|
||||
return {
|
||||
"Secure desktop, lock screen or unavailable input desktop; remote "
|
||||
"operation paused. Privacy windows cannot cover system security UI.",
|
||||
"Secure desktop, lock screen or unavailable input desktop; "
|
||||
"privacy screen will turn off",
|
||||
false};
|
||||
}
|
||||
std::string cursor_error;
|
||||
@@ -283,8 +270,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
if (FAILED(SHQueryUserNotificationState(&shell_state)) ||
|
||||
shell_state == QUNS_RUNNING_D3D_FULL_SCREEN) {
|
||||
return {
|
||||
"Exclusive fullscreen or unknown display presentation; remote "
|
||||
"operation paused",
|
||||
"Exclusive fullscreen or unknown display presentation",
|
||||
false};
|
||||
}
|
||||
MonitorEnumeration monitors;
|
||||
@@ -304,16 +290,14 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
});
|
||||
if (changed || layout_changed_) {
|
||||
layout_changed_ = false;
|
||||
// Retain old covers until new monitor regions are covered. Never
|
||||
// automatically resume remote work after a topology/DPI transition.
|
||||
// Retain old covers until the monitor check completes, then release
|
||||
// privacy so capture can refresh its display mapping independently.
|
||||
std::string error;
|
||||
if (!ReconcileMonitors(error))
|
||||
health_error_ = error;
|
||||
else
|
||||
health_error_ =
|
||||
"Display layout/DPI changed; coverage rebuilt, remote operation "
|
||||
"paused. Turn privacy off and reconnect to refresh display "
|
||||
"mapping.";
|
||||
"Display layout/DPI changed; privacy screen will turn off";
|
||||
}
|
||||
if (!health_error_.empty()) return {health_error_, false};
|
||||
BOOL composed = FALSE;
|
||||
@@ -349,8 +333,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
sizeof(cloaked))) ||
|
||||
cloaked) {
|
||||
return {
|
||||
"Privacy window visibility, geometry or capture affinity was lost; "
|
||||
"remote operation paused",
|
||||
"Privacy window visibility, geometry or capture affinity was lost",
|
||||
false};
|
||||
}
|
||||
}
|
||||
@@ -369,7 +352,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
if (above_band == 0 || above_band == 1) continue;
|
||||
if (std::any_of(windows_.begin(), windows_.end(),
|
||||
[above](const auto& own) {
|
||||
return above == own.cover || above == own.probe;
|
||||
return above == own.cover;
|
||||
}))
|
||||
continue;
|
||||
RECT bounds{}, intersection{};
|
||||
@@ -390,8 +373,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
now - last_obstruction_log_ >= 1000) {
|
||||
LOG_WARN(
|
||||
"Potential privacy cover overlap: class={}, process={}, "
|
||||
"band={}, bounds=({}, {})-({}, {}), exstyle=0x{:x}; "
|
||||
"remote operation paused",
|
||||
"band={}, bounds=({}, {})-({}, {}), exstyle=0x{:x}",
|
||||
window_class, process_id, above_band, bounds.left, bounds.top,
|
||||
bounds.right, bounds.bottom,
|
||||
GetWindowLongPtrW(above, GWL_EXSTYLE));
|
||||
@@ -399,10 +381,9 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
last_obstruction_log_ = now;
|
||||
}
|
||||
// HWND order cannot prove compositor order, but this window may
|
||||
// display content. Retain protection and require explicit recovery.
|
||||
// display content. Report failure so optional privacy is released.
|
||||
return {
|
||||
"A visible window may overlap privacy coverage; "
|
||||
"remote operation paused. Turn privacy off to recover.",
|
||||
"A visible window may overlap privacy coverage",
|
||||
false};
|
||||
}
|
||||
}
|
||||
@@ -410,100 +391,11 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
return {health_error_, false};
|
||||
}
|
||||
|
||||
public:
|
||||
bool PaintChallenge(uint32_t value, const std::string& title,
|
||||
std::string& error) override {
|
||||
using Pattern = PrivacyProbePattern;
|
||||
for (auto& w : windows_) {
|
||||
if (!w.probe && !CreateProbe(w, error)) return false;
|
||||
PrivacyProbeImage image;
|
||||
if (!RenderPrivacyProbe(title, PrivacyWindowDpi(w.probe),
|
||||
w.rect.right - w.rect.left - 2 * Pattern::kOffset,
|
||||
w.rect.bottom - w.rect.top - 2 * Pattern::kOffset,
|
||||
value, image, error)) {
|
||||
LOG_ERROR("Privacy verification panel: {}", error);
|
||||
return false;
|
||||
}
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC memory = CreateCompatibleDC(screen);
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
info.bmiHeader.biWidth = image.width;
|
||||
info.bmiHeader.biHeight = -image.height;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
HBITMAP bitmap =
|
||||
CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
bool ok = screen && memory && bitmap && bits;
|
||||
if (ok) {
|
||||
std::memcpy(bits, image.pixels.data(),
|
||||
image.pixels.size() * sizeof(uint32_t));
|
||||
const auto old = SelectObject(memory, bitmap);
|
||||
POINT position{w.rect.left + Pattern::kOffset,
|
||||
w.rect.top + Pattern::kOffset},
|
||||
source{};
|
||||
SIZE size{image.width, image.height};
|
||||
ok = old && old != HGDI_ERROR;
|
||||
if (ok) {
|
||||
ok = UpdateLayeredWindow(w.probe, screen, &position, &size, memory,
|
||||
&source, 0, nullptr, ULW_OPAQUE);
|
||||
SelectObject(memory, old);
|
||||
}
|
||||
}
|
||||
if (!ok) error = Error("Present opaque privacy verification panel");
|
||||
if (bitmap) DeleteObject(bitmap);
|
||||
if (memory) DeleteDC(memory);
|
||||
if (screen) ReleaseDC(nullptr, screen);
|
||||
if (!ok) return false;
|
||||
// Present only after the complete text and square bitmap is ready.
|
||||
if (!IsWindowVisible(w.probe) &&
|
||||
(!SetWindowPos(
|
||||
w.probe, HWND_TOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW) ||
|
||||
!IsWindowVisible(w.probe))) {
|
||||
error = Error("Show privacy verification panel beneath cover");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClearChallenges(std::string& error) override {
|
||||
for (auto& w : windows_) {
|
||||
Destroy(w.probe);
|
||||
if (w.probe) {
|
||||
error = "Could not remove the temporary capture verification window";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HasMonitor(int left, int top, int width, int height) const override {
|
||||
// Metadata only; also serialized against Poll by PrivacyController.
|
||||
return std::any_of(windows_.begin(), windows_.end(), [&](const auto& w) {
|
||||
return w.rect.left == left && w.rect.top == top &&
|
||||
w.rect.right - left == width && w.rect.bottom - top == height;
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
struct Window {
|
||||
RECT rect{};
|
||||
HWND cover = nullptr;
|
||||
HWND probe = nullptr;
|
||||
};
|
||||
|
||||
static void Destroy(HWND& hwnd) {
|
||||
if (hwnd && IsWindow(hwnd) && !DestroyWindow(hwnd)) {
|
||||
Error("DestroyWindow");
|
||||
return;
|
||||
}
|
||||
hwnd = nullptr;
|
||||
}
|
||||
|
||||
bool Initialize(std::string& error) {
|
||||
if (sink_) return true;
|
||||
// This dedicated thread owns no existing application windows. Never alter
|
||||
@@ -537,22 +429,6 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CreateProbe(Window& w, std::string& error) {
|
||||
constexpr DWORD styles = WS_EX_TOPMOST | WS_EX_TOOLWINDOW |
|
||||
WS_EX_NOACTIVATE | WS_EX_LAYERED |
|
||||
WS_EX_TRANSPARENT;
|
||||
w.probe =
|
||||
CreateWindowExW(styles, kClassName, L"CrossDesk capture verification",
|
||||
WS_POPUP, w.rect.left + PrivacyProbePattern::kOffset,
|
||||
w.rect.top + PrivacyProbePattern::kOffset, 1, 1,
|
||||
nullptr, nullptr, GetModuleHandleW(nullptr), this);
|
||||
if (!w.probe) {
|
||||
error = Error("CreateWindowExW capture challenge");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReconcileMonitors(std::string& error) {
|
||||
MonitorEnumeration monitors;
|
||||
if (!EnumDisplayMonitors(nullptr, nullptr, EnumMonitor,
|
||||
@@ -576,26 +452,18 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
error = "High-band broker has not covered every physical monitor";
|
||||
return false;
|
||||
}
|
||||
windows_.push_back({rect, reinterpret_cast<HWND>(cover->hwnd), nullptr});
|
||||
windows_.push_back({rect, reinterpret_cast<HWND>(cover->hwnd)});
|
||||
LOG_INFO(
|
||||
"Privacy high-band cover ready: bounds=({}, {})-({}, {}), "
|
||||
"affinity=0x11, alpha=255",
|
||||
rect.left, rect.top, rect.right, rect.bottom);
|
||||
}
|
||||
for (auto it = windows_.begin(); it != windows_.end();) {
|
||||
if (std::none_of(monitors.rects.begin(), monitors.rects.end(),
|
||||
[&](const RECT& r) { return SameRect(it->rect, r); })) {
|
||||
Destroy(it->probe);
|
||||
it->cover = nullptr; // The broker owns cover destruction.
|
||||
if (!it->probe)
|
||||
it = windows_.erase(it);
|
||||
else {
|
||||
error = "Old privacy window could not be released";
|
||||
return false;
|
||||
}
|
||||
} else
|
||||
++it;
|
||||
}
|
||||
// Cover windows are owned and released by the broker.
|
||||
windows_.erase(std::remove_if(windows_.begin(), windows_.end(),
|
||||
[&](const auto& window) {
|
||||
return std::none_of(monitors.rects.begin(), monitors.rects.end(),
|
||||
[&](const RECT& rect) { return SameRect(window.rect, rect); });
|
||||
}), windows_.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -619,14 +487,6 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
}
|
||||
if (msg == WM_NCHITTEST) return HTTRANSPARENT;
|
||||
if (msg == WM_CLOSE) return 0;
|
||||
if (msg == WM_PAINT) {
|
||||
// UpdateLayeredWindow owns the complete probe image; WM_PAINT only
|
||||
// validates the update region and never draws a second copy.
|
||||
PAINTSTRUCT paint{};
|
||||
BeginPaint(hwnd, &paint);
|
||||
EndPaint(hwnd, &paint);
|
||||
return 0;
|
||||
}
|
||||
if ((msg == WM_DISPLAYCHANGE || msg == WM_DPICHANGED) && self) {
|
||||
self->layout_changed_ = true;
|
||||
return 0;
|
||||
@@ -641,7 +501,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
hwnd == self->sink_ || GetAncestor(hwnd, GA_ROOT) != hwnd)
|
||||
return;
|
||||
for (const auto& w : self->windows_)
|
||||
if (hwnd == w.cover || hwnd == w.probe) return;
|
||||
if (hwnd == w.cover) return;
|
||||
// OUTOFCONTEXT delivers on this window thread. Only mark work here;
|
||||
// monitor/session queries and Z-order changes remain in the control loop.
|
||||
const auto get_band = reinterpret_cast<GetPrivacyWindowBand>(
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
#include "../screen_capturer/dxgi_cursor_state.h"
|
||||
#include "privacy_cursor_api.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
@@ -105,6 +106,9 @@ bool PrivacyCursorGuard::Start(std::string& error) {
|
||||
process_ = child.hProcess;
|
||||
stop_event_ = stop.value;
|
||||
stop.value = nullptr;
|
||||
// The helper may hide the pointer before signaling readiness. Track its
|
||||
// entire lifetime, including startup failure and delayed restoration.
|
||||
SharedDxgiCursorState().SetPrivacyCursorHidden(true);
|
||||
HANDLE startup_events[] = {ready.value, process_};
|
||||
const DWORD result = WaitForMultipleObjects(2, startup_events, FALSE, 2000);
|
||||
const DWORD ready_error =
|
||||
@@ -127,6 +131,7 @@ bool PrivacyCursorGuard::RestoreAfterFailure(std::string& error) {
|
||||
return Failed("Restore system cursor after helper failure", error);
|
||||
}
|
||||
restore_pending_ = false;
|
||||
SharedDxgiCursorState().SetPrivacyCursorHidden(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ bool PrivacyCursorGuard::Healthy(std::string& error) {
|
||||
if (restore_pending_ || (process_ && !helper_failure_reported_))
|
||||
RestoreAfterFailure(error);
|
||||
helper_failure_reported_ = true;
|
||||
error = "System cursor helper exited; remote operation paused";
|
||||
error = "System cursor helper exited; privacy screen will turn off";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -152,6 +157,7 @@ bool PrivacyCursorGuard::Stop(std::string& error) {
|
||||
CloseHandle(stop_event_);
|
||||
stop_event_ = nullptr;
|
||||
if (code != ERROR_SUCCESS) return RestoreAfterFailure(error);
|
||||
SharedDxgiCursorState().SetPrivacyCursorHidden(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
namespace crossdesk {
|
||||
|
||||
// The owner controls lifecycle; the dedicated message thread owns LL hooks.
|
||||
// Rendering, capture verification and session queries never run on this thread.
|
||||
// Rendering and session queries never run on this thread.
|
||||
class PrivacyInputGuard {
|
||||
public:
|
||||
~PrivacyInputGuard();
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "privacy_probe_renderer.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "privacy_probe_pattern.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
struct Canvas {
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
HFONT font = nullptr;
|
||||
HGDIOBJ old_font = nullptr;
|
||||
HBITMAP bitmap = nullptr;
|
||||
HGDIOBJ old_bitmap = nullptr;
|
||||
~Canvas() {
|
||||
if (old_bitmap) SelectObject(dc, old_bitmap);
|
||||
if (old_font) SelectObject(dc, old_font);
|
||||
if (bitmap) DeleteObject(bitmap);
|
||||
if (font) DeleteObject(font);
|
||||
if (dc) DeleteDC(dc);
|
||||
}
|
||||
};
|
||||
bool Failed(const char* operation, std::string& error) {
|
||||
error = std::string(operation) + " (Windows error " +
|
||||
std::to_string(GetLastError()) + ")";
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width,
|
||||
int maximum_height, uint32_t value,
|
||||
PrivacyProbeImage& image, std::string& error) {
|
||||
using Pattern = PrivacyProbePattern;
|
||||
const int length =
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(),
|
||||
static_cast<int>(title.size()), nullptr, 0);
|
||||
if (length <= 0) return Failed("Decode privacy verification text", error);
|
||||
std::wstring text(length, L'\0');
|
||||
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(),
|
||||
static_cast<int>(title.size()), text.data(), length))
|
||||
return Failed("Decode privacy verification text", error);
|
||||
const int available_text_width =
|
||||
maximum_width - Pattern::kTextLeft - Pattern::kPadding;
|
||||
if (available_text_width < 80 ||
|
||||
maximum_height < Pattern::kSide + 2 * Pattern::kPadding) {
|
||||
error = "Display is too small for the privacy verification panel";
|
||||
return false;
|
||||
}
|
||||
Canvas canvas;
|
||||
if (!canvas.dc)
|
||||
return Failed("Create privacy verification drawing context", error);
|
||||
canvas.font = CreateFontW(
|
||||
-MulDiv(28, static_cast<int>(dpi), 96), 0, 0, 0, FW_SEMIBOLD, FALSE,
|
||||
FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
|
||||
ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI");
|
||||
if (!canvas.font) return Failed("Create privacy verification font", error);
|
||||
canvas.old_font = SelectObject(canvas.dc, canvas.font);
|
||||
if (!canvas.old_font || canvas.old_font == HGDI_ERROR) {
|
||||
canvas.old_font = nullptr;
|
||||
return Failed("Select privacy verification font", error);
|
||||
}
|
||||
SIZE extent{};
|
||||
if (!GetTextExtentPoint32W(canvas.dc, text.data(), length, &extent))
|
||||
return Failed("Measure privacy verification text", error);
|
||||
const int text_width =
|
||||
(std::min)(available_text_width,
|
||||
(std::min)(static_cast<int>(extent.cx) + 4,
|
||||
MulDiv(520, static_cast<int>(dpi), 96)));
|
||||
RECT text_bounds{0, 0, text_width, 0};
|
||||
constexpr UINT text_flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX;
|
||||
if (!DrawTextW(canvas.dc, text.data(), length, &text_bounds,
|
||||
text_flags | DT_CALCRECT))
|
||||
return Failed("Lay out privacy verification text", error);
|
||||
const int width = Pattern::kTextLeft + text_width + Pattern::kPadding;
|
||||
const int height =
|
||||
(std::max)(Pattern::kSide, static_cast<int>(text_bounds.bottom)) +
|
||||
2 * Pattern::kPadding;
|
||||
if (height > maximum_height || text_bounds.right > text_width) {
|
||||
error =
|
||||
"Display is too small for localized privacy verification text at this "
|
||||
"DPI";
|
||||
return false;
|
||||
}
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
info.bmiHeader.biWidth = width;
|
||||
info.bmiHeader.biHeight = -height;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
canvas.bitmap =
|
||||
CreateDIBSection(canvas.dc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
if (!canvas.bitmap || !bits)
|
||||
return Failed("Create privacy verification image", error);
|
||||
canvas.old_bitmap = SelectObject(canvas.dc, canvas.bitmap);
|
||||
if (!canvas.old_bitmap || canvas.old_bitmap == HGDI_ERROR) {
|
||||
canvas.old_bitmap = nullptr;
|
||||
return Failed("Select privacy verification image", error);
|
||||
}
|
||||
auto pixels = static_cast<uint32_t*>(bits);
|
||||
std::fill(pixels, pixels + static_cast<size_t>(width) * height, 0xFFF4F6F9u);
|
||||
for (int y = 0; y < height; ++y)
|
||||
for (int x = 0; x < width; ++x)
|
||||
if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
|
||||
pixels[static_cast<size_t>(y) * width + x] = 0xFFD0D6E0u;
|
||||
SetBkMode(canvas.dc, TRANSPARENT);
|
||||
SetTextColor(canvas.dc, RGB(34, 46, 64));
|
||||
RECT text_rect{Pattern::kTextLeft, (height - text_bounds.bottom) / 2,
|
||||
width - Pattern::kPadding, (height + text_bounds.bottom) / 2};
|
||||
// Explicit clipping makes font fallback/overhang unable to reach the code.
|
||||
IntersectClipRect(canvas.dc, text_rect.left, text_rect.top, text_rect.right,
|
||||
text_rect.bottom);
|
||||
if (!DrawTextW(canvas.dc, text.data(), length, &text_rect, text_flags))
|
||||
return Failed("Draw privacy verification text", error);
|
||||
if (!GdiFlush()) return Failed("Flush privacy verification text", error);
|
||||
for (size_t index = 0; index < static_cast<size_t>(width) * height; ++index)
|
||||
pixels[index] |= 0xFF000000u;
|
||||
for (int row = 0; row < Pattern::kRows; ++row)
|
||||
for (int column = 0; column < Pattern::kColumns; ++column) {
|
||||
const uint32_t color =
|
||||
Pattern::White(value, column, row) ? 0xFFFFFFFFu : 0xFF000000u;
|
||||
for (int y = 0; y < Pattern::kCell; ++y)
|
||||
for (int x = 0; x < Pattern::kCell; ++x)
|
||||
pixels[static_cast<size_t>(Pattern::kPadding + row * Pattern::kCell +
|
||||
y) *
|
||||
width +
|
||||
Pattern::kPadding + column * Pattern::kCell + x] = color;
|
||||
}
|
||||
image.width = width;
|
||||
image.height = height;
|
||||
image.pixels.assign(pixels, pixels + static_cast<size_t>(width) * height);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_PROBE_RENDERER_H_
|
||||
#define _PRIVACY_PROBE_RENDERER_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct PrivacyProbeImage {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<uint32_t> pixels;
|
||||
};
|
||||
|
||||
// Produces an opaque BGRA panel. The localized text and the machine-readable
|
||||
// square occupy disjoint rectangles. Caller runs on the owning window thread.
|
||||
bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width,
|
||||
int maximum_height, uint32_t value,
|
||||
PrivacyProbeImage& image, std::string& error);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_PROBE_RENDERER_H_
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-16
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CAPTURED_CURSOR_STATE_H_
|
||||
#define _CAPTURED_CURSOR_STATE_H_
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Metadata belongs to the frame callback, not to the requested capture mode.
|
||||
// WGC's DLL adapter copies its frame metadata into this executable-local scope.
|
||||
inline thread_local bool current_frame_has_cursor = false;
|
||||
|
||||
class CapturedCursorFrameScope {
|
||||
public:
|
||||
explicit CapturedCursorFrameScope(bool embedded)
|
||||
: previous_(current_frame_has_cursor) {
|
||||
current_frame_has_cursor = embedded;
|
||||
}
|
||||
~CapturedCursorFrameScope() { current_frame_has_cursor = previous_; }
|
||||
CapturedCursorFrameScope(const CapturedCursorFrameScope&) = delete;
|
||||
CapturedCursorFrameScope& operator=(const CapturedCursorFrameScope&) = delete;
|
||||
|
||||
private:
|
||||
bool previous_;
|
||||
};
|
||||
|
||||
class CapturedCursorState {
|
||||
public:
|
||||
void Update(bool embedded, void* monitor) {
|
||||
embedded_monitor_.store(embedded ? monitor : nullptr,
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
void Reset() { Update(false, nullptr); }
|
||||
bool IsEmbedded(void* monitor) const {
|
||||
return monitor && embedded_monitor_.load(std::memory_order_relaxed) == monitor;
|
||||
}
|
||||
|
||||
private:
|
||||
// This opaque handle is the entire state; no other data needs publishing.
|
||||
std::atomic<void*> embedded_monitor_{nullptr};
|
||||
};
|
||||
|
||||
inline CapturedCursorState& SharedCapturedCursorState() {
|
||||
static CapturedCursorState state;
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-16
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CURSOR_DRAW_H_
|
||||
#define _CURSOR_DRAW_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// ptScreenPos is the input hotspot; DrawIconEx expects the bitmap's top-left.
|
||||
// Return true only if a cursor intersects the captured rectangle and was drawn.
|
||||
inline bool DrawCursorInCapture(HDC dc, const CURSORINFO& cursor, int left,
|
||||
int top, int width, int height) {
|
||||
if (!(cursor.flags & CURSOR_SHOWING) || !cursor.hCursor) return false;
|
||||
struct CursorIconInfo : ICONINFO {
|
||||
CursorIconInfo() : ICONINFO{} {}
|
||||
~CursorIconInfo() {
|
||||
if (hbmColor) DeleteObject(hbmColor);
|
||||
if (hbmMask) DeleteObject(hbmMask);
|
||||
}
|
||||
} icon;
|
||||
if (!GetIconInfo(cursor.hCursor, &icon)) return false;
|
||||
BITMAP bitmap{};
|
||||
if (!GetObject(icon.hbmColor ? icon.hbmColor : icon.hbmMask, sizeof(bitmap),
|
||||
&bitmap))
|
||||
return false;
|
||||
const int cursor_width = bitmap.bmWidth;
|
||||
const int cursor_height =
|
||||
icon.hbmColor ? bitmap.bmHeight : bitmap.bmHeight / 2;
|
||||
const int64_t x =
|
||||
static_cast<int64_t>(cursor.ptScreenPos.x) - left - icon.xHotspot;
|
||||
const int64_t y =
|
||||
static_cast<int64_t>(cursor.ptScreenPos.y) - top - icon.yHotspot;
|
||||
if (x >= width || y >= height || x + cursor_width <= 0 ||
|
||||
y + cursor_height <= 0)
|
||||
return false;
|
||||
return DrawIconEx(dc, static_cast<int>(x), static_cast<int>(y),
|
||||
cursor.hCursor, 0, 0, 0, nullptr, DI_NORMAL) != FALSE;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifndef CROSSDESK_DXGI_CURSOR_STATE_H_
|
||||
#define CROSSDESK_DXGI_CURSOR_STATE_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// DXGI's Visible flag describes the separate hardware pointer, not the
|
||||
// system cursor. When it is false, a visible system cursor can already be
|
||||
// included in the desktop texture (for example, during window dragging).
|
||||
// Share that distinction with the GUI thread without changing cursor input
|
||||
// coordinates or the wire protocol.
|
||||
class DxgiCursorState {
|
||||
public:
|
||||
void Update(int64_t last_mouse_update_time, bool separate_pointer_visible,
|
||||
void* monitor) {
|
||||
// PointerPosition is undefined on desktop-only updates. Retain the last
|
||||
// pointer state until DXGI actually reports another pointer update.
|
||||
if (last_mouse_update_time == 0) return;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (privacy_cursor_hidden_) return;
|
||||
embedded_monitor_ = separate_pointer_visible ? nullptr : monitor;
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
embedded_monitor_ = nullptr;
|
||||
}
|
||||
|
||||
void SetPrivacyCursorHidden(bool hidden) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (privacy_cursor_hidden_ == hidden) return;
|
||||
privacy_cursor_hidden_ = hidden;
|
||||
// Pointer updates from before/during suppression cannot describe whether
|
||||
// the restored cursor is embedded. Wait for a fresh DXGI pointer update.
|
||||
embedded_monitor_ = nullptr;
|
||||
}
|
||||
|
||||
bool ShouldDrawCursor(bool system_cursor_visible, void* cursor_monitor) const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
// MagShowSystemCursor(FALSE) hides the local pointer plane without changing
|
||||
// CURSOR_SHOWING. DXGI Visible=false then means hidden, not embedded in the
|
||||
// captured frame. Keep sending the application's cursor to the controller.
|
||||
// CURSOR_SHOWING still takes precedence when the application hides it.
|
||||
if (privacy_cursor_hidden_) return system_cursor_visible;
|
||||
// A hidden cursor or a pointer on another monitor also makes DXGI report
|
||||
// Visible=false. Only suppress a visible cursor on the captured monitor.
|
||||
return system_cursor_visible &&
|
||||
(!cursor_monitor || embedded_monitor_ != cursor_monitor);
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
void* embedded_monitor_ = nullptr;
|
||||
bool privacy_cursor_hidden_ = false;
|
||||
};
|
||||
|
||||
inline DxgiCursorState& SharedDxgiCursorState() {
|
||||
static DxgiCursorState state;
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_DXGI_CURSOR_STATE_H_
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dxgi_cursor_state.h"
|
||||
#include "libyuv.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
@@ -232,6 +233,7 @@ void ScreenCapturerDxgi::EnumerateDisplays() {
|
||||
}
|
||||
|
||||
bool ScreenCapturerDxgi::CreateDuplicationForMonitor(int monitor_index) {
|
||||
SharedDxgiCursorState().Reset();
|
||||
if (monitor_index < 0 || monitor_index >= (int)outputs_.size()) return false;
|
||||
Microsoft::WRL::ComPtr<IDXGIOutput1> output1;
|
||||
HRESULT hr = outputs_[monitor_index]->QueryInterface(
|
||||
@@ -284,6 +286,7 @@ bool ScreenCapturerDxgi::RecreateDuplicationForCurrentMonitor() {
|
||||
}
|
||||
|
||||
void ScreenCapturerDxgi::ReleaseDuplication() {
|
||||
SharedDxgiCursorState().Reset();
|
||||
++duplication_generation_;
|
||||
staging_.Reset();
|
||||
if (duplication_) {
|
||||
@@ -330,8 +333,7 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
timeout_ms, &frame_info, desktop_resource.GetAddressOf());
|
||||
if (hr == DXGI_ERROR_WAIT_TIMEOUT) {
|
||||
// DXGI explicitly reports an unchanged desktop. Re-emit only a frame
|
||||
// successfully acquired for this monitor, so a static screen still has
|
||||
// a delivery heartbeat after temporary privacy probes are removed.
|
||||
// successfully acquired for this monitor to keep delivering static frames.
|
||||
if (cached_frame_valid && cached_generation == duplication_generation_ &&
|
||||
cached_monitor == monitor_index_.load() && callback_ && nv12_frame_) {
|
||||
const auto stream_id = MakeDisplayStreamId(cached_monitor);
|
||||
@@ -342,9 +344,10 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
continue;
|
||||
}
|
||||
// Never replay a cached image after an acquisition/conversion failure or
|
||||
// a backend rebuild. Normal error/reset handling must pause privacy.
|
||||
// a backend rebuild. Normal error/reset handling releases optional privacy.
|
||||
cached_frame_valid = false;
|
||||
if (FAILED(hr)) {
|
||||
SharedDxgiCursorState().Reset();
|
||||
LOG_ERROR("DXGI: AcquireNextFrame failed, hr={}", (int)hr);
|
||||
capture_lock.unlock();
|
||||
if (callback_)
|
||||
@@ -353,6 +356,14 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame_monitor >= 0 &&
|
||||
frame_monitor < static_cast<int>(display_info_list_.size())) {
|
||||
SharedDxgiCursorState().Update(
|
||||
frame_info.LastMouseUpdateTime.QuadPart,
|
||||
frame_info.PointerPosition.Visible != FALSE,
|
||||
display_info_list_[frame_monitor].handle);
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> acquired_tex;
|
||||
if (desktop_resource) {
|
||||
hr = desktop_resource->QueryInterface(
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "captured_cursor_state.h"
|
||||
#include "cursor_draw.h"
|
||||
#include "libyuv.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
@@ -184,18 +186,15 @@ void ScreenCapturerGdi::CaptureLoop() {
|
||||
BitBlt(mem_dc, 0, 0, width, height, screen_dc, left, top,
|
||||
SRCCOPY | CAPTUREBLT);
|
||||
|
||||
bool cursor_embedded = false;
|
||||
if (show_cursor_) {
|
||||
CURSORINFO ci{};
|
||||
ci.cbSize = sizeof(CURSORINFO);
|
||||
if (GetCursorInfo(&ci) && ci.flags == CURSOR_SHOWING && ci.hCursor) {
|
||||
POINT pt = ci.ptScreenPos;
|
||||
int cx = pt.x - left;
|
||||
int cy = pt.y - top;
|
||||
if (cx >= -64 && cy >= -64 && cx < width + 64 && cy < height + 64) {
|
||||
DrawIconEx(mem_dc, cx, cy, ci.hCursor, 0, 0, 0, nullptr, DI_NORMAL);
|
||||
}
|
||||
if (GetCursorInfo(&ci)) {
|
||||
cursor_embedded = DrawCursorInCapture(mem_dc, ci, left, top, width, height);
|
||||
}
|
||||
}
|
||||
GdiFlush();
|
||||
|
||||
int stride_argb = width * 4;
|
||||
int nv12_size = width * height * 3 / 2;
|
||||
@@ -211,6 +210,7 @@ void ScreenCapturerGdi::CaptureLoop() {
|
||||
width, height);
|
||||
|
||||
if (callback_) {
|
||||
CapturedCursorFrameScope cursor_scope(cursor_embedded);
|
||||
const std::string stream_id = MakeDisplayStreamId(idx);
|
||||
callback_(nv12_frame_, nv12_size, width, height, stream_id.c_str(),
|
||||
nullptr);
|
||||
|
||||
@@ -31,6 +31,9 @@ class ScreenCapturerGdi : public ScreenCapturer {
|
||||
int Destroy() override;
|
||||
int Start(bool show_cursor) override;
|
||||
int Stop() override;
|
||||
void SetCursorCapture(bool enabled) {
|
||||
show_cursor_.store(enabled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int Pause(int monitor_index) override;
|
||||
int Resume(int monitor_index) override;
|
||||
@@ -65,4 +68,4 @@ class ScreenCapturerGdi : public ScreenCapturer {
|
||||
};
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <iostream>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "captured_cursor_state.h"
|
||||
#include "libyuv.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
@@ -72,6 +73,16 @@ HMONITOR GetPrimaryMonitor() {
|
||||
|
||||
ScreenCapturerWgc::ScreenCapturerWgc() : monitor_(nullptr) {}
|
||||
|
||||
int ScreenCapturerWgc::SetCursorCapture(bool enabled) {
|
||||
int result = 0;
|
||||
for (auto& session : sessions_) {
|
||||
if (!session.inited_ || !session.running_) continue;
|
||||
const int ret = session.session_->Start(enabled);
|
||||
if (ret != 0) result = ret;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ScreenCapturerWgc::~ScreenCapturerWgc() {
|
||||
Stop();
|
||||
CleanUp();
|
||||
@@ -409,6 +420,7 @@ void ScreenCapturerWgc::OnFrame(const WgcSession::wgc_session_frame& frame,
|
||||
even_width, even_width, even_height);
|
||||
|
||||
const std::string stream_id = MakeDisplayStreamId(id);
|
||||
CapturedCursorFrameScope cursor_scope(frame.cursor_captured);
|
||||
on_data_(nv12_frame_, nv12_size, even_width, even_height,
|
||||
stream_id.c_str(), nullptr);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class ScreenCapturerWgc : public ScreenCapturer,
|
||||
int Destroy() override;
|
||||
int Start(bool show_cursor) override;
|
||||
int Stop() override;
|
||||
int SetCursorCapture(bool enabled);
|
||||
|
||||
int Pause(int monitor_index) override;
|
||||
int Resume(int monitor_index) override;
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "captured_nv12_frame.h"
|
||||
#include "captured_cursor_state.h"
|
||||
#include "dxgi_cursor_state.h"
|
||||
#include "interactive_state.h"
|
||||
#include "named_pipe_deadline.h"
|
||||
#include "rd_log.h"
|
||||
#include "screen_capturer_dxgi.h"
|
||||
#include "screen_capturer_gdi.h"
|
||||
#include "secure_desktop_cursor_state.h"
|
||||
#include "secure_desktop_frame_schedule.h"
|
||||
#include "secure_desktop_status_poller.h"
|
||||
#include "service_host.h"
|
||||
@@ -33,6 +36,8 @@ namespace {
|
||||
|
||||
using Json = nlohmann::json;
|
||||
|
||||
thread_local bool current_frame_from_secure_desktop = false;
|
||||
|
||||
constexpr DWORD kSecureDesktopStatusIntervalMs = 250;
|
||||
constexpr DWORD kSecureDesktopStatusPipeTimeoutMs = 500;
|
||||
constexpr DWORD kSecureDesktopHelperPipeTimeoutMs = 120;
|
||||
@@ -48,6 +53,8 @@ class WgcPluginCapturer final : public ScreenCapturer {
|
||||
public:
|
||||
using CreateFn = ScreenCapturer* (*)();
|
||||
using DestroyFn = void (*)(ScreenCapturer*);
|
||||
using SetCursorFn = decltype(&CrossDeskSetWgcCursorCapture);
|
||||
using FrameCursorFn = decltype(&CrossDeskWgcFrameCapturesCursor);
|
||||
|
||||
static std::unique_ptr<ScreenCapturer> Create() {
|
||||
std::filesystem::path plugin_path;
|
||||
@@ -95,12 +102,38 @@ class WgcPluginCapturer final : public ScreenCapturer {
|
||||
}
|
||||
|
||||
int Init(const int fps, cb_desktop_data cb) override {
|
||||
return impl_ ? impl_->Init(fps, std::move(cb)) : -1;
|
||||
if (!impl_) return -1;
|
||||
return impl_->Init(fps, [this, cb = std::move(cb)](
|
||||
unsigned char* data, int size, int width, int height,
|
||||
const char* stream_id, const MiniRtcNativeVideoFrame* native_frame) {
|
||||
bool embedded = false;
|
||||
if (frame_cursor_fn_) {
|
||||
embedded = frame_cursor_fn_();
|
||||
} else if (legacy_capture_cursor_.load(std::memory_order_relaxed)) {
|
||||
CURSORINFO info{};
|
||||
info.cbSize = sizeof(info);
|
||||
embedded = GetCursorInfo(&info) && (info.flags & CURSOR_SHOWING) != 0;
|
||||
}
|
||||
// The plugin has its own globals. Bridge only this callback's metadata
|
||||
// into the executable before the wrapper accepts/publishes the frame.
|
||||
CapturedCursorFrameScope cursor_scope(embedded);
|
||||
cb(data, size, width, height, stream_id, native_frame);
|
||||
});
|
||||
}
|
||||
int Destroy() override { return impl_ ? impl_->Destroy() : 0; }
|
||||
int Start(bool show_cursor) override {
|
||||
if (!frame_cursor_fn_) {
|
||||
legacy_capture_cursor_.store(show_cursor, std::memory_order_relaxed);
|
||||
}
|
||||
return impl_ ? impl_->Start(show_cursor) : -1;
|
||||
}
|
||||
int SetCursorCapture(bool enabled) {
|
||||
if (!impl_) return -1;
|
||||
if (set_cursor_fn_) return set_cursor_fn_(impl_, enabled);
|
||||
// Older plugins only apply the cursor setting at startup.
|
||||
const int ret = impl_->Stop();
|
||||
return ret == 0 ? Start(enabled) : ret;
|
||||
}
|
||||
int Stop() override { return impl_ ? impl_->Stop() : 0; }
|
||||
int Pause(int monitor_index) override {
|
||||
return impl_ ? impl_->Pause(monitor_index) : -1;
|
||||
@@ -120,11 +153,21 @@ class WgcPluginCapturer final : public ScreenCapturer {
|
||||
|
||||
private:
|
||||
WgcPluginCapturer(HMODULE module, ScreenCapturer* impl, DestroyFn destroy_fn)
|
||||
: module_(module), impl_(impl), destroy_fn_(destroy_fn) {}
|
||||
: module_(module), impl_(impl), destroy_fn_(destroy_fn) {
|
||||
set_cursor_fn_ = reinterpret_cast<SetCursorFn>(
|
||||
GetProcAddress(module, "CrossDeskSetWgcCursorCapture"));
|
||||
frame_cursor_fn_ = reinterpret_cast<FrameCursorFn>(
|
||||
GetProcAddress(module, "CrossDeskWgcFrameCapturesCursor"));
|
||||
// Use the legacy restart path unless both parts of the extension exist.
|
||||
if (!frame_cursor_fn_) set_cursor_fn_ = nullptr;
|
||||
}
|
||||
|
||||
HMODULE module_ = nullptr;
|
||||
ScreenCapturer* impl_ = nullptr;
|
||||
DestroyFn destroy_fn_ = nullptr;
|
||||
SetCursorFn set_cursor_fn_ = nullptr;
|
||||
FrameCursorFn frame_cursor_fn_ = nullptr;
|
||||
std::atomic<bool> legacy_capture_cursor_{false};
|
||||
};
|
||||
|
||||
const char* CaptureBackendName(const ScreenCapturer* capturer) {
|
||||
@@ -222,6 +265,7 @@ bool ParseSecureDesktopFrameResponse(const std::vector<uint8_t>& response,
|
||||
*width_out = static_cast<int>(header.width);
|
||||
*height_out = static_cast<int>(header.height);
|
||||
nv12_frame_out->assign(response.begin() + sizeof(header), response.end());
|
||||
SharedSecureDesktopCursorState().Update(header.cursor);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -339,7 +383,7 @@ ScreenCapturerWin::~ScreenCapturerWin() { Destroy(); }
|
||||
|
||||
void ScreenCapturerWin::NotifyPrivacyCapture(bool running) {
|
||||
if (privacy_)
|
||||
privacy_->CaptureChanged(CaptureBackendName(impl_.get()), running);
|
||||
privacy_->CaptureChanged(running);
|
||||
}
|
||||
|
||||
int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
|
||||
@@ -358,16 +402,19 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
|
||||
const char* reported_stream_id,
|
||||
const MiniRtcNativeVideoFrame* native_frame) {
|
||||
if (size == ScreenCapturer::kBackendReset) {
|
||||
if (privacy_) privacy_->CaptureInterrupted();
|
||||
SharedCapturedCursorState().Reset();
|
||||
if (privacy_) privacy_->Fail("Capture restarted; privacy screen will turn off");
|
||||
return;
|
||||
}
|
||||
if (secure_desktop_capture_active_.load(std::memory_order_relaxed)) {
|
||||
SharedDxgiCursorState().Reset();
|
||||
SharedCapturedCursorState().Reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const char* raw_stream_id = reported_stream_id ? reported_stream_id : "";
|
||||
std::string mapped_stream_id;
|
||||
DisplayInfo privacy_display{"", 0, 0, 0, 0};
|
||||
void* frame_monitor = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(alias_mutex_);
|
||||
auto it = stream_id_alias_.find(raw_stream_id);
|
||||
@@ -381,21 +428,17 @@ 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 (current_frame_has_cursor) {
|
||||
for (size_t index = 0; index < canonical_displays_.size(); ++index) {
|
||||
if (mapped_stream_id == MakeDisplayStreamId(index)) {
|
||||
frame_monitor = canonical_displays_[index].handle;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (privacy_) {
|
||||
if (privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
|
||||
privacy_->Fail("Secure or unavailable desktop; privacy cannot cover system security UI. Remote operation paused.");
|
||||
return;
|
||||
}
|
||||
if (!privacy_->ObserveFrame(data, size > 0 ? static_cast<size_t>(size) : 0,
|
||||
w, h, privacy_display.left, privacy_display.top,
|
||||
privacy_display.width, privacy_display.height)) return;
|
||||
if (privacy_ && privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
|
||||
privacy_->Fail("Secure or unavailable desktop; privacy screen will turn off");
|
||||
}
|
||||
if (mapped_stream_id.empty()) {
|
||||
if (!invalid_stream_id_logged_.exchange(true,
|
||||
@@ -407,6 +450,7 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
|
||||
return;
|
||||
}
|
||||
invalid_stream_id_logged_.store(false, std::memory_order_relaxed);
|
||||
SharedCapturedCursorState().Update(current_frame_has_cursor, frame_monitor);
|
||||
if (post_secure_desktop_waiting_for_frame_.exchange(
|
||||
false, std::memory_order_relaxed)) {
|
||||
const ULONGLONG start_tick =
|
||||
@@ -473,20 +517,37 @@ int ScreenCapturerWin::Destroy() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool ScreenCapturerWin::CurrentFrameIsFromSecureDesktop() {
|
||||
return current_frame_from_secure_desktop;
|
||||
}
|
||||
|
||||
void ScreenCapturerWin::EmitCapturedFrame(
|
||||
unsigned char* data, int size, int width, int height,
|
||||
const char* stream_id, const MiniRtcNativeVideoFrame* native_frame,
|
||||
bool from_secure_desktop) {
|
||||
// A helper IPC already in flight when enable began must not bypass the
|
||||
// verified ordinary-desktop callback when it completes later.
|
||||
// Helper frames do not use DXGI's pointer plane. Do not carry normal-desktop
|
||||
// cursor suppression into the lock screen or UAC desktop.
|
||||
if (from_secure_desktop) {
|
||||
SharedDxgiCursorState().Reset();
|
||||
SharedCapturedCursorState().Reset();
|
||||
}
|
||||
// Secure-desktop capture takes priority; remove privacy without dropping
|
||||
// the helper frame or delaying the remote session.
|
||||
if (from_secure_desktop && privacy_ && privacy_->Engaged()) {
|
||||
privacy_->Fail("Secure desktop helper frame arrived during privacy; remote operation paused");
|
||||
return;
|
||||
}
|
||||
if (!cb_orig_ || (privacy_ && !privacy_->RemoteAllowed())) {
|
||||
privacy_->Fail("Secure desktop helper frame arrived; privacy screen will turn off");
|
||||
}
|
||||
if (!cb_orig_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal and helper callbacks can originate on different threads during
|
||||
// desktop transitions. Keep diagnostics tied to this callback's source.
|
||||
struct RestoreFrameSource {
|
||||
bool previous;
|
||||
~RestoreFrameSource() { current_frame_from_secure_desktop = previous; }
|
||||
} restore_source{
|
||||
std::exchange(current_frame_from_secure_desktop, from_secure_desktop)};
|
||||
|
||||
if (native_frame) {
|
||||
if (!native_output_logged_.exchange(true, std::memory_order_relaxed)) {
|
||||
LOG_INFO("Windows capturer native frame output enabled (type={})",
|
||||
@@ -536,9 +597,10 @@ bool ScreenCapturerWin::TryStartBackend(
|
||||
return false;
|
||||
}
|
||||
const char* name = CaptureBackendName(candidate.get());
|
||||
const bool show_cursor = show_cursor_.load(std::memory_order_relaxed);
|
||||
int ret = candidate->Init(fps_, cb_);
|
||||
if (ret == 0)
|
||||
ret = candidate->Start(show_cursor_.load(std::memory_order_relaxed));
|
||||
ret = candidate->Start(show_cursor);
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Windows capturer: {} initialization/start failed (ret={})", name,
|
||||
ret);
|
||||
@@ -548,6 +610,7 @@ bool ScreenCapturerWin::TryStartBackend(
|
||||
// Commit only after the replacement is running. Failed candidates release
|
||||
// their resources without replacing the current backend.
|
||||
impl_ = std::move(candidate);
|
||||
applied_show_cursor_ = show_cursor;
|
||||
RestoreMonitor(monitor_index);
|
||||
LOG_INFO("Windows capturer: started {}", name);
|
||||
return true;
|
||||
@@ -561,6 +624,8 @@ int ScreenCapturerWin::Start(bool show_cursor) {
|
||||
NotifyPrivacyCapture(false);
|
||||
|
||||
show_cursor_.store(show_cursor, std::memory_order_relaxed);
|
||||
applied_show_cursor_ = show_cursor;
|
||||
SharedCapturedCursorState().Reset();
|
||||
paused_.store(false, std::memory_order_relaxed);
|
||||
invalid_stream_id_logged_.store(false, std::memory_order_relaxed);
|
||||
|
||||
@@ -607,6 +672,7 @@ int ScreenCapturerWin::Start(bool show_cursor) {
|
||||
|
||||
running_.store(true, std::memory_order_relaxed);
|
||||
NotifyPrivacyCapture(true);
|
||||
SharedSecureDesktopCursorState().SetActive(false);
|
||||
secure_desktop_capture_active_.store(false, std::memory_order_relaxed);
|
||||
post_secure_desktop_waiting_for_frame_.store(false,
|
||||
std::memory_order_relaxed);
|
||||
@@ -628,10 +694,13 @@ int ScreenCapturerWin::Stop() {
|
||||
post_secure_desktop_drop_logged_.store(false, std::memory_order_relaxed);
|
||||
post_secure_desktop_started_tick_.store(0, std::memory_order_relaxed);
|
||||
int ret = 0;
|
||||
// Drain management operations before stopping/replacing their backend.
|
||||
StopSecureCaptureThread();
|
||||
if (impl_) {
|
||||
ret = impl_->Stop();
|
||||
}
|
||||
StopSecureCaptureThread();
|
||||
SharedCapturedCursorState().Reset();
|
||||
SharedSecureDesktopCursorState().SetActive(false);
|
||||
StopSecureDesktopSharedCapture(secure_shared_session_id_);
|
||||
return ret;
|
||||
}
|
||||
@@ -650,12 +719,11 @@ int ScreenCapturerWin::Resume(int monitor_index) {
|
||||
|
||||
int ScreenCapturerWin::SwitchTo(int monitor_index) {
|
||||
if (!impl_) return -1;
|
||||
if (privacy_) privacy_->DisplayChanging();
|
||||
const int ret = impl_->SwitchTo(monitor_index);
|
||||
if (ret == 0) {
|
||||
monitor_index_.store(monitor_index, std::memory_order_relaxed);
|
||||
} else if (privacy_) {
|
||||
privacy_->Fail("Display switch failed; remote operation paused");
|
||||
privacy_->Fail("Display switch failed; privacy screen will turn off");
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@@ -790,6 +858,7 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
|
||||
impl_->Stop();
|
||||
int ret = impl_->Start(show_cursor);
|
||||
if (ret == 0) {
|
||||
applied_show_cursor_ = show_cursor;
|
||||
RestoreMonitor(current_monitor);
|
||||
return true;
|
||||
}
|
||||
@@ -804,6 +873,7 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
|
||||
ret = impl_->Start(show_cursor);
|
||||
}
|
||||
if (ret == 0) {
|
||||
applied_show_cursor_ = show_cursor;
|
||||
RestoreMonitor(current_monitor);
|
||||
return true;
|
||||
}
|
||||
@@ -826,6 +896,24 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
|
||||
return false;
|
||||
}
|
||||
|
||||
void ScreenCapturerWin::ApplyCursorCaptureSetting() {
|
||||
const bool requested = show_cursor_.load(std::memory_order_relaxed);
|
||||
if (!impl_ || requested == applied_show_cursor_) return;
|
||||
int ret = 0;
|
||||
if (auto* gdi = dynamic_cast<ScreenCapturerGdi*>(impl_.get())) {
|
||||
gdi->SetCursorCapture(requested);
|
||||
} else if (auto* wgc = dynamic_cast<WgcPluginCapturer*>(impl_.get())) {
|
||||
ret = wgc->SetCursorCapture(requested);
|
||||
}
|
||||
if (ret == 0) {
|
||||
applied_show_cursor_ = requested;
|
||||
LOG_INFO("Windows capturer cursor capture updated: backend={}, enabled={}",
|
||||
CaptureBackendName(impl_.get()), requested);
|
||||
} else {
|
||||
LOG_WARN("Windows capturer cursor update failed, ret={}; will retry", ret);
|
||||
}
|
||||
}
|
||||
|
||||
bool ScreenCapturerWin::GetCurrentCaptureRegion(int* left, int* top, int* width,
|
||||
int* height,
|
||||
std::string* display_name) {
|
||||
@@ -1014,6 +1102,9 @@ bool ScreenCapturerWin::ReadSecureDesktopSharedFrame(
|
||||
}
|
||||
|
||||
nv12_frame_out->resize(payload_size);
|
||||
const SecureDesktopCursorSnapshot cursor = header->cursor;
|
||||
const int captured_width = static_cast<int>(header->width);
|
||||
const int captured_height = static_cast<int>(header->height);
|
||||
std::memcpy(nv12_frame_out->data(), secure_frame_view_ + sizeof(*header),
|
||||
payload_size);
|
||||
MemoryBarrier();
|
||||
@@ -1024,8 +1115,9 @@ bool ScreenCapturerWin::ReadSecureDesktopSharedFrame(
|
||||
return false;
|
||||
}
|
||||
|
||||
*width_out = static_cast<int>(header->width);
|
||||
*height_out = static_cast<int>(header->height);
|
||||
*width_out = captured_width;
|
||||
*height_out = captured_height;
|
||||
SharedSecureDesktopCursorState().Update(cursor);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1101,6 +1193,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
bool post_secure_restart_pending = false;
|
||||
ULONGLONG post_secure_restart_deadline_tick = 0;
|
||||
ULONGLONG last_post_secure_restart_tick = 0;
|
||||
ULONGLONG next_cursor_update_tick = 0;
|
||||
SecureDesktopServiceStatus status;
|
||||
SecureDesktopStatusPoller status_poller(
|
||||
QuerySecureDesktopServiceStatus,
|
||||
@@ -1126,15 +1219,25 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
max_status_ms = 0;
|
||||
};
|
||||
std::vector<uint8_t> secure_frame;
|
||||
ULONGLONG last_shared_error_tick = 0;
|
||||
auto report_shared_error = [&](const char* operation,
|
||||
const std::string& error) {
|
||||
const ULONGLONG now = GetTickCount64();
|
||||
if (last_shared_error_tick == 0 || now - last_shared_error_tick >= 5000) {
|
||||
LOG_WARN(
|
||||
"Windows capturer secure shared capture {} failed; using pipe "
|
||||
"fallback, stage='{}', session_id={}, error={}",
|
||||
operation, status.interactive_stage, status.active_session_id, error);
|
||||
last_shared_error_tick = now;
|
||||
}
|
||||
};
|
||||
|
||||
while (running_.load(std::memory_order_relaxed)) {
|
||||
// A privacy session must NEVER continue using the helper's secure-desktop
|
||||
// GDI frames. Its HWNDs belong to the ordinary interactive desktop only.
|
||||
// Privacy covers belong to the ordinary desktop. Drop privacy on a
|
||||
// secure desktop, while continuing the normal helper capture path.
|
||||
if (privacy_ && privacy_->Engaged()) {
|
||||
if (!IsWindowsPrivacyDesktopAvailable())
|
||||
privacy_->Fail("Secure desktop or lock screen; remote operation paused. Privacy cannot cover Windows security UI.");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
continue;
|
||||
privacy_->Fail("Secure desktop or lock screen; privacy screen will turn off. Privacy cannot cover Windows security UI.");
|
||||
}
|
||||
if (paused_.load(std::memory_order_relaxed)) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
@@ -1170,6 +1273,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
|
||||
secure_desktop_capture_active_.store(status.capture_active,
|
||||
std::memory_order_relaxed);
|
||||
SharedSecureDesktopCursorState().SetActive(status.capture_active);
|
||||
if (status.capture_active != previous.capture_active ||
|
||||
status.interactive_stage != previous.interactive_stage ||
|
||||
status.active_session_id != previous.active_session_id ||
|
||||
@@ -1227,6 +1331,10 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
!RestartCaptureBackendAfterSecureDesktop();
|
||||
}
|
||||
}
|
||||
if (!post_secure_restart_pending && now >= next_cursor_update_tick) {
|
||||
next_cursor_update_tick = now + 500;
|
||||
ApplyCursorCaptureSetting();
|
||||
}
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::milliseconds(status.service_available ? 50 : 200));
|
||||
continue;
|
||||
@@ -1274,6 +1382,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
status.interactive_stage, status.interactive_desktop, show_cursor,
|
||||
shared_fps, &error_message);
|
||||
if (!shared_ready) {
|
||||
report_shared_error("start", error_message);
|
||||
frame_schedule.OnStartFailure(GetTickCount64());
|
||||
} else if (!was_started) {
|
||||
frame_schedule.Reset(GetTickCount64());
|
||||
@@ -1293,6 +1402,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
IsPendingSecureDesktopFrame(error_message);
|
||||
if (frame_pending) ++shared_waits;
|
||||
if (shared_ready && !frame_delivered && !frame_pending) {
|
||||
report_shared_error("read", error_message);
|
||||
StopSecureDesktopSharedCapture(secure_shared_session_id_);
|
||||
frame_schedule.OnStartFailure(GetTickCount64());
|
||||
}
|
||||
@@ -1356,6 +1466,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
report_stats(true);
|
||||
StopSecureDesktopSharedCapture(secure_shared_session_id_);
|
||||
secure_desktop_capture_active_.store(false, std::memory_order_relaxed);
|
||||
SharedSecureDesktopCursorState().SetActive(false);
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -37,6 +37,10 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
int Destroy() override;
|
||||
int Start(bool show_cursor) override;
|
||||
int Stop() override;
|
||||
// Applied on the capture-management thread, including secure-desktop capture.
|
||||
void SetCursorCapture(bool enabled) {
|
||||
show_cursor_.store(enabled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
int Pause(int monitor_index) override;
|
||||
int Resume(int monitor_index) override;
|
||||
@@ -45,9 +49,12 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
int ResetToInitialMonitor() override;
|
||||
|
||||
std::vector<DisplayInfo> GetDisplayInfoList() override;
|
||||
void SetPrivacyController(PrivacyController* privacy) { privacy_ = privacy; }
|
||||
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
|
||||
// Set before Init(), while capture is stopped.
|
||||
void SetCaptureMethod(ScreenCaptureMethod method) { capture_method_ = method; }
|
||||
// Query synchronously inside the frame callback, on the delivering thread.
|
||||
// This describes the actual frame, independently of service status polling.
|
||||
static bool CurrentFrameIsFromSecureDesktop();
|
||||
|
||||
private:
|
||||
std::unique_ptr<ScreenCapturer> impl_;
|
||||
@@ -72,6 +79,7 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> paused_{false};
|
||||
std::atomic<bool> show_cursor_{true};
|
||||
bool applied_show_cursor_ = true;
|
||||
std::atomic<int> monitor_index_{0};
|
||||
int initial_monitor_index_ = 0;
|
||||
std::atomic<bool> secure_desktop_capture_active_{false};
|
||||
@@ -106,6 +114,7 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
bool from_secure_desktop = false);
|
||||
void StopSecureCaptureThread();
|
||||
bool RestartCaptureBackendAfterSecureDesktop();
|
||||
void ApplyCursorCaptureSetting();
|
||||
void SecureDesktopCaptureLoop();
|
||||
bool GetCurrentCaptureRegion(int* left, int* top, int* width, int* height,
|
||||
std::string* display_name);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#ifndef CROSSDESK_SECURE_DESKTOP_CURSOR_STATE_H_
|
||||
#define CROSSDESK_SECURE_DESKTOP_CURSOR_STATE_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Fixed-width IPC metadata. Cursor handles belong to the helper's desktop and
|
||||
// must be converted to a semantic shape before crossing the process boundary.
|
||||
struct SecureDesktopCursorSnapshot {
|
||||
uint32_t valid = 0;
|
||||
uint32_t visible = 0;
|
||||
uint32_t shape = 0;
|
||||
int32_t x = 0;
|
||||
int32_t y = 0;
|
||||
uint32_t render_mode = 0;
|
||||
uint32_t hidden_reason = 0;
|
||||
};
|
||||
static_assert(sizeof(SecureDesktopCursorSnapshot) == 28);
|
||||
|
||||
// The capture thread publishes helper samples for the GUI thread. While secure
|
||||
// capture is active, even an unavailable sample must not fall back to sampling
|
||||
// the ordinary desktop, which can report a hidden or stale cursor.
|
||||
class SecureDesktopCursorState {
|
||||
public:
|
||||
void SetActive(bool active) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (active_ != active) snapshot_ = {};
|
||||
active_ = active;
|
||||
}
|
||||
|
||||
void Update(const SecureDesktopCursorSnapshot& snapshot) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (active_) {
|
||||
snapshot_ = snapshot;
|
||||
updated_at_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
}
|
||||
|
||||
bool Get(SecureDesktopCursorSnapshot* snapshot) const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!active_ || !snapshot) return false;
|
||||
*snapshot = snapshot_;
|
||||
if (std::chrono::steady_clock::now() - updated_at_ >=
|
||||
std::chrono::milliseconds(1500)) {
|
||||
snapshot->valid = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
bool active_ = false;
|
||||
SecureDesktopCursorSnapshot snapshot_{};
|
||||
std::chrono::steady_clock::time_point updated_at_{};
|
||||
};
|
||||
|
||||
inline SecureDesktopCursorState& SharedSecureDesktopCursorState() {
|
||||
static SecureDesktopCursorState state;
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_SECURE_DESKTOP_CURSOR_STATE_H_
|
||||
@@ -24,6 +24,10 @@ CROSSDESK_WGC_PLUGIN_API crossdesk::ScreenCapturer*
|
||||
CrossDeskCreateWgcCapturer();
|
||||
CROSSDESK_WGC_PLUGIN_API void CrossDeskDestroyWgcCapturer(
|
||||
crossdesk::ScreenCapturer* capturer);
|
||||
// Optional extensions; they do not change ScreenCapturer's virtual ABI.
|
||||
CROSSDESK_WGC_PLUGIN_API int CrossDeskSetWgcCursorCapture(
|
||||
crossdesk::ScreenCapturer* capturer, bool enabled);
|
||||
CROSSDESK_WGC_PLUGIN_API bool CrossDeskWgcFrameCapturesCursor();
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <mutex>
|
||||
|
||||
#include "captured_cursor_state.h"
|
||||
#include "path_manager.h"
|
||||
#include "rd_log.h"
|
||||
#include "screen_capturer_wgc.h"
|
||||
@@ -27,4 +28,12 @@ crossdesk::ScreenCapturer* CrossDeskCreateWgcCapturer() {
|
||||
void CrossDeskDestroyWgcCapturer(crossdesk::ScreenCapturer* capturer) {
|
||||
delete capturer;
|
||||
}
|
||||
|
||||
int CrossDeskSetWgcCursorCapture(crossdesk::ScreenCapturer* capturer, bool enabled) {
|
||||
return static_cast<crossdesk::ScreenCapturerWgc*>(capturer)->SetCursorCapture(enabled);
|
||||
}
|
||||
|
||||
bool CrossDeskWgcFrameCapturesCursor() {
|
||||
return crossdesk::current_frame_has_cursor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class WgcSession {
|
||||
unsigned int row_pitch;
|
||||
|
||||
const unsigned char* data;
|
||||
bool cursor_captured;
|
||||
};
|
||||
|
||||
class wgc_session_observer {
|
||||
@@ -38,4 +39,4 @@ class WgcSession {
|
||||
virtual ~WgcSession() {};
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -201,7 +201,13 @@ int WgcSessionImpl::StartCaptureLocked(bool show_cursor) {
|
||||
}
|
||||
|
||||
int WgcSessionImpl::StartLocked(bool show_cursor) {
|
||||
if (is_running_) return 0;
|
||||
const bool was_paused = is_paused_;
|
||||
if (is_running_) {
|
||||
if (last_show_cursor_ == show_cursor) return 0;
|
||||
// Replace the pool along with the setting, so buffered frames from the
|
||||
// previous cursor mode cannot be labeled with the new mode.
|
||||
StopLocked();
|
||||
}
|
||||
|
||||
last_show_cursor_ = show_cursor;
|
||||
if (!is_initialized_) {
|
||||
@@ -218,6 +224,7 @@ int WgcSessionImpl::StartLocked(bool show_cursor) {
|
||||
|
||||
LOG_WARN("WGC: start capture failed, rebuilding capture item");
|
||||
CleanUpLocked();
|
||||
is_paused_ = was_paused;
|
||||
ret = InitializeLocked();
|
||||
if (ret != 0) {
|
||||
return ret;
|
||||
@@ -259,10 +266,12 @@ void WgcSessionImpl::OnFrame(
|
||||
[[maybe_unused]] winrt::Windows::Foundation::IInspectable const& args) {
|
||||
std::lock_guard locker(lock_);
|
||||
|
||||
if (!is_running_ || sender != capture_framepool_) return;
|
||||
auto is_new_size = false;
|
||||
|
||||
try {
|
||||
auto frame = sender.TryGetNextFrame();
|
||||
if (!frame) return;
|
||||
auto frame_size = frame.ContentSize();
|
||||
|
||||
if (frame_size.Width != capture_frame_size_.Width ||
|
||||
@@ -310,12 +319,21 @@ void WgcSessionImpl::OnFrame(
|
||||
|
||||
// copy data from map_result.pData
|
||||
if (map_result.pData && observer_) {
|
||||
CURSORINFO cursor{};
|
||||
cursor.cbSize = sizeof(cursor);
|
||||
const bool cursor_captured =
|
||||
last_show_cursor_ && GetCursorInfo(&cursor) && cursor.hCursor &&
|
||||
(cursor.flags & CURSOR_SHOWING) != 0 &&
|
||||
(target_.is_window ||
|
||||
MonitorFromPoint(cursor.ptScreenPos, MONITOR_DEFAULTTONULL) ==
|
||||
target_.hmonitor);
|
||||
observer_->OnFrame(
|
||||
wgc_session_frame{static_cast<unsigned int>(frame_size.Width),
|
||||
static_cast<unsigned int>(frame_size.Height),
|
||||
map_result.RowPitch,
|
||||
const_cast<const unsigned char*>(
|
||||
(unsigned char*)map_result.pData)},
|
||||
(unsigned char*)map_result.pData),
|
||||
cursor_captured},
|
||||
id_);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
#include "path_manager.h"
|
||||
#include "rd_log.h"
|
||||
#include "session_helper_shared.h"
|
||||
#include "windows_cursor_state.h"
|
||||
#include "cursor_draw.h"
|
||||
#include "windows_input_marker.h"
|
||||
|
||||
namespace {
|
||||
@@ -97,18 +99,18 @@ struct SecureSharedCaptureState {
|
||||
uint32_t sequence = 0;
|
||||
};
|
||||
|
||||
struct PipeSecurityAttributes {
|
||||
PipeSecurityAttributes() = default;
|
||||
~PipeSecurityAttributes() {
|
||||
struct IpcSecurityAttributes {
|
||||
IpcSecurityAttributes() = default;
|
||||
~IpcSecurityAttributes() {
|
||||
if (security_descriptor_ != nullptr) {
|
||||
LocalFree(security_descriptor_);
|
||||
}
|
||||
}
|
||||
|
||||
bool Initialize() {
|
||||
constexpr wchar_t kPipeSddl[] = L"D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)";
|
||||
bool Initialize(const wchar_t* sddl =
|
||||
L"D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)") {
|
||||
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
kPipeSddl, SDDL_REVISION_1, &security_descriptor_, nullptr)) {
|
||||
sddl, SDDL_REVISION_1, &security_descriptor_, nullptr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -452,7 +454,7 @@ std::string BuildHelperStatusResponse(HelperState* helper_state) {
|
||||
|
||||
void HelperIpcServerLoop(HANDLE stop_event, DWORD session_id,
|
||||
HelperState* helper_state) {
|
||||
PipeSecurityAttributes security_attributes;
|
||||
IpcSecurityAttributes security_attributes;
|
||||
SECURITY_ATTRIBUTES* pipe_attributes = nullptr;
|
||||
if (security_attributes.Initialize()) {
|
||||
pipe_attributes = security_attributes.get();
|
||||
@@ -1135,6 +1137,7 @@ struct SecureDesktopGdiResources {
|
||||
void* bits = nullptr;
|
||||
const char* error = nullptr;
|
||||
DWORD error_code = ERROR_SUCCESS;
|
||||
crossdesk::SecureDesktopCursorSnapshot cursor_snapshot{};
|
||||
|
||||
~SecureDesktopGdiResources() {
|
||||
if (old_bitmap != nullptr && old_bitmap != HGDI_ERROR) {
|
||||
@@ -1183,17 +1186,28 @@ struct SecureDesktopGdiResources {
|
||||
request.left, request.top, SRCCOPY | CAPTUREBLT)) {
|
||||
return Fail("bitblt_failed");
|
||||
}
|
||||
if (request.show_cursor) {
|
||||
CURSORINFO cursor{};
|
||||
cursor.cbSize = sizeof(cursor);
|
||||
if (GetCursorInfo(&cursor) && cursor.flags == CURSOR_SHOWING &&
|
||||
cursor.hCursor != nullptr) {
|
||||
const int x = cursor.ptScreenPos.x - request.left;
|
||||
const int y = cursor.ptScreenPos.y - request.top;
|
||||
if (x >= -64 && y >= -64 && x < request.width + 64 &&
|
||||
y < request.height + 64) {
|
||||
DrawIconEx(mem_dc, x, y, cursor.hCursor, 0, 0, 0, nullptr, DI_NORMAL);
|
||||
}
|
||||
// Sample on the thread bound to the captured input desktop, including when
|
||||
// native controllers render the cursor themselves (show_cursor == false).
|
||||
cursor_snapshot = {};
|
||||
CURSORINFO cursor{};
|
||||
cursor.cbSize = sizeof(cursor);
|
||||
if (GetCursorInfo(&cursor)) {
|
||||
static thread_local crossdesk::WindowsCursorState cursor_state;
|
||||
crossdesk::CursorState state{};
|
||||
cursor_state.Sample(cursor, false, &state);
|
||||
cursor_snapshot.valid = 1;
|
||||
cursor_snapshot.visible = state.visible;
|
||||
cursor_snapshot.shape = static_cast<uint32_t>(state.shape);
|
||||
cursor_snapshot.render_mode = static_cast<uint32_t>(state.render_mode);
|
||||
cursor_snapshot.hidden_reason = static_cast<uint32_t>(state.hidden_reason);
|
||||
cursor_snapshot.x = cursor.ptScreenPos.x;
|
||||
cursor_snapshot.y = cursor.ptScreenPos.y;
|
||||
if (request.show_cursor && crossdesk::DrawCursorInCapture(
|
||||
mem_dc, cursor, request.left, request.top,
|
||||
request.width, request.height)) {
|
||||
cursor_snapshot.visible = 0;
|
||||
cursor_snapshot.render_mode =
|
||||
static_cast<uint32_t>(crossdesk::CursorRenderMode::embedded);
|
||||
}
|
||||
}
|
||||
// Complete GDI writes before libyuv reads the DIB's pixel memory.
|
||||
@@ -1237,6 +1251,7 @@ std::vector<uint8_t> CaptureSecureDesktopFrame(
|
||||
header.height = static_cast<uint32_t>(request.height);
|
||||
header.payload_size =
|
||||
static_cast<uint32_t>(capture_buffers->nv12_frame.size());
|
||||
header.cursor = capture.cursor_snapshot;
|
||||
|
||||
std::vector<uint8_t> response(sizeof(header) +
|
||||
capture_buffers->nv12_frame.size());
|
||||
@@ -1362,6 +1377,7 @@ void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) {
|
||||
header->width = static_cast<uint32_t>(request.width);
|
||||
header->height = static_cast<uint32_t>(request.height);
|
||||
header->payload_size = static_cast<uint32_t>(nv12_frame.size());
|
||||
header->cursor = capture->cursor_snapshot;
|
||||
std::memcpy(payload, nv12_frame.data(), nv12_frame.size());
|
||||
header->sequence = ++capture_state->sequence;
|
||||
MemoryBarrier();
|
||||
@@ -1453,12 +1469,21 @@ std::vector<uint8_t> StartSecureDesktopSharedCapture(
|
||||
return BuildTextResponseBytes(BuildErrorJson("invalid_capture_size"));
|
||||
}
|
||||
|
||||
PipeSecurityAttributes security_attributes;
|
||||
IpcSecurityAttributes security_attributes;
|
||||
SECURITY_ATTRIBUTES* attributes = nullptr;
|
||||
if (security_attributes.Initialize()) {
|
||||
attributes = security_attributes.get();
|
||||
}
|
||||
|
||||
// Event GENERIC_READ/GENERIC_WRITE omit SYNCHRONIZE. The GUI only needs
|
||||
// to wait for frames; the SYSTEM helper retains permission to signal them.
|
||||
IpcSecurityAttributes event_security_attributes;
|
||||
if (!event_security_attributes.Initialize(
|
||||
L"D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x00100000;;;AU)")) {
|
||||
return BuildTextResponseBytes(
|
||||
BuildErrorJson("create_frame_event_security_failed", GetLastError()));
|
||||
}
|
||||
|
||||
const std::wstring mapping_name =
|
||||
crossdesk::GetCrossDeskSecureDesktopFrameMappingName(
|
||||
capture_state->session_id);
|
||||
@@ -1485,7 +1510,8 @@ std::vector<uint8_t> StartSecureDesktopSharedCapture(
|
||||
}
|
||||
|
||||
HANDLE frame_ready_event =
|
||||
CreateEventW(attributes, FALSE, FALSE, event_name.c_str());
|
||||
CreateEventW(event_security_attributes.get(), FALSE, FALSE,
|
||||
event_name.c_str());
|
||||
if (frame_ready_event == nullptr) {
|
||||
const DWORD error = GetLastError();
|
||||
UnmapViewOfFile(frame_view);
|
||||
@@ -1647,7 +1673,7 @@ void SecureInputHelperIpcServerLoop(HANDLE stop_event, DWORD session_id) {
|
||||
auto capture_state = std::make_shared<SecureSharedCaptureState>();
|
||||
capture_state->session_id = session_id;
|
||||
|
||||
PipeSecurityAttributes security_attributes;
|
||||
IpcSecurityAttributes security_attributes;
|
||||
SECURITY_ATTRIBUTES* pipe_attributes = nullptr;
|
||||
if (security_attributes.Initialize()) {
|
||||
pipe_attributes = security_attributes.get();
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "secure_desktop_cursor_state.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
inline constexpr wchar_t kCrossDeskSessionHelperPipePrefix[] =
|
||||
@@ -33,7 +35,9 @@ inline constexpr wchar_t kCrossDeskSecureDesktopFrameMappingPrefix[] =
|
||||
inline constexpr wchar_t kCrossDeskSecureDesktopFrameReadyEventPrefix[] =
|
||||
L"Global\\CrossDeskSecureDesktopFrameReady-";
|
||||
inline constexpr uint32_t kCrossDeskSecureDesktopFrameMagic = 0x50444358;
|
||||
inline constexpr uint32_t kCrossDeskSecureDesktopFrameVersion = 1;
|
||||
// Version 3 distinguishes embedded, hidden and unavailable cursor states.
|
||||
// The GUI and local session helper must be deployed together.
|
||||
inline constexpr uint32_t kCrossDeskSecureDesktopFrameVersion = 3;
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct CrossDeskSecureDesktopFrameHeader {
|
||||
@@ -44,6 +48,7 @@ struct CrossDeskSecureDesktopFrameHeader {
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t payload_size;
|
||||
SecureDesktopCursorSnapshot cursor;
|
||||
};
|
||||
|
||||
struct CrossDeskSecureDesktopSharedFrameHeader {
|
||||
@@ -57,6 +62,7 @@ struct CrossDeskSecureDesktopSharedFrameHeader {
|
||||
uint32_t height;
|
||||
uint32_t payload_size;
|
||||
uint32_t buffer_size;
|
||||
SecureDesktopCursorSnapshot cursor;
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
|
||||
@@ -7,14 +7,12 @@
|
||||
#ifndef _PRIVACY_BACKEND_H_
|
||||
#define _PRIVACY_BACKEND_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct PrivacyScreenText {
|
||||
std::string verification = "Privacy screen verification";
|
||||
std::string unlock_hint = "To turn off the privacy screen, press";
|
||||
};
|
||||
|
||||
@@ -27,12 +25,13 @@ struct PrivacyCapabilities {
|
||||
struct PrivacyHealth {
|
||||
std::string failure;
|
||||
bool emergency_exit = false;
|
||||
bool ready = true;
|
||||
};
|
||||
|
||||
// Lifecycle, message pumping and native resource operations run on the
|
||||
// controller's message thread. Const metadata queries are serialized with
|
||||
// those operations by the controller mutex and must not call native UI APIs.
|
||||
// No platform backend is installed on macOS/Linux yet.
|
||||
// AppKit work is queued to the main thread without waiting under this mutex.
|
||||
class PrivacyBackend {
|
||||
public:
|
||||
virtual ~PrivacyBackend() = default;
|
||||
@@ -41,23 +40,26 @@ class PrivacyBackend {
|
||||
std::string& error) = 0;
|
||||
virtual void Recover() = 0;
|
||||
virtual bool IsRecovered() const = 0;
|
||||
virtual PrivacyHealth Poll(bool force_check = false) = 0;
|
||||
virtual bool RecoveryPending() const { return false; }
|
||||
virtual PrivacyHealth Poll() = 0;
|
||||
// Wait on both native window messages and controller commands.
|
||||
// Wake is the only backend operation callable from another thread.
|
||||
virtual void WaitForEvents(unsigned timeout_ms) = 0;
|
||||
virtual void Wake() = 0;
|
||||
virtual bool PaintChallenge(uint32_t value, const std::string& title,
|
||||
std::string& error) = 0;
|
||||
// Remove only the temporary capture probes, keeping all privacy covers up.
|
||||
// The controller drains queued probe frames before admitting clean video.
|
||||
virtual bool ClearChallenges(std::string& error) = 0;
|
||||
virtual bool HasMonitor(int left, int top, int width, int height) const = 0;
|
||||
};
|
||||
|
||||
#ifdef _WIN32
|
||||
std::unique_ptr<PrivacyBackend> CreateWindowsPrivacyBackend();
|
||||
bool IsWindowsPrivacyDesktopAvailable();
|
||||
#endif
|
||||
#ifdef __APPLE__
|
||||
std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend();
|
||||
// Flush queued AppKit cleanup after joining the controller at app shutdown.
|
||||
void FlushMacPrivacyTasks();
|
||||
#endif
|
||||
#ifdef __linux__
|
||||
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
|
||||
#endif
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
|
||||
@@ -1,496 +1,210 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
#include "privacy_controller.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
uint64_t PrivacyController::Now() {
|
||||
return static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch())
|
||||
.count());
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
PrivacyController::PrivacyController(Factory factory) {
|
||||
status_.state = PrivacyState::unsupported;
|
||||
std::strcpy(status_.reason,
|
||||
"Privacy screen is not available on this platform");
|
||||
std::strcpy(status_.reason, "Privacy screen is not available on this platform");
|
||||
#ifdef _WIN32
|
||||
if (!factory) factory = CreateWindowsPrivacyBackend;
|
||||
#elif defined(__APPLE__)
|
||||
if (!factory) factory = CreateMacPrivacyBackend;
|
||||
#elif defined(__linux__)
|
||||
if (!factory) factory = CreateLinuxPrivacyBackend;
|
||||
#endif
|
||||
thread_ = std::thread([this, factory] { Run(factory); });
|
||||
}
|
||||
|
||||
PrivacyController::~PrivacyController() { Shutdown(); }
|
||||
|
||||
void PrivacyController::WakeLocked() {
|
||||
if (backend_) backend_->Wake();
|
||||
wake_.notify_all();
|
||||
}
|
||||
|
||||
void PrivacyController::Shutdown() {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
quit_ = true;
|
||||
remote_paused_.store(true);
|
||||
WakeLocked();
|
||||
}
|
||||
if (thread_.joinable()) thread_.join();
|
||||
#ifdef __APPLE__
|
||||
FlushMacPrivacyTasks();
|
||||
#endif
|
||||
}
|
||||
|
||||
PrivacyStatus PrivacyController::Snapshot() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return status_;
|
||||
}
|
||||
|
||||
void PrivacyController::SetText(const PrivacyScreenText& text) {
|
||||
std::lock_guard lock(mutex_);
|
||||
text_ = text;
|
||||
}
|
||||
|
||||
void PrivacyController::SetState(PrivacyState state,
|
||||
const std::string& reason) {
|
||||
void PrivacyController::SetState(PrivacyState state, const std::string& reason) {
|
||||
status_.state = state;
|
||||
status_.remote_paused = remote_paused_.load();
|
||||
status_.remote_paused = false; // Wire compatibility; privacy never pauses a session.
|
||||
++status_.revision;
|
||||
std::strncpy(status_.reason, reason.c_str(), sizeof(status_.reason) - 1);
|
||||
status_.reason[sizeof(status_.reason) - 1] = '\0';
|
||||
LOG_INFO(
|
||||
"Privacy: state={}, overlay={}, input_blocked={}, paused={}, reason={}",
|
||||
static_cast<int>(state), status_.overlay_active, status_.input_blocked,
|
||||
status_.remote_paused, reason);
|
||||
LOG_INFO("Privacy: state={}, overlay={}, input_blocked={}, reason={}",
|
||||
static_cast<int>(state), status_.overlay_active, status_.input_blocked, reason);
|
||||
}
|
||||
|
||||
void PrivacyController::Enable(bool block_input) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_) return;
|
||||
if (status_.state == PrivacyState::on ||
|
||||
status_.state == PrivacyState::starting ||
|
||||
status_.state == PrivacyState::stopping ||
|
||||
status_.state == PrivacyState::failed) {
|
||||
SetState(status_.state, status_.reason);
|
||||
return;
|
||||
}
|
||||
if (quit_ || engaged_.load() || disable_pending_) return;
|
||||
if (!status_.supported || (block_input && !status_.input_block_supported)) {
|
||||
SetState(PrivacyState::unsupported, status_.reason);
|
||||
SetState(PrivacyState::unsupported, "Privacy screen is unavailable");
|
||||
return;
|
||||
}
|
||||
// Failure recovery is explicit: first turn off, then request a fresh enable.
|
||||
engaged_.store(true);
|
||||
remote_paused_.store(true);
|
||||
block_requested_ = block_input;
|
||||
automatic_enable_ = false;
|
||||
enable_pending_ = true;
|
||||
ever_enabled_ = false;
|
||||
SetState(PrivacyState::starting,
|
||||
"Creating privacy windows and checking capture");
|
||||
failure_.clear();
|
||||
started_ = Now();
|
||||
SetState(PrivacyState::starting, "Enabling privacy screen");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::EnableOnConnection() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_ || status_.state == PrivacyState::on ||
|
||||
status_.state == PrivacyState::starting ||
|
||||
status_.state == PrivacyState::failed)
|
||||
return;
|
||||
// Also accept a reconnect while the previous disconnect is still cleaning
|
||||
// up. Run() keeps the gate closed between recovery and this new enable.
|
||||
if (quit_ || (engaged_.load() && !disable_pending_)) return;
|
||||
// A reconnect may arrive before asynchronous AppKit recovery has completed.
|
||||
engaged_.store(true);
|
||||
remote_paused_.store(true);
|
||||
block_requested_ = true;
|
||||
automatic_enable_ = true;
|
||||
capture_start_deadline_ = Now() + 10000;
|
||||
enable_pending_ = true;
|
||||
ever_enabled_ = false;
|
||||
SetState(PrivacyState::starting,
|
||||
"Connected; automatically enabling privacy and verifying capture");
|
||||
failure_.clear();
|
||||
started_ = Now();
|
||||
SetState(PrivacyState::starting, "Enabling privacy on connection");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::Disable() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_) return;
|
||||
if (!engaged_.load() && !status_.overlay_active) {
|
||||
SetState(status_.state, status_.reason);
|
||||
return;
|
||||
}
|
||||
enable_pending_ = false;
|
||||
failure_.clear();
|
||||
disable_pending_ = true;
|
||||
remote_paused_.store(true);
|
||||
SetState(PrivacyState::stopping, "Restoring local desktop and input");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::Disconnected() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_ ||
|
||||
(!engaged_.load() && !status_.overlay_active && !remote_paused_.load()))
|
||||
return;
|
||||
remote_paused_.store(true);
|
||||
void PrivacyController::Disconnected() { Disable(); }
|
||||
void PrivacyController::FailLocked(const std::string& reason) {
|
||||
if (!engaged_.load() || disable_pending_) return;
|
||||
failure_ = reason;
|
||||
enable_pending_ = false;
|
||||
disable_pending_ = true;
|
||||
SetState(PrivacyState::stopping,
|
||||
"Last controller disconnected; restoring desktop, input and session "
|
||||
"state");
|
||||
SetState(PrivacyState::failed, reason);
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::FailLocked(const std::string& reason) {
|
||||
if (!engaged_.load() || status_.state == PrivacyState::failed ||
|
||||
status_.state == PrivacyState::stopping)
|
||||
return;
|
||||
remote_paused_.store(true);
|
||||
enable_pending_ = false;
|
||||
SetState(PrivacyState::failed, reason);
|
||||
}
|
||||
|
||||
void PrivacyController::Fail(const std::string& reason) {
|
||||
std::lock_guard lock(mutex_);
|
||||
FailLocked(reason);
|
||||
}
|
||||
|
||||
void PrivacyController::BeginVerification(const std::string& reason) {
|
||||
remote_paused_.store(true);
|
||||
verifier_.Reset(true);
|
||||
verification_phase_ = VerificationPhase::challenge;
|
||||
clean_frames_ = 0;
|
||||
last_frame_received_ = 0;
|
||||
drain_after_recovery_.store(false);
|
||||
started_ = Now();
|
||||
last_issue_ = 0;
|
||||
frame_monitor_set_ = false;
|
||||
first_verified_frame_logged_ = false;
|
||||
SetState(PrivacyState::starting,
|
||||
!reason.empty() ? reason
|
||||
: automatic_enable_ && !capture_running_
|
||||
? "Waiting for desktop capture startup"
|
||||
: "Verifying live desktop through " + capture_backend_);
|
||||
}
|
||||
|
||||
void PrivacyController::CaptureChanged(const std::string& backend,
|
||||
bool running) {
|
||||
void PrivacyController::CaptureChanged(bool running) {
|
||||
std::lock_guard lock(mutex_);
|
||||
capture_backend_ = backend;
|
||||
// Windows announces a stopped capture while preparing the first session.
|
||||
// Keep automatic privacy pending until capture starts, within its deadline.
|
||||
// Once capture was running or backend enable began, a stop remains a fault.
|
||||
const bool waiting_for_initial_capture =
|
||||
automatic_enable_ && enable_pending_ && !capture_running_;
|
||||
capture_running_ = running;
|
||||
if (!engaged_.load()) return;
|
||||
if (ever_enabled_) {
|
||||
FailLocked(
|
||||
"Capture backend stopped or rebuilt; remote operation paused. Turn "
|
||||
"privacy off to recover.");
|
||||
} else if (status_.state == PrivacyState::starting) {
|
||||
BeginVerification();
|
||||
}
|
||||
if (!running && !waiting_for_initial_capture)
|
||||
FailLocked("Capture changed; privacy screen is being turned off");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::DisplayChanging() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (status_.state == PrivacyState::on) BeginVerification();
|
||||
}
|
||||
|
||||
void PrivacyController::CaptureInterrupted() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!engaged_.load()) return;
|
||||
if (ever_enabled_) {
|
||||
FailLocked(
|
||||
"Capture component is rebuilding; remote operation paused. Turn "
|
||||
"privacy off to recover.");
|
||||
} else if (status_.state == PrivacyState::starting) {
|
||||
// The capturer owns recovery and backend selection. Require fresh frames
|
||||
// from it without letting repeated resets extend verification forever.
|
||||
const auto attempt_started = started_;
|
||||
BeginVerification(
|
||||
"Capture component interrupted; verifying recovered frames");
|
||||
if (attempt_started) started_ = attempt_started;
|
||||
}
|
||||
}
|
||||
|
||||
bool PrivacyController::ObserveFrame(const uint8_t* y, size_t size, int width,
|
||||
int height, int left, int top,
|
||||
int physical_width, int physical_height) {
|
||||
if (!engaged_.load() && !drain_after_recovery_.load()) return RemoteAllowed();
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!engaged_.load()) {
|
||||
// Disabling during verification can leave already-captured probe frames
|
||||
// queued behind HWND destruction. Do not leak them after reporting Off.
|
||||
if (drain_after_recovery_.load()) {
|
||||
if (!y || width <= 0 || height <= 0 ||
|
||||
size < static_cast<size_t>(width) * height)
|
||||
return false;
|
||||
if (verifier_.ContainsChallenge(y, size, width, height)) {
|
||||
clean_frames_ = 0;
|
||||
return false;
|
||||
}
|
||||
if (++clean_frames_ < 2) return false;
|
||||
drain_after_recovery_.store(false);
|
||||
verifier_.Reset();
|
||||
}
|
||||
return RemoteAllowed();
|
||||
}
|
||||
if (!backend_ || !status_.overlay_active ||
|
||||
(status_.state != PrivacyState::starting &&
|
||||
status_.state != PrivacyState::on))
|
||||
return false;
|
||||
if (!backend_->HasMonitor(left, top, physical_width, physical_height) ||
|
||||
width != (physical_width & ~1) || height != (physical_height & ~1)) {
|
||||
if (ever_enabled_)
|
||||
FailLocked(
|
||||
"Capture geometry changed or cannot be verified; remote operation "
|
||||
"paused");
|
||||
return false;
|
||||
}
|
||||
if (frame_monitor_set_ && (frame_left_ != left || frame_top_ != top)) {
|
||||
BeginVerification();
|
||||
}
|
||||
frame_left_ = left;
|
||||
frame_top_ = top;
|
||||
frame_monitor_set_ = true;
|
||||
if (!y || size < static_cast<size_t>(width) * height) {
|
||||
FailLocked(
|
||||
"Capture returned an invalid desktop frame; remote operation paused");
|
||||
return false;
|
||||
}
|
||||
last_frame_received_ = Now();
|
||||
if (verification_phase_ == VerificationPhase::challenge) {
|
||||
verifier_.Observe(y, size, width, height, last_frame_received_);
|
||||
return false; // Verification pixels are never sent to the encoder.
|
||||
}
|
||||
if (verifier_.ContainsChallenge(y, size, width, height)) {
|
||||
clean_frames_ = 0;
|
||||
if (verification_phase_ == VerificationPhase::live) {
|
||||
drain_after_recovery_.store(true);
|
||||
FailLocked(
|
||||
"Capture reintroduced verification pixels; remote operation paused");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (verification_phase_ == VerificationPhase::draining) {
|
||||
if (clean_frames_ < 2) ++clean_frames_;
|
||||
return false; // Only the owner thread can publish the verified On state.
|
||||
}
|
||||
if (status_.state != PrivacyState::on) return false;
|
||||
if (!first_verified_frame_logged_) {
|
||||
first_verified_frame_logged_ = true;
|
||||
LOG_INFO(
|
||||
"Privacy: first clean verified desktop frame admitted, backend={}, "
|
||||
"size={}x{}, origin=({}, {})",
|
||||
capture_backend_, width, height, left, top);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void PrivacyController::Run(Factory factory) {
|
||||
std::unique_lock lock(mutex_);
|
||||
try {
|
||||
if (factory) backend_ = factory();
|
||||
if (backend_) {
|
||||
const auto caps = backend_->Query();
|
||||
last_capability_check_ = Now();
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
// A connection can arm the gate before this thread has initialized.
|
||||
if (!engaged_.load())
|
||||
SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported,
|
||||
caps.reason);
|
||||
}
|
||||
while (!quit_) {
|
||||
if (backend_ && !engaged_.load() &&
|
||||
Now() - last_capability_check_ >= 2000) {
|
||||
if (last_capability_check_ == 0 ||
|
||||
(!engaged_.load() && Now() - last_capability_check_ >= 2000)) {
|
||||
last_capability_check_ = Now();
|
||||
const auto caps = backend_->Query();
|
||||
if (status_.supported != caps.overlay ||
|
||||
status_.input_block_supported != caps.block_input) {
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported,
|
||||
caps.reason);
|
||||
}
|
||||
const auto caps = backend_ ? backend_->Query() : PrivacyCapabilities{};
|
||||
const bool changed = status_.supported != caps.overlay ||
|
||||
status_.input_block_supported != caps.block_input;
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
if (!engaged_.load() && (changed || status_.revision == 0))
|
||||
SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported, caps.reason);
|
||||
}
|
||||
if (disable_pending_) {
|
||||
if (backend_) backend_->Recover();
|
||||
if (backend_ && backend_->RecoveryPending()) {
|
||||
lock.unlock();
|
||||
backend_->WaitForEvents(25);
|
||||
lock.lock();
|
||||
continue;
|
||||
}
|
||||
disable_pending_ = false;
|
||||
if (backend_ && !backend_->IsRecovered()) {
|
||||
enable_pending_ = false;
|
||||
SetState(PrivacyState::failed,
|
||||
"Could not release every privacy resource; remote operation "
|
||||
"remains paused");
|
||||
continue;
|
||||
}
|
||||
status_.overlay_active = status_.input_blocked = false;
|
||||
const bool drain = challenges_visible_ ||
|
||||
verification_phase_ == VerificationPhase::draining ||
|
||||
drain_after_recovery_.load();
|
||||
challenges_visible_ = false;
|
||||
clean_frames_ = 0;
|
||||
drain_after_recovery_.store(drain);
|
||||
if (!drain) verifier_.Reset();
|
||||
verification_phase_ = VerificationPhase::live;
|
||||
if (enable_pending_) {
|
||||
SetState(PrivacyState::starting,
|
||||
"Reconnected; automatically restoring privacy protection");
|
||||
disable_pending_ = true; // Retry native cleanup without affecting the session.
|
||||
SetState(PrivacyState::failed, "Could not release every privacy resource");
|
||||
} else {
|
||||
ever_enabled_ = false;
|
||||
automatic_enable_ = false;
|
||||
block_requested_ = false;
|
||||
capture_start_deadline_ = 0;
|
||||
started_ = last_issue_ = 0;
|
||||
frame_monitor_set_ = false;
|
||||
first_verified_frame_logged_ = false;
|
||||
engaged_.store(false);
|
||||
remote_paused_.store(false);
|
||||
SetState(PrivacyState::off, "Privacy screen is off");
|
||||
status_.overlay_active = status_.input_blocked = false;
|
||||
if (!enable_pending_) {
|
||||
engaged_.store(false);
|
||||
SetState(failure_.empty() ? PrivacyState::off : PrivacyState::failed,
|
||||
failure_.empty() ? "Privacy screen is off" : failure_);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enable_pending_) {
|
||||
enable_pending_ = false;
|
||||
std::string error;
|
||||
if (automatic_enable_) {
|
||||
const auto caps =
|
||||
backend_ ? backend_->Query()
|
||||
: PrivacyCapabilities{false, false,
|
||||
"No privacy backend is installed"};
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
if (!caps.overlay || !caps.block_input)
|
||||
FailLocked("Automatic privacy unavailable: " + caps.reason);
|
||||
}
|
||||
if (status_.state != PrivacyState::failed) {
|
||||
if (!capture_running_ && !automatic_enable_) {
|
||||
FailLocked("No running desktop capture; remote operation paused");
|
||||
} else if (!backend_->Enable(block_requested_, text_, error)) {
|
||||
backend_->Recover();
|
||||
status_.overlay_active = !backend_->IsRecovered();
|
||||
const auto caps = backend_ ? backend_->Query() : PrivacyCapabilities{};
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
if (!caps.overlay || (block_requested_ && !caps.block_input)) {
|
||||
FailLocked("Privacy screen unavailable: " + caps.reason);
|
||||
} else if (!capture_running_) {
|
||||
if (!automatic_enable_ || Now() - started_ > 10000)
|
||||
FailLocked("No desktop capture available for privacy screen");
|
||||
} else {
|
||||
enable_pending_ = false;
|
||||
std::string error;
|
||||
if (!backend_->Enable(block_requested_, text_, error)) {
|
||||
FailLocked(error);
|
||||
} else {
|
||||
status_.overlay_active = true;
|
||||
status_.input_blocked = block_requested_;
|
||||
BeginVerification();
|
||||
started_ = Now();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pump the native queue even after recovery: the message sink is reused.
|
||||
const auto health =
|
||||
backend_ ? backend_->Poll(status_.state == PrivacyState::starting &&
|
||||
verifier_.Verified())
|
||||
: PrivacyHealth{};
|
||||
if (backend_ && engaged_.load()) {
|
||||
const auto health = backend_ ? backend_->Poll() : PrivacyHealth{};
|
||||
if (engaged_.load() && !disable_pending_) {
|
||||
if (health.emergency_exit) {
|
||||
remote_paused_.store(true);
|
||||
failure_.clear();
|
||||
enable_pending_ = false;
|
||||
disable_pending_ = true;
|
||||
SetState(PrivacyState::stopping,
|
||||
"Local privacy exit; restoring desktop and input before "
|
||||
"resuming remote operation");
|
||||
continue;
|
||||
SetState(PrivacyState::stopping, "Local shortcut: restoring desktop and input");
|
||||
} else if (!health.failure.empty()) {
|
||||
FailLocked(health.failure);
|
||||
}
|
||||
if (status_.overlay_active &&
|
||||
(status_.state == PrivacyState::starting ||
|
||||
status_.state == PrivacyState::on)) {
|
||||
const auto now = Now();
|
||||
if (verification_phase_ == VerificationPhase::challenge &&
|
||||
now - last_issue_ >= 250) {
|
||||
uint32_t challenge;
|
||||
do {
|
||||
challenge = random_();
|
||||
} while (challenge == 0 || challenge == UINT32_MAX);
|
||||
std::string error;
|
||||
challenges_visible_ = true;
|
||||
// Record even a partially painted multi-monitor challenge, so
|
||||
// cleanup can reject its queued frames if another monitor fails.
|
||||
verifier_.Issue(challenge, now);
|
||||
if (!backend_->PaintChallenge(challenge, text_.verification,
|
||||
error)) {
|
||||
FailLocked(error);
|
||||
} else {
|
||||
last_issue_ = now;
|
||||
}
|
||||
}
|
||||
if (status_.state == PrivacyState::starting &&
|
||||
verification_phase_ == VerificationPhase::challenge &&
|
||||
verifier_.Verified() && capture_running_) {
|
||||
std::string error;
|
||||
if (!backend_->ClearChallenges(error)) {
|
||||
FailLocked(error);
|
||||
} else {
|
||||
challenges_visible_ = false;
|
||||
verification_phase_ = VerificationPhase::draining;
|
||||
clean_frames_ = 0;
|
||||
started_ = now;
|
||||
SetState(PrivacyState::starting,
|
||||
"Capture exclusion verified; waiting for clean desktop "
|
||||
"frames");
|
||||
}
|
||||
}
|
||||
if (status_.state == PrivacyState::starting &&
|
||||
verification_phase_ == VerificationPhase::draining &&
|
||||
clean_frames_ >= 2 && capture_running_) {
|
||||
verification_phase_ = VerificationPhase::live;
|
||||
ever_enabled_ = true;
|
||||
automatic_enable_ = false;
|
||||
remote_paused_.store(false);
|
||||
SetState(PrivacyState::on, "Privacy screen verified using " +
|
||||
capture_backend_ +
|
||||
"; verification windows removed");
|
||||
}
|
||||
if (status_.state == PrivacyState::on &&
|
||||
now - last_frame_received_ > 1500) {
|
||||
// Live sessions rely on capture delivery and native window/backend
|
||||
// health; verification pixels are never painted during operation.
|
||||
FailLocked(
|
||||
"Capture stopped delivering desktop frames; remote operation "
|
||||
"paused");
|
||||
} else if (status_.state == PrivacyState::starting &&
|
||||
automatic_enable_ && !capture_running_) {
|
||||
if (now >= capture_start_deadline_)
|
||||
FailLocked(
|
||||
"Desktop capture did not start; automatic privacy remains "
|
||||
"paused");
|
||||
} else if (status_.state == PrivacyState::starting &&
|
||||
now - started_ > 3000) {
|
||||
if (verification_phase_ == VerificationPhase::draining) {
|
||||
FailLocked(
|
||||
"Capture did not clear its verification pixels; remote "
|
||||
"operation paused");
|
||||
} else {
|
||||
FailLocked("Current capture backend " + capture_backend_ +
|
||||
" did not pass privacy verification; remote operation "
|
||||
"paused. Turn privacy off to recover.");
|
||||
}
|
||||
}
|
||||
} else if (!enable_pending_ && status_.state == PrivacyState::starting) {
|
||||
if (health.ready)
|
||||
SetState(PrivacyState::on, "Privacy screen is on");
|
||||
else if (Now() - started_ > 3000)
|
||||
FailLocked("Privacy screen did not become ready");
|
||||
}
|
||||
}
|
||||
if (backend_) {
|
||||
// Keep window notifications and emergency exits responsive. Low-level
|
||||
// input hooks are pumped independently by PrivacyInputGuard.
|
||||
const unsigned timeout_ms = engaged_.load() ? 25 : 2000;
|
||||
const unsigned timeout = engaged_.load() || disable_pending_ ? 25 : 2000;
|
||||
lock.unlock();
|
||||
backend_->WaitForEvents(timeout_ms);
|
||||
backend_->WaitForEvents(timeout);
|
||||
lock.lock();
|
||||
} else {
|
||||
// Unsupported platforms have no native health or capability polling.
|
||||
wake_.wait(lock);
|
||||
wake_.wait(lock, [this] { return quit_ || enable_pending_ || disable_pending_; });
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
if (!lock.owns_lock()) lock.lock();
|
||||
remote_paused_.store(true);
|
||||
SetState(PrivacyState::failed,
|
||||
std::string("Privacy component failed: ") + e.what());
|
||||
}
|
||||
if (backend_) {
|
||||
backend_->Recover();
|
||||
backend_.reset();
|
||||
SetState(PrivacyState::failed, std::string("Privacy component failed: ") + e.what());
|
||||
}
|
||||
if (backend_) { backend_->Recover(); backend_.reset(); }
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
#ifndef _PRIVACY_CONTROLLER_H_
|
||||
#define _PRIVACY_CONTROLLER_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <thread>
|
||||
|
||||
#include "privacy_backend.h"
|
||||
#include "privacy_frame_verifier.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Privacy is optional: it must never gate desktop video, audio or remote input.
|
||||
// A failed backend is recovered automatically while the session keeps running.
|
||||
class PrivacyController {
|
||||
public:
|
||||
using Factory = std::function<std::unique_ptr<PrivacyBackend>()>;
|
||||
@@ -28,72 +21,39 @@ class PrivacyController {
|
||||
~PrivacyController();
|
||||
PrivacyController(const PrivacyController&) = delete;
|
||||
PrivacyController& operator=(const PrivacyController&) = delete;
|
||||
|
||||
PrivacyStatus Snapshot() const;
|
||||
// The cover uses a snapshot of these strings when the next session starts.
|
||||
void SetText(const PrivacyScreenText& text);
|
||||
void Enable(bool block_input);
|
||||
// Arms protection synchronously before an incoming connection is published.
|
||||
// Unlike a manual request, startup/unavailable capture cannot leave the
|
||||
// newly connected peer with ordinary unprotected remote access.
|
||||
void EnableOnConnection();
|
||||
void Disable();
|
||||
// Called only after the last control session has been removed. Failures keep
|
||||
// protection while a session exists; ending that session recovers everything
|
||||
// and must not leave the next connection in a paused/failed state.
|
||||
void Disconnected();
|
||||
void Shutdown();
|
||||
void Fail(const std::string& reason);
|
||||
bool RemoteAllowed() const { return !remote_paused_.load(); }
|
||||
bool Engaged() const { return engaged_.load(); }
|
||||
void CaptureChanged(const std::string& backend, bool running);
|
||||
void CaptureInterrupted();
|
||||
void DisplayChanging();
|
||||
bool ObserveFrame(const uint8_t* y, size_t size, int width, int height,
|
||||
int left, int top, int physical_width, int physical_height);
|
||||
void CaptureChanged(bool running);
|
||||
|
||||
private:
|
||||
enum class VerificationPhase { challenge, draining, live };
|
||||
void Run(Factory factory);
|
||||
void WakeLocked();
|
||||
void SetState(PrivacyState state, const std::string& reason);
|
||||
void FailLocked(const std::string& reason);
|
||||
void BeginVerification(const std::string& reason = {});
|
||||
static uint64_t Now();
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable wake_;
|
||||
std::thread thread_;
|
||||
std::unique_ptr<PrivacyBackend> backend_;
|
||||
PrivacyStatus status_{};
|
||||
PrivacyFrameVerifier verifier_;
|
||||
PrivacyScreenText text_;
|
||||
std::mt19937 random_{std::random_device{}()};
|
||||
std::atomic<bool> remote_paused_{false};
|
||||
std::atomic<bool> engaged_{false};
|
||||
std::atomic<bool> drain_after_recovery_{false};
|
||||
VerificationPhase verification_phase_ = VerificationPhase::challenge;
|
||||
bool challenges_visible_ = false;
|
||||
unsigned clean_frames_ = 0;
|
||||
uint64_t last_frame_received_ = 0;
|
||||
bool quit_ = false;
|
||||
bool enable_pending_ = false;
|
||||
bool disable_pending_ = false;
|
||||
bool block_requested_ = false;
|
||||
bool capture_running_ = false;
|
||||
bool ever_enabled_ = false;
|
||||
bool automatic_enable_ = false;
|
||||
uint64_t capture_start_deadline_ = 0;
|
||||
std::string capture_backend_;
|
||||
bool capture_running_ = false;
|
||||
std::string failure_;
|
||||
uint64_t started_ = 0;
|
||||
uint64_t last_issue_ = 0;
|
||||
uint64_t last_capability_check_ = 0;
|
||||
int frame_left_ = 0;
|
||||
int frame_top_ = 0;
|
||||
bool frame_monitor_set_ = false;
|
||||
bool first_verified_frame_logged_ = false;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_CONTROLLER_H_
|
||||
#endif
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_FRAME_VERIFIER_H_
|
||||
#define _PRIVACY_FRAME_VERIFIER_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
|
||||
#include "privacy_probe_pattern.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// An opaque square code beside localized text, UNDER the privacy cover.
|
||||
// Used only while transmission is gated during capture validation. Probes are
|
||||
// then removed, and queued frames containing their tokens must be discarded.
|
||||
class PrivacyFrameVerifier {
|
||||
public:
|
||||
void Reset(bool retain_history = false) {
|
||||
challenges_.clear();
|
||||
if (!retain_history) issued_values_.clear();
|
||||
matches_ = 0;
|
||||
}
|
||||
void Issue(uint32_t value, uint64_t now) {
|
||||
challenges_.push_back({value, now, false});
|
||||
issued_values_.push_back(value);
|
||||
// Covers more than the startup/drain deadlines, including backend retries.
|
||||
while (issued_values_.size() > 128) issued_values_.pop_front();
|
||||
// A validation attempt is time-bounded. Keep every issued token so an old
|
||||
// queued frame cannot escape merely because its token expired for matching.
|
||||
}
|
||||
bool Observe(const uint8_t* y, size_t size, int width, int height,
|
||||
uint64_t now) {
|
||||
uint32_t value = 0;
|
||||
if (!Decode(y, size, width, height, value)) return false;
|
||||
for (auto& challenge : challenges_) {
|
||||
if (challenge.value != value || now - challenge.issued > 750) continue;
|
||||
if (!challenge.seen) {
|
||||
challenge.seen = true;
|
||||
++matches_;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool Verified() const { return matches_ >= 2; }
|
||||
bool ContainsChallenge(const uint8_t* y, size_t size, int width,
|
||||
int height) const {
|
||||
uint32_t value = 0;
|
||||
if (!Decode(y, size, width, height, value)) return false;
|
||||
for (const auto issued : issued_values_)
|
||||
if (issued == value) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
static bool Decode(const uint8_t* y, size_t size, int width, int height,
|
||||
uint32_t& value) {
|
||||
using Pattern = PrivacyProbePattern;
|
||||
if (!y || width < Pattern::kCaptureExtent ||
|
||||
height < Pattern::kCaptureExtent ||
|
||||
size < static_cast<size_t>(width) * height)
|
||||
return false;
|
||||
value = 0;
|
||||
for (int bit = 0; bit < 32; ++bit) {
|
||||
const int x = Pattern::kOffset + Pattern::kPadding +
|
||||
(bit % Pattern::kColumns) * Pattern::kCell +
|
||||
Pattern::kCell / 2;
|
||||
const int row = Pattern::kOffset + Pattern::kPadding +
|
||||
(bit / Pattern::kColumns) * Pattern::kCell +
|
||||
Pattern::kCell / 2;
|
||||
const auto pixel = y[static_cast<size_t>(row) * width + x];
|
||||
const auto inverse =
|
||||
y[static_cast<size_t>(row + 4 * Pattern::kCell) * width + x];
|
||||
if ((pixel > 60 && pixel < 190) || (inverse > 60 && inverse < 190) ||
|
||||
(pixel >= 190) == (inverse >= 190))
|
||||
return false;
|
||||
if (pixel >= 190) value |= uint32_t{1} << bit;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
struct Challenge {
|
||||
uint32_t value;
|
||||
uint64_t issued;
|
||||
bool seen;
|
||||
};
|
||||
std::deque<Challenge> challenges_;
|
||||
std::deque<uint32_t> issued_values_;
|
||||
unsigned matches_ = 0;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_FRAME_VERIFIER_H_
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_PROBE_PATTERN_H_
|
||||
#define _PRIVACY_PROBE_PATTERN_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Physical-pixel geometry shared by the renderer and raw-frame verifier.
|
||||
// Text can scale/wrap independently without moving any sampling location.
|
||||
struct PrivacyProbePattern {
|
||||
static constexpr int kOffset = 16;
|
||||
static constexpr int kPadding = 16;
|
||||
static constexpr int kGap = 24;
|
||||
static constexpr int kCell = 12;
|
||||
static constexpr int kColumns = 8;
|
||||
static constexpr int kRows = 8;
|
||||
static constexpr int kSide = kColumns * kCell;
|
||||
static constexpr int kCaptureExtent = kOffset + kPadding + kSide;
|
||||
static constexpr int kTextLeft = kPadding + kSide + kGap;
|
||||
|
||||
// Top half carries all 32 bits; bottom half carries their complements.
|
||||
static constexpr bool White(uint32_t value, int column, int row) {
|
||||
const int index = row * kColumns + column;
|
||||
const bool bit = (value & (uint32_t{1} << (index % 32))) != 0;
|
||||
return index < 32 ? bit : !bit;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_PROBE_PATTERN_H_
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class PrivacyController;
|
||||
|
||||
class ScreenCapturer {
|
||||
public:
|
||||
// Windows backends emit a control-only callback BEFORE automatic recovery.
|
||||
@@ -32,6 +34,7 @@ class ScreenCapturer {
|
||||
|
||||
public:
|
||||
virtual int Init(const int fps, cb_desktop_data cb) = 0;
|
||||
virtual void SetPrivacyController(PrivacyController*) {}
|
||||
virtual int Destroy() = 0;
|
||||
virtual int Start(bool show_cursor) = 0;
|
||||
virtual int Stop() = 0;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Run from the repository root: xmake lua apps/desktop/tests/build_define_scope_test.lua windows true
|
||||
-- Inspect the real Xmake target graph without configuring/downloading packages.
|
||||
function main(plat, portable)
|
||||
import("core.project.config")
|
||||
import("core.project.project")
|
||||
config.load()
|
||||
config.set("plat", plat or "windows", {force = true})
|
||||
config.set("arch", plat == "macosx" and "arm64" or "x64", {force = true})
|
||||
config.set("CROSSDESK_PORTABLE", portable == "true", {force = true})
|
||||
config.set("CROSSDESK_VERSION", "v1.5.3-2-20260913", {force = true})
|
||||
local targets = project.targets()
|
||||
local function flatten(values)
|
||||
local result = {}
|
||||
local function append(value)
|
||||
if type(value) == "table" then
|
||||
for _, entry in ipairs(value) do append(entry) end
|
||||
elseif value ~= nil then
|
||||
table.insert(result, value)
|
||||
end
|
||||
end
|
||||
append(values)
|
||||
return result
|
||||
end
|
||||
local function contains(values, expected)
|
||||
return table.contains(flatten(values), expected)
|
||||
end
|
||||
local function effective_defines(target)
|
||||
return table.join(table.wrap(target:get("defines")),
|
||||
flatten(target:get_from("defines", "dep::*")))
|
||||
end
|
||||
|
||||
for _, name in ipairs({"minirtc", "media", "common", "crossdesk_wire", "rd_log"}) do
|
||||
for _, define in ipairs(effective_defines(targets[name])) do
|
||||
assert(not define:startswith("CROSSDESK_VERSION"), name .. " inherits version macros")
|
||||
assert(not define:startswith("CROSSDESK_PORTABLE"), name .. " inherits portable macros")
|
||||
end
|
||||
end
|
||||
for _, name in ipairs({"gui", "crossdesk", "path_manager"}) do
|
||||
assert(contains(effective_defines(targets[name]), "CROSSDESK_PORTABLE=1") ==
|
||||
(portable == "true"), name .. " has inconsistent portable layout/configuration")
|
||||
end
|
||||
|
||||
local gui = targets.gui
|
||||
local version_files = {
|
||||
["apps/desktop/src/gui/application/gui_application.cpp"] = true,
|
||||
["apps/desktop/src/gui/runtime/peer_event_handler.cpp"] = true
|
||||
}
|
||||
local seen = {}
|
||||
for _, file in ipairs(gui:sourcefiles()) do
|
||||
assert(not seen[file], "duplicate GUI source: " .. file)
|
||||
seen[file] = true
|
||||
local defines = (gui:fileconfig(file) or {}).defines
|
||||
assert(contains(defines, 'CROSSDESK_VERSION="v1.5.3-2-20260913"') ==
|
||||
(version_files[file] == true), "unexpected version scope: " .. file)
|
||||
end
|
||||
for file in pairs(version_files) do
|
||||
assert(seen[file], "missing version consumer: " .. file)
|
||||
end
|
||||
|
||||
if plat == "windows" then
|
||||
local resources = {
|
||||
crossdesk = portable == "true" and "crossdesk_portable" or "crossdesk",
|
||||
crossdesk_service = "crossdesk_service",
|
||||
crossdesk_session_helper = "crossdesk_session_helper",
|
||||
crossdesk_privacy_window = "crossdesk_privacy_window",
|
||||
wgc_plugin = "wgc_plugin"
|
||||
}
|
||||
for name, resource in pairs(resources) do
|
||||
local defines = targets[name]:fileconfig(
|
||||
"apps/desktop/resources/windows/" .. resource .. ".rc").defines
|
||||
assert(contains(defines, 'CROSSDESK_VERSION_STRING="v1.5.3-2-20260913"'))
|
||||
assert(contains(defines, "CROSSDESK_VERSION_NUMERIC=1,5,3,0"))
|
||||
assert(contains(defines, "CROSSDESK_PORTABLE=1") ==
|
||||
(name == "crossdesk_session_helper" and portable == "true"))
|
||||
end
|
||||
end
|
||||
print("Build define scopes passed: %s portable=%s", plat, portable)
|
||||
end
|
||||
@@ -1,52 +0,0 @@
|
||||
#include <X11/Xlib.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <thread>
|
||||
|
||||
#include "keyboard_capturer.h"
|
||||
|
||||
int main() {
|
||||
Display* receiver = XOpenDisplay(nullptr);
|
||||
if (!receiver) {
|
||||
std::fprintf(stderr, "open display failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int screen = DefaultScreen(receiver);
|
||||
Window window = XCreateSimpleWindow(receiver, RootWindow(receiver, screen),
|
||||
0, 0, 100, 100, 0, 0, 0);
|
||||
XSelectInput(receiver, window, KeyPressMask | KeyReleaseMask);
|
||||
XMapWindow(receiver, window);
|
||||
XSync(receiver, False);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
XSetInputFocus(receiver, window, RevertToParent, CurrentTime);
|
||||
XSync(receiver, False);
|
||||
|
||||
crossdesk::PlatformKeyboardCapturer keyboard;
|
||||
// Exercise the scan-code fallback: the controller supplied no usable VK,
|
||||
// but did preserve the set-1 scan code for the A key.
|
||||
const int down = keyboard.SendKeyboardCommand(0, true, 0x1E, false);
|
||||
const int up = keyboard.SendKeyboardCommand(0, false, 0x1E, false);
|
||||
|
||||
bool saw_down = false;
|
||||
bool saw_up = false;
|
||||
const auto deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::seconds(2);
|
||||
while (std::chrono::steady_clock::now() < deadline &&
|
||||
(!saw_down || !saw_up)) {
|
||||
while (XPending(receiver) > 0) {
|
||||
XEvent event{};
|
||||
XNextEvent(receiver, &event);
|
||||
saw_down = saw_down || event.type == KeyPress;
|
||||
saw_up = saw_up || event.type == KeyRelease;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
|
||||
XDestroyWindow(receiver, window);
|
||||
XCloseDisplay(receiver);
|
||||
std::printf("down_ret=%d up_ret=%d saw_down=%d saw_up=%d\n", down, up,
|
||||
saw_down, saw_up);
|
||||
return down == 0 && up == 0 && saw_down && saw_up ? 0 : 2;
|
||||
}
|
||||
@@ -29,30 +29,6 @@ function setup_options_and_dependencies()
|
||||
set_description("Build CrossDesk as a portable package that stores data beside the executable")
|
||||
option_end()
|
||||
|
||||
-- Preserve the existing codebase/toolchain contract. Slint targets opt in to
|
||||
-- C++20 locally because its generated C++ API requires it.
|
||||
local crossdesk_version = get_config("CROSSDESK_VERSION") or "0.0.0"
|
||||
local version_base = crossdesk_version:gsub("^v", ""):match("^(%d[%d%.]*)") or "0.0.0"
|
||||
local version_parts = {}
|
||||
for part in version_base:gmatch("%d+") do
|
||||
local value = tonumber(part) or 0
|
||||
if value > 65535 then
|
||||
value = 0
|
||||
end
|
||||
table.insert(version_parts, value)
|
||||
if #version_parts == 4 then
|
||||
break
|
||||
end
|
||||
end
|
||||
while #version_parts < 4 do
|
||||
table.insert(version_parts, 0)
|
||||
end
|
||||
|
||||
add_defines("CROSSDESK_VERSION_STRING=\"" .. crossdesk_version .. "\"")
|
||||
if is_os("windows") then
|
||||
add_defines("CROSSDESK_VERSION_NUMERIC=" .. table.concat(version_parts, ","))
|
||||
end
|
||||
|
||||
-- set_policy("build.warning", true)
|
||||
-- set_warnings("all", "extra")
|
||||
-- add_cxxflags("/W4", "/WX")
|
||||
@@ -61,10 +37,6 @@ function setup_options_and_dependencies()
|
||||
add_defines("USE_CUDA=" .. (is_config("USE_CUDA", true) and "1" or "0"))
|
||||
add_defines("USE_WAYLAND=" .. (is_config("USE_WAYLAND", true) and "1" or "0"))
|
||||
add_defines("USE_DRM=" .. (is_config("USE_DRM", true) and "1" or "0"))
|
||||
if is_config("CROSSDESK_PORTABLE", true) then
|
||||
add_defines("CROSSDESK_PORTABLE=1")
|
||||
end
|
||||
|
||||
if is_mode("debug") then
|
||||
add_defines("CROSSDESK_DEBUG")
|
||||
end
|
||||
|
||||
@@ -3,6 +3,30 @@ function setup_targets()
|
||||
|
||||
includes("deps/submodules", "deps/thirdparty")
|
||||
|
||||
local crossdesk_version = get_config("CROSSDESK_VERSION") or "0.0.0"
|
||||
local version_define = "CROSSDESK_VERSION=\"" .. crossdesk_version .. "\""
|
||||
local windows_resource_defines = {
|
||||
"CROSSDESK_VERSION_STRING=\"" .. crossdesk_version .. "\""
|
||||
}
|
||||
local version_base = crossdesk_version:gsub("^v", ""):match("^(%d[%d%.]*)") or "0.0.0"
|
||||
local version_parts = {}
|
||||
for part in version_base:gmatch("%d+") do
|
||||
local value = tonumber(part) or 0
|
||||
table.insert(version_parts, value <= 65535 and value or 0)
|
||||
if #version_parts == 4 then
|
||||
break
|
||||
end
|
||||
end
|
||||
while #version_parts < 4 do
|
||||
table.insert(version_parts, 0)
|
||||
end
|
||||
table.insert(windows_resource_defines,
|
||||
"CROSSDESK_VERSION_NUMERIC=" .. table.concat(version_parts, ","))
|
||||
local windows_helper_resource_defines = table.join(windows_resource_defines)
|
||||
if is_config("CROSSDESK_PORTABLE", true) then
|
||||
table.insert(windows_helper_resource_defines, "CROSSDESK_PORTABLE=1")
|
||||
end
|
||||
|
||||
local function copy_slint_runtime(target)
|
||||
if not target:is_plat("windows") then
|
||||
return
|
||||
@@ -50,6 +74,9 @@ function setup_targets()
|
||||
|
||||
target("path_manager")
|
||||
set_kind("object")
|
||||
if is_config("CROSSDESK_PORTABLE", true) then
|
||||
add_defines("CROSSDESK_PORTABLE=1")
|
||||
end
|
||||
add_deps("rd_log")
|
||||
add_files("apps/desktop/src/path_manager/*.cpp")
|
||||
if is_os("windows") then
|
||||
@@ -167,6 +194,13 @@ function setup_targets()
|
||||
add_deps("crossdesk_privacy_window", {inherit = false})
|
||||
add_includedirs("apps/desktop/src/platform/windows/input")
|
||||
add_syslinks("Wtsapi32", "Ole32", {public = true})
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/privacy/*.mm")
|
||||
add_mxxflags("-fobjc-arc")
|
||||
add_frameworks("AppKit", "ApplicationServices", {public = true})
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/privacy/*.cpp")
|
||||
add_syslinks("Xi", {public = true})
|
||||
end
|
||||
|
||||
if is_os("windows") then
|
||||
@@ -175,7 +209,8 @@ function setup_targets()
|
||||
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_files("apps/desktop/resources/windows/crossdesk_privacy_window.rc",
|
||||
{defines = windows_resource_defines})
|
||||
add_syslinks("user32", "gdi32", "dwmapi")
|
||||
|
||||
target("crossdesk_privacy_cursor_helper")
|
||||
@@ -199,10 +234,13 @@ function setup_targets()
|
||||
add_includedirs("apps/desktop/src/platform/windows/screen_capturer",
|
||||
"apps/desktop/src/platform/windows/service")
|
||||
elseif is_os("macosx") then
|
||||
add_deps("privacy")
|
||||
add_mxxflags("-fobjc-arc")
|
||||
add_files("apps/desktop/src/platform/macos/screen_capturer/*.cpp",
|
||||
"apps/desktop/src/platform/macos/screen_capturer/*.mm")
|
||||
add_includedirs("apps/desktop/src/platform/macos/screen_capturer")
|
||||
elseif is_os("linux") then
|
||||
add_deps("privacy")
|
||||
add_packages("libyuv")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_x11.cpp")
|
||||
@@ -259,20 +297,13 @@ function setup_targets()
|
||||
add_files("apps/desktop/src/platform/linux/input/mouse/*.cpp",
|
||||
"apps/desktop/src/platform/linux/input/keyboard/*.cpp",
|
||||
"apps/desktop/src/platform/linux/input/device_controller_factory.cpp")
|
||||
add_syslinks("Xi", {public = true})
|
||||
add_includedirs("apps/desktop/src/platform/linux/input/mouse",
|
||||
"apps/desktop/src/platform/linux/input/keyboard",
|
||||
"apps/desktop/src/platform/linux/input",
|
||||
"apps/desktop/src/platform/linux/common")
|
||||
end
|
||||
|
||||
if is_os("linux") then
|
||||
target("linux_keyboard_x11_integration_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("device_controller")
|
||||
add_files("apps/desktop/tests/linux_keyboard_x11_integration_test.cpp")
|
||||
end
|
||||
|
||||
target("thumbnail")
|
||||
set_kind("object")
|
||||
add_packages("libyuv", "openssl3")
|
||||
@@ -311,7 +342,6 @@ function setup_targets()
|
||||
target("version_checker")
|
||||
set_kind("object")
|
||||
add_packages("cpp-httplib")
|
||||
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
|
||||
add_deps("rd_log")
|
||||
add_files("apps/desktop/src/version_checker/*.cpp")
|
||||
add_includedirs("apps/desktop/src/version_checker", {public = true})
|
||||
@@ -343,7 +373,11 @@ function setup_targets()
|
||||
add_packages("slint", {public = true})
|
||||
add_packages("libyuv", "tinyfiledialogs")
|
||||
add_rules("slint")
|
||||
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
|
||||
if is_config("CROSSDESK_PORTABLE", true) then
|
||||
-- RuntimeState changes layout in portable builds. Consumers of GUI
|
||||
-- headers must see the same definition, without affecting its deps.
|
||||
add_defines("CROSSDESK_PORTABLE=1", {public = true})
|
||||
end
|
||||
add_deps("rd_log", "common", "assets", "config_center", "minirtc",
|
||||
"path_manager", "screen_capturer", "speaker_capturer",
|
||||
"device_controller", "thumbnail", "version_checker", "tools",
|
||||
@@ -355,13 +389,20 @@ function setup_targets()
|
||||
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
|
||||
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
|
||||
"apps/desktop/src/gui/features/settings/*.cpp", "apps/desktop/src/gui/ui/crossdesk_ui.slint")
|
||||
-- Only these translation units consume the application version. Keep
|
||||
-- version changes out of the flags for media and the rest of the GUI.
|
||||
add_files("apps/desktop/src/gui/application/gui_application.cpp",
|
||||
"apps/desktop/src/gui/runtime/peer_event_handler.cpp",
|
||||
{defines = version_define})
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/gui", {public = true})
|
||||
add_includedirs("apps/desktop/src/platform/common/input")
|
||||
if is_os("windows") then
|
||||
add_cxxflags("/bigobj")
|
||||
add_links("opengl32")
|
||||
add_syslinks("Comctl32", {public = true})
|
||||
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
|
||||
"apps/desktop/src/platform/windows/gui/slint_backend.cpp",
|
||||
"apps/desktop/src/platform/windows/gui/window_icons.cpp",
|
||||
"apps/desktop/src/platform/windows/gui/slint_renderer_probe.cpp",
|
||||
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
|
||||
"apps/desktop/src/platform/common/gui/cuda_driver.cpp",
|
||||
@@ -406,7 +447,8 @@ function setup_targets()
|
||||
add_files("apps/desktop/src/platform/windows/screen_capturer/screen_capturer_wgc.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/wgc_plugin_entry.cpp")
|
||||
add_files("apps/desktop/resources/windows/wgc_plugin.rc")
|
||||
add_files("apps/desktop/resources/windows/wgc_plugin.rc",
|
||||
{defines = windows_resource_defines})
|
||||
add_includedirs("apps/desktop/src/common", "apps/desktop/src/screen_capturer",
|
||||
"apps/desktop/src/platform/windows/screen_capturer",
|
||||
"deps/submodules/minirtc/src/api")
|
||||
@@ -417,8 +459,10 @@ function setup_targets()
|
||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||
add_files("apps/desktop/src/platform/windows/service/main.cpp",
|
||||
"apps/desktop/src/platform/windows/service/service_host.cpp")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_service.rc")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_service.rc",
|
||||
{defines = windows_resource_defines})
|
||||
add_includedirs("apps/desktop/src/platform/windows/service",
|
||||
"apps/desktop/src/platform/windows/screen_capturer")
|
||||
|
||||
target("crossdesk_session_helper")
|
||||
set_kind("binary")
|
||||
@@ -426,10 +470,13 @@ function setup_targets()
|
||||
add_deps("rd_log", "path_manager")
|
||||
add_links("Advapi32", "User32", "Wtsapi32", "Gdi32")
|
||||
add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc",
|
||||
{defines = windows_helper_resource_defines})
|
||||
add_includedirs("apps/desktop/src/common",
|
||||
"apps/desktop/src/platform/windows/service")
|
||||
"apps/desktop/src/platform/windows/service",
|
||||
"apps/desktop/src/platform/windows/screen_capturer")
|
||||
add_includedirs("apps/desktop/src/platform/windows/input")
|
||||
add_includedirs("libs/wire/include")
|
||||
end
|
||||
|
||||
target("crossdesk")
|
||||
@@ -440,10 +487,11 @@ function setup_targets()
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/daemon_backend.cpp")
|
||||
add_files("apps/desktop/src/platform/windows/service/service_host.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service",
|
||||
"apps/desktop/src/platform/windows/screen_capturer")
|
||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||
add_deps("wgc_plugin", "crossdesk_service", "crossdesk_session_helper")
|
||||
add_files(crossdesk_windows_resource)
|
||||
add_files(crossdesk_windows_resource, {defines = windows_resource_defines})
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/common/posix_daemon_backend.cpp",
|
||||
"apps/desktop/src/platform/macos/daemon_backend.cpp")
|
||||
|
||||
@@ -99,8 +99,7 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
@property(nonatomic, weak, nullable) id<CrossDeskRTCBridgeDelegate> delegate;
|
||||
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort
|
||||
enableSRTP:(BOOL)enableSRTP;
|
||||
signalPort:(NSInteger)signalPort;
|
||||
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
|
||||
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
|
||||
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
|
||||
|
||||
@@ -75,7 +75,6 @@ struct RTCState {
|
||||
CallbackContext controller_context;
|
||||
std::string signal_host;
|
||||
int signal_port = 0;
|
||||
bool enable_srtp = false;
|
||||
bool hardware_acceleration = true;
|
||||
VideoDegradationPreference video_adaptation_policy =
|
||||
VideoDegradationPreference::MaintainResolution;
|
||||
@@ -86,6 +85,7 @@ struct RTCState {
|
||||
std::string identity_base;
|
||||
std::string controller_login;
|
||||
std::string pending_remote_id;
|
||||
uint64_t pending_controller_generation = 0;
|
||||
std::string pending_password;
|
||||
std::string transmission_id;
|
||||
std::string log_path;
|
||||
@@ -336,7 +336,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
params.native_video_output = true;
|
||||
params.av1_encoding = false;
|
||||
params.turn_mode = TurnMode::TurnAutoUdpTcp;
|
||||
params.enable_srtp = state.enable_srtp;
|
||||
params.enable_srtp = true;
|
||||
params.video_content_type = VideoContentType::ScreenContent;
|
||||
params.video_quality = VideoQuality::QualityHigh;
|
||||
params.video_frame_rate = 60;
|
||||
@@ -432,8 +432,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort
|
||||
enableSRTP:(BOOL)enableSRTP {
|
||||
signalPort:(NSInteger)signalPort {
|
||||
NSString *trimmed = [host stringByTrimmingCharactersInSet:
|
||||
NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
const char *host_c_string = trimmed.UTF8String;
|
||||
@@ -455,7 +454,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->enable_srtp == enableSRTP &&
|
||||
self->_state->identity_peer != nullptr;
|
||||
if (unchanged) return;
|
||||
|
||||
@@ -463,7 +461,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->enable_srtp = enableSRTP;
|
||||
self->_state->log_path = log_path;
|
||||
self->_state->identity_ready = false;
|
||||
self->_state->identity_recovery_attempted = false;
|
||||
@@ -583,8 +580,14 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
password_c_string ? password_c_string : "";
|
||||
if (identifier.empty()) return;
|
||||
|
||||
// Invalidate queued callbacks immediately, before asynchronous teardown. A
|
||||
// canceled attempt for the same remote must not close a newly started one.
|
||||
const uint64_t generation = _controllerGenerationCounter.fetch_add(1) + 1;
|
||||
_activeControllerGeneration.store(0);
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
if (generation != self->_controllerGenerationCounter.load()) return;
|
||||
self->_state->pending_remote_id = identifier;
|
||||
self->_state->pending_controller_generation = generation;
|
||||
self->_state->pending_password = password_value;
|
||||
[self destroyControllerPeer];
|
||||
if (self->_state->identity_ready &&
|
||||
@@ -595,6 +598,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
|
||||
- (void)disconnect {
|
||||
_controllerGenerationCounter.fetch_add(1);
|
||||
_activeControllerGeneration.store(0);
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
self->_state->pending_remote_id.clear();
|
||||
self->_state->pending_password.clear();
|
||||
@@ -616,21 +621,15 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
y:(float)y
|
||||
action:(CrossDeskPointerAction)action {
|
||||
const std::string message = MouseJSON(x, y, 0, static_cast<int>(action));
|
||||
// Movement is latency-sensitive and can be superseded by newer positions.
|
||||
// Button transitions must not be lost or reordered, otherwise a tap can be
|
||||
// applied at a stale pointer position or leave a button logically pressed.
|
||||
// MiniRTC reliability is configured per stream, so transitions use the
|
||||
// existing reliable control stream while movement stays on the mouse stream.
|
||||
const BOOL is_movement = action == CrossDeskPointerActionMove;
|
||||
// Keep movement, button transitions, and scrolling ordered on the reliable
|
||||
// mouse stream.
|
||||
#if DEBUG
|
||||
if (!is_movement) {
|
||||
if (action != CrossDeskPointerActionMove) {
|
||||
NSLog(@"CrossDesk pointer action=%ld normalized=(%.5f, %.5f)",
|
||||
static_cast<long>(action), x, y);
|
||||
}
|
||||
#endif
|
||||
[self sendMessage:message
|
||||
reliable:!is_movement
|
||||
stream:is_movement ? kMouseStream : kControlStream];
|
||||
[self sendMessage:message reliable:YES stream:kMouseStream];
|
||||
}
|
||||
|
||||
- (void)sendScrollX:(float)x
|
||||
@@ -643,7 +642,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
const int flag = vertical ? 7 : 8;
|
||||
const int wheel = raw_delta > 0 ? 1 : -1;
|
||||
const std::string message = MouseJSON(x, y, wheel, flag);
|
||||
[self sendMessage:message reliable:NO stream:kMouseStream];
|
||||
[self sendMessage:message reliable:YES stream:kMouseStream];
|
||||
}
|
||||
|
||||
- (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown {
|
||||
@@ -868,7 +867,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
_state->identity_base.empty()) {
|
||||
return;
|
||||
}
|
||||
const uint64_t generation = _controllerGenerationCounter.fetch_add(1) + 1;
|
||||
const uint64_t generation = _state->pending_controller_generation;
|
||||
if (generation == 0 || generation != _controllerGenerationCounter.load()) return;
|
||||
_state->controller_context.generation = generation;
|
||||
_activeControllerGeneration.store(generation);
|
||||
_state->controller_login = "C-" + _state->identity_base;
|
||||
@@ -887,7 +887,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
// display. Remote display receivers are created from the host's SDP.
|
||||
AddAudioStream(_state->controller_peer, kAudioStream);
|
||||
AddDataStream(_state->controller_peer, kDataStream, false);
|
||||
AddDataStream(_state->controller_peer, kMouseStream, false);
|
||||
AddDataStream(_state->controller_peer, kMouseStream, true);
|
||||
AddDataStream(_state->controller_peer, kKeyboardStream, true);
|
||||
AddDataStream(_state->controller_peer, kControlStream, true);
|
||||
AddDataStream(_state->controller_peer, kFileStream, true);
|
||||
@@ -1112,6 +1112,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
remoteID:(const std::string &)remoteID
|
||||
generation:(uint64_t)generation {
|
||||
if (![self isControllerGenerationCurrent:generation]) return;
|
||||
if (state != CrossDeskConnectionStateFailed &&
|
||||
![self isControllerGenerationActive:generation]) return;
|
||||
const std::string remote_copy = remoteID;
|
||||
if (state == CrossDeskConnectionStateConnected) {
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
@@ -1149,7 +1151,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
DispatchMain(^{
|
||||
if (![self isControllerGenerationCurrent:generation]) return;
|
||||
if (state == CrossDeskConnectionStateConnected &&
|
||||
if (state != CrossDeskConnectionStateFailed &&
|
||||
![self isControllerGenerationActive:generation]) {
|
||||
return;
|
||||
}
|
||||
@@ -1164,6 +1166,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
|
||||
- (BOOL)isControllerGenerationActive:(uint64_t)generation {
|
||||
return generation != 0 &&
|
||||
_controllerGenerationCounter.load() == generation &&
|
||||
_activeControllerGeneration.load() == generation;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ struct ContentView: View {
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
|
||||
.preferredColorScheme(session.sessionVisible ? .dark : .light)
|
||||
.alert("连接提示", isPresented: Binding(
|
||||
get: { session.connectionFailureMessage != nil },
|
||||
set: { if !$0 { session.dismissConnectionFailure() } }
|
||||
)) {
|
||||
Button("确定", role: .cancel) { session.dismissConnectionFailure() }
|
||||
} message: {
|
||||
Text(session.connectionFailureMessage ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
if !session.sessionVisible {
|
||||
AppOrientation.update(to: .portrait)
|
||||
@@ -710,17 +718,14 @@ private struct ServerSettingsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Section("服务器") {
|
||||
Section {
|
||||
TextField("信令服务器", text: $session.signalHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("信令端口", text: $session.signalPort)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
Section {
|
||||
Toggle("启用 SRTP", isOn: $session.enableSRTP)
|
||||
} header: {
|
||||
Text("传输")
|
||||
Text("服务器")
|
||||
} footer: {
|
||||
Text(session.localIdentity.isEmpty
|
||||
? "正在获取本机 ID…"
|
||||
|
||||
@@ -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 enableSRTP = false
|
||||
@Published var mouseControlMode = MouseControlMode.saved {
|
||||
didSet { mouseControlMode.save() }
|
||||
}
|
||||
@@ -398,6 +397,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
@Published private(set) var recentConnections = RecentConnectionStore.load()
|
||||
@Published private(set) var recentConnectionPresence: [String: Bool] = [:]
|
||||
@Published private(set) var deviceOfflineAlertVisible = false
|
||||
@Published private(set) var connectionFailureMessage: String?
|
||||
|
||||
let bridge = CrossDeskRTCBridge()
|
||||
private let audioPlayer = RemoteAudioPlayer()
|
||||
@@ -424,7 +424,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
private struct BridgeConfiguration: Equatable {
|
||||
let host: String
|
||||
let signalPort: Int
|
||||
let enableSRTP: Bool
|
||||
}
|
||||
private var bridgeConfiguration: BridgeConfiguration?
|
||||
|
||||
@@ -457,8 +456,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
signalStatus = "服务器配置无效"
|
||||
return false
|
||||
}
|
||||
let configuration = BridgeConfiguration(host: host, signalPort: signal,
|
||||
enableSRTP: enableSRTP)
|
||||
let configuration = BridgeConfiguration(host: host, signalPort: signal)
|
||||
if bridgeConfiguration != configuration {
|
||||
bridgeConfiguration = configuration
|
||||
signalConnected = false
|
||||
@@ -472,8 +470,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware)
|
||||
bridge.setVideoAdaptationPolicy(videoAdaptationPolicy.bridgeValue)
|
||||
bridge.configure(withSignalHost: host,
|
||||
signalPort: signal,
|
||||
enableSRTP: enableSRTP)
|
||||
signalPort: signal)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -484,6 +481,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
}
|
||||
|
||||
func connect() {
|
||||
connectionFailureMessage = nil
|
||||
let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !identifier.isEmpty else {
|
||||
connectionStatus = "请输入远程设备 ID"
|
||||
@@ -576,7 +574,25 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
deviceOfflineAlertVisible = false
|
||||
}
|
||||
|
||||
func dismissConnectionFailure() {
|
||||
guard connectionFailureMessage != nil else { return }
|
||||
connectionFailureMessage = nil
|
||||
}
|
||||
|
||||
private func finishConnection(withError message: String) {
|
||||
// Cleanup may emit Closed. Preserve the terminal reason independently
|
||||
// of the progress view and invalidate those callbacks in the bridge.
|
||||
resetConnection()
|
||||
connectionStatus = message
|
||||
connectionFailureMessage = message
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
resetConnection()
|
||||
}
|
||||
|
||||
private func resetConnection() {
|
||||
connectionFailureMessage = nil
|
||||
cancelVideoRecovery()
|
||||
let wasCheckingPresence = pendingPresenceRemoteID != nil
|
||||
cancelPresenceProbe()
|
||||
@@ -882,6 +898,10 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didChange state: CrossDeskConnectionState,
|
||||
remoteID: String) {
|
||||
guard isConnecting || isConnected || sessionVisible,
|
||||
remoteID.isEmpty || remoteID == activeRemoteID else {
|
||||
return
|
||||
}
|
||||
isConnected = state.rawValue == 1
|
||||
if !isConnected {
|
||||
cancelVideoRecovery()
|
||||
@@ -897,35 +917,25 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
bridge.setAudioEnabled(audioEnabled)
|
||||
AppOrientation.update(to: .allButUpsideDown)
|
||||
case 2: connectionStatus = "正在收集 ICE 候选"
|
||||
case 3: connectionStatus = "网络连接已中断"
|
||||
case 3: finishConnection(withError: "网络连接已中断,请重新连接。")
|
||||
case 4:
|
||||
connectionStatus = "P2P/TURN 建链失败"
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
finishConnection(withError: "P2P/TURN 建链失败,请检查网络后重试。")
|
||||
case 5:
|
||||
connectionStatus = "未连接"
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
finishConnection(withError: "远程连接已结束。")
|
||||
case 6:
|
||||
connectionStatus = "访问密码错误"
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
finishConnection(withError: "访问密码错误,请重新输入。")
|
||||
case 7:
|
||||
connectionStatus = "远程设备 ID 不存在"
|
||||
if !remoteID.isEmpty {
|
||||
recentConnectionPresence[remoteID] = false
|
||||
presenceUpdatedAt.removeValue(forKey: remoteID)
|
||||
}
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
finishConnection(withError: "远程设备 ID 不存在,请检查设备 ID。")
|
||||
case 8:
|
||||
connectionStatus = "远程设备当前不可用"
|
||||
if !remoteID.isEmpty {
|
||||
recentConnectionPresence[remoteID] = false
|
||||
presenceUpdatedAt.removeValue(forKey: remoteID)
|
||||
}
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
finishConnection(withError: "远程设备当前不可用,请稍后重试。")
|
||||
default: connectionStatus = "未知连接状态"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -56,12 +56,17 @@ the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
|
||||
|
||||
## Video codecs
|
||||
|
||||
Every iOS build includes VideoToolbox, OpenH264, dav1d, libaom, and SVT-AV1. H.264 uses
|
||||
Every iOS build includes VideoToolbox, OpenH264, dav1d, and SVT-AV1. H.264 uses
|
||||
VideoToolbox by default; choose software processing under **设置 → 视频编解码 →
|
||||
处理方式** (Settings → Video codec → Processing mode) to use OpenH264 for H.264.
|
||||
The change takes effect on the next connection. AV1 encoding uses SVT-AV1 and AV1 decoding
|
||||
uses dav1d; VideoToolbox AV1 hardware decoding is currently unsupported.
|
||||
|
||||
The unused libaom backends are excluded by default. To include them for development,
|
||||
set the `MINIRTC_ENABLE_AOM` environment variable (or Xcode user-defined build setting)
|
||||
to `true`. This includes libaom in the merged native archive without changing the
|
||||
AV1 factories' choice of SVT-AV1 and dav1d.
|
||||
|
||||
## Physical-device test checklist
|
||||
|
||||
Run the app from Xcode on a physical device and connect to a current desktop
|
||||
|
||||
@@ -8,6 +8,11 @@ REPO_DIR="${IOS_DIR:h:h}"
|
||||
MINIRTC_DIR="${REPO_DIR}/deps/submodules/minirtc"
|
||||
CONFIG_NAME="${CONFIGURATION:-Debug}"
|
||||
MODE="${CONFIG_NAME:l}"
|
||||
MINIRTC_ENABLE_AOM="${MINIRTC_ENABLE_AOM:-false}"
|
||||
if [[ "${MINIRTC_ENABLE_AOM}" != "true" && "${MINIRTC_ENABLE_AOM}" != "false" ]]; then
|
||||
print -u2 "MINIRTC_ENABLE_AOM must be true or false."
|
||||
exit 64
|
||||
fi
|
||||
|
||||
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
|
||||
# cross-project cache collisions, this guarantees every archive is compiled
|
||||
@@ -77,17 +82,43 @@ WIRE_MANIFEST_FILE="${WIRE_WORK_DIR}/xmake.lua"
|
||||
WIRE_LIBRARY="${WIRE_BUILD_DIR}/iphoneos/arm64/${MODE}/libcrossdesk_wire.a"
|
||||
|
||||
mkdir -p "${OUTPUT_DIR}" "${WIRE_WORK_DIR}"
|
||||
cp "${REPO_DIR}/xmake.lua" "${WIRE_MANIFEST_FILE}"
|
||||
if ! cmp -s "${REPO_DIR}/xmake.lua" "${WIRE_MANIFEST_FILE}"; then
|
||||
cp "${REPO_DIR}/xmake.lua" "${WIRE_MANIFEST_FILE}"
|
||||
fi
|
||||
|
||||
# Reuse Xmake's compiler/dependency probes unless the build environment changes.
|
||||
# Still configure on every invocation: Xmake must see manifest edits and restore
|
||||
# the iOS configuration if MiniRTC was built standalone for another platform.
|
||||
CONFIG_SIGNATURE="$(print -r -- "${XMAKE_BIN}" "${XMAKE_DEVELOPER_DIR}" \
|
||||
"${MODE}" "${ARCH_NAME}" "16.0" "${XMAKE_PKG_INSTALLDIR}" "${MINIRTC_ENABLE_AOM}"
|
||||
run_xmake --version | sed -n '1p'
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcodebuild -version
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk iphoneos --show-sdk-path
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" xcrun --sdk iphoneos --show-sdk-version)"
|
||||
|
||||
configure_xmake() {
|
||||
local project_dir="$1"
|
||||
local build_dir="$2"
|
||||
shift 2
|
||||
local stamp="${build_dir}/ios-config-signature"
|
||||
local clean_flags=()
|
||||
if [[ ! -f "${stamp}" || "$(<"${stamp}")" != "${CONFIG_SIGNATURE}" ]]; then
|
||||
clean_flags=(-c)
|
||||
fi
|
||||
run_xmake f -P "${project_dir}" "${clean_flags[@]}" -o "${build_dir}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" --target_minver=16.0 -y "$@"
|
||||
mkdir -p "${build_dir}"
|
||||
print -r -- "${CONFIG_SIGNATURE}" > "${stamp}"
|
||||
}
|
||||
|
||||
# Xmake stores the configured build directory relative to the process working
|
||||
# directory. Xcode does not guarantee that directory for build phases, so keep
|
||||
# configuration, compilation and inspection anchored to the MiniRTC project.
|
||||
(
|
||||
cd "${MINIRTC_DIR}"
|
||||
run_xmake f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" \
|
||||
--as="${XMAKE_TOOLCHAIN_BIN}/clang" \
|
||||
--target_minver=16.0 --USE_CUDA=false -y
|
||||
configure_xmake "${MINIRTC_DIR}" "${MINIRTC_BUILD_DIR}" \
|
||||
--as="${XMAKE_TOOLCHAIN_BIN}/clang" --USE_CUDA=false \
|
||||
--MINIRTC_ENABLE_AOM="${MINIRTC_ENABLE_AOM}"
|
||||
run_xmake b -P "${MINIRTC_DIR}" minirtc
|
||||
)
|
||||
|
||||
@@ -101,9 +132,7 @@ fi
|
||||
# no second project definition to maintain.
|
||||
(
|
||||
cd "${WIRE_WORK_DIR}"
|
||||
run_xmake f -P "${WIRE_WORK_DIR}" -c -o "${WIRE_BUILD_DIR}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" \
|
||||
--target_minver=16.0 -y
|
||||
configure_xmake "${WIRE_WORK_DIR}" "${WIRE_BUILD_DIR}"
|
||||
run_xmake b -P "${WIRE_WORK_DIR}" crossdesk_wire
|
||||
)
|
||||
|
||||
@@ -123,8 +152,11 @@ REQUIRED_LINKS=(
|
||||
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
|
||||
gupnp-igd-1.6 gupnp-1.6 gssdp-1.6 soup-3.0 nghttp2 sqlite3 psl xml2
|
||||
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
|
||||
datachannel usrsctp openh264 dav1d aom SvtAv1Enc
|
||||
datachannel usrsctp openh264 dav1d SvtAv1Enc
|
||||
)
|
||||
if [[ "${MINIRTC_ENABLE_AOM}" == "true" ]]; then
|
||||
REQUIRED_LINKS+=(aom)
|
||||
fi
|
||||
DEPENDENCY_ARCHIVES=()
|
||||
|
||||
for link_name in "${REQUIRED_LINKS[@]}"; do
|
||||
@@ -144,10 +176,24 @@ for link_name in "${REQUIRED_LINKS[@]}"; do
|
||||
done
|
||||
|
||||
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
|
||||
rm -f "${TEMP_LIBRARY}"
|
||||
MERGE_MANIFEST="${OUTPUT_LIBRARY}.inputs.sha256"
|
||||
TEMP_MANIFEST="${MERGE_MANIFEST}.tmp"
|
||||
trap 'rm -f "${TEMP_LIBRARY}" "${TEMP_MANIFEST}"' EXIT
|
||||
|
||||
# Hash contents as well as paths so same-size archives replaced within the same
|
||||
# timestamp tick cannot leave a stale merged library. Preserve the output mtime
|
||||
# on no-op builds to avoid relinking the Xcode app.
|
||||
shasum -a 256 "${SCRIPT_DIR}/build_minirtc_ios.sh" "${MINIRTC_LIBRARY}" \
|
||||
"${DEPENDENCY_ARCHIVES[@]}" "${WIRE_LIBRARY}" > "${TEMP_MANIFEST}"
|
||||
if [[ -f "${OUTPUT_LIBRARY}" ]] && cmp -s "${TEMP_MANIFEST}" "${MERGE_MANIFEST}"; then
|
||||
print "Up to date: ${OUTPUT_LIBRARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
|
||||
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
|
||||
"${WIRE_LIBRARY}"
|
||||
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
|
||||
mv -f "${TEMP_MANIFEST}" "${MERGE_MANIFEST}"
|
||||
|
||||
print "Created ${OUTPUT_LIBRARY}"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user