[feat] add DeskHub group control

This commit is contained in:
dijunkun
2026-10-14 10:20:05 +08:00
parent 417ef7d58c
commit 3ee0d42494
7 changed files with 97 additions and 73 deletions
@@ -4167,6 +4167,8 @@ void GuiApplication::CloseStreamTab(const std::string& remote_id) {
}
if (controlled_remote_id_ == remote_id)
ReleaseControl();
else if (!gallery_group_control_id_.empty())
ReleasePressedInput();
keyboard_.ReleaseRemotePressedKeys(remote_id, "stream_tab_closed");
CloseRemoteSession(props);
ResetRemoteSessionResources(props);
@@ -4179,7 +4181,7 @@ void GuiApplication::CloseStreamTab(const std::string& remote_id) {
}
if (focused_remote_id_ == remote_id) {
focused_remote_id_.clear();
controlled_remote_id_.clear();
if (controlled_remote_id_ == remote_id) controlled_remote_id_.clear();
if (ui_->video_presenter && ui_->video_presenter->SelectStream({})) {
video_frame_dirty_.store(true, std::memory_order_release);
}
@@ -4267,13 +4269,7 @@ void GuiApplication::SendPointerInput(int button, int kind, float x, float y) {
ui_->pointer_x = point->x;
ui_->pointer_y = point->y;
controlled_remote_id_ = props->remote_id_;
const std::string message = action.to_json();
{
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());
SendRemoteInput(action);
}
void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x,
@@ -4312,14 +4308,8 @@ 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();
SendReliableDataFrame(props->peer_, message.c_str(), message.size(),
props->mouse_label_.c_str());
SendRemoteInput(action);
}
void GuiApplication::SendKeyInput(const std::string& text, bool pressed,
@@ -4386,20 +4376,15 @@ void GuiApplication::SyncAudioPlayback() {
void GuiApplication::ReleasePressedInput() {
keyboard_.ForceReleasePressedKeys();
if (auto props = FindRemoteSession(controlled_remote_id_);
props && props->peer_) {
for (int button : ui_->pressed_mouse_buttons) {
RemoteAction action{};
action.type = ControlType::mouse;
action.m.x = ui_->pointer_x;
action.m.y = ui_->pointer_y;
action.m.flag = button == 1 ? MouseFlag::left_up
: button == 2 ? MouseFlag::right_up
: MouseFlag::middle_up;
const auto message = action.to_json();
SendReliableDataFrame(props->peer_, message.data(), message.size(),
props->mouse_label_.c_str());
}
for (int button : ui_->pressed_mouse_buttons) {
RemoteAction action{};
action.type = ControlType::mouse;
action.m.x = ui_->pointer_x;
action.m.y = ui_->pointer_y;
action.m.flag = button == 1 ? MouseFlag::left_up
: button == 2 ? MouseFlag::right_up
: MouseFlag::middle_up;
SendRemoteInput(action);
}
ui_->pressed_mouse_buttons.clear();
}
@@ -4408,6 +4393,8 @@ void GuiApplication::ReleaseControl() {
const auto gallery_target = ui_->gallery_control_id;
devices_.StopKeyboardCapturer();
ReleasePressedInput();
gallery_group_control_id_.clear();
ui_->wall->set_gallery_group_controlling(false);
if (!ui_->gallery_control_id.empty()) {
ui_->gallery_control_id.clear();
ui_->wall->set_gallery_controlling(false);
@@ -4845,10 +4832,17 @@ void GuiApplication::GalleryAction(const std::string& action,
} else if (action == "focus") {
ReleaseControl();
ui_->gallery_focus = value;
} else if (action == "control") {
} else if (action == "group-release") {
// Flush releases to the old group before returning to single-device input.
ReleasePressedInput();
gallery_group_control_id_.clear();
ui_->wall->set_gallery_group_controlling(false);
} else if (action == "control" || action == "group-control") {
ReleaseControl();
auto props = FindRemoteSession(value);
if (!props || props->connection_status_ != ConnectionStatus::Connected)
if (value != ui_->gallery_focus || !props || !props->peer_ ||
props->closing_ ||
props->connection_status_ != ConnectionStatus::Connected)
return;
{
std::lock_guard lock(props->gallery_frame_mutex_);
@@ -4858,6 +4852,8 @@ void GuiApplication::GalleryAction(const std::string& action,
}
ui_->gallery_control_id = value;
controlled_remote_id_ = value;
if (action == "group-control") gallery_group_control_id_ = value;
ui_->wall->set_gallery_group_controlling(!gallery_group_control_id_.empty());
props->control_mouse_ = true;
props->enable_mouse_control_ = true;
props->clipboard_allowed_ = true;
@@ -5735,7 +5731,7 @@ void GuiApplication::Cleanup() {
#endif
privacy_.Disable();
clipboard_.Shutdown();
keyboard_.ForceReleasePressedKeys();
ReleaseControl();
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
devices_.DestroyDevices();
privacy_.Shutdown();
@@ -160,27 +160,7 @@ void KeyboardController::SendHeartbeat(bool force) {
action.ks.pressed_count = index;
}
const std::string target_id = owner_.controlled_remote_id_.empty()
? owner_.focused_remote_id_
: owner_.controlled_remote_id_;
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
const auto props_it = owner_.remote_sessions_.find(target_id);
if (target_id.empty() || props_it == owner_.remote_sessions_.end() ||
props_it->second->connection_status_.load() !=
ConnectionStatus::Connected ||
!props_it->second->peer_) {
last_heartbeat_tick_ = now;
return;
}
const std::string message = action.to_json();
const int result = SendReliableDataFrame(
props_it->second->peer_, message.c_str(), message.size(),
props_it->second->keyboard_label_.c_str());
if (result != 0) {
LOG_WARN("Send keyboard heartbeat failed, remote_id={}, ret={}", target_id,
result);
}
owner_.SendRemoteInput(action);
last_heartbeat_tick_ = now;
}
@@ -200,24 +180,7 @@ int KeyboardController::SendKeyCommand(int key_code, bool is_down,
action.k.scan_code = scan_code;
action.k.extended = extended;
const std::string target_id = owner_.controlled_remote_id_.empty()
? owner_.focused_remote_id_
: owner_.controlled_remote_id_;
std::shared_lock sessions_lock(owner_.remote_sessions_mutex_);
const auto props_it = owner_.remote_sessions_.find(target_id);
if (!target_id.empty() && props_it != owner_.remote_sessions_.end() &&
props_it->second->connection_status_.load() ==
ConnectionStatus::Connected &&
props_it->second->peer_) {
const std::string message = action.to_json();
const int result = SendReliableDataFrame(
props_it->second->peer_, message.c_str(), message.size(),
props_it->second->keyboard_label_.c_str());
if (result != 0) {
LOG_WARN("Send keyboard command failed, remote_id={}, ret={}", target_id,
result);
}
}
owner_.SendRemoteInput(action);
TrackPressedKey(key_code, is_down, scan_code, extended);
return 0;
@@ -28,6 +28,49 @@ GuiRuntime::GuiRuntime()
GuiRuntime::~GuiRuntime() { WaitForSessionCleanup(); }
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
const bool mouse = action.type == ControlType::mouse;
if (!mouse && action.type != ControlType::keyboard &&
action.type != ControlType::keyboard_state)
return;
const std::string& target_id = controlled_remote_id_.empty()
? focused_remote_id_ : controlled_remote_id_;
if (target_id.empty()) return;
const bool group = gallery_group_control_id_ == target_id;
const bool releasing = mouse
? action.m.flag == MouseFlag::left_up ||
action.m.flag == MouseFlag::right_up ||
action.m.flag == MouseFlag::middle_up
: action.type == ControlType::keyboard
? action.k.flag == KeyFlag::key_up : action.ks.pressed_count == 0;
const auto available = [](const auto& props) {
return props && props->peer_ && !props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected;
};
std::shared_lock lock(remote_sessions_mutex_);
const auto source = remote_sessions_.find(target_id);
// A lost leader must stop new input, but its followers still need key/button
// releases before group control is cleared by the UI.
if (!releasing &&
(source == remote_sessions_.end() || !available(source->second)))
return;
const auto message = action.to_json();
for (const auto& [id, props] : remote_sessions_) {
if ((!group && id != target_id) || !available(props)) continue;
if (mouse) {
std::lock_guard cursor_lock(props->remote_cursor_state_mutex_);
props->cursor_presentation_.NoteInput(SDL_GetTicks());
}
// Reuse exactly the leader's mapped coordinates and key metadata. Neither
// gallery filters nor the follower's preview dimensions affect input.
const int result = SendReliableDataFrame(
props->peer_, message.data(), message.size(),
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
if (result != 0)
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
}
}
int GuiRuntime::CreateConnectionPeer() {
// Tick retries after the old peer's leave notification and socket shutdown.
if (connection_peer_cleanup_.valid()) return 0;
@@ -87,6 +87,10 @@ class GuiRuntime : protected gui_detail::GuiState {
void WaitForSessionCleanup();
std::shared_ptr<RemoteSession> FindRemoteSession(
const std::string& remote_id);
// UI thread: send mapped mouse/keyboard input to the current control target,
// or every connected session while that target leads DeskHub group control.
void SendRemoteInput(const RemoteAction& action);
std::string gallery_group_control_id_;
void ResetRemoteServiceStatus(RemoteSession& props);
void ApplyRemoteServiceStatus(RemoteSession& props,
+17 -2
View File
@@ -43,6 +43,7 @@ export struct GalleryActionDevice {
export component GalleryView inherits Rectangle {
in property <bool> controlling: false;
in property <bool> group-controlling: false;
in property <bool> input-suspended: false;
in property <bool> simplified: false;
in property <bool> fullscreen: false;
@@ -566,12 +567,24 @@ export component GalleryView inherits Rectangle {
background: root.controlling ? #e7f1fd : #f0f2f5;
focus-state := Text {
x: 8px; width: parent.width - 16px; height: parent.height;
text: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
text: root.group-controlling ? GalleryStrings.tr("群控中", "Group control", "Групповое")
: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
: root.focused.connected && root.focused.has-frame && !root.focused.stale
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
font-size: 10px; color: root.controlling ? #3e77b3 : #6b7480; vertical-alignment: center; overflow: elide;
}
}
GalleryToolbarButton {
horizontal-padding: root.toolbar-button-padding;
primary: root.group-controlling;
enabled: root.group-controlling || (root.focused.connected && root.focused.has-frame && !root.focused.stale);
text: root.group-controlling ? GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу") : GalleryStrings.tr("开启群控", "Group control", "Групп. ввод");
alternate-text: root.group-controlling ? GalleryStrings.tr("开启群控", "Group control", "Групп. ввод") : GalleryStrings.tr("关闭群控", "Stop group", "Откл. группу");
accessible-role: button;
accessible-label: self.text + GalleryStrings.tr(":同步键鼠到所有已连接设备", ": mirror input to all connected devices", ": ввод на всех подключённых устройствах");
accessible-action-default => { self.clicked(); }
clicked => { root.action(root.group-controlling ? "group-release" : "group-control", root.focused.id); }
}
CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
}
Rectangle {
@@ -759,7 +772,9 @@ export component GalleryView inherits Rectangle {
x: 0px; y: 0px; width: parent.width - 124px; height: parent.height;
connection-count := Text {
x: 0px; width: min(self.preferred-width, parent.width - network-button.width - 12px); height: parent.height;
text: root.connected-summary;
text: root.group-controlling
? GalleryStrings.tr("群控中 · 所有已连接设备", "Group control · All connected devices", "Групповое управление · Все подключения")
: root.connected-summary;
color: #62666d; vertical-alignment: center; font-size: 11px; overflow: elide;
}
network-button := SecondaryButton {
@@ -309,6 +309,7 @@ export component MainWindow inherits Window {
}
in-out property <string> connection-input-error;
in property <bool> gallery-controlling: false;
in property <bool> gallery-group-controlling: false;
out property <float> gallery-video-width: gallery.video-width;
out property <float> gallery-video-height: gallery.video-height;
out property <float> gallery-preview-width: gallery.preview-width;
@@ -1321,6 +1322,7 @@ export component MainWindow inherits Window {
focus-rail-height <=> root.gallery-focus-rail-height;
network-open => { root.gallery-network-open = true; }
controlling: root.gallery-controlling;
group-controlling: root.gallery-group-controlling;
input-suspended: root.gallery-transitioning;
pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); }
scroll-input(dx, dy, x, y) => { root.gallery-scroll(dx, dy, x, y); }