[feat] add remote display resolution selection to video settings

This commit is contained in:
dijunkun
2026-11-11 05:30:25 +08:00
parent 14fa95beeb
commit a3159b198e
15 changed files with 740 additions and 43 deletions
@@ -1071,6 +1071,9 @@ struct GuiApplication::SlintUi {
std::make_shared<slint::VectorModel<ui::StreamTab>>();
std::shared_ptr<slint::VectorModel<slint::SharedString>> display_model =
std::make_shared<slint::VectorModel<slint::SharedString>>();
std::shared_ptr<slint::VectorModel<slint::SharedString>> resolution_model =
std::make_shared<slint::VectorModel<slint::SharedString>>();
DisplayResolutionInfo resolution_choices;
struct TransferNotification {
std::optional<slint::ComponentHandle<ui::FileTransferNotification>> 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<int>(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<int>(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<slint::SharedString> 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<slint::SharedString> 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();
@@ -799,6 +799,7 @@ void SessionDeviceManager::UpdateInteractions() {
}
owner_.keyboard_.CheckRemoteTimeouts();
owner_.HandleDisplayResolutionChanges();
}
void SessionDeviceManager::DrainCapturedKeyboardInput() {
@@ -0,0 +1,89 @@
#include "runtime/gui_runtime.h"
#include <SDL3/SDL_timer.h>
#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<DisplayResolutionInfo> 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<PendingDisplayResolution> 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
@@ -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 <display_resolution.h>
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<DisplayResolutionInfo> 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<DisplayResolutionInfo> 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
@@ -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<DisplayResolutionInfo> QueryHostDisplayResolutions();
void QueueDisplayResolutionChange(const std::string& id,
PeerEventHandler* source,
const DisplayResolutionChange& change);
void HandleDisplayResolutionChanges();
void CloseRemoteSession(std::shared_ptr<RemoteSession> props);
void CloseAllRemoteSessions();
@@ -247,6 +252,15 @@ class GuiRuntime : protected gui_detail::GuiState {
DisconnectLockPolicy disconnect_lock_;
std::mutex privacy_commands_mutex_;
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
struct PendingDisplayResolution {
std::string remote_id;
std::weak_ptr<PeerEventHandler> source;
DisplayResolutionChange change;
};
std::mutex display_resolution_commands_mutex_;
std::deque<PendingDisplayResolution> display_resolution_commands_;
std::vector<DisplayResolutionInfo> 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;
@@ -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],
@@ -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();
@@ -24,6 +24,7 @@
#include <stream_names.h>
#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<DisplayInfo> 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.
+40 -32
View File
@@ -234,6 +234,7 @@ export component ImageButton inherits Rectangle {
export component CompactComboBox inherits FlatSurface {
in property <[string]> model;
in property <length> maximum-popup-height: root.model.length * 23px + 8px;
in-out property <int> current-index: 0;
in property <bool> enabled: true;
in property <bool> 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();
}
}
}
+48 -2
View File
@@ -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 <string> remote-update: "Update the remote device to the latest version for improved features and performance.";
in-out property <string> remote-update-dismiss: "Got it";
in-out property <string> video-settings: "Video settings";
in-out property <string> remote-resolution: "Resolution";
in-out property <string> video-settings-unavailable: "The remote device does not support live video settings";
in-out property <string> video-quality: "Video quality";
in-out property <string> frame-rate: "Capture frame rate";
@@ -329,6 +331,26 @@ export component StreamWindow inherits Window {
in-out property <int> frame-rate-index: 1;
in-out property <int> adaptation-policy-index: 1;
callback change-video-settings(int, int, int);
in property <[string]> remote-resolutions;
in property <bool> remote-resolution-visible: false;
in property <int> remote-resolution-index: -1;
private property <int> 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 <bool> remote-resolution-enabled: false;
in property <string> remote-resolution-feedback: "";
callback change-remote-resolution(int);
in property <bool> fullscreen-enabled: false;
in property <bool> stats-visible: false;
in property <[NetworkStatsRow]> stats-rows;
@@ -410,7 +432,9 @@ export component StreamWindow inherits Window {
private property <length> shortcut-menu-y: root.control-panel-y(root.shortcut-menu-height);
in-out property <bool> video-menu-open: false;
private property <length> video-menu-width: min(320px, video.width - 8px);
private property <length> video-menu-height: root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px;
private property <length> 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 <length> video-menu-x: min(max(4px, control.x + video-button.x), video.width - root.video-menu-width - 4px);
private property <length> video-menu-y: root.control-panel-y(root.video-menu-height);
private property <bool> 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];
@@ -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]));
@@ -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 <Windows.h>
#include <algorithm>
#include <cmath>
#include <map>
#include <string>
#include <vector>
#include "display_info.h"
#include "display_label.h"
#include <display_resolution.h>
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<DEVMODEW>
WindowsResolutionModes(const wchar_t *device, const DEVMODEW &current,
const WindowsResolutionApi &api = {}) {
std::map<std::pair<int, int>, 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<DEVMODEW> 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, &current))
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, &current, 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<HMONITOR>(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<DisplayResolutionInfo>
QueryWindowsDisplayResolutions(const std::vector<DisplayInfo> &displays) {
std::vector<DisplayResolutionInfo> 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, &current))
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<DisplayInfo> &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
+65
View File
@@ -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 <algorithm>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
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<DisplayResolution> 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
+6
View File
@@ -13,6 +13,7 @@
#include <vector>
#include <remote_cursor_shape.h>
#include <display_resolution.h>
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<DisplayResolutionInfo> display_resolutions;
DisplayResolutionChange resolution_change;
std::string to_json() const;
bool from_json(const std::string& json_string);
+106
View File
@@ -13,6 +13,73 @@ namespace {
using json = nlohmann::json;
json DisplayResolutionsJson(const std::vector<DisplayResolutionInfo>& 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>() > uint64_t(max)))
return false;
result = value.get<int64_t>();
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<std::string>();
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<DisplayResolutionInfo>& 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<ControlType>(object.at("type").get<int>());
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<bool>();
if (!ReadDisplayResolutions(object.at("display_resolutions"),
output.display_resolutions)) return false;
}
break;
}
case ControlType::mouse:
output.m.x = object.at("mouse").at("x").get<float>();
output.m.y = object.at("mouse").at("y").get<float>();
@@ -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 =