mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-21 20:18:12 +08:00
Compare commits
25
Commits
| 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 |
@@ -22,6 +22,7 @@
|
||||
#include "config_center.h"
|
||||
#include "daemon.h"
|
||||
#include "path_manager.h"
|
||||
#include "rd_log.h"
|
||||
#include "render.h"
|
||||
|
||||
#ifdef __linux__
|
||||
@@ -192,6 +193,14 @@ int main(int argc, char* argv[]) {
|
||||
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]);
|
||||
}
|
||||
@@ -218,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");
|
||||
|
||||
@@ -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());
|
||||
@@ -472,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);
|
||||
@@ -99,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;
|
||||
@@ -1155,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());
|
||||
@@ -1230,7 +1237,6 @@ void GuiApplication::ResetSettingsUi() {
|
||||
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_);
|
||||
@@ -1585,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;
|
||||
@@ -1645,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();
|
||||
}
|
||||
});
|
||||
@@ -1883,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();
|
||||
@@ -1924,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 ||
|
||||
@@ -1946,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;
|
||||
@@ -1964,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.
|
||||
@@ -1982,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) {
|
||||
@@ -1991,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) {
|
||||
@@ -2130,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()) {
|
||||
@@ -2364,6 +2406,7 @@ void GuiApplication::SetStreamKeyboardFocus(bool focused) {
|
||||
|
||||
focus_on_stream_window_ = focused;
|
||||
if (!focused) {
|
||||
devices_.StopKeyboardCapturer();
|
||||
keyboard_.ForceReleasePressedKeys();
|
||||
}
|
||||
}
|
||||
@@ -2418,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(
|
||||
@@ -2582,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);
|
||||
@@ -2609,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,
|
||||
@@ -2865,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();
|
||||
@@ -2987,7 +3036,6 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
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();
|
||||
@@ -3010,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_);
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,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", \
|
||||
@@ -234,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"Устройство офлайн") \
|
||||
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
@@ -88,6 +136,11 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
|
||||
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
|
||||
@@ -103,7 +156,15 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
const char *display_name,
|
||||
const MiniRtcNativeVideoFrame *native_frame) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -170,9 +231,24 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
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;
|
||||
@@ -195,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()) {
|
||||
@@ -210,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);
|
||||
}
|
||||
@@ -301,6 +389,7 @@ int SessionDeviceManager::StopMouseController() {
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartKeyboardCapturer() {
|
||||
ClearCapturedKeyboardInput();
|
||||
owner_.keyboard_capturer_uses_window_events_ = false;
|
||||
|
||||
#ifdef __APPLE__
|
||||
@@ -336,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");
|
||||
@@ -346,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");
|
||||
@@ -353,8 +446,6 @@ int SessionDeviceManager::StopKeyboardCapturer() {
|
||||
}
|
||||
|
||||
if (keyboard_capturer_) {
|
||||
keyboard_capturer_->Unhook();
|
||||
ClearCapturedKeyboardInput();
|
||||
LOG_INFO("Stop keyboard capturer");
|
||||
}
|
||||
return 0;
|
||||
@@ -424,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 =
|
||||
@@ -474,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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,6 +96,7 @@ 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;
|
||||
|
||||
@@ -234,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_) {
|
||||
@@ -245,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;
|
||||
@@ -290,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 {
|
||||
|
||||
@@ -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_);
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
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;
|
||||
})) {
|
||||
// Request optional privacy without delaying connection or capture.
|
||||
// Only the first controller takes over local input. A later join must
|
||||
// not release an existing controller's keys or re-enable privacy that
|
||||
// the current session explicitly turned off.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,15 +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();
|
||||
auto status = privacy_.Snapshot();
|
||||
const uint64_t now = SDL_GetTicks();
|
||||
if (!peer_ || (last_privacy_status_tick_ != 0 &&
|
||||
status.revision == last_privacy_revision_ &&
|
||||
@@ -53,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,9 @@ 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 {
|
||||
@@ -160,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;
|
||||
|
||||
@@ -195,6 +198,12 @@ 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;
|
||||
@@ -284,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
|
||||
@@ -506,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;
|
||||
@@ -695,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;
|
||||
@@ -732,7 +731,7 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
shortcut-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 41px : 74px; y: 7px;
|
||||
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;
|
||||
@@ -744,51 +743,51 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
mouse-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 73px : 106px; y: 7px;
|
||||
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(); }
|
||||
}
|
||||
audio-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 137px : 170px; y: 7px;
|
||||
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(); }
|
||||
}
|
||||
file-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 169px : 202px; y: 7px;
|
||||
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(); }
|
||||
}
|
||||
stats-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 201px : 234px; y: 7px;
|
||||
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(); }
|
||||
}
|
||||
fullscreen-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 233px : 266px; y: 7px;
|
||||
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(); }
|
||||
}
|
||||
close-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 265px : 298px; y: 7px;
|
||||
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
|
||||
@@ -800,7 +799,7 @@ 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;
|
||||
@@ -812,75 +811,110 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -888,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;
|
||||
}
|
||||
@@ -897,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1020,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;
|
||||
@@ -1052,8 +1115,9 @@ export component StreamWindow inherits Window {
|
||||
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;
|
||||
y: control.y + control.height + self.height + 8px <= video.height
|
||||
? control.y + control.height + 4px : max(4px, control.y - self.height - 4px);
|
||||
// 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;
|
||||
control-tip := Text {
|
||||
x: 8px; y: 6px; width: parent.width - 16px;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -95,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"
|
||||
@@ -129,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_) {
|
||||
@@ -344,6 +347,7 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
// 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_)
|
||||
@@ -352,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;
|
||||
}
|
||||
|
||||
@@ -358,15 +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) {
|
||||
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;
|
||||
void* frame_monitor = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(alias_mutex_);
|
||||
auto it = stream_id_alias_.find(raw_stream_id);
|
||||
@@ -380,6 +428,14 @@ 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));
|
||||
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_ && privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
|
||||
privacy_->Fail("Secure or unavailable desktop; privacy screen will turn off");
|
||||
@@ -394,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 =
|
||||
@@ -460,10 +517,20 @@ 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) {
|
||||
// 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()) {
|
||||
@@ -473,6 +540,14 @@ void ScreenCapturerWin::EmitCapturedFrame(
|
||||
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={})",
|
||||
@@ -522,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);
|
||||
@@ -534,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;
|
||||
@@ -547,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);
|
||||
|
||||
@@ -593,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);
|
||||
@@ -614,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;
|
||||
}
|
||||
@@ -775,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;
|
||||
}
|
||||
@@ -789,6 +873,7 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
|
||||
ret = impl_->Start(show_cursor);
|
||||
}
|
||||
if (ret == 0) {
|
||||
applied_show_cursor_ = show_cursor;
|
||||
RestoreMonitor(current_monitor);
|
||||
return true;
|
||||
}
|
||||
@@ -811,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) {
|
||||
@@ -999,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();
|
||||
@@ -1009,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;
|
||||
}
|
||||
|
||||
@@ -1086,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,
|
||||
@@ -1111,6 +1219,18 @@ 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)) {
|
||||
// Privacy covers belong to the ordinary desktop. Drop privacy on a
|
||||
@@ -1153,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 ||
|
||||
@@ -1210,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;
|
||||
@@ -1257,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());
|
||||
@@ -1276,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());
|
||||
}
|
||||
@@ -1339,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;
|
||||
@@ -48,6 +52,9 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -297,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")
|
||||
@@ -406,8 +399,10 @@ function setup_targets()
|
||||
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",
|
||||
@@ -466,7 +461,8 @@ function setup_targets()
|
||||
"apps/desktop/src/platform/windows/service/service_host.cpp")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_service.rc",
|
||||
{defines = windows_resource_defines})
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service",
|
||||
"apps/desktop/src/platform/windows/screen_capturer")
|
||||
|
||||
target("crossdesk_session_helper")
|
||||
set_kind("binary")
|
||||
@@ -477,8 +473,10 @@ function setup_targets()
|
||||
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")
|
||||
@@ -489,7 +487,8 @@ 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, {defines = windows_resource_defines})
|
||||
|
||||
@@ -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 = "未知连接状态"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 29f5031b42...96b765f818
@@ -1,706 +0,0 @@
|
||||
# CrossDesk 双端 UDP 打洞增强实现方案
|
||||
|
||||
状态:P0 门槛已通过;P1–P4 默认关闭原型、受控运行与跨平台构建已实现;P5 实验工具已交付,真实未知 NAT 对照和最终双端应用切换验收待执行。日期:2026-09-14。第 15 节保留 P0 历史记录,最新进展与边界见第 16 节。
|
||||
|
||||
源码基线:CrossDesk `73e3bf709d7c054df7bd75cfafceda0721c6cda1`;MiniRTC 子模块 `f169a696cb4e6cb59037ec02e85532e30d3c1262`;libnice `0.1.24` 加仓库已有补丁。
|
||||
|
||||
本文规定第一版的范围、接口职责、协议、资源预算、开发顺序和验收条件。第 1–14 节保留设计基线,实际接口及完成情况以第 15–16 节和所链接的执行报告为准。实现和受控测试不代表真实 NAT 成功率已得到验证。
|
||||
|
||||
## 1. 目标与取证依据
|
||||
|
||||
目标是在普通 ICE 检查未建立直连、TURN 已经可以传输的情况下,通过“固定端点一侧扩大目的端口探索+另一侧使用多个本地 UDP 端点”寻找新的可达组合,再接入已有 ICE 检查及中继转直连流程。
|
||||
|
||||
2026-09-13 两次连接提供了以下依据:
|
||||
|
||||
| 项目 | CrossDesk 16:06 会话 | UU 21:30 会话 |
|
||||
| --- | --- | --- |
|
||||
| Mac 的直接探测 | 同一个本地端口,向 11 个公网端口发出 324 个 Binding Request,含重传 | 同一个本地端口,向 231 个离散目的端口发送短探测包 |
|
||||
| Windows 的直接探测 | 主 ICE 端口向 Mac 公网候选持续检查,双方未捕获到直接入站 | 两个常规端点检查失败后,49 个不同本地源端口轮流向 Mac 固定公网端点发短包 |
|
||||
| 成功组合 | 没有观察到 | Windows 第 46 个短包端点与 Mac 建立双向通信,随后 STUN 成功并持续传输 |
|
||||
| 成功端点是否继续传输 | 不适用 | Windows :52257,经 NAT 为 :11968,后续沿用该端点 |
|
||||
|
||||
Mac 先前发往 :11968 的短包没有出现在 Windows 捕获中;成功开始于 Windows 自己发出的短包到达 Mac。无需把两个固定载荷短包解释成请求与回显,才能说明双方端口探索的作用。
|
||||
|
||||
证据边界:
|
||||
|
||||
- 231、49 是成功附近实际执行的数量,不是已知的 UU 配置上限。
|
||||
- 抓包证明多个源端口参与,没有证明所有历史 socket 句柄同时存活,也没有恢复 UU 的端口生成算法。
|
||||
- NAT 内部建表和过滤过程仍是推断;本方案不宣称复刻 UU 私有实现。
|
||||
- 两次应用运行时间、socket 和服务端不同,不能从这两个样本计算成功率提升幅度。
|
||||
- 详细原始取证位于本次任务的本地分析产物;实现与验收必须能够独立于 `/tmp` 文件运行,不把抓包或会话密钥提交到仓库。
|
||||
|
||||
## 2. 第一版范围和关键决策
|
||||
|
||||
### 2.1 纳入范围
|
||||
|
||||
- 原生 MiniRTC ↔ 原生 MiniRTC;首先验证 Windows x64 与 macOS arm64,Linux 用于编译和可控网络测试。
|
||||
- IPv4 UDP,单个 ICE component,选择一个明确的物理接口和原始 host socket。
|
||||
- 非强制中继、非 reliable ICE;已有选中路径包含 relay,且 DTLS 握手和远端指纹验证已经成功。
|
||||
- 本轮一侧具有同一 socket 对多个 STUN 目标的稳定公网映射观测,另一侧具有目的相关映射观测。
|
||||
- 每个 ICE generation 至多执行一轮增强;失败继续使用原连接。
|
||||
|
||||
### 2.2 暂不纳入
|
||||
|
||||
- 浏览器/libdatachannel 对端、iOS 启用、IPv6 探索、两侧都目的相关或两侧信息不足时的通用算法。
|
||||
- TURN 尚未建立时的首次连接加速、`TurnDisabled` 下的增强协商、未启用 SRTP/DTLS 的会话。
|
||||
- UPnP/PCP/NAT-PMP 的新实现、网关配置修改、跨多个接口同时建立端口池。
|
||||
- 根据 UU 单个样本自动学习端口分布,或把端口枚举成全部普通 ICE 候选。
|
||||
- 新增直连失效后无缝回切 TURN 的完整恢复算法。第一版遵循当前恢复行为,验证失败恢复;不得把“仍保留 TURN allocation”描述为已经具备自动回切能力。
|
||||
|
||||
### 2.3 架构决策
|
||||
|
||||
1. **先验证 socket 接入,再实现大范围探测。** 新端点在中继 READY 后必须能参与 ICE 并成为媒体端点,这是 P0 的前置验收项。
|
||||
2. **SDP 只协商能力,运行时协调复用已选中路径。** 第一版通过现有 relay 数据路径传递带认证的内部控制包,不新增 WSS 消息类型,也不要求修改 CrossDesk Server。
|
||||
3. **新 socket 由 libnice 管理。** 不在 GUI、业务数据通道或独立线程中建立脱离 ICE 生命周期的临时 socket 池。
|
||||
4. **探索和 ICE 检查分开调度。** 数百个目的端口属于有限的探索集合;只有少量命中端点进入普通检查表。
|
||||
5. **保留普通 ICE 与已有 30 秒升级窗口。** 增强操作不能重置该窗口、重启 DTLS,或延迟正常的中继可用状态。
|
||||
6. **默认关闭。** 编译扩展存在、运行配置开启、双方声明支持、网络和连接状态满足条件时才启动。
|
||||
|
||||
这会使第一版主要改善“已通过中继连接,但希望随后升为 P2P”的场景。将来需要在没有中继时运行同类算法,应另行设计可验证的信令协调通道。
|
||||
|
||||
## 3. 现有代码与拟增加模块
|
||||
|
||||
| 位置 | 当前职责 / 后续变更 |
|
||||
| --- | --- |
|
||||
| [ice_utils.h](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/ice/ice_utils.h) | 已有精确匹配 SDP 能力行与 ufrag 解析;新增独立 v1 能力,不改变旧能力含义 |
|
||||
| [nat_traversal.h](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/ice/nat_traversal.h) | 已有映射观测和附近端口预测;提取带 socket/generation 的快照,新增角色选择和有界采样逻辑 |
|
||||
| [ice_agent.h/.cpp](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/ice/ice_agent.cpp) | 持有 NiceAgent、GMainContext 和 DTLS;接入协调器、验证后的 exporter、内部控制包分流和停止流程 |
|
||||
| [ice_transport.cpp](/Users/dijunkun/crossdesk/deps/submodules/minirtc/src/transport/ice_transport.cpp) | 已有选路日志、trickle 和媒体入口;防止内部端点误发普通候选,新增结果统计,保持媒体状态连续 |
|
||||
| [MiniRTC xmake.lua](/Users/dijunkun/crossdesk/deps/submodules/minirtc/xmake.lua) | 注册可选的纯逻辑测试和 ICE 集成测试目标,接入编译开关 |
|
||||
| [libnice 配方](/Users/dijunkun/crossdesk/deps/submodules/minirtc/thirdparty/libnice/xmake.lua) | 注册新补丁、哈希及包身份版本,增加扩展符号检查,确保双端重新构建依赖 |
|
||||
| [relay upgrade 补丁](/Users/dijunkun/crossdesk/deps/submodules/minirtc/thirdparty/libnice/patches/relay_upgrade_0.1.24.patch) | 已有保留直连检查、定期重试及再次提名;扩展接入点须遵守已有截止时间和优先级条件 |
|
||||
|
||||
拟新增到 MiniRTC 的模块,路径以子模块根目录为基准:
|
||||
|
||||
```text
|
||||
src/ice/punch_config.h 配置、硬上限、实验模式
|
||||
src/ice/punch_protocol.h/.cpp 编解码、认证、去重、generation 校验
|
||||
src/ice/punch_scheduler.h/.cpp 端口采样、预算、时序;可注入时钟和随机源
|
||||
src/ice/punch_coordinator.h/.cpp 每个 IceAgent 的增强状态机
|
||||
src/ice/punch_budget.h/.cpp 进程级 socket/发包配额
|
||||
thirdparty/libnice/patches/punch_pool_0.1.24.patch
|
||||
tests/ice/punch_* 纯逻辑和集成测试
|
||||
tests/ice/lab/ 可控 NAT 场景与抓包核验脚本
|
||||
```
|
||||
|
||||
MiniRTC 和 CrossDesk 为不同 Git 仓库。后续先在子模块形成可验证的提交,再更新主仓库的子模块指针;不得只修改本机 Xmake 下载缓存中的 libnice 源码。
|
||||
|
||||
## 4. 能力、身份和角色协商
|
||||
|
||||
### 4.1 能力开关
|
||||
|
||||
拟新增精确 SDP 行:
|
||||
|
||||
```text
|
||||
a=x-minirtc-udp-punch:1
|
||||
```
|
||||
|
||||
只有本地构建包含对应 libnice 扩展、运行配置开启、原生 UDP 路径允许时才通告。双方同时支持该能力和现有 relay-upgrade 能力才启用。旧的 `a=x-minirtc-p2p-enhancement:1` 继续表示原有增强能力,不隐式包含端口池协议。
|
||||
|
||||
SDP 的能力行不是命令;资源数量由本地配置和认证后的协商共同限制。未知版本或异常重复能力声明只停用此增强,不使普通 ICE 失败。
|
||||
|
||||
现有 `IceTransport::SetRemoteSdp` 会提取媒体段,并显式补回旧增强能力行;新增能力也必须经过该路径保留。需要测试能力位于 session、视频或其他媒体段,以及 fingerprint 提取前后的情况,避免完整 SDP 中支持、传入 IceAgent 后却丢失。
|
||||
|
||||
### 4.2 连接和轮次身份
|
||||
|
||||
- 使用有序的 offer/answer ufrag 对标识 ICE generation;记录本地单调递增 generation 序号,防止异步旧回调误用新连接。
|
||||
- 线上 `generation_id` 为长度前缀编码的 offer/answer ufrag 对的 SHA-256 前 16 字节;实际认证密钥另由本次已验证的 DTLS 会话导出。
|
||||
- 原始 offer 发送方担任协调方,分配随机 128-bit `round_id`。协调角色不等于动态 ICE controlling 角色。
|
||||
- 所有控制包、探测包、候选提升请求和回调必须携带 generation/round 范围。重连、ICE restart、接口改变时旧范围作废。
|
||||
|
||||
### 4.3 网络角色
|
||||
|
||||
命名:`anchor` 表示复用固定 socket 的一侧;`pool` 表示使用额外本地端点的一侧。角色根据观测选择,不与 Windows/macOS、主控/被控绑定。
|
||||
|
||||
有效映射快照至少包含:同一原始 host socket 的不透明 ID、本地地址、候选 foundation、component、generation、目标 STUN 地址、公网结果、接收的单调时间、观测序号。
|
||||
|
||||
- `stable-observed`:当前 generation 的同一 socket 至少对两个不同公网服务器 IP 得到同一公网 IP:port,观测仍在有效期内。
|
||||
- `dependent-observed`:同一 socket 对不同目标出现不同映射;不能把多个本地源端口的结果混在一起分类。
|
||||
- `unknown`:不足、过期、来源不一致,或公网 IP 本身变化。
|
||||
|
||||
第一版只在恰好一侧 `stable-observed`、另一侧 `dependent-observed` 时开始。前者为 anchor。其余组合记录跳过原因,保留常规 ICE;实验工具可显式固定角色验证接入,但不得在生产中把固定角色伪装成自动分类。
|
||||
|
||||
快照默认有效期 15 秒。角色选择只表示实验条件,不把有限的 STUN 观测升级为对 NAT 过滤类型或未来分配行为的保证。anchor 的公网端点必须与被保留的那个真实 socket 对应;不能使用无关联的临时 STUN socket 的结果。
|
||||
|
||||
## 5. 控制通道与报文契约
|
||||
|
||||
### 5.1 通道和密钥
|
||||
|
||||
控制包通过 NiceAgent 当前选中的 relay 路径发送,在 `IceAgent::OnNiceRecv` 中、DTLS/RTP/SRTP 判断之前识别和消费;不送入桌面业务协议,也不占用媒体 SSRC。
|
||||
|
||||
直接探测包在被借用的 anchor socket 或 libnice 管理的 pool socket 上发送,使用相同的会话范围,但使用独立的密钥域。原始 socket 收包钩子只接收直接路径的探测种类;TURN 解封装后的逻辑来源不能产生“直连命中”事件。
|
||||
|
||||
密钥方案:
|
||||
|
||||
1. 新增 `dtls_peer_verified` 状态,仅在握手完成且远端 fingerprint 比较成功后设置。当前 `dtls_handshake_done_` 在 fingerprint 判断之前赋值,不能单独作为导出增强密钥的条件。
|
||||
2. 在 DTLS 所属上下文内调用 `SSL_export_keying_material`,使用独立标签 `EXPORTER-MiniRTC-UDP-Punch-v1`,context 使用上节有序 ufrag 编码,`use_context=1`。
|
||||
3. 导出 128 字节,依次拆为四个 32-byte 密钥:offer→answer 控制、answer→offer 控制、offer→answer 探测、answer→offer 探测。不得复用 SRTP 密钥或把密钥写入 SDP/日志。
|
||||
4. 报文采用 HMAC-SHA-256 前 16 字节认证完整头和载荷,常量时间比较。控制内容只含连接元数据,不承载媒体或密钥;它是带认证的内部数据报,不宣称因走 relay 就自动被 DTLS 加密。
|
||||
|
||||
### 5.2 固定头
|
||||
|
||||
采用显式序列化,整数为网络字节序,不直接发送 C/C++ struct。
|
||||
|
||||
| 字段 | 长度 | 约束 |
|
||||
| --- | --- | --- |
|
||||
| magic | 4 | `f3 4d 50 31`,避免与常见 STUN、DTLS、RTP 首字节范围混淆 |
|
||||
| version | 1 | 1 |
|
||||
| kind | 1 | 见下表,控制与探测种类严格分离 |
|
||||
| payload_length | 2 | 总数据报必须恰好为 `44 + payload_length + 16` 字节 |
|
||||
| generation_id | 16 | 与当前连接一致 |
|
||||
| round_id | 16 | HELLO 阶段为全零;其他消息为已接受轮次 |
|
||||
| sequence | 4 | 按方向和密钥域递增,重传保持原序号与内容 |
|
||||
| payload | 可变 | v1 控制采用 UTF-8 JSON;直接探测采用固定二进制载荷 |
|
||||
| tag | 16 | HMAC 截断值 |
|
||||
|
||||
控制包总长上限 1,200 字节,直接 PROBE/ACK 总长不超过 128 字节。先校验长度、版本、范围、认证,再解析业务字段;不支持分片重组。未知类型、越界数字、重复 JSON 字段、尾随内容均拒绝。
|
||||
|
||||
控制 JSON 使用受限、版本化的字段集;HMAC 对线上原始字节计算,接收端不重新序列化后验签。v1 禁止未知字段,后续增加字段需升级协议契约或显式定义可选字段。
|
||||
|
||||
### 5.3 消息和顺序
|
||||
|
||||
| kind | 作用与必须字段 |
|
||||
| --- | --- |
|
||||
| `HELLO (0x01)` | 本地观测快照、候选锚点、运行预算、剩余升级时间;快照只发送分类所需的少量样本 |
|
||||
| `PREPARE (0x02)` | 协调方发送 round、角色、双方端点、配置摘要和本轮时长;不能授权任意第三方 IP |
|
||||
| `READY (0x03)` | 接收方已保留原始 socket 或分配所需 pool socket,返回实际预算与 PREPARE 摘要 |
|
||||
| `START (0x04)` | 协调方确认开始;双方按本地 monotonic clock 运行,不传递绝对系统时钟作为启动依据 |
|
||||
| `STARTED (0x05)` | 确认 START 已接受;重复 START 不重置计时器 |
|
||||
| `RESULT (0x06)` | 本端已选中直连,包含结果和地址摘要;该消息本身不能改变对端 selected pair |
|
||||
| `CANCEL (0x07)` | 超时、状态变化、资源不足或主动停止;对端同样有本地截止时间 |
|
||||
| `PROBE (0x20)` | 直接包;载荷为网络字节序 uint64 socket_id,随后为 12-byte probe_nonce,共 20 字节 |
|
||||
| `PROBE_ACK (0x21)` | 直接包;载荷原样返回 PROBE 的 socket_id 和 nonce,由接收它的同一个 socket 发回 |
|
||||
|
||||
协调方通过已有能力与 HELLO 判定条件后发 PREPARE;双方准备完成后执行 START/STARTED。若协商出的实际预算改变,更新 PREPARE 并重新 READY,不在一端静默扩大或缩减配置。
|
||||
|
||||
控制状态消息按 200/400/800 毫秒退避,最多重发 3 次;使用本轮总截止时间约束。每种消息响应幂等,重复 READY/START 不重复分配 socket,也不重启轮次。乱序消息只能在对应状态允许的范围内缓存或拒绝,不能越级启动。
|
||||
|
||||
每方向、每密钥域保留 256 项序号重放窗口,允许正常乱序;已处理的控制请求可重新发送缓存响应,不能重新执行副作用。探测 ACK 必须匹配本轮尚有效的 nonce 和本地 socket_id。未认证包不回复。
|
||||
|
||||
上述直接 socket_id 是本地注册表的不透明标识,不是 OS 文件描述符;不得由远端指定本地 FD。
|
||||
|
||||
## 6. 探测算法与预算
|
||||
|
||||
### 6.1 anchor 的行为
|
||||
|
||||
1. 保留与稳定公网观测对应的实际 host socket,沿用当前物理接口绑定。
|
||||
2. 在已确认的对端公网 IP 上,从 1024–65535 无放回抽取目的端口,默认 512 个。先生成完整集合再按随机顺序调度,使用可注入的 CSPRNG;测试使用固定种子。
|
||||
3. 现有普通候选和邻近预测继续由原 ICE 调度;新探索集合避免重复消耗这些目标的探测预算。低于 1024 的探测以及非均匀采样留待后续实验,不声称第一版覆盖 UU 的全部端口范围。
|
||||
4. 每个新目标默认发一次 PROBE。收到 pool 的认证 PROBE 时,从同一 socket 向该包实际来源端点回 PROBE_ACK;该源 IP 必须仍属于本轮批准的对端公网 IP。
|
||||
5. 原始来包地址用于路径发现;不能用包内宣称的地址替代源地址,也不能把 TURN 逻辑地址当成直接源地址。
|
||||
|
||||
### 6.2 pool 的行为
|
||||
|
||||
1. 在同一选定物理接口上分配默认 64 个不同 UDP 本地端口;绑定地址和接口遵循原 ICE socket 的平台规则。
|
||||
2. 新 socket 不为每个端点申请 TURN allocation、启动独立 DTLS 或开展完整多 STUN 采集,只向 anchor 已协商的公网端点探索。
|
||||
3. 以 round-robin 调度 PROBE,最多 3 轮;相邻轮至少间隔 1 秒。全部端点保持可接收至本轮停止或被接入 ICE,不能发完就关闭。
|
||||
4. 收到 anchor 的有效 PROBE 或匹配 ACK 后,对该具体 socket 请求提升并启动普通 ICE 检查;收到 PROBE 时先从同一 socket 回复 ACK。
|
||||
5. 不根据本地源端口序列推算公网端口。探测过程中没有第三方 STUN 返回也不妨碍命中后的标准 ICE 验证。
|
||||
|
||||
两侧时间窗需要重叠,但不要求包在同一毫秒发出。允许丢失第一包、控制消息重传和两端系统时钟有偏差。
|
||||
|
||||
### 6.3 初始配置
|
||||
|
||||
以下是第一轮实验参数,不是从 UU 反推的最佳值。所有值通过经过校验的不可变 `PunchConfig` 进入每个 IceAgent。
|
||||
|
||||
| 参数 | 默认实验值 | v1 硬上限 / 规则 |
|
||||
| --- | --- | --- |
|
||||
| enabled | false | 必须双方开启 |
|
||||
| trigger_delay_ms | 1500 | 从首次选中 relay 计时,并等待 DTLS 指纹已验证 |
|
||||
| mapping_sample_max_age_ms | 15000 | 不跨 generation 或接口变化复用 |
|
||||
| pool_size | 64 | 每会话最多 128;禁止远端扩容越过本地上限 |
|
||||
| target_port_count | 512 | 最多 1024,唯一端口数 |
|
||||
| probe_pps | 100 | 本端全部新增 PROBE/ACK 合计最多 200 包/秒 |
|
||||
| burst_packets | 8 | token bucket;事件循环滞后时不能瞬间补发整个积压队列 |
|
||||
| pool_probe_rounds | 3 | v1 最多 3;重发也计入总预算 |
|
||||
| round_duration_ms | 10000 | 探索阶段时长,最多 15000,并受原 relay upgrade 截止时间限制 |
|
||||
| ice_finish_reserve_ms | 3000 | 到原升级截止前停止新探索,留给已命中 ICE 检查 |
|
||||
| probe_packet_budget | 1024 | 本端 PROBE/ACK 累计上限;发送失败也记录尝试计数 |
|
||||
| control_packet_budget | 48 | 本端全部控制消息与重传总量 |
|
||||
| promoted_endpoint_limit | 4 | 最多 8;只有命中端点进入普通 ICE 检查 |
|
||||
| rounds_per_generation | 1 | 重复能力/控制消息不创建新轮次 |
|
||||
| process_active_round_limit | 2 | 超额会话跳过增强,正常媒体不排队 |
|
||||
| process_extra_socket_limit | 256 | 进程内所有 IceAgent 共享配额 |
|
||||
| process_probe_pps | 200 | 进程级公平调度,优先及时 ACK,仍受总预算约束 |
|
||||
|
||||
定义两个截止时间:`T_probe = min(本轮开始 + 探索时长, 原升级截止 - ICE 保留时间)`;`T_finish = min(T_probe + ICE 保留时间, 原升级截止)`。到 T_probe 停止新探索、释放未命中端点,已提升且正在检查的端点进入 DRAINING,继续标准 ICE 至 T_finish。到 T_finish 才取消仍未完成的增强检查;已选中的端点继续归正常 ICE 管理。协商、分配、重传均不延长原有 30 秒窗口。双方各自检查本地剩余时间;不足时拒绝开始。
|
||||
|
||||
控制包与新增 PROBE/ACK 单独计数;提升后的普通 STUN 检查遵循 libnice 原节拍,但需要记录它们带来的额外事务和重传。媒体包不计入探测预算。
|
||||
|
||||
首次实现用内部配置和明确命名的实验环境变量加载配置,仅读取一次,不增加公开 `Params` 布局或 GUI 开关;例如 `MINIRTC_UDP_PUNCH=off|wide-only|pool-wide`。无效值禁用增强并打印原因。端口池大小等调参入口统一由同一校验函数处理。
|
||||
|
||||
`wide-only` 保留同样的协议、角色和锚点选择,但 pool 侧只用一个端点;`pool-wide` 使用配置的端点池。基线 `off` 仍保留已有附近端口预测。不能让实验分组顺带改变其他 ICE、TURN 或重试配置。
|
||||
|
||||
单端点模式也经过新的协调和端点接入流程,因此 A/B 差异不能全部归因于采样范围;B/C 对比用于隔离多个本地端点带来的增量作用。
|
||||
|
||||
## 7. libnice 接入与命中后的完整闭环
|
||||
|
||||
### 7.1 后端职责
|
||||
|
||||
新增能力全部在 NiceAgent 的 GMainContext 中执行。C++ 只持有带 generation 的不透明句柄,不直接持有可悬空的 `NiceSocket*`。
|
||||
|
||||
拟提供的内部接口契约,确切 C 签名在 P0 验证后冻结:
|
||||
|
||||
| 拟新增接口 | 语义 |
|
||||
| --- | --- |
|
||||
| `punch_get_capabilities()` | 扩展版本及可用能力;不能仅因头文件存在就通告支持 |
|
||||
| `punch_acquire_anchor(base_candidate)` | 借用并保留真实 anchor socket,返回句柄与实际本地地址 |
|
||||
| `punch_create_pool(base_candidate, count)` | component 拥有新 socket,逐项报告分配成功/失败;只做直接探索 |
|
||||
| `punch_send(handle, remote, bytes)` | 从指定 socket 发出原始直接 UDP,不走当前 selected relay |
|
||||
| `punch_packet` 事件 | 句柄、原始外层 src/dst、字节、generation;只在直接 socket 接收路径产生 |
|
||||
| `punch_promote_and_check(handle, anchor_candidate)` | 原子地把该 pool socket 注册为可检查的本地 host 候选,向已有 anchor 远端候选触发普通 STUN |
|
||||
| `punch_stop_discovery(round)` | 到探索截止时停止自定义探测,释放未命中端点;保留已提升并在验证的端点 |
|
||||
| `punch_cancel(round)` | 撤销探索 source、借用引用和未被 ICE 接管的 socket;幂等 |
|
||||
| `punch_get_upgrade_deadline()` | 返回已有升级截止的 monotonic 时间,避免 C++ 层另起不一致的 30 秒计时 |
|
||||
|
||||
不要用 `nice_agent_set_port_range()` 代替端口池;该接口只约束 host 候选的端口分配范围。不要调用强制 selected-candidate API 绕过 ICE 验证。
|
||||
|
||||
接入点要覆盖:component 的 socket source 管理、UDP 接收分流、候选与 socket 的关联、候选对入队、READY 后重新唤起检查计时器、候选裁剪和销毁。新增 socket 使用现有 UDP 后端的创建过程,保持 Windows/Apple 物理接口绑定行为。
|
||||
|
||||
### 7.2 直接接收分流
|
||||
|
||||
- anchor 原始 socket:在 libnice 将未知源的非 STUN 数据丢弃之前,识别仅在已协商轮次生效的 PROBE/ACK,传给协调器验证。
|
||||
- pool 探索 socket:只处理本轮的直接 PROBE/ACK;普通业务数据不向上交付。提升后允许该 socket 进入正常 libnice STUN/媒体处理。
|
||||
- 接收钩子必须区分原始 UDP、TURN 服务器来源及 TURN 解封装后来源。嵌套的同 magic 载荷不得走直接命中分支。
|
||||
- 在原来的 NiceSocket 事件循环中读取,禁止另一个 recvfrom 线程竞争同一 socket 的 STUN 或媒体包。
|
||||
- 回调不能在 libnice 内部持有不可重入锁时同步反向调用后端;必要时保留有界字节副本和句柄,投递到同一 context 的后续任务。
|
||||
|
||||
### 7.3 标准 ICE 验证路径
|
||||
|
||||
第一版固定由 pool 端首先对新 socket 启动普通检查,避免将每个猜测端口伪装成已发现的远端候选:
|
||||
|
||||
1. pool 从某个 socket 收到有效 PROBE 或匹配 ACK,确认该端点有直接入站证据。
|
||||
2. 后端保持该实际 socket 不关闭,将其提升为正常本地 host 候选,并与已有 anchor 公网候选建立检查对。
|
||||
3. pool 从该 socket 发出正常 Binding Request,使用当前 generation 的 ICE 凭据、真实 controlling/controlled 角色和既有 priority 规则。
|
||||
4. anchor 在原始 socket 上收到请求,经 libnice 认证后按标准逻辑学习新的 peer-reflexive 远端地址、响应并触发反向检查。
|
||||
5. 双向检查形成 valid pair,controlling 一侧通过现有 relay-upgrade 路径执行 nomination/renomination。
|
||||
6. 收到真实 selected-pair 变化,双方才各自将状态记为 `DIRECT_SELECTED`。RESULT、短包 ACK 或发现 prflx 均不能代替这个条件。
|
||||
|
||||
直接 probe 认证和 ICE 认证是两个不同步骤。成功 probe 不标记候选 valid,不伪造 STUN 成功事务,也不提前对外报告“学到 prflx”。
|
||||
|
||||
必须覆盖 pool 为 controlling 和 pool 为 controlled 两种情况。新增 pair 的优先级由现有候选计算规则产生,并验证满足已有“优先级高于所选 relay 才升级”的条件。
|
||||
|
||||
### 7.4 候选数量与 end-of-candidates
|
||||
|
||||
探索 socket 在命中之前不参加全量候选配对,不为 64×512 个组合建立检查对象;控制提升数量,逐个接入正常检查。
|
||||
|
||||
内部增强端点不通过普通 `new_candidate` 信令发送。为这些端点明确添加内部来源标记,并在现有 `new-candidate-full` 回调处处理,防止在已发送 end-of-candidates 后又误发一批普通候选。既有 host/srflx/relay 的正常收集和结束标记不受影响。
|
||||
|
||||
这属于双方协商的 MiniRTC 扩展;不能假定标准浏览器或未打补丁的 libnice 在 READY 后接受相同的动态行为。
|
||||
|
||||
### 7.5 媒体连续性和所有权
|
||||
|
||||
- selected relay 在新的 pair 真正选中前持续传输;不调用 ICE restart,不创建第二套 DTLS/SRTP 会话,不重置 SSRC、编码器和传输控制器。
|
||||
- 探索中:component 持有 pool socket;协调器持有不透明句柄。
|
||||
- 提升后:所有权转入正常候选/检查生命周期,协调器只保留观察标记。
|
||||
- 选中后:候选及 component 保持 socket 存活直到连接关闭;取消探测不能关闭它。
|
||||
- 未选中的已提升 socket 若仍被进行中的 STUN/checklist 引用,先在 context 中安全移除相应工作,再释放。不能按一个“关闭整个池”的循环直接 close。
|
||||
- 借用的 anchor socket 和原 relay socket 始终由原 ICE 生命周期管理,探测清理只释放额外引用。
|
||||
|
||||
进程级额外 socket 配额随实际 socket 生命周期持有。提升或选中只转移配额所有者,不能提前返还配额后让进程实际句柄数越过上限。
|
||||
|
||||
## 8. 状态机、取消和故障行为
|
||||
|
||||
```text
|
||||
DISABLED / INELIGIBLE
|
||||
|
|
||||
v
|
||||
WAIT_RELAY_AND_VERIFIED_DTLS
|
||||
|
|
||||
v
|
||||
EXCHANGE_HELLO -> PREPARING -> READY -> PROBING
|
||||
| |
|
||||
| v
|
||||
| ICE_VALIDATING -> DIRECT_SELECTED
|
||||
| |
|
||||
v v
|
||||
DRAINING -> DIRECT_SELECTED
|
||||
|
|
||||
v
|
||||
STOPPED
|
||||
```
|
||||
|
||||
`ICE_VALIDATING` 表示至少一个端点已提升;在数量与时间预算内可继续其余探索。单个命中检查失败不能立刻报告整个实验成功或关闭仍在工作的 relay。
|
||||
|
||||
到 T_probe 或探索预算耗尽时停止新探索;存在待验证端点则进入 DRAINING,否则结束。DRAINING 只继续已经登记的 ICE 工作,不再创建新端点或扩大端口集合;最多持续到 T_finish。
|
||||
|
||||
任何活动状态均可因下列事件进入 STOPPED:
|
||||
|
||||
- 对端不支持、模式不符、观测不足、预算不足、控制协商超时。
|
||||
- 普通 ICE 提前选中了直连;立即停止本增强,区分成功来源为 `ordinary-ice`。
|
||||
- 原升级窗口到期、会话断开、ICE generation 改变、被借用 socket/接口失效。
|
||||
- 本轮 socket 分配不足、队列过载、认证上下文消失、协议冲突。
|
||||
|
||||
无效外部包只丢弃和限频计数,不能让任意错误报文取消有效会话。已认证对端主动 CANCEL 可以停止本轮,但不关闭媒体连接。
|
||||
|
||||
关闭顺序必须是:禁止新调度并标记取消 → 作废 generation/round 任务 → 移除 GSource/回调 → 释放探索资源 → 安全处理未选中提升端点 → 执行现有 NiceAgent 异步关闭 → 清理 exporter 密钥和 DTLS。工作不能阻塞或反向等待自己的 nice_thread。
|
||||
|
||||
构建默认开关关闭时应没有额外 socket、计时器、控制包或能力通告。运行关闭后不能靠收到对端消息隐式开启。
|
||||
|
||||
## 9. 日志与测量口径
|
||||
|
||||
INFO 记录阶段和结论;逐包记录放入可选调试日志。所有记录包含 connection/generation/round 标识,不打印 ICE 密码、exporter 或完整认证 tag。
|
||||
|
||||
| 事件 | 必须字段 |
|
||||
| --- | --- |
|
||||
| eligibility | 模式、双方能力、角色依据、样本 socket/年龄、跳过原因 |
|
||||
| prepare/start | 协商实际预算、本地截止时间、anchor 本地/公网端点、批准的对端 IP |
|
||||
| probe-tx/rx | 本地 socket_id、实际地址、原始外层源/目的、nonce、direct/control/relay 标记 |
|
||||
| promote | 命中的具体 socket、触发原因、候选 foundation、候选对标识 |
|
||||
| ICE check | TID、pair、角色、状态、外层地址、TURN peer(若有) |
|
||||
| selected | 完整本地/远端候选类型和地址、成功来源、是否沿用命中 socket |
|
||||
| stop | 原因、持续时间、唯一目标数、socket 峰值、发送尝试/成功/失败/重传、清理结果 |
|
||||
|
||||
成功率至少分别报告:
|
||||
|
||||
- 全部连接尝试中的直连建立比例。
|
||||
- 满足增强条件并实际启动的连接中的增强成功比例。
|
||||
- 普通 ICE 自行成功、增强成功、继续 relay、连接失败的数量。
|
||||
|
||||
分母包含失败/超时,不把“跳过增强”混入增强失败,也不能只汇总成功样本。记录 P50/P95 建立耗时、额外包数、峰值句柄、CPU 和切换期间的媒体间隙。每条结论都能追到 session ID、二进制版本和抓包。
|
||||
|
||||
## 10. 分阶段实施与验收
|
||||
|
||||
以下顺序是依赖关系。P0 未通过时先解决后端接入问题,不能通过增加扫描范围掩盖“已命中却无法接入 ICE”。
|
||||
|
||||
### P0:验证 READY 后新增 socket 与媒体切换
|
||||
|
||||
交付:最小 libnice 扩展及双端集成测试,在受控网络中创建一个额外 pool socket,获得直接入站后提升、普通 ICE 验证、选中并传输。
|
||||
|
||||
验收:
|
||||
|
||||
- 从真实 selected relay、READY 和已验证 DTLS 状态开始,端点在此后创建。
|
||||
- Windows/macOS 均验证创建、物理接口绑定、接收、提升及结束清理;两个 ICE 角色都覆盖。
|
||||
- 捕获证明命中的本地端口和后续 STUN/媒体端口一致。
|
||||
- 验证候选不会因 READY、检查上限、同 foundation/base 或 end-of-candidates 流程被错误抑制。
|
||||
- 选路变化不重启 DTLS;未命中和主动取消保留原 relay。
|
||||
- 冻结后端接口、所有权表和必要补丁范围,记录实际代码接入点。
|
||||
|
||||
P0 工具只能使用实验提供的明确对端地址,不含广范围探测。
|
||||
|
||||
### P1:协议、密钥与配置
|
||||
|
||||
交付:能力行、验证后 exporter、报文编解码、重放窗口、HELLO/PREPARE/READY/START 协商和默认关闭配置。
|
||||
|
||||
验收:新旧版本互连正常;关闭时无新增行为;无效 tag、旧 generation、重复 START、丢失 READY、时钟偏差不导致越级启动或重复分配。控制包不会进入 DTLS/SRTP/业务包解析。
|
||||
|
||||
### P2:角色、端口采样和预算
|
||||
|
||||
交付:映射快照、角色判断、无放回端口采样、token bucket、pool 轮发策略、进程级配额。
|
||||
|
||||
验收:假时钟可以复现相同发送计划;延迟唤醒不超突发上限;观测过期/多 socket 混淆被拒绝;同进程多连接不突破共享限制;短 ACK 不绕过总预算。
|
||||
|
||||
### P3:组合探测与提升
|
||||
|
||||
交付:anchor、pool、直接报文接收钩子、命中后普通 ICE 检查、selected-pair 归因。
|
||||
|
||||
验收:完整实现第 7 节闭环;只提升命中端点;来自 TURN 内层的相同短包不产生直接命中;成功、超时和普通 ICE 抢先成功均正确清理。
|
||||
|
||||
### P4:生命周期、兼容与构建
|
||||
|
||||
交付:断开/重连/接口变化处理,libnice 配方和包身份更新,新符号检测,Windows/macOS/Linux 编译验证。
|
||||
|
||||
验收:连续创建/停止至少 100 轮无残留端口池或 source,关闭竞态检查通过;旧依赖缓存不能错误通告新能力;iOS/WebRTC 路径未启用此模式且正常构建。
|
||||
|
||||
### P5:真实网络对照与启用决定
|
||||
|
||||
交付:A/B/C 配置、自动汇总脚本、双端抓包复核和实验结论。
|
||||
|
||||
- A:现有策略,增强关闭。
|
||||
- B:相同新协议与相同的自动角色判定规则,一侧单端点,另一侧扩大目的端口;角色必须由有效观测支持。
|
||||
- C:与 B 相同,但使用端口池。
|
||||
|
||||
按交错顺序重复 A/B/C,第一轮每组至少 30 次;保持网络、版本、STUN/TURN 和其余配置相同,记录每次启动间隔和真实端点。观察到一次成功不等于方案验收完成。
|
||||
|
||||
先验收机制和无回归,再评价收益:每个增强成功样本必须能证明直接命中 → 标准 STUN → nomination → 直接媒体;默认关闭路径无行为变化;资源全部在上限内。报告成功比例的不确定性,不从小样本承诺固定提升幅度。
|
||||
|
||||
在真实网络证明净收益且兼容/退出检查通过之前,保持默认关闭;通过后再单独形成默认启用及参数调整变更。
|
||||
|
||||
## 11. 测试设计
|
||||
|
||||
### 11.1 纯逻辑与协议测试
|
||||
|
||||
新增拟定目标 `minirtc_punch_unit_test`,覆盖:
|
||||
|
||||
- 跨 generation、跨 socket 的样本隔离和角色对称性。
|
||||
- 端口采样范围、唯一性、可复现性、耗尽与数量边界。
|
||||
- 速率/包数/时长/进程配额,事件循环暂停后的行为。
|
||||
- 报文截断、长度溢出、错误域密钥、错 tag、旧轮次、nonce/socket 不匹配、乱序与重放。
|
||||
- READY/START/CANCEL 重复、断开在任意状态发生、部分分配失败。
|
||||
- 默认关闭、新旧能力组合、强制 relay、reliable、无 DTLS 验证的跳过条件。
|
||||
|
||||
### 11.2 libnice 集成测试
|
||||
|
||||
新增拟定目标 `minirtc_punch_integration_test`。当前 MiniRTC 没有已注册的可直接运行的 ICE 测试目标,旧 `tests/peerconnection` 示例不是可用基线。实现时必须注册目标并写出准确的构建/执行命令。
|
||||
|
||||
集成测试验证真实 NiceAgent、真实 socket、STUN 认证、triggered check、选路、媒体收发及销毁。模拟器内的单元测试不能替代 P0。
|
||||
|
||||
### 11.3 可控网络场景
|
||||
|
||||
搭建受控 UDP/NAT 测试拓扑,或具有真实 socket 和明确翻译规则的测试代理。不要默认 Linux 普通 MASQUERADE 就等于本次目的相关映射;必须先通过多目标 STUN 验证场景行为。
|
||||
|
||||
必测:
|
||||
|
||||
1. LAN/普通可直连网络:普通 ICE 成功,不启动或及时停止增强。
|
||||
2. 一侧稳定映射且有端口相关过滤,另一侧目的相关映射:未知新映射参与匹配。
|
||||
3. 丢弃 anchor 最早的探测,让 pool 后来的包首先到达:复现 UU 的关键可达顺序。
|
||||
4. 没有目标端口交集:到期后保持 relay,释放探索资源。
|
||||
5. 模拟 UDP 丢失、重复、乱序,控制包延迟,以及两端墙上时钟相差至少 500 ms。
|
||||
6. 只有 TURN 内层收到同内容数据:不得记录为直接命中。
|
||||
7. 两侧都目的相关、观测不足、IPv6-only:v1 跳过,原功能继续。
|
||||
8. pool 任一端点命中后断开、提名中取消、接口变化、旧任务到达新 generation。
|
||||
9. 进程中超过两个连接请求增强:资源限制与普通媒体均正常。
|
||||
|
||||
### 11.4 真实会话验收
|
||||
|
||||
双方记录二进制/提交版本、实际配置、网卡、抓包起止与内核丢包计数;按 nonce、STUN TID、外层地址和成功后的 socket 对齐。NAT 可能重写端口,不能仅按发端源端口搜索对端抓包。
|
||||
|
||||
“直连成功”的严格条件:候选对两端均不含 relay,标准检查和提名完成,双端网卡观察到相匹配的外层 UDP,并持续承载应用数据。日志中的 prflx 或一个短包 ACK 不算成功。
|
||||
|
||||
## 12. 已识别的实现风险与解决位置
|
||||
|
||||
| 风险 | 解决阶段 |
|
||||
| --- | --- |
|
||||
| READY 后新增 socket 无法进入检查或被裁剪 | P0 验证并冻结必要 libnice 改动 |
|
||||
| 为全部端口组合创建 ICE 对象导致检查表膨胀 | P2/P3 将探索与少量提升分离 |
|
||||
| 临时 socket 成功后被清理,媒体仍发旧端口 | P0/P3 明确所有权转移并核对原始端口 |
|
||||
| 把 TURN 解封装逻辑来源当成直接来包 | P3 在原始 socket 层分类,P5 双端抓包验收 |
|
||||
| 现有 DTLS done 标志先于指纹验证 | P1 增加明确的 peer-verified 状态与导出门槛 |
|
||||
| 自定义控制包落入 SRTP/业务解析 | P1/P3 按版本和种类分流,协议测试与媒体回归 |
|
||||
| libnice/GLib 回调锁内重入、退出时悬空引用 | P0/P4 同 context 调度、句柄注册表与幂等停止 |
|
||||
| 两端升级窗口不同或旧消息延长新轮次 | P1/P2 本地 monotonic 截止、generation/round 隔离 |
|
||||
| 头文件与旧 libnice 静态库不匹配 | P4 包身份、补丁哈希、编译/链接及运行能力检测 |
|
||||
| 把一次成功当作普遍成功率提升 | P5 分组重复实验,保留全部失败和跳过样本 |
|
||||
|
||||
## 13. 后续执行入口
|
||||
|
||||
后续实现从 **P0** 开始,先读取本方案和当时仓库状态,确认源码基线是否变化。每完成一个阶段,在对应实现提交说明中记录:改动范围、验收证据、未解决问题和本方案是否需要修订。
|
||||
|
||||
如果 P0 证明某个后端接口或 selected-pair 路径不可按本文实现,应先根据实际 libnice 代码修订这一处设计并给出替代路径,再继续后续阶段;不要把未完成的直接验证改成强制选路或延长超时。
|
||||
|
||||
本方案当前的执行目标是一个默认关闭、可复现实验、完整管理 socket 生命周期的原型。通过 P5 后,才讨论默认启用、扩大网络适用范围,以及首次连接阶段的增强。
|
||||
|
||||
## 14. 技术依据
|
||||
|
||||
- [RFC 8445:ICE 的认证、peer-reflexive 候选、触发检查和提名](https://www.rfc-editor.org/rfc/rfc8445.html):命中后的验证复用 ICE;本文 READY 后探索为双方协商的额外能力。
|
||||
- [libnice NiceAgent 文档](https://libnice.freedesktop.org/libnice/NiceAgent.html):候选收集、端口范围、接收回调和检查参数;新增 pool API 不属于现有公共 API。
|
||||
- [OpenSSL 3.3:SSL_export_keying_material](https://docs.openssl.org/3.3/man3/SSL_export_keying_material/):独立标签及 context 导出会话密钥;本方案新增 peer-verified 前置条件。
|
||||
- [NAT traversal:多 socket 与目的端口探索](https://tailscale.com/blog/how-nat-traversal-works#the-benefits-of-birthdays):两个集合相交的通用思路,用于解释实验方向,不作为 UU 私有实现或成功率证明。
|
||||
|
||||
|
||||
## 15. 本轮 P0 实施记录(2026-09-13)
|
||||
|
||||
MiniRTC 实施提交:`7f0e666216891c9b79bd4a8f75b63741fe1fabf2`。
|
||||
本机最终检查为 22/22 通过,包括 en1 实际 IPv4 与内核接口绑定读回;这不是跨 NAT
|
||||
成功率。详情见 [P0 测试记录](../deps/submodules/minirtc/tests/ice/lab/p0-validation-2026-09-13.md)
|
||||
与 [构建、双机执行和抓包步骤](../deps/submodules/minirtc/tests/ice/lab/p0-experiments.md)。
|
||||
|
||||
本节是执行记录,不改变 P0→P5 的依赖门槛。**本轮没有完成可启用的“双端
|
||||
UDP 打洞增强”产品功能。** P0 的本机机制已可运行;Windows、双端物理网卡抓包
|
||||
和实际 CrossDesk 媒体尚待验收,因此不启动 P1–P5,不通告生产 v1 能力,也不进行
|
||||
512 目的端口/64 本地端点的广范围探索。
|
||||
|
||||
交付位于 MiniRTC 子模块,不能仅依赖开发机缓存:
|
||||
|
||||
- `thirdparty/libnice/patches/punch_pool_0.1.24.patch`:在前三个现有补丁后应用,
|
||||
新增 `nice/punch.h` 与单端点实验后端,补丁 SHA、包身份版本与符号链接检查已进配方。
|
||||
- `tests/ice/punch_integration_test.cpp`:真实 NiceAgent、TURN、DTLS、SRTP 与 UDP
|
||||
转发门控,pool 的两个 ICE 角色均可执行;在代码中检查内核实际 selected socket。
|
||||
- `tests/ice/punch_peer_test.cpp`:两个独立进程/两台 LAN 主机的 P0 实验入口,
|
||||
私有共享目录提供测试信令,便于 Windows/macOS 后续配合;不是生产控制协议。
|
||||
- `tests/ice/lab/run_p0.py`:临时 coturn、STUN 就绪检查、场景矩阵、二进制 SHA、
|
||||
结构化结果与清理;`tests/ice/lab/p0-experiments.md` 给出准确构建、双机操作和抓包清单。
|
||||
- `src/ice/ice_agent.*`:明确 `dtls_peer_verified_`,统一两个握手完成入口的指纹
|
||||
验证;导出 SRTP 前要求验证成功;有界保留早于本端 READY 的 DTLS flight。
|
||||
|
||||
### 15.1 经源码和实验确认的调整
|
||||
|
||||
1. `priv_conn_check_add_for_candidate_pair_matched()` 原来会将 READY 降为 CONNECTED。
|
||||
对已打开 P0 端点且处于原升级窗口内的连接保留 READY,正常 STUN 排队、优先级、
|
||||
valid pair 和 nomination 不变。取消增强检查时,尚存的 selected relay 不因
|
||||
checklist 中提名对象已裁剪而被错误宣告 FAILED。
|
||||
2. 直接接收分流必须排除两类中继:本端 TURN 解封装,以及**对端 allocation 向
|
||||
本端 host 发出的普通外层 UDP**。后者在 pool 为 controlling 的负向测试中被发现,
|
||||
因而在原始接收钩子中额外排除已知远端 relay candidate 地址。
|
||||
3. 初始实验复现了既有 DTLS 次序问题:ClientHello 到达时本端仍为 CONNECTED,
|
||||
原代码丢包且对端没有后续包推动握手。现在最多暂存 4 个、每个不超过 2048 字节的
|
||||
早到 DTLS 数据报,仍由原 READY 回调启动同一个 DTLS 会话;没有重新握手、修改
|
||||
ICE/DTLS 超时或延长升级窗口。BIO 方法初始化也改为线程安全的一次性初始化。
|
||||
4. libnice 的 SDP 解析器按 LF 切分,直接复制 ufrag/password;实验初版擅自将
|
||||
libnice 输出改成 CRLF,造成密码尾部多出 CR 与真实 STUN integrity 失败。
|
||||
夹具已保留原 LF 格式。此修正仅在测试信令中,不改写生产信令行为。
|
||||
|
||||
### 15.2 当前后端契约及边界
|
||||
|
||||
公开名称刻意标为 `NICE_PUNCH_P0_ABI`,不是 `udp-punch:1` 的运行能力证明。
|
||||
|
||||
| 接口 | P0 实际语义 |
|
||||
| --- | --- |
|
||||
| `nice_agent_punch_get_abi/live_sockets` | 实验 ABI、进程额外 socket 存量 |
|
||||
| `nice_agent_punch_get_deadline` | 读取现有有效 relay upgrade 截止,无新窗口 |
|
||||
| `nice_agent_punch_open` | 指定真实 IPv4 host base,在 READY relay 上借用或创建一个端点 |
|
||||
| `nice_agent_punch_send` | 指定 generation/不透明 handle,向明确地址发送 80 字节实验探测 |
|
||||
| `punch-packet` | 锁外信号,带范围、实际外层地址与有界字节副本;不表示 ICE valid |
|
||||
| `nice_agent_punch_promote` | 保持 socket,加入一个普通 host/已知 anchor remote 检查对 |
|
||||
| `nice_agent_punch_stop(..., FALSE)` | 停止探索,关闭未提升端点,继续已提升检查 |
|
||||
| `nice_agent_punch_stop(..., TRUE)` | 先剪除未选中检查再关 socket;已选中端点保持传输 |
|
||||
|
||||
原始 anchor、relay 的所有权始终属于原 ICE;新 socket 属于 component 的
|
||||
SocketSource;提升不关闭或重建 socket。额外 socket 配额在实际 SocketSource
|
||||
释放之后返还,选中后仍占配额直到关闭。句柄不暴露 FD,不在 libnice 锁内反向调用。
|
||||
|
||||
P0 仅允许每 component 一个实验端点,进程硬限制 256 个额外 socket。它没有实现
|
||||
P1 的生产认证上下文、P2 速率公平预算、P3 广范围组合探测或 P4 完整重连协调器。
|
||||
实验 HMAC 密钥仅来自可信夹具/私有测试信令,不声称已经完成 DTLS exporter。
|
||||
`MINIRTC_UDP_PUNCH=wide-only|pool-wide` 目前没有被实现或读取;产品仍关闭。
|
||||
|
||||
### 15.3 验收和下一步
|
||||
|
||||
当前可运行的构建与实验步骤见子模块
|
||||
`tests/ice/lab/p0-experiments.md`;可公开的测试摘要保存在同目录的结果记录中。
|
||||
成功意味着本机真实中继 → 验证过的 DTLS → 新 socket 的认证入站 → 正常 STUN/
|
||||
nomination → 同一 socket 的双向 SRTP,不能将编译、短包 ACK 或一次局域网成功
|
||||
当作 P0 总验收或 P5 的真实网络收益。
|
||||
|
||||
P0 总门槛仍需 Windows x64 编译/执行、Windows/macOS 双端抓包、两个 ICE 角色、
|
||||
实际桌面媒体连续性及退出检查。完成这些后再冻结生产后端接口并进入 P1,沿用本文
|
||||
其余协议、预算和 P1–P5 验收要求。此处没有通过降低验收要求提前启动广范围探索。
|
||||
|
||||
2026-09-13 后续进展:用户报告同步后的 Windows 构建成功,尚未取得 Windows
|
||||
网络测试日志或二进制哈希;不能将构建成功记为 P0 运行验收。两端实际不在同一
|
||||
局域网,当前双机夹具要求明确且互相可达的 UDP 地址,因此不能直接使用两端
|
||||
私网 IP 运行该夹具。`run_p0.py` 新增外部实验 TURN 模式,使 Windows 可以先
|
||||
独立完成本机双 ICE 角色、SRTP 和资源清理矩阵,无需本机安装 coturn。外部 TURN
|
||||
不等于双机直连拓扑,此调整不改变 P0 门槛、原升级窗口或后续阶段依赖。
|
||||
|
||||
### 15.4 两台 Mac 局域网实测
|
||||
|
||||
用户提供同局域网的第二台 arm64 Mac 后,已在 `192.168.31.125/en1` 与
|
||||
`192.168.31.102/en0` 执行真实双机 P0;双方系统均为 macOS 26.6.2。
|
||||
`run_p0_ssh.py` 仅用 SSH 传递实验 JSON 信令,探测、STUN 和 SRTP 仍通过真实
|
||||
LAN UDP socket 与原受控门控转发。没有改用 SSH 隧道承载被测传输。
|
||||
|
||||
四种 ICE/端口池角色组合的媒体断言均通过。首轮严格双端逐包核对为 3/4 通过,
|
||||
另一组有一个 SRTP 包只出现在发送端抓包中,原始失败记录已保留,未改判为成功。
|
||||
该组以新端口、新 run ID 复测后通过,两端均取得零内核丢包统计。四种组合现均有
|
||||
探测 HMAC → 认证 STUN/提名 → 命中 socket 上双向 SRTP 的双机报文证据。
|
||||
|
||||
双机夹具增加开池前至少 10 个中继 SRTP、停止探测后至少 10 个直连 SRTP、
|
||||
selected socket 的物理接口绑定读回;全部仍在原升级窗口内。仓库新增
|
||||
`verify_p0_capture.py`,核对实际 pcapng、ICE MESSAGE-INTEGRITY、提名 TID 和
|
||||
门控两侧的相同报文,私有密钥和原始抓包不入库。
|
||||
|
||||
完整次数、哈希、例外、复现和清理记录见
|
||||
[双 Mac P0 实测记录](../deps/submodules/minirtc/tests/ice/lab/p0-lan-validation-2026-09-13.md)。
|
||||
本轮完成 Mac 双机机制与抓包验证,**没有替代 Windows 运行、跨平台及实际应用
|
||||
媒体验收**。P0 总验收仍未完成,P1–P5 继续保持未启动,生产功能默认关闭。
|
||||
|
||||
### 15.5 Windows 局域网验收准备(2026-09-14)
|
||||
|
||||
用户另提供同局域网 Windows:WLAN `192.168.31.220/24`,仓库
|
||||
`D:\SourceCode\crossdesk`,账户 `desktop-ri1qfbe\kunkundi`。用户输出确认
|
||||
Python 3.13.5 可用,MiniRTC 为 `7f0e666`,两个 P0 测试 EXE 文件存在。
|
||||
这些信息不代表测试已运行;升级后的双机媒体断言需要同步并重新构建 peer 目标。
|
||||
|
||||
实验驱动增加 `--remote-shell powershell`,通过编码命令传入 Windows 的字面路径,
|
||||
仍仅使用 SSH 管道传递私有信令。抓包核验器允许显式指定两端物理和本地回环接口名,
|
||||
以适配 Npcap 的命名;未将虚拟网卡或回环流量当作物理网卡交付证据。
|
||||
README 补充 Windows SSH 驱动、两接口抓包和精确接口名核验步骤。
|
||||
|
||||
本机已检查 Python 语法、带空格/单引号/中文路径的命令编码、POSIX 参数兼容,
|
||||
并用原 Mac 抓包回归默认和显式接口选择;改写接口名后的同一份 Mac 报文也能核验,
|
||||
错误物理接口或 anchor 回环接口会被拒绝。这只是工具回归,**未执行 Windows
|
||||
PowerShell 启动、Windows 网络矩阵或 Npcap 验证**。当前等待 Windows SSH 接入,
|
||||
后续使用本机临时 TURN 开展单机矩阵和四种跨平台角色组合,保持原 P0 门槛。
|
||||
|
||||
### 15.6 Windows/macOS P0 验收完成(2026-09-14)
|
||||
|
||||
Windows 真实单机矩阵 20/20 通过,包含两个 ICE 角色、100 次生命周期、取消、
|
||||
未命中、错误指纹和原截止时间。跨平台四角色均已取得命中端口上的认证 STUN、
|
||||
提名及双向 SRTP 的严格双端抓包证据。完整结果、二进制和抓包 SHA、失败样本及
|
||||
边界见 [Windows/macOS P0 记录](../deps/submodules/minirtc/tests/ice/lab/p0-windows-validation-2026-09-14.md)。
|
||||
|
||||
本轮没有把全部尝试改判为通过:初次双机抓包为 3/4,修正夹具收尾后为 4/5。
|
||||
最终普通 Mac pool/controlling 一组有中段一个 SRTP 包只出现在 Mac 抓包;同角色
|
||||
的单向通知延迟 1 秒组严格通过,因此四种角色均已有最终版本证据。另一次 Windows
|
||||
开池前 TURN 媒体基线失败,针对两种角色复测均通过,原失败仍保留且原因未确立。
|
||||
Windows 抓包统计包含 16 个 flushed,pcap/dumpcap/接口丢包为 0;未声称总丢包为 0。
|
||||
|
||||
经源码和最小实验确认两处夹具修正:Windows `IP_UNICAST_IF` GET 已为主机序,
|
||||
去掉重复转换;旧夹具等待对端完成消息时仍发媒体,另一端可先销毁 gate,单向
|
||||
延迟实验复现最后 13 包未转发。新增停止发送、交换最终序号、双端认证接收确认后
|
||||
再销毁的屏障;没有修改标准 ICE、原窗口、DTLS 或降低媒体门槛。
|
||||
|
||||
完整 CrossDesk 两端编译成功,Mac 使用 `xmake r crossdesk` 提供 Slint 运行环境。
|
||||
用户对双向画面、声音、鼠标键盘和三次重连请求回复已完成,未报告异常;此项作为
|
||||
用户报告的普通应用回归,不能声称独立测量了 pool 切换时的实际编解码连续性。
|
||||
命中 socket 的切换与认证媒体由 P0 真实 SRTP 夹具验证。
|
||||
|
||||
P0 后端接口、所有权与取消语义按 15.2 冻结,允许进入 P1。P0 仍是每 component
|
||||
一个实验端点;P3 扩展数量时必须继续遵守同一 socket、标准检查和选中资源所有权。
|
||||
生产能力、广范围探索和默认启用尚未交付,P5 真实未知 NAT 的收益仍需后续对照。
|
||||
|
||||
## 16. P1–P4 实现与 P5 实验入口(2026-09-14)
|
||||
|
||||
本节更新当前状态;第 15 节“未开始 P1–P5”等文字是对应时间点的历史记录。
|
||||
在 P0 跨平台门槛通过后依次完成 P1/P2 基础、P3 多端点接入和 P4 生命周期/构建,
|
||||
没有在 P0 前放大探索范围。源码及实验工具由主仓库当前 gitlink 固定。完整证据见
|
||||
[P1–P4 验证记录](../deps/submodules/minirtc/tests/ice/lab/punch-runtime-validation-2026-09-14.md),
|
||||
准确命令见 [v1 实验步骤](../deps/submodules/minirtc/tests/ice/lab/punch-v1-experiments.md)。
|
||||
|
||||
| 阶段 | 当前交付及边界 |
|
||||
| --- | --- |
|
||||
| P0 | 已通过原门槛,保持单端点 ABI 1 与原始失败记录 |
|
||||
| P1 | 配置/能力、指纹验证后 exporter、认证协议与协商完成;默认关闭,新旧/未知能力纯逻辑及实际生成能力检查通过 |
|
||||
| P2 | 同源观测、角色、CSPRNG 采样、本地与进程预算完成;首次实现时 Mac/Windows/Linux 各 2,952 个单元断言通过,后续清理回归见验证记录 |
|
||||
| P3 | 独立 ABI 2、64/128 端点、同 socket 提升/标准检查/提名/媒体闭环实现;三个平台受控 runtime 各 12/12 |
|
||||
| P4 | owner context 清理、DRAINING、取消、重连身份和接口/base 检查实现;百次 source/agent 生命周期、Mac/Windows 完整应用、Linux ICE smoke、iOS 静态库已验证。物理网卡真实切换和最终应用互连仍待双端验证 |
|
||||
| P5 | 90 次交错清单与核验工具已准备;有 1 次增强成功双端日志但无该轮物理抓包。后续双端抓包取得 A/off 的 3 个完整窗口样本(均中继),B/wide-only 的 2 次已启动未命中和 1 次启动前跳过。跳过暴露的 READY 重查时序问题已修复,真实双端修复后复测及正式随机 A/B/C 仍待进行;保留先前 SDP 失败,不作收益或默认开启决定 |
|
||||
|
||||
### 16.1 对实际源码的必要调整
|
||||
|
||||
- 保留已验收的 `punch_pool_0.1.24.patch`,追加 `punch_v1_0.1.24.patch`。
|
||||
`nice_agent_punch_get_v1_abi/open_v1` 与 P0 API 区分;v1 每池最多 128、进程 256,
|
||||
提升最多 8。补丁 SHA 与包身份版本进入 MiniRTC 配方,不依赖本机缓存改动。
|
||||
- `PunchRuntime` 由 IceAgent 持有,在同一 GMainContext 使用真实句柄,直接收包
|
||||
与 TURN 控制分流,认证命中后由 pool 提升。实际 selected pair 才报告结果,
|
||||
各自的原始截止不会因 HELLO/重传/START 被延长。
|
||||
- SDP 指纹最小实验发现原解析吞并 fingerprint 之后所有行并截断 SDP,导致新增
|
||||
候选/能力位于其后时 DTLS 校验失败。改为逐行解析、保留其余 SDP、拒绝冲突指纹,
|
||||
并加入真实握手与位置测试;没有通过禁用指纹校验绕过失败。
|
||||
- 通用 DEBUG 日志在现有项目中编译关闭,因此添加独立、默认关闭的
|
||||
`MINIRTC_UDP_PUNCH_TRACE=1`,只记录有界探测元数据。应用原有 SDP 日志包含 ICE
|
||||
密码,原始实验文件必须保持私有;新代码不记录 exporter 或完整 tag。
|
||||
- 首次启用后的真实应用试跑发现 eligibility 日志读取未赋值的旧 ufrag 指针,
|
||||
在接收 SDP 时崩溃;最小实验复现后,eligibility/skip 与 exporter 统一从当前
|
||||
libnice stream 读取 generation 凭据。补充双角色真实 SDP 接收/重建及自动 skip
|
||||
的 TURN/SRTP 回归,详情见验证记录。此修复不改变协议或绕过探测前置条件。
|
||||
- Mac off 试跑发现传输层复制指纹行后缺少 LF,追加原能力时产生 CR CR LF,
|
||||
无 v1 能力的 SDP 被严格校验拒绝。完整 IceTransport 实验复现后在拼接处补齐
|
||||
LF;三种运行模式各 36 个组合及错误指纹拒绝通过,证书校验和默认关闭不变。
|
||||
- B 组一次启动遇到 READY → CONNECTED → READY 的正常候选重查。后端截止时间
|
||||
查询只在 READY 有效,旧入口把暂时返回 0 当作最终跳过。保存首次 READY 时
|
||||
后端提供的真实截止时间;在该时间内等待 CONNECTED 恢复,随后重新让后端验证
|
||||
是否可探测。不在 CONNECTED 开池,不从恢复时间重新计算窗口;新增真实 TURN
|
||||
的 `auto-recheck` 夹具,覆盖两个角色、原截止不变和单次 DTLS/认证中继媒体。
|
||||
- 第 10 节 B 组原“固定角色”明确为**固定自动判定规则**,与第 4 节生产禁用强制
|
||||
角色保持一致。受控夹具仍可固定角色;真实实验记录观测/角色变化及跳过,不把
|
||||
它们隐藏为成功或擅自在生产中绕过适用条件。
|
||||
|
||||
### 16.2 验收边界与下一步
|
||||
|
||||
已验证 DRAINING 后真实 STUN 重传仍能提名并传输、认证 CANCEL 保留 relay、
|
||||
TURN 内层同形载荷不会产生直接命中,以及选中 socket 的实际存活和配额保持。
|
||||
Windows 64 端点百次压力测试在单一窗口到期后拒绝分配;工具通过多个新连接分批,
|
||||
最终两角色合计 20/20,各累计 100 次分配/停止/端口重绑定,没有扩大 ICE 窗口。
|
||||
分批首轮 15/20 暴露开池前基线只按发送次数判断的问题;改为原截止前等待双方
|
||||
各实际收到至少 10 个认证 SRTP 包后才开池。所有初次失败和修正理由保存在报告中,
|
||||
不追溯认定所有历史媒体缺包原因相同。
|
||||
|
||||
用户已报告完成的普通画面/声音/控制/三次重连回归属于 P0 时点,不代表后续原型
|
||||
已通过真实应用切换验收。后续已有异网配对的双端成功日志,下一步补齐物理抓包,
|
||||
并在同步接收端修复后,用同一版本、同一信令
|
||||
与 STUN/TURN 配置,按文档交错执行 A/B/C;首先确认新鲜多 STUN 观测符合一侧稳定、
|
||||
另一侧目的相关。每个增强成功必须核验双端物理报文、标准提名、同一命中 socket、
|
||||
同一 DTLS/SRTP 会话以及实际画面/声音/控制。至少每组 30 次、保留全部失败/缺测。
|
||||
在这些工作完成前,原型仍默认关闭,不能声称已提高真实网络成功率。
|
||||
@@ -0,0 +1,10 @@
|
||||
# **更新内容**
|
||||
|
||||
1. 新增Windows、macOS与Linux X11隐私屏,支持遮蔽被控端本地画面、屏蔽本地键鼠输入,并在断开连接或异常时自动恢复;远程会话保持正常操作。
|
||||
2. 增强桌面端与iOS端P2P穿透能力,优化UDP打洞、ICE连接建立与带宽恢复,支持使用服务器下发的连接配置,并改善加密传输与旧版客户端的兼容性。
|
||||
3. 新增Windows屏幕采集方式设置,支持DXGI、WGC与GDI;修复锁屏、隐私屏及未连接物理鼠标时远程光标不可见、位置异常和拖动窗口时出现重复光标的问题。
|
||||
4. 优化画面缩小时的显示清晰度、帧率与分辨率恢复,修复Windows、macOS与Linux系统音频采集可能卡住的问题,提升远程画面与音频的稳定性。
|
||||
5. 修复Linux X11远程键盘输入与macOS窗口缩放光标识别,改进鼠标操作和光标状态传输的可靠性;优化控制栏提示、网络状态布局与加密状态显示,并修复部分平台的Dock及任务栏图标显示问题。
|
||||
6. 完善iOS/iPadOS连接失败提示,保留密码错误、设备不可用和网络中断等具体原因,简化服务器配置并优化原生客户端构建流程。
|
||||
|
||||
PC客户端版本为v1.5.1,iOS/iPadOS客户端暂未上架App Store,目前仅支持作为控制端连接远端电脑。隐私屏需控制端与被控端均支持,暂不支持Web及旧版客户端;macOS要求14.0及以上版本,Linux仅支持X11会话。
|
||||
@@ -73,6 +73,24 @@ struct KeyboardState {
|
||||
KeyboardStateKey pressed_keys[kMaxKeyboardStateKeys];
|
||||
};
|
||||
|
||||
// Optional presentation metadata. Zero preserves pre-metadata peers' behavior.
|
||||
enum class CursorRenderMode : uint8_t {
|
||||
legacy = 0,
|
||||
separate,
|
||||
embedded,
|
||||
hidden,
|
||||
unknown,
|
||||
};
|
||||
|
||||
enum class CursorHiddenReason : uint8_t {
|
||||
unspecified = 0,
|
||||
system_hidden,
|
||||
system_suppressed,
|
||||
no_pointing_device,
|
||||
sampling_failed,
|
||||
secure_desktop_pending,
|
||||
};
|
||||
|
||||
struct CursorState {
|
||||
uint32_t seq;
|
||||
bool visible;
|
||||
@@ -89,6 +107,10 @@ struct CursorState {
|
||||
// Shape-only updates set this to false so cursor appearance can remain
|
||||
// responsive while position feedback to the input source is suppressed.
|
||||
bool position_update;
|
||||
// Keep visible/shape for old receivers; new receivers can distinguish an
|
||||
// embedded pointer from a hidden pointer and an unavailable sample.
|
||||
CursorRenderMode render_mode;
|
||||
CursorHiddenReason hidden_reason;
|
||||
};
|
||||
|
||||
struct HostInfo {
|
||||
@@ -100,6 +122,8 @@ struct HostInfo {
|
||||
int* top;
|
||||
int* right;
|
||||
int* bottom;
|
||||
// Optional capability; absent in legacy desktop and web host information.
|
||||
bool supports_privacy_screen;
|
||||
};
|
||||
|
||||
struct ServiceStatus {
|
||||
|
||||
@@ -22,6 +22,7 @@ void ResetHostInfo(HostInfo& info) {
|
||||
info.top = nullptr;
|
||||
info.right = nullptr;
|
||||
info.bottom = nullptr;
|
||||
info.supports_privacy_screen = false;
|
||||
}
|
||||
|
||||
bool AllocateHostDisplays(HostInfo& info, std::size_t count) {
|
||||
@@ -90,7 +91,9 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
{"visual_offset_x", action.cs.visual_offset_x},
|
||||
{"visual_offset_y", action.cs.visual_offset_y},
|
||||
{"display_id", action.cs.display_id},
|
||||
{"position_update", action.cs.position_update}};
|
||||
{"position_update", action.cs.position_update},
|
||||
{"render_mode", static_cast<int>(action.cs.render_mode)},
|
||||
{"hidden_reason", static_cast<int>(action.cs.hidden_reason)}};
|
||||
break;
|
||||
case ControlType::audio_capture:
|
||||
object["audio_capture"] = action.a;
|
||||
@@ -132,7 +135,9 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
}
|
||||
object["host_info"] = {{"host_name", action.i.host_name},
|
||||
{"display_num", action.i.display_num},
|
||||
{"displays", displays}};
|
||||
{"displays", displays},
|
||||
{"supports_privacy_screen",
|
||||
action.i.supports_privacy_screen}};
|
||||
break;
|
||||
}
|
||||
case ControlType::invalid:
|
||||
@@ -194,6 +199,19 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
output.cs.seq = cursor_state_object.at("seq").get<uint32_t>();
|
||||
output.cs.visible = cursor_state_object.at("visible").get<bool>();
|
||||
output.cs.shape = static_cast<RemoteCursorShape>(shape);
|
||||
const int mode = cursor_state_object.value("render_mode", 0);
|
||||
const int reason = cursor_state_object.value("hidden_reason", 0);
|
||||
// Unknown extensions from a newer peer fall back to visible/shape.
|
||||
output.cs.render_mode =
|
||||
mode >= 0 && mode <= static_cast<int>(CursorRenderMode::unknown)
|
||||
? static_cast<CursorRenderMode>(mode)
|
||||
: CursorRenderMode::legacy;
|
||||
output.cs.hidden_reason =
|
||||
reason >= 0 &&
|
||||
reason <= static_cast<int>(
|
||||
CursorHiddenReason::secure_desktop_pending)
|
||||
? static_cast<CursorHiddenReason>(reason)
|
||||
: CursorHiddenReason::unspecified;
|
||||
output.cs.position_valid =
|
||||
cursor_state_object.value("position_valid", false);
|
||||
output.cs.x = cursor_state_object.value("x", 0.5f);
|
||||
@@ -272,6 +290,11 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
ResetHostInfo(output.i);
|
||||
owns_host_info = true;
|
||||
const auto& host_info_object = object.at("host_info");
|
||||
const auto privacy_support =
|
||||
host_info_object.find("supports_privacy_screen");
|
||||
output.i.supports_privacy_screen =
|
||||
privacy_support != host_info_object.end() &&
|
||||
privacy_support->is_boolean() && privacy_support->get<bool>();
|
||||
const std::string host_name =
|
||||
host_info_object.at("host_name").get<std::string>();
|
||||
std::strncpy(output.i.host_name, host_name.c_str(),
|
||||
|
||||
Reference in New Issue
Block a user