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 e6385c9a..aaa27474 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -49,7 +49,14 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) frame.data = reinterpret_cast(data); frame.size = size; frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_); - SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str()); + std::shared_lock lock(owner_.connection_status_mutex_); + for (const auto& [id, status] : owner_.connection_status_) { + if (status == ConnectionStatus::Connected && + owner_.preview_subscriptions_.ShouldSendAudio(id)) + SendAudioFrameToPeer(owner_.peer_, &frame, + owner_.audio_label_.c_str(), id.data(), + id.size()); + } }) {} SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); } @@ -245,6 +252,8 @@ int SessionDeviceManager::InitializeScreenCapturer() { const auto send_started = std::chrono::steady_clock::now(); uint64_t send_errors = 0; for (const std::string &remote_id : connected_remote_ids) { + if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time, + stream_id)) continue; if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(), remote_id.data(), remote_id.size()) != 0) ++send_errors; diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index eb12ea4c..40296ae3 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -130,6 +130,7 @@ void GuiRuntime::HandlePresenceProbeTimeout() { void GuiRuntime::HandleServerControllerDisconnected( const std::string& remote_id, const char* reason) { + preview_subscriptions_.Remove(remote_id); keyboard_.ReleaseRemotePressedKeys(remote_id, reason); #ifdef _WIN32 devices_.ReleaseRemoteMouseButtons(); diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 4b64d930..3766ae5e 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -151,14 +151,67 @@ void PeerEventHandler::OnReceiveDataBuffer( return; } + const std::string remote_id(user_id, user_id_size); std::string json_str(data, size); + const auto preview_json = + json_str.find("\"deskhub_preview") != std::string::npos + ? nlohmann::json::parse(json_str, nullptr, false) + : nlohmann::json{}; + if (preview_json.is_object() && + preview_json.value("type", nlohmann::json{}) == "deskhub_preview") { + if (source_id != runtime->control_data_label_ || + !runtime->IsAuthorizedController(remote_id)) + return; + const auto subscription = PreviewSubscription::Parse(preview_json); + if (!subscription) return; + bool accepted = true; + // Apply encoder quality to this controller only; other controllers retain + // their own demand. Frame gating happens before encoding and transmission. + if (subscription->fps > 0 && runtime->preview_subscriptions_.IsNewer( + remote_id, subscription->request_id)) + accepted = UpdateVideoSettings( + runtime->peer_, remote_id.data(), remote_id.size(), + static_cast(subscription->quality), + subscription->frame_rate, + static_cast( + subscription->EncoderPreference())) == 0; + if (accepted && runtime->preview_subscriptions_.Set(remote_id, *subscription) && + subscription->fps > 0) + RequestAllVideoKeyFrames(runtime->peer_); + const auto reply = nlohmann::json{ + {"type", "deskhub_preview_status"}, + {"version", 1}, + {"request_id", subscription->request_id}, + {"accepted", + accepted}}.dump(); + SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(), + runtime->control_data_label_.c_str(), + remote_id.data(), remote_id.size()); + return; + } + if (preview_json.is_object() && + preview_json.value("type", nlohmann::json{}) == + "deskhub_preview_status") { + if (auto props = runtime->FindRemoteSession(remote_id); + props && source_id == props->control_data_label_) { + try { + if (preview_json.at("version") == 1 && + preview_json.at("request_id").is_number_unsigned() && + preview_json.at("accepted").is_boolean()) { + props->preview_accepted_ = preview_json.at("accepted").get(); + props->preview_ack_ = preview_json.at("request_id").get(); + } + } catch (...) { + } + } + return; + } RemoteAction remote_action{}; if (!remote_action.from_json(json_str)) { LOG_ERROR("Failed to parse RemoteAction JSON payload"); return; } - std::string remote_id(user_id, user_id_size); if (remote_action.type == ControlType::video_settings_status) { if (auto props = runtime->FindRemoteSession(remote_id)) { if (source_id != props->control_data_label_) return; @@ -342,6 +395,8 @@ void PeerEventHandler::OnReceiveDataBuffer( if (props && source_id == props->control_data_label_) { std::lock_guard lock(props->video_settings_mutex_); const bool first_advertisement = !props->video_settings_supported_; + props->preview_subscription_supported_ = + remote_action.i.supports_deskhub_preview; props->video_settings_supported_ = remote_action.i.supports_video_settings; if (first_advertisement && props->video_settings_supported_) { diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 79233932..d3b95c14 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -288,6 +288,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, /*supports_privacy_screen=*/!runtime->unattended_host_, CROSSDESK_VERSION, kClientPlatform); remote_action.i.supports_video_settings = true; + remote_action.i.supports_deskhub_preview = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, remote_action.i.display_list[i]); @@ -327,6 +328,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, } props->net_traffic_stats_.Reset(); props->enable_mouse_control_ = false; + props->preview_subscription_supported_ = false; runtime->ResetRemoteServiceStatus(*props); { std::lock_guard lock(props->video_settings_mutex_); @@ -414,6 +416,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, switch (status) { case ConnectionStatus::Connected: { + runtime->preview_subscriptions_.Remove(remote_id); #if _WIN32 runtime->last_windows_service_status_tick_ = 0; #endif @@ -423,6 +426,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, /*supports_privacy_screen=*/!runtime->unattended_host_, CROSSDESK_VERSION, kClientPlatform); remote_action.i.supports_video_settings = true; + remote_action.i.supports_deskhub_preview = true; for (std::size_t i = 0; i < remote_action.i.display_num; i++) { LOG_INFO("Local display [{}:{}]", i + 1, remote_action.i.display_list[i]); diff --git a/apps/desktop/src/gui/runtime/preview_subscriptions.h b/apps/desktop/src/gui/runtime/preview_subscriptions.h new file mode 100644 index 00000000..8cc015ea --- /dev/null +++ b/apps/desktop/src/gui/runtime/preview_subscriptions.h @@ -0,0 +1,78 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PREVIEW_SUBSCRIPTIONS_H_ +#define _PREVIEW_SUBSCRIPTIONS_H_ + +#include + +#include +#include +#include +#include + +namespace crossdesk { +// Accessed by the network callback and screen-capture callback. Legacy +// controllers have no entry and continue receiving their normal stream. +class PreviewSubscriptions { +public: + using Clock = std::chrono::steady_clock; + bool IsNewer(const std::string &id, uint32_t request_id) { + std::lock_guard lock(mutex_); + const auto found = states_.find(id); + return found == states_.end() || + request_id > found->second.request.request_id; + } + bool Set(const std::string &id, const PreviewSubscription &request) { + std::lock_guard lock(mutex_); + auto &state = states_[id]; + if (request.request_id <= state.request.request_id) + return false; + state.request = request; + state.next.clear(); + return true; + } + bool ShouldSend(const std::string &id, Clock::time_point now = Clock::now(), + const std::string &stream = {}) { + std::lock_guard lock(mutex_); + const auto found = states_.find(id); + if (found == states_.end()) + return true; + auto &state = found->second; + if (!state.request.fps) + return false; + // Interactive streams use the encoder's own 30/60 FPS cadence. A second + // time gate here can drop valid frames because capture timing varies. + if (state.request.fps == 60) + return true; + auto &next = state.next[stream]; + if (now < next) + return false; + next = now + std::chrono::milliseconds(1000 / state.request.fps); + return true; + } + bool ShouldSendAudio(const std::string &id) { + std::lock_guard lock(mutex_); + const auto found = states_.find(id); + return found == states_.end() || found->second.request.audio; + } + void Remove(const std::string &id) { + std::lock_guard lock(mutex_); + states_.erase(id); + } + +private: + struct State { + PreviewSubscription request; + // One display must not consume another display's preview frame budget. + std::unordered_map next; + }; + std::mutex mutex_; + std::unordered_map states_; +}; +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 14715e89..78d907b2 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -20,6 +20,7 @@ #include #include +#include #include #include "display_info.h" #include "cursor_presentation.h" @@ -70,6 +71,12 @@ struct FileTransferState { // Runtime state for one connected or connecting remote endpoint. It groups the // connection, media, input, window and transfer data that share one lifetime. struct RemoteSession { + std::atomic preview_subscription_supported_{false}; + std::atomic preview_ack_{0}; + std::atomic preview_accepted_{false}; + uint32_t preview_request_ = 0; + PreviewSubscription preview_requested_; + std::chrono::steady_clock::time_point preview_request_at_{}; Params params_; PeerPtr* peer_ = nullptr; std::shared_ptr peer_events_; diff --git a/apps/desktop/src/gui/runtime/runtime_state.h b/apps/desktop/src/gui/runtime/runtime_state.h index cc37c37b..3f0dee20 100644 --- a/apps/desktop/src/gui/runtime/runtime_state.h +++ b/apps/desktop/src/gui/runtime/runtime_state.h @@ -25,6 +25,7 @@ #include "common/task_queue_lock_free.h" #include "path_manager.h" #include "runtime/device_presence_cache.h" +#include "runtime/preview_subscriptions.h" #include "runtime/remote_session.h" #include "runtime/session_lifecycle.h" #include "thumbnail.h" @@ -36,6 +37,7 @@ namespace crossdesk::gui_detail { struct InfrastructureState { + PreviewSubscriptions preview_subscriptions_; std::unique_ptr config_center_; ConfigCenter::LANGUAGE localization_language_ = ConfigCenter::LANGUAGE::CHINESE; diff --git a/libs/wire/include/preview_subscription.h b/libs/wire/include/preview_subscription.h new file mode 100644 index 00000000..aec4c88e --- /dev/null +++ b/libs/wire/include/preview_subscription.h @@ -0,0 +1,87 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PREVIEW_SUBSCRIPTION_H_ +#define _PREVIEW_SUBSCRIPTION_H_ + +#include +#include +#include + +namespace crossdesk { +// Versioned independently from the interactive 30/60 FPS video settings. +struct PreviewSubscription { + uint32_t request_id = 0; + int fps = + 0; // 0 pauses encoding/delivery, 1 overview, 8 focus, 60 interactive. + int quality = 0; + int frame_rate = 30; + int preference = 0; + bool audio = false; + + static PreviewSubscription Interactive(int quality, int frame_rate, + int preference, bool audio = false) { + return {0, 60, quality, frame_rate, preference, audio}; + } + + bool SameSettings(const PreviewSubscription &other) const { + return fps == other.fps && quality == other.quality && + frame_rate == other.frame_rate && + EncoderPreference() == other.EncoderPreference() && + audio == other.audio; + } + + int EncoderPreference() const { + // Sparse preview input is intentional. Preserve spatial detail instead of + // applying the interactive frame-rate/keyframe latency budget to it. + return fps == 1 || fps == 8 ? 1 : preference; + } + + nlohmann::json ToJson() const { + return {{"type", "deskhub_preview"}, {"version", 1}, + {"request_id", request_id}, {"fps", fps}, + {"quality", quality}, {"frame_rate", frame_rate}, + {"preference", EncoderPreference()}, {"audio", audio}}; + } + static std::optional Parse(const nlohmann::json &json) { + try { + if (json.at("type") != "deskhub_preview" || json.at("version") != 1) + return {}; + for (const char *key : + {"request_id", "fps", "quality", "frame_rate", "preference"}) + if (!json.at(key).is_number_integer()) + return {}; + const auto id = json.at("request_id").get(); + if (id <= 0 || id > UINT32_MAX) + return {}; + for (const char *key : {"fps", "quality", "frame_rate", "preference"}) { + const auto value = json.at(key).get(); + if (value < 0 || value > 60) + return {}; + } + PreviewSubscription result{static_cast(id), json.at("fps"), + json.at("quality"), json.at("frame_rate"), + json.at("preference")}; + if ((result.fps != 0 && result.fps != 1 && result.fps != 8 && + result.fps != 60) || + result.quality < 0 || result.quality > 2 || + (result.frame_rate != 30 && result.frame_rate != 60) || + result.preference < 0 || result.preference > 2) + return {}; + if (json.contains("audio")) { + if (!json.at("audio").is_boolean()) + return {}; + result.audio = json.at("audio").get(); + } + return result; + } catch (...) { + return {}; + } + } +}; +} // namespace crossdesk + +#endif diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index ed9653d5..8608c206 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -137,6 +137,7 @@ struct HostInfo { // Optional capability; absent in legacy desktop and web host information. bool supports_privacy_screen; bool supports_video_settings; + bool supports_deskhub_preview; }; struct ServiceStatus { diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index ff4fd9ad..3ac5d061 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -26,6 +26,7 @@ void ResetHostInfo(HostInfo& info) { info.bottom = nullptr; info.supports_privacy_screen = false; info.supports_video_settings = false; + info.supports_deskhub_preview = false; } bool AllocateHostDisplays(HostInfo& info, std::size_t count) { @@ -219,7 +220,8 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { {"display_num", action.i.display_num}, {"displays", displays}, {"supports_privacy_screen", action.i.supports_privacy_screen}, - {"supports_video_settings", action.i.supports_video_settings}}; + {"supports_video_settings", action.i.supports_video_settings}, + {"supports_deskhub_preview", action.i.supports_deskhub_preview}}; break; } case ControlType::invalid: @@ -412,6 +414,8 @@ bool RemoteAction::FromJson(const std::string& json_string, privacy_support->is_boolean() && privacy_support->get(); output.i.supports_video_settings = host_info_object.value("supports_video_settings", false); + output.i.supports_deskhub_preview = + host_info_object.value("supports_deskhub_preview", false); const std::string host_name = host_info_object.at("host_name").get(); std::strncpy(output.i.host_name, host_name.c_str(),