From a3159b198e1f611daa1b27d5269677a1cc06bfaa Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 11 Nov 2026 05:30:25 +0800 Subject: [PATCH] [feat] add remote display resolution selection to video settings --- .../src/gui/application/gui_application.cpp | 77 ++++++- .../devices/session_device_manager.cpp | 1 + .../runtime/display_resolution_runtime.cpp | 89 ++++++++ .../gui/runtime/display_resolution_state.h | 75 +++++++ apps/desktop/src/gui/runtime/gui_runtime.h | 14 ++ .../src/gui/runtime/peer_data_callbacks.cpp | 25 ++- .../src/gui/runtime/peer_event_handler.cpp | 9 + apps/desktop/src/gui/runtime/remote_session.h | 3 + apps/desktop/src/gui/ui/common.slint | 72 ++++--- apps/desktop/src/gui/ui/stream_window.slint | 50 ++++- apps/desktop/src/gui/ui/ui_localization.h | 1 + .../screen_capturer/display_resolution_win.h | 190 ++++++++++++++++++ libs/wire/include/display_resolution.h | 65 ++++++ libs/wire/include/remote_action.h | 6 + libs/wire/src/remote_action.cpp | 106 ++++++++++ 15 files changed, 740 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/gui/runtime/display_resolution_runtime.cpp create mode 100644 apps/desktop/src/gui/runtime/display_resolution_state.h create mode 100644 apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h create mode 100644 libs/wire/include/display_resolution.h diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index d2503ba6..994b4d31 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1071,6 +1071,9 @@ struct GuiApplication::SlintUi { std::make_shared>(); std::shared_ptr> display_model = std::make_shared>(); + std::shared_ptr> resolution_model = + std::make_shared>(); + DisplayResolutionInfo resolution_choices; struct TransferNotification { std::optional> window; RemoteSession::FileTransferInfo info; @@ -2280,9 +2283,10 @@ void GuiApplication::BindStreamCallbacks() { }); stream->on_switch_display([this](int index) { auto props = SelectedSession(); - if (!props || index < 0 || - index >= static_cast(props->display_info_list_.size())) { - return; + if (!props || !props->peer_ || index < 0) return; + { + std::lock_guard lock(props->remote_version_mutex_); + if (index >= static_cast(props->display_info_list_.size())) return; } props->selected_display_ = index; { @@ -2394,6 +2398,32 @@ void GuiApplication::BindStreamCallbacks() { props->video_settings_request_tick_ = SDL_GetTicks(); } }); + stream->on_change_remote_resolution([this](int index) { + auto props = SelectedSession(); + if (!props || !props->peer_ || props->closing_ || index < 0 || + props->connection_status_ != MiniRtcConnectionStatus::MiniRtcConnectionConnected || + ui_->resolution_choices.display_id != props->selected_display_ || + index >= int(ui_->resolution_choices.modes.size())) return; + RemoteAction action; + action.type = ControlType::display_resolution; + { + std::lock_guard lock(props->display_resolution_mutex_); + auto& state = props->display_resolution_; + const DisplayResolutionChange selection{props->selected_display_, + ui_->resolution_choices.display_key, ui_->resolution_choices.modes[index]}; + if (!state.Begin(selection, SDL_GetTicks())) return; + action.resolution_change = state.requested; + } + const auto message = action.to_json(); + if (MiniRtcSendReliableDataFrame(props->peer_, message.data(), message.size(), + props->control_data_label_.c_str()) != 0) { + std::lock_guard lock(props->display_resolution_mutex_); + if (props->display_resolution_.requested.request_id == action.resolution_change.request_id) { + props->display_resolution_.pending = false; + props->display_resolution_.failed = true; + } + } + }); stream->on_toggle_audio([this] { auto props = SelectedSession(); if (!props || !props->connection_established_) { @@ -3876,6 +3906,7 @@ void GuiApplication::SyncStreamWindow() { ui_->localized_language = -1; (*ui_->stream)->set_tabs(ui_->tab_model); (*ui_->stream)->set_displays(ui_->display_model); + (*ui_->stream)->set_remote_resolutions(ui_->resolution_model); (*ui_->stream)->set_stats_rows(ui_->stats_model); BindStreamCallbacks(); (*ui_->stream)->window().set_size(slint::LogicalSize({ @@ -4148,14 +4179,44 @@ void GuiApplication::SyncStreamWindow() { (*ui_->stream)->set_stats_visible(props->net_traffic_stats_button_pressed_); std::vector displays; - displays.reserve(props->display_info_list_.size()); - for (size_t index = 0; index < props->display_info_list_.size(); ++index) { - displays.emplace_back(UiText(localization::FormatDisplayLabel( - index, props->display_info_list_[index].name, - localization_language_index_))); + { + std::lock_guard lock(props->remote_version_mutex_); + displays.reserve(props->display_info_list_.size()); + for (size_t index = 0; index < props->display_info_list_.size(); ++index) { + displays.emplace_back(UiText(localization::FormatDisplayLabel( + index, props->display_info_list_[index].name, + localization_language_index_))); + } + if (props->selected_display_ >= int(displays.size())) props->selected_display_ = 0; } ui::UpdateModelRows(*ui_->display_model, std::move(displays)); (*ui_->stream)->set_selected_display(props->selected_display_); + { + std::lock_guard lock(props->display_resolution_mutex_); + auto& state = props->display_resolution_; + state.Expire(SDL_GetTicks()); + ui_->resolution_choices = {}; + int selected = -1; + std::vector resolutions; + for (const auto& display : state.displays) { + if (display.display_id != props->selected_display_) continue; + ui_->resolution_choices = display; + for (const auto& mode : display.modes) { + if (mode == display.current) selected = int(resolutions.size()); + resolutions.emplace_back(UiText(FormatResolution(mode.width, mode.height))); + } + break; + } + const bool visible = status == MiniRtcConnectionStatus::MiniRtcConnectionConnected && + !resolutions.empty(); + ui::UpdateModelRows(*ui_->resolution_model, std::move(resolutions)); + (*ui_->stream)->set_remote_resolution_visible(visible); + (*ui_->stream)->set_remote_resolution_enabled(visible && !state.pending); + (*ui_->stream)->set_remote_resolution_index(selected); + (*ui_->stream)->set_remote_resolution_feedback(UiText( + state.failed ? localization::video_settings_failed[localization_language_index_] + : state.pending ? localization::video_settings_pending[localization_language_index_] : "")); + } const auto net = props->net_traffic_stats_.Load(); const auto codecs = props->net_traffic_stats_.LoadVideoCodecs(); diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.cpp b/apps/desktop/src/gui/features/devices/session_device_manager.cpp index 18c409de..d2367b2c 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -799,6 +799,7 @@ void SessionDeviceManager::UpdateInteractions() { } owner_.keyboard_.CheckRemoteTimeouts(); + owner_.HandleDisplayResolutionChanges(); } void SessionDeviceManager::DrainCapturedKeyboardInput() { diff --git a/apps/desktop/src/gui/runtime/display_resolution_runtime.cpp b/apps/desktop/src/gui/runtime/display_resolution_runtime.cpp new file mode 100644 index 00000000..fa7c091d --- /dev/null +++ b/apps/desktop/src/gui/runtime/display_resolution_runtime.cpp @@ -0,0 +1,89 @@ +#include "runtime/gui_runtime.h" + +#include + +#ifdef _WIN32 +#include "interactive_desktop.h" +#include "platform/windows/screen_capturer/display_resolution_win.h" +#endif + +namespace crossdesk { +namespace { +// EnumDisplaySettings caches its enumeration. Serialize queries on transport +// threads with mode changes on the application thread. +std::mutex native_display_resolution_mutex; +} // namespace + +std::vector GuiRuntime::QueryHostDisplayResolutions() { +#ifdef _WIN32 + std::lock_guard lock(native_display_resolution_mutex); + return QueryWindowsDisplayResolutions(devices_.display_info_list()); +#else + return {}; +#endif +} + +void GuiRuntime::QueueDisplayResolutionChange( + const std::string &id, PeerEventHandler *source, + const DisplayResolutionChange &change) { + const auto events = ControllerEvents(id); + if (events.get() != source || !IsAuthorizedController(id)) + return; + std::lock_guard lock(display_resolution_commands_mutex_); + if (display_resolution_commands_.size() >= 32) + return; + display_resolution_commands_.push_back({id, events, change}); +} + +void GuiRuntime::HandleDisplayResolutionChanges() { + std::deque commands; + { + std::lock_guard lock(display_resolution_commands_mutex_); + commands.swap(display_resolution_commands_); + } + for (const auto &command : commands) { + const auto source = command.source.lock(); + if (!source || !source->IsActive() || + source != ControllerEvents(command.remote_id) || + !IsAuthorizedController(command.remote_id)) + continue; + RemoteAction reply; + reply.type = ControlType::display_resolution_status; + reply.resolution_change = command.change; + reply.resolution_change.accepted = false; +#ifdef _WIN32 + { + std::lock_guard lock(native_display_resolution_mutex); + if (IsCurrentSessionUserDesktopActive()) + reply.resolution_change.accepted = ApplyWindowsDisplayResolution( + devices_.display_info_list(), command.change); + } +#endif + reply.display_resolutions = QueryHostDisplayResolutions(); + const auto message = reply.to_json(); + SendControllerData(command.remote_id, message.data(), message.size(), + control_data_label_.c_str()); + next_display_resolution_poll_ = 0; + } + const auto now = SDL_GetTicks(); + if (now < next_display_resolution_poll_) + return; + next_display_resolution_poll_ = now + 2000; + const auto controllers = ConnectedControllers(); + if (controllers.empty()) + return; + auto current = QueryHostDisplayResolutions(); + if (current == last_display_resolutions_) + return; + last_display_resolutions_ = std::move(current); + // Re-advertise after local changes/hotplug as well as remote requests. The + // optional host-info extension is harmless to pre-feature controllers. + const auto message = BuildHostInformation(); + for (const auto &id : controllers) { + if (IsAuthorizedController(id)) + SendControllerData(id, message.data(), message.size(), + control_data_label_.c_str()); + } +} + +} // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/display_resolution_state.h b/apps/desktop/src/gui/runtime/display_resolution_state.h new file mode 100644 index 00000000..822a870f --- /dev/null +++ b/apps/desktop/src/gui/runtime/display_resolution_state.h @@ -0,0 +1,75 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _DISPLAY_RESOLUTION_STATE_H_ +#define _DISPLAY_RESOLUTION_STATE_H_ + +#include + +namespace crossdesk { + +// Guarded by RemoteSession::display_resolution_mutex_. The current selection +// always comes from the host, never from an unacknowledged request. +struct DisplayResolutionState { + std::vector displays; + DisplayResolutionChange requested; + bool pending = false; + bool failed = false; + uint64_t request_tick = 0; + + bool Begin(const DisplayResolutionChange &selection, uint64_t now) { + if (pending) + return false; + for (const auto &display : displays) { + if (!SupportsDisplayResolution(display, selection)) + continue; + if (display.current == selection.resolution) + return false; + uint32_t next = requested.request_id + 1; + if (next == 0) + next = 1; + requested = {display.display_id, display.display_key, + selection.resolution, next, false}; + pending = true; + failed = false; + request_tick = now; + return true; + } + return false; + } + + void Complete(const DisplayResolutionChange &response, + std::vector actual) { + if (response.request_id != requested.request_id || + response.display_id != requested.display_id || + response.display_key != requested.display_key || + !(response.resolution == requested.resolution)) + return; + displays = std::move(actual); + pending = false; + failed = !response.accepted; + if (response.accepted) { + const auto found = std::find_if( + displays.begin(), displays.end(), [&](const auto &display) { + return display.display_id == response.display_id && + display.display_key == response.display_key && + display.current == response.resolution; + }); + failed = found == displays.end(); + } + } + + void Expire(uint64_t now) { + if (pending && now - request_tick >= 10000) { + pending = false; + failed = true; + } + } +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index 75f26bf8..e6d2e6a2 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -152,6 +152,11 @@ class GuiRuntime : protected gui_detail::GuiState { // any connected controller lacks support, or on for the first automatic one. void ApplyPrivacyAdmission(bool automatic_enable); void QueuePrivacyCommand(const std::string& remote_id, const PrivacyCommand& command); + std::vector QueryHostDisplayResolutions(); + void QueueDisplayResolutionChange(const std::string& id, + PeerEventHandler* source, + const DisplayResolutionChange& change); + void HandleDisplayResolutionChanges(); void CloseRemoteSession(std::shared_ptr props); void CloseAllRemoteSessions(); @@ -247,6 +252,15 @@ class GuiRuntime : protected gui_detail::GuiState { DisconnectLockPolicy disconnect_lock_; std::mutex privacy_commands_mutex_; std::deque> privacy_commands_; + struct PendingDisplayResolution { + std::string remote_id; + std::weak_ptr source; + DisplayResolutionChange change; + }; + std::mutex display_resolution_commands_mutex_; + std::deque display_resolution_commands_; + std::vector last_display_resolutions_; + uint64_t next_display_resolution_poll_ = 0; bool privacy_settings_changed_ = false; uint32_t last_privacy_revision_ = 0; uint64_t last_privacy_status_tick_ = 0; diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index a8a24db2..be82a954 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -280,6 +280,21 @@ void PeerEventHandler::OnReceiveDataBuffer( return; } + if (remote_action.type == ControlType::display_resolution_status) { + if (auto props = runtime->FindRemoteSession(remote_id); + props && source_id == props->control_data_label_ && !props->closing_ && + props->connection_status_ == MiniRtcConnectionStatus::MiniRtcConnectionConnected) { + std::lock_guard lock(props->display_resolution_mutex_); + props->display_resolution_.Complete(remote_action.resolution_change, + std::move(remote_action.display_resolutions)); + } + return; + } + if (remote_action.type == ControlType::display_resolution) { + if (source_id == runtime->control_data_label_ || source_id == kWebControlStream) + runtime->QueueDisplayResolutionChange(remote_id, handler, remote_action.resolution_change); + return; + } if (remote_action.type == ControlType::video_codec_status) { if (auto props = runtime->FindRemoteSession(remote_id); props && source_id == props->control_data_label_ && @@ -460,6 +475,10 @@ void PeerEventHandler::OnReceiveDataBuffer( } if (is_client_mode) { + if (props && source_id == props->control_data_label_) { + std::lock_guard lock(props->display_resolution_mutex_); + props->display_resolution_.displays = std::move(remote_action.display_resolutions); + } if (props && source_id == props->control_data_label_) { std::lock_guard lock(props->remote_version_mutex_); props->remote_host_info_received_ = true; @@ -502,7 +521,11 @@ void PeerEventHandler::OnReceiveDataBuffer( props->remote_host_name_ = std::string(remote_action.i.host_name, remote_action.i.host_name_size); LOG_DEBUG("Remote hostname: [{}]", props->remote_host_name_); - + } + // Preserve legacy hosts' initial display list on their original data + // channel; live topology updates use the reliable control channel. + if (source_id == props->control_data_label_ || props->display_info_list_.empty()) { + props->display_info_list_.clear(); for (int i = 0; i < remote_action.i.display_num; i++) { props->display_info_list_.push_back( DisplayInfo(remote_action.i.display_list[i], diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index ab06bab9..f74cd53c 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -333,6 +333,10 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status, if (!runtime->BeginControllerTrust(remote_id, handler)) return; } else { runtime->ForgetControllerTrust(remote_id, handler); + std::lock_guard lock(runtime->display_resolution_commands_mutex_); + std::erase_if(runtime->display_resolution_commands_, [&](const auto& request) { + return request.remote_id == remote_id; + }); } #ifdef _WIN32 if (runtime->unattended_host_) { @@ -422,6 +426,10 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status, props->video_settings_pending_ = false; props->video_settings_failed_ = false; } + { + std::lock_guard lock(props->display_resolution_mutex_); + props->display_resolution_ = {}; + } { std::lock_guard lock(props->remote_cursor_state_mutex_); props->remote_cursor_state_ = {}; @@ -648,6 +656,7 @@ void PeerEventHandler::OnNetStatusReport( std::string GuiRuntime::BuildHostInformation(bool advertise_trust) { auto action = MakeHostInformation(GetHostName(), devices_.host_display_list(), UserSessionFeaturesAvailable(), CROSSDESK_VERSION, kClientPlatform); + action.display_resolutions = QueryHostDisplayResolutions(); action.i.supports_video_settings = true; action.i.supports_video_codec_status = true; action.i.supports_text_input = devices_.SupportsTextInput(); diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index cefe24d3..acae0985 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -24,6 +24,7 @@ #include #include "display_info.h" #include "features/gallery/preview_resolution.h" +#include "runtime/display_resolution_state.h" #include "cursor_presentation.h" #include "minirtc.h" #include "rendering/video_latency.h" @@ -203,6 +204,8 @@ struct RemoteSession { bool remote_service_available_ = false; std::string remote_interactive_stage_; std::vector display_info_list_; + std::mutex display_resolution_mutex_; + DisplayResolutionState display_resolution_; // Cursor snapshots arrive on the transport callback thread and are applied // by the Slint UI thread. Keep the snapshot atomic as a unit so visibility // and shape cannot briefly come from different protocol messages. diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index 3844e354..25a4f9d1 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -234,6 +234,7 @@ export component ImageButton inherits Rectangle { export component CompactComboBox inherits FlatSurface { in property <[string]> model; + in property maximum-popup-height: root.model.length * 23px + 8px; in-out property current-index: 0; in property enabled: true; in property subtle: false; @@ -331,7 +332,7 @@ export component CompactComboBox inherits FlatSurface { x: 0px; y: root.height + 4px; width: root.width; - height: root.model.length * 23px + 8px; + height: min(root.maximum-popup-height, root.model.length * 23px + 8px); close-policy: close-on-click-outside; FlatSurface { @@ -345,39 +346,46 @@ export component CompactComboBox inherits FlatSurface { outline-color: root.normal-outline-color; border-radius: UiRadius.menu; clip: true; - for choice[index] in root.model: Rectangle { - x: 4px; y: 4px + index * 23px; + Flickable { + x: 4px; y: 4px; width: parent.width - 8px; - height: 23px; - border-radius: UiRadius.control; - background: index == root.current-index - ? (option-touch.pressed ? ButtonStyle.pressed : ButtonStyle.hover) - : option-touch.pressed ? ButtonStyle.pressed : option-touch.has-hover ? ButtonStyle.hover : white; - Text { - x: 6px; width: parent.width - 28px; height: parent.height; text: choice; - color: index == root.current-index ? ControlStyle.selected-text : #202124; - font-weight: index == root.current-index ? 600 : 400; - font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; - } - if index == root.current-index: Text { - x: parent.width - 20px; width: 14px; height: parent.height; - text: FontAwesomeIcons.check; color: UiSurface.focus; - font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px; - horizontal-alignment: center; vertical-alignment: center; - accessible-role: none; - } - option-touch := TouchArea { - enabled: root.enabled; - mouse-cursor: pointer; - clicked => { - popup-open-timer.running = false; - root.popup-animation-enabled = false; - root.popup-progress = 0.0; - if root.enabled && index >= 0 && index < root.model.length { - root.current-index = index; - root.selected(index); + height: max(0px, parent.height - 8px); + viewport-width: self.width; + viewport-height: root.model.length * 23px; + for choice[index] in root.model: Rectangle { + y: index * 23px; + width: parent.width; + height: 23px; + border-radius: UiRadius.control; + background: index == root.current-index + ? (option-touch.pressed ? ButtonStyle.pressed : ButtonStyle.hover) + : option-touch.pressed ? ButtonStyle.pressed : option-touch.has-hover ? ButtonStyle.hover : white; + Text { + x: 6px; width: parent.width - 28px; height: parent.height; text: choice; + color: index == root.current-index ? ControlStyle.selected-text : #202124; + font-weight: index == root.current-index ? 600 : 400; + font-size: root.subtle ? 12px : ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; + } + if index == root.current-index: Text { + x: parent.width - 20px; width: 14px; height: parent.height; + text: FontAwesomeIcons.check; color: UiSurface.focus; + font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } + option-touch := TouchArea { + enabled: root.enabled; + mouse-cursor: pointer; + clicked => { + popup-open-timer.running = false; + root.popup-animation-enabled = false; + root.popup-progress = 0.0; + if root.enabled && index >= 0 && index < root.model.length { + root.current-index = index; + root.selected(index); + } + combo-popup.close(); } - combo-popup.close(); } } } diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index adb5ce9d..51915dcd 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -1,6 +1,7 @@ import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint"; import { UiRadius } from "ui_radius.slint"; -import { ComboBox, ScrollView } from "std-widgets.slint"; +import { ScrollView } from "std-widgets.slint"; +import { SettingsSelect } from "settings_controls.slint"; import { RemoteCursor } from "remote_cursor.slint"; import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiFontStyle, ControlStyle, ButtonStyle, ScrollingText } from "common.slint"; @@ -34,6 +35,7 @@ export global StreamStrings { in-out property remote-update: "Update the remote device to the latest version for improved features and performance."; in-out property remote-update-dismiss: "Got it"; in-out property video-settings: "Video settings"; + in-out property remote-resolution: "Resolution"; in-out property video-settings-unavailable: "The remote device does not support live video settings"; in-out property video-quality: "Video quality"; in-out property frame-rate: "Capture frame rate"; @@ -329,6 +331,26 @@ export component StreamWindow inherits Window { in-out property frame-rate-index: 1; in-out property adaptation-policy-index: 1; callback change-video-settings(int, int, int); + in property <[string]> remote-resolutions; + in property remote-resolution-visible: false; + in property remote-resolution-index: -1; + private property resolution-selection: -1; + changed remote-resolution-index => { root.resolution-selection = root.remote-resolution-index; } + resolution-confirmation-timer := Timer { + interval: 1ms; + running: false; + triggered => { + self.running = false; + root.resolution-selection = root.remote-resolution-index; + } + } + function restore-confirmed-resolution() { + resolution-confirmation-timer.running = true; + resolution-confirmation-timer.restart(); + } + in property remote-resolution-enabled: false; + in property remote-resolution-feedback: ""; + callback change-remote-resolution(int); in property fullscreen-enabled: false; in property stats-visible: false; in property <[NetworkStatsRow]> stats-rows; @@ -410,7 +432,9 @@ export component StreamWindow inherits Window { private property shortcut-menu-y: root.control-panel-y(root.shortcut-menu-height); in-out property video-menu-open: false; private property video-menu-width: min(320px, video.width - 8px); - private property video-menu-height: root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px; + private property video-menu-height: + (root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px) + + (root.remote-resolution-visible ? 57px + (root.remote-resolution-feedback == "" ? 0px : 32px) : 0px); private property video-menu-x: min(max(4px, control.x + video-button.x), video.width - root.video-menu-width - 4px); private property video-menu-y: root.control-panel-y(root.video-menu-height); private property disconnect-menu-open: false; @@ -427,6 +451,7 @@ export component StreamWindow inherits Window { changed control-menu-open => { root.keyboard-focus-changed(!root.control-menu-open && !root.modal-visible && input-focus.has-focus); } + changed selected-display => { root.video-menu-open = false; } changed selected-tab => { root.close-control-menus(); root.cancel-remote-pointer(); @@ -1630,6 +1655,27 @@ export component StreamWindow inherits Window { } VerticalLayout { padding: 10px; spacing: 8px; + if root.remote-resolution-visible: VerticalLayout { + spacing: 4px; + Text { text: StreamStrings.remote-resolution; color: #5c626c; font-size: 11px; height: 18px; } + SettingsSelect { + height: 27px; + width: parent.width; + maximum-popup-height: 240px; + model: root.remote-resolutions; + current-index <=> root.resolution-selection; + enabled: root.remote-resolution-enabled; + selected(index) => { + root.change-remote-resolution(index); + // Restore the host-confirmed value after selection. + root.restore-confirmed-resolution(); + } + } + } + if root.remote-resolution-visible && root.remote-resolution-feedback != "": Text { + text: root.remote-resolution-feedback; wrap: word-wrap; + color: #8b8f95; font-size: 11px; height: 24px; + } VideoSettingRow { label: StreamStrings.video-quality; options: [StreamStrings.quality-low, StreamStrings.quality-medium, StreamStrings.quality-high]; diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 1c167888..66b4fac0 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -274,6 +274,7 @@ inline int ApplyStreamWindowStrings( Text(localization::video_settings_unavailable[language])); strings.set_video_quality(Text(localization::video_quality[language])); strings.set_frame_rate(Text(localization::video_frame_rate[language])); + strings.set_remote_resolution(Text(localization::resolution[language])); strings.set_adaptation_policy( Text(localization::video_adaptation_policy[language])); strings.set_quality_low(Text(localization::video_quality_low[language])); diff --git a/apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h b/apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h new file mode 100644 index 00000000..ebd66c2a --- /dev/null +++ b/apps/desktop/src/platform/windows/screen_capturer/display_resolution_win.h @@ -0,0 +1,190 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _DISPLAY_RESOLUTION_WIN_H_ +#define _DISPLAY_RESOLUTION_WIN_H_ + +#include + +#include +#include +#include +#include +#include + +#include "display_info.h" +#include "display_label.h" +#include + +namespace crossdesk { + +// Injectable Win32 calls keep mode selection and failure handling testable +// without changing the developer's desktop. +struct WindowsResolutionApi { + decltype(&EnumDisplaySettingsW) enumerate = &EnumDisplaySettingsW; + decltype(&ChangeDisplaySettingsExW) change = &ChangeDisplaySettingsExW; +}; + +inline std::vector +WindowsResolutionModes(const wchar_t *device, const DEVMODEW ¤t, + const WindowsResolutionApi &api = {}) { + std::map, DEVMODEW> modes; + for (DWORD index = 0; index < 16384; ++index) { + DEVMODEW mode{}; + mode.dmSize = sizeof(mode); + if (!api.enumerate(device, index, &mode)) + break; + if (mode.dmBitsPerPel != current.dmBitsPerPel || + (mode.dmDisplayFlags & DM_INTERLACED) || mode.dmPelsWidth > 32768 || + mode.dmPelsHeight > 32768 || + !ValidDisplayResolution( + {int(mode.dmPelsWidth), int(mode.dmPelsHeight)})) + continue; + // Keep orientation, placement and scaling while choosing the nearest + // supported refresh rate to the existing desktop mode. + mode.dmPosition = current.dmPosition; + mode.dmDisplayOrientation = current.dmDisplayOrientation; + mode.dmDisplayFixedOutput = current.dmDisplayFixedOutput; + mode.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_BITSPERPEL | + DM_DISPLAYFLAGS | DM_DISPLAYFREQUENCY | + (current.dmFields & (DM_POSITION | DM_DISPLAYORIENTATION | + DM_DISPLAYFIXEDOUTPUT)); + const auto key = + std::make_pair(int(mode.dmPelsWidth), int(mode.dmPelsHeight)); + const auto found = modes.find(key); + if (found == modes.end() || + std::abs(double(mode.dmDisplayFrequency) - current.dmDisplayFrequency) < + std::abs(double(found->second.dmDisplayFrequency) - + current.dmDisplayFrequency)) + modes[key] = mode; + } + std::vector result; + for (auto it = modes.rbegin(); it != modes.rend(); ++it) { + result.push_back(it->second); + if (result.size() == kMaxDisplayResolutionModes) + break; + } + return result; +} + +inline bool ApplyWindowsResolutionMode(const wchar_t *device, + DisplayResolution requested, + const WindowsResolutionApi &api = {}) { + if (!ValidDisplayResolution(requested)) + return false; + DEVMODEW current{}; + current.dmSize = sizeof(current); + if (!api.enumerate(device, ENUM_CURRENT_SETTINGS, ¤t)) + return false; + for (auto mode : WindowsResolutionModes(device, current, api)) { + if (mode.dmPelsWidth != requested.width || + mode.dmPelsHeight != requested.height) + continue; + if (current.dmPelsWidth == requested.width && + current.dmPelsHeight == requested.height) + return true; + if (api.change(device, &mode, nullptr, CDS_TEST, nullptr) != + DISP_CHANGE_SUCCESSFUL) + return false; + // Dynamic change only; never persist remote choices in the user's registry. + if (api.change(device, &mode, nullptr, 0, nullptr) != + DISP_CHANGE_SUCCESSFUL) + return false; + DEVMODEW applied{}; + applied.dmSize = sizeof(applied); + if (api.enumerate(device, ENUM_CURRENT_SETTINGS, &applied) && + applied.dmPelsWidth == requested.width && + applied.dmPelsHeight == requested.height) + return true; + api.change(device, ¤t, nullptr, 0, nullptr); + return false; + } + return false; +} + +inline bool ResolveWindowsResolutionDisplay(const DisplayInfo &display, + std::wstring &device, + std::string &key) { + if (display.width <= 0 || display.height <= 0) + return false; + MONITORINFOEXW monitor{}; + monitor.cbSize = sizeof(monitor); + if (display.handle && + GetMonitorInfoW(static_cast(display.handle), &monitor)) { + if (GetDisplayLabel(monitor.szDevice) != display.name) + return false; + device = monitor.szDevice; + } else { + // A mode change can invalidate HMONITOR before capture publishes its new + // geometry. Resolve the same native output; the interface key below still + // protects change requests against a different monitor occupying the slot. + for (DWORD index = 0; index < 256; ++index) { + DISPLAY_DEVICEW adapter{}; + adapter.cb = sizeof(adapter); + if (!EnumDisplayDevicesW(nullptr, index, &adapter, 0)) + break; + if ((adapter.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) && + GetDisplayLabel(adapter.DeviceName) == display.name) { + device = adapter.DeviceName; + break; + } + } + if (device.empty()) + return false; + } + DISPLAY_DEVICEW target{}; + target.cb = sizeof(target); + if (!EnumDisplayDevicesW(device.c_str(), 0, &target, + EDD_GET_DEVICE_INTERFACE_NAME) || + !target.DeviceID[0]) + return false; + key = WideToUtf8(device) + "|" + WideToUtf8(target.DeviceID); + return true; +} + +inline std::vector +QueryWindowsDisplayResolutions(const std::vector &displays) { + std::vector result; + for (int index = 0; + index < int(displays.size()) && index < kMaxResolutionDisplays; + ++index) { + DisplayResolutionInfo info; + info.display_id = index; + std::wstring device; + if (!ResolveWindowsResolutionDisplay(displays[index], device, + info.display_key)) + continue; + DEVMODEW current{}; + current.dmSize = sizeof(current); + if (!EnumDisplaySettingsW(device.c_str(), ENUM_CURRENT_SETTINGS, ¤t)) + continue; + info.current = {int(current.dmPelsWidth), int(current.dmPelsHeight)}; + if (!ValidDisplayResolution(info.current)) + continue; + for (const auto &mode : WindowsResolutionModes(device.c_str(), current)) + info.modes.push_back({int(mode.dmPelsWidth), int(mode.dmPelsHeight)}); + result.push_back(std::move(info)); + } + return result; +} + +inline bool +ApplyWindowsDisplayResolution(const std::vector &displays, + const DisplayResolutionChange &change) { + if (change.display_id < 0 || change.display_id >= int(displays.size())) + return false; + std::wstring device; + std::string key; + if (!ResolveWindowsResolutionDisplay(displays[change.display_id], device, + key) || + key != change.display_key) + return false; + return ApplyWindowsResolutionMode(device.c_str(), change.resolution); +} + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/libs/wire/include/display_resolution.h b/libs/wire/include/display_resolution.h new file mode 100644 index 00000000..161a10b0 --- /dev/null +++ b/libs/wire/include/display_resolution.h @@ -0,0 +1,65 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _DISPLAY_RESOLUTION_H_ +#define _DISPLAY_RESOLUTION_H_ + +#include +#include +#include +#include +#include + +namespace crossdesk { + +inline constexpr int kMaxResolutionDisplays = 32; +inline constexpr int kMaxDisplayResolutionModes = 256; + +struct DisplayResolution { + int width = 0; + int height = 0; + bool operator==(const DisplayResolution &other) const { + return width == other.width && height == other.height; + } +}; + +inline bool ValidDisplayResolution(const DisplayResolution &mode) { + return mode.width > 0 && mode.height > 0 && mode.width <= 32768 && + mode.height <= 32768; +} + +struct DisplayResolutionInfo { + int display_id = 0; + // Opaque native identity: reject requests for a replaced/hot-unplugged + // screen. + std::string display_key; + DisplayResolution current; + std::vector modes; + bool operator==(const DisplayResolutionInfo &other) const { + return display_id == other.display_id && display_key == other.display_key && + current == other.current && modes == other.modes; + } +}; + +struct DisplayResolutionChange { + int display_id = 0; + std::string display_key; + DisplayResolution resolution; + uint32_t request_id = 0; + bool accepted = false; +}; + +inline bool SupportsDisplayResolution(const DisplayResolutionInfo &display, + const DisplayResolutionChange &change) { + return display.display_id == change.display_id && + display.display_key == change.display_key && + std::find(display.modes.begin(), display.modes.end(), + change.resolution) != display.modes.end(); +} + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index d5138229..d8841f7b 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -13,6 +13,7 @@ #include #include +#include namespace crossdesk { @@ -33,6 +34,8 @@ enum ControlType { video_settings_status = 12, text_input = 13, video_codec_status = 14, + display_resolution = 15, + display_resolution_status = 16, }; enum MouseFlag { @@ -217,6 +220,9 @@ struct RemoteAction { // Committed UTF-8 text. Kept outside the POD union for automatic ownership. std::string text; + // Optional host information extension, also returned with resolution status. + std::vector display_resolutions; + DisplayResolutionChange resolution_change; std::string to_json() const; bool from_json(const std::string& json_string); diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index 4d8d4820..f5461c98 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -13,6 +13,73 @@ namespace { using json = nlohmann::json; +json DisplayResolutionsJson(const std::vector& displays) { + json result = json::array(); + for (const auto& display : displays) { + json modes = json::array(); + for (const auto& mode : display.modes) + modes.push_back({{"width", mode.width}, {"height", mode.height}}); + result.push_back({{"display_id", display.display_id}, + {"display_key", display.display_key}, + {"width", display.current.width}, + {"height", display.current.height}, {"modes", modes}}); + } + return result; +} + +bool ReadInteger(const json& object, const char* key, int64_t min, + int64_t max, int64_t& result) { + const auto& value = object.at(key); + if (!value.is_number_integer() || + (value.is_number_unsigned() && value.get() > uint64_t(max))) + return false; + result = value.get(); + return result >= min && result <= max; +} + +bool ReadResolution(const json& object, DisplayResolution& result) { + int64_t width = 0, height = 0; + if (!ReadInteger(object, "width", 1, 32768, width) || + !ReadInteger(object, "height", 1, 32768, height)) return false; + result = {int(width), int(height)}; + return true; +} + +bool ReadDisplayKey(const json& object, int& id, std::string& key) { + int64_t index = 0; + if (!ReadInteger(object, "display_id", 0, kMaxResolutionDisplays - 1, index)) + return false; + key = object.at("display_key").get(); + if (key.empty() || key.size() > 1024 || key.find('\0') != std::string::npos) + return false; + id = int(index); + return true; +} + +bool ReadDisplayResolutions(const json& values, + std::vector& result) { + result.clear(); + if (!values.is_array() || values.size() > kMaxResolutionDisplays) return false; + for (const auto& value : values) { + DisplayResolutionInfo display; + if (!ReadDisplayKey(value, display.display_id, display.display_key) || + !ReadResolution(value, display.current)) return false; + if (std::any_of(result.begin(), result.end(), [&](const auto& existing) { + return existing.display_id == display.display_id; + })) return false; + const auto& modes = value.at("modes"); + if (!modes.is_array() || modes.size() > kMaxDisplayResolutionModes) return false; + for (const auto& mode : modes) { + DisplayResolution parsed; + if (!ReadResolution(mode, parsed)) return false; + if (std::find(display.modes.begin(), display.modes.end(), parsed) == + display.modes.end()) display.modes.push_back(parsed); + } + result.push_back(std::move(display)); + } + return true; +} + void ResetHostInfo(HostInfo& info) { info.host_name[0] = '\0'; info.host_name_size = 0; @@ -191,6 +258,21 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { case ControlType::video_codec_status: object["video_codec"] = {{"encoder", action.vcs.encoder}}; break; + case ControlType::display_resolution: + case ControlType::display_resolution_status: { + const auto& change = action.resolution_change; + if (!ValidDisplayResolution(change.resolution) || change.request_id == 0 || + change.display_id < 0 || change.display_id >= kMaxResolutionDisplays || + change.display_key.empty() || change.display_key.size() > 1024 || + change.display_key.find('\0') != std::string::npos) return {}; + object["display_resolution"] = { + {"display_id", change.display_id}, {"display_key", change.display_key}, + {"width", change.resolution.width}, {"height", change.resolution.height}, + {"request_id", change.request_id}, {"accepted", change.accepted}}; + if (action.type == ControlType::display_resolution_status) + object["display_resolutions"] = DisplayResolutionsJson(action.display_resolutions); + break; + } case ControlType::video_settings: case ControlType::video_settings_status: if (!ValidVideoSettings(action.vs)) return {}; @@ -254,6 +336,9 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { {"supports_deskhub_fps15", action.i.supports_deskhub_fps15}, {"supports_deskhub_bandwidth", action.i.supports_deskhub_bandwidth}, {"supports_file_browser", action.i.supports_file_browser}}; + if (!action.display_resolutions.empty()) + object["host_info"]["display_resolutions"] = + DisplayResolutionsJson(action.display_resolutions); break; } case ControlType::invalid: @@ -268,8 +353,26 @@ bool RemoteAction::FromJson(const std::string& json_string, bool owns_host_info = false; try { const json object = json::parse(json_string); + output.display_resolutions.clear(); output.type = static_cast(object.at("type").get()); switch (output.type) { + case ControlType::display_resolution: + case ControlType::display_resolution_status: { + const auto& value = object.at("display_resolution"); + auto& change = output.resolution_change; + int64_t request_id = 0; + if (!ReadDisplayKey(value, change.display_id, change.display_key) || + !ReadResolution(value, change.resolution) || + !ReadInteger(value, "request_id", 1, UINT32_MAX, request_id)) return false; + change.request_id = uint32_t(request_id); + change.accepted = false; + if (output.type == ControlType::display_resolution_status) { + change.accepted = value.at("accepted").get(); + if (!ReadDisplayResolutions(object.at("display_resolutions"), + output.display_resolutions)) return false; + } + break; + } case ControlType::mouse: output.m.x = object.at("mouse").at("x").get(); output.m.y = object.at("mouse").at("y").get(); @@ -446,6 +549,9 @@ bool RemoteAction::FromJson(const std::string& json_string, ResetHostInfo(output.i); owns_host_info = true; const auto& host_info_object = object.at("host_info"); + if (const auto modes = host_info_object.find("display_resolutions"); + modes != host_info_object.end() && + !ReadDisplayResolutions(*modes, output.display_resolutions)) return false; const auto platform = host_info_object.find("platform"); if (platform != host_info_object.end() && platform->is_string()) { output.i.platform =