diff --git a/apps/desktop/src/app/main.cpp b/apps/desktop/src/app/main.cpp index de5c650a..12a79daf 100644 --- a/apps/desktop/src/app/main.cpp +++ b/apps/desktop/src/app/main.cpp @@ -19,10 +19,12 @@ #include "platform/windows/autostart_task.h" #include "platform/windows/gui/slint_backend.h" #include "platform/windows/runtime_daemon.h" +#include "platform/windows/service/autostart_control.h" #include "service_host.h" #include "service_lifetime.h" #include "shutdown_order.h" #include "unattended_config.h" +#include "shared_identity.h" #endif #include "config_center.h" @@ -83,7 +85,7 @@ void PrintServiceCliUsage() { << " --service-status Query service runtime status\n" << " --service-help Show this help\n"; std::cout - << " --unattended-enable Enable a separate machine host before login\n" + << " --unattended-enable Enable the shared device identity before login\n" << " --unattended-disable Stop the machine host and disable boot " "startup\n" << " --unattended-status Show machine host status and connection " @@ -138,6 +140,11 @@ void TryStartManagedWindowsService() { return; } + if (crossdesk::IsUnattendedEnabled() && !crossdesk::IsSharedIdentityServiceConfigured()) { + if (!crossdesk::RepairCrossDeskService(service_path.wstring())) + crossdesk::get_logger()->warn("Could not prepare the service for shared-identity migration"); + return; + } crossdesk::StartCrossDeskService(); } @@ -158,84 +165,16 @@ int HandleServiceCliCommand(const std::string& command) { std::cout << crossdesk::ReadUnattendedStatus() << '\n'; return 0; } - const auto service = GetSiblingServiceExecutablePath(); - if (service.empty() || !std::filesystem::exists(service)) { - std::cerr << "Install CrossDesk with its Windows service first.\n"; + const bool enabled = command == "--unattended-enable"; + std::string error; + if (!crossdesk::SetServiceAutostart(enabled, error)) { + std::cerr << error << '\n'; return 1; } - if (command == "--unattended-disable") { - // Disarm boot startup even if stopping the current process fails. - const bool saved = crossdesk::SetUnattendedEnabled(false); - const bool configured = - saved && crossdesk::InstallCrossDeskService(service.wstring()); - const bool stopped = crossdesk::StopCrossDeskService(20000); - std::cout << (configured && stopped - ? "Unattended host disabled.\n" - : "Failed to disable unattended host.\n"); - return configured && stopped ? 0 : 1; - } -#if defined(CROSSDESK_DEBUG) || CROSSDESK_PORTABLE - std::cerr << "Unattended mode requires the installed release build.\n"; - return 1; -#else - const bool was_enabled = crossdesk::IsUnattendedEnabled(); - if (!crossdesk::PrepareUnattendedDirectory(true)) { - std::cerr - << "Cannot create a private machine profile. Existing permissive " - "directories and links are rejected.\n"; - return 1; - } - if (crossdesk::IsCrossDeskServiceInstalled() && - !crossdesk::StopCrossDeskService(20000)) { - std::cerr << "Could not stop the service to configure unattended mode.\n"; - return 1; - } - crossdesk::PathManager paths("CrossDesk"); - crossdesk::ConfigCenter source( - (paths.GetCachePath() / "config.ini").string()); - const auto machine_config = crossdesk::UnattendedDataPath() / "config.ini"; - const auto staged_config = - crossdesk::UnattendedDataPath() / "config.pending.ini"; - // Import only server/media preferences. Never share the GUI identity or - // consume its file-transfer paths in the SYSTEM process. - const auto host = source.IsSelfHosted() ? source.GetSignalServerHost() - : source.GetDefaultServerHost(); - const int port = source.IsSelfHosted() - ? source.GetSignalServerPort() - : source.GetDefaultSignalServerPort(); - bool configured = std::filesystem::exists(machine_config); - if (!configured) { - crossdesk::ConfigCenter destination(staged_config.string()); - configured = - destination.SetServerHost(host) == 0 && - destination.SetServerPort(port) == 0 && - destination.SetSelfHosted(source.IsSelfHosted()) == 0 && - destination.SetTurnMode(source.GetTurnMode()) == 0 && - destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) == - 0 && - destination.SetScreenCaptureMethod( - crossdesk::ScreenCaptureMethod::Auto) == 0 && - destination.SetPrivacyScreen(false) == 0 && - destination.SetSaveRemotePreviews(false) == 0; - } - if (configured && !std::filesystem::exists(machine_config)) { - configured = MoveFileExW(staged_config.c_str(), machine_config.c_str(), - MOVEFILE_WRITE_THROUGH) != FALSE; - } - if (!configured || !crossdesk::SetUnattendedEnabled(true) || - !crossdesk::InstallCrossDeskService(service.wstring()) || - !crossdesk::StartCrossDeskService()) { - crossdesk::SetUnattendedEnabled(was_enabled); - crossdesk::InstallCrossDeskService(service.wstring()); - if (was_enabled) crossdesk::StartCrossDeskService(); - std::cerr << "Failed to enable unattended host.\n"; - return 1; - } - std::cout << "Unattended host enabled for boot startup.\n" - "After it connects, use --unattended-status to obtain its " - "separate device ID and password.\n"; + std::cout << (enabled + ? "Unattended host enabled for boot startup with the GUI's device ID and password.\n" + : "Unattended host disabled.\n"); return 0; -#endif } if (command == "--service-install" || command == "--service-repair") { @@ -356,9 +295,9 @@ static int RunApplication( } #ifdef _WIN32 - if (!crossdesk::platform::MigrateLegacyAutostart("CrossDesk")) { + if (!crossdesk::CleanupLegacyServiceAutostart()) { crossdesk::get_logger()->warn( - "Could not migrate the legacy Windows autostart entry"); + "Could not remove legacy Windows logon startup"); } TryStartManagedWindowsService(); #endif @@ -484,7 +423,8 @@ int main(int argc, char* argv[]) { // Internal service entry point: never elevate an ordinary invocation into // a SYSTEM host, and never open the GUI or the per-user daemon here. if (argc != 4 || !crossdesk::IsLocalSystemProcess() || - !crossdesk::IsUnattendedEnabled()) + (!crossdesk::IsUnattendedEnabled() && !crossdesk::SharedHostIdentity::GuiPresent()) || + !crossdesk::PrepareUnattendedDirectory(false)) return 1; const std::string name(argv[2]); if (name.rfind("Global\\CrossDeskUnattendedStop-", 0) != 0) return 1; @@ -498,6 +438,9 @@ int main(int argc, char* argv[]) { CloseHandle(stop); return 1; } + // Runtime members can log during construction. Select the protected host + // log before constructing Render, not after its logger has already opened. + crossdesk::InitLogger((crossdesk::UnattendedDataPath() / L"logs").string()); crossdesk::Render render; const int result = render.RunUnattended(stop); CloseHandle(stop); @@ -511,7 +454,7 @@ int main(int argc, char* argv[]) { "crossdesk-autostart-cli"); const bool success = std::strcmp(argv[1], "--autostart-migrate") == 0 - ? crossdesk::platform::MigrateLegacyAutostart("CrossDesk") + ? crossdesk::CleanupLegacyServiceAutostart() : crossdesk::platform::RemoveInstalledAutostart("CrossDesk"); return success ? 0 : 1; } @@ -522,8 +465,12 @@ int main(int argc, char* argv[]) { if (argc > 1 && IsServiceCliCommand(argv[1])) { EnsureConsoleForCli(); crossdesk::platform::SingleInstanceGuard cli_instance; - if (AcquireInstance(cli_instance, crossdesk::platform::InstanceRole::kServiceCli) != - crossdesk::platform::InstanceResult::kAcquired) return 1; + // Unattended changes acquire this lease in the shared GUI/CLI entry point. + const bool unattended_change = std::strcmp(argv[1], "--unattended-enable") == 0 || + std::strcmp(argv[1], "--unattended-disable") == 0; + if (!unattended_change && + AcquireInstance(cli_instance, crossdesk::platform::InstanceRole::kServiceCli) != + crossdesk::platform::InstanceResult::kAcquired) return 1; crossdesk::PathManager paths("CrossDesk"); crossdesk::InitLogger(paths.GetLogPath().string(), "crossdesk-service-cli"); return HandleServiceCliCommand(argv[1]); diff --git a/apps/desktop/src/common/credential_cache.h b/apps/desktop/src/common/credential_cache.h new file mode 100644 index 00000000..f12e6587 --- /dev/null +++ b/apps/desktop/src/common/credential_cache.h @@ -0,0 +1,71 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-08 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _CREDENTIAL_CACHE_H_ +#define _CREDENTIAL_CACHE_H_ + +#include +#include + +namespace crossdesk::credential_cache { +inline constexpr uint32_t kMagic = 0x33444358; +inline constexpr uint32_t kVersion = 3; + +struct CacheV1 { + char client_id_with_password[17]; + int language; + // Reserved legacy slots: retain the on-disk cache layout. + int legacy_video_quality; + int legacy_video_frame_rate; + int video_encode_format; + bool legacy_enable_hardware_video_codec; + bool enable_turn; + bool enable_srtp; + unsigned char key[16]; + unsigned char iv[16]; +}; + +struct CacheV2 { + char client_id_with_password[17]; + int language; + // Reserved legacy slots: retain the on-disk cache layout. + int legacy_video_quality; + int legacy_video_frame_rate; + int video_encode_format; + bool legacy_enable_hardware_video_codec; + bool enable_turn; + bool enable_srtp; + unsigned char key[16]; + unsigned char iv[16]; + char self_hosted_id[17]; +}; + +struct CacheV3 { + uint32_t magic; + uint32_t version; + CacheV2 base; + char pending_identity[17]; + char pending_server_host[256]; + int pending_server_port; + bool pending_self_hosted; + char pending_request_id[64]; + uint32_t checksum; +}; + + +inline uint32_t Checksum(const void* data, size_t size) { + const auto* bytes = static_cast(data); + uint32_t hash = 2166136261u; + for (size_t i = 0; i < size; ++i) { hash ^= bytes[i]; hash *= 16777619u; } + return hash; +} +inline bool Valid(const CacheV3& cache) { + return cache.magic == kMagic && cache.version == kVersion && + cache.checksum == Checksum(&cache, offsetof(CacheV3, checksum)); +} +} // namespace crossdesk::credential_cache + +#endif \ No newline at end of file diff --git a/apps/desktop/src/config_center/config_center.cpp b/apps/desktop/src/config_center/config_center.cpp index 6385c54d..cbd7f61e 100644 --- a/apps/desktop/src/config_center/config_center.cpp +++ b/apps/desktop/src/config_center/config_center.cpp @@ -14,6 +14,7 @@ #ifdef _WIN32 #include "platform/windows/runtime_daemon.h" +#include "platform/windows/service/autostart_control.h" #endif #if defined(__linux__) && !defined(__OHOS__) @@ -92,13 +93,8 @@ int ConfigCenter::Load() { ? CONNECTION_MODE::DIRECT_IP : CONNECTION_MODE::DEVICE_ID; - const long screen_capture_method_value = ini_->GetLongValue( - section_, "screen_capture_method", - static_cast(ScreenCaptureMethod::Auto)); - screen_capture_method_ = IsValidScreenCaptureMethod(screen_capture_method_value) - ? static_cast( - screen_capture_method_value) - : ScreenCaptureMethod::Auto; + // Capture always follows DXGI -> WGC -> GDI; discard old forced choices. + persist_config_migration |= ini_->Delete(section_, "screen_capture_method"); video_encode_format_ = static_cast( ini_->GetLongValue(section_, "video_encode_format", @@ -237,8 +233,6 @@ int ConfigCenter::Save() { ini_->SetValue(section_, "connection_mode", connection_mode_ == CONNECTION_MODE::DIRECT_IP ? "ip" : "id"); ini_->SetBoolValue(section_, "enable_ip_direct", enable_ip_direct_); - ini_->SetLongValue(section_, "screen_capture_method", - static_cast(screen_capture_method_)); ini_->SetLongValue(section_, "video_encode_format", static_cast(video_encode_format_)); ini_->SetLongValue(section_, "turn_mode", static_cast(turn_mode_)); @@ -282,6 +276,56 @@ int ConfigCenter::Save() { return 0; } +std::string ConfigCenter::ExportSharedHostConfig() const { + CSimpleIniA host; + host.SetUnicode(true); + host.SetLongValue(section_, "shared_identity_config_version", 1); + host.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_); + host.SetValue(section_, "signal_server_host", signal_server_host_.c_str()); + host.SetLongValue(section_, "signal_server_port", signal_server_port_); + host.SetLongValue(section_, "turn_mode", static_cast(turn_mode_)); + host.SetLongValue(section_, "video_encode_format", static_cast(video_encode_format_)); + host.SetBoolValue(section_, "trusted_devices_only", trusted_devices_only_.load()); + host.SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load()); + host.SetBoolValue(section_, "enable_privacy_screen", false); + host.SetBoolValue(section_, "save_remote_previews", false); + host.SetBoolValue(section_, "enable_ip_direct", false); + std::string contents; + host.Save(contents); + return contents; +} + +int ConfigCenter::ApplySharedHostConfig(const std::string& contents) { + CSimpleIniA host; + host.SetUnicode(true); + if (host.LoadData(contents.c_str(), contents.size()) < 0 || + host.GetLongValue(section_, "shared_identity_config_version", 0) != 1) return -1; + const bool self_hosted = host.GetBoolValue(section_, "enable_self_hosted", false); + const std::string server = host.GetValue(section_, "signal_server_host", ""); + const long port = host.GetLongValue(section_, "signal_server_port", 0); + const long turn = host.GetLongValue(section_, "turn_mode", static_cast(turn_mode_)); + const long codec = host.GetLongValue(section_, "video_encode_format", static_cast(video_encode_format_)); + const bool trusted = host.GetBoolValue(section_, "trusted_devices_only", false); + const bool lock = host.GetBoolValue(section_, "lock_on_disconnect", true); + MiniRtcSignalServerAddress address{}; + if (!IsValidTurnModeValue(turn) || codec < 0 || codec > 1 || + (self_hosted && (port < 1 || port > 65535 || + MiniRtcParseSignalServerAddress(server.data(), server.size(), &address) != 0))) return -1; + if (StoreValues({{"enable_self_hosted", self_hosted ? "true" : "false"}, + {"signal_server_host", server}, {"signal_server_port", std::to_string(port)}, + {"turn_mode", std::to_string(turn)}, {"video_encode_format", std::to_string(codec)}, + {"trusted_devices_only", trusted ? "true" : "false"}, + {"lock_on_disconnect", lock ? "true" : "false"}}) != 0) return -1; + enable_self_hosted_ = self_hosted; + signal_server_host_ = server; + signal_server_port_ = static_cast(port); + turn_mode_ = static_cast(turn); + video_encode_format_ = static_cast(codec); + trusted_devices_only_.store(trusted); + lock_on_disconnect_.store(lock); + return 0; +} + // setters int ConfigCenter::SetLanguage(LANGUAGE language) { @@ -319,20 +363,6 @@ int ConfigCenter::SetIpDirectEnabled(bool enabled) { return 0; } -int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) { - if (!IsValidScreenCaptureMethod(static_cast(method))) { - return -1; - } - ini_->SetLongValue(section_, "screen_capture_method", static_cast(method)); - if (CommitIni() < 0) { - ini_->SetLongValue(section_, "screen_capture_method", - static_cast(screen_capture_method_)); - return -1; - } - screen_capture_method_ = method; - return 0; -} - int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) { if (format != VIDEO_ENCODE_FORMAT::H264 && format != VIDEO_ENCODE_FORMAT::AV1) return -1; if (StoreValues({{"video_encode_format", std::to_string(static_cast(format))}}) != 0) return -1; @@ -394,7 +424,18 @@ int ConfigCenter::SetSelfHosted(bool enabled) { } int ConfigCenter::SetAutostart(bool enabled) { + std::string error = "Operating system startup registration failed"; +#ifdef _WIN32 + if (!SupportsServiceAutostart()) return enabled ? -1 : 0; + const bool previous = IsServiceAutostartEnabled(); + const bool update_registration = true; + const auto configure = [&error](bool value) { return SetServiceAutostart(value, error); }; +#else const bool previous = IsAutostartEnabled("CrossDesk"); + const bool update_registration = enabled != previous; + const auto configure = [](bool value) { + return value ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"); + }; #if defined(__linux__) && !defined(__OHOS__) // Repair old deb entries even when the saved boolean is unchanged, keeping // desktop-managed disabled flags and custom commands intact. @@ -403,24 +444,14 @@ int ConfigCenter::SetAutostart(bool enabled) { return -1; } #endif - // Refresh the executable path and remove legacy registrations even when the - // boolean state is unchanged. In particular, a Run entry no longer counts - // as enabled for an elevated Windows build, but still needs deleting. -#ifdef _WIN32 - const bool update_registration = true; -#else - const bool update_registration = enabled != previous; #endif - if (update_registration && - !(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) { - LOG_ERROR("SetAutostart failed"); + if (update_registration && !configure(enabled)) { + LOG_ERROR("Cannot configure autostart: {}", error); return -1; } if (StoreValues({{"enable_autostart", enabled ? "true" : "false"}}) != 0) { - if (enabled != previous) { - if (previous) EnableAutostart("CrossDesk"); - else DisableAutostart("CrossDesk"); - } + if (enabled != previous && !configure(previous)) + LOG_ERROR("Cannot restore autostart: {}", error); return -1; } enable_autostart_ = enabled; @@ -554,10 +585,6 @@ ConfigCenter::CONNECTION_MODE ConfigCenter::GetConnectionMode() const { bool ConfigCenter::IsIpDirectEnabled() const { return enable_ip_direct_; } -ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const { - return screen_capture_method_; -} - ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const { return video_encode_format_; } @@ -588,7 +615,13 @@ int ConfigCenter::GetDefaultSignalServerPort() const { bool ConfigCenter::IsSelfHosted() const { return enable_self_hosted_; } -bool ConfigCenter::IsEnableAutostart() const { return enable_autostart_; } +bool ConfigCenter::IsEnableAutostart() const { +#ifdef _WIN32 + return IsServiceAutostartEnabled(); +#else + return enable_autostart_; +#endif +} bool ConfigCenter::IsEnableDaemon() const { return enable_daemon_; } diff --git a/apps/desktop/src/config_center/config_center.h b/apps/desktop/src/config_center/config_center.h index 84469dca..42be575b 100644 --- a/apps/desktop/src/config_center/config_center.h +++ b/apps/desktop/src/config_center/config_center.h @@ -14,7 +14,6 @@ #include #include -#include "screen_capturer/screen_capture_method.h" namespace crossdesk { @@ -42,7 +41,6 @@ class ConfigCenter { int SetLanguage(LANGUAGE language); int SetConnectionMode(CONNECTION_MODE mode); int SetIpDirectEnabled(bool enabled); - int SetScreenCaptureMethod(ScreenCaptureMethod method); int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format); int SetTurnMode(TURN_MODE turn_mode); int SetForceRelay(bool force_relay); @@ -67,7 +65,6 @@ class ConfigCenter { LANGUAGE GetLanguage() const; CONNECTION_MODE GetConnectionMode() const; bool IsIpDirectEnabled() const; - ScreenCaptureMethod GetScreenCaptureMethod() const; VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const; TURN_MODE GetTurnMode() const; bool IsForceRelay() const; @@ -91,6 +88,9 @@ class ConfigCenter { int Load(); int Save(); + // Only connection/admission preferences cross into the SYSTEM host profile. + std::string ExportSharedHostConfig() const; + int ApplySharedHostConfig(const std::string& contents); private: int CommitIni(); @@ -104,7 +104,6 @@ class ConfigCenter { LANGUAGE language_ = LANGUAGE::SYSTEM; CONNECTION_MODE connection_mode_ = CONNECTION_MODE::DEVICE_ID; bool enable_ip_direct_ = true; - ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto; VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264; TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP; std::string signal_server_host_ = ""; diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index f27c56e0..6aeb53ed 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -62,6 +62,9 @@ #include "platform/windows/gui/shutdown_session_guard.h" #include "platform/windows/gui/tray/win_tray.h" #include "platform/windows/gui/window_icons.h" +#include "platform/windows/service/autostart_control.h" +#include "platform/windows/service/interactive_desktop.h" +#include "platform/windows/service/unattended_config.h" #elif defined(__APPLE__) #include "platform/macos/gui/tray/mac_tray.h" #include "platform/window_drag.h" @@ -1172,6 +1175,19 @@ int GuiApplication::Run() { announcements_.SetStorageDirectory(path_manager_->GetCachePath() / "announcement_reads"); config_center_ = std::make_unique(cache_path_ + "/config.ini"); +#ifdef _WIN32 + if (SupportsServiceAutostart() && SharedHostIdentity::Configured()) { + // User-session features attach after unlock; the persistent host keeps + // its transport, capture and input across this transition. + if (IsUnattendedEnabled() && !IsCurrentSessionUserDesktopActive()) { + LOG_INFO("Waiting for the unlocked user desktop before attaching GUI features"); + do { + Sleep(250); + } while (IsUnattendedEnabled() && !IsCurrentSessionUserDesktopActive()); + } + if (!PrepareSharedGuiIdentity(true)) return -1; + } +#endif InitializeTrustedDevices(path_manager_->GetConfigPath() / "trusted-devices"); #ifdef __APPLE__ StartMacPermissionMigration(false); @@ -1198,6 +1214,13 @@ int GuiApplication::Run() { #else InitializeSettings(); #endif +#ifdef _WIN32 + // Upgrade an already-enabled standalone host before the GUI opens its peer. + if (SupportsServiceAutostart() && IsUnattendedEnabled() && !shared_identity_) { + if (!PrepareSharedGuiIdentity(false) || config_center_->SetAutostart(true) != 0) + return -1; + } +#endif #if defined(__linux__) && !defined(__OHOS__) HeadlessConsole::Instance().BindSettings( config_center_.get(), [this] { @@ -1572,6 +1595,10 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) { } main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_self_hosted_enabled(enable_self_hosted_); +#ifdef _WIN32 + // The machine setting can also be changed by the CLI or another admin. + enable_autostart_ = config_center_->IsEnableAutostart(); +#endif main->set_autostart_enabled(enable_autostart_); #if defined(__OHOS__) main->set_autostart_system_managed(true); @@ -1584,11 +1611,6 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) { main->set_privacy_local_unlock_allowed(config_center_->IsPrivacyLocalUnlockAllowed()); main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect()); main->set_trusted_devices_only(config_center_->IsTrustedDevicesOnly()); -#ifdef _WIN32 - main->set_capture_method_visible(true); - main->set_capture_method_index( - static_cast(config_center_->GetScreenCaptureMethod())); -#endif #if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__)) main->set_privacy_setting_available(true); main->set_lock_on_disconnect_available(true); @@ -2498,6 +2520,9 @@ void GuiApplication::Tick() { if (ShutdownDrained()) slint::quit_event_loop(); return; } +#ifdef _WIN32 + TickLocalHost(); +#endif TickLan(); TickControllerTrust(); if (LanAvailable()) { @@ -2811,6 +2836,7 @@ void GuiApplication::ShareLocalCursorState() { std::shared_lock lock(connection_status_mutex_); connected_controllers.reserve(connection_status_.size()); for (const auto& [remote_id, status] : connection_status_) { + if (managed_host_frontend_ && remote_id.rfind("lan/", 0) != 0) continue; if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) { connected_controllers.push_back(remote_id); } @@ -2953,7 +2979,7 @@ void GuiApplication::ShareLocalCursorState() { if (!delivery.has_sent || delivery.last_sent.render_mode != outgoing.render_mode || delivery.last_sent.hidden_reason != outgoing.hidden_reason) { - LOG_DEBUG("Send cursor presentation to [{}]: mode={}, reason={}", + LOG_INFO("Send cursor presentation to [{}]: mode={}, reason={}", recipient.remote_id, static_cast(outgoing.render_mode), static_cast(outgoing.hidden_reason)); } @@ -2967,6 +2993,18 @@ void GuiApplication::ShareLocalCursorState() { } bool GuiApplication::RequestPasswordChange(const std::string &password) { +#ifdef _WIN32 + if (managed_host_frontend_) { + if (password.size() != 6 || password.find_first_not_of( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") != std::string::npos) + return false; + if (!SendLocalHostCommand({{"op", "password"}, {"password", password}})) { + offline_warning_text_ = localization::signal_disconnected[localization_language_index_]; + show_offline_warning_window_ = true; + } + return true; + } +#endif const auto notify = [](const std::string& message) { #if defined(__linux__) && !defined(__OHOS__) HeadlessConsole::Instance().NotifyKey(message.c_str()); @@ -3035,7 +3073,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) { {"request_id", request_id}, {"new_password", password}}; const std::string message = request.dump(); - if (MiniRtcSendSignalMessage(peer_, message.data(), message.size()) != 0) { + if (PeerSendSignalMessage(peer_, message.data(), message.size()) != 0) { { std::lock_guard lock(password_change_mutex_); password_change_pending_ = false; @@ -3056,6 +3094,7 @@ bool GuiApplication::RequestPasswordChange(const std::string &password) { } void GuiApplication::HandlePasswordChangeResult() { + if (managed_host_frontend_) return; bool succeeded = false; bool uncertain = false; std::string error; @@ -3129,6 +3168,7 @@ void GuiApplication::HandlePasswordChangeResult() { } void GuiApplication::HandleCredentialRecovery() { + if (managed_host_frontend_) return; CredentialRecoveryAction action; { std::lock_guard lock(password_change_mutex_); @@ -3166,7 +3206,7 @@ void GuiApplication::HandleCredentialRecovery() { } const auto message = nlohmann::json{{"type", "change_password"}, {"request_id", request_id}, {"new_password", password}}.dump(); - if (MiniRtcSendSignalMessage(peer_, message.data(), message.size()) != 0) { + if (PeerSendSignalMessage(peer_, message.data(), message.size()) != 0) { std::lock_guard lock(password_change_mutex_); password_change_result_ready_ = true; password_change_result_uncertain_ = true; @@ -3221,6 +3261,14 @@ void GuiApplication::UpdateLocalization() { static std::vector shutdown_reasons(localization::GetSupportedLanguages().size()); ui_->shutdown_reason = localization::GetWindowsLocalizedLabel( language, "shutdown_reason", L"Remote session active.", shutdown_reasons); + const bool available = SupportsServiceAutostart(); + for (const auto& main : {ui_->main, ui_->wall}) { + main->set_service_autostart_platform(true); + main->set_autostart_available(available); + main->set_service_autostart_hint(UiText(localization::Literal(available + ? "开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。" + : "开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", language))); + } #endif ui_localization::ApplyMainWindowStrings(ui_->main, language); ui_localization::ApplyStreamWindowStrings(ui_->main, language); @@ -3242,7 +3290,7 @@ void GuiApplication::SyncAnnouncements() { const auto request = announcements_.TakePendingRequest(); if (!request.is_null()) { const auto payload = request.dump(); - if (!peer_ || MiniRtcSendSignalMessage(peer_, payload.data(), payload.size()) != 0) + if (!peer_ || PeerSendSignalMessage(peer_, payload.data(), payload.size()) != 0) announcements_.Failed(request["request_id"]); } if (ui_->announcement_version == announcements_.Version()) return; @@ -4400,13 +4448,12 @@ void GuiApplication::BindTrustedDeviceCallbacks(bool gallery_window) { } // The Add action approves the current input. Import only updates the // list; a live connection must still complete its own authentication. - if (trusted_devices_->Contains(device->public_key)) { - main->set_trusted_devices_notice(message("已在可信列表中")); - } else { - trusted_devices_->Add(*device); - main->set_trusted_devices_notice(message("已加入可信设备。")); - } - main->set_trust_pairing_input(""); + const bool existing = trusted_devices_->Contains(device->public_key); + if (existing ? PublishSharedIdentity() : AddTrustedDevice(*device)) { + main->set_trusted_devices_notice(message(existing + ? "已在可信列表中" : "已加入可信设备。")); + main->set_trust_pairing_input(""); + } else set_error("无法更新可信设备,请检查列表容量和存储权限后重试。"); } else if ((action == "add" && !TrustController(std::string(id), std::string(key))) || (action == "remove" && !RemoveTrustedDevice(std::string(key)))) { set_error("无法更新可信设备,请检查列表容量和存储权限后重试。"); @@ -4859,6 +4906,12 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) { } bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { +#ifdef _WIN32 + // A later setter can fail after earlier values were persisted. Reconcile + // with the host on the next status even when no config request was sent. + if (managed_host_frontend_) + local_host_status_.config_dirty = local_host_status_.identity_dirty = true; +#endif const auto previous_server = GetSignalServerSettings(*config_center_); const bool previous_ip_enabled = config_center_->IsIpDirectEnabled(); const auto previous_connection_mode = config_center_->GetConnectionMode(); @@ -4983,11 +5036,19 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { config_center_->SetForceRelay(main->get_force_relay_enabled()); main->set_force_relay_enabled(config_center_->IsForceRelay()); #if !defined(__OHOS__) - if (config_center_->SetAutostart(enable_autostart_) != 0) { + bool identity_ready = true; +#ifdef _WIN32 + identity_ready = !enable_autostart_ || PrepareSharedGuiIdentity(false); +#endif + if (!identity_ready || config_center_->SetAutostart(enable_autostart_) != 0) { enable_autostart_ = config_center_->IsEnableAutostart(); main->set_autostart_enabled(enable_autostart_); main->set_settings_save_error(UiText( +#ifdef _WIN32 + localization::Literal("无法配置无人值守服务,请检查管理员权限和安装文件。", localization_language_index_))); +#else localization::Literal("保存失败,请重试", localization_language_index_))); +#endif return false; } #endif @@ -5005,25 +5066,6 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { LOG_ERROR("Failed to clear saved remote previews"); } reload_recent_connections_ = true; -#ifdef _WIN32 - if (!HasActiveSession()) { - const auto previous_capture_method = config_center_->GetScreenCaptureMethod(); - const auto capture_method = static_cast( - std::clamp(main->get_capture_method_index(), 0, 3)); - if (config_center_->SetScreenCaptureMethod(capture_method) != 0) { - LOG_ERROR("Failed to save screen capture method"); - } else if (capture_method != previous_capture_method) { - // Reconfigure only capture; its backend does not own signaling. - devices_.StopScreenCapturer(); - screen_capturer_is_started_ = false; - if (devices_.InitializeScreenCapturer() != 0) { - LOG_ERROR("Failed to apply screen capture method"); - } - } - } - main->set_capture_method_index( - static_cast(config_center_->GetScreenCaptureMethod())); -#endif #if defined(_WIN32) || defined(__APPLE__) || (defined(__linux__) && !defined(__OHOS__)) if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) != 0) { @@ -5053,6 +5095,11 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { return false; } TickControllerTrust(); + if (!PublishSharedIdentity(true)) { + main->set_settings_save_error(UiText(localization::Literal( + "保存失败,请重试", localization_language_index_))); + return false; + } ApplySettingsFromConfig(previous_server != GetSignalServerSettings(*config_center_)); ResetSettingsUi(gallery_window); @@ -5107,7 +5154,7 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) { // Update the listening peer for incoming connections and the template // copied by ConnectTo for outgoing connections. Existing sessions retain // the codec/ICE snapshot with which they negotiated. - if (!peer_ || MiniRtcUpdateConnectionSettings(peer_, true, av1, turn) == 0) { + if (!peer_ || PeerUpdateConnectionSettings(peer_, true, av1, turn) == 0) { params_.av1_encoding = av1; params_.turn_mode = turn; } else { diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index 93fb0411..8f3bdab0 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -58,6 +58,9 @@ private: void BindStreamCallbacks(); void BindServerCallbacks(); void Tick(); +#ifdef _WIN32 + void TickLocalHost(); +#endif void PollUpdateCheck(); void ShareLocalCursorState(); bool RequestPasswordChange(const std::string &password); diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 410f8cda..78a9519e 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -539,6 +539,9 @@ inline constexpr TranslationRow kTranslationRows[] = { {"服务已修复并启动", {"服务已修复并启动", "Service repaired and started", "Служба восстановлена и запущена", "服務已修復並啟動", "サービスを修復し、起動しました", "서비스를 복구하고 시작했습니다", "Dienst repariert und gestartet", "Service réparé et démarré", "Servicio reparado e iniciado", "Serviço reparado e iniciado"}}, {"修复失败,请检查管理员权限和便携版文件后重试", {"修复失败,请检查管理员权限和便携版文件后重试", "Repair failed. Check administrator permission and portable package files, then retry.", "Восстановление не удалось. Проверьте права администратора и файлы портативной версии, затем повторите попытку.", "修復失敗,請檢查管理員權限和可攜版檔案後重試", "修復に失敗しました。管理者権限とポータブル版のファイルを確認して再試行してください。", "복구하지 못했습니다. 관리자 권한과 포터블 버전 파일을 확인한 후 다시 시도하세요.", "Reparatur fehlgeschlagen. Prüfen Sie die Administratorrechte und die Dateien der portablen Version und versuchen Sie es erneut.", "Échec de la réparation. Vérifiez les droits administrateur et les fichiers de la version portable, puis réessayez.", "Error al reparar. Compruebe los permisos de administrador y los archivos de la versión portátil e inténtelo de nuevo.", "Falha no reparo. Verifique a permissão de administrador e os arquivos da versão portátil e tente novamente."}}, {"远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。", {"远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。", "The remote Windows service is unavailable. Lock-screen control will not work. Install or repair the CrossDesk service on the remote device.", "Служба Windows на удалённом устройстве недоступна. Управление экраном блокировки не работает. Установите или восстановите службу CrossDesk на удалённом устройстве.", "遠端 Windows 服務無法使用,鎖定畫面時無法操作。請在遠端安裝或修復 CrossDesk 服務。", "リモートの Windows サービスが利用できないため、ロック画面を操作できません。リモート端末で CrossDesk サービスをインストールまたは修復してください。", "원격 Windows 서비스를 사용할 수 없어 잠금 화면을 제어할 수 없습니다. 원격 장치에서 CrossDesk 서비스를 설치하거나 복구하세요.", "Der Windows-Dienst auf dem entfernten Gerät ist nicht verfügbar. Der Sperrbildschirm kann nicht gesteuert werden. Installieren oder reparieren Sie dort den CrossDesk-Dienst.", "Le service Windows distant est indisponible. Le contrôle de l’écran verrouillé ne fonctionnera pas. Installez ou réparez le service CrossDesk sur l’appareil distant.", "El servicio de Windows remoto no está disponible. No se podrá controlar la pantalla de bloqueo. Instale o repare el servicio CrossDesk en el dispositivo remoto.", "O serviço do Windows remoto está indisponível. Não será possível controlar a tela de bloqueio. Instale ou repare o serviço CrossDesk no dispositivo remoto."}}, + {"开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", {"开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", "Boot startup requires the installed Release build. Portable and Debug builds do not support the unattended service.", "Автозапуск требует установленной сборки Release. Переносная версия и Debug не поддерживают автономную службу.", "開機自啟需要安裝版 Release;可攜版和 Debug 不支援無人值守服務。", "自動起動にはインストール版 Release が必要です。ポータブル版と Debug は無人アクセスサービスに対応していません。", "자동 시작에는 설치형 Release 버전이 필요합니다. 포터블 및 Debug 버전은 무인 접속 서비스를 지원하지 않습니다.", "Der Systemstart erfordert die installierte Release-Version. Portable- und Debug-Versionen unterstützen den unbeaufsichtigten Dienst nicht.", "Le démarrage automatique nécessite la version Release installée. Les versions portable et Debug ne prennent pas en charge le service sans surveillance.", "El inicio automático requiere la versión Release instalada. Las versiones portátil y Debug no admiten el servicio desatendido.", "A inicialização automática exige a versão Release instalada. As versões portátil e Debug não oferecem suporte ao serviço não assistido."}}, + {"无法配置无人值守服务,请检查管理员权限和安装文件。", {"无法配置无人值守服务,请检查管理员权限和安装文件。", "Could not configure the unattended service. Check administrator privileges and installation files.", "Не удалось настроить автономную службу. Проверьте права администратора и файлы установки.", "無法設定無人值守服務,請檢查管理員權限和安裝檔案。", "無人アクセスサービスを設定できません。管理者権限とインストールファイルを確認してください。", "무인 접속 서비스를 설정할 수 없습니다. 관리자 권한과 설치 파일을 확인하세요.", "Der unbeaufsichtigte Dienst konnte nicht eingerichtet werden. Prüfen Sie Administratorrechte und Installationsdateien.", "Impossible de configurer le service sans surveillance. Vérifiez les droits administrateur et les fichiers d’installation.", "No se pudo configurar el servicio desatendido. Compruebe los permisos de administrador y los archivos de instalación.", "Não foi possível configurar o serviço não assistido. Verifique as permissões de administrador e os arquivos de instalação."}}, + {"开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", {"开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", "Connect after boot without signing in. After login, keep the same ID, password and full functionality.", "Подключайтесь после загрузки без входа в Windows. После входа сохраняются ID, пароль и все функции.", "開機後無需登入即可連線;登入後繼續使用同一 ID、密碼和完整功能。", "サインイン前でも接続できます。サインイン後も同じ ID とパスワードですべての機能を利用できます。", "부팅 후 로그인하지 않아도 연결할 수 있습니다. 로그인 후에도 같은 ID와 비밀번호로 모든 기능을 사용할 수 있습니다.", "Verbinden Sie sich nach dem Start ohne Anmeldung. Nach der Anmeldung bleiben ID, Passwort und alle Funktionen erhalten.", "Connectez-vous après le démarrage sans ouvrir de session. Après connexion, conservez les mêmes ID et mot de passe et toutes les fonctions.", "Conéctese tras arrancar sin iniciar sesión. Después de iniciar sesión, conserve el mismo ID, contraseña y todas las funciones.", "Conecte-se após a inicialização sem fazer login. Após o login, mantenha o mesmo ID, senha e todas as funções."}}, }; struct NamedTranslation { const char* name; const char* literal; }; @@ -681,8 +684,6 @@ inline constexpr NamedTranslation kNamedTranslations[] = { {"remote_unlock_requires_secure_desktop", "当前仍需要安全桌面专用采集/输入"}, {"settings", "设置"}, {"language", "语言"}, - {"screen_capture_method", "采集方式"}, - {"screen_capture_method_auto", "自动"}, {"video_settings", "画面设置"}, {"remote_update_title", "建议升级被控端"}, {"remote_update", "新版本包含功能改进和性能优化,建议将被控端升级至最新版本。"}, @@ -1195,8 +1196,6 @@ inline constexpr const char* kSlintLiterals[] = { X(remote_unlock_requires_secure_desktop) \ X(settings) \ X(language) \ - X(screen_capture_method) \ - X(screen_capture_method_auto) \ X(video_settings) \ X(remote_update_title) \ X(remote_update) \ 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 0e2c4d0a..18c409de 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -50,7 +51,7 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) owner_.WithController( id, [&](MiniRtcPeerPtr* peer, const std::string& wire) { frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer); - return MiniRtcSendAudioFrameToPeer(peer, &frame, + return PeerSendAudioFrameToPeer(peer, &frame, owner_.audio_label_.c_str(), wire.data(), wire.size()); }); @@ -58,6 +59,19 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); } +#ifdef _WIN32 +bool SessionDeviceManager::OwnsNativeCursor() const { + const auto* windows = dynamic_cast(screen_capturer_); + return windows && windows->OwnsNativeCursor(); +} +void SessionDeviceManager::SetNativeCursorPolicy(bool default_enabled, bool current_enabled) { + owner_.host_cursor_default_ = default_enabled; + owner_.host_cursor_preference_ = current_enabled; + if (auto* windows = dynamic_cast(screen_capturer_)) + windows->SetNativeCursorPolicy(default_enabled, current_enabled); +} +#endif + bool SessionDeviceManager::ShouldSendCapturedFrame( std::chrono::steady_clock::time_point now, int fps) { const auto interval = @@ -148,7 +162,8 @@ int SessionDeviceManager::InitializeScreenCapturer() { screen_capturer_->SetPrivacyController(&owner_.privacy_); #ifdef _WIN32 if (auto windows = dynamic_cast(screen_capturer_)) { - windows->SetCaptureMethod(owner_.config_center_->GetScreenCaptureMethod()); + windows->SetUnattendedHost(owner_.unattended_host_); + windows->SetNativeCursorPolicy(owner_.host_cursor_default_, owner_.host_cursor_preference_); } #endif @@ -176,6 +191,7 @@ int SessionDeviceManager::InitializeScreenCapturer() { const auto now_time = std::chrono::steady_clock::now(); bool from_secure_desktop = false; #ifdef _WIN32 + owner_.host_capture_tick_ = SDL_GetTicks(); from_secure_desktop = ScreenCapturerWin::CurrentFrameIsFromSecureDesktop(); #endif @@ -236,6 +252,7 @@ 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_.managed_host_frontend_ && remote_id.rfind("lan/", 0) != 0) continue; if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time, stream_id)) continue; if (owner_.WithController( @@ -243,7 +260,7 @@ int SessionDeviceManager::InitializeScreenCapturer() { if (resumed_after_gap || stream_changed) MiniRtcRequestVideoKeyFrame(peer, stream_id.c_str()); frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer); - return MiniRtcSendVideoFrameToPeer(peer, &frame, stream_id.c_str(), + return PeerSendVideoFrameToPeer(peer, &frame, stream_id.c_str(), wire.data(), wire.size()); }) != 0) ++send_errors; @@ -702,14 +719,20 @@ void SessionDeviceManager::UpdateInteractions() { #endif if (owner_.start_screen_capturer_ && !owner_.screen_capturer_is_started_) { - if (StartScreenCapturer() == 0) { - owner_.screen_capturer_is_started_ = true; + const auto now = std::chrono::steady_clock::now(); + if (now >= next_capture_start_retry_) { + next_capture_start_retry_ = now + std::chrono::seconds(1); + if (StartScreenCapturer() == 0) { + owner_.screen_capturer_is_started_ = true; + next_capture_start_retry_ = {}; + } } } else if (!owner_.start_screen_capturer_ && owner_.screen_capturer_is_started_) { StopScreenCapturer(); owner_.screen_capturer_is_started_ = false; } + if (!owner_.start_screen_capturer_) next_capture_start_retry_ = {}; UpdateScreenSharingPower(); diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.h b/apps/desktop/src/gui/features/devices/session_device_manager.h index faa42b75..c994de39 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.h +++ b/apps/desktop/src/gui/features/devices/session_device_manager.h @@ -66,6 +66,8 @@ public: return remote_mouse_buttons_.Apply(remote_id, flag, std::move(send)); } #ifdef _WIN32 + bool OwnsNativeCursor() const; + void SetNativeCursorPolicy(bool default_enabled, bool current_enabled); std::string SendServiceMouseCommand(int x, int y, int wheel, int flag); #endif // Requests are asynchronous; Stop drains/revokes audio delivery before @@ -145,6 +147,7 @@ private: #endif std::chrono::steady_clock::time_point last_frame_time_{}; std::chrono::steady_clock::time_point next_frame_deadline_{}; + std::chrono::steady_clock::time_point next_capture_start_retry_{}; std::mutex capture_metrics_mutex_; std::chrono::steady_clock::time_point capture_metrics_started_{}; std::chrono::steady_clock::time_point last_capture_callback_{}; diff --git a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp index 4c5f71e4..1e61c082 100644 --- a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp +++ b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp @@ -126,7 +126,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { auto callback = events->EnterCallback(); if (!callback || !events->DataAllowed()) return -1; const auto wire_id = events->WireId(file.remote_id); - return MiniRtcSendReliableDataFrameToPeer( + return PeerSendReliableDataFrameToPeer( peer, data, size, file.file_label.c_str(), wire_id.data(), wire_id.size()); }, file_transfer::Session::Progress{}); @@ -176,7 +176,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { // Release that reservation even though no data chunk was ever sent. const auto packet = EncodeFileCancel(id, 0); const auto wire_id = events->WireId(file.remote_id); - MiniRtcSendReliableDataFrameToPeer(peer, packet.data(), packet.size(), + PeerSendReliableDataFrameToPeer(peer, packet.data(), packet.size(), file.file_label.c_str(), wire_id.data(), wire_id.size()); } } @@ -382,7 +382,7 @@ std::shared_ptr FileTransferManager::ReceiverFor( if (!callback || !events->DataAllowed()) return -1; const auto bytes = EncodeFileTransferAck(ack); const auto wire_id = events->WireId(remote); - return MiniRtcSendReliableDataFrameToPeer( + return PeerSendReliableDataFrameToPeer( peer, bytes.data(), bytes.size(), kFileFeedbackStream, wire_id.data(), wire_id.size()); }); @@ -533,7 +533,7 @@ bool FileTransferManager::SendBrowser(const std::string& remote, const FileBrows const auto data = message.dump(-1, ' ', false, FileBrowser::Json::error_handler_t::replace); if (!peer || data.size() > 60000) return false; const auto wire_id = events->WireId(remote); - return MiniRtcSendReliableDataFrameToPeer(peer, data.data(), data.size(), + return PeerSendReliableDataFrameToPeer(peer, data.data(), data.size(), owner_.control_data_label_.c_str(), wire_id.data(), wire_id.size()) == 0; } diff --git a/apps/desktop/src/gui/features/settings/settings_manager.cpp b/apps/desktop/src/gui/features/settings/settings_manager.cpp index 06057996..06e1f6bb 100644 --- a/apps/desktop/src/gui/features/settings/settings_manager.cpp +++ b/apps/desktop/src/gui/features/settings/settings_manager.cpp @@ -34,9 +34,6 @@ namespace crossdesk { namespace { -constexpr uint32_t kCacheV3Magic = 0x33444358; // "XCD3" -constexpr uint32_t kCacheV3Version = 3; - #if defined(__linux__) && !defined(__OHOS__) constexpr const char *kCredentialCacheNames[] = { "secure_cache_v3.enc", "secure_cache_v2.enc", "secure_cache.enc"}; @@ -132,16 +129,6 @@ void CopyString(char (&destination)[Size], const char *source) { } } -uint32_t CacheChecksum(const void *data, size_t size) { - const auto *bytes = static_cast(data); - uint32_t hash = 2166136261u; - for (size_t i = 0; i < size; ++i) { - hash ^= bytes[i]; - hash *= 16777619u; - } - return hash; -} - std::filesystem::path TemporaryPathFor( const std::filesystem::path &target) { const auto timestamp = std::chrono::steady_clock::now() @@ -259,6 +246,15 @@ bool WriteFileAtomically(const std::filesystem::path &target, SettingsManager::SettingsManager(GuiRuntime &owner) : owner_(owner) {} +bool SettingsManager::ApplyHostIdentity(const std::string& identity, bool self_hosted) { + if (identity.size() != 16 || identity[9] != '@' || + identity.substr(0, 9).find_first_not_of("0123456789") != std::string::npos) + return false; + std::lock_guard lock(cache_mutex_); + ActivateIdentity(identity.c_str(), self_hosted); + return SaveLocked() == 0; +} + int SettingsManager::Save() { std::lock_guard lock(cache_mutex_); return SaveLocked(); @@ -271,8 +267,8 @@ int SettingsManager::SaveLocked() { std::memcpy(cache_v2_.iv, owner_.aes128_iv_, sizeof(owner_.aes128_iv_)); CopyString(cache_v2_.self_hosted_id, owner_.self_hosted_id_); - cache_v3_.magic = kCacheV3Magic; - cache_v3_.version = kCacheV3Version; + cache_v3_.magic = credential_cache::kMagic; + cache_v3_.version = credential_cache::kVersion; cache_v3_.base = cache_v2_; cache_v3_.pending_identity[sizeof(cache_v3_.pending_identity) - 1] = '\0'; cache_v3_.pending_server_host[sizeof(cache_v3_.pending_server_host) - 1] = @@ -280,7 +276,7 @@ int SettingsManager::SaveLocked() { cache_v3_.pending_request_id[sizeof(cache_v3_.pending_request_id) - 1] = '\0'; cache_v3_.checksum = - CacheChecksum(&cache_v3_, offsetof(CacheV3, checksum)); + credential_cache::Checksum(&cache_v3_, offsetof(CacheV3, checksum)); if (!WriteFileAtomically(owner_.cache_path_ + "/secure_cache_v3.enc", &cache_v3_, sizeof(cache_v3_))) { @@ -302,7 +298,7 @@ int SettingsManager::SaveLocked() { LOG_WARN("Failed to update legacy v1 credential cache"); } - return 0; + return owner_.PublishSharedIdentity() ? 0 : -1; } bool SettingsManager::ReadV3Locked() { @@ -325,9 +321,7 @@ bool SettingsManager::ReadV3Locked() { return false; } #endif - if (loaded.magic != kCacheV3Magic || - loaded.version != kCacheV3Version || - loaded.checksum != CacheChecksum(&loaded, offsetof(CacheV3, checksum))) { + if (!credential_cache::Valid(loaded)) { LOG_WARN("Ignore invalid v3 credential cache"); return false; } diff --git a/apps/desktop/src/gui/features/settings/settings_manager.h b/apps/desktop/src/gui/features/settings/settings_manager.h index 9804d6f3..352bc2a7 100644 --- a/apps/desktop/src/gui/features/settings/settings_manager.h +++ b/apps/desktop/src/gui/features/settings/settings_manager.h @@ -7,6 +7,7 @@ #include #include "thumbnail.h" +#include "common/credential_cache.h" namespace crossdesk { @@ -21,6 +22,7 @@ public: int Save(); int Load(); std::string ActiveIdentity() const; + bool ApplyHostIdentity(const std::string& identity, bool self_hosted); int LoadRecentConnectionAliases(); int SaveRecentConnectionAliases() const; @@ -56,46 +58,9 @@ public: bool ClearPendingPasswordChange(); private: - struct CacheV1 { - char client_id_with_password[17]; - int language; - // Reserved legacy slots: retain the on-disk cache layout. - int legacy_video_quality; - int legacy_video_frame_rate; - int video_encode_format; - bool legacy_enable_hardware_video_codec; - bool enable_turn; - bool enable_srtp; - unsigned char key[16]; - unsigned char iv[16]; - }; - - struct CacheV2 { - char client_id_with_password[17]; - int language; - // Reserved legacy slots: retain the on-disk cache layout. - int legacy_video_quality; - int legacy_video_frame_rate; - int video_encode_format; - bool legacy_enable_hardware_video_codec; - bool enable_turn; - bool enable_srtp; - unsigned char key[16]; - unsigned char iv[16]; - char self_hosted_id[17]; - }; - - struct CacheV3 { - uint32_t magic; - uint32_t version; - CacheV2 base; - char pending_identity[17]; - char pending_server_host[256]; - int pending_server_port; - bool pending_self_hosted; - char pending_request_id[64]; - uint32_t checksum; - }; + using CacheV1 = credential_cache::CacheV1; + using CacheV2 = credential_cache::CacheV2; + using CacheV3 = credential_cache::CacheV3; int SaveLocked(); bool ReadV3Locked(); diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index 7ce0530a..48889509 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -79,7 +79,7 @@ void GuiRuntime::HandleConnectionStatusChange() { const auto message = BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true, {}, gallery_presence_ids_).dump(); - if (MiniRtcSendSignalMessage(peer_, message.data(), message.size()) == 0) { + if (PeerSendSignalMessage(peer_, message.data(), message.size()) == 0) { next_presence_refresh_at_ = now + kPresenceRefreshInterval; } else { need_to_send_recent_connections_ = true; @@ -200,7 +200,8 @@ void GuiRuntime::HandleServerControllerDisconnected( std::unique_lock lock(connection_status_mutex_); connection_status_.erase(remote_id); disconnect_lock_.Disconnected(remote_id, - config_center_->IsLockOnDisconnect()); + config_center_->IsLockOnDisconnect() && + !(managed_host_frontend_ && remote_id.rfind("lan/", 0) != 0)); remote_input_.Disconnect(remote_id); devices_.OnControllerConnectionsChanged(); connection_host_names_.erase(remote_id); @@ -296,7 +297,7 @@ int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id, gallery_presence_ids_) .dump(); - int ret = MiniRtcSendSignalMessage(peer_, s.data(), s.size()); + int ret = PeerSendSignalMessage(peer_, s.data(), s.size()); if (ret != 0) { std::lock_guard lock(pending_presence_probe_mutex_); pending_presence_probe_ = false; @@ -315,7 +316,7 @@ int GuiRuntime::ForceSignalReconnect() { LOG_WARN("Signal reconnect requested with no connection peer"); return -1; } - return MiniRtcReconnectSignal(peer_); + return PeerReconnectSignal(peer_); } void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { @@ -430,7 +431,7 @@ void GuiRuntime::CloseServerController(const std::string& remote_id) { return; controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask( [peer = peer_, transmission_id = std::string(client_id_), remote_id] { - if (MiniRtcDisconnectPeerConnection(peer, transmission_id.c_str(), + if (PeerDisconnectPeerConnection(peer, transmission_id.c_str(), remote_id.c_str()) != 0) { LOG_WARN("[{}] Could not queue controller disconnect notification", remote_id); @@ -460,6 +461,14 @@ void GuiRuntime::CloseConnectionPeer() { } for (const auto& id : controllers) HandleServerControllerDisconnected(id, "peer_closed"); +#ifdef _WIN32 + { + std::lock_guard lock(local_host_state_mutex_); + mirrored_controllers_.clear(); + local_controller_info_.clear(); + local_host_status_.controllers_dirty = true; + } +#endif signal_connected_ = false; device_presence_cache_.SetSignalConnected(false); signal_status_ = MiniRtcSignalStatus::MiniRtcSignalClosed; @@ -474,7 +483,8 @@ void GuiRuntime::CloseConnectionPeer() { [peer, handler = peer_events_, id = std::string(client_id_)]() mutable { handler->Deactivate(); LOG_INFO("[{}] Background connection peer cleanup", id); - MiniRtcLeaveConnection(peer, id.c_str()); + if (!IsLocalPeer(peer)) MiniRtcLeaveConnection(peer, id.c_str()); + UnbindLocalPeer(peer); MiniRtcDestroyPeer(&peer); }); } diff --git a/apps/desktop/src/gui/runtime/device_trust_runtime.cpp b/apps/desktop/src/gui/runtime/device_trust_runtime.cpp index a19546d2..ddeb2acc 100644 --- a/apps/desktop/src/gui/runtime/device_trust_runtime.cpp +++ b/apps/desktop/src/gui/runtime/device_trust_runtime.cpp @@ -14,6 +14,12 @@ void GuiRuntime::InitializeTrustedDevices(const std::filesystem::path& directory } bool GuiRuntime::TrustedControllerAllowed(const std::string& id) { +#ifdef _WIN32 + if (managed_host_frontend_ && id.rfind("lan/", 0) != 0) { + std::lock_guard lock(local_host_state_mutex_); + return mirrored_controllers_.contains(id); + } +#endif if (!config_center_ || !config_center_->IsTrustedDevicesOnly()) return true; std::lock_guard lock(controller_trust_mutex_); const auto it = controller_trust_.find(id); @@ -21,6 +27,7 @@ bool GuiRuntime::TrustedControllerAllowed(const std::string& id) { } bool GuiRuntime::BeginControllerTrust(const std::string& id, PeerEventHandler* events) { + if (managed_host_frontend_ && !events->IsDirect()) return TrustedControllerAllowed(id); const bool required = config_center_->IsTrustedDevicesOnly(); std::lock_guard lock(controller_trust_mutex_); const auto previous = controller_trust_.find(id); @@ -54,7 +61,7 @@ void GuiRuntime::ForgetControllerTrust(const std::string& id, PeerEventHandler* int GuiRuntime::SendTrustMessage(const std::string& id, const nlohmann::json& message) { const auto text = message.dump(); return WithController(id, [&](MiniRtcPeerPtr* peer, const std::string& wire) { - return MiniRtcSendReliableDataFrameToPeer(peer, text.data(), text.size(), + return PeerSendReliableDataFrameToPeer(peer, text.data(), text.size(), control_data_label_.c_str(), wire.data(), wire.size()); }, false); } @@ -94,7 +101,7 @@ bool GuiRuntime::HandleDeviceTrustMessage(const std::string& id, PeerEventHandle const auto wire = events->WireId(id); const auto proof = trusted_devices_->Prove(message.at("nonce"), props->local_id_, GetHostName()).dump(); - MiniRtcSendReliableDataFrameToPeer(props->peer_, proof.data(), proof.size(), + PeerSendReliableDataFrameToPeer(props->peer_, proof.data(), proof.size(), control_data_label_.c_str(), wire.data(), wire.size()); } else if (type == "device_trust_proof") { std::lock_guard lock(controller_trust_mutex_); @@ -127,6 +134,7 @@ void GuiRuntime::TickControllerTrust() { { std::lock_guard lock(controller_trust_mutex_); for (auto& [id, state] : controller_trust_) { + if (managed_host_frontend_ && state.events == peer_events_.get()) continue; if (state.closing) continue; const bool trusted = state.IsTrusted(trusted_devices_.get()); if (required && (state.invalid || (state.verified && !trusted) || @@ -179,14 +187,28 @@ bool GuiRuntime::TrustController(const std::string& id, const std::string& expec if (!trusted_devices_ || !IsAuthorizedController(id)) return false; const auto device = VerifiedController(id); if (!device || device->public_key != expected_key) return false; - try { trusted_devices_->Add(*device); return true; } + return AddTrustedDevice(*device); +} +bool GuiRuntime::AddTrustedDevice(const TrustedDevices::Device& device) { +#ifdef _WIN32 + if (managed_host_frontend_) return SendLocalHostCommand({{"op", "trust_add"}, + {"key", device.public_key}, {"name", device.name}, {"device", device.device_id}}); +#endif + if (!trusted_devices_) return false; + try { trusted_devices_->Add(device); } catch (const std::exception& error) { LOG_ERROR("Cannot trust device: {}", error.what()); return false; } + // Local persistence completes before publishing. A shared-store failure + // must not leave the in-memory trust list behind its own on-disk state. + return PublishSharedIdentity(); } bool GuiRuntime::RemoveTrustedDevice(const std::string& public_key) { +#ifdef _WIN32 + if (managed_host_frontend_) return SendLocalHostCommand({{"op", "trust_remove"}, {"key", public_key}}); +#endif if (!trusted_devices_) return false; try { trusted_devices_->Remove(public_key); } catch (const std::exception& error) { LOG_ERROR("Cannot remove trusted device: {}", error.what()); return false; } TickControllerTrust(); - return true; + return PublishSharedIdentity(); } } // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/gui_runtime.cpp b/apps/desktop/src/gui/runtime/gui_runtime.cpp index 07e3a30f..ed973a25 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.cpp +++ b/apps/desktop/src/gui/runtime/gui_runtime.cpp @@ -20,6 +20,8 @@ #include "rd_log.h" #ifdef _WIN32 #include "platform/windows/input/windows_input_injector.h" +#include "platform/windows/service/autostart_control.h" +#include "service_host.h" #endif #if defined(__linux__) && !defined(__OHOS__) #include "platform/linux/headless/headless_console.h" @@ -131,6 +133,63 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) { } } +bool GuiRuntime::PrepareSharedGuiIdentity(bool import_existing) { +#ifdef _WIN32 + if (!SupportsServiceAutostart() || shared_identity_) return true; + if (!import_existing && settings_.Save() != 0) return false; + if (peer_) { + CloseConnectionPeer(); + WaitForSessionCleanup(); + } + std::string error; + if (!import_existing && !SharedHostIdentity::Prepare( + path_manager_->GetCachePath(), path_manager_->GetConfigPath() / "trusted-devices", + config_center_->ExportSharedHostConfig(), error)) { + LOG_ERROR("Cannot prepare persistent host: {}", error); + return false; + } + auto identity = SharedHostIdentity::AttachGui(path_manager_->GetCachePath(), + path_manager_->GetConfigPath() / "trusted-devices", import_existing, error); + if (!identity || + (import_existing && config_center_->ApplySharedHostConfig(identity->HostConfig()) != 0)) { + LOG_ERROR("Cannot load the machine identity: {}", error); + return false; + } + shared_identity_ = std::move(identity); + managed_host_frontend_ = true; + StartCrossDeskService(); +#else + (void)import_existing; +#endif + return true; +} + +bool GuiRuntime::PublishSharedIdentity(bool include_config) { +#ifdef _WIN32 + if (managed_host_frontend_) { + if (!include_config) return true; + pending_local_config_ = + {{"op", "config"}, {"config", config_center_->ExportSharedHostConfig()}, + {"privacy_on_connect", config_center_->IsEnablePrivacyScreen()}, + {"request", ++next_local_config_request_}}; + local_config_sent_ = false; + return true; // Tick sends after startup/reattachment; errors return to the UI. + } + if (shared_identity_) { + local_host_profile_dirty_ = true; + std::string error; + if (!shared_identity_->Publish(include_config ? config_center_->ExportSharedHostConfig() + : std::string{}, error)) { + LOG_ERROR("Cannot synchronize the shared device identity: {}", error); + return false; + } + } +#else + (void)include_config; +#endif + return true; +} + int GuiRuntime::CreateConnectionPeer() { // Tick retries after the old peer's leave notification and socket shutdown. if (connection_peer_cleanup_.valid()) return 0; @@ -286,12 +345,38 @@ int GuiRuntime::CreateConnectionPeer() { need_to_send_recent_connections_ = true; signal_status_ = MiniRtcSignalStatus::MiniRtcSignalConnecting; + // Complete slow headless display setup before connecting to signaling. + if (unattended_host_ && devices_.EnsureCaptureReady() != 0) return -1; { std::unique_lock lock(remote_sessions_mutex_); peer_ = MiniRtcCreatePeer(¶ms_); } if (peer_) { LOG_INFO("Create peer instance [{}] successful", client_id_); +#ifdef _WIN32 + if (managed_host_frontend_) { + if (!local_host_) local_host_ = std::make_shared(false); + std::weak_ptr channel = local_host_; + BindLocalPeer(peer_, [this, channel](nlohmann::json message) { + if (message.contains("id")) { + std::lock_guard lock(local_host_state_mutex_); + const auto found = mirrored_controllers_.find(message.at("id").get()); + if (found == mirrored_controllers_.end()) return false; + message["epoch"] = found->second; + } + const auto connection = channel.lock(); + return connection && connection->Send(message); + }); + local_host_->Send({{"op", "hello"}, + {"privacy_on_connect", config_center_->IsEnablePrivacyScreen()}, + {"privacy_requested", privacy_.Requested() || config_center_->IsEnablePrivacyScreen()}}); + // Outgoing viewer/direct sessions still register their native streams + // from the user's local display metadata; this does not start capture. + devices_.EnsureCaptureReady(); + LOG_INFO("GUI attached to persistent host; no second signaling login"); + return 0; + } +#endif MiniRtcInit(peer_); LOG_INFO("Peer [{}] init finish", client_id_); } else { @@ -313,8 +398,7 @@ int GuiRuntime::CreateConnectionPeer() { MiniRtcAddDataStream(peer_, file_label_.c_str(), true); MiniRtcAddDataStream(peer_, file_feedback_label_.c_str(), true); MiniRtcAddDataStream(peer_, clipboard_label_.c_str(), true); - if (!unattended_host_) - MiniRtcAddDataStream(peer_, web_clipboard::kStream, true); + MiniRtcAddDataStream(peer_, web_clipboard::kStream, true); return 0; } else { return -1; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index 45641150..75f26bf8 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -27,11 +27,16 @@ #include "runtime/disconnect_lock_policy.h" #include "runtime/announcement_inbox.h" #include "runtime/peer_event_handler.h" +#include "runtime/peer_transport.h" #include "runtime/privacy_session_policy.h" #include "runtime/trusted_devices.h" #ifdef __APPLE__ #include "platform/macos/gui/runtime/mac_permission_migration.h" #endif +#ifdef _WIN32 +#include "local_host_channel.h" +#include "runtime/local_host_status.h" +#endif namespace crossdesk { @@ -114,6 +119,8 @@ class GuiRuntime : protected gui_detail::GuiState { void HandleScreenLock(); bool IsAuthorizedController(const std::string& remote_id); void InitializeTrustedDevices(const std::filesystem::path& directory); + bool PublishSharedIdentity(bool include_config = false); + bool PrepareSharedGuiIdentity(bool import_existing); bool TrustedControllerAllowed(const std::string& remote_id); bool BeginControllerTrust(const std::string& remote_id, PeerEventHandler* events); void ForgetControllerTrust(const std::string& remote_id, PeerEventHandler* events); @@ -122,6 +129,7 @@ class GuiRuntime : protected gui_detail::GuiState { void TickControllerTrust(); std::optional VerifiedController(const std::string& remote_id); bool TrustController(const std::string& remote_id, const std::string& expected_key); + bool AddTrustedDevice(const TrustedDevices::Device& device); bool RemoveTrustedDevice(const std::string& public_key); int SendTrustMessage(const std::string& remote_id, const nlohmann::json& message); std::unique_ptr trusted_devices_; @@ -201,6 +209,39 @@ class GuiRuntime : protected gui_detail::GuiState { PrivacyController privacy_; // Set before initializing callbacks; immutable for the process lifetime. bool unattended_host_ = false; + bool managed_host_frontend_ = false; + std::string BuildHostInformation(bool advertise_trust = false); + void AnnounceHostCapabilities(const std::string& id); +#ifdef _WIN32 + std::shared_ptr local_host_; + std::atomic local_gui_ready_{false}; + std::mutex local_host_state_mutex_; + std::map local_controller_info_; + std::map mirrored_controllers_; + uint64_t next_controller_epoch_ = 0; + uint64_t next_local_host_status_ = 0; + LocalHostStatus local_host_status_; + std::atomic local_host_profile_dirty_{true}; + nlohmann::json pending_local_config_; + uint64_t next_local_config_request_ = 0; + bool local_config_sent_ = false; + std::optional local_cursor_hidden_sent_; + std::optional local_cursor_request_sent_; + bool local_cursor_policy_ready_ = false; + bool host_cursor_default_ = true, host_cursor_preference_ = true; + std::atomic host_capture_tick_{0}; + bool ForwardUserSessionData(const std::string& id, const std::string& stream, + const char* data, size_t size); + void PublishLocalHostStatus(); + bool SendLocalHostCommand(nlohmann::json message); +#endif + bool UserSessionFeaturesAvailable() const { +#ifdef _WIN32 + return !unattended_host_ || local_gui_ready_.load(); +#else + return !unattended_host_; +#endif + } AnnouncementInbox announcements_; PrivacySessionPolicy privacy_sessions_; DisconnectLockPolicy disconnect_lock_; diff --git a/apps/desktop/src/gui/runtime/lan_runtime.cpp b/apps/desktop/src/gui/runtime/lan_runtime.cpp index 14a2a97b..a6c160d2 100644 --- a/apps/desktop/src/gui/runtime/lan_runtime.cpp +++ b/apps/desktop/src/gui/runtime/lan_runtime.cpp @@ -199,7 +199,7 @@ int GuiRuntime::WithController( int GuiRuntime::SendControllerData(const std::string& id, const char* data, size_t size, const char* label) { return WithController(id, [&](MiniRtcPeerPtr* peer, const std::string& wire) { - return MiniRtcSendReliableDataFrameToPeer(peer, data, size, label, wire.data(), + return PeerSendReliableDataFrameToPeer(peer, data, size, label, wire.data(), wire.size()); }); } diff --git a/apps/desktop/src/gui/runtime/local_host_status.h b/apps/desktop/src/gui/runtime/local_host_status.h new file mode 100644 index 00000000..6ef30d33 --- /dev/null +++ b/apps/desktop/src/gui/runtime/local_host_status.h @@ -0,0 +1,43 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LOCAL_HOST_STATUS_H_ +#define _LOCAL_HOST_STATUS_H_ + +#include + +namespace crossdesk { + +// Pending sections survive deferred settings and failed local persistence. +// Clear each flag only after its section has been applied successfully. +struct LocalHostStatus { + nlohmann::json values = nlohmann::json::object(); + bool config_dirty = true; + bool identity_dirty = true; + bool controllers_dirty = true; + bool trusted_dirty = true; + + void Reset() { + values.clear(); + config_dirty = identity_dirty = controllers_dirty = trusted_dirty = true; + } + + void Merge(const nlohmann::json& update) { + const auto changed = [&](const char* field) { + return update.contains(field) && + (!values.contains(field) || update.at(field) != values.at(field)); + }; + config_dirty |= changed("config"); + identity_dirty |= changed("identity") || changed("self_hosted"); + controllers_dirty |= changed("controllers"); + trusted_dirty |= changed("trusted"); + values.update(update); + } +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 5576c810..982d6149 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -139,6 +139,10 @@ void PeerEventHandler::OnReceiveDataBuffer( const auto viewer = runtime->FindRemoteSession(remote_id); if (viewer && viewer->peer_events_.get() != handler) return; if (!viewer && !runtime->TrustedControllerAllowed(remote_id)) return; +#ifdef _WIN32 + if (runtime->unattended_host_ && !viewer && + runtime->ForwardUserSessionData(remote_id, source_id, data, size)) return; +#endif // The service-owned host runs as SYSTEM. Do not expose filesystem or // clipboard operations in that security context. if (runtime->unattended_host_ && @@ -198,6 +202,11 @@ void PeerEventHandler::OnReceiveDataBuffer( return; const auto subscription = PreviewSubscription::Parse(preview_json); if (!subscription) return; + if (runtime->managed_host_frontend_ && !handler->IsDirect()) { + runtime->preview_subscriptions_.Set(remote_id, *subscription); + if (subscription->audio) runtime->devices_.StartSpeakerCapturer(); + return; // The persistent host has already applied/acknowledged this. + } const auto previous = runtime->preview_subscriptions_.Get(remote_id); bool accepted = true; // Apply encoder quality to this controller only; other controllers retain @@ -240,7 +249,7 @@ void PeerEventHandler::OnReceiveDataBuffer( {"request_id", subscription->request_id}, {"accepted", accepted}}.dump(); - MiniRtcSendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(), + PeerSendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(), runtime->control_data_label_.c_str(), wire_id.data(), wire_id.size()); return; @@ -324,7 +333,7 @@ void PeerEventHandler::OnReceiveDataBuffer( settings.frame_rate, settings.preference, remote_action.vs.accepted); remote_action.type = ControlType::video_settings_status; const auto message = remote_action.to_json(); - MiniRtcSendReliableDataFrameToPeer(bound_peer, message.data(), message.size(), + PeerSendReliableDataFrameToPeer(bound_peer, message.data(), message.size(), runtime->control_data_label_.c_str(), wire_id.data(), wire_id.size()); return; @@ -526,6 +535,10 @@ void PeerEventHandler::OnReceiveDataBuffer( } } else { if (remote_action.type == ControlType::audio_capture) { + if (runtime->managed_host_frontend_ && !handler->IsDirect()) { + if (remote_action.a) runtime->devices_.StartSpeakerCapturer(); + return; + } // A negotiated subscription remains authoritative if a legacy capture // command arrives after it (e.g. when unmuting an inactive window). const auto subscription = runtime->preview_subscriptions_.Get(remote_id); @@ -586,8 +599,10 @@ InputOutcome GuiRuntime::ApplyRemoteInput(const std::string& remote_id, const int display = selected_display_.load(); if (is_server_mode_ && remote_action.type == ControlType::mouse) { std::lock_guard lock(remote_pointer_input_mutex_); - last_remote_pointer_input_time_[remote_id] = - std::chrono::steady_clock::now(); + const auto now = std::chrono::steady_clock::now(); + auto [position, first] = last_remote_pointer_input_time_.try_emplace(remote_id, now); + position->second = now; + if (first) LOG_INFO("Remote pointer input received: peer={}", remote_id); } if (remote_action.type == ControlType::mouse) { @@ -652,6 +667,12 @@ InputOutcome GuiRuntime::InjectRemoteMouse(const RemoteAction& remote_action, static_cast(remote_action.m.flag)); auto json = nlohmann::json::parse(response, nullptr, false); const bool sent = json.is_object() && json.value("ok", false); + static thread_local bool logged_service_input = false; + if (sent && !logged_service_input) { + logged_service_input = true; + LOG_INFO("Secure desktop pointer injection succeeded: stage='{}', desktop='{}'", + WindowsInputStage(), json.value("desktop", "")); + } const std::string error = json.is_object() ? json.value("error", "") : ""; return {sent, IsDesktopInputSetupPending(error)}; diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 4aa35998..c9d3b7e0 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -75,7 +75,7 @@ void PeerEventHandler::SendClientInfo(MiniRtcPeerPtr* peer, {"version", CROSSDESK_VERSION}, {"platform", HostPlatformName(kClientPlatform)}}; const std::string payload = message.dump(); - if (MiniRtcSendSignalMessage(peer, payload.data(), payload.size()) != 0) { + if (PeerSendSignalMessage(peer, payload.data(), payload.size()) != 0) { LOG_WARN("[{}] failed to report client information", client_id); } } @@ -118,6 +118,12 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size, return; } std::string type = j["type"].get(); +#ifdef _WIN32 + if (runtime->unattended_host_ && runtime->local_host_ && + (type == "presence" || type == "presence_update" || + type == "announcements" || type == "announcements_changed")) + runtime->local_host_->Send({{"op", "signal"}, {"message", s}}); +#endif if (type == "user_join_transmission" && j.contains("status") && j["status"] == "failed" && j.contains("transmission_id") && j["transmission_id"].is_string()) { @@ -329,6 +335,19 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status, } else { runtime->ForgetControllerTrust(remote_id, handler); } +#ifdef _WIN32 + if (runtime->unattended_host_) { + std::lock_guard lock(runtime->local_host_state_mutex_); + if (status == MiniRtcConnectionStatus::MiniRtcConnectionConnected) { + auto& info = runtime->local_controller_info_[remote_id]; + if (!info.contains("epoch")) info["epoch"] = ++runtime->next_controller_epoch_; + } else if (status == MiniRtcConnectionStatus::MiniRtcConnectionClosed || + status == MiniRtcConnectionStatus::MiniRtcConnectionFailed || + status == MiniRtcConnectionStatus::MiniRtcConnectionDisconnected) { + runtime->local_controller_info_.erase(remote_id); + } + } +#endif } if (props) { @@ -348,25 +367,7 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status, props->cursor_presentation_ = {}; } { - RemoteAction remote_action = MakeHostInformation( - GetHostName(), runtime->devices_.host_display_list(), - /*supports_privacy_screen=*/!runtime->unattended_host_, - CROSSDESK_VERSION, kClientPlatform); - remote_action.i.supports_video_settings = true; - remote_action.i.supports_video_codec_status = true; - remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput(); - remote_action.i.supports_deskhub_preview = true; - remote_action.i.supports_deskhub_resolution = true; - remote_action.i.supports_deskhub_fps15 = true; - remote_action.i.supports_deskhub_bandwidth = true; - remote_action.i.supports_file_browser = !runtime->unattended_host_; - 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]); - } - auto information = nlohmann::json::parse(remote_action.to_json()); - information["device_trust_version"] = 1; - std::string msg = information.dump(); + const auto msg = runtime->BuildHostInformation(true); const int ret = MiniRtcSendReliableDataFrame(props->peer_, msg.data(), msg.size(), runtime->control_data_label_.c_str()); @@ -374,7 +375,6 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status, LOG_WARN("[{}] host information send failed, ret={}", remote_id, ret); } - FreeRemoteAction(remote_action); } if (!props->wall_managed_ && !runtime->need_to_create_stream_window_) { @@ -517,43 +517,20 @@ void PeerEventHandler::DispatchConnectionStatus(MiniRtcConnectionStatus status, #if _WIN32 runtime->last_windows_service_status_tick_ = 0; #endif - { - RemoteAction remote_action = MakeHostInformation( - GetHostName(), runtime->devices_.host_display_list(), - /*supports_privacy_screen=*/!runtime->unattended_host_, - CROSSDESK_VERSION, kClientPlatform); - remote_action.i.supports_video_settings = true; - remote_action.i.supports_video_codec_status = true; - remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput(); - remote_action.i.supports_deskhub_preview = true; - remote_action.i.supports_deskhub_resolution = true; - remote_action.i.supports_deskhub_fps15 = true; - remote_action.i.supports_deskhub_bandwidth = true; - remote_action.i.supports_file_browser = !runtime->unattended_host_; - 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]); - } - std::string msg = remote_action.to_json(); - const int ret = - runtime->SendControllerData(remote_id, msg.data(), msg.size(), - runtime->control_data_label_.c_str()); - if (ret != 0) { - LOG_WARN("[{}] host information send failed, ret={}", remote_id, - ret); - } - FreeRemoteAction(remote_action); - } + if (!runtime->managed_host_frontend_ || handler->IsDirect()) + runtime->AnnounceHostCapabilities(remote_id); runtime->need_to_destroy_server_window_.store( false, std::memory_order_release); runtime->need_to_create_server_window_.store(true, std::memory_order_release); runtime->is_server_mode_ = true; - runtime->start_screen_capturer_ = true; + if (!runtime->managed_host_frontend_ || handler->IsDirect()) + runtime->start_screen_capturer_ = true; runtime->devices_.StartSpeakerCapturer(); runtime->remote_client_id_ = remote_id; - runtime->start_mouse_controller_ = true; + if (!runtime->managed_host_frontend_ || handler->IsDirect()) + runtime->start_mouse_controller_ = true; break; } @@ -670,4 +647,33 @@ void PeerEventHandler::OnNetStatusReport( props->net_traffic_stats_.Store(*net_traffic_stats); } } +std::string GuiRuntime::BuildHostInformation(bool advertise_trust) { + auto action = MakeHostInformation(GetHostName(), devices_.host_display_list(), + UserSessionFeaturesAvailable(), CROSSDESK_VERSION, kClientPlatform); + action.i.supports_video_settings = true; + action.i.supports_video_codec_status = true; + action.i.supports_text_input = devices_.SupportsTextInput(); + action.i.supports_deskhub_preview = true; + action.i.supports_deskhub_resolution = true; + action.i.supports_deskhub_fps15 = true; + action.i.supports_deskhub_bandwidth = true; + action.i.supports_file_browser = UserSessionFeaturesAvailable(); + for (std::size_t i = 0; i < action.i.display_num; ++i) + LOG_INFO("Local display [{}:{}]", i + 1, action.i.display_list[i]); + auto message = action.to_json(); + FreeRemoteAction(action); + if (advertise_trust) { + auto information = nlohmann::json::parse(message); + information["device_trust_version"] = 1; + message = information.dump(); + } + return message; +} + +void GuiRuntime::AnnounceHostCapabilities(const std::string& id) { + const auto message = BuildHostInformation(); + const int ret = SendControllerData(id, message.data(), message.size(), control_data_label_.c_str()); + if (ret != 0) LOG_WARN("[{}] host information send failed, ret={}", id, ret); +} + } // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/peer_transport.cpp b/apps/desktop/src/gui/runtime/peer_transport.cpp new file mode 100644 index 00000000..7ff8cdbd --- /dev/null +++ b/apps/desktop/src/gui/runtime/peer_transport.cpp @@ -0,0 +1,104 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PEER_TRANSPORT_H_ +#define _PEER_TRANSPORT_H_ + +#include "runtime/peer_transport.h" + +#ifdef _WIN32 +#include +#include +#endif + +namespace crossdesk { +namespace { +using Json = nlohmann::json; +using LocalSender = std::function; +Json Bytes(const char* data, size_t size) { + return Json::binary(std::vector(data, data + size)); +} +#ifdef _WIN32 +std::mutex routes_mutex; +std::map routes; +LocalSender FindLocalSender(MiniRtcPeerPtr* peer) { + std::lock_guard lock(routes_mutex); + const auto found = routes.find(peer); + return found == routes.end() ? LocalSender{} : found->second; +} +#else +LocalSender FindLocalSender(MiniRtcPeerPtr*) { return {}; } +#endif +} +#ifdef _WIN32 +void BindLocalPeer(MiniRtcPeerPtr* peer, std::function send) { + std::lock_guard lock(routes_mutex); + routes[peer] = std::move(send); +} +void UnbindLocalPeer(MiniRtcPeerPtr* peer) { + std::lock_guard lock(routes_mutex); + routes.erase(peer); +} +bool IsLocalPeer(MiniRtcPeerPtr* peer) { + std::lock_guard lock(routes_mutex); + return routes.contains(peer); +} +#else +void BindLocalPeer(MiniRtcPeerPtr*, std::function) {} +void UnbindLocalPeer(MiniRtcPeerPtr*) {} +bool IsLocalPeer(MiniRtcPeerPtr*) { return false; } +#endif + +int PeerSendReliableDataFrameToPeer(MiniRtcPeerPtr* peer, const char* data, + size_t size, const char* stream, const char* id, size_t id_size) { + if (auto send = FindLocalSender(peer)) { + if (!data || !size || !stream || !id || !id_size) return -1; + return send({{"op", "data"}, {"id", std::string(id, id_size)}, + {"stream", stream}, {"bytes", Bytes(data, size)}}) ? 0 : -1; + } + return MiniRtcSendReliableDataFrameToPeer(peer, data, size, stream, id, id_size); +} +int PeerSendAudioFrameToPeer(MiniRtcPeerPtr* peer, const MiniRtcAudioFrame* frame, + const char* stream, const char* id, size_t size) { + if (auto send = FindLocalSender(peer)) { + if (!frame || !frame->data || !frame->size || !stream || !id || !size) return -1; + return send({{"op", "audio"}, {"id", std::string(id, size)}, + {"stream", stream}, {"bytes", Bytes(frame->data, frame->size)}}) ? 0 : -1; + } + return MiniRtcSendAudioFrameToPeer(peer, frame, stream, id, size); +} +int PeerSendVideoFrameToPeer(MiniRtcPeerPtr* peer, const MiniRtcVideoFrame* frame, + const char* stream, const char* id, size_t size) { + // The persistent host owns video. A GUI capturing for a direct LAN viewer + // must not send that second capture into its mirrored cloud connections. + return IsLocalPeer(peer) ? 0 : MiniRtcSendVideoFrameToPeer(peer, frame, stream, id, size); +} +int PeerSendSignalMessage(MiniRtcPeerPtr* peer, const char* data, size_t size) { + if (auto send = FindLocalSender(peer)) { + if (!data || !size) return -1; + return send({{"op", "signal"}, {"message", std::string(data, size)}}) ? 0 : -1; + } + return MiniRtcSendSignalMessage(peer, data, size); +} +int PeerReconnectSignal(MiniRtcPeerPtr* peer) { + if (auto send = FindLocalSender(peer)) return send({{"op", "reconnect"}}) ? 0 : -1; + return MiniRtcReconnectSignal(peer); +} +int PeerDisconnectPeerConnection(MiniRtcPeerPtr* peer, const char* host, const char* id) { + if (auto send = FindLocalSender(peer)) + return send({{"op", "disconnect"}, {"id", id}}) ? 0 : -1; + return MiniRtcDisconnectPeerConnection(peer, host, id); +} +int PeerUpdateConnectionSettings(MiniRtcPeerPtr* peer, bool hardware, bool av1, + MiniRtcTurnMode turn) { + if (auto send = FindLocalSender(peer)) + return send({{"op", "connection_settings"}, + {"av1", av1}, {"turn", static_cast(turn)}}) ? 0 : -1; + return MiniRtcUpdateConnectionSettings(peer, hardware, av1, turn); +} +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/gui/runtime/peer_transport.h b/apps/desktop/src/gui/runtime/peer_transport.h new file mode 100644 index 00000000..232d83eb --- /dev/null +++ b/apps/desktop/src/gui/runtime/peer_transport.h @@ -0,0 +1,34 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PEER_TRANSPORT_H_ +#define _PEER_TRANSPORT_H_ + +#include +#include +#include + +namespace crossdesk { + +// A managed GUI has a local endpoint instead of another signaling login. +// Viewer peers and direct LAN peers continue to use MiniRTC normally. +void BindLocalPeer(MiniRtcPeerPtr*, std::function send); +void UnbindLocalPeer(MiniRtcPeerPtr*); +bool IsLocalPeer(MiniRtcPeerPtr*); +int PeerSendReliableDataFrameToPeer(MiniRtcPeerPtr*, const char*, size_t, + const char*, const char*, size_t); +int PeerSendAudioFrameToPeer(MiniRtcPeerPtr*, const MiniRtcAudioFrame*, + const char*, const char*, size_t); +int PeerSendVideoFrameToPeer(MiniRtcPeerPtr*, const MiniRtcVideoFrame*, + const char*, const char*, size_t); +int PeerSendSignalMessage(MiniRtcPeerPtr*, const char*, size_t); +int PeerReconnectSignal(MiniRtcPeerPtr*); +int PeerDisconnectPeerConnection(MiniRtcPeerPtr*, const char*, const char*); +int PeerUpdateConnectionSettings(MiniRtcPeerPtr*, bool, bool, MiniRtcTurnMode); + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/gui/runtime/runtime_state.h b/apps/desktop/src/gui/runtime/runtime_state.h index 03f220eb..7f4a8fef 100644 --- a/apps/desktop/src/gui/runtime/runtime_state.h +++ b/apps/desktop/src/gui/runtime/runtime_state.h @@ -33,6 +33,7 @@ #if _WIN32 #include "platform/windows/service/service_query_worker.h" +#include "platform/windows/service/shared_identity.h" #endif namespace crossdesk::gui_detail { @@ -47,6 +48,9 @@ struct InfrastructureState { std::string exec_log_path_; std::string dll_log_path_; std::string cache_path_; +#ifdef _WIN32 + std::unique_ptr shared_identity_; +#endif int localization_language_index_ = -1; bool modules_inited_ = false; unsigned char aes128_key_[16]{}; diff --git a/apps/desktop/src/gui/runtime/trusted_devices.cpp b/apps/desktop/src/gui/runtime/trusted_devices.cpp index a3883a51..4a049e35 100644 --- a/apps/desktop/src/gui/runtime/trusted_devices.cpp +++ b/apps/desktop/src/gui/runtime/trusted_devices.cpp @@ -230,4 +230,16 @@ void TrustedDevices::Remove(const std::string& public_key) { Save(next); devices_.swap(next); } +void TrustedDevices::Replace(const std::vector& devices) { + Require(devices.size() <= kMaxTrustedDevices); + std::map next; + for (const auto& device : devices) { + Require(IsHex(device.public_key, 32) && Label(device.name) && Label(device.device_id)); + Require(next.emplace(device.public_key, device).second); + } + std::lock_guard lock(mutex_); + if (next == devices_) return; + Save(next); + devices_.swap(next); +} } // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/trusted_devices.h b/apps/desktop/src/gui/runtime/trusted_devices.h index 7ace4345..f32c6f64 100644 --- a/apps/desktop/src/gui/runtime/trusted_devices.h +++ b/apps/desktop/src/gui/runtime/trusted_devices.h @@ -48,6 +48,9 @@ class TrustedDevices { // Call only after a local add action for a verified peer or valid pairing info. void Add(const Device& device); void Remove(const std::string& public_key); + // Replace a complete snapshot from the authenticated machine host. + // Validation and persistence finish before the in-memory list changes. + void Replace(const std::vector& devices); private: void Save(const std::map& devices) const; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 6d4ea674..0cac7b6f 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -73,8 +73,6 @@ export global UiStrings { in-out property delete-connection: "Delete connection"; in-out property confirm-delete: "Delete this recent connection?"; in-out property language: "Language"; - in-out property capture-method: "Capture method"; - in-out property capture-method-auto: "Auto"; in-out property codec: "Video codec"; in-out property pause-video-when-minimized: "Pause video when minimized"; in-out property pause-video-when-minimized-hint: "Pause video reception when the control window is minimized to reduce local decoding resource usage."; @@ -737,8 +735,6 @@ export component MainWindow inherits Window { in-out property language-index: 10; in property <[string]> language-options; - in property capture-method-visible: false; - in-out property capture-method-index: 0; in-out property codec-index: 0; in-out property ip-direct-enabled-draft: true; in-out property lan-listen-all-draft: true; @@ -746,6 +742,9 @@ export component MainWindow inherits Window { in-out property force-relay-enabled: false; in-out property self-hosted-enabled: false; in-out property autostart-enabled: false; + in property autostart-available: true; + in property service-autostart-platform: false; + in property service-autostart-hint; in property autostart-system-managed: false; in property autostart-status: AutostartStatus.checking; out property autostart-status-text: @@ -2876,11 +2875,7 @@ export component MainWindow inherits Window { } SettingsGroup { title: GalleryStrings.texts[115]; - rows: root.capture-method-visible ? 3 : 2; - if root.capture-method-visible: SettingsRow { - label: UiStrings.capture-method; enabled: !root.settings-session-active; - SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; } - } + rows: 2; SettingsRow { label: UiStrings.codec; enabled: !root.settings-session-active; SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; } @@ -2983,7 +2978,7 @@ export component MainWindow inherits Window { } SettingsGroup { title: GalleryStrings.texts[123]; rows: 2; - if !root.autostart-system-managed: SettingsToggleRow { label: UiStrings.autostart; checked <=> root.autostart-enabled; enabled: !root.settings-session-active; } + if !root.autostart-system-managed: SettingsToggleRow { label: UiStrings.autostart; checked <=> root.autostart-enabled; enabled: root.autostart-available && !root.settings-session-active; } if root.autostart-system-managed: SettingsToggleRow { label: UiStrings.autostart; checked: root.autostart-status == AutostartStatus.enabled; @@ -2992,6 +2987,11 @@ export component MainWindow inherits Window { } SettingsToggleRow { label: UiStrings.daemon; checked <=> root.daemon-enabled; enabled: !root.settings-session-active; divider: false; } } + if root.service-autostart-platform: Text { + text: root.service-autostart-hint; wrap: word-wrap; + font-size: 11px; color: WorkbenchStyle.muted; + } + } } } diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 67f2eb3f..821bea8c 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -131,9 +131,6 @@ inline int ApplyMainWindowStrings( strings.set_confirm_delete( Text(localization::confirm_delete_connection[language])); strings.set_language(Text(localization::language[language])); - strings.set_capture_method(Text(localization::screen_capture_method[language])); - strings.set_capture_method_auto( - Text(localization::screen_capture_method_auto[language])); strings.set_codec(Text(localization::video_encode_format[language])); strings.set_pause_video_when_minimized( Text(localization::pause_video_when_minimized[language])); diff --git a/apps/desktop/src/platform/windows/gui/application/local_host_application.cpp b/apps/desktop/src/platform/windows/gui/application/local_host_application.cpp new file mode 100644 index 00000000..11fc8ef5 --- /dev/null +++ b/apps/desktop/src/platform/windows/gui/application/local_host_application.cpp @@ -0,0 +1,416 @@ +#include "application/gui_application.h" + +#include +#include +#include +#include + +#include "interactive_desktop.h" +#include "localization.h" +#include "rd_log.h" +#include "platform/windows/screen_capturer/dxgi_cursor_state.h" + +namespace crossdesk { +namespace { +using Json = nlohmann::json; +Json DeviceInfo(const TrustedDevices::Device& device) { + return {{"key", device.public_key}, {"name", device.name}, {"device", device.device_id}}; +} +TrustedDevices::Device ReadDevice(const Json& message) { + return {message.at("key").get(), message.at("name").get(), + message.at("device").get()}; +} +auto ServerSettings(const ConfigCenter& config) { + return std::tuple(config.IsSelfHosted(), config.GetSignalServerHost(), + config.GetSignalServerPort()); +} +} // namespace + +bool GuiRuntime::SendLocalHostCommand(Json message) { + return local_host_ && local_host_->Send(message); +} + +bool GuiRuntime::ForwardUserSessionData(const std::string& id, + const std::string& stream, const char* data, size_t size) { + const bool user_stream = stream == file_label_ || stream == file_feedback_label_ || + stream == clipboard_label_ || stream == web_clipboard::kStream; + Json action; + if (stream == control_data_label_ && size <= 65536) + action = Json::parse(data, data + size, nullptr, false); + const auto type = action.is_object() ? action.value("type", Json{}) : Json{}; + const bool host_info = type == static_cast(ControlType::host_infomation); + const bool preview = type == "deskhub_preview"; + const bool user_control = type == "file_browser" || + type == static_cast(ControlType::privacy_command); + const bool audio = type == static_cast(ControlType::audio_capture); + uint64_t epoch = 0; + { + std::lock_guard lock(local_host_state_mutex_); + const auto found = local_controller_info_.find(id); + if (found == local_controller_info_.end()) return user_stream || user_control; + epoch = found->second.value("epoch", uint64_t{}); + if (host_info) found->second["host_info"] = action; + if (preview) found->second["preview"] = action; + } + if (local_gui_ready_ && local_host_ && + (user_stream || user_control || host_info || preview || audio)) { + if (!local_host_->Send({{"op", "data"}, {"id", id}, {"epoch", epoch}, {"stream", stream}, + {"bytes", Json::binary(std::vector(data, data + size))}})) { + LOG_WARN("Cannot forward user-session data for [{}] on {}; reconnecting local frontend", id, stream); + local_gui_ready_ = false; + // Reset the mirrored sessions so an incomplete reliable stream fails + // explicitly. Never fall through to file/clipboard handling as SYSTEM. + local_host_->Reconnect(); + } + } + return user_stream || user_control; +} + +void GuiRuntime::PublishLocalHostStatus() { + if (!local_host_ || !local_gui_ready_) return; + Json controllers = Json::object(); + for (const auto& id : ConnectedControllers()) { + if (!IsAuthorizedController(id)) continue; + Json info; + { + std::lock_guard lock(local_host_state_mutex_); + const auto found = local_controller_info_.find(id); + if (found == local_controller_info_.end()) continue; + info = found->second; + } + if (auto device = VerifiedController(id)) info["verified"] = DeviceInfo(*device); + controllers[id] = std::move(info); + } + bool password_pending = false; + { + std::lock_guard lock(password_change_mutex_); + password_pending = password_change_pending_ || credential_recovery_in_progress_; + } + Json status{{"op", "status"}, {"online", signal_connected_.load()}, + {"identity", settings_.ActiveIdentity()}, {"self_hosted", config_center_->IsSelfHosted()}, + {"controllers", std::move(controllers)}, + {"capture", screen_capturer_is_started_ && host_capture_tick_ != 0 && + SDL_GetTicks() - host_capture_tick_.load() < 2000}, + {"cursor_owned", devices_.OwnsNativeCursor()}, + {"password_pending", password_pending}}; + // Settings/trust mutations invalidate this profile; reattachment sends a + // full snapshot. Frequent heartbeats carry only changed large fields. + const bool refresh_profile = local_host_profile_dirty_.exchange(false); + if (refresh_profile) { + status["config"] = config_center_->ExportSharedHostConfig(); + auto& trusted = status["trusted"] = Json::array(); + if (trusted_devices_) + for (const auto& device : trusted_devices_->List()) trusted.push_back(DeviceInfo(device)); + } + for (const char* field : {"config", "trusted", "controllers"}) { + if (status.contains(field) && local_host_status_.values.contains(field) && + status.at(field) == local_host_status_.values.at(field)) status.erase(field); + } + if (local_host_->Send(status)) local_host_status_.values.update(status); + else if (refresh_profile) local_host_profile_dirty_ = true; +} + +void GuiApplication::TickLocalHost() { + if (!local_host_) return; + const auto error = [this](const std::string& message) { + if (unattended_host_) local_host_->Send({{"op", "error"}, {"message", message}}); + else { + offline_warning_text_ = message; + show_offline_warning_window_ = true; + } + }; + const auto deliver = [this](const std::string& id, const std::string& stream, + const char* bytes, size_t size) { + PeerEventHandler::OnReceiveDataBuffer(bytes, size, id.data(), id.size(), + stream.data(), stream.size(), peer_events_.get()); + }; + const auto remove_mirror = [this](const std::string& id) { + if (peer_events_) peer_events_->ApplyConnectionStatus(MiniRtcConnectionClosed, id); + std::lock_guard lock(local_host_state_mutex_); + mirrored_controllers_.erase(id); + local_controller_info_.erase(id); + }; + const auto ensure_mirror = [this, &remove_mirror](const std::string& id, uint64_t epoch) { + if (!peer_ || !peer_events_ || !epoch || id.empty() || id.size() > 128) return false; + uint64_t existing = 0; + { + std::lock_guard lock(local_host_state_mutex_); + const auto found = mirrored_controllers_.find(id); + if (found != mirrored_controllers_.end()) existing = found->second; + } + if (existing == epoch) return true; + local_host_status_.controllers_dirty = true; + if (existing) remove_mirror(id); + { + std::lock_guard lock(local_host_state_mutex_); + mirrored_controllers_[id] = epoch; + } + peer_events_->ApplyConnectionStatus(MiniRtcConnectionConnected, id); + return true; + }; + for (auto& message : local_host_->Receive()) { + try { + const auto op = message.at("op").get(); + if (op == "attached" || op == "detached" || (unattended_host_ && op == "hello")) { + local_host_status_.Reset(); + local_host_profile_dirty_ = true; + } + if (unattended_host_) { + if (op == "attached" || op == "hello" || op == "detached") { + local_gui_ready_ = op != "detached"; + if (op == "detached") { + SharedDxgiCursorState().SetExternalCursorHidden(false); + devices_.SetNativeCursorPolicy(true, true); + } + if (op == "hello") { + devices_.SetNativeCursorPolicy(message.value("privacy_on_connect", true), + message.value("privacy_requested", true)); + } + PublishLocalHostStatus(); + for (const auto& id : ConnectedControllers()) AnnounceHostCapabilities(id); + LOG_INFO("User-session frontend {}; RTC host remains running", + local_gui_ready_ ? "attached" : "detached"); + continue; + } + if (!local_gui_ready_) continue; + if (op == "privacy_cursor") { + if (message.contains("requested")) + devices_.SetNativeCursorPolicy(host_cursor_default_, message.at("requested").get()); + const bool hidden = message.at("hidden").get(); + if (hidden != SharedDxgiCursorState().ExternalCursorHidden()) { + SharedDxgiCursorState().SetExternalCursorHidden(hidden); + LOG_INFO("Host cursor: user-session privacy cursor hidden={}", hidden); + } + continue; + } + if (op == "data" || op == "audio" || op == "disconnect") { + const auto id = message.at("id").get(); + if (!IsAuthorizedController(id)) continue; + { + std::lock_guard lock(local_host_state_mutex_); + const auto found = local_controller_info_.find(id); + if (found == local_controller_info_.end() || + message.at("epoch") != found->second.at("epoch")) continue; + } + if (op == "disconnect") { CloseServerController(id); continue; } + const auto& bytes = message.at("bytes").get_binary(); + const auto stream = message.at("stream").get(); + if (bytes.empty()) continue; + if (op == "audio") { + if (stream != audio_label_ || bytes.size() > 65536) continue; + WithController(id, [&](MiniRtcPeerPtr* peer, const std::string& wire) { + MiniRtcAudioFrame frame{}; + frame.data = reinterpret_cast(bytes.data()); + frame.size = bytes.size(); + frame.captured_timestamp = MiniRtcGetSystemTimeMicros(peer); + return MiniRtcSendAudioFrameToPeer(peer, &frame, audio_label_.c_str(), + wire.data(), wire.size()); + }); + } else if (stream == file_label_ || stream == file_feedback_label_ || + stream == clipboard_label_ || stream == web_clipboard::kStream || + stream == control_data_label_) { + SendControllerData(id, reinterpret_cast(bytes.data()), bytes.size(), stream.c_str()); + } + } else if (op == "signal") { + const auto text = message.at("message").get(); + const auto request = Json::parse(text, nullptr, false); + const auto type = request.is_object() ? request.value("type", Json{}) : Json{}; + // Authentication, credential changes and connection ownership stay + // exclusively in the persistent host's own signaling exchange. + if (peer_ && (type == "recent_connections_presence" || type == "announcements_list")) + MiniRtcSendSignalMessage(peer_, text.data(), text.size()); + } else if (op == "password") { + if (!RequestPasswordChange(message.at("password").get())) + error("Invalid host password"); + } else if (op == "config") { + const auto previous = config_center_->ExportSharedHostConfig(); + const auto server = ServerSettings(*config_center_); + bool ok = config_center_->ApplySharedHostConfig(message.at("config")) == 0; + const bool reconnect = server != ServerSettings(*config_center_); + if (ok && reconnect && HasActiveSession()) { + config_center_->ApplySharedHostConfig(previous); + ok = false; + } + if (ok) { + devices_.SetNativeCursorPolicy(message.value("privacy_on_connect", true), + host_cursor_preference_); + ok = PublishSharedIdentity(true); + ApplySettingsFromConfig(reconnect); + TickControllerTrust(); + } + if (!local_host_->Send({{"op", "config_result"}, {"ok", ok}, + {"request", message.at("request")}})) { + LOG_WARN("Cannot acknowledge host settings; reconnecting local frontend"); + local_host_->Reconnect(); + } + if (!ok) error("Could not apply host settings while a connection is active"); + } else if (op == "connection_settings") { + const auto turn = message.at("turn").get(); + if (peer_ && turn >= 0 && turn <= 3) + MiniRtcUpdateConnectionSettings(peer_, true, message.at("av1"), + static_cast(turn)); + } else if (op == "reconnect") { + if (peer_ && !HasActiveSession()) MiniRtcReconnectSignal(peer_); + } else if (op == "trust_add") { + if (!AddTrustedDevice(ReadDevice(message))) error("Could not add trusted device"); + } else if (op == "trust_remove") { + if (!RemoveTrustedDevice(message.at("key"))) error("Could not remove trusted device"); + } + continue; + } + + if (op == "attached" || op == "detached") { + SharedDxgiCursorState().SetExternalControllerAvailable(op == "attached"); + local_config_sent_ = false; + local_cursor_hidden_sent_.reset(); + local_cursor_request_sent_.reset(); + local_cursor_policy_ready_ = false; + } + if (op == "attached") { + local_host_->Send({{"op", "hello"}, + {"privacy_on_connect", config_center_->IsEnablePrivacyScreen()}, + {"privacy_requested", privacy_.Requested() || config_center_->IsEnablePrivacyScreen()}}); + } else if (op == "detached") { + SharedDxgiCursorState().SetExternalCursorHidden(false); + signal_connected_ = false; + device_presence_cache_.SetSignalConnected(false); + signal_status_ = MiniRtcSignalReconnecting; + std::vector ids; + { + std::lock_guard lock(local_host_state_mutex_); + for (const auto& [id, _] : mirrored_controllers_) ids.push_back(id); + } + for (const auto& id : ids) remove_mirror(id); + privacy_.Disconnected(); + } else if (op == "status" && peer_) { + // Retain omitted fields across heartbeats, including while a local + // settings request is pending. Detach/attach clears this snapshot. + local_host_status_.Merge(message); + const auto& status = local_host_status_.values; + // Apply before mirroring Connected/resuming privacy. The GUI then + // reuses this owner instead of launching another cursor hider. + SharedDxgiCursorState().SetExternalCursorHidden(status.value("cursor_owned", false)); + const bool online = status.at("online"); + if (online && !signal_connected_) need_to_send_recent_connections_ = true; + signal_connected_ = online; + device_presence_cache_.SetSignalConnected(online); + signal_status_ = online ? MiniRtcSignalConnected : MiniRtcSignalReconnecting; + if (pending_local_config_.is_null()) { + if (local_host_status_.config_dirty) { + const auto& config = status.at("config").get_ref(); + const bool changed = config != config_center_->ExportSharedHostConfig(); + if (!changed || config_center_->ApplySharedHostConfig(config) == 0) { + local_host_status_.config_dirty = false; + if (changed) ApplySettingsFromConfig(false); + } + } + if (!local_host_status_.config_dirty && local_host_status_.identity_dirty) { + // ApplyHostIdentity may activate the identity before a disk write + // fails. Retry persistence even if ActiveIdentity already matches. + local_host_status_.identity_dirty = !settings_.ApplyHostIdentity( + status.at("identity"), status.at("self_hosted")); + } + } + { + std::lock_guard lock(password_change_mutex_); + password_change_pending_ = status.at("password_pending"); + credential_recovery_in_progress_ = false; + credential_recovery_attempt_pending_ = false; + } + if (local_host_status_.controllers_dirty) { + bool applied = true; + const auto& controllers = status.at("controllers"); + if (!controllers.empty() && std::all_of(controllers.begin(), controllers.end(), + [](const auto& info) { return info.contains("host_info"); })) + local_cursor_policy_ready_ = true; + std::vector removed; + { + std::lock_guard lock(local_host_state_mutex_); + for (const auto& [id, _] : mirrored_controllers_) + if (!controllers.contains(id)) removed.push_back(id); + } + for (const auto& id : removed) remove_mirror(id); + for (const auto& [id, info] : controllers.items()) { + if (!ensure_mirror(id, info.at("epoch"))) { applied = false; continue; } + if (info.contains("verified")) { + std::lock_guard lock(controller_trust_mutex_); + auto& trust = controller_trust_[id]; + trust.events = peer_events_.get(); + trust.verified = ReadDevice(info.at("verified")); + } + for (const char* field : {"host_info", "preview"}) { + if (!info.contains(field)) continue; + bool changed = false; + { + std::lock_guard lock(local_host_state_mutex_); + auto& previous = local_controller_info_[id][field]; + changed = previous != info.at(field); + previous = info.at(field); + } + if (changed) { + const auto data = info.at(field).dump(); + deliver(id, control_data_label_, data.data(), data.size()); + } + } + } + local_host_status_.controllers_dirty = !applied; + } + if (trusted_devices_ && local_host_status_.trusted_dirty) { + std::vector current; + for (const auto& item : status.at("trusted")) current.push_back(ReadDevice(item)); + trusted_devices_->Replace(current); + local_host_status_.trusted_dirty = false; + } + if (IsCurrentSessionUserDesktopActive()) { + privacy_.CaptureChanged(status.at("capture")); + privacy_.ResumeAfterDesktopRecovery(); + } else privacy_.SuspendForDesktop(); + } else if (op == "data") { + const auto id = message.at("id").get(); + if (!ensure_mirror(id, message.at("epoch"))) continue; + const auto stream = message.at("stream").get(); + const auto& data = message.at("bytes").get_binary(); + deliver(id, stream, reinterpret_cast(data.data()), data.size()); + } else if (op == "signal" && peer_events_) { + const auto data = message.at("message").get(); + PeerEventHandler::OnSignalMessage(data.data(), data.size(), peer_events_.get()); + } else if (op == "config_result") { + if (!pending_local_config_.is_null() && message.at("request") == pending_local_config_.at("request")) { + pending_local_config_ = Json{}; + local_host_status_.config_dirty = local_host_status_.identity_dirty = true; + } + } else if (op == "error") { + error(message.at("message")); + } + } catch (const std::exception&) { + LOG_WARN("Ignored malformed local host message"); + } + } + if (managed_host_frontend_ && !pending_local_config_.is_null() && !local_config_sent_) + local_config_sent_ = local_host_->Send(pending_local_config_); + if (managed_host_frontend_) { + // Report only the GUI's own helper and privacy intent. Echoing a borrowed + // host owner back as GUI-owned would create a feedback loop on release. + const bool hidden = SharedDxgiCursorState().LocalPrivacyCursorHidden(); + const bool requested = privacy_.Requested(); + if (requested || !config_center_->IsEnablePrivacyScreen()) local_cursor_policy_ready_ = true; + if (local_cursor_hidden_sent_ != hidden || + (local_cursor_policy_ready_ && local_cursor_request_sent_ != requested)) { + Json command{{"op", "privacy_cursor"}, {"hidden", hidden}}; + if (local_cursor_policy_ready_) command["requested"] = requested; + if (local_host_->Send(command)) { + local_cursor_hidden_sent_ = hidden; + if (local_cursor_policy_ready_) local_cursor_request_sent_ = requested; + } + } + } + if (unattended_host_ && GetTickCount64() >= next_local_host_status_) { + next_local_host_status_ = GetTickCount64() + 250; + PublishLocalHostStatus(); + if (show_offline_warning_window_) { + error(offline_warning_text_); + show_offline_warning_window_ = false; + } + } +} +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp b/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp index 97a7dfcb..cb91dedc 100644 --- a/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp +++ b/apps/desktop/src/platform/windows/gui/application/unattended_application.cpp @@ -5,11 +5,14 @@ #include "rd_log.h" #include "unattended_config.h" +#include "shared_identity.h" +#include "platform/windows/winrt_apartment.h" namespace crossdesk { int GuiApplication::RunUnattended(void* stop_event) { - if (!stop_event || !IsLocalSystemProcess() || !IsUnattendedEnabled() || + if (!stop_event || !IsLocalSystemProcess() || + (!IsUnattendedEnabled() && !SharedHostIdentity::GuiPresent()) || !PrepareUnattendedDirectory(false)) return 1; unattended_host_ = true; @@ -19,12 +22,23 @@ int GuiApplication::RunUnattended(void* stop_event) { path_manager_ = std::make_unique("CrossDesk"); const auto root = UnattendedDataPath(); - cache_path_ = root.string(); + std::string identity_error; + shared_identity_ = SharedHostIdentity::AcquireHost(identity_error); + if (!shared_identity_) { + LOG_ERROR("Cannot acquire the unattended host identity: {}", identity_error); + return 1; + } + cache_path_ = shared_identity_->CachePath().string(); exec_log_path_ = (root / L"logs").string(); dll_log_path_ = exec_log_path_; InitializeLogger(); + LOG_INFO("Unattended host initialized in session {}", session); + // Reconnect starts WGC on this main thread, whereas the first unlock can + // start it on the capture recovery thread. Both need their own WinRT scope. + ScopedWinRtApartment apartment; + if (!apartment.ready()) return 1; config_center_ = std::make_unique(cache_path_ + "/config.ini"); - InitializeTrustedDevices(root / "trusted-devices"); + InitializeTrustedDevices(shared_identity_->TrustedDevicesPath()); if (config_center_->SetPrivacyScreen(false) != 0 || config_center_->SetSaveRemotePreviews(false) != 0) return 1; @@ -34,19 +48,22 @@ int GuiApplication::RunUnattended(void* stop_event) { // video driver, a tray icon, or a logged-in user's audio/clipboard session. if (!SDL_Init(SDL_INIT_EVENTS)) return 1; devices_.Initialize(); + local_host_ = std::make_shared(true); ULONGLONG last_status = 0; ULONGLONG next_peer_attempt = 0; while (WaitForSingleObject(stop_event, 16) == WAIT_TIMEOUT) { HandleSessionCleanup(); HandlePasswordChangeResult(); HandleCredentialRecovery(); + TickLocalHost(); const auto now = GetTickCount64(); if (!peer_ && now >= next_peer_attempt) { next_peer_attempt = now + 3000; if (CreateConnectionPeer() != 0) CloseConnectionPeer(); } TickControllerTrust(); - HandleConnectionStatusChange(); + // The GUI owns presence/announcement subscriptions on this transport. + // Publishing an empty headless subscription would replace its device list. HandleScreenLock(); HandleWindowsServiceIntegration(); devices_.UpdateInteractions(); @@ -70,6 +87,7 @@ int GuiApplication::RunUnattended(void* stop_event) { remote_input_.Stop(); CloseAllRemoteSessions(); WaitForSessionCleanup(); + local_host_.reset(); keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit"); devices_.DestroyDevices(); devices_.DestroyFactories(); diff --git a/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp b/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp index 1b8cb0b2..be2a8cc5 100644 --- a/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp +++ b/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp @@ -295,6 +295,7 @@ void GuiRuntime::HandleWindowsServiceIntegration() { BuildWindowsServiceStatusAction(broadcast_status); std::string msg = remote_action.to_json(); for (const auto& id : ConnectedControllers()) { + if (managed_host_frontend_ && id.rfind("lan/", 0) != 0) continue; const int ret = SendControllerData(id, msg.data(), msg.size(), control_data_label_.c_str()); if (ret != 0) diff --git a/apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp b/apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp index e64a1e5b..899a8e1b 100644 --- a/apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp +++ b/apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp @@ -30,15 +30,18 @@ DWORD FailureCode() { } // namespace int wmain(int argc, wchar_t** argv) { - if (argc != 5) return ERROR_INVALID_PARAMETER; + if (argc != 5 && argc != 6) return ERROR_INVALID_PARAMETER; HANDLE parent = ParseHandle(argv[1]); HANDLE stop = ParseHandle(argv[2]); HANDLE ready = ParseHandle(argv[3]); HANDLE mapping = ParseHandle(argv[4]); + HANDLE capture_owner = argc == 6 ? ParseHandle(argv[5]) : nullptr; // Observe the inherited process OBJECT, never a reused PID or a process name. if (!parent || !stop || !ready || !mapping || WaitForSingleObject(parent, 0) != WAIT_TIMEOUT || - WaitForSingleObject(stop, 0) != WAIT_TIMEOUT) + WaitForSingleObject(stop, 0) != WAIT_TIMEOUT || + (argc == 6 && (!capture_owner || + WaitForSingleObject(capture_owner, 0) != WAIT_TIMEOUT))) return ERROR_INVALID_HANDLE; auto shared = static_cast(MapViewOfFile( mapping, FILE_MAP_WRITE, 0, 0, sizeof(crossdesk::PrivacyCursorReady))); @@ -50,12 +53,13 @@ int wmain(int argc, wchar_t** argv) { InterlockedExchange(&shared->error, static_cast(error)); if (!SetEvent(ready)) error = FailureCode(); if (!error) { - HANDLE watched[] = {parent, stop}; + HANDLE watched[] = {parent, stop, capture_owner}; + const DWORD count = capture_owner ? 3 : 2; for (;;) { const DWORD result = - MsgWaitForMultipleObjects(2, watched, FALSE, 250, QS_ALLINPUT); - if (result == WAIT_OBJECT_0 || result == WAIT_OBJECT_0 + 1) break; - if (result == WAIT_OBJECT_0 + 2) { + MsgWaitForMultipleObjects(count, watched, FALSE, 250, QS_ALLINPUT); + if (result < WAIT_OBJECT_0 + count) break; + if (result == WAIT_OBJECT_0 + count) { MSG message{}; while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&message); @@ -79,5 +83,6 @@ int wmain(int argc, wchar_t** argv) { CloseHandle(ready); CloseHandle(stop); CloseHandle(parent); + if (capture_owner) CloseHandle(capture_owner); return static_cast(error); } diff --git a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp index 0c45c08a..86015a8b 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp +++ b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp @@ -39,9 +39,34 @@ PrivacyCursorGuard::~PrivacyCursorGuard() { if (stop_event_) CloseHandle(stop_event_); } -bool PrivacyCursorGuard::Start(std::string& error) { - if (process_ || restore_pending_) +bool PrivacyCursorGuard::Start(std::string& error, const std::wstring& desktop, + HANDLE capture_owner) { + if (process_ || restore_pending_ || delegated_) return Failed("Cursor guard already active", error, ERROR_BUSY); + desktop_ = desktop; + if (!capture_owner && SharedDxgiCursorState().ExternalControllerAvailable()) { + // The managed GUI's privacy request reaches the host on its next tick. + // Give that owner time to become ready before falling back to a local + // helper (e.g. a GUI-only LAN session). Keep the privacy window responsive. + const auto deadline = GetTickCount64() + 1000; + while (!SharedDxgiCursorState().ExternalCursorHidden() && + SharedDxgiCursorState().ExternalControllerAvailable() && + GetTickCount64() < deadline) { + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + Sleep(10); + } + } + // A managed GUI can reuse the persistent host's confirmed cursor guard. + // It must not start a second helper that restores the shared cursor later. + // Secure capture with its own client lifetime always owns its watchdog. + if (!capture_owner && SharedDxgiCursorState().ExternalCursorHidden()) { + delegated_ = true; + return true; + } helper_failure_reported_ = false; std::vector module(32768); const DWORD count = GetModuleFileNameW(nullptr, module.data(), @@ -52,10 +77,14 @@ bool PrivacyCursorGuard::Start(std::string& error) { L"crossdesk_privacy_cursor_helper.exe"; SECURITY_ATTRIBUTES security{sizeof(security), nullptr, TRUE}; Handle parent; + Handle owner; if (!DuplicateHandle(GetCurrentProcess(), GetCurrentProcess(), GetCurrentProcess(), &parent.value, SYNCHRONIZE, TRUE, 0)) return Failed("Duplicate parent process handle", error); + if (capture_owner && !DuplicateHandle(GetCurrentProcess(), capture_owner, + GetCurrentProcess(), &owner.value, SYNCHRONIZE, TRUE, 0)) + return Failed("Duplicate capture owner process handle", error); Handle stop{CreateEventW(&security, TRUE, FALSE, nullptr)}; Handle ready{CreateEventW(&security, TRUE, FALSE, nullptr)}; Handle mapping{CreateFileMappingW(INVALID_HANDLE_VALUE, &security, @@ -73,6 +102,8 @@ bool PrivacyCursorGuard::Start(std::string& error) { std::vector attributes(bytes); STARTUPINFOEXW startup{}; startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.lpDesktop = desktop.empty() + ? nullptr : const_cast(desktop.c_str()); startup.lpAttributeList = reinterpret_cast(attributes.data()); if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, @@ -81,10 +112,11 @@ bool PrivacyCursorGuard::Start(std::string& error) { UnmapViewOfFile(shared); return Failed("Initialize cursor guard handle list", error, code); } - HANDLE inherited[] = {parent.value, stop.value, ready.value, mapping.value}; + std::vector inherited{parent.value, stop.value, ready.value, mapping.value}; + if (owner.value) inherited.push_back(owner.value); bool ok = UpdateProcThreadAttribute( startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, - inherited, sizeof(inherited), nullptr, nullptr) != FALSE; + inherited.data(), inherited.size() * sizeof(HANDLE), nullptr, nullptr) != FALSE; PROCESS_INFORMATION child{}; bool used_job_fallback = false; if (ok) { @@ -140,6 +172,11 @@ bool PrivacyCursorGuard::RestoreAfterFailure(std::string& error) { } bool PrivacyCursorGuard::Healthy(std::string& error) { + if (delegated_) { + if (SharedDxgiCursorState().ExternalCursorHidden()) return true; + delegated_ = false; + return Start(error, desktop_); + } if (process_ && WaitForSingleObject(process_, 0) == WAIT_TIMEOUT) return true; if (restore_pending_ || (process_ && !helper_failure_reported_)) RestoreAfterFailure(error); @@ -149,6 +186,10 @@ bool PrivacyCursorGuard::Healthy(std::string& error) { } bool PrivacyCursorGuard::Stop(std::string& error) { + if (delegated_) { + delegated_ = false; + return true; + } if (!process_) return !restore_pending_ || RestoreAfterFailure(error); if (!SetEvent(stop_event_)) return Failed("Stop cursor helper", error); if (WaitForSingleObject(process_, 2000) != WAIT_OBJECT_0) { diff --git a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h index dea99e7d..75ff6ba4 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h +++ b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h @@ -13,18 +13,22 @@ namespace crossdesk { -// Called only on the privacy message thread. The child owns cursor suppression -// and restores it when either this guard stops it or its parent process exits. +// Owned by one privacy or capture thread. The child owns cursor suppression +// and restores it on Stop(), parent exit, or optional capture-owner exit. class PrivacyCursorGuard { public: PrivacyCursorGuard() = default; PrivacyCursorGuard(const PrivacyCursorGuard&) = delete; PrivacyCursorGuard& operator=(const PrivacyCursorGuard&) = delete; ~PrivacyCursorGuard(); - bool Start(std::string& error); + // Capture on Winlogon can specify its bound desktop and an additional + // process whose exit must restore the cursor, even if capture is blocked. + bool Start(std::string& error, const std::wstring& desktop = {}, + HANDLE capture_owner = nullptr); bool Healthy(std::string& error); bool Stop(std::string& error); - bool stopped() const { return !process_ && !restore_pending_; } + bool stopped() const { return !delegated_ && !process_ && !restore_pending_; } + bool delegated() const { return delegated_; } private: bool RestoreAfterFailure(std::string& error); @@ -32,6 +36,8 @@ class PrivacyCursorGuard { HANDLE stop_event_ = nullptr; bool restore_pending_ = false; bool helper_failure_reported_ = false; + bool delegated_ = false; + std::wstring desktop_; }; } // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/screen_capturer/capture_backend_policy.h b/apps/desktop/src/platform/windows/screen_capturer/capture_backend_policy.h index 5b213a08..2e1ccfb3 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/capture_backend_policy.h +++ b/apps/desktop/src/platform/windows/screen_capturer/capture_backend_policy.h @@ -7,25 +7,18 @@ #ifndef _CAPTURE_BACKEND_POLICY_H_ #define _CAPTURE_BACKEND_POLICY_H_ -#include +#include #include "screen_capture_method.h" namespace crossdesk { -inline std::vector WindowsCaptureBackendOrder( - ScreenCaptureMethod requested, bool headless, bool usbmmidd) { - if (requested != ScreenCaptureMethod::Auto) return {requested}; - if (headless) return {ScreenCaptureMethod::Gdi}; - // usbmmidd may put its software cursor directly into a DXGI/GDI image. - // WGC can exclude it at capture time. Keep WGC for both cursor modes so a - // native client joining/leaving never needs to inherit an embedded cursor. - // Do not silently fall back to a backend that cannot exclude that cursor. - if (usbmmidd) return {ScreenCaptureMethod::Wgc}; +inline constexpr std::array WindowsCaptureBackendOrder() { + // One policy for physical/virtual outputs, headless and secure desktops. return {ScreenCaptureMethod::Dxgi, ScreenCaptureMethod::Wgc, ScreenCaptureMethod::Gdi}; } } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/platform/windows/screen_capturer/display_presence.h b/apps/desktop/src/platform/windows/screen_capturer/display_presence.h index 1aaf0db2..877c1e2d 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/display_presence.h +++ b/apps/desktop/src/platform/windows/screen_capturer/display_presence.h @@ -41,12 +41,13 @@ inline DisplayPresence ClassifyDisplayPresence( const DisplayPresenceProbe& probe, bool capture_stalled = false) { if (probe.query_succeeded) { if (probe.available_targets > 0) { - // NVIDIA can retain both availability AND EDID/interface identity after - // the last unplug. Those describe a target, not a connected monitor. - // Require stalled capture plus a desktop adapter with no active monitor, - // and exempt built-in/wireless/IDD targets and failed target queries. - // A remaining physical monitor (even without EDID) prevents takeover. - if (capture_stalled && probe.desktop_devices > 0 && + // KVMs can leave an available phantom target with no monitor identity. + // NVIDIA can also retain an old EDID after unplug; only that ambiguous + // case needs stalled capture as additional evidence. An active monitor, + // built-in/IDD target, or failed target query still prevents takeover. + const bool unidentified_only = probe.identified_targets == 0 && + probe.unidentified_targets == probe.available_targets; + if ((unidentified_only || capture_stalled) && probe.desktop_devices > 0 && probe.attached_monitors == 0 && probe.embedded_or_virtual_targets == 0 && probe.target_query_failures == 0) { diff --git a/apps/desktop/src/platform/windows/screen_capturer/dxgi_cursor_state.h b/apps/desktop/src/platform/windows/screen_capturer/dxgi_cursor_state.h index c5f8a938..c6188de8 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/dxgi_cursor_state.h +++ b/apps/desktop/src/platform/windows/screen_capturer/dxgi_cursor_state.h @@ -19,7 +19,7 @@ class DxgiCursorState { // a separate pointer update. Preserve this output-specific default across // privacy restoration; an explicit DXGI update can still override it. software_cursor_monitor_ = software_cursor ? monitor : nullptr; - embedded_monitor_ = privacy_cursor_hidden_ ? nullptr : software_cursor_monitor_; + embedded_monitor_ = HiddenLocked() ? nullptr : software_cursor_monitor_; } void Update(int64_t last_mouse_update_time, bool separate_pointer_visible, @@ -28,7 +28,7 @@ class DxgiCursorState { // pointer state until DXGI actually reports another pointer update. if (last_mouse_update_time == 0) return; std::lock_guard lock(mutex_); - if (privacy_cursor_hidden_) return; + if (HiddenLocked()) return; embedded_monitor_ = separate_pointer_visible ? nullptr : monitor; } @@ -41,10 +41,57 @@ class DxgiCursorState { void SetPrivacyCursorHidden(bool hidden) { std::lock_guard lock(mutex_); if (privacy_cursor_hidden_ == hidden) return; + const bool previous = HiddenLocked(); privacy_cursor_hidden_ = hidden; + RefreshHiddenState(previous); + } + + void SetExternalCursorHidden(bool hidden) { + std::lock_guard lock(mutex_); + if (external_cursor_hidden_ == hidden) return; + const bool previous = HiddenLocked(); + external_cursor_hidden_ = hidden; + RefreshHiddenState(previous); + } + + bool LocalPrivacyCursorHidden() const { + std::lock_guard lock(mutex_); + return privacy_cursor_hidden_; + } + + bool ExternalCursorHidden() const { + std::lock_guard lock(mutex_); + return external_cursor_hidden_; + } + + void SetExternalControllerAvailable(bool available) { + std::lock_guard lock(mutex_); + external_controller_available_ = available; + } + bool ExternalControllerAvailable() const { + std::lock_guard lock(mutex_); + return external_controller_available_; + } + + uint64_t CursorVisibilityRevision() const { + std::lock_guard lock(mutex_); + return visibility_revision_; + } + + private: + bool HiddenLocked() const { return privacy_cursor_hidden_ || external_cursor_hidden_; } + void RefreshHiddenState(bool previous) { + if (previous == HiddenLocked()) return; + ++visibility_revision_; // Discard stale pointer-plane updates, then restore the output's default. // A software-cursor IDD may never send a fresh pointer-plane update. - embedded_monitor_ = hidden ? nullptr : software_cursor_monitor_; + embedded_monitor_ = HiddenLocked() ? nullptr : software_cursor_monitor_; + } + + public: + bool PrivacyCursorHidden() const { + std::lock_guard lock(mutex_); + return HiddenLocked(); } bool ShouldDrawCursor(bool system_cursor_visible, void* cursor_monitor) const { @@ -53,7 +100,7 @@ class DxgiCursorState { // CURSOR_SHOWING. DXGI Visible=false then means hidden, not embedded in the // captured frame. Keep sending the application's cursor to the controller. // CURSOR_SHOWING still takes precedence when the application hides it. - if (privacy_cursor_hidden_) return system_cursor_visible; + if (HiddenLocked()) return system_cursor_visible; // A hidden cursor or a pointer on another monitor also makes DXGI report // Visible=false. Only suppress a visible cursor on the captured monitor. return system_cursor_visible && @@ -65,6 +112,9 @@ class DxgiCursorState { void* embedded_monitor_ = nullptr; void* software_cursor_monitor_ = nullptr; bool privacy_cursor_hidden_ = false; + bool external_cursor_hidden_ = false; + bool external_controller_available_ = false; + uint64_t visibility_revision_ = 0; }; inline DxgiCursorState& SharedDxgiCursorState() { diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp index 5e5e36fe..ba062170 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp @@ -300,15 +300,25 @@ bool ScreenCapturerDxgi::CreateDuplicationForMonitor(int monitor_index) { } void ScreenCapturerDxgi::ReleaseDuplication() { - SharedDxgiCursorState().Reset(); + // A stopped candidate may be destroyed after its replacement has started. + // Only the live duplication owns the shared pointer-plane state. + if (duplication_) SharedDxgiCursorState().Reset(); ++duplication_generation_; staging_.Reset(); - if (duplication_) { - duplication_->ReleaseFrame(); - } + ReleaseAcquiredFrame(); duplication_.Reset(); } +HRESULT ScreenCapturerDxgi::ReleaseAcquiredFrame() { + if (!frame_acquired_) return S_OK; + frame_acquired_ = false; + const HRESULT result = duplication_->ReleaseFrame(); + if (FAILED(result)) + LOG_WARN("DXGI: ReleaseFrame failed, hr=0x{:08X}", + static_cast(result)); + return result; +} + bool ScreenCapturerDxgi::ConvertFrame(int frame_monitor, bool* cursor_embedded, CursorFrameCompositor& compositor) { *cursor_embedded = false; @@ -417,6 +427,7 @@ void ScreenCapturerDxgi::CaptureLoop() { uint64_t cached_generation = 0; auto next_topology_check = std::chrono::steady_clock::now(); unsigned acquire_failures = 0; + auto cursor_revision = SharedDxgiCursorState().CursorVisibilityRevision(); while (running_) { if (paused_) { cached_frame_valid = false; @@ -429,7 +440,13 @@ void ScreenCapturerDxgi::CaptureLoop() { std::unique_lock capture_lock(switch_mutex_); if (paused_ || !running_) continue; const auto now = std::chrono::steady_clock::now(); - const bool refresh = topology_refresh_requested_.exchange(false); + const auto revision = SharedDxgiCursorState().CursorVisibilityRevision(); + // An IDD can bake the pointer into the last desktop texture. Retire that + // cached texture when the user helper hides/restores the system cursor; + // pointer-only updates must not keep replaying its old image. + const bool refresh = topology_refresh_requested_.exchange(false) || + revision != cursor_revision; + cursor_revision = revision; if (refresh || now >= next_topology_check) { next_topology_check = now + std::chrono::milliseconds(500); const bool changed = EnumerateDisplays(); @@ -452,6 +469,23 @@ void ScreenCapturerDxgi::CaptureLoop() { } const int frame_monitor = monitor_index_.load(); + const auto deliver_cached = [&] { + if (!cached_frame_valid || cached_generation != duplication_generation_ || + cached_monitor != monitor_index_.load() || !callback_ || !nv12_frame_) + return; + // Recompose from the clean staging texture while showing a cursor, or + // once after disabling it. Never replay a baked-in stale cursor. + if ((show_cursor_.load(std::memory_order_relaxed) || cached_cursor_embedded) && + !ConvertFrame(cached_monitor, &cached_cursor_embedded, cursor_compositor)) { + cached_frame_valid = false; + return; + } + const auto stream_id = MakeDisplayStreamId(cached_monitor); + capture_lock.unlock(); + CapturedCursorFrameScope cursor_scope(cached_cursor_embedded); + callback_(nv12_frame_, nv12_width_ * nv12_height_ * 3 / 2, nv12_width_, + nv12_height_, stream_id.c_str(), nullptr); + }; DXGI_OUTDUPL_FRAME_INFO frame_info{}; Microsoft::WRL::ComPtr desktop_resource; HRESULT hr = duplication_->AcquireNextFrame( @@ -459,30 +493,13 @@ void ScreenCapturerDxgi::CaptureLoop() { if (hr == DXGI_ERROR_WAIT_TIMEOUT) { // DXGI explicitly reports an unchanged desktop. Re-emit only a frame // successfully acquired for this monitor to keep delivering static frames. - if (cached_frame_valid && cached_generation == duplication_generation_ && - cached_monitor == monitor_index_.load() && callback_ && nv12_frame_) { - // Recompose from the clean staging texture while showing a cursor, or - // once after disabling it. Never replay a baked-in stale cursor. - if (show_cursor_.load(std::memory_order_relaxed) || - cached_cursor_embedded) { - if (!ConvertFrame(cached_monitor, &cached_cursor_embedded, - cursor_compositor)) { - cached_frame_valid = false; - continue; - } - } - const auto stream_id = MakeDisplayStreamId(cached_monitor); - capture_lock.unlock(); - CapturedCursorFrameScope cursor_scope(cached_cursor_embedded); - callback_(nv12_frame_, nv12_width_ * nv12_height_ * 3 / 2, nv12_width_, - nv12_height_, stream_id.c_str(), nullptr); - } + deliver_cached(); continue; } // Never replay a cached image after an acquisition/conversion failure or // a backend rebuild. Display recovery retains the session privacy state. - cached_frame_valid = false; if (FAILED(hr)) { + cached_frame_valid = false; LOG_ERROR("DXGI: AcquireNextFrame failed, hr={}", (int)hr); ReleaseDuplication(); // A single ACCESS_LOST (mode switch, fullscreen toggle, UAC return) is @@ -494,6 +511,7 @@ void ScreenCapturerDxgi::CaptureLoop() { } continue; } + frame_acquired_ = true; acquire_failures = 0; if (frame_monitor >= 0 && @@ -504,16 +522,31 @@ void ScreenCapturerDxgi::CaptureLoop() { display_info_list_[frame_monitor].handle); } + // A successful acquire can contain only a cursor update. It is not a new + // desktop image; in particular, do not seed a newly rebuilt capture with + // its surface. Keep the last confirmed desktop until a present arrives. + if (frame_info.LastPresentTime.QuadPart == 0) { + if (FAILED(ReleaseAcquiredFrame())) { + cached_frame_valid = false; + ReleaseDuplication(); + next_topology_check = std::chrono::steady_clock::now(); + } else { + deliver_cached(); + } + continue; + } + cached_frame_valid = false; + Microsoft::WRL::ComPtr acquired_tex; if (desktop_resource) { hr = desktop_resource->QueryInterface( IID_PPV_ARGS(acquired_tex.GetAddressOf())); if (FAILED(hr)) { - duplication_->ReleaseFrame(); + ReleaseAcquiredFrame(); continue; } } else { - duplication_->ReleaseFrame(); + ReleaseAcquiredFrame(); continue; } @@ -530,7 +563,7 @@ void ScreenCapturerDxgi::CaptureLoop() { staging_.GetAddressOf()); if (FAILED(hr)) { LOG_ERROR("DXGI: CreateTexture2D staging failed, hr={}", (int)hr); - duplication_->ReleaseFrame(); + ReleaseAcquiredFrame(); continue; } } @@ -558,7 +591,13 @@ void ScreenCapturerDxgi::CaptureLoop() { last_logged_cursor_embedding_ = static_cast(cursor_embedded); } } - duplication_->ReleaseFrame(); + // A mode/desktop transition can invalidate the acquire during readback. + // Do not publish that frame if releasing it reports the lost capture. + if (FAILED(ReleaseAcquiredFrame())) { + ReleaseDuplication(); + next_topology_check = std::chrono::steady_clock::now(); + continue; + } capture_lock.unlock(); if (converted && frame_monitor == monitor_index_.load() && callback_) { diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h index e35ba543..36bfa738 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h @@ -66,6 +66,7 @@ class ScreenCapturerDxgi : public ScreenCapturer { bool ConvertFrame(int frame_monitor, bool* cursor_embedded, CursorFrameCompositor& compositor); void ReleaseDuplication(); + HRESULT ReleaseAcquiredFrame(); private: std::vector display_info_list_; @@ -76,6 +77,7 @@ class ScreenCapturerDxgi : public ScreenCapturer { Microsoft::WRL::ComPtr d3d_device_; Microsoft::WRL::ComPtr d3d_context_; Microsoft::WRL::ComPtr duplication_; + bool frame_acquired_ = false; // Guarded by switch_mutex_ while capturing. Microsoft::WRL::ComPtr staging_; DXGI_MODE_ROTATION rotation_ = DXGI_MODE_ROTATION_IDENTITY; uint64_t duplication_generation_ = 0; diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp index 69d21d87..3d399d5b 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp @@ -123,6 +123,7 @@ void ScreenCapturerGdi::CaptureLoop() { int interval_ms = fps_ > 0 ? (1000 / fps_) : 16; HDC screen_dc = GetDC(nullptr); bool dc_error_logged = false; + bool capture_error_logged = false; while (running_) { if (paused_) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); @@ -184,8 +185,21 @@ void ScreenCapturerGdi::CaptureLoop() { continue; } - BitBlt(mem_dc, 0, 0, width, height, screen_dc, left, top, - SRCCOPY | CAPTUREBLT); + if (!BitBlt(mem_dc, 0, 0, width, height, screen_dc, left, top, + SRCCOPY | CAPTUREBLT)) { + if (!capture_error_logged) { + LOG_WARN("GDI: BitBlt failed, error={}; reacquiring desktop DC", GetLastError()); + capture_error_logged = true; + } + SelectObject(mem_dc, old); + DeleteObject(dib); + DeleteDC(mem_dc); + ReleaseDC(nullptr, screen_dc); + screen_dc = nullptr; + std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); + continue; + } + capture_error_logged = false; bool cursor_embedded = false; if (show_cursor_) { diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp index 84d86bd4..860dc4d7 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp @@ -36,7 +36,9 @@ #include "service_host.h" #include "session_helper_shared.h" #include "../virtual_display/usbmmidd_virtual_display.h" -#include "wgc_plugin_api.h" +#include "wgc_plugin_capturer.h" +#include "../winrt_apartment.h" +#include "../privacy/privacy_cursor_guard.h" namespace crossdesk { @@ -58,141 +60,21 @@ constexpr int kSecureDesktopCaptureMinFps = 30; constexpr int kSecureDesktopCaptureMaxIntervalMs = 1000 / kSecureDesktopCaptureMinFps; -class CaptureThreadApartment { - public: - CaptureThreadApartment() : result_(RoInitialize(RO_INIT_MULTITHREADED)) { - if (FAILED(result_) && result_ != RPC_E_CHANGED_MODE) - LOG_WARN("Windows capturer: WinRT initialization failed, hr={}", - static_cast(result_)); - } - ~CaptureThreadApartment() { - if (SUCCEEDED(result_)) RoUninitialize(); +std::unique_ptr CreateCaptureBackend(ScreenCaptureMethod method) { + switch (method) { + case ScreenCaptureMethod::Dxgi: return std::make_unique(); + case ScreenCaptureMethod::Wgc: return WgcPluginCapturer::Create(); + case ScreenCaptureMethod::Gdi: return std::make_unique(); + default: return nullptr; } +} - private: - HRESULT result_; -}; - -class WgcPluginCapturer final : public ScreenCapturer { - public: - using CreateFn = ScreenCapturer* (*)(); - using DestroyFn = void (*)(ScreenCapturer*); - using SetCursorFn = decltype(&CrossDeskSetWgcCursorCapture); - using FrameCursorFn = decltype(&CrossDeskWgcFrameCapturesCursor); - - static std::unique_ptr Create() { - std::filesystem::path plugin_path; - wchar_t module_path[MAX_PATH] = {0}; - const DWORD len = GetModuleFileNameW(nullptr, module_path, MAX_PATH); - if (len == 0 || len >= MAX_PATH) { - return nullptr; - } - plugin_path = - std::filesystem::path(module_path).parent_path() / L"wgc_plugin.dll"; - - HMODULE module = LoadLibraryW(plugin_path.c_str()); - if (!module) { - return nullptr; - } - - auto create_fn = reinterpret_cast( - GetProcAddress(module, "CrossDeskCreateWgcCapturer")); - auto destroy_fn = reinterpret_cast( - GetProcAddress(module, "CrossDeskDestroyWgcCapturer")); - if (!create_fn || !destroy_fn) { - FreeLibrary(module); - return nullptr; - } - - ScreenCapturer* impl = create_fn(); - if (!impl) { - FreeLibrary(module); - return nullptr; - } - - return std::unique_ptr( - new WgcPluginCapturer(module, impl, destroy_fn)); - } - - ~WgcPluginCapturer() override { - if (impl_) { - destroy_fn_(impl_); - impl_ = nullptr; - } - if (module_) { - FreeLibrary(module_); - module_ = nullptr; - } - } - - int Init(const int fps, cb_desktop_data cb) override { - if (!impl_) return -1; - return impl_->Init(fps, [this, cb = std::move(cb)]( - unsigned char* data, int size, int width, int height, - const char* stream_id, const MiniRtcNativeVideoFrame* native_frame) { - bool embedded = false; - if (frame_cursor_fn_) { - embedded = frame_cursor_fn_(); - } else if (legacy_capture_cursor_.load(std::memory_order_relaxed)) { - CURSORINFO info{}; - info.cbSize = sizeof(info); - embedded = GetCursorInfo(&info) && (info.flags & CURSOR_SHOWING) != 0; - } - // The plugin has its own globals. Bridge only this callback's metadata - // into the executable before the wrapper accepts/publishes the frame. - CapturedCursorFrameScope cursor_scope(embedded); - cb(data, size, width, height, stream_id, native_frame); - }); - } - int Destroy() override { return impl_ ? impl_->Destroy() : 0; } - int Start(bool show_cursor) override { - if (!frame_cursor_fn_) { - legacy_capture_cursor_.store(show_cursor, std::memory_order_relaxed); - } - return impl_ ? impl_->Start(show_cursor) : -1; - } - int SetCursorCapture(bool enabled) { - if (!impl_) return -1; - if (set_cursor_fn_) return set_cursor_fn_(impl_, enabled); - // Older plugins only apply the cursor setting at startup. - const int ret = impl_->Stop(); - return ret == 0 ? Start(enabled) : ret; - } - int Stop() override { return impl_ ? impl_->Stop() : 0; } - int Pause(int monitor_index) override { - return impl_ ? impl_->Pause(monitor_index) : -1; - } - int Resume(int monitor_index) override { - return impl_ ? impl_->Resume(monitor_index) : -1; - } - std::vector GetDisplayInfoList() override { - return impl_ ? impl_->GetDisplayInfoList() : std::vector{}; - } - int SwitchTo(int monitor_index) override { - return impl_ ? impl_->SwitchTo(monitor_index) : -1; - } - int ResetToInitialMonitor() override { - return impl_ ? impl_->ResetToInitialMonitor() : -1; - } - - private: - WgcPluginCapturer(HMODULE module, ScreenCapturer* impl, DestroyFn destroy_fn) - : module_(module), impl_(impl), destroy_fn_(destroy_fn) { - set_cursor_fn_ = reinterpret_cast( - GetProcAddress(module, "CrossDeskSetWgcCursorCapture")); - frame_cursor_fn_ = reinterpret_cast( - GetProcAddress(module, "CrossDeskWgcFrameCapturesCursor")); - // Use the legacy restart path unless both parts of the extension exist. - if (!frame_cursor_fn_) set_cursor_fn_ = nullptr; - } - - HMODULE module_ = nullptr; - ScreenCapturer* impl_ = nullptr; - DestroyFn destroy_fn_ = nullptr; - SetCursorFn set_cursor_fn_ = nullptr; - FrameCursorFn frame_cursor_fn_ = nullptr; - std::atomic legacy_capture_cursor_{false}; -}; +ScreenCaptureMethod CaptureBackendMethod(const ScreenCapturer* capturer) { + if (dynamic_cast(capturer)) return ScreenCaptureMethod::Dxgi; + if (dynamic_cast(capturer)) return ScreenCaptureMethod::Wgc; + if (dynamic_cast(capturer)) return ScreenCaptureMethod::Gdi; + return ScreenCaptureMethod::Auto; +} const char* CaptureBackendName(const ScreenCapturer* capturer) { if (dynamic_cast(capturer)) return "DXGI"; @@ -213,6 +95,15 @@ bool CapturesUsbmmiddOutput(ScreenCapturer* capturer) { IsUsbmmiddDisplayDevice(info.szDevice); } +bool BackendCursorCapture(ScreenCapturer* capturer, bool requested) { + // GDI cannot exclude usbmmidd's software cursor. Compose and advertise it + // as part of the frame so native controllers suppress their extra cursor. + // DXGI already detects cursor embedding from the selected output's metadata. + return requested || (dynamic_cast(capturer) && + !SharedDxgiCursorState().PrivacyCursorHidden() && + CapturesUsbmmiddOutput(capturer)); +} + // Debug aid: CROSSDESK_FORCE_HEADLESS=1 makes a host that has monitors behave // as if it had none until a usbmmidd monitor is plugged, so the virtual // display path can be exercised without unplugging cables. Once the virtual @@ -227,15 +118,10 @@ bool ForcedHeadlessForTesting() { return forced && CountUsbmmiddMonitors() == 0; } -// Headless compatibility mode: no monitor is attached, so the desktop lives on -// a phantom target at an OS-chosen resolution. DXGI cannot duplicate it and -// WGC has no real monitor item; GDI reads the composed desktop. This is a -// best-effort baseline that needs no driver. Only an explicit Auto selection -// takes this shortcut; a pinned method keeps its own failure semantics. -bool DetectHeadlessDesktop(ScreenCaptureMethod method, - bool capture_stalled = false, +// Detect phantom/headless topology for virtual-display provisioning. +// This does not change the DXGI/WGC/GDI backend order. +bool DetectHeadlessDesktop(bool capture_stalled = false, bool log_probe = false) { - if (method != ScreenCaptureMethod::Auto) return false; if (ForcedHeadlessForTesting()) { static std::atomic logged{false}; if (!logged.exchange(true)) { @@ -486,12 +372,77 @@ bool QuerySecureDesktopHelperFrame( ScreenCapturerWin::ScreenCapturerWin() {} ScreenCapturerWin::~ScreenCapturerWin() { Destroy(); } +void ScreenCapturerWin::ReleaseNativeCursor() { + if (!native_cursor_guard_) return; + native_cursor_transition_.store(true, std::memory_order_release); + std::string error; + if (!native_cursor_guard_->Stop(error)) + LOG_WARN("Desktop cursor restoration pending: {}", error); + native_cursor_guard_.reset(); + native_cursor_owned_.store(false, std::memory_order_release); + native_cursor_transition_.store(false, std::memory_order_release); +} + +void ScreenCapturerWin::UpdateNativeCursor(ScreenCapturer* backend) { + const bool required = unattended_host_ && native_cursor_enabled_.load() && !show_cursor_.load() && + IsCurrentSessionUserDesktopActive() && + (virtual_display_.active() || CapturesUsbmmiddOutput(backend)); + if (!required) { + ReleaseNativeCursor(); + return; + } + std::string error; + if (native_cursor_guard_) { + if (native_cursor_guard_->delegated() && + !SharedDxgiCursorState().ExternalCursorHidden()) + native_cursor_transition_.store(true, std::memory_order_release); + if (native_cursor_guard_->Healthy(error)) { + native_cursor_owned_.store(!native_cursor_guard_->delegated(), std::memory_order_release); + native_cursor_transition_.store(false, std::memory_order_release); + return; + } + ReleaseNativeCursor(); + } + const auto now = GetTickCount64(); + if (now < next_native_cursor_retry_) return; + next_native_cursor_retry_ = now + 1000; + native_cursor_transition_.store(true, std::memory_order_release); + native_cursor_guard_ = std::make_unique(); + if (native_cursor_guard_->Start(error, L"winsta0\\Default")) { + native_cursor_owned_.store(!native_cursor_guard_->delegated(), std::memory_order_release); + LOG_INFO("Desktop cursor ready before capture: owner={}", + native_cursor_guard_->delegated() ? "GUI" : "persistent host"); + } else { + LOG_WARN("Desktop cursor exclusion unavailable: {}; retaining captured cursor", error); + native_cursor_guard_.reset(); + native_cursor_owned_.store(false, std::memory_order_release); + } + native_cursor_transition_.store(false, std::memory_order_release); +} + +bool ScreenCapturerWin::PrepareUnattendedDisplay() { + if (!unattended_host_ || + (!virtual_display_.active() && !DetectHeadlessDesktop())) return false; + virtual_display_.PrepareAcquire(); + std::string error; + if (!virtual_display_.Acquire(ResolveVirtualDisplayMode(), &error)) { + LOG_WARN("Cannot prepare unattended capture display: {}", error); + return false; + } + const auto name = GetDisplayLabel(FindUsbmmiddDisplayDeviceName()); + std::lock_guard lock(alias_mutex_); + virtual_display_name_ = name; + virtual_display_slot_ = name.empty() ? -1 : 0; + return !name.empty(); +} + void ScreenCapturerWin::NotifyPrivacyCapture(bool running) { if (privacy_) privacy_->CaptureChanged(running); } int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { + ResolveVirtualDisplayMode(); // Remember the attached mode before a later unplug/reboot. NotifyPrivacyCapture(false); fps_ = fps; cb_orig_ = cb; @@ -515,10 +466,14 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { } if (size == ScreenCapturer::kBackendReset) { SharedCapturedCursorState().Reset(); + backend_reset_pending_.store(true, std::memory_order_relaxed); // Backend recovery is not a user request to disable local privacy. return; } - if (data && size > 0 && w > 0 && h > 0) { + if (native_cursor_transition_.load(std::memory_order_acquire)) return; + if ((data || native_frame) && size > 0 && w > 0 && h > 0) { + backend_frame_received_.store(true, std::memory_order_relaxed); + backend_reset_pending_.store(false, std::memory_order_relaxed); last_capture_progress_tick_.store(GetTickCount64(), std::memory_order_relaxed); } @@ -572,6 +527,9 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { SharedCapturedCursorState().Update(current_frame_has_cursor, frame_monitor); if (post_secure_desktop_waiting_for_frame_.exchange( false, std::memory_order_relaxed)) { + // Keep the independent secure-desktop cursor until a valid normal + // frame commits the new source, after its cursor guard is ready. + SharedSecureDesktopCursorState().SetActive(false); const ULONGLONG start_tick = post_secure_desktop_started_tick_.exchange( 0, std::memory_order_relaxed); @@ -587,12 +545,13 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { native_frame); }; + JoinVirtualDisplayThread(); + const bool prepared_display = PrepareUnattendedDisplay(); impl_.reset(); - const bool headless = DetectHeadlessDesktop(capture_method_); - headless_compat_.store(headless, std::memory_order_relaxed); + const bool headless = DetectHeadlessDesktop(); int ret = -1; - for (const auto& create : PreferredBackends(headless)) { - std::unique_ptr candidate = create(); + for (const auto backend : WindowsCaptureBackendOrder()) { + auto candidate = CreateCaptureBackend(backend); ret = candidate ? candidate->Init(fps_, cb_) : -1; if (ret == 0) { impl_ = std::move(candidate); @@ -603,21 +562,38 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { } if (impl_) { LOG_INFO("Windows capturer: using {}{}", CaptureBackendName(impl_.get()), - headless ? " (headless compatibility mode)" : ""); + headless ? " (headless desktop)" : ""); BuildCanonicalFromImpl(); monitor_index_.store(0, std::memory_order_relaxed); + if (prepared_display) { + const auto name = virtual_display_name_; + const auto displays = impl_->GetDisplayInfoList(); + if (std::none_of(displays.begin(), displays.end(), [&name](const DisplayInfo& display) { + return display.name == name && display.width > 0 && display.height > 0; + })) { + LOG_ERROR("Prepared startup display '{}' is not available to capture", name); + return -1; + } + { + std::lock_guard lock(alias_mutex_); + canonical_displays_.erase(canonical_displays_.begin() + 1, canonical_displays_.end()); + } + RestoreMonitor(0); + const auto selected = GetDisplayInfoList().front(); + LOG_INFO("Startup display '{}' bound to Display1: {}x{}", + name, selected.width, selected.height); + } return 0; } - LOG_ERROR("Windows capturer: selected capture method {} failed, ret={}", - static_cast(capture_method_), ret); + LOG_ERROR("Windows capturer: DXGI/WGC/GDI initialization failed, ret={}", ret); return -1; } int ScreenCapturerWin::Destroy() { Stop(); - // Stop() releases the virtual display on a worker; finish it here so the - // provisioner is idle before its owner goes away. + if (unattended_host_) BeginVirtualDisplayRelease(); + // Finish display release before the provisioner and its owner go away. JoinVirtualDisplayThread(); paused_.store(false, std::memory_order_relaxed); if (impl_) { @@ -739,8 +715,11 @@ bool ScreenCapturerWin::TryStartBackend( candidate->SwitchTo(static_cast(found - displays.begin())); } if (ret == 0) { + UpdateNativeCursor(candidate.get()); + backend_frame_received_.store(false, std::memory_order_relaxed); + backend_reset_pending_.store(false, std::memory_order_relaxed); last_capture_progress_tick_.store(GetTickCount64(), std::memory_order_relaxed); - ret = candidate->Start(show_cursor); + ret = candidate->Start(BackendCursorCapture(candidate.get(), show_cursor)); } if (ret != 0) { LOG_WARN("Windows capturer: {} initialization/start failed (ret={})", name, @@ -754,76 +733,47 @@ bool ScreenCapturerWin::TryStartBackend( std::lock_guard lock(impl_mutex_); impl_ = std::move(candidate); } - applied_show_cursor_ = show_cursor; + applied_show_cursor_ = BackendCursorCapture(impl_.get(), show_cursor); RestoreMonitor(monitor_index); committed->store(true, std::memory_order_release); LOG_INFO("Windows capturer: started {}", name); + if (!required_display.empty()) + LOG_INFO("Windows capturer: virtual display '{}' captured via {}", + required_display, name); return true; } -std::vector -ScreenCapturerWin::PreferredBackends(bool headless, bool usbmmidd) const { - std::vector factories; - for (const auto backend : - WindowsCaptureBackendOrder(capture_method_, headless, usbmmidd)) { - switch (backend) { - case ScreenCaptureMethod::Dxgi: - factories.push_back([] { return std::make_unique(); }); - break; - case ScreenCaptureMethod::Wgc: - factories.push_back([] { return WgcPluginCapturer::Create(); }); - break; - case ScreenCaptureMethod::Gdi: - factories.push_back([] { return std::make_unique(); }); - break; - default: - break; - } - } - return factories; -} - -bool ScreenCapturerWin::StartPreferredBackend(bool headless, - int monitor_index, - bool usbmmidd) { - if (usbmmidd && capture_method_ == ScreenCaptureMethod::Auto) { - LOG_INFO("Windows capturer: usbmmidd uses WGC with cursor capture={}", - show_cursor_.load(std::memory_order_relaxed)); - } - for (const auto& create : PreferredBackends(headless, usbmmidd)) { - if (TryStartBackend(create(), monitor_index)) return true; +bool ScreenCapturerWin::StartPreferredBackend(int monitor_index, ScreenCaptureMethod after) { + bool include = after == ScreenCaptureMethod::Auto; + for (const auto backend : WindowsCaptureBackendOrder()) { + if (!include) { include = backend == after; continue; } + if (TryStartBackend(CreateCaptureBackend(backend), monitor_index)) return true; } return false; } -void ScreenCapturerWin::ResetPostSecureDesktopState() { - secure_desktop_capture_active_.store(false, std::memory_order_relaxed); +void ScreenCapturerWin::ResetPostSecureDesktopState(bool secure) { + secure_desktop_capture_active_.store(secure, std::memory_order_relaxed); post_secure_desktop_waiting_for_frame_.store(false, std::memory_order_relaxed); post_secure_desktop_started_tick_.store(0, std::memory_order_relaxed); - SharedSecureDesktopCursorState().SetActive(false); -} - -int ScreenCapturerWin::MarkStarted() { - running_.store(true, std::memory_order_relaxed); - NotifyPrivacyCapture(true); - ResetPostSecureDesktopState(); - if (!secure_capture_thread_.joinable()) { - secure_capture_thread_ = - std::thread([this]() { SecureDesktopCaptureLoop(); }); - } - return 0; + SharedSecureDesktopCursorState().SetActive(secure); } int ScreenCapturerWin::Start(bool show_cursor) { - if (!impl_) return -1; + if (!impl_) { + ReleaseNativeCursor(); + return -1; + } if (running_.load(std::memory_order_relaxed)) { return 0; } + native_cursor_enabled_.store(native_cursor_default_.load()); NotifyPrivacyCapture(false); show_cursor_.store(show_cursor, std::memory_order_relaxed); applied_show_cursor_ = show_cursor; + ResetPostSecureDesktopState(!IsCurrentSessionUserDesktopActive()); SharedCapturedCursorState().Reset(); paused_.store(false, std::memory_order_relaxed); invalid_stream_id_logged_.store(false, std::memory_order_relaxed); @@ -837,94 +787,34 @@ int ScreenCapturerWin::Start(bool show_cursor) { // A monitor may have been attached or removed since the last session. Pick // the backend for the desktop as it is now, not as it was at Init. JoinVirtualDisplayThread(); - { + // Reconcile the live target, not the display name cached at process startup. + if (!PrepareUnattendedDisplay()) { std::lock_guard lock(alias_mutex_); virtual_display_name_.clear(); virtual_display_slot_ = -1; } - const bool headless = DetectHeadlessDesktop(capture_method_, false, true); - const bool usbmmidd = !headless && CapturesUsbmmiddOutput(impl_.get()); - // The virtual display is provisioned off this thread (Start() runs on the - // UI thread and a driver install can take minutes). Begin in compatibility - // mode; MaybeAdoptVirtualDisplay() switches to a GPU backend once ready. + DetectHeadlessDesktop(false, true); virtual_display_ready_.store(false, std::memory_order_relaxed); display_probe_requested_.store(true, std::memory_order_relaxed); last_display_probe_tick_ = 0; last_capture_retry_tick_ = 0; last_virtual_adoption_tick_ = 0; headless_recovery_.Reset(); - const bool was_headless = - headless_compat_.exchange(headless, std::memory_order_relaxed); - const bool backend_is_gdi = dynamic_cast(impl_.get()) != nullptr; - int ret = 0; - const bool needs_wgc = usbmmidd && - capture_method_ == ScreenCaptureMethod::Auto && - dynamic_cast(impl_.get()) == nullptr; - // A prior session may have fallen back while RDP was changing its output. - // Retry the preferred GPU backend on a new connection instead of retaining - // WGC/GDI indefinitely. Keep the headless and usbmmidd cursor policies. - const bool needs_dxgi = capture_method_ == ScreenCaptureMethod::Auto && - !headless && !usbmmidd && - dynamic_cast(impl_.get()) == nullptr; - if (headless != was_headless || (headless && !backend_is_gdi) || needs_wgc || - needs_dxgi) { - LOG_INFO("Windows capturer: reselecting capture backend " - "(headless={}, usbmmidd={}, retry_dxgi={})", headless, usbmmidd, - needs_dxgi); - impl_->Destroy(); - // Keep an instance around on failure so the next Start can retry. - std::unique_ptr previous; - { - std::lock_guard lock(impl_mutex_); - previous = std::move(impl_); - } - if (!StartPreferredBackend(headless, requested_monitor, usbmmidd)) { - std::lock_guard lock(impl_mutex_); - impl_ = std::move(previous); - LOG_ERROR("Windows capturer: no capture backend could start"); - return -1; - } - return MarkStarted(); - } impl_->Destroy(); - ret = impl_->Init(fps_, cb_); - if (ret == 0) { - RebuildAliasesFromImpl(); - // Canonical wire/input slots may differ from this backend's refreshed - // enumeration. Select through the mapping before its first callback. - SwitchTo(requested_monitor); - last_capture_progress_tick_.store(GetTickCount64(), std::memory_order_relaxed); - ret = impl_->Start(show_cursor); + if (!StartPreferredBackend(requested_monitor)) { + LOG_ERROR("Windows capturer: DXGI/WGC/GDI could not start"); + ReleaseNativeCursor(); + return -1; } - if (ret != 0) { - if (capture_method_ != ScreenCaptureMethod::Auto) { - LOG_ERROR("Windows capturer: selected backend {} failed to start (ret={})", - CaptureBackendName(impl_.get()), ret); - return ret; - } - LOG_WARN("Windows capturer: refresh/start failed (ret={}), trying fallback", - ret); - - bool fallback_started = false; - if (usbmmidd) { - fallback_started = StartPreferredBackend(false, requested_monitor, true); - } else if (dynamic_cast(impl_.get()) && - TryStartBackend(WgcPluginCapturer::Create(), requested_monitor)) { - fallback_started = true; - } - if (!usbmmidd && !fallback_started && (dynamic_cast(impl_.get()) || - dynamic_cast(impl_.get()))) { - fallback_started = TryStartBackend(std::make_unique(), - requested_monitor); - } - - if (!fallback_started) { - LOG_ERROR("Windows capturer: all fallbacks failed to start"); - return ret; - } + applied_show_cursor_ = BackendCursorCapture( + impl_.get(), show_cursor_.load(std::memory_order_relaxed)); + running_.store(true, std::memory_order_relaxed); + NotifyPrivacyCapture(true); + if (!secure_capture_thread_.joinable()) { + secure_capture_thread_ = + std::thread([this]() { SecureDesktopCaptureLoop(); }); } - - return MarkStarted(); + return 0; } int ScreenCapturerWin::Stop() { @@ -943,11 +833,14 @@ int ScreenCapturerWin::Stop() { if (impl_) { ret = impl_->Stop(); } + ReleaseNativeCursor(); SharedCapturedCursorState().Reset(); StopSecureDesktopSharedCapture(secure_shared_session_id_); // After the backend released its duplication of the virtual output. The // unplug itself can take seconds; do not hold the UI thread for it. - BeginVirtualDisplayRelease(); + // Keep the machine host's display alive between controller connections. + // Destroy() releases it when the host hands off or exits. + if (!unattended_host_) BeginVirtualDisplayRelease(); return ret; } @@ -965,7 +858,7 @@ void ScreenCapturerWin::BeginVirtualDisplayProvisioning() { // A usbmmidd virtual monitor turns the phantom desktop into a real // display target, so the GPU backends and a chosen resolution apply. std::string error; - if (virtual_display_.Acquire(kDefaultVirtualDisplayMode, &error)) { + if (virtual_display_.Acquire(ResolveVirtualDisplayMode(), &error)) { virtual_display_ready_.store(true, std::memory_order_release); } else if (error != "cancelled") { LOG_WARN("Windows capturer: no usbmmidd virtual display ({}); " @@ -1013,14 +906,13 @@ void ScreenCapturerWin::MaybeAdoptVirtualDisplay() { virtual_display_name_ = virtual_name; virtual_display_slot_ = current_monitor; } - if (StartPreferredBackend(false, current_monitor, true)) { + if (StartPreferredBackend(current_monitor)) { + if (!IsCurrentSessionUserDesktopActive()) impl_->Stop(); virtual_display_ready_.store(false, std::memory_order_release); - headless_compat_.store(false, std::memory_order_relaxed); // The owner refreshes its display list and input mapping every tick from // GetDisplayInfoList(), so the new geometry is picked up without a hint. } else { - LOG_ERROR("Windows capturer: WGC could not start on usbmmidd; " - "will retry cursor-excluding capture"); + LOG_ERROR("Windows capturer: no backend could start on usbmmidd; will retry"); { std::lock_guard lock(alias_mutex_); virtual_display_name_.clear(); @@ -1030,8 +922,7 @@ void ScreenCapturerWin::MaybeAdoptVirtualDisplay() { std::lock_guard lock(impl_mutex_); impl_ = std::move(previous); } - // Retain the stopped instance for Stop()/the next retry, but do not start - // DXGI/GDI on the now-attached IDD: it can bake the cursor into the video. + // Retain the stopped instance for Stop() and the next ordered retry. } NotifyPrivacyCapture(true); } @@ -1074,6 +965,7 @@ int ScreenCapturerWin::SwitchTo(int monitor_index) { } int ScreenCapturerWin::ResetToInitialMonitor() { + if (unattended_host_ && virtual_display_.active()) return SwitchTo(0); std::lock_guard lock(impl_mutex_); if (!impl_) return -1; const int ret = impl_->ResetToInitialMonitor(); @@ -1230,69 +1122,20 @@ void ScreenCapturerWin::StopSecureCaptureThread() { bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() { NotifyPrivacyCapture(false); - if (!impl_ || !running_.load(std::memory_order_relaxed)) { - return false; - } - - const bool show_cursor = show_cursor_.load(std::memory_order_relaxed); - const int current_monitor = monitor_index_.load(std::memory_order_relaxed); - const bool usbmmidd = virtual_display_.active() || CapturesUsbmmiddOutput(impl_.get()); - const bool headless = DetectHeadlessDesktop(capture_method_, false, true); - headless_compat_.store(headless, std::memory_order_relaxed); - - LOG_INFO("Windows capturer: restarting capture backend after secure desktop"); + if (!impl_ || !running_.load(std::memory_order_relaxed)) return false; + const int monitor = monitor_index_.load(std::memory_order_relaxed); + LOG_INFO("Windows capturer: restarting DXGI/WGC/GDI after secure desktop"); impl_->Stop(); - if (capture_method_ == ScreenCaptureMethod::Auto && !headless && !usbmmidd && - dynamic_cast(impl_.get()) == nullptr) { - LOG_INFO("Windows capturer: desktop recovered; retrying DXGI before {}", - CaptureBackendName(impl_.get())); - if (StartPreferredBackend(headless, current_monitor, usbmmidd)) { - NotifyPrivacyCapture(true); - return true; - } - // Failed candidates retain the previous instance, which may still recover - // even if constructing a replacement failed during the display transition. - } - last_capture_progress_tick_.store(GetTickCount64(), std::memory_order_relaxed); - int ret = impl_->Start(show_cursor); - if (ret == 0) { - applied_show_cursor_ = show_cursor; - RestoreMonitor(current_monitor); + if (StartPreferredBackend(monitor)) { NotifyPrivacyCapture(true); return true; } - - LOG_WARN( - "Windows capturer: capture backend restart after secure desktop failed " - "(ret={}), rebuilding backend", - ret); - impl_->Destroy(); - ret = impl_->Init(fps_, cb_); - if (ret == 0) { - ret = impl_->Start(show_cursor); - } - if (ret == 0) { - applied_show_cursor_ = show_cursor; - RestoreMonitor(current_monitor); - NotifyPrivacyCapture(true); - return true; - } - - if (StartPreferredBackend(headless_compat_.load(std::memory_order_relaxed), - current_monitor, usbmmidd)) { - NotifyPrivacyCapture(true); - return true; - } - - LOG_WARN( - "Windows capturer: all backend restart attempts after secure desktop " - "failed (last_ret={})", - ret); + LOG_WARN("Windows capturer: all backend restart attempts failed"); + ReleaseNativeCursor(); return false; } void ScreenCapturerWin::CheckDisplayPresence(ULONGLONG now) { - if (capture_method_ != ScreenCaptureMethod::Auto) return; // Topology events accelerate the next probe, but driver rebuild callbacks // must not flood QueryDisplayConfig. Polling also covers WGC/GDI and unplug // events that NVIDIA does not expose as a changed DXGI output. @@ -1303,7 +1146,8 @@ void ScreenCapturerWin::CheckDisplayPresence(ULONGLONG now) { last_display_probe_tick_ = now; const bool topology_changed = display_probe_requested_.exchange(false, std::memory_order_relaxed); - if (!IsCurrentSessionUserDesktopActive()) { + const bool desktop_available = IsCurrentSessionUserDesktopActive(); + if (!desktop_available && !unattended_host_) { headless_recovery_.ShouldProvision(now, false, false, false); return; } @@ -1311,54 +1155,77 @@ void ScreenCapturerWin::CheckDisplayPresence(ULONGLONG now) { last_capture_progress_tick_.load(std::memory_order_relaxed); const bool stalled = last_progress != 0 && now >= last_progress && now - last_progress >= kCaptureStallTimeoutMs; - const bool headless = DetectHeadlessDesktop(capture_method_, stalled, + const bool headless = DetectHeadlessDesktop(stalled, topology_changed); + const bool owns_display = virtual_display_.active(); + bool target_ready = false; + if (owns_display) { + const auto current = GetDisplayLabel(FindUsbmmiddDisplayDeviceName()); + std::lock_guard lock(alias_mutex_); + target_ready = !current.empty() && current == virtual_display_name_; + } if (!headless_recovery_.ShouldProvision( - now, headless, virtual_display_busy_.load(std::memory_order_acquire), - virtual_display_.active())) return; + now, headless || (owns_display && !target_ready), + virtual_display_busy_.load(std::memory_order_acquire), target_ready)) return; if (!running_.load(std::memory_order_relaxed)) return; headless_recovery_.Attempted(now); - headless_compat_.store(true, std::memory_order_relaxed); + // The current backend has lost its usable target. Continue the same order + // while the virtual-display worker prepares a replacement target. + if (headless && desktop_available && impl_ && + dynamic_cast(impl_.get()) == nullptr) { + const int current_monitor = monitor_index_.load(std::memory_order_relaxed); + NotifyPrivacyCapture(false); + impl_->Stop(); + const bool started = StartPreferredBackend(current_monitor, CaptureBackendMethod(impl_.get())); + NotifyPrivacyCapture(started); + } LOG_WARN("Windows capturer: confirmed headless desktop during capture " "(stalled={}); provisioning usbmmidd", stalled); BeginVirtualDisplayProvisioning(); } void ScreenCapturerWin::CheckCaptureProgress(ULONGLONG now) { - if (capture_method_ != ScreenCaptureMethod::Auto || !running_.load()) return; - auto* dxgi = dynamic_cast(impl_.get()); - if (!dxgi) return; - - const auto last_progress = - last_capture_progress_tick_.load(std::memory_order_relaxed); - // A capture callback may have advanced the tick after this loop sampled now. - if (last_progress == 0 || now < last_progress || - now - last_progress < kCaptureStallTimeoutMs) return; - if (last_capture_retry_tick_ != 0 && - now - last_capture_retry_tick_ < kCaptureStallTimeoutMs) return; - // Probe/recovery is handled separately. When another real monitor remains, - // retry only the pinned output instead of silently selecting that monitor. - LOG_WARN("Windows capturer: DXGI has delivered no frame for {} ms; " - "refreshing the selected output", now - last_progress); - dxgi->RequestTopologyRefresh(); + if (!running_.load() || !impl_) return; + if (dynamic_cast(impl_.get()) && backend_frame_received_.load() && + !backend_reset_pending_.load()) return; // Static WGC images have no new events. + const auto last_progress = last_capture_progress_tick_.load(std::memory_order_relaxed); + if (!last_progress || now < last_progress || now - last_progress < kCaptureStallTimeoutMs) return; + if (last_capture_retry_tick_ && now - last_capture_retry_tick_ < kCaptureStallTimeoutMs) return; + if (auto* dxgi = dynamic_cast(impl_.get()); + dxgi && (!last_capture_retry_tick_ || last_progress > last_capture_retry_tick_)) { + LOG_WARN("Windows capturer: DXGI has no frame; refreshing the selected output"); + dxgi->RequestTopologyRefresh(); + } else { + const auto failed = CaptureBackendMethod(impl_.get()); + const int monitor = monitor_index_.load(std::memory_order_relaxed); + LOG_WARN("Windows capturer: {} has no frame; advancing DXGI/WGC/GDI fallback", CaptureBackendName(impl_.get())); + NotifyPrivacyCapture(false); + impl_->Stop(); + // Exhausting the sequence starts a fresh ordered recovery cycle. + const bool started = StartPreferredBackend(monitor, failed) || StartPreferredBackend(monitor); + NotifyPrivacyCapture(started); + if (!started) ReleaseNativeCursor(); + } last_capture_retry_tick_ = now; } void ScreenCapturerWin::ApplyCursorCaptureSetting() { const bool requested = show_cursor_.load(std::memory_order_relaxed); - if (!impl_ || requested == applied_show_cursor_) return; + if (!impl_) return; + const bool effective = BackendCursorCapture(impl_.get(), requested); + if (effective == applied_show_cursor_) return; int ret = 0; if (auto* gdi = dynamic_cast(impl_.get())) { - gdi->SetCursorCapture(requested); + gdi->SetCursorCapture(effective); } else if (auto* dxgi = dynamic_cast(impl_.get())) { - dxgi->SetCursorCapture(requested); + dxgi->SetCursorCapture(effective); } else if (auto* wgc = dynamic_cast(impl_.get())) { - ret = wgc->SetCursorCapture(requested); + ret = wgc->SetCursorCapture(effective); } if (ret == 0) { - applied_show_cursor_ = requested; - LOG_INFO("Windows capturer cursor capture updated: backend={}, enabled={}", - CaptureBackendName(impl_.get()), requested); + applied_show_cursor_ = effective; + LOG_INFO("Windows capturer cursor capture updated: backend={}, requested={}, effective={}", + CaptureBackendName(impl_.get()), requested, effective); } else { LOG_WARN("Windows capturer cursor update failed, ret={}; will retry", ret); } @@ -1434,6 +1301,7 @@ void ScreenCapturerWin::StopSecureDesktopSharedCapture(DWORD session_id) { secure_shared_start_pending_ = false; secure_shared_session_id_ = 0xFFFFFFFF; secure_shared_configuration_ = {}; + secure_shared_last_keepalive_ = 0; } bool ScreenCapturerWin::OpenSecureDesktopSharedFrame(DWORD session_id, @@ -1586,6 +1454,20 @@ bool ScreenCapturerWin::StartSecureDesktopSharedCapture( secure_shared_session_id_ == session_id && secure_shared_configuration_ == configuration && OpenSecureDesktopSharedFrame(session_id, mapping_size, error_out)) { + const auto now = GetTickCount64(); + if (now - secure_shared_last_keepalive_ >= 1000) { + // Reuse the idempotent start operation to renew the producer's lease. + // If a stop packet is lost, the helper still restores the local cursor. + secure_shared_last_keepalive_ = now; + std::vector response; + std::string error; + const auto command = BuildSecureCaptureStartCommand( + left, top, width, height, show_cursor, fps, stage, desktop); + const bool sent = QuerySecureDesktopHelperCommand(session_id, command, &response, &error); + const auto reply = Json::parse(response.begin(), response.end(), nullptr, false); + if (!sent || !reply.is_object() || !reply.value("ok", false)) + LOG_WARN("Secure capture keepalive failed; retaining available frames: {}", error); + } return true; } @@ -1622,6 +1504,7 @@ bool ScreenCapturerWin::StartSecureDesktopSharedCapture( secure_shared_capture_started_ = true; secure_shared_session_id_ = session_id; secure_shared_configuration_ = configuration; + secure_shared_last_keepalive_ = GetTickCount64(); if (!OpenSecureDesktopSharedFrame(session_id, mapping_size, error_out)) { StopSecureDesktopSharedCapture(session_id); @@ -1634,7 +1517,7 @@ bool ScreenCapturerWin::StartSecureDesktopSharedCapture( void ScreenCapturerWin::SecureDesktopCaptureLoop() { // Virtual-display adoption constructs WGC on this std::thread, which does // not inherit the UI thread's WinRT apartment. - CaptureThreadApartment apartment; + ScopedWinRtApartment apartment; const int frame_interval_ms = fps_ > 0 ? (std::min)(kSecureDesktopCaptureMaxIntervalMs, 1000 / fps_) : kSecureDesktopCaptureMaxIntervalMs; @@ -1647,7 +1530,6 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() { bool normal_backend_suspended = false; ULONGLONG post_secure_restart_deadline_tick = 0; ULONGLONG last_post_secure_restart_tick = 0; - ULONGLONG next_cursor_update_tick = 0; SecureDesktopServiceStatus status; SecureDesktopStatusPoller status_poller( QuerySecureDesktopServiceStatus, @@ -1743,7 +1625,8 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() { secure_desktop_capture_active_.store(status.capture_active, std::memory_order_relaxed); - SharedSecureDesktopCursorState().SetActive(status.capture_active); + if (status.capture_active) + SharedSecureDesktopCursorState().SetActive(true); if (status.capture_active != previous.capture_active || status.interactive_stage != previous.interactive_stage || status.active_session_id != previous.active_session_id || @@ -1816,12 +1699,18 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() { } } + if (unattended_host_ && !desktop_available) { + ReleaseNativeCursor(); + CheckDisplayPresence(now); + MaybeAdoptVirtualDisplay(); + } if (!status.capture_active || status.active_session_id == 0xFFFFFFFF) { StopSecureDesktopSharedCapture(secure_shared_session_id_); // Normal capture was intentionally unavailable on the secure desktop. // Do not schedule a second topology/stall rebuild during its recovery. if (!post_secure_restart_pending && desktop_available && !normal_backend_suspended) { + UpdateNativeCursor(impl_.get()); CheckDisplayPresence(now); MaybeAdoptVirtualDisplay(); CheckCaptureProgress(now); @@ -1853,9 +1742,8 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() { privacy_recovery_pending = false; if (privacy_) privacy_->ResumeAfterDesktopRecovery(); } - if (!post_secure_restart_pending && !normal_backend_suspended && - now >= next_cursor_update_tick) { - next_cursor_update_tick = now + 500; + // Apply once after any backend/desktop recovery in this iteration. + if (desktop_available && !post_secure_restart_pending && !normal_backend_suspended) { ApplyCursorCaptureSetting(); } std::this_thread::sleep_for( diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h index 704dc6db..25b490b8 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -31,6 +30,7 @@ namespace crossdesk { class CapturedNv12FramePool; +class PrivacyCursorGuard; class ScreenCapturerWin : public ScreenCapturer { public: @@ -55,27 +55,33 @@ class ScreenCapturerWin : public ScreenCapturer { std::vector GetDisplayInfoList() override; int GetCurrentMonitorIndex() const override; - // True while this session captures a usbmmidd monitor it plugged itself. - bool IsVirtualDisplayActive() const { return virtual_display_.active(); } + bool OwnsNativeCursor() const { return native_cursor_owned_.load(); } + void SetNativeCursorPolicy(bool default_enabled, bool current_enabled) { + native_cursor_default_.store(default_enabled); + native_cursor_enabled_.store(current_enabled); + } void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; } // Set before Init(), while capture is stopped. - void SetCaptureMethod(ScreenCaptureMethod method) { capture_method_ = method; } + void SetUnattendedHost(bool enabled) { unattended_host_ = enabled; } // Query synchronously inside the frame callback, on the delivering thread. // This describes the actual frame, independently of service status polling. static bool CurrentFrameIsFromSecureDesktop(); private: - using BackendFactory = std::function()>; std::unique_ptr impl_; // Serializes backend replacement (secure-desktop recovery runs on the // management thread) against the public entry points that dereference it. // Lock order: impl_mutex_ before alias_mutex_; never the reverse. std::mutex impl_mutex_; - ScreenCaptureMethod capture_method_ = ScreenCaptureMethod::Auto; - bool IsBackendEnabled(ScreenCaptureMethod method) const { - return capture_method_ == ScreenCaptureMethod::Auto || - capture_method_ == method; - } + bool unattended_host_ = false; + bool PrepareUnattendedDisplay(); + void UpdateNativeCursor(ScreenCapturer* backend); + void ReleaseNativeCursor(); + std::unique_ptr native_cursor_guard_; + std::atomic native_cursor_owned_{false}; + std::atomic native_cursor_transition_{false}; + std::atomic native_cursor_default_{true}, native_cursor_enabled_{true}; + ULONGLONG next_native_cursor_retry_ = 0; PrivacyController* privacy_ = nullptr; void NotifyPrivacyCapture(bool running); int fps_ = 60; @@ -92,6 +98,7 @@ class ScreenCapturerWin : public ScreenCapturer { std::atomic native_output_error_logged_{false}; std::atomic running_{false}; std::atomic last_capture_progress_tick_{0}; + std::atomic backend_frame_received_{false}, backend_reset_pending_{false}; std::atomic display_probe_requested_{false}; ULONGLONG last_display_probe_tick_ = 0; ULONGLONG last_capture_retry_tick_ = 0; @@ -99,13 +106,11 @@ class ScreenCapturerWin : public ScreenCapturer { std::atomic paused_{false}; std::atomic show_cursor_{true}; bool applied_show_cursor_ = true; - std::atomic headless_compat_{false}; - // Session-scoped: plugged on a headless desktop after Start() returns and - // released after Stop(), so an attached physical monitor is never shadowed - // between sessions. Both run on virtual_display_thread_ because the helper - // may need up to two minutes to install the driver; Start() itself begins - // in GDI compatibility mode and the management thread adopts the virtual - // display (switching to a GPU backend) once virtual_display_ready_ is set. + // GUI sessions provision on a worker and release after Stop(). Machine + // hosts prepare before signaling, revalidate before capture, and retain + // the monitor until Destroy(). Recovery also handles lost login targets. + // The management thread adopts the output once virtual_display_ready_ is + // set, trying the same ordered backends on the replacement target. VirtualDisplayProvisioner virtual_display_; std::thread virtual_display_thread_; std::atomic virtual_display_ready_{false}; @@ -128,23 +133,17 @@ class ScreenCapturerWin : public ScreenCapturer { SharedCaptureConfiguration secure_shared_configuration_; bool secure_shared_capture_started_ = false; bool secure_shared_start_pending_ = false; + ULONGLONG secure_shared_last_keepalive_ = 0; std::shared_ptr native_frame_pool_; void BuildCanonicalFromImpl(); void RebuildAliasesFromImpl(bool preserve_backend_slots = false); void RestoreMonitor(int monitor_index); - // Backends in preference order for the current desktop; each factory is - // invoked lazily so an unused WGC plugin is never loaded. - std::vector PreferredBackends(bool headless, - bool usbmmidd = false) const; bool TryStartBackend(std::unique_ptr candidate, int monitor_index); - bool StartPreferredBackend(bool headless, int monitor_index, - bool usbmmidd = false); - void ResetPostSecureDesktopState(); - // Common tail of every successful Start(): publish running state and - // launch the management thread. - int MarkStarted(); + bool StartPreferredBackend(int monitor_index, + ScreenCaptureMethod after = ScreenCaptureMethod::Auto); + void ResetPostSecureDesktopState(bool secure = false); void EmitCapturedFrame(unsigned char* data, int size, int width, int height, const char* stream_id, const MiniRtcNativeVideoFrame* native_frame = nullptr, diff --git a/apps/desktop/src/platform/windows/screen_capturer/secure_desktop_wgc_capture.h b/apps/desktop/src/platform/windows/screen_capturer/secure_desktop_wgc_capture.h new file mode 100644 index 00000000..6fd7242c --- /dev/null +++ b/apps/desktop/src/platform/windows/screen_capturer/secure_desktop_wgc_capture.h @@ -0,0 +1,168 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _SECURE_DESKTOP_WGC_CAPTURE_H_ +#define _SECURE_DESKTOP_WGC_CAPTURE_H_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "wgc_plugin_capturer.h" +#include "../winrt_apartment.h" +#include "../windows_thread_dpi.h" + +namespace crossdesk { + +// The helper's producer consumes the latest cropped NV12 image. The plugin +// remains responsible for WGC/WinRT and cursor exclusion, as in normal capture. +class SecureDesktopWgcCapture { + public: + ~SecureDesktopWgcCapture() { + if (stop_) SetEvent(stop_); + if (worker_.joinable()) worker_.join(); + // Close from the caller after the worker's windows have been destroyed. + if (desktop_) CloseDesktop(desktop_); + if (stop_) CloseHandle(stop_); + } + + int Initialize(int left, int top, int width, int height, int fps, bool cursor) { + if (width <= 0 || height <= 0 || width > 16384 || height > 16384 || + ((width | height) & 1) || worker_.joinable()) return -1; + DWORD bytes = 0; + const auto current = GetThreadDesktop(GetCurrentThreadId()); + GetUserObjectInformationW(current, UOI_NAME, nullptr, 0, &bytes); + if (!bytes) return -1; + std::vector name(bytes / sizeof(wchar_t) + 1); + if (!GetUserObjectInformationW(current, UOI_NAME, name.data(), bytes, &bytes)) return -1; + desktop_ = OpenDesktopW(name.data(), 0, FALSE, + DESKTOP_READOBJECTS | DESKTOP_WRITEOBJECTS | DESKTOP_CREATEWINDOW); + stop_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!desktop_ || !stop_) return -1; + std::promise ready; + auto initialized = ready.get_future(); + // WinRT may create thread-owned windows. Keep those off the producer, + // which must remain able to bind another input desktop after lock/unlock. + worker_ = std::thread([this, left, top, width, height, fps, cursor, + ready = std::move(ready)]() mutable { + if (!SetThreadDesktop(desktop_)) { ready.set_value(-1); return; } + ScopedWindowsPhysicalCoordinates coordinates; + ScopedWinRtApartment apartment; + int result = -1; + try { + if (apartment.ready()) result = InitializeWorker(left, top, width, height, fps, cursor); + } catch (...) { result = -1; } + ready.set_value(result); + if (result == 0) { + while (MsgWaitForMultipleObjects(1, &stop_, FALSE, INFINITE, QS_ALLINPUT) == WAIT_OBJECT_0 + 1) { + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + } + } + // Drain callbacks before either the apartment or frame storage goes away. + if (capture_) { capture_->Stop(); capture_->Destroy(); capture_.reset(); } + }); + return initialized.get(); + } + + private: + int InitializeWorker(int left, int top, int width, int height, int fps, bool cursor) { + capture_ = WgcPluginCapturer::Create(); + if (!capture_) return -1; + width_ = width; + height_ = height; + const int initialized = capture_->Init(fps, [this](unsigned char* data, + int size, int width, int height, const char* stream, const MiniRtcNativeVideoFrame*) { + if (size == ScreenCapturer::kBackendReset || size == ScreenCapturer::kDisplayTopologyChanged) { + std::lock_guard lock(mutex_); + invalidated_ = true; + return; + } + if (!data || size <= 0 || !stream || stream_ != stream) return; + std::lock_guard lock(mutex_); + if (x_ < 0 || y_ < 0 || width <= 0 || height <= 0 || + x_ + width_ > width || y_ + height_ > height || + static_cast(size) < static_cast(width) * height * 3 / 2) { + invalidated_ = true; + return; + } + const size_t luma = static_cast(width_) * height_; + pending_.resize(luma * 3 / 2); + for (int row = 0; row < height_; ++row) + std::memcpy(pending_.data() + static_cast(row) * width_, + data + static_cast(y_ + row) * width + x_, width_); + const auto* uv = data + static_cast(width) * height; + for (int row = 0; row < height_ / 2; ++row) + std::memcpy(pending_.data() + luma + static_cast(row) * width_, + uv + static_cast(y_ / 2 + row) * width + x_, width_); + pending_cursor_ = current_frame_has_cursor; + invalidated_ = false; + ++sequence_; + }); + if (initialized != 0) return initialized; + const auto displays = capture_->GetDisplayInfoList(); + for (size_t index = 0; index < displays.size(); ++index) { + const auto& display = displays[index]; + if (left < display.left || top < display.top || + static_cast(left) + width > display.right || + static_cast(top) + height > display.bottom) continue; + x_ = left - display.left; + y_ = top - display.top; + // The IPC regions are normally whole monitors. A region with an odd + // chroma offset, or spanning outputs, falls through to GDI. + if ((x_ | y_ | width | height) & 1) return -1; + stream_ = MakeDisplayStreamId(index); + const int selected = capture_->SwitchTo(static_cast(index)); + return selected == 0 ? capture_->Start(cursor) : selected; + } + return -1; + } + + public: + HRESULT Capture() { + std::lock_guard lock(mutex_); + updated_ = false; + if (invalidated_) return DXGI_ERROR_ACCESS_LOST; + if (!sequence_) return S_FALSE; + // A static desktop need not produce another WGC event. Retain the valid + // image until a new frame or an explicit reset, just like DXGI timeouts. + if (consumed_ != sequence_) { + pixels_.swap(pending_); + cursor_ = pending_cursor_; + consumed_ = sequence_; + updated_ = true; + } + return S_OK; + } + const std::vector& pixels() const { return pixels_; } + bool updated() const { return updated_; } + bool CursorEmbedded() const { return cursor_; } + + private: + std::unique_ptr capture_; + HDESK desktop_ = nullptr; + HANDLE stop_ = nullptr; + std::thread worker_; + std::mutex mutex_; + int width_ = 0, height_ = 0, x_ = 0, y_ = 0; + std::string stream_; + std::vector pending_, pixels_; + uint64_t sequence_ = 0, consumed_ = 0; + bool invalidated_ = false; + bool pending_cursor_ = false, cursor_ = false, updated_ = false; +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/platform/windows/screen_capturer/wgc_plugin_capturer.h b/apps/desktop/src/platform/windows/screen_capturer/wgc_plugin_capturer.h new file mode 100644 index 00000000..0ceb34ba --- /dev/null +++ b/apps/desktop/src/platform/windows/screen_capturer/wgc_plugin_capturer.h @@ -0,0 +1,134 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _WGC_PLUGIN_CAPTURER_H_ +#define _WGC_PLUGIN_CAPTURER_H_ + +#include +#include +#include +#include +#include +#include "captured_cursor_state.h" +#include "wgc_plugin_api.h" + +namespace crossdesk { +class WgcPluginCapturer final : public ScreenCapturer { + public: + using CreateFn = ScreenCapturer* (*)(); + using DestroyFn = void (*)(ScreenCapturer*); + using SetCursorFn = decltype(&CrossDeskSetWgcCursorCapture); + using FrameCursorFn = decltype(&CrossDeskWgcFrameCapturesCursor); + + static std::unique_ptr Create() { + wchar_t module_path[MAX_PATH] = {0}; + const DWORD len = GetModuleFileNameW(nullptr, module_path, MAX_PATH); + if (len == 0 || len >= MAX_PATH) { + return nullptr; + } + const auto plugin_path = + std::filesystem::path(module_path).parent_path() / L"wgc_plugin.dll"; + + HMODULE module = LoadLibraryW(plugin_path.c_str()); + if (!module) { + return nullptr; + } + + auto create_fn = reinterpret_cast( + GetProcAddress(module, "CrossDeskCreateWgcCapturer")); + auto destroy_fn = reinterpret_cast( + GetProcAddress(module, "CrossDeskDestroyWgcCapturer")); + if (!create_fn || !destroy_fn) { + FreeLibrary(module); + return nullptr; + } + + ScreenCapturer* impl = create_fn(); + if (!impl) { + FreeLibrary(module); + return nullptr; + } + + return std::unique_ptr( + new WgcPluginCapturer(module, impl, destroy_fn)); + } + + ~WgcPluginCapturer() override { + destroy_fn_(impl_); + FreeLibrary(module_); + } + + int Init(const int fps, cb_desktop_data cb) override { + return impl_->Init(fps, [this, cb = std::move(cb)]( + unsigned char* data, int size, int width, int height, + const char* stream_id, const MiniRtcNativeVideoFrame* native_frame) { + bool embedded = false; + if (frame_cursor_fn_) { + embedded = frame_cursor_fn_(); + } else if (legacy_capture_cursor_.load(std::memory_order_relaxed)) { + CURSORINFO info{}; + info.cbSize = sizeof(info); + embedded = GetCursorInfo(&info) && (info.flags & CURSOR_SHOWING) != 0; + } + // The plugin has its own globals. Bridge only this callback's metadata + // into the executable before the wrapper accepts/publishes the frame. + CapturedCursorFrameScope cursor_scope(embedded); + cb(data, size, width, height, stream_id, native_frame); + }); + } + int Destroy() override { return impl_->Destroy(); } + int Start(bool show_cursor) override { + if (!frame_cursor_fn_) { + legacy_capture_cursor_.store(show_cursor, std::memory_order_relaxed); + } + return impl_->Start(show_cursor); + } + int SetCursorCapture(bool enabled) { + if (set_cursor_fn_) return set_cursor_fn_(impl_, enabled); + // Older plugins only apply the cursor setting at startup. + const int ret = impl_->Stop(); + return ret == 0 ? Start(enabled) : ret; + } + int Stop() override { return impl_->Stop(); } + int Pause(int monitor_index) override { + return impl_->Pause(monitor_index); + } + int Resume(int monitor_index) override { + return impl_->Resume(monitor_index); + } + std::vector GetDisplayInfoList() override { + return impl_->GetDisplayInfoList(); + } + int SwitchTo(int monitor_index) override { + return impl_->SwitchTo(monitor_index); + } + int ResetToInitialMonitor() override { + return impl_->ResetToInitialMonitor(); + } + + private: + WgcPluginCapturer(HMODULE module, ScreenCapturer* impl, DestroyFn destroy_fn) + : module_(module), impl_(impl), destroy_fn_(destroy_fn) { + set_cursor_fn_ = reinterpret_cast( + GetProcAddress(module, "CrossDeskSetWgcCursorCapture")); + frame_cursor_fn_ = reinterpret_cast( + GetProcAddress(module, "CrossDeskWgcFrameCapturesCursor")); + // Use the legacy restart path unless both parts of the extension exist. + if (!frame_cursor_fn_) set_cursor_fn_ = nullptr; + } + + // Create() validates these before constructing the wrapper. + HMODULE module_; + ScreenCapturer* impl_; + DestroyFn destroy_fn_; + SetCursorFn set_cursor_fn_ = nullptr; + FrameCursorFn frame_cursor_fn_ = nullptr; + std::atomic legacy_capture_cursor_{false}; +}; + +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp b/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp index 2aaf3fd5..5e6f9063 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp @@ -193,8 +193,9 @@ int WgcSessionImpl::StartCaptureLocked(bool show_cursor) { capture_session_.StartCapture(); is_running_ = true; return 0; - } catch (const winrt::hresult_error&) { - LOG_ERROR("AE_WGC_CREATE_CAPTURER_FAILED"); + } catch (const winrt::hresult_error& error) { + LOG_ERROR("WGC: start capture failed, hr=0x{:08X}, thread={}", + static_cast(error.code().value), GetCurrentThreadId()); return 86; } catch (...) { LOG_ERROR("AE_WGC_CREATE_CAPTURER_FAILED"); @@ -296,9 +297,7 @@ void WgcSessionImpl::OnFrame( if (!d3d11_texture_mapped_ || is_new_size) { HRESULT tex_hr = CreateMappedTexture(frame_captured); if (FAILED(tex_hr)) { - OutputDebugStringW( - (L"CreateMappedTexture failed: " + std::to_wstring(tex_hr)) - .c_str()); + ReportFrameFailure(tex_hr); return; } } @@ -314,33 +313,38 @@ void WgcSessionImpl::OnFrame( , &map_result); if (FAILED(hr)) { - OutputDebugStringW( - (L"map resource failed: " + std::to_wstring(hr)).c_str()); + ReportFrameFailure(hr); return; } - // copy data from map_result.pData - if (map_result.pData && observer_) { - CURSORINFO cursor{}; - cursor.cbSize = sizeof(cursor); - const bool cursor_captured = - last_show_cursor_ && GetCursorInfo(&cursor) && cursor.hCursor && - (cursor.flags & CURSOR_SHOWING) != 0 && - (target_.is_window || - MonitorFromPoint(cursor.ptScreenPos, MONITOR_DEFAULTTONULL) == - target_.hmonitor); - observer_->OnFrame( - wgc_session_frame{static_cast(frame_size.Width), - static_cast(frame_size.Height), - map_result.RowPitch, - const_cast( - (unsigned char*)map_result.pData), - cursor_captured}, - id_); + { + struct UnmapOnExit { + ID3D11DeviceContext* context; + ID3D11Texture2D* texture; + ~UnmapOnExit() { context->Unmap(texture, 0); } + } unmap{d3d11_device_context_.get(), d3d11_texture_mapped_.get()}; + // copy data from map_result.pData + if (map_result.pData && observer_) { + CURSORINFO cursor{}; + cursor.cbSize = sizeof(cursor); + const bool cursor_captured = + last_show_cursor_ && GetCursorInfo(&cursor) && cursor.hCursor && + (cursor.flags & CURSOR_SHOWING) != 0 && + (target_.is_window || + MonitorFromPoint(cursor.ptScreenPos, MONITOR_DEFAULTTONULL) == + target_.hmonitor); + observer_->OnFrame( + wgc_session_frame{static_cast(frame_size.Width), + static_cast(frame_size.Height), + map_result.RowPitch, + const_cast( + (unsigned char*)map_result.pData), + cursor_captured}, + id_); + frame_failure_reported_ = false; + } } - d3d11_device_context_->Unmap(d3d11_texture_mapped_.get(), 0); - if (is_new_size) { capture_framepool_.Recreate( d3d11_direct_device_, @@ -348,13 +352,22 @@ void WgcSessionImpl::OnFrame( DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, capture_frame_size_); } - } catch (const winrt::hresult_error&) { - LOG_WARN("WGC: frame processing failed"); + } catch (const winrt::hresult_error& error) { + ReportFrameFailure(error.code()); } catch (...) { - LOG_WARN("WGC: frame processing failed"); + ReportFrameFailure(E_FAIL); } } +void WgcSessionImpl::ReportFrameFailure(HRESULT error) { + if (frame_failure_reported_) return; + frame_failure_reported_ = true; + LOG_WARN("WGC: frame processing failed, hr=0x{:08X}", static_cast(error)); + // Let both normal and secure capture advance to GDI rather than replay a + // stale image indefinitely after a readback/device failure. + if (observer_) observer_->OnFrame({}, id_); +} + void WgcSessionImpl::OnClosed( winrt::Windows::Graphics::Capture::GraphicsCaptureItem const&, winrt::Windows::Foundation::IInspectable const&) { @@ -409,8 +422,9 @@ int WgcSessionImpl::InitializeLocked() { GetDXGIInterfaceFromObject(d3d11_direct_device_); d3d11_device->GetImmediateContext(d3d11_device_context_.put()); - } catch (winrt::hresult_error) { - LOG_ERROR("AE_WGC_CREATE_CAPTURER_FAILED"); + } catch (const winrt::hresult_error& error) { + LOG_ERROR("WGC: create capture item failed, hr=0x{:08X}, thread={}", + static_cast(error.code().value), GetCurrentThreadId()); return 86; } catch (...) { return 86; diff --git a/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.h b/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.h index 9dc6f4b8..b7e4c690 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.h +++ b/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.h @@ -69,6 +69,7 @@ class WgcSessionImpl : public WgcSession { winrt::Windows::Foundation::IInspectable const&); int InitializeLocked(); + void ReportFrameFailure(HRESULT error); int StartLocked(bool show_cursor); int StartCaptureLocked(bool show_cursor); void StopLocked(); @@ -84,6 +85,7 @@ class WgcSessionImpl : public WgcSession { bool is_running_ = false; bool is_paused_ = false; bool last_show_cursor_ = false; + bool frame_failure_reported_ = false; wgc_session_observer* observer_ = nullptr; diff --git a/apps/desktop/src/platform/windows/service/autostart_control.cpp b/apps/desktop/src/platform/windows/service/autostart_control.cpp new file mode 100644 index 00000000..d5bb79fa --- /dev/null +++ b/apps/desktop/src/platform/windows/service/autostart_control.cpp @@ -0,0 +1,140 @@ +#include "platform/windows/service/autostart_control.h" + +#include + +#include + +#include "config_center.h" +#include "path_manager.h" +#include "platform/single_instance.h" +#include "platform/windows/autostart_task.h" +#include "platform/windows/virtual_display/usbmmidd_virtual_display.h" +#include "platform/autostart_backend.h" +#include "service_host.h" +#include "shared_identity.h" +#include "rd_log.h" +#include "unattended_config.h" + +namespace crossdesk { +namespace { +struct ServiceHandle { + SC_HANDLE value; + ~ServiceHandle() { if (value) CloseServiceHandle(value); } +}; + +bool ReadServiceStartType(DWORD& start_type) { + start_type = SERVICE_DEMAND_START; + ServiceHandle manager{OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT)}; + if (!manager.value) return false; + ServiceHandle service{OpenServiceW(manager.value, L"CrossDeskService", SERVICE_QUERY_CONFIG)}; + const DWORD open_error = service.value ? ERROR_SUCCESS : GetLastError(); + bool read = open_error == ERROR_SERVICE_DOES_NOT_EXIST; + if (service.value) { + alignas(QUERY_SERVICE_CONFIGW) BYTE buffer[8192]{}; + auto* config = reinterpret_cast(buffer); + DWORD size = 0; + read = QueryServiceConfigW(service.value, config, sizeof(buffer), &size) != FALSE; + if (read) start_type = config->dwStartType; + } + return read; +} + +bool ConfigureServiceAutostart(bool enabled) { + ServiceHandle manager{OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT)}; + if (!manager.value) return false; + ServiceHandle service{OpenServiceW(manager.value, L"CrossDeskService", SERVICE_CHANGE_CONFIG)}; + if (!service.value) return !enabled && GetLastError() == ERROR_SERVICE_DOES_NOT_EXIST; + return ChangeServiceConfigW(service.value, SERVICE_NO_CHANGE, + enabled ? SERVICE_AUTO_START : SERVICE_DEMAND_START, SERVICE_NO_CHANGE, + nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr) != FALSE; +} +} // namespace + +bool SupportsServiceAutostart() { + return platform::kAutostartRequiresElevation; +} + +bool IsServiceAutostartEnabled() { + if (!SupportsServiceAutostart() || !IsUnattendedEnabled() || + !IsSharedIdentityServiceConfigured() || !SharedHostIdentity::Configured()) return false; + DWORD start_type = 0; + return ReadServiceStartType(start_type) && start_type == SERVICE_AUTO_START; +} + +bool CleanupLegacyServiceAutostart() { + return !SupportsServiceAutostart() || !IsServiceAutostartEnabled() || + platform::MigrateLegacyAutostart("CrossDesk"); +} + +bool SetServiceAutostart(bool enabled, std::string& error) { + error.clear(); + if (enabled && !SupportsServiceAutostart()) { + error = "Unattended startup requires the installed Release build"; + return false; + } + if (!IsAdministratorProcess()) { + error = "Administrator privileges are required to configure boot startup"; + return false; + } + // GUI and CLI use the same operation and command lease. No second GUI + // executable is launched, and saving settings never stops a current service. + platform::SingleInstanceGuard command; + if (command.TryAcquire(platform::InstanceRole::kServiceCli, error) != + platform::InstanceResult::kAcquired) { + if (error.empty()) error = "Another service configuration is in progress"; + return false; + } + DWORD start_type = 0; + if (!ReadServiceStartType(start_type)) { + error = "Could not query Windows service startup configuration"; + return false; + } + if (!enabled) { + // Disarm the host without stopping the lock-screen service used by GUI + // sessions. A missing service/binary must not prevent disabling startup. + if (SetUnattendedEnabled(false) && ConfigureServiceAutostart(false) && + platform::RemoveInstalledAutostart("CrossDesk")) return true; + error = "Could not disable unattended startup"; + return false; + } + + const auto executable = platform::GetAutostartExecutablePath(); + const auto service = std::filesystem::u8path(executable).parent_path() / L"crossdesk_service.exe"; + std::error_code file_error; + if (executable.empty() || !std::filesystem::is_regular_file(service, file_error) || + !std::filesystem::is_regular_file(service.parent_path() / L"crossdesk_session_helper.exe", file_error)) { + error = "The Windows service or session helper is missing. Repair the installation"; + return false; + } + platform::SingleInstanceGuard bootstrap_client; + if (!SharedHostIdentity::GuiPresent() && + bootstrap_client.TryAcquire(platform::InstanceRole::kClient, error) != + platform::InstanceResult::kAcquired) { + error = "Enable boot startup from the running GUI settings, or close the GUI first"; + return false; + } + const bool current = IsSharedIdentityServiceConfigured() && + QueryCrossDeskServiceRegistration(service.wstring()) == ServiceRegistrationState::current; + if (!current && IsCrossDeskServiceInstalled() && !StopCrossDeskService(20000)) { + error = "Could not stop the old service to configure shared identity"; + return false; + } + PathManager paths("CrossDesk"); + ResolveVirtualDisplayMode(); // Persist the console mode while enabling boot startup. + ConfigCenter source((paths.GetCachePath() / "config.ini").string()); + if (!SharedHostIdentity::Prepare(paths.GetCachePath(), paths.GetConfigPath() / "trusted-devices", + source.ExportSharedHostConfig(), error)) return false; + const bool was_enabled = IsUnattendedEnabled(); + if (SetUnattendedEnabled(true) && + ((current && start_type == SERVICE_AUTO_START) || InstallCrossDeskService(service.wstring())) && + StartCrossDeskService() && platform::EnableAutostart("CrossDesk", executable)) return true; + + const bool restored_flag = SetUnattendedEnabled(was_enabled); + const bool restored_start = ConfigureServiceAutostart(start_type == SERVICE_AUTO_START); + if (!restored_flag || !restored_start) + LOG_ERROR("Could not restore the previous unattended startup configuration"); + error = "Could not enable persistent host startup and the user-session GUI"; + return false; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/service/autostart_control.h b/apps/desktop/src/platform/windows/service/autostart_control.h new file mode 100644 index 00000000..443ef181 --- /dev/null +++ b/apps/desktop/src/platform/windows/service/autostart_control.h @@ -0,0 +1,27 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-08 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _AUTOSTART_CONTROL_H_ +#define _AUTOSTART_CONTROL_H_ + +#include + +namespace crossdesk { + +// Windows startup preferences control the machine host and its GUI handoff. +// Portable and Debug builds keep the existing unattended-mode restriction. +bool SupportsServiceAutostart(); +bool IsServiceAutostartEnabled(); +// Shared GUI/CLI entry point; serializes service changes and preserves a +// running host when the installed service already matches this executable. +bool SetServiceAutostart(bool enabled, std::string& error); + +// Keep a legacy Run entry from bypassing the elevated GUI handoff task. +bool CleanupLegacyServiceAutostart(); + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/platform/windows/service/local_host_channel.cpp b/apps/desktop/src/platform/windows/service/local_host_channel.cpp new file mode 100644 index 00000000..9b3e4e28 --- /dev/null +++ b/apps/desktop/src/platform/windows/service/local_host_channel.cpp @@ -0,0 +1,274 @@ +#include "local_host_channel.h" + +#include +#include + +#include +#include +#include +#include "rd_log.h" + +namespace crossdesk { +namespace { +constexpr size_t kMaxPacket = 1024 * 1024; +constexpr size_t kMaxQueue = 8 * kMaxPacket; +struct Handle { + HANDLE value = INVALID_HANDLE_VALUE; + ~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); } +}; +std::wstring ImagePath(HANDLE process) { + std::wstring path(32768, L'\0'); + DWORD size = static_cast(path.size()); + if (!QueryFullProcessImageNameW(process, 0, path.data(), &size)) return {}; + path.resize(size); + return path; +} +std::vector User(HANDLE token) { + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector result(size); + if (!size || !GetTokenInformation(token, TokenUser, result.data(), size, &size)) + result.clear(); + return result; +} +} // namespace + +LocalHostChannel::LocalHostChannel(bool server) : server_(server) { + ProcessIdToSessionId(GetCurrentProcessId(), &session_); + stop_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (stop_ && session_) worker_ = std::thread([this] { Run(); }); +} +LocalHostChannel::~LocalHostChannel() { + if (stop_) SetEvent(stop_); + if (worker_.joinable()) worker_.join(); + if (stop_) CloseHandle(stop_); +} + +bool LocalHostChannel::Authenticate(HANDLE pipe, std::string& reason) const { + reason = "pipe peer PID unavailable"; + ULONG pid = 0; + if (!(server_ ? GetNamedPipeClientProcessId(pipe, &pid) + : GetNamedPipeServerProcessId(pipe, &pid))) return false; + DWORD session = 0; + reason = "pipe peer belongs to another session"; + if (!ProcessIdToSessionId(pid, &session) || session != session_) return false; + Handle process{OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)}; + const auto expected = ImagePath(GetCurrentProcess()); + reason = "pipe peer executable does not match this installation"; + if (!process.value || expected.empty() || + _wcsicmp(expected.c_str(), ImagePath(process.value).c_str())) return false; + Handle token; + reason = "pipe peer token cannot be queried"; + if (!OpenProcessToken(process.value, TOKEN_QUERY, &token.value)) return false; + const auto user = User(token.value); + if (user.empty()) return false; + const auto sid = reinterpret_cast(user.data())->User.Sid; + if (!server_) { + reason = "pipe server is not SYSTEM"; + return IsWellKnownSid(sid, WinLocalSystemSid); + } + // A different elevated user must not attach to this console user's files, + // clipboard, or host controls. The pipe DACL also excludes non-admin callers. + Handle console_token; + reason = "console user token unavailable"; + if (!WTSQueryUserToken(session_, &console_token.value)) return false; + const auto console_user = User(console_token.value); + reason = "pipe client is not the console user"; + return !console_user.empty() && EqualSid(sid, + reinterpret_cast(console_user.data())->User.Sid); +} + +bool LocalHostChannel::Read(HANDLE pipe, HANDLE event, void* data, DWORD size) { + if (!ResetEvent(event)) return false; + OVERLAPPED operation{}; + operation.hEvent = event; + DWORD completed = 0; + const BOOL ok = ReadFile(pipe, data, size, &completed, &operation); + if (!ok) { + if (GetLastError() != ERROR_IO_PENDING) return false; + HANDLE waits[] = {stop_, event}; + if (WaitForMultipleObjects(2, waits, FALSE, 1000) != WAIT_OBJECT_0 + 1) { + CancelIoEx(pipe, &operation); + GetOverlappedResult(pipe, &operation, &completed, TRUE); + return false; + } + if (!GetOverlappedResult(pipe, &operation, &completed, FALSE)) return false; + } + return completed == size; +} + +bool LocalHostChannel::Send(const nlohmann::json& message) { + if (!connected_) return false; + auto bytes = nlohmann::json::to_cbor(message); + if (bytes.empty() || bytes.size() > kMaxPacket) return false; + const uint32_t size = static_cast(bytes.size()); + bytes.insert(bytes.begin(), sizeof(size), 0); + std::memcpy(bytes.data(), &size, sizeof(size)); + std::lock_guard lock(mutex_); + if (!connected_ || outgoing_bytes_ + bytes.size() > kMaxQueue) return false; + outgoing_bytes_ += bytes.size(); + outgoing_.push_back(std::move(bytes)); + return true; +} +void LocalHostChannel::Reconnect() { + std::lock_guard lock(mutex_); + connected_ = false; +} +std::vector LocalHostChannel::Receive() { + std::lock_guard lock(mutex_); + std::vector result; + result.swap(incoming_); + incoming_bytes_ = 0; + return result; +} +bool LocalHostChannel::QueueIncoming(nlohmann::json message, size_t size) { + std::lock_guard lock(mutex_); + if (incoming_bytes_ + size > kMaxQueue) return false; + incoming_bytes_ += size; + incoming_.push_back(std::move(message)); + return true; +} +void LocalHostChannel::SetConnected(bool connected) { + std::lock_guard lock(mutex_); + connected_ = connected; + outgoing_.clear(); + outgoing_bytes_ = 0; + if (!connected) { + incoming_.clear(); + incoming_bytes_ = 0; + } + incoming_.push_back({{"op", connected ? "attached" : "detached"}}); +} + +void LocalHostChannel::Run() { + const auto name = L"\\\\.\\pipe\\CrossDesk.Host." + std::to_wstring(session_); + std::string last_auth_error; + while (WaitForSingleObject(stop_, 0) == WAIT_TIMEOUT) { + Handle pipe; + if (server_) { + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;GA;;;SY)(A;;GA;;;BA)", SDDL_REVISION_1, + &descriptor, nullptr)) return; + SECURITY_ATTRIBUTES security{sizeof(security), descriptor, FALSE}; + pipe.value = CreateNamedPipeW(name.c_str(), PIPE_ACCESS_DUPLEX | + FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, 65536, 65536, 0, &security); + LocalFree(descriptor); + if (pipe.value != INVALID_HANDLE_VALUE) { + Handle event{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; + OVERLAPPED connection{}; + connection.hEvent = event.value; + const BOOL accepted = ConnectNamedPipe(pipe.value, &connection); + const auto error = accepted ? ERROR_SUCCESS : GetLastError(); + if (error == ERROR_IO_PENDING) { + HANDLE waits[] = {stop_, event.value}; + if (WaitForMultipleObjects(2, waits, FALSE, INFINITE) != WAIT_OBJECT_0 + 1) { + CancelIoEx(pipe.value, &connection); + DWORD unused = 0; + GetOverlappedResult(pipe.value, &connection, &unused, TRUE); + return; + } + } else if (error != ERROR_SUCCESS && error != ERROR_PIPE_CONNECTED) { + WaitForSingleObject(stop_, 250); + continue; + } + } + } else { + pipe.value = CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED | + SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, nullptr); + } + if (pipe.value == INVALID_HANDLE_VALUE) { + WaitForSingleObject(stop_, 250); + continue; + } + std::string auth_error; + if (!Authenticate(pipe.value, auth_error)) { + if (auth_error != last_auth_error) + LOG_WARN("Local host channel authentication rejected: {}", auth_error); + last_auth_error = auth_error; + WaitForSingleObject(stop_, 250); + continue; + } + last_auth_error.clear(); + Handle read_event{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; + Handle write_event{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; + if (!read_event.value || !write_event.value) return; + SetConnected(true); + std::vector input; + std::vector output; + OVERLAPPED writing{}; + writing.hEvent = write_event.value; + bool write_pending = false; + ULONGLONG write_started = 0; + while (connected_ && WaitForSingleObject(stop_, 2) == WAIT_TIMEOUT) { + if (write_pending) { + DWORD completed = 0; + if (GetOverlappedResult(pipe.value, &writing, &completed, FALSE)) { + if (completed != output.size()) break; + write_pending = false; + output.clear(); + } else if (GetLastError() != ERROR_IO_INCOMPLETE || + GetTickCount64() - write_started > 5000) break; + } + if (!write_pending) { + std::lock_guard lock(mutex_); + if (!outgoing_.empty()) { + output = std::move(outgoing_.front()); + outgoing_.pop_front(); + outgoing_bytes_ -= output.size(); + } + } + if (!write_pending && !output.empty()) { + ResetEvent(write_event.value); + DWORD completed = 0; + if (WriteFile(pipe.value, output.data(), static_cast(output.size()), + &completed, &writing)) { + if (completed != output.size()) break; + output.clear(); + } else { + if (GetLastError() != ERROR_IO_PENDING) break; + write_pending = true; + write_started = GetTickCount64(); + } + } + // Drain the other direction even while a large write is waiting. Both + // processes may be sending files at once; blocking writes would deadlock. + DWORD available = 0; + if (!PeekNamedPipe(pipe.value, nullptr, 0, nullptr, &available, nullptr)) break; + if (!available) continue; + const DWORD count = std::min(available, 65536); + const auto offset = input.size(); + input.resize(offset + count); + if (!Read(pipe.value, read_event.value, input.data() + offset, count)) break; + bool valid = true; + size_t consumed = 0; + while (input.size() - consumed >= sizeof(uint32_t)) { + uint32_t size = 0; + std::memcpy(&size, input.data() + consumed, sizeof(size)); + if (!size || size > kMaxPacket) { valid = false; break; } + if (input.size() - consumed < sizeof(size) + size) break; + auto message = nlohmann::json::from_cbor( + input.begin() + consumed + sizeof(size), + input.begin() + consumed + sizeof(size) + size, true, false); + if (!message.is_object() || !QueueIncoming(std::move(message), size)) { + valid = false; + break; + } + consumed += sizeof(size) + size; + } + if (!valid) break; + input.erase(input.begin(), input.begin() + consumed); + } + if (write_pending) { + CancelIoEx(pipe.value, &writing); + DWORD completed = 0; + GetOverlappedResult(pipe.value, &writing, &completed, TRUE); + } + SetConnected(false); + if (server_) DisconnectNamedPipe(pipe.value); + } +} +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/service/local_host_channel.h b/apps/desktop/src/platform/windows/service/local_host_channel.h new file mode 100644 index 00000000..f97a7f2a --- /dev/null +++ b/apps/desktop/src/platform/windows/service/local_host_channel.h @@ -0,0 +1,53 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LOCAL_HOST_CHANNEL_H_ +#define _LOCAL_HOST_CHANNEL_H_ + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace crossdesk { + +// Authenticated, local-only link between the persistent SYSTEM host and the +// console user's GUI. Losing this link never tears down a remote RTC session. +// Application messages are consumed on the application's main thread. +class LocalHostChannel { + public: + explicit LocalHostChannel(bool server); + ~LocalHostChannel(); + bool Send(const nlohmann::json& message); + // Fail pending user-session operations and reconnect without closing RTC. + void Reconnect(); + std::vector Receive(); + + private: + void Run(); + bool Authenticate(HANDLE pipe, std::string& reason) const; + bool Read(HANDLE pipe, HANDLE event, void* bytes, DWORD size); + bool QueueIncoming(nlohmann::json message, size_t size); + void SetConnected(bool connected); + const bool server_; + DWORD session_ = 0; + HANDLE stop_ = nullptr; + std::thread worker_; + std::atomic connected_{false}; + std::mutex mutex_; + std::deque> outgoing_; + std::vector incoming_; + size_t outgoing_bytes_ = 0, incoming_bytes_ = 0; +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/platform/windows/service/service_host.cpp b/apps/desktop/src/platform/windows/service/service_host.cpp index 86d264f9..e1f0c81b 100644 --- a/apps/desktop/src/platform/windows/service/service_host.cpp +++ b/apps/desktop/src/platform/windows/service/service_host.cpp @@ -27,6 +27,7 @@ #include "secure_text_input.h" #include "unattended_config.h" #include "unattended_policy.h" +#include "shared_identity.h" #include "rdp_session_handoff.h" namespace crossdesk { @@ -991,6 +992,8 @@ int CrossDeskServiceHost::RunInConsole() { } int CrossDeskServiceHost::InitializeRuntime() { + if (IsUnattendedEnabled() && !PrepareUnattendedDirectoryForService()) + LOG_ERROR("Cannot prepare the protected machine identity directory"); stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); if (stop_event_ == nullptr) { DWORD error = GetLastError(); @@ -1177,7 +1180,8 @@ void CrossDeskServiceHost::ClientProcessMonitorLoop() { } const DWORD console_session = WTSGetActiveConsoleSessionId(); const auto action = - UnattendedAction(enabled, console_session, + UnattendedAction((enabled || SharedHostIdentity::GuiPresent()) && + SharedHostIdentity::Configured(), console_session, unattended_process_ != nullptr, unattended_session_); if (action == UnattendedProcessAction::stop) StopUnattendedHost(); if (action == UnattendedProcessAction::start) { diff --git a/apps/desktop/src/platform/windows/service/session_helper_main.cpp b/apps/desktop/src/platform/windows/service/session_helper_main.cpp index a66b7207..ebe71f2d 100644 --- a/apps/desktop/src/platform/windows/service/session_helper_main.cpp +++ b/apps/desktop/src/platform/windows/service/session_helper_main.cpp @@ -30,6 +30,8 @@ #include "session_helper_shared.h" #include "secure_text_input.h" #include "secure_desktop_dxgi_capture.h" +#include "secure_desktop_wgc_capture.h" +#include "capture_backend_policy.h" #include "secure_desktop_frame_pacer.h" #include "secure_desktop_nv12_cache.h" #include "service_lifetime.h" @@ -40,10 +42,12 @@ #include "usbmmidd_virtual_display.h" #include "windows_cursor_state.h" #include "cursor_draw.h" +#include "dxgi_cursor_state.h" #include "windows_input_marker.h" #include "windows_text_input.h" #include "windows_key_metadata.h" #include "../windows_thread_dpi.h" +#include "../privacy/privacy_cursor_guard.h" namespace { @@ -53,6 +57,7 @@ using crossdesk::ScopedInteractiveDesktop; using Json = nlohmann::json; constexpr DWORD kSessionHelperStatePollMs = 1000; +constexpr ULONGLONG kCaptureOwnerLeaseMs = 4000; std::atomic g_session_helper_desktop_switch_event{nullptr}; @@ -81,6 +86,8 @@ struct HelperState { struct SecureCaptureRequest : crossdesk::SharedCaptureConfiguration { std::string interactive_stage; std::string interactive_desktop; + std::shared_ptr owner_process; + DWORD owner_pid = 0; }; struct SecureMouseRequest { @@ -99,6 +106,7 @@ struct SecureSharedCaptureState { HANDLE frame_ready_event = nullptr; uint8_t* frame_view = nullptr; uint32_t sequence = 0; + std::atomic last_owner_refresh{0}; // Last member: joins/cleans the producer before any captured state is destroyed. crossdesk::SharedCaptureLifecycle lifecycle; }; @@ -1363,9 +1371,12 @@ std::vector BuildTextResponseBytes(const std::string& response) { } // Own the desktop before its capture objects, and release those objects before -// restoring the original desktop. The legacy pipe path remains GDI-only. +// restoring the original desktop. Shared and pipe capture use the same order. struct SecureDesktopCaptureResources { ScopedInteractiveDesktop desktop; + // Destroy before releasing the bound desktop, after capture objects. + std::unique_ptr cursor_guard; + bool cursor_hidden = false; HDC screen_dc = nullptr; HDC mem_dc = nullptr; HBITMAP dib = nullptr; @@ -1375,12 +1386,15 @@ struct SecureDesktopCaptureResources { DWORD error_code = ERROR_SUCCESS; crossdesk::SecureDesktopCursorSnapshot cursor_snapshot{}; std::unique_ptr dxgi; - ULONGLONG next_dxgi_attempt = 0; - ULONGLONG dxgi_started = 0; + std::unique_ptr wgc; + ULONGLONG next_gpu_attempt = 0, dxgi_started = 0, wgc_started = 0; + bool wgc_attempted = false; bool using_dxgi = false; + bool using_wgc = false; bool frame_pending = false; const uint8_t* frame_bits = nullptr; int frame_stride = 0; + const std::vector* frame_nv12 = nullptr; crossdesk::SecureDesktopNv12Cache nv12; ~SecureDesktopCaptureResources() { @@ -1399,7 +1413,7 @@ struct SecureDesktopCaptureResources { return false; } - bool Initialize(const SecureCaptureRequest& request) { + bool Initialize(const SecureCaptureRequest& request, bool shared = false) { const auto fallback = DesktopNameForInteractiveStage( request.interactive_stage, request.interactive_desktop); if (!desktop.Bind(fallback.empty() ? L"Winlogon" : fallback)) { @@ -1409,6 +1423,19 @@ struct SecureDesktopCaptureResources { SetLastError(ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION); return Fail("inactive_capture_desktop"); } + if (shared && !request.show_cursor && request.owner_process) { + cursor_guard = std::make_unique(); + std::string cursor_error; + cursor_hidden = cursor_guard->Start(cursor_error, + L"winsta0\\" + desktop.name(), request.owner_process.get()); + if (cursor_hidden) { + LOG_INFO("Secure capture: system cursor hidden on '{}'; native controller draws it", + WideToUtf8(desktop.name())); + } else { + LOG_WARN("Secure capture: cursor exclusion unavailable: {}", cursor_error); + cursor_guard.reset(); + } + } return true; } @@ -1434,48 +1461,101 @@ struct SecureDesktopCaptureResources { return true; } - bool Capture(const SecureCaptureRequest& request, bool allow_dxgi = false) { + bool Capture(const SecureCaptureRequest& request, bool wait_for_first_frame = false) { frame_pending = false; - const ULONGLONG now = GetTickCount64(); - if (allow_dxgi && !dxgi && now >= next_dxgi_attempt) { - next_dxgi_attempt = now + 5000; - auto candidate = std::make_unique(); - const HRESULT hr = candidate->Initialize( - request.left, request.top, request.width, request.height); - if (SUCCEEDED(hr)) { - dxgi = std::move(candidate); - dxgi_started = now; - } else { - LOG_INFO("Secure capture DXGI unavailable; using GDI, hr={}", - static_cast(hr)); - } - } - using_dxgi = false; - if (dxgi) { - const HRESULT hr = dxgi->Capture(); - using_dxgi = hr == S_OK; - if (FAILED(hr) || (hr == S_FALSE && now - dxgi_started >= 250)) { - LOG_INFO("Secure capture DXGI lost; using GDI, hr={}", - static_cast(hr)); + if (cursor_hidden) { + std::string cursor_error; + if (!cursor_guard->Healthy(cursor_error)) { + cursor_hidden = false; + cursor_guard.reset(); dxgi.reset(); - next_dxgi_attempt = now + 5000; - } else if (hr == S_FALSE) { - // DuplicateOutput often succeeds before the first desktop image is - // ready. A synchronous BitBlt here can stall the first frame for a - // desktop transition. Retry DXGI at the requested cadence instead. - frame_pending = true; - return false; + wgc.reset(); + next_gpu_attempt = 0; + wgc_attempted = false; + LOG_WARN("Secure capture: cursor helper stopped; restoring captured cursor: {}", + cursor_error); } } - if (!using_dxgi) { - if (!InitializeGdi(request)) return false; - if (!BitBlt(mem_dc, 0, 0, request.width, request.height, screen_dc, - request.left, request.top, SRCCOPY | CAPTUREBLT)) { - return Fail("bitblt_failed"); + const ULONGLONG now = GetTickCount64(); + const bool retry_gpu = now >= next_gpu_attempt; + if (retry_gpu) { + next_gpu_attempt = now + 5000; + wgc_attempted = false; + } + using_dxgi = using_wgc = false; + frame_nv12 = nullptr; + frame_bits = nullptr; + for (const auto backend : crossdesk::WindowsCaptureBackendOrder()) { + if (backend == crossdesk::ScreenCaptureMethod::Dxgi) { + if (!dxgi && retry_gpu) { + auto candidate = std::make_unique(); + const HRESULT result = candidate->Initialize(request.left, request.top, + request.width, request.height); + if (SUCCEEDED(result)) { + dxgi = std::move(candidate); + dxgi_started = GetTickCount64(); + } else LOG_INFO("Secure capture: DXGI unavailable, trying WGC, hr=0x{:08X}", + static_cast(result)); + } + if (!dxgi) continue; + const HRESULT result = dxgi->Capture(); + if (result == S_OK) { + using_dxgi = true; + wgc.reset(); + wgc_attempted = false; + frame_bits = dxgi->pixels(); + frame_stride = dxgi->stride(); + break; + } + if (result == S_FALSE && GetTickCount64() - dxgi_started < 250) { + // Keep an already usable fallback while the preferred GPU warms up. + if (wait_for_first_frame && !wgc && !bits) { + frame_pending = true; + return false; + } + } else { + LOG_INFO("Secure capture: DXGI lost, trying WGC, hr=0x{:08X}", static_cast(result)); + dxgi.reset(); + next_gpu_attempt = now + 5000; + } + } else if (backend == crossdesk::ScreenCaptureMethod::Wgc) { + if (!wgc && !wgc_attempted) { + wgc_attempted = true; + auto candidate = std::make_unique(); + const int result = candidate->Initialize(request.left, request.top, request.width, + request.height, request.fps, request.show_cursor); + if (result == 0) { + wgc = std::move(candidate); + wgc_started = GetTickCount64(); + LOG_INFO("Secure capture: WGC started"); + } else LOG_INFO("Secure capture: WGC unavailable, trying GDI, ret={}", result); + } + if (!wgc) continue; + const HRESULT result = wgc->Capture(); + if (result == S_OK) { + using_wgc = true; + frame_nv12 = &wgc->pixels(); + break; + } + if (result == S_FALSE && GetTickCount64() - wgc_started < 250) { + if (wait_for_first_frame && !bits) { + frame_pending = true; + return false; + } + } else { + LOG_INFO("Secure capture: WGC lost, trying GDI, hr=0x{:08X}", static_cast(result)); + wgc.reset(); + next_gpu_attempt = now + 5000; + } + } else { + if (!InitializeGdi(request)) return false; + if (!BitBlt(mem_dc, 0, 0, request.width, request.height, screen_dc, + request.left, request.top, SRCCOPY | CAPTUREBLT)) + return Fail("bitblt_failed"); + frame_bits = static_cast(bits); + frame_stride = request.width * 4; } } - frame_bits = using_dxgi ? dxgi->pixels() : static_cast(bits); - frame_stride = using_dxgi ? dxgi->stride() : request.width * 4; // Sample on the thread bound to the captured input desktop, including when // native controllers render the cursor themselves (show_cursor == false). cursor_snapshot = {}; @@ -1484,7 +1564,12 @@ struct SecureDesktopCaptureResources { if (GetCursorInfo(&cursor)) { static thread_local crossdesk::WindowsCursorState cursor_state; crossdesk::CursorState state{}; - const bool embedded = using_dxgi && dxgi->CursorEmbedded(cursor); + // MagShowSystemCursor hides the pointer without clearing CURSOR_SHOWING. + // A successfully hidden pointer is separate, regardless of the driver's + // usual software-cursor default on Winlogon. + const bool embedded = using_wgc ? wgc->CursorEmbedded() + : !(cursor_hidden || crossdesk::SharedDxgiCursorState().PrivacyCursorHidden()) && + using_dxgi && dxgi->CursorEmbedded(cursor); cursor_state.Sample(cursor, embedded, &state); cursor_snapshot.valid = 1; cursor_snapshot.visible = state.visible; @@ -1495,14 +1580,22 @@ struct SecureDesktopCaptureResources { cursor_snapshot.y = cursor.ptScreenPos.y; // Never draw into DXGI's cached desktop: each cursor position starts // with clean pixels, including on a static screen or after a toggle. - if (request.show_cursor && !embedded && using_dxgi && + if (request.show_cursor && !embedded && (using_dxgi || using_wgc) && (cursor.flags & CURSOR_SHOWING) != 0) { if (!InitializeGdi(request)) return false; const size_t row_bytes = static_cast(request.width) * 4; - for (int y = 0; y < request.height; ++y) { - std::memcpy(static_cast(bits) + y * row_bytes, - frame_bits + static_cast(y) * frame_stride, - row_bytes); + if (frame_nv12) { + const auto* pixels = frame_nv12->data(); + if (libyuv::NV12ToARGB(pixels, request.width, + pixels + static_cast(request.width) * request.height, + request.width, static_cast(bits), request.width * 4, + request.width, request.height) != 0) + return Fail("nv12_to_argb_failed"); + frame_nv12 = nullptr; + } else { + for (int y = 0; y < request.height; ++y) + std::memcpy(static_cast(bits) + y * row_bytes, + frame_bits + static_cast(y) * frame_stride, row_bytes); } frame_bits = static_cast(bits); frame_stride = request.width * 4; @@ -1517,7 +1610,7 @@ struct SecureDesktopCaptureResources { } } // Complete GDI writes before libyuv reads the DIB's pixel memory. - if (!using_dxgi || frame_bits == bits) GdiFlush(); + if ((!using_dxgi && !using_wgc) || (bits && frame_bits == bits)) GdiFlush(); return true; } }; @@ -1537,7 +1630,9 @@ std::vector CaptureSecureDesktopFrame( // cannot be reused. Convert straight into the response payload. std::vector response(sizeof(header) + nv12_size); uint8_t* payload = response.data() + sizeof(header); - const int convert_result = libyuv::ARGBToNV12( + const int convert_result = capture.frame_nv12 + ? (std::memcpy(payload, capture.frame_nv12->data(), nv12_size), 0) + : libyuv::ARGBToNV12( capture.frame_bits, capture.frame_stride, payload, request.width, payload + static_cast(request.width) * request.height, request.width, request.width, request.height); @@ -1578,6 +1673,8 @@ void CloseSecureDesktopSharedCaptureResources( capture_state->frame_mapping = nullptr; } capture_state->sequence = 0; + capture_state->request = {}; + capture_state->last_owner_refresh.store(0, std::memory_order_relaxed); } void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) { @@ -1599,7 +1696,7 @@ void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) { std::unique_ptr capture; ULONGLONG last_error_tick = 0; auto stats_started = std::chrono::steady_clock::now(); - unsigned frame_count = 0, dxgi_frames = 0, dxgi_updates = 0; + unsigned frame_count = 0, dxgi_frames = 0, dxgi_updates = 0, wgc_frames = 0; unsigned converted_frames = 0; double capture_ms = 0, convert_ms = 0, publish_ms = 0; auto report_stats = [&](bool force) { @@ -1611,19 +1708,29 @@ void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) { LOG_INFO( "Secure capture producer: fps={:.1f}, capture_avg_ms={:.1f}, " "convert_avg_ms={:.1f}, publish_avg_ms={:.1f}, size={}x{}, " - "target_fps={}, dxgi_frames={}, dxgi_updates={}, gdi_frames={}, " + "target_fps={}, dxgi_frames={}, dxgi_updates={}, wgc_frames={}, gdi_frames={}, " "converted_frames={}, reused_frames={}", frame_count / elapsed, capture_ms / frame_count, convert_ms / frame_count, publish_ms / frame_count, request.width, - request.height, request.fps, dxgi_frames, dxgi_updates, - frame_count - dxgi_frames, converted_frames, + request.height, request.fps, dxgi_frames, dxgi_updates, wgc_frames, + frame_count - dxgi_frames - wgc_frames, converted_frames, frame_count - converted_frames); } stats_started = std::chrono::steady_clock::now(); - frame_count = dxgi_frames = dxgi_updates = converted_frames = 0; + frame_count = dxgi_frames = dxgi_updates = wgc_frames = converted_frames = 0; capture_ms = convert_ms = publish_ms = 0; }; while (!capture_state->lifecycle.StopRequested()) { + if (request.owner_process && + WaitForSingleObject(request.owner_process.get(), 0) != WAIT_TIMEOUT) { + LOG_INFO("Secure capture owner exited; stopping capture and restoring cursor"); + break; + } + if (GetTickCount64() - capture_state->last_owner_refresh.load( + std::memory_order_relaxed) >= kCaptureOwnerLeaseMs) { + LOG_INFO("Secure capture lease expired; restoring cursor"); + break; + } const auto frame_started = std::chrono::steady_clock::now(); report_stats(false); // A lock/credential/UAC transition can change the desktop before the @@ -1633,7 +1740,7 @@ void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) { } if (!capture) { capture = std::make_unique(); - if (!capture->Initialize(request)) { + if (!capture->Initialize(request, true)) { const DWORD error = capture->error_code; capture.reset(); const ULONGLONG now = GetTickCount64(); @@ -1657,9 +1764,10 @@ void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) { // or pointer-only update can reuse the NV12 image safely. const bool pixels_changed = !capture->using_dxgi || capture->dxgi->updated() || request.show_cursor; - if (capture->nv12.Convert(capture->frame_bits, capture->frame_stride, + if (capture->frame_nv12 || capture->nv12.Convert(capture->frame_bits, capture->frame_stride, request.width, request.height, pixels_changed)) { - const auto& nv12_frame = capture->nv12.pixels(); + const auto& nv12_frame = capture->frame_nv12 ? *capture->frame_nv12 : capture->nv12.pixels(); + const bool pixels_updated = capture->frame_nv12 ? capture->wgc->updated() : capture->nv12.updated(); const auto converted_at = std::chrono::steady_clock::now(); auto* header = reinterpret_cast< crossdesk::CrossDeskSecureDesktopSharedFrameHeader*>(frame_view); @@ -1674,7 +1782,7 @@ void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) { header->height = static_cast(request.height); header->payload_size = static_cast(nv12_frame.size()); header->cursor = capture->cursor_snapshot; - if (capture->nv12.updated()) { + if (pixels_updated) { std::memcpy(payload, nv12_frame.data(), nv12_frame.size()); ++converted_frames; } @@ -1686,7 +1794,7 @@ void SecureDesktopSharedCaptureThread(SecureSharedCaptureState* capture_state) { if (capture->using_dxgi) { ++dxgi_frames; if (capture->dxgi->updated()) ++dxgi_updates; - } + } else if (capture->using_wgc) ++wgc_frames; capture_ms += std::chrono::duration(captured_at - frame_started) .count(); @@ -1815,6 +1923,7 @@ std::vector PrepareSecureDesktopSharedCapture( capture_state->frame_view = frame_view; capture_state->sequence = 0; capture_state->request = request; + capture_state->last_owner_refresh.store(GetTickCount64(), std::memory_order_relaxed); return {}; } @@ -1837,7 +1946,13 @@ std::vector StartSecureDesktopSharedCapture( }, [&] { // A timed-out caller may retry a start that already succeeded. - return capture_state->request == request; + const bool reuse = capture_state->request == request && + capture_state->request.owner_pid == request.owner_pid && + capture_state->request.owner_process && + WaitForSingleObject(capture_state->request.owner_process.get(), 0) == WAIT_TIMEOUT; + if (reuse) + capture_state->last_owner_refresh.store(GetTickCount64(), std::memory_order_relaxed); + return reuse; }); if (result == crossdesk::SharedCaptureLifecycle::StartResult::ShuttingDown) { return BuildTextResponseBytes(BuildErrorJson("capture_shutting_down")); @@ -1859,7 +1974,8 @@ std::vector StartSecureDesktopSharedCapture( std::vector HandleSecureInputHelperCommand( const std::string& command, - SecureSharedCaptureState* capture_state) { + SecureSharedCaptureState* capture_state, + const std::shared_ptr& owner_process, DWORD owner_pid) { if (command.rfind(crossdesk::kSecureTextInputCommandPrefix, 0) == 0) { crossdesk::SecureTextInputRequest request; if (!crossdesk::ParseSecureTextInputCommand(command, request)) @@ -1885,6 +2001,10 @@ std::vector HandleSecureInputHelperCommand( SecureCaptureRequest capture_start_request; if (ParseSecureInputCaptureStartCommand(command, &capture_start_request)) { + if (!owner_process || WaitForSingleObject(owner_process.get(), 0) != WAIT_TIMEOUT) + return BuildTextResponseBytes(BuildErrorJson("capture_owner_unavailable")); + capture_start_request.owner_process = owner_process; + capture_start_request.owner_pid = owner_pid; return StartSecureDesktopSharedCapture(capture_start_request, capture_state); } @@ -1971,8 +2091,19 @@ void HandleSecureInputHelperPipeClient( pipe, event_handle, false, buffer, sizeof(buffer) - 1, GetTickCount64() + 1000, &bytes_read, &error) && bytes_read > 0) { + // Bind the producer/cursor watchdog to the Windows-identified caller, + // never a PID supplied in a capture command. The process object remains + // valid even after its PID is reused. + ULONG owner_pid = 0; + std::shared_ptr owner_process; + if (GetNamedPipeClientProcessId(pipe, &owner_pid)) { + HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, owner_pid); + if (process) owner_process = std::shared_ptr(process, + [](void* handle) { CloseHandle(handle); }); + } std::vector response = HandleSecureInputHelperCommand( - std::string(buffer, buffer + bytes_read), capture_state.get()); + std::string(buffer, buffer + bytes_read), capture_state.get(), + owner_process, owner_pid); DWORD bytes_written = 0; if (!response.empty()) { if (crossdesk::pipe_deadline_detail::Transfer( diff --git a/apps/desktop/src/platform/windows/service/shared_identity.cpp b/apps/desktop/src/platform/windows/service/shared_identity.cpp new file mode 100644 index 00000000..98632f52 --- /dev/null +++ b/apps/desktop/src/platform/windows/service/shared_identity.cpp @@ -0,0 +1,337 @@ +#include "shared_identity.h" + +#include +#include +#include +#include +#include +#include + +#include "common/credential_cache.h" +#include "private_file.h" +#include "unattended_config.h" + +namespace crossdesk { +namespace { +using Json = nlohmann::json; +constexpr wchar_t kSnapshot[] = L"shared-identity.json"; +constexpr wchar_t kGuiOwner[] = L"gui-owner.json"; + +struct Handle { + HANDLE value = INVALID_HANDLE_VALUE; + ~Handle() { if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); } +}; +void Require(bool ok, const char* message) { + if (!ok) throw std::runtime_error(message); +} +uint64_t Created(HANDLE process) { + FILETIME created{}, exited{}, kernel{}, user{}; + if (!GetProcessTimes(process, &created, &exited, &kernel, &user)) return 0; + return (uint64_t(created.dwHighDateTime) << 32) | created.dwLowDateTime; +} +std::wstring ProcessPath(HANDLE process) { + wchar_t path[32768]{}; + DWORD count = 32768; + if (!QueryFullProcessImageNameW(process, 0, path, &count)) return {}; + return {path, count}; +} +std::string Read(const std::filesystem::path& path, DWORD limit = 256 * 1024) { + const DWORD sharing = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + Handle file{path.parent_path() == UnattendedDataPath() + ? unattended_detail::OpenPrivateMachineFile(UnattendedDataPath(), path, + GENERIC_READ, sharing, OPEN_EXISTING) + : CreateFileW(path.c_str(), GENERIC_READ, sharing, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, nullptr)}; + Require(file.value != INVALID_HANDLE_VALUE, "Cannot open shared identity data"); + BY_HANDLE_FILE_INFORMATION info{}; + Require(GetFileInformationByHandle(file.value, &info) && + !(info.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)) && + info.nNumberOfLinks == 1 && !info.nFileSizeHigh && info.nFileSizeLow <= limit, + "Invalid shared identity file"); + std::string value(info.nFileSizeLow, '\0'); + DWORD read = 0; + Require(ReadFile(file.value, value.data(), static_cast(value.size()), &read, + nullptr) && read == value.size(), "Cannot read shared identity data"); + return value; +} +Json Document(const std::filesystem::path& path) { + auto document = Json::parse(Read(path), nullptr, false); + Require(document.is_object(), "Invalid shared identity document"); + return document; +} +Json PrivateDocument(const std::filesystem::path& path) { + auto document = Json::parse(private_file::Read(path), nullptr, false); + Require(document.is_object(), "Invalid private identity document"); + return document; +} +void WriteMachine(const wchar_t* name, const std::string& contents) { + const auto root = UnattendedDataPath(); + Require(unattended_detail::WritePrivateMachineFile(root, root / name, contents), + "Cannot persist machine identity"); +} +HANDLE Lock(const wchar_t* name) { + const auto root = UnattendedDataPath(); + return unattended_detail::OpenPrivateMachineFile(root, + root / name, GENERIC_READ | GENERIC_WRITE, 0, OPEN_ALWAYS); +} +std::string Hex(const std::string& bytes) { + constexpr char digits[] = "0123456789abcdef"; + std::string result; + for (unsigned char byte : bytes) { result += digits[byte >> 4]; result += digits[byte & 15]; } + return result; +} +std::string Unhex(const std::string& text) { + Require(text.size() % 2 == 0, "Invalid identity cache encoding"); + const auto nibble = [](char c) -> unsigned { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + throw std::runtime_error("Invalid identity cache encoding"); + }; + std::string bytes; + for (size_t i = 0; i < text.size(); i += 2) + bytes += static_cast((nibble(text[i]) << 4) | nibble(text[i + 1])); + return bytes; +} +credential_cache::CacheV3 ValidateCache(const std::string& bytes) { + Require(bytes.size() == sizeof(credential_cache::CacheV3), "Missing valid v3 identity cache"); + credential_cache::CacheV3 cache{}; + std::memcpy(&cache, bytes.data(), sizeof(cache)); + Require(credential_cache::Valid(cache), "Invalid identity cache checksum"); + const auto valid = [](const char* identity) { + if (!identity[0]) return true; + if (identity[16] || identity[9] != '@') return false; + for (int i = 0; i < 9; ++i) if (identity[i] < '0' || identity[i] > '9') return false; + for (int i = 10; i < 16; ++i) if (!identity[i]) return false; + return true; + }; + Require(valid(cache.base.client_id_with_password) && valid(cache.base.self_hosted_id) && + valid(cache.pending_identity) && + (cache.base.client_id_with_password[0] || cache.base.self_hosted_id[0]), + "Connect once to obtain a device ID before enabling unattended startup"); + return cache; +} +void PreserveLocalCipher(const std::filesystem::path& directory, + credential_cache::CacheV3& destination, bool gui) { + bool existing = false; + for (const char* name : {"secure_cache_v3.enc", "secure_cache_v2.enc", "secure_cache.enc"}) { + const auto path = directory / name; + if (!std::filesystem::exists(path)) continue; + existing = true; + const auto bytes = Read(path); + if (std::strcmp(name, "secure_cache_v3.enc") == 0 && + bytes.size() == sizeof(credential_cache::CacheV3)) { + credential_cache::CacheV3 local{}; + std::memcpy(&local, bytes.data(), sizeof(local)); + if (!credential_cache::Valid(local)) continue; + std::memcpy(destination.base.key, local.base.key, sizeof(local.base.key)); + std::memcpy(destination.base.iv, local.base.iv, sizeof(local.base.iv)); + return; + } + if (std::strcmp(name, "secure_cache_v2.enc") == 0 && + bytes.size() == sizeof(credential_cache::CacheV2)) { + credential_cache::CacheV2 local{}; + std::memcpy(&local, bytes.data(), sizeof(local)); + std::memcpy(destination.base.key, local.key, sizeof(local.key)); + std::memcpy(destination.base.iv, local.iv, sizeof(local.iv)); + return; + } + if (std::strcmp(name, "secure_cache.enc") == 0 && + bytes.size() == sizeof(credential_cache::CacheV1)) { + credential_cache::CacheV1 local{}; + std::memcpy(&local, bytes.data(), sizeof(local)); + std::memcpy(destination.base.key, local.key, sizeof(local.key)); + std::memcpy(destination.base.iv, local.iv, sizeof(local.iv)); + return; + } + } + Require(!gui || !existing, "Cannot preserve this user's saved-connection encryption keys"); + Require(RAND_bytes(destination.base.key, sizeof(destination.base.key)) == 1 && + RAND_bytes(destination.base.iv, sizeof(destination.base.iv)) == 1, + "Cannot create local cache encryption keys"); +} +void ValidateTrustedDevices(const Json& devices) { + Require(devices.at("version") == 2 && !devices.contains("private_key") && + devices.at("devices").is_array() && devices.at("devices").size() <= 128, + "Invalid shared trusted device store"); + std::set keys; + for (const auto& device : devices.at("devices")) { + const auto key = device.at("public_key").get(); + Require(Unhex(key).size() == 32 && keys.insert(key).second, + "Invalid shared trusted device key"); + for (const char* field : {"name", "device_id"}) { + const auto label = device.at(field).get(); + Require(label.size() <= 128 && std::none_of(label.begin(), label.end(), + [](unsigned char c) { return c < 32 || c == 127; }), + "Invalid shared trusted device label"); + } + } +} +Json Snapshot() { + Require(PrepareUnattendedDirectory(false), "Machine identity directory is not private"); + auto data = Document(UnattendedDataPath() / kSnapshot); + Require(data.at("version") == 1 && data.at("host_config").is_string(), + "Unsupported shared identity version"); + ValidateCache(Unhex(data.at("cache_v3").get())); + Require(Unhex(data.at("trust_private_key").get()).size() == 32, + "Invalid shared device identity key"); + ValidateTrustedDevices(data.at("trusted_devices")); + return data; +} +bool SameOwner(const Json& owner) { + return owner.value("pid", 0u) == GetCurrentProcessId() && + owner.value("created", uint64_t{0}) == Created(GetCurrentProcess()); +} +void RemoveGuiOwner() { + try { + if (SameOwner(Document(UnattendedDataPath() / kGuiOwner))) + DeleteFileW((UnattendedDataPath() / kGuiOwner).c_str()); + } catch (...) {} +} +} // namespace + +SharedHostIdentity::SharedHostIdentity(std::filesystem::path cache, + std::filesystem::path trusted, HANDLE lease, bool gui) + : cache_(std::move(cache)), trusted_(std::move(trusted)), lease_(lease), gui_(gui) {} + +SharedHostIdentity::~SharedHostIdentity() { + if (gui_) RemoveGuiOwner(); + if (lease_ != INVALID_HANDLE_VALUE) CloseHandle(lease_); +} + +bool SharedHostIdentity::Configured() { + const auto path = UnattendedDataPath() / kSnapshot; + const DWORD attributes = GetFileAttributesW(path.c_str()); + return attributes != INVALID_FILE_ATTRIBUTES; +} + +bool SharedHostIdentity::GuiPresent() { + try { + if (!PrepareUnattendedDirectory(false)) return false; + const auto owner = Document(UnattendedDataPath() / kGuiOwner); + Handle process{OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + FALSE, owner.at("pid").get())}; + if (!process.value || WaitForSingleObject(process.value, 0) != WAIT_TIMEOUT || + !Created(process.value) || Created(process.value) != owner.at("created")) return false; + DWORD session = 0; + return ProcessIdToSessionId(owner.at("pid").get(), &session) && session != 0 && + session == owner.at("session").get() && + std::filesystem::path(ProcessPath(process.value)).u8string() == owner.at("executable"); + } catch (...) { return false; } +} + +std::unique_ptr SharedHostIdentity::AttachGui( + const std::filesystem::path& cache, const std::filesystem::path& trusted, + bool import_existing, std::string& error) { + error.clear(); + bool reserved = false; + try { + Require(IsAdministratorProcess() && !IsLocalSystemProcess(), "Shared GUI identity requires an administrator"); + Require(PrepareUnattendedDirectory(true), "Cannot prepare private machine identity directory"); + Handle claim{Lock(L"gui-claim.lock")}; + Require(claim.value != INVALID_HANDLE_VALUE, "Another GUI attachment is in progress"); + Require(!GuiPresent(), "Another GUI is already using this machine identity"); + DWORD session = 0; + Require(ProcessIdToSessionId(GetCurrentProcessId(), &session) && session != 0, + "A GUI identity requires an interactive session"); + WriteMachine(kGuiOwner, Json{{"pid", GetCurrentProcessId()}, {"created", Created(GetCurrentProcess())}, + {"session", session}, {"executable", std::filesystem::path(ProcessPath(GetCurrentProcess())).u8string()}}.dump()); + reserved = true; + // The GUI imports a profile, but never acquires the host's network lease. + // Keeping the lease in SYSTEM is what preserves RTC across console login. + auto result = std::unique_ptr( + new SharedHostIdentity(cache, trusted, INVALID_HANDLE_VALUE, true)); + if (import_existing) result->Import(); + else result->host_config_ = Snapshot().at("host_config").get(); + return result; + } catch (const std::exception& failure) { + if (reserved) RemoveGuiOwner(); + error = failure.what(); + return nullptr; + } +} + +std::unique_ptr SharedHostIdentity::AcquireHost(std::string& error) { + error.clear(); + try { + Require(IsLocalSystemProcess() && PrepareUnattendedDirectory(false), "Machine identity requires SYSTEM"); + HANDLE lease = Lock(L"identity-owner.lock"); + Require(lease != INVALID_HANDLE_VALUE, "Machine identity is already in use"); + const auto cache = UnattendedDataPath() / L"host"; + auto result = std::unique_ptr( + new SharedHostIdentity(cache, cache / L"trusted-devices", lease, false)); + result->Import(); + private_file::Write(cache / "config.ini", result->host_config_); + return result; + } catch (const std::exception& failure) { error = failure.what(); return nullptr; } +} + +void SharedHostIdentity::Import() { + const auto data = Snapshot(); + private_file::EnsureDirectory(cache_, true); + private_file::EnsureDirectory(trusted_); + Json identity{{"version", 1}}; + if (std::filesystem::exists(cache_ / "identity.json")) + identity = PrivateDocument(cache_ / "identity.json"); + Require(identity.is_object() && identity.value("version", 0) == 1, + "Invalid existing device identity store"); + identity["trust_private_key"] = data.at("trust_private_key"); + const auto bytes = Unhex(data.at("cache_v3").get()); + credential_cache::CacheV3 cache{}; + std::memcpy(&cache, bytes.data(), sizeof(cache)); + // These keys encrypt local thumbnails/recent credentials, not the device + // identity. Sharing them would make another user's saved history unreadable. + PreserveLocalCipher(cache_, cache, gui_); + cache.checksum = credential_cache::Checksum(&cache, offsetof(credential_cache::CacheV3, checksum)); + // Validate everything before replacing any user identity data. Both stores + // stay private to this process's user; their DACLs are never relaxed. + private_file::Write(cache_ / "identity.json", identity.dump()); + private_file::Write(trusted_ / "devices.json", data.at("trusted_devices").dump()); + private_file::Write(cache_ / "secure_cache_v3.enc", + std::string(reinterpret_cast(&cache), sizeof(cache))); + private_file::Write(cache_ / "secure_cache_v2.enc", + std::string(reinterpret_cast(&cache.base), sizeof(cache.base))); + host_config_ = data.at("host_config").get(); +} + +bool SharedHostIdentity::Publish(const std::string& host_config, std::string& error) { + std::lock_guard lock(mutex_); + error.clear(); + try { + Require(!gui_ && lease_ != INVALID_HANDLE_VALUE, "Machine identity lease is unavailable"); + auto cache = ValidateCache(Read(cache_ / "secure_cache_v3.enc")); + std::memset(cache.base.key, 0, sizeof(cache.base.key)); + std::memset(cache.base.iv, 0, sizeof(cache.base.iv)); + cache.checksum = credential_cache::Checksum(&cache, offsetof(credential_cache::CacheV3, checksum)); + const std::string identity_cache(reinterpret_cast(&cache), sizeof(cache)); + const auto identity = PrivateDocument(cache_ / "identity.json"); + const auto key = identity.at("trust_private_key").get(); + Require(Unhex(key).size() == 32, "Invalid device identity key"); + const auto devices = PrivateDocument(trusted_ / "devices.json"); + ValidateTrustedDevices(devices); + const auto& configuration = host_config.empty() ? host_config_ : host_config; + Require(!configuration.empty(), "Shared host configuration is missing"); + Json snapshot{{"version", 1}, {"cache_v3", Hex(identity_cache)}, {"trust_private_key", key}, + {"trusted_devices", devices}, {"host_config", configuration}}; + const auto contents = snapshot.dump(); + Require(contents.size() <= 256 * 1024, "Shared identity is too large"); + WriteMachine(kSnapshot, contents); + host_config_ = configuration; + return true; + } catch (const std::exception& failure) { error = failure.what(); return false; } +} + +bool SharedHostIdentity::Prepare(const std::filesystem::path& cache, + const std::filesystem::path& trusted, const std::string& host_config, std::string& error) { + error.clear(); + try { + Require(PrepareUnattendedDirectory(true), "Cannot prepare private machine identity directory"); + if (Configured()) { Snapshot(); return true; } + Require(!GuiPresent(), "Enable shared startup from the running GUI settings"); + HANDLE lease = Lock(L"identity-owner.lock"); + Require(lease != INVALID_HANDLE_VALUE, "Machine identity is already in use"); + SharedHostIdentity snapshot(cache, trusted, lease, false); + return snapshot.Publish(host_config, error); + } catch (const std::exception& failure) { error = failure.what(); return false; } +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/service/shared_identity.h b/apps/desktop/src/platform/windows/service/shared_identity.h new file mode 100644 index 00000000..6bc62ca5 --- /dev/null +++ b/apps/desktop/src/platform/windows/service/shared_identity.h @@ -0,0 +1,54 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-08 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _SHARED_IDENTITY_H_ +#define _SHARED_IDENTITY_H_ + +#include + +#include +#include +#include +#include + +namespace crossdesk { + +// Credential snapshots live in the administrator/SYSTEM-only machine root. +// Each process imports them into its own user-only identity store. Only SYSTEM +// takes the exclusive network lease. The GUI marker records frontend presence. +class SharedHostIdentity { + public: + ~SharedHostIdentity(); + static bool Configured(); + static bool GuiPresent(); + static bool Prepare(const std::filesystem::path& cache, + const std::filesystem::path& trusted_devices, + const std::string& host_config, std::string& error); + static std::unique_ptr AttachGui( + const std::filesystem::path& cache, + const std::filesystem::path& trusted_devices, bool import_existing, + std::string& error); + static std::unique_ptr AcquireHost(std::string& error); + + bool Publish(const std::string& host_config, std::string& error); + const std::string& HostConfig() const { return host_config_; } + const std::filesystem::path& CachePath() const { return cache_; } + const std::filesystem::path& TrustedDevicesPath() const { return trusted_; } + + private: + SharedHostIdentity(std::filesystem::path cache, std::filesystem::path trusted, + HANDLE lease, bool gui); + void Import(); + std::filesystem::path cache_, trusted_; + HANDLE lease_ = INVALID_HANDLE_VALUE; + bool gui_ = false; + std::string host_config_; + std::mutex mutex_; +}; + +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/platform/windows/service/unattended_config.cpp b/apps/desktop/src/platform/windows/service/unattended_config.cpp index e1a9e5ae..568bc281 100644 --- a/apps/desktop/src/platform/windows/service/unattended_config.cpp +++ b/apps/desktop/src/platform/windows/service/unattended_config.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,20 @@ bool TrustedSid(PSID sid) { IsWellKnownSid(sid, WinBuiltinAdministratorsSid)); } +bool PrivateAccess(PSID owner, PACL acl, BYTE required_flags = 0) { + if (!TrustedSid(owner) || !acl || !acl->AceCount) return false; + for (DWORD i = 0; i < acl->AceCount; ++i) { + void* raw = nullptr; + if (!GetAce(acl, i, &raw)) return false; + auto* ace = static_cast(raw); + // Credentials and SYSTEM configuration are restricted to SYSTEM/admins. + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || + !TrustedSid(&ace->SidStart) || + (ace->Header.AceFlags & required_flags) != required_flags) return false; + } + return true; +} + bool PrivateDirectory(const std::filesystem::path& path) { const DWORD attributes = GetFileAttributesW(path.c_str()); if (attributes == INVALID_FILE_ATTRIBUTES || @@ -34,22 +49,7 @@ bool PrivateDirectory(const std::filesystem::path& path) { OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, nullptr, &dacl, nullptr, &descriptor) != ERROR_SUCCESS) return false; - bool safe = TrustedSid(owner) && dacl && dacl->AceCount > 0; - for (DWORD index = 0; safe && index < dacl->AceCount; ++index) { - void* raw = nullptr; - if (!GetAce(dacl, index, &raw)) { - safe = false; - break; - } - auto* ace = static_cast(raw); - // Only SYSTEM and elevated administrators can read credentials or write - // configuration that the service will consume as SYSTEM. - safe = - ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && - TrustedSid(&ace->SidStart) && - (ace->Header.AceFlags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)) == - (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE); - } + const bool safe = PrivateAccess(owner, dacl, OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE); LocalFree(descriptor); return safe; } @@ -82,13 +82,25 @@ bool SetUnattendedEnabled(bool enabled) { nullptr) != ERROR_SUCCESS) return false; const DWORD value = enabled ? 1 : 0; - const auto result = + auto result = RegSetValueExW(key, L"Enabled", 0, REG_DWORD, reinterpret_cast(&value), sizeof(value)); + if (result == ERROR_SUCCESS && enabled) { + const DWORD version = 1; + result = RegSetValueExW(key, L"SharedIdentityVersion", 0, REG_DWORD, + reinterpret_cast(&version), sizeof(version)); + } RegCloseKey(key); return result == ERROR_SUCCESS; } +bool IsSharedIdentityServiceConfigured() { + DWORD version = 0; + DWORD size = sizeof(version); + return RegGetValueW(HKEY_LOCAL_MACHINE, kRegistryKey, L"SharedIdentityVersion", + RRF_RT_REG_DWORD | RRF_SUBKEY_WOW6464KEY, nullptr, &version, &size) == ERROR_SUCCESS && version == 1; +} + bool IsAdministratorProcess() { BYTE sid[SECURITY_MAX_SID_SIZE]; DWORD size = sizeof(sid); @@ -116,6 +128,25 @@ bool PrepareUnattendedDirectory(bool create) { create); } +bool PrepareUnattendedDirectoryForService() { + if (!IsLocalSystemProcess()) return false; + const auto root = UnattendedDataPath(); + // Only adopt an already trusted SYSTEM/Administrators directory. Never + // repair a permissive, linked, or ordinary-user-owned machine profile. + if (!unattended_detail::PreparePrivateDirectory(root, true)) return false; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + kDirectorySecurity, SDDL_REVISION_1, &descriptor, nullptr)) return false; + PACL acl = nullptr; + BOOL present = FALSE, defaulted = FALSE; + const bool updated = GetSecurityDescriptorDacl(descriptor, &present, &acl, &defaulted) && + present && SetNamedSecurityInfoW(const_cast(root.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + nullptr, nullptr, acl, nullptr) == ERROR_SUCCESS; + LocalFree(descriptor); + return updated && PrivateDirectory(root); +} + bool unattended_detail::PreparePrivateDirectory( const std::filesystem::path& root, bool create) { if (root.empty()) return false; @@ -134,6 +165,62 @@ bool unattended_detail::PreparePrivateDirectory( return PrivateDirectory(root); } +HANDLE unattended_detail::OpenPrivateMachineFile(const std::filesystem::path& root, + const std::filesystem::path& path, DWORD access, DWORD sharing, + DWORD creation, DWORD flags) { + if (path.parent_path() != root || !PreparePrivateDirectory(root, false)) { + SetLastError(ERROR_ACCESS_DENIED); + return INVALID_HANDLE_VALUE; + } + PSECURITY_DESCRIPTOR security = nullptr; + const auto sddl = IsLocalSystemProcess() + ? L"O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)" + : L"O:BAG:BAD:P(A;;FA;;;SY)(A;;FA;;;BA)"; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, SDDL_REVISION_1, &security, nullptr)) return INVALID_HANDLE_VALUE; + SECURITY_ATTRIBUTES attributes{sizeof(attributes), security, FALSE}; + HANDLE file = CreateFileW(path.c_str(), access | READ_CONTROL, sharing, + &attributes, creation, flags | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + const DWORD error = GetLastError(); + LocalFree(security); + if (file == INVALID_HANDLE_VALUE) { SetLastError(error); return file; } + BY_HANDLE_FILE_INFORMATION info{}; + PSID owner = nullptr; + PACL acl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + const bool safe = GetFileInformationByHandle(file, &info) && info.nNumberOfLinks == 1 && + !(info.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)) && + GetSecurityInfo(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &acl, nullptr, &descriptor) == ERROR_SUCCESS && + PrivateAccess(owner, acl); + if (descriptor) LocalFree(descriptor); + if (safe) return file; + CloseHandle(file); + SetLastError(ERROR_ACCESS_DENIED); + return INVALID_HANDLE_VALUE; +} + +bool unattended_detail::WritePrivateMachineFile(const std::filesystem::path& root, + const std::filesystem::path& path, const std::string& contents) { + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES && + (attributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY))) return false; + static std::atomic sequence{0}; + const auto temporary = path.wstring() + L".tmp-" + std::to_wstring(GetCurrentProcessId()) + + L"-" + std::to_wstring(GetTickCount64()) + L"-" + std::to_wstring(sequence++); + HANDLE output = OpenPrivateMachineFile( + root, temporary, GENERIC_WRITE, 0, CREATE_NEW); + if (output == INVALID_HANDLE_VALUE) return false; + DWORD written = 0; + const bool saved = WriteFile(output, contents.data(), static_cast(contents.size()), + &written, nullptr) && written == contents.size() && FlushFileBuffers(output); + CloseHandle(output); + if (saved && MoveFileExW(temporary.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return true; + DeleteFileW(temporary.c_str()); + return false; +} + bool WriteUnattendedStatus(const std::string& identity, bool online, DWORD session_id) { const auto root = UnattendedDataPath(); @@ -141,6 +228,7 @@ bool WriteUnattendedStatus(const std::string& identity, bool online, const auto separator = identity.find('@'); nlohmann::json status = { {"online", online}, + {"mode", "service"}, {"process_id", GetCurrentProcessId()}, {"session_id", session_id}, {"updated_at", std::time(nullptr)}, @@ -148,13 +236,7 @@ bool WriteUnattendedStatus(const std::string& identity, bool online, separator == std::string::npos ? "" : identity.substr(0, separator)}, {"password", separator == std::string::npos ? "" : identity.substr(separator + 1)}}; - const auto temporary = root / L"status.tmp"; - std::ofstream output(temporary, std::ios::binary | std::ios::trunc); - output << status.dump(2); - output.close(); - return !output.fail() && - MoveFileExW(temporary.c_str(), (root / L"status.json").c_str(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); + return unattended_detail::WritePrivateMachineFile(root, root / L"status.json", status.dump()); } std::string ReadUnattendedStatus() { diff --git a/apps/desktop/src/platform/windows/service/unattended_config.h b/apps/desktop/src/platform/windows/service/unattended_config.h index c718bf34..3e654547 100644 --- a/apps/desktop/src/platform/windows/service/unattended_config.h +++ b/apps/desktop/src/platform/windows/service/unattended_config.h @@ -10,15 +10,26 @@ namespace crossdesk { // This directory is deliberately separate from every user's GUI profile. std::filesystem::path UnattendedDataPath(); bool IsUnattendedEnabled(); +bool IsSharedIdentityServiceConfigured(); bool SetUnattendedEnabled(bool enabled); bool IsLocalSystemProcess(); bool IsAdministratorProcess(); // Reject reparse points, untrusted owners and permissive existing DACLs. bool PrepareUnattendedDirectory(bool create); +// SYSTEM repairs the old host's user-only root ACL before GUI migration. +bool PrepareUnattendedDirectoryForService(); namespace unattended_detail { // Parameterized only for isolated native ACL tests; production always uses // the fixed ProgramData path above. bool PreparePrivateDirectory(const std::filesystem::path& root, bool create); +// Both owner and DACL are restricted to SYSTEM/Administrators, including +// WRITE_DAC access from a filtered token belonging to the file's creator. +HANDLE OpenPrivateMachineFile(const std::filesystem::path& root, + const std::filesystem::path& path, DWORD access, DWORD sharing, + DWORD creation, DWORD flags = FILE_ATTRIBUTE_NORMAL); +// Write through a protected temporary file, then atomically replace the target. +bool WritePrivateMachineFile(const std::filesystem::path& root, + const std::filesystem::path& path, const std::string& contents); } // namespace unattended_detail bool WriteUnattendedStatus(const std::string& identity, bool online, DWORD session_id); diff --git a/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp index 3cf894dd..b6d975ee 100644 --- a/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp +++ b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp @@ -191,6 +191,58 @@ std::vector EnumerateModes(const std::wstring& device) { } // namespace +VirtualDisplayMode ResolveVirtualDisplayMode() { + constexpr wchar_t value_name[] = L"LastAttachedDisplayMode"; + DWORD saved = 0, size = sizeof(saved); + if (RegGetValueW(HKEY_LOCAL_MACHINE, kUsbmmiddInstallMarkerKey, value_name, + RRF_RT_REG_DWORD | RRF_SUBKEY_WOW6464KEY, nullptr, &saved, &size) != ERROR_SUCCESS) + saved = 0; + const auto valid = [](VirtualDisplayMode mode) { + return mode.width > 0 && mode.height > 0 && mode.width <= 16384 && mode.height <= 16384; + }; + VirtualDisplayMode attached; + // RDP creates a display mode for that client, not the console's next boot. + if (!GetSystemMetrics(SM_REMOTESESSION)) { + for (DWORD index = 0;; ++index) { + DISPLAY_DEVICEW adapter{sizeof(adapter)}; + if (!EnumDisplayDevicesW(nullptr, index, &adapter, 0)) break; + if (!(adapter.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) || + IsUsbmmiddAdapter(adapter.DeviceString, adapter.DeviceID)) continue; + bool present = false; + for (DWORD child = 0; !present; ++child) { + DISPLAY_DEVICEW monitor{sizeof(monitor)}; + if (!EnumDisplayDevicesW(adapter.DeviceName, child, &monitor, 0)) break; + present = (monitor.StateFlags & DISPLAY_DEVICE_ACTIVE) != 0 && + !ContainsCaseInsensitive(monitor.DeviceID, L"Default_Monitor"); + } + DEVMODEW mode{}; + mode.dmSize = sizeof(mode); + if (!present || !EnumDisplaySettingsExW(adapter.DeviceName, ENUM_CURRENT_SETTINGS, &mode, 0)) + continue; + const VirtualDisplayMode current{static_cast(mode.dmPelsWidth), + static_cast(mode.dmPelsHeight)}; + if (!valid(current)) continue; + if (!valid(attached) || (adapter.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE)) attached = current; + if (adapter.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE) break; + } + } + if (valid(attached)) { + const DWORD packed = (static_cast(attached.width) << 16) | + static_cast(attached.height); + HKEY key = nullptr; + if (packed != saved && RegCreateKeyExW(HKEY_LOCAL_MACHINE, kUsbmmiddInstallMarkerKey, + 0, nullptr, 0, KEY_SET_VALUE | KEY_WOW64_64KEY, nullptr, &key, nullptr) == ERROR_SUCCESS) { + if (RegSetValueExW(key, value_name, 0, REG_DWORD, + reinterpret_cast(&packed), sizeof(packed)) == ERROR_SUCCESS) + LOG_INFO("Remembered attached display mode: {}x{}", attached.width, attached.height); + RegCloseKey(key); + } + return attached; + } + const VirtualDisplayMode previous{static_cast(saved >> 16), static_cast(saved & 0xffff)}; + return valid(previous) ? previous : kDefaultVirtualDisplayMode; +} + UsbmmiddPackage LocateUsbmmiddPackage() { UsbmmiddPackage package; const std::filesystem::path base = CurrentExecutableDirectory(); diff --git a/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.h b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.h index 1e0e6026..a59f5c98 100644 --- a/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.h +++ b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.h @@ -17,9 +17,8 @@ namespace crossdesk { // Plug/unplug talks to the driver through Amyuni's installer and needs -// administrator rights inside the interactive session. The session helper -// (SYSTEM, console session) is the intended caller; the GUI only calls these -// directly when it is elevated and no helper is reachable. +// administrator rights inside the interactive session. An elevated GUI or +// SYSTEM host provisions locally with its own token. struct UsbmmiddPackage { std::filesystem::path directory; @@ -40,6 +39,10 @@ struct UsbmmiddStatus { }; UsbmmiddStatus QueryUsbmmiddStatus(); +// Remember an attached display's current pixel mode for GUI/SYSTEM startup. +// Phantom targets and our own fallback monitor must not overwrite that mode. +VirtualDisplayMode ResolveVirtualDisplayMode(); + // Adapters attached to the desktop, i.e. monitors currently plugged in. int CountUsbmmiddMonitors(); // GDI device name of an attached usbmmidd output, or empty while settling. diff --git a/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp index d4334477..a60e72c5 100644 --- a/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp +++ b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp @@ -32,6 +32,14 @@ constexpr DWORD kInstallAndPlugTimeoutMs = 120 * 1000; constexpr DWORD kUnplugTimeoutMs = 10 * 1000; constexpr DWORD kPresenceSettleTimeoutMs = 5 * 1000; +bool HasProvisioningPrivileges() { + BYTE admin_sid[SECURITY_MAX_SID_SIZE]; + DWORD size = sizeof(admin_sid); + BOOL member = FALSE; + return CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, admin_sid, &size) && + CheckTokenMembership(nullptr, admin_sid, &member) && member; +} + struct HelperVirtualDisplayStatus { bool ok = false; bool package_available = false; @@ -189,23 +197,36 @@ bool VirtualDisplayProvisioner::Acquire(VirtualDisplayMode mode, std::string* error) { std::string local_error; if (error == nullptr) error = &local_error; - if (active_.load(std::memory_order_relaxed)) return true; if (Cancelled()) { *error = "cancelled"; return false; } - + if (active_.load(std::memory_order_relaxed)) { + if (CountUsbmmiddMonitors() > 0) return ApplyUsbmmiddDisplayMode(mode, error); + // Windows can remove a boot-time target while restoring display topology. + // The old ownership count no longer describes any attached monitor. + LOG_WARN("Virtual display target disappeared; recreating it"); + active_.store(false, std::memory_order_relaxed); + via_helper_ = pending_helper_release_ = false; + plugged_locally_ = 0; + } bool helper_reachable = false; const ULONGLONG started = GetTickCount64(); - bool ok = AcquireViaHelper(mode, error, &helper_reachable); - if (ok) { - via_helper_ = true; - } else if (!helper_reachable && *error != "cancelled") { - LOG_INFO("Virtual display: session helper unreachable ({}), trying " - "in-process provisioning", - *error); + bool ok = false; + if (HasProvisioningPrivileges()) { + // A user-token helper cannot inspect/authorize a SYSTEM caller. SYSTEM + // and elevated GUI hosts already have the rights required by the driver. + LOG_INFO("Virtual display: provisioning with this host's administrator/SYSTEM token"); ok = AcquireLocally(mode, error); via_helper_ = false; + } else { + ok = AcquireViaHelper(mode, error, &helper_reachable); + via_helper_ = ok; + if (!ok && !helper_reachable && *error != "cancelled") { + LOG_INFO("Virtual display: session helper unreachable ({}), trying in-process provisioning", *error); + error->clear(); + ok = AcquireLocally(mode, error); + } } if (!ok) { LOG_WARN("Virtual display: provisioning failed after {} ms, error={}", diff --git a/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.h b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.h index f6e04291..198f8cdf 100644 --- a/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.h +++ b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.h @@ -17,9 +17,9 @@ namespace crossdesk { // Session-scoped usbmmidd virtual display for a host without any monitor. -// Acquire() asks the session helper (SYSTEM in the console session) to -// install/plug the driver; without a reachable helper an elevated GUI does it -// itself. Release() unplugs only what this object plugged. +// SYSTEM/elevated hosts provision directly with their existing token. Other +// hosts use the session helper, with a local fallback if it is unreachable. +// Release() unplugs only what this object plugged. class VirtualDisplayProvisioner { public: VirtualDisplayProvisioner() = default; diff --git a/apps/desktop/src/platform/windows/winrt_apartment.h b/apps/desktop/src/platform/windows/winrt_apartment.h new file mode 100644 index 00000000..046aa45c --- /dev/null +++ b/apps/desktop/src/platform/windows/winrt_apartment.h @@ -0,0 +1,38 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _WINRT_APARTMENT_H_ +#define _WINRT_APARTMENT_H_ + +#include + +#include "rd_log.h" + +namespace crossdesk { + +// Keep WinRT initialized for the lifetime of the thread's capture objects. +// An existing STA is valid too; balance only our successful initialization. +class ScopedWinRtApartment { + public: + ScopedWinRtApartment() : result_(RoInitialize(RO_INIT_MULTITHREADED)) { + if (!ready()) + LOG_ERROR("WinRT initialization failed, hr=0x{:08X}, thread={}", + static_cast(result_), GetCurrentThreadId()); + } + ~ScopedWinRtApartment() { + if (SUCCEEDED(result_)) RoUninitialize(); + } + ScopedWinRtApartment(const ScopedWinRtApartment&) = delete; + ScopedWinRtApartment& operator=(const ScopedWinRtApartment&) = delete; + bool ready() const { return SUCCEEDED(result_) || result_ == RPC_E_CHANGED_MODE; } + + private: + HRESULT result_; +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/privacy/privacy_controller.cpp b/apps/desktop/src/privacy/privacy_controller.cpp index a481cacc..55d2c81a 100644 --- a/apps/desktop/src/privacy/privacy_controller.cpp +++ b/apps/desktop/src/privacy/privacy_controller.cpp @@ -41,6 +41,10 @@ PrivacyStatus PrivacyController::Snapshot() const { std::lock_guard lock(mutex_); return status_; } +bool PrivacyController::Requested() const { + std::lock_guard lock(mutex_); + return engaged_.load() || (automatic_enable_ && resume_after_unlock_); +} void PrivacyController::SetText(const PrivacyScreenText& text) { std::lock_guard lock(mutex_); if (text_.unlock_hint == text.unlock_hint && text_.locked_hint == text.locked_hint && diff --git a/apps/desktop/src/privacy/privacy_controller.h b/apps/desktop/src/privacy/privacy_controller.h index c0178bbe..e6757b02 100644 --- a/apps/desktop/src/privacy/privacy_controller.h +++ b/apps/desktop/src/privacy/privacy_controller.h @@ -35,6 +35,8 @@ class PrivacyController { void Shutdown(); void Fail(const std::string& reason); bool Engaged() const { return engaged_.load(); } + // Includes automatic privacy suspended until the user desktop recovers. + bool Requested() const; void CaptureChanged(bool running); private: diff --git a/apps/desktop/src/screen_capturer/screen_capture_method.h b/apps/desktop/src/screen_capturer/screen_capture_method.h index 61d4533d..fb6683ba 100644 --- a/apps/desktop/src/screen_capturer/screen_capture_method.h +++ b/apps/desktop/src/screen_capturer/screen_capture_method.h @@ -9,14 +9,9 @@ namespace crossdesk { -// Persisted values; keep these in the same order as the settings choices. +// Backend identifiers. Auto starts the fixed DXGI/WGC/GDI selection order. enum class ScreenCaptureMethod { Auto = 0, Dxgi = 1, Wgc = 2, Gdi = 3 }; -inline bool IsValidScreenCaptureMethod(long value) { - return value >= static_cast(ScreenCaptureMethod::Auto) && - value <= static_cast(ScreenCaptureMethod::Gdi); -} - } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index fffb6d87..700c00ea 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -77,12 +77,23 @@ function setup_targets() -- MiniRTC owns a target named "common". Reusing it merges the two targets -- and makes the media library inherit desktop sources and dependencies. + target("private_file") + set_kind("static") + add_files("apps/desktop/src/common/private_file.cpp") + add_includedirs("apps/desktop/src/common", {public = true}) + add_packages("openssl3", {public = true}) + if is_os("windows") then + add_syslinks("Advapi32", {public = true}) + end + target("desktop_common") set_kind("object") add_deps("rd_log", "crossdesk_wire") add_packages("libyuv", "openssl3") add_packages("concurrentqueue") add_files("apps/desktop/src/common/*.cpp") + remove_files("apps/desktop/src/common/private_file.cpp") + add_deps("private_file") if is_os("windows") then add_files("apps/desktop/src/platform/windows/system_info.cpp", "apps/desktop/src/platform/windows/screen_lock.cpp", @@ -477,8 +488,11 @@ function setup_targets() if is_os("windows") then target("windows_unattended_config") set_kind("object") + add_deps("private_file") add_packages("nlohmann_json") add_files("apps/desktop/src/platform/windows/service/unattended_config.cpp") + add_files("apps/desktop/src/platform/windows/service/shared_identity.cpp") + add_includedirs("apps/desktop/src") add_includedirs("apps/desktop/src/platform/windows/service") add_syslinks("Advapi32", "Shell32", "Ole32", {public = true}) @@ -653,6 +667,9 @@ function setup_targets() add_cxxflags("/bigobj") add_deps("windows_unattended_config") add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp") + add_files("apps/desktop/src/platform/windows/gui/application/local_host_application.cpp", + "apps/desktop/src/platform/windows/service/local_host_channel.cpp") + add_syslinks("Wtsapi32") add_links("opengl32") add_syslinks("Comctl32", "Iphlpapi", "Ws2_32", "Advapi32", {public = true}) add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp", @@ -728,15 +745,19 @@ function setup_targets() target("crossdesk_session_helper") set_kind("binary") + add_deps("crossdesk_privacy_cursor_helper", {inherit = false}) + add_deps("wgc_plugin", {inherit = false}) add_packages("libyuv", "nlohmann_json") add_deps("rd_log", "path_manager") add_links("Advapi32", "User32", "Wtsapi32", "Gdi32", "Setupapi", - "d3d11", "dxgi") + "d3d11", "dxgi", "RuntimeObject") add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp") + add_files("apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp") add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp") add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc", {defines = windows_resource_defines}) add_includedirs("apps/desktop/src/common", + "apps/desktop/src/screen_capturer", "deps/submodules/minirtc/src/api", "apps/desktop/src/platform/windows/service", "apps/desktop/src/platform/windows/screen_capturer", "apps/desktop/src/platform/windows/virtual_display") @@ -754,7 +775,8 @@ function setup_targets() add_files("apps/desktop/src/app/*.cpp") add_includedirs("apps/desktop/src", "apps/desktop/src/app", {public = true}) if is_os("windows") then - add_files("apps/desktop/src/platform/windows/daemon_backend.cpp") + add_files("apps/desktop/src/platform/windows/daemon_backend.cpp", + "apps/desktop/src/platform/windows/service/autostart_control.cpp") add_files("apps/desktop/src/platform/windows/service/service_host.cpp", "apps/desktop/src/platform/windows/service/rdp_session_handoff.cpp") add_includedirs("apps/desktop/src/platform/windows/service", diff --git a/deps/submodules/minirtc b/deps/submodules/minirtc index dda03808..15c41351 160000 --- a/deps/submodules/minirtc +++ b/deps/submodules/minirtc @@ -1 +1 @@ -Subproject commit dda038083d712698b1ffb9969bfb7d97b366bec2 +Subproject commit 15c4135127ab5560e8cbdfe49e074ed234879577 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 936d9273..be71f950 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -150,14 +150,13 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-.deb" | 设置 | 说明 | | --- | --- | | 语言 | 中文、English、Русский | -| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 | | 视频编码格式 | H.264 / AV1;H.264 自动优先使用硬件编解码,不可用或初始化失败时回退到软件;桌面端 AV1 使用软件编解码 | | 启用 IP 直连 | 默认开启;保存后即时启停 IP 直连。关闭会断开 IP 会话、停止监听并隐藏主窗口模式按钮,重启后保持 | | 强制中继连接 | 关闭时自动选择连接方式,允许 P2P 和 TURN 中继回退;开启时仅通过中继连接 | | 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 | | 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 | | 自托管配置 | 点击“编辑”填写服务器地址和信令服务端口,使用右侧开关启用 | -| 开机自启 | 保存后设置登录桌面时自动启动 | +| 开机自启 | Windows 安装版 Release:使用现有 ID、密码在登录前提供远控;用户登录后自动启动普通客户端并接管同一身份,恢复完整功能。Windows 便携版和 Debug 不支持此开关。其他桌面平台保存后设置登录桌面时自动启动 | | 启用守护进程 | Windows / Linux 桌面模式保存后立即启用或停用异常退出后的自动重启,无需重启程序;正常退出或注销不会重新拉起。macOS 下次启动生效,无头控制台模式不启用内置守护 | | 文件保存路径 | 选择接收文件的目录 | @@ -238,11 +237,15 @@ CrossDesk 已支持鸿蒙 PC、手机和平板,PC 与移动端分别构建 HAP ## Windows 锁屏与登录界面 +Windows 的屏幕采集统一按 **DXGI → WGC → GDI** 自动回退,适用于实体屏、虚拟屏、无头启动、登录/安全桌面及管道兜底采集。设置页不再提供手动选择,旧的强制采集配置会在加载时清理。后端不可用时才继续尝试下一项;重连及解锁恢复重新按此顺序选择,运行中失效则沿序列继续回退。 + **CrossDesk Service**(服务名 `CrossDeskService`)提供锁屏、登录、凭据输入及安全桌面的状态上报、`Ctrl+Alt+Del` 发送和键鼠输入转发。 安装版默认注册按需启动的服务,客户端启动时尝试启动它;未启用无人值守时,本机没有 CrossDesk 客户端进程后服务会自动退出。便携版可在提示窗口或“设置 → 锁屏控制服务”中安装服务,需要管理员权限。 -Windows 的“开机自启”在当前用户登录后启动客户端。安装版使用以最高权限运行的登录计划任务,后续登录无需再次确认 UAC;便携版和 Debug 构建使用当前用户的普通启动项。升级安装版或手动启动新版时,会自动迁移指向该程序的旧启动项。关闭自启会删除相应注册。登录前远控需要另外启用下面的无人值守模式。 +Windows 安装版 Release 的“开机自启”使用无人值守服务:将 `CrossDeskService` 设为系统自动启动,并为启用此选项的用户注册登录后的 GUI 启动任务。后台持续独占本机身份和远控连接,GUI 通过经过身份验证的本机管道接入用户功能。关闭后移除开机与登录自动启动,将服务恢复为按需启动;手动打开 GUI 时仍由后台提供远控连接。“启用守护进程”只控制普通 GUI 的崩溃恢复。便携版和 Debug 不支持此开关。 + +首次开启时,先让普通客户端联网获得本机 ID。程序会保留当前 ID、密码、设备身份密钥和可信设备记录,并将它们同步到仅管理员和 SYSTEM 可访问的机器配置;原有用户配置保留。旧 Run 启动项会被登录启动任务替代,不再产生另一套无人值守 ID。已经使用旧版独立无人值守身份的安装,在新版中完成迁移后改用普通客户端现有身份。 @@ -264,28 +267,32 @@ foreach ($serviceAction in '--service-stop', '--service-install', '--service-sta 服务重启期间远控可能短暂中断。完成后重新连接,分别测试普通桌面和锁屏输入。服务将使用该构建目录中的程序,请保留该目录;恢复使用安装版时,重新安装完整安装包以恢复对应的服务注册。 +若已启用“开机自启”,切换构建目录后还需用新版客户端保存一次该设置,或在普通客户端退出后执行新版的 `--unattended-enable`,以更新登录后的 GUI 启动任务。单独修复服务不会更新这个任务的程序路径。 + ### 开机后尚未登录时远控 -安装版支持独立的无人值守被控端。在完整安装目录中打开**管理员 PowerShell**,一次性启用: +在“设置 → 开机自启”中开启并保存,主界面的本机 ID 和密码可以在登录前后通用,无需另记一套连接信息。也可以在完整安装目录中打开**管理员 PowerShell**启用同一功能;首次使用 CLI 启用前,先关闭普通客户端,或直接使用运行中客户端的设置开关: ```powershell .\CrossDesk.exe --unattended-enable -# 等待联网注册后,查看独立设备 ID、密码和 online 状态 +# 查看共享的设备 ID、密码、online 状态及当前托管方 mode .\CrossDesk.exe --unattended-status ``` -使用返回的 `device_id` 和 `password` 从另一台设备连接。首次启用时复制当前客户端的服务器和编码设置,单独生成无人值守凭据;它与普通客户端的设备 ID 不同,重启、注销和重新启用时保留。后续执行启用命令会复用机器配置,不会覆盖它。自建服务器的根证书需要安装到本地计算机的信任存储中。 +使用原有 `device_id` 和 `password` 从另一台设备连接。密码变更(包括尚未确认的变更记录)、设备身份密钥和可信设备记录会在两种托管模式之间同步。后续重新启用会复用共享身份,不会生成新 ID。自建服务器的根证书需要安装到本地计算机的信任存储中。 -启用后,`CrossDeskService` 改为系统启动时自动运行,无需 Windows 自动登录,也不需要保存 Windows 登录密码。服务以 SYSTEM 身份在当前控制台会话中启动无界面的被控进程,复用登录界面抓屏、键鼠及 `Ctrl+Alt+Del` 支持。用户登录、锁屏和关闭普通客户端不会停止它;注销、切换用户或进程崩溃后服务会重新拉起,期间连接可能中断,需要重新连接同一设备 ID。启动时网络尚未就绪会继续重试。 +启用后,`CrossDeskService` 随系统启动,无需 Windows 自动登录,也不需要保存 Windows 登录密码。服务以 SYSTEM 身份在控制台会话中启动无界面的被控进程,持续负责网络连接、抓屏、键鼠及 `Ctrl+Alt+Del`。GUI 等待用户桌面解锁后附加到后台,不再重新登录信令服务器,也不因 GUI 启动或退出而替换正在进行的远控连接。登录过程中保留同一条连接;退出或重启 GUI 时,后台仍可继续传送画面和处理键鼠。注销、切换 Windows 会话或重启服务仍需重建后台进程;网络中断也可能导致断线。启动时网络尚未就绪会继续重试。 -无人值守模式提供画面、键鼠、多屏切换和安全桌面控制;不提供声音、文件传输、剪贴板或隐私遮罩。便携版和 Debug 构建不能启用此模式。设置和凭据位于 `%ProgramData%\CrossDeskUnattended`,只允许 SYSTEM 和管理员访问;日志位于其 `logs` 子目录。 +登录前提供画面、键鼠、多屏切换和安全桌面控制。登录界面支持由原生控制端独立绘制光标;采集期间暂时隐藏本机系统光标,停止采集、采集进程退出或连接保活超时后自动恢复。网页控制端仍使用视频内光标。启用自动隐私模式时,后台会在恢复虚拟屏桌面采集前接续光标控制,GUI 随后复用该状态,避免初始化期间短暂出现视频内鼠标;手动关闭隐私遮罩仍会恢复本机光标。GUI 附加后,声音、文件传输、剪贴板和隐私遮罩由登录用户进程处理,通过本机通道复用后台的连接;文件操作不会以 SYSTEM 身份执行。GUI 的主动远控和局域网直连仍使用原有实现。密码、共享设置和可信设备记录由后台统一保存并同步给 GUI。GUI 退出时用户功能暂时不可用,重新打开后恢复。便携版和 Debug 不能启用此模式。共享身份位于 `%ProgramData%\CrossDeskUnattended`,只允许 SYSTEM 和管理员访问;GUI 与 SYSTEM 各自的运行缓存仍保持用户私有权限,日志位于机器目录的 `logs` 子目录。 + +无实体显示器或切换器未提供显示器信息时,无人值守端会在接受连接前尝试准备虚拟显示器,优先恢复此前记录的显示器分辨率(包括 3840×2160);没有有效记录时才使用 1920×1080。记录在启用开机自启和初始化抓屏时更新,CrossDesk 自己的虚拟屏与临时无显示器模式不会覆盖它。若驱动初始化失败,程序会记录原因并继续使用 Windows 提供的显示模式。 ```powershell # 停止无人值守被控端,并恢复服务按需启动 .\CrossDesk.exe --unattended-disable ``` -关闭普通“开机自启”不会关闭无人值守模式。禁用或卸载服务会取消无人值守启动,但保留受保护的机器配置;若需彻底清除凭据,在服务停止后由管理员删除该目录。 +关闭设置中的“开机自启”等同于禁用无人值守模式;CLI 的启停结果也会反映在该开关中。禁用或卸载服务会取消无人值守启动,但保留受保护的机器配置;若需彻底清除凭据,在服务停止后由管理员删除该目录。
手动部署与服务管理命令 diff --git a/localization/catalog.json b/localization/catalog.json index bddc6777..27b9c7f0 100644 --- a/localization/catalog.json +++ b/localization/catalog.json @@ -10811,6 +10811,42 @@ "fr": "Le service Windows distant est indisponible. Le contrôle de l’écran verrouillé ne fonctionnera pas. Installez ou réparez le service CrossDesk sur l’appareil distant.", "es": "El servicio de Windows remoto no está disponible. No se podrá controlar la pantalla de bloqueo. Instale o repare el servicio CrossDesk en el dispositivo remoto.", "pt-BR": "O serviço do Windows remoto está indisponível. Não será possível controlar a tela de bloqueio. Instale ou repare o serviço CrossDesk no dispositivo remoto." + }, + "开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。": { + "zh-Hans": "开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", + "en": "Boot startup requires the installed Release build. Portable and Debug builds do not support the unattended service.", + "ru": "Автозапуск требует установленной сборки Release. Переносная версия и Debug не поддерживают автономную службу.", + "zh-Hant": "開機自啟需要安裝版 Release;可攜版和 Debug 不支援無人值守服務。", + "ja": "自動起動にはインストール版 Release が必要です。ポータブル版と Debug は無人アクセスサービスに対応していません。", + "ko": "자동 시작에는 설치형 Release 버전이 필요합니다. 포터블 및 Debug 버전은 무인 접속 서비스를 지원하지 않습니다.", + "de": "Der Systemstart erfordert die installierte Release-Version. Portable- und Debug-Versionen unterstützen den unbeaufsichtigten Dienst nicht.", + "fr": "Le démarrage automatique nécessite la version Release installée. Les versions portable et Debug ne prennent pas en charge le service sans surveillance.", + "es": "El inicio automático requiere la versión Release instalada. Las versiones portátil y Debug no admiten el servicio desatendido.", + "pt-BR": "A inicialização automática exige a versão Release instalada. As versões portátil e Debug não oferecem suporte ao serviço não assistido." + }, + "无法配置无人值守服务,请检查管理员权限和安装文件。": { + "zh-Hans": "无法配置无人值守服务,请检查管理员权限和安装文件。", + "en": "Could not configure the unattended service. Check administrator privileges and installation files.", + "ru": "Не удалось настроить автономную службу. Проверьте права администратора и файлы установки.", + "zh-Hant": "無法設定無人值守服務,請檢查管理員權限和安裝檔案。", + "ja": "無人アクセスサービスを設定できません。管理者権限とインストールファイルを確認してください。", + "ko": "무인 접속 서비스를 설정할 수 없습니다. 관리자 권한과 설치 파일을 확인하세요.", + "de": "Der unbeaufsichtigte Dienst konnte nicht eingerichtet werden. Prüfen Sie Administratorrechte und Installationsdateien.", + "fr": "Impossible de configurer le service sans surveillance. Vérifiez les droits administrateur et les fichiers d’installation.", + "es": "No se pudo configurar el servicio desatendido. Compruebe los permisos de administrador y los archivos de instalación.", + "pt-BR": "Não foi possível configurar o serviço não assistido. Verifique as permissões de administrador e os arquivos de instalação." + }, + "开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。": { + "zh-Hans": "开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", + "en": "Connect after boot without signing in. After login, keep the same ID, password and full functionality.", + "ru": "Подключайтесь после загрузки без входа в Windows. После входа сохраняются ID, пароль и все функции.", + "zh-Hant": "開機後無需登入即可連線;登入後繼續使用同一 ID、密碼和完整功能。", + "ja": "サインイン前でも接続できます。サインイン後も同じ ID とパスワードですべての機能を利用できます。", + "ko": "부팅 후 로그인하지 않아도 연결할 수 있습니다. 로그인 후에도 같은 ID와 비밀번호로 모든 기능을 사용할 수 있습니다.", + "de": "Verbinden Sie sich nach dem Start ohne Anmeldung. Nach der Anmeldung bleiben ID, Passwort und alle Funktionen erhalten.", + "fr": "Connectez-vous après le démarrage sans ouvrir de session. Après connexion, conservez les mêmes ID et mot de passe et toutes les fonctions.", + "es": "Conéctese tras arrancar sin iniciar sesión. Después de iniciar sesión, conserve el mismo ID, contraseña y todas las funciones.", + "pt-BR": "Conecte-se após a inicialização sem fazer login. Após o login, mantenha o mesmo ID, senha e todas as funções." } }, "desktop": { @@ -10952,8 +10988,6 @@ "remote_unlock_requires_secure_desktop": "当前仍需要安全桌面专用采集/输入", "settings": "设置", "language": "语言", - "screen_capture_method": "采集方式", - "screen_capture_method_auto": "自动", "video_settings": "画面设置", "remote_update_title": "建议升级被控端", "remote_update": "新版本包含功能改进和性能优化,建议将被控端升级至最新版本。", @@ -11852,7 +11886,10 @@ "正在修复 Windows 服务…", "服务已修复并启动", "修复失败,请检查管理员权限和便携版文件后重试", - "远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。" + "远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。", + "开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", + "无法配置无人值守服务,请检查管理员权限和安装文件。", + "开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。" ], "android": [ " · 已完成",