diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 59f2dfc0..127b7cea 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -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(); diff --git a/apps/desktop/src/gui/features/input/keyboard_controller.cpp b/apps/desktop/src/gui/features/input/keyboard_controller.cpp index 41be9e39..152c1266 100644 --- a/apps/desktop/src/gui/features/input/keyboard_controller.cpp +++ b/apps/desktop/src/gui/features/input/keyboard_controller.cpp @@ -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; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.cpp b/apps/desktop/src/gui/runtime/gui_runtime.cpp index 206a135b..abdf4c0a 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.cpp +++ b/apps/desktop/src/gui/runtime/gui_runtime.cpp @@ -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; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index adc416aa..854b5d71 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -87,6 +87,10 @@ class GuiRuntime : protected gui_detail::GuiState { void WaitForSessionCleanup(); std::shared_ptr 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, diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint index 8dbc5e3f..302ee410 100644 --- a/apps/desktop/src/gui/ui/gallery_view.slint +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -43,6 +43,7 @@ export struct GalleryActionDevice { export component GalleryView inherits Rectangle { in property controlling: false; + in property group-controlling: false; in property input-suspended: false; in property simplified: false; in property 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 { diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index cee42ed7..bdc0ed0d 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -309,6 +309,7 @@ export component MainWindow inherits Window { } in-out property connection-input-error; in property gallery-controlling: false; + in property gallery-group-controlling: false; out property gallery-video-width: gallery.video-width; out property gallery-video-height: gallery.video-height; out property 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); } diff --git a/docs/deskhub.md b/docs/deskhub.md index dfcd7f5b..a21bb5de 100644 --- a/docs/deskhub.md +++ b/docs/deskhub.md @@ -26,6 +26,7 @@ - 主窗口菜单中的全局“设置”顶部并排提供“常规”和“桌面墙”两个选项卡;“桌面墙”可统一配置桌面墙设备的传输分辨率上限和帧率,桌面墙处于普通、精简、展开或全屏视图时,也可通过主窗口菜单打开设置。切换选项卡保留未保存修改,底部统一保存或取消。分辨率支持自动、320 × 180、640 × 360、1280 × 720、1920 × 1080、2560 × 1440、3840 × 2160和原始分辨率;固定上限适用于缩略图和展开画面,保持比例且不放大源画面。帧率支持跟随设备设置、15、30、60 FPS。保存后立即更新当前桌面墙会话,也用于后续桌面墙连接,不影响普通连接,跨分组和分页生效;取消不应用修改,恢复默认需保存后生效。配置保存在本地,重启后保留;保存失败时保留面板和输入。打开设置会释放远程操控。 - 服务器连接状态、指示灯和重连入口仅在主窗口显示,桌面墙的普通、精简、展开、批量选择和全屏视图均不显示服务器状态栏。桌面墙底部操作栏左侧以普通文字显示已连接设备数量,旁边是紧凑的“网络状态”按钮;点击按钮可打开当前已建立连接的设备列表,空白区域不响应点击,也不显示整条悬停提示。弹窗采用紧凑宽度,高度随设备数量调整,最多同时显示 6 行,更多设备通过滚动查看。网络列表不受桌面墙分组和分页限制;离线、未连接和连接中的设备不列入,断开后自动移除。列表显示设备名称、ID、直连/中继、实际协商的 SRTP 加密状态、控制端接收与发送速率、视频丢包率、视频延时、实际接收分辨率与帧率,打开期间每秒刷新。加密状态紧随连接方式显示为“已加密/未加密”,尚无统计或报告超过 3 秒未更新时显示“待确认”。分辨率与帧率在解码交付时统计,墙内预览和后台会话均可用;尚未收到画面时显示“—”,画面停止更新超过 1 秒时帧率显示 0 FPS。网络数值尚无统计或报告超过 3 秒未更新时显示“—”。每台设备采用两行布局:画面列显示分辨率和帧率,视频延时列显示延时和丢包率,不再显示 RTT。视频延时直接显示毫秒数,取最近 1 秒内新提交预览帧的平均值;从被控端采集时间戳计时,包含编码、传输、解码、预览转换和 UI 等待,按采集到画面提交估算,不包含提交后到屏幕实际显示的等待。当前无墙内预览样本时,可使用该设备独立串流窗口的延时统计。没有新显示帧、时间戳不可用或无有效样本时显示“—”,不把 RTT 当作视频延时。可通过关闭按钮、Esc 或点击列表外关闭;打开列表会释放远程操控。 - 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 +- 放大视图中点击“开启群控”,以当前设备为主控,同时接管操控;鼠标移动、点击、拖拽、滚轮、键盘和组合键同步到所有其他已连接设备,包含其他分组、分页及未显示在墙内的连接。所有设备使用主控设备映射后的相同坐标和按键,不针对屏幕比例另做适配;新建立的连接也会接收后续输入,未连接和正在断开的设备跳过。点击“关闭群控”先释放各设备已按下的键鼠,再恢复当前设备的单独操控;释放操控、切换设备、窗口失焦或主控设备断开时自动结束群控。群控状态不保存,重新接管需要手动开启。 - 桌面墙连接默认静音,包含新建连接、重连、复用已有连接、添加或导入已有会话,以及切入桌面墙时已有的连接。展开、全屏或接管操控不会自动开启声音;需要声音时,可通过批量“取消静音”或在独立串流窗口主动开启。两个入口共用同一静音状态,主动开启的声音在桌面墙可见时播放,翻页、展开及释放操控保留声音设置;每台设备使用独立音频流同时播放,静音一台不会清空其他设备的声音。新版本对端按会话关闭音频订阅;旧版本发送的音频由控制端丢弃,不通过停止对端的全局声音采集影响其他控制端。 - 放大视图使用一行设备操作栏,显示返回、设备名称、状态和接管/释放操控按钮,右侧保留精简模式及全屏入口;选中的桌面在下方放大显示,同页其他桌面的缩略图统一在上方横向排列,超出宽度时可横向滚动。点击上方缩略图可切换下方桌面,当前桌面不在缩略图中重复显示;同页只有一台设备时隐藏缩略图栏。普通模式缩略图下方以一行显示设备名,长名称省略,不显示状态圆点;不在线设备的缩略图和下方放大预览统一使用灰色,精简模式同样生效;悬停可查看设备名称、ID 和在线状态,精简模式也支持悬停查看。缺少画面或画面停滞时保留状态占位;滚动条单独留出空间。普通和精简模式的缩略图预览区域均固定为 16:9,普通模式的设备名称单独显示在画面下方。普通、精简、宽窗口及全屏均保持上下布局,上下区域之间提供带抓手的分隔条:向上拖动扩大下方主画面,向下拖动同步放大上方缩略图;选中分隔条后也可用上下方向键微调。缩略图始终保持 16:9,并保留主画面的最小可用高度;窗口缩小时临时限制尺寸,恢复窗口后沿用原先尺寸。普通和精简模式共用此尺寸,切换设备后保持,松开鼠标后保存并在重启时恢复;与网格的单元格尺寸独立。同页只有一台设备时同时隐藏缩略图栏和分隔条。拖动分隔条不会切换设备或发送远程键鼠,Esc、窗口失焦及窗口大小变化会结束拖动。调整尺寸后仍保留当前设备所在页。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 - 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。