diff --git a/apps/desktop/src/config_center/config_center.cpp b/apps/desktop/src/config_center/config_center.cpp index 9ffdb428..b873f354 100644 --- a/apps/desktop/src/config_center/config_center.cpp +++ b/apps/desktop/src/config_center/config_center.cpp @@ -68,6 +68,13 @@ int ConfigCenter::Load() { language_ = static_cast(language_value); } + enable_ip_direct_ = ini_->GetBoolValue(section_, "enable_ip_direct", true); + connection_mode_ = + enable_ip_direct_ && + std::strcmp(ini_->GetValue(section_, "connection_mode", "id"), "ip") == 0 + ? CONNECTION_MODE::DIRECT_IP + : CONNECTION_MODE::DEVICE_ID; + const long screen_capture_method_value = ini_->GetLongValue( section_, "screen_capture_method", static_cast(ScreenCaptureMethod::Auto)); @@ -201,6 +208,9 @@ int ConfigCenter::StoreValues( int ConfigCenter::Save() { ini_->SetLongValue(section_, "language", static_cast(language_)); + 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", @@ -249,6 +259,34 @@ int ConfigCenter::SetLanguage(LANGUAGE language) { return 0; } +int ConfigCenter::SetConnectionMode(CONNECTION_MODE mode) { + if (mode != CONNECTION_MODE::DEVICE_ID && mode != CONNECTION_MODE::DIRECT_IP) { + return -1; + } + if (mode == CONNECTION_MODE::DIRECT_IP && !enable_ip_direct_) return -1; + if (mode == connection_mode_) return 0; + if (StoreValues({{"connection_mode", + mode == CONNECTION_MODE::DIRECT_IP ? "ip" : "id"}}) != 0) { + return -1; + } + connection_mode_ = mode; + return 0; +} + +int ConfigCenter::SetIpDirectEnabled(bool enabled) { + if (enabled == enable_ip_direct_) return 0; + const auto mode = enabled ? connection_mode_ : CONNECTION_MODE::DEVICE_ID; + // Persist availability and the fallback mode together, so a failed save + // leaves both the current runtime and the next launch unchanged. + if (StoreValues({{"enable_ip_direct", enabled ? "true" : "false"}, + {"connection_mode", mode == CONNECTION_MODE::DIRECT_IP + ? "ip" : "id"}}) != 0) + return -1; + enable_ip_direct_ = enabled; + connection_mode_ = mode; + return 0; +} + int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) { if (!IsValidScreenCaptureMethod(static_cast(method))) { return -1; @@ -409,6 +447,12 @@ int ConfigCenter::SetSponsorshipPromptDismissedVersion( ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; } +ConfigCenter::CONNECTION_MODE ConfigCenter::GetConnectionMode() const { + return connection_mode_; +} + +bool ConfigCenter::IsIpDirectEnabled() const { return enable_ip_direct_; } + ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const { return screen_capture_method_; } diff --git a/apps/desktop/src/config_center/config_center.h b/apps/desktop/src/config_center/config_center.h index 5b4679a7..fef88eea 100644 --- a/apps/desktop/src/config_center/config_center.h +++ b/apps/desktop/src/config_center/config_center.h @@ -21,6 +21,7 @@ namespace crossdesk { class ConfigCenter { public: enum class LANGUAGE { CHINESE = 0, ENGLISH = 1, RUSSIAN = 2 }; + enum class CONNECTION_MODE { DEVICE_ID = 0, DIRECT_IP = 1 }; enum class VIDEO_ENCODE_FORMAT { H264 = 0, AV1 = 1 }; enum class TURN_MODE { DISABLED = 0, @@ -35,6 +36,8 @@ class ConfigCenter { // write config 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); @@ -54,6 +57,8 @@ class ConfigCenter { // read config LANGUAGE GetLanguage() const; + CONNECTION_MODE GetConnectionMode() const; + bool IsIpDirectEnabled() const; ScreenCaptureMethod GetScreenCaptureMethod() const; VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const; TURN_MODE GetTurnMode() const; @@ -86,6 +91,8 @@ class ConfigCenter { const char* section_ = "Settings"; LANGUAGE language_ = LANGUAGE::CHINESE; + 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; diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 6550fc5a..0cf7c31e 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1162,6 +1162,7 @@ int GuiApplication::Run() { updater_ = std::make_unique(path_manager_->GetCachePath()); #endif InitializeUi(); + InitializeLan(); #ifndef __OHOS__ ui_->activation_receiver = std::move(activation_receiver); #endif @@ -1296,6 +1297,10 @@ void GuiApplication::InitializeUi() { slint::set_xdg_app_id("crossdesk"); #endif ui_ = std::make_unique(); + for (const auto& window : {ui_->main, ui_->wall}) + window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled()); + ui_->main->set_lan_mode(config_center_->GetConnectionMode() == + ConfigCenter::CONNECTION_MODE::DIRECT_IP); #if _WIN32 ui_->shutdown_guard = std::make_unique(windows_rdp_handoff_connected_); @@ -1436,7 +1441,6 @@ void GuiApplication::InitializeSystemTray() { }; auto exit_app = [this] { exit_ = true; - slint::quit_event_loop(); }; #if _WIN32 ui_->tray = std::make_unique( @@ -1485,6 +1489,8 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) { main->set_language_index(language_button_value_); main->set_codec_index(video_encode_format_button_value_); main->set_turn_enabled(enable_turn_); + main->set_ip_direct_enabled_draft(config_center_->IsIpDirectEnabled()); + main->set_settings_save_error(""); main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_self_hosted_enabled(enable_self_hosted_); main->set_autostart_enabled(enable_autostart_); @@ -1544,6 +1550,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta); }); main->on_open_gallery([this] { ShowGalleryWindow(); }); + main->on_connection_mode_changed([this, main](bool lan_mode) { + const auto mode = lan_mode ? ConfigCenter::CONNECTION_MODE::DIRECT_IP + : ConfigCenter::CONNECTION_MODE::DEVICE_ID; + if (config_center_->SetConnectionMode(mode) != 0) { + LOG_WARN("Could not save connection mode preference"); + main->set_offline_warning(UiText( + localization_language_index_ == 0 + ? "无法保存连接模式,下次启动可能不会恢复当前模式。" + : localization_language_index_ == 2 + ? "Не удалось сохранить режим подключения. При следующем запуске он может не восстановиться." + : "Could not save the connection mode. It may not be restored on the next launch.")); + } + }); main->on_toggle_gallery_fullscreen( [this] { ToggleGalleryFullscreen(); }); main->on_gallery_action( @@ -1678,8 +1697,19 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { show_password_ = !show_password_; main->set_password_visible(show_password_); }); - main->on_reset_password([this](slint::SharedString value) { - return RequestPasswordChange(std::string(value)); + main->on_reset_password([this, main](slint::SharedString value) { + const std::string password(value); + if (!main->get_lan_mode()) return RequestPasswordChange(password); + if (!lan::ValidPassword(password)) return false; + if (!LanAvailable()) return true; // The initialization error is shown on the main view. + try { + lan_error_.clear(); + lan_->SetPassword(password); + main->set_lan_password_pending(lan_->password_pending()); + } catch (const std::exception& error) { + lan_error_ = error.what(); + } + return true; }); main->on_refresh_password([this] { const auto password = GenerateRandomPassword(password_saved_); @@ -1691,6 +1721,106 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { } RequestPasswordChange(*password); }); + main->on_lan_select_address([this, main](slint::SharedString address) { + if (!LanAvailable()) { + main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_)); + return false; + } + try { + lan_->identity().SelectAddress(std::string(address)); + const auto port = + UiText(std::to_string(lan_->identity().PortFor(std::string(address)))); + main->set_lan_listener_ip(address); + main->set_lan_listener_port(port); + main->set_lan_local_port(port); + main->set_lan_port_dirty(false); + main->set_lan_endpoint_error(""); + return true; + } catch (const std::exception& error) { + main->set_lan_endpoint_error(UiText(error.what())); + return false; + } + }); + main->on_lan_configure([this, main](slint::SharedString address, + slint::SharedString port) { + if (!LanAvailable()) { + main->set_lan_endpoint_error(UiText(lan_error_.empty() ? "Busy" : lan_error_)); + return false; + } + try { + const auto port_number = lan::ParsePort(std::string(port)); + lan_->Configure(std::string(address), port_number); + const auto normalized_port = UiText(std::to_string(port_number)); + main->set_lan_listener_ip(address); + main->set_lan_listener_port(normalized_port); + main->set_lan_local_port(normalized_port); + main->set_lan_port_dirty(false); + lan_error_.clear(); + main->set_lan_endpoint_error(""); + return true; + } catch (const std::exception& error) { + main->set_lan_endpoint_error(UiText(error.what())); + return false; + } + }); + main->on_lan_reset_password([this] { + if (LanAvailable()) { + lan_error_.clear(); + lan_->ResetPassword(); + } + }); + main->on_lan_copy_text([this](slint::SharedString value) { + if (!SDL_SetClipboardText(std::string(value).c_str())) { + lan_error_ = "Cannot copy text"; + return false; + } + return true; + }); + main->on_lan_prepare_connect([this, main](slint::SharedString endpoint) { + try { + const auto [address, port] = lan::ParseEndpoint(Trim(std::string(endpoint)), 52888); + if (!LanAvailable()) return UiText(lan_error_.empty() ? "Busy" : lan_error_); + main->set_lan_remote_address(UiText(address)); + main->set_lan_remote_port(UiText(std::to_string(port))); + main->set_lan_retry_after(lan_->RetryAfter(address, port)); + // History is newest first. Match both address and port, and only use + // the latest matching record when an endpoint has been reused. + for (const auto& [_, recent] : recent_connections_) { + if (recent.lan.device_id.empty() || recent.lan.address != address || + recent.lan.port != port) + continue; + if (recent.remember_password && lan::ValidPassword(recent.password)) { + main->set_lan_access_password(UiText(recent.password)); + main->set_lan_remember_password(true); + } + break; + } + lan_error_.clear(); + main->set_lan_message(""); + return slint::SharedString{}; + } catch (const std::exception& error) { + return UiText(error.what()); + } + }); + main->on_lan_cancel_connect([this, main] { + if (lan_ && lan_connecting_) lan_->Close(lan_connecting_); + lan_connecting_.reset(); + main->set_lan_connecting(false); + lan_error_.clear(); + main->set_lan_message(""); + }); + main->on_lan_connect([this, main](slint::SharedString address, + slint::SharedString port, + slint::SharedString password, bool remember) { + try { + ConnectLan(std::string(address), lan::ParsePort(std::string(port)), + std::string(password), remember); + } catch (const std::exception& error) { + lan_error_ = error.what(); + } + main->set_lan_connecting(lan_connecting_ != nullptr); + main->set_lan_message(UiText(lan_error_)); + }); main->on_connect_requested( [this](slint::SharedString id) { ConnectFromUi(std::string(id)); }); main->on_format_remote_id([](slint::SharedString value) { @@ -1703,7 +1833,7 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { if (auto props = FindRemoteSession(remote_id); props && props->connection_status_ == ConnectionStatus::Connected && !props->closing_) { - CloseStreamTab(remote_id); + CloseStreamTab(props->remote_id_); SyncMainWindow(); } }); @@ -2266,9 +2396,101 @@ void GuiApplication::BindServerCallbacks() { void GuiApplication::Tick() { if (exit_) { - slint::quit_event_loop(); + BeginShutdown(); + if (ShutdownDrained()) slint::quit_event_loop(); return; } + TickLan(); + if (LanAvailable()) { + const auto address = lan_->identity().settings().value("address", ""); + ui_->main->set_lan_password_pending(lan_->password_pending()); + ui_->main->set_lan_listening(lan_->listening()); + if (lan_connecting_ && lan_connecting_->authorized) { + ui_->main->set_lan_open(false); + lan_connecting_.reset(); + } else if (lan_connecting_ && lan_connecting_->closing) { + lan_connecting_.reset(); + } + ui_->main->set_lan_connecting(lan_connecting_ != nullptr); + int retry_after = 0; + if (ui_->main->get_lan_open()) { + try { + retry_after = lan_->RetryAfter( + std::string(ui_->main->get_lan_remote_address()), + lan::ParsePort(std::string(ui_->main->get_lan_remote_port()))); + } catch (const std::exception&) { + // An incomplete address has no cooldown; input validation handles it. + } + } + ui_->main->set_lan_retry_after(retry_after); + if (auto password = lan_->TakeNewPassword(); !password.empty()) + ui_->main->set_lan_new_password(UiText(password)); + const auto& available_interfaces = lan_->interfaces(); + const auto previous = ui_->main->get_lan_interfaces(); + bool changed = !previous || previous->row_count() != available_interfaces.size(); + for (size_t i = 0; !changed && i < available_interfaces.size(); ++i) + changed = std::string_view(previous->row_data(i).value()) != + available_interfaces[i].address; + if (changed) { + std::vector addresses; + addresses.reserve(available_interfaces.size()); + for (const auto& interface : available_interfaces) + addresses.push_back(UiText(interface.address)); + ui_->main->set_lan_interfaces( + std::make_shared>(std::move(addresses))); + } + // Keep the current displayed address while it remains available, including + // when a previously missing preferred address returns during a port edit. + const auto previous_address = std::string(ui_->main->get_lan_listener_ip()); + auto display_address = previous_address.empty() ? address : previous_address; + int interface_index = -1; + for (size_t i = 0; i < available_interfaces.size(); ++i) + if (available_interfaces[i].address == display_address) { + interface_index = static_cast(i); + break; + } + if (interface_index < 0) { + interface_index = available_interfaces.empty() ? -1 : 0; + display_address = interface_index == 0 + ? available_interfaces.front().address : ""; + } + const auto port = + UiText(std::to_string(lan_->identity().PortFor(display_address))); + if (previous_address != display_address) { + // A disappearing address must not transfer its unfinished port edit to + // another interface. Explicit selection commits the old edit first. + if (!previous_address.empty() && ui_->main->get_lan_port_dirty()) { + ui_->main->set_lan_access_warning_endpoint(UiText(previous_address)); + ui_->main->set_lan_access_warning("Selected LAN interface is unavailable"); + } + ui_->main->set_lan_port_dirty(false); + ui_->main->set_lan_endpoint_error(""); + ui_->main->set_lan_local_port(port); + } + ui_->main->set_lan_listener_ip(UiText(display_address)); + ui_->main->set_lan_listener_port(port); + ui_->main->set_lan_interface_index(interface_index); + ui_->main->set_lan_interface_draft_index(interface_index); + if (!ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) + ui_->main->set_lan_local_port(port); + ui_->main->set_lan_listener_error(UiText( + display_address.empty() ? "Selected LAN interface is unavailable" + : lan_->ListenerError(display_address))); + // Show failures one address at a time, without interrupting edits or another + // dialog. A working listener must not clear another address's error. + if (ui_->main->get_lan_mode() && ui_->main->get_can_show_sponsorship() && + !ui_->main->get_lan_port_editing() && !ui_->main->get_lan_port_dirty()) { + if (auto issue = lan_->TakeListenerWarning()) { + ui_->main->set_lan_access_warning_endpoint( + UiText(issue->address + ":" + std::to_string(issue->port))); + ui_->main->set_lan_access_warning(UiText(issue->error)); + } + } + } + const auto lan_message = lan_error_.rfind("ConnectionFailed", 0) == 0 + ? std::string("ConnectionFailed") + : lan_error_; + ui_->main->set_lan_message(UiText(lan_message)); #if _WIN32 if (ui_->shutdown_guard) { ui_->shutdown_guard->Update(ui_->shutdown_reason); @@ -2461,7 +2683,7 @@ void GuiApplication::ShareLocalCursorState() { } } } - if (!is_server_mode_ || !peer_ || connected_controllers.empty()) { + if (!is_server_mode_ || connected_controllers.empty()) { cursor_delivery_states_.clear(); return; } @@ -2587,9 +2809,8 @@ void GuiApplication::ShareLocalCursorState() { action.type = ControlType::cursor_state; action.cs = outgoing; const std::string message = action.to_json(); - const int result = SendReliableDataFrameToPeer( - peer_, message.c_str(), message.size(), mouse_label_.c_str(), - recipient.remote_id.c_str(), recipient.remote_id.size()); + const int result = SendControllerData(recipient.remote_id, message.c_str(), + message.size(), mouse_label_.c_str()); if (result != 0) { LOG_WARN("Send cursor state to [{}] failed, ret={}", recipient.remote_id, result); @@ -3008,8 +3229,9 @@ void GuiApplication::SyncMainWindow() { std::vector recent_groups{ tr_group("全部设备", "All devices", "Все устройства"), tr_group("未分组", "Ungrouped", "Без группы")}; + const bool lan_mode = ui_->main->get_lan_mode(); int recent_group_index = 0; - if (ui_->recent_group_filter) { + if (!lan_mode && ui_->recent_group_filter) { if (ui_->recent_group_filter->empty()) recent_group_index = 1; else { const auto found = std::find(ui_->gallery.groups.begin(), @@ -3048,10 +3270,12 @@ void GuiApplication::SyncMainWindow() { }; std::ostringstream signature_builder; signature_builder << config_center_->IsSaveRemotePreviews() << '\n' + << lan_mode << '\n' << bool(ui_->recent_group_filter) << '\0' << ui_->recent_group_filter.value_or("") << '\n'; for (const auto& [key, connection] : recent_connections_) { signature_builder << key << '\0' << connection.remote_id << '\0' + << connection.lan.address << ':' << connection.lan.port << '\0' << connection.remote_host_name << '\0' << static_cast(connection.platform) << '\0' << settings_.RecentConnectionDisplayName(connection) @@ -3072,7 +3296,9 @@ void GuiApplication::SyncMainWindow() { std::vector model; model.reserve(recent_connections_.size()); for (const auto& [_, connection] : recent_connections_) { - if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter)) + if (lan_mode != !connection.lan.device_id.empty()) continue; + if (!lan_mode && + !ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter)) continue; // Slint-generated scalar fields have no defaults. With no image, // has_thumbnail must remain false so the remote platform is rendered. @@ -3082,7 +3308,12 @@ void GuiApplication::SyncMainWindow() { item.group_names = UiText(group_labels[connection.remote_id]); item.display_name = UiText(settings_.RecentConnectionDisplayName(connection)); - item.online = device_presence_cache_.IsOnline(connection.remote_id); + item.lan = !connection.lan.device_id.empty(); + item.endpoint = UiText(item.lan ? connection.lan.address + ":" + + std::to_string(connection.lan.port) + : ""); + item.online = + !item.lan && device_presence_cache_.IsOnline(connection.remote_id); item.connected = is_connected(connection.remote_id); item.platform = UiText(HostPlatformName(connection.platform)); std::vector rgba; @@ -3163,7 +3394,9 @@ void GuiApplication::SyncConnectionDialog() { } else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) { text = localization::connection_not_authenticated[localization_language_index_]; } else { - text = ConnectionStatusText(status, localization_language_index_); + text = props->lan_session_ && !props->lan_session_->error.empty() + ? props->lan_session_->error + : ConnectionStatusText(status, localization_language_index_); } if (auth.retry_after > 0) { char retry_text[256]; @@ -4260,6 +4493,7 @@ bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) { bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { const auto previous_server = GetSignalServerSettings(*config_center_); auto& main = ui_->Window(gallery_window); + main->set_settings_save_error(""); const bool has_endpoint = main->get_self_hosted_enabled() || !std::string(main->get_server_host()).empty() || !std::string(main->get_server_port()).empty(); @@ -4298,6 +4532,15 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { main->set_self_host_settings_open(true); return false; } + if (config_center_->SetIpDirectEnabled(main->get_ip_direct_enabled_draft()) != 0) { + if (wall_changed && !ui_->gallery.Save(ui_->gallery_path)) + LOG_ERROR("Failed to restore DeskHub settings after IP direct save failure"); + main->set_settings_save_error(UiText( + localization_language_index_ == 0 ? "无法保存 IP 直连设置,请重试。" + : localization_language_index_ == 2 ? "Не удалось сохранить настройки IP. Повторите попытку." + : "Could not save direct IP settings. Try again.")); + return false; + } language_button_value_ = localization::detail::ClampLanguageIndex(main->get_language_index()); video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1); @@ -4407,9 +4650,12 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) { #endif if (ui_) { ui_->localized_language = -1; + for (const auto& window : {ui_->main, ui_->wall}) + window->set_ip_direct_enabled(config_center_->IsIpDirectEnabled()); if (!ui_->main->get_settings_open()) ResetSettingsUi(); if (!ui_->wall->get_settings_open()) ResetSettingsUi(true); } + ApplyLanSettings(); if (reconnect && !HasActiveSession()) { // Tick recreates the peer after asynchronous cleanup, using the committed // configuration. Keep the application and its console running throughout. @@ -4434,6 +4680,26 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) { void GuiApplication::ConnectFromUi(const std::string& input) { ui_->gallery_auth_id.clear(); + if (input.rfind("lan-", 0) == 0) { + if (!LanAvailable()) return; + if (auto existing = FindRemoteSession(input); + existing && existing->connection_established_) { + existing->tab_opened_ = true; + focused_remote_id_ = existing->remote_id_; + need_to_create_stream_window_ = true; + return; + } + for (const auto& [_, recent] : recent_connections_) + if (recent.remote_id == input && !recent.lan.device_id.empty()) { + ui_->main->invoke_request_lan_connection( + UiText(recent.lan.address + + (recent.lan.port == 52888 + ? "" + : ":" + std::to_string(recent.lan.port)))); + return; + } + return; + } const std::string remote_id = CompactPeerId(input); if (!gallery::Workspace::ValidId(remote_id)) { ui_->main->set_connection_input_error( @@ -5209,6 +5475,7 @@ void GuiApplication::GalleryAction(const std::string& action, std::set seen; int available = 0; for (const auto& [_, recent] : recent_connections_) { + if (!recent.lan.device_id.empty()) continue; if (!gallery::Workspace::ValidId(recent.remote_id) || !seen.insert(recent.remote_id).second) continue; @@ -5697,8 +5964,9 @@ void GuiApplication::SyncGallery() { }; const auto name = [this](const std::string& id) { for (const auto& [_, recent] : recent_connections_) - if (recent.remote_id == id) - return settings_.RecentConnectionDisplayName(recent); + if (recent.lan.device_id.empty()) + if (recent.remote_id == id) + return settings_.RecentConnectionDisplayName(recent); Thumbnail::RecentConnection device{}; device.remote_id = id; if (auto props = FindRemoteSession(id)) { @@ -5716,7 +5984,7 @@ void GuiApplication::SyncGallery() { { std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, props] : remote_sessions_) - if (props && !props->closing_ && + if (props && !props->lan_session_ && !props->closing_ && props->connection_status_ == ConnectionStatus::Connected) connected_ids.push_back(id); } @@ -5816,11 +6084,12 @@ void GuiApplication::SyncGallery() { bool remember = operation.remember_password; if (!operation.password) for (const auto& [_, recent] : recent_connections_) - if (recent.remote_id == operation.id) { - password = recent.password; - remember = recent.remember_password; - break; - } + if (recent.lan.device_id.empty()) + if (recent.remote_id == operation.id) { + password = recent.password; + remember = recent.remember_password; + break; + } // Join itself determines availability. Background probes must never // overwrite the interactive page's single pending presence probe. ConnectTo(operation.id, password.c_str(), remember, true, true); @@ -5970,6 +6239,7 @@ void GuiApplication::SyncGallery() { tile.selected = wall.selected.count(id); auto props = FindRemoteSession(id); for (const auto& [_, recent] : recent_connections_) { + if (!recent.lan.device_id.empty()) continue; if (recent.remote_id != id) continue; tile.host_name = UiText(recent.remote_host_name); break; @@ -6205,6 +6475,9 @@ void GuiApplication::SyncGallery() { } void GuiApplication::Cleanup() { + BeginShutdown(); + while (!ShutdownDrained()) + std::this_thread::sleep_for(std::chrono::milliseconds(2)); remote_input_.Stop(); #if _WIN32 windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed); diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index fbc228e3..5c348b58 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -37,7 +37,7 @@ struct TranslationRow { X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \ X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \ X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \ - X(console_help, u8"命令: status 查看ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \ + X(console_help, u8"命令: status 查看 ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \ X(console_status, u8"状态", "Status", u8"Состояние") \ X(console_online, u8"在线", "Online", u8"В сети") \ X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \ @@ -100,7 +100,7 @@ struct TranslationRow { X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \ X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \ X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \ - X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \ + X(local_id, u8"本机 ID", "Local ID", u8"Локальный ID") \ X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \ u8"Скопировано в буфер обмена") \ X(password, u8"密码", "Password", u8"Пароль") \ @@ -110,7 +110,7 @@ struct TranslationRow { X(remote_desktop, u8"远程桌面", "Remote Desktop", \ u8"Удаленный рабочий стол") \ X(device_name, u8"设备名称", "Device Name", u8"Имя устройства") \ - X(remote_id, u8"对端ID", "Remote ID", u8"Удаленный ID") \ + X(remote_id, u8"对端 ID", "Remote ID", u8"Удаленный ID") \ X(connect, u8"连接", "Connect", u8"Подключиться") \ X(recent_connections, u8"近期连接", "Recent Connections", \ u8"Недавние подключения") \ @@ -145,16 +145,16 @@ struct TranslationRow { X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \ X(mute, u8" 静音", " Mute", u8"Без звука") \ X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Сочетания клавиш") \ - X(send_sas, u8"发送SAS", "Send SAS", u8"Отправить SAS") \ + X(send_sas, u8"发送 SAS", "Send SAS", u8"Отправить SAS") \ X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать") \ X(remote_password_box_visible, u8"远端密码框已出现", \ "Remote password box visible", u8"Окно ввода пароля видно") \ - X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送SAS", \ + X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送 SAS", \ "Remote lock screen visible, send SAS", \ u8"Видна блокировка, отправьте SAS") \ X(remote_secure_desktop_active, u8"远端已进入安全桌面", \ "Remote secure desktop active", u8"Активен защищенный рабочий стол") \ - X(remote_service_unavailable, u8"远端Windows服务不可用", \ + X(remote_service_unavailable, u8"远端 Windows 服务不可用", \ "Remote Windows service unavailable", \ u8"Служба Windows на удаленной стороне недоступна") \ X(windows_service_setup_title, u8"安装 CrossDesk Service", \ @@ -288,7 +288,7 @@ struct TranslationRow { X(connection_not_authenticated, u8"登录状态已失效,请重新连接。", "Signaling login expired. Please reconnect.", u8"Сеанс входа истёк. Подключитесь заново.") \ X(connection_retry_after, u8"请在 %d 秒后重试。", "Retry in %d seconds.", u8"Повторите через %d сек.") \ X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \ - X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \ + X(no_such_id, u8"无此 ID", "No such ID", u8"ID не найден") \ X(about, u8"关于", "About", u8"О программе") \ X(sponsorship_title, u8"赞助支持", "Sponsorship", u8"Поддержка проекта") \ X(sponsorship_message, u8"如果 CrossDesk 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \ diff --git a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp index 90a5fece..5fcb1cec 100644 --- a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp +++ b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp @@ -226,15 +226,11 @@ int ClipboardController::SendToPeers(const std::string &text) { } } - if (owner_.peer_) { - const int ret = - SendReliableDataFrame(owner_.peer_, text.data(), text.size(), - owner_.clipboard_label_.c_str()); - if (ret != 0) { - LOG_WARN("Failed to send clipboard data to peer [{}], ret={}", - owner_.remote_id_display_, ret); - return ret; - } + lock.unlock(); + for (const auto& id : owner_.ConnectedControllers()) { + const int ret = owner_.SendControllerData(id, text.data(), text.size(), + owner_.clipboard_label_.c_str()); + if (ret != 0) return ret; } return 0; 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 ad0831fc..0e16c2e7 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -43,18 +43,17 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) SpeakerCapturerFactory().Create()); }, [this](unsigned char *data, size_t size, const char *) { - if (!owner_.peer_) return; MiniRtcAudioFrame frame{}; frame.data = reinterpret_cast(data); frame.size = size; - frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_); - std::shared_lock lock(owner_.connection_status_mutex_); - for (const auto& [id, status] : owner_.connection_status_) { - if (status == ConnectionStatus::Connected) - SendAudioFrameToPeer(owner_.peer_, &frame, - owner_.audio_label_.c_str(), id.data(), - id.size()); - } + for (const auto& id : owner_.ConnectedControllers()) + owner_.WithController( + id, [&](PeerPtr* peer, const std::string& wire) { + frame.captured_timestamp = GetSystemTimeMicros(peer); + return SendAudioFrameToPeer(peer, &frame, + owner_.audio_label_.c_str(), + wire.data(), wire.size()); + }); }) {} SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); } @@ -132,6 +131,10 @@ void SessionDeviceManager::Initialize() { DeviceControllerFactory::Device::Keyboard)); } +int SessionDeviceManager::EnsureCaptureReady() { + return screen_capturer_ ? 0 : InitializeScreenCapturer(); +} + int SessionDeviceManager::InitializeScreenCapturer() { #ifdef __APPLE__ if (!owner_.EnsureMacScreenRecordingPermission()) { @@ -193,16 +196,7 @@ int SessionDeviceManager::InitializeScreenCapturer() { .count() : 0; - std::vector connected_remote_ids; - { - std::shared_lock lock(owner_.connection_status_mutex_); - connected_remote_ids.reserve(owner_.connection_status_.size()); - for (const auto &[remote_id, status] : owner_.connection_status_) { - if (status == ConnectionStatus::Connected) { - connected_remote_ids.push_back(remote_id); - } - } - } + const auto connected_remote_ids = owner_.ConnectedControllers(); // Capture can still deliver frames while ICE is gathering or after // the final controller disconnects. Do not broadcast those frames to @@ -232,28 +226,27 @@ int SessionDeviceManager::InitializeScreenCapturer() { has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs; const bool stream_changed = !last_video_frame_stream_id_.empty() && last_video_frame_stream_id_ != stream_id; - if (resumed_after_gap || stream_changed) { - if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) { - LOG_DEBUG("Request video key frame before sending captured frame, " - "stream='{}', gap_ms={}, stream_changed={}", - stream_id, duration_ms, stream_changed); - } - } MiniRtcVideoFrame frame{}; frame.data = reinterpret_cast(data); frame.size = size; frame.width = width; frame.height = height; - frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_); + frame.native_frame = native_frame; const auto send_started = std::chrono::steady_clock::now(); uint64_t send_errors = 0; for (const std::string &remote_id : connected_remote_ids) { if (!owner_.preview_subscriptions_.ShouldSend(remote_id, now_time, stream_id)) continue; - if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(), - remote_id.data(), remote_id.size()) != 0) + if (owner_.WithController( + remote_id, [&](PeerPtr* peer, const std::string& wire) { + if (resumed_after_gap || stream_changed) + RequestVideoKeyFrame(peer, stream_id.c_str()); + frame.captured_timestamp = GetSystemTimeMicros(peer); + return SendVideoFrameToPeer(peer, &frame, stream_id.c_str(), + wire.data(), wire.size()); + }) != 0) ++send_errors; } const auto send_us = 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 0f24efbb..114864d1 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.h +++ b/apps/desktop/src/gui/features/devices/session_device_manager.h @@ -54,6 +54,7 @@ public: void DestroyFactories(); int InitializeScreenCapturer(); + int EnsureCaptureReady(); int StartScreenCapturer(); int StopScreenCapturer(); bool ReleaseRemoteMouseButtons(); 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 cd5e7f4e..c9b21b0d 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 @@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { peer = props ? props->peer_ : owner_.peer_; events = props ? props->peer_events_ : owner_.peer_events_; } + if (!props && file.remote_id.rfind("lan/", 0) == 0) { + events = owner_.ControllerEvents(file.remote_id); + peer = events ? events->DirectPeer() : nullptr; + } auto session = std::make_shared( [peer, events, file, props](const char*, const char* data, size_t size) { if (!peer || !events || (props && props->closing_)) return -1; auto callback = events->EnterCallback(); - if (!callback) return -1; - return SendReliableDataFrameToPeer(peer, data, size, file.file_label.c_str(), - file.remote_id.c_str(), file.remote_id.size()); + if (!callback || !events->DataAllowed()) return -1; + const auto wire_id = events->WireId(file.remote_id); + return SendReliableDataFrameToPeer( + peer, data, size, file.file_label.c_str(), + wire_id.data(), wire_id.size()); }, file_transfer::Session::Progress{}); state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0; state.current_file_id_ = id; @@ -313,6 +319,10 @@ void FileTransferManager::Receive(const char* data, size_t size, peer = props ? props->peer_ : owner_.peer_; events = props ? props->peer_events_ : owner_.peer_events_; } + if (!props && remote.rfind("lan/", 0) == 0) { + events = owner_.ControllerEvents(remote); + peer = events ? events->DirectPeer() : nullptr; + } if (!peer || !events || events.get() != handler || (props && props->closing_)) return; std::shared_ptr receiver; std::filesystem::path default_directory; @@ -333,10 +343,12 @@ void FileTransferManager::Receive(const char* data, size_t size, }); entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) { auto callback = events->EnterCallback(); - if (!callback) return -1; + if (!callback || !events->DataAllowed()) return -1; const auto bytes = EncodeFileTransferAck(ack); - return SendReliableDataFrameToPeer(peer, bytes.data(), bytes.size(), kFileFeedbackStream, - remote.c_str(), remote.size()); + const auto wire_id = events->WireId(remote); + return SendReliableDataFrameToPeer( + peer, bytes.data(), bytes.size(), kFileFeedbackStream, + wire_id.data(), wire_id.size()); }); } receiver = entry.session; diff --git a/apps/desktop/src/gui/features/settings/settings_manager.cpp b/apps/desktop/src/gui/features/settings/settings_manager.cpp index 63698a82..37b5b6f0 100644 --- a/apps/desktop/src/gui/features/settings/settings_manager.cpp +++ b/apps/desktop/src/gui/features/settings/settings_manager.cpp @@ -744,6 +744,8 @@ std::string SettingsManager::RecentConnectionDisplayName( connection.remote_host_name != "unknown") { return connection.remote_host_name; } + if (!connection.lan.device_id.empty()) + return connection.lan.address + ":" + std::to_string(connection.lan.port); return connection.remote_id; } diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index 31b719fa..c7496571 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -26,6 +26,7 @@ constexpr auto kPresenceRetryInterval = std::chrono::seconds(1); } // namespace bool GuiRuntime::HasActiveSession() { + if (lan_ && !lan_->Quiescent()) return true; if (stream_window_inited_) return true; std::shared_lock lock(connection_status_mutex_); return std::any_of(connection_status_.begin(), connection_status_.end(), @@ -188,7 +189,8 @@ void GuiRuntime::HandlePresenceProbeTimeout() { void GuiRuntime::HandleServerControllerDisconnected( const std::string& remote_id, const char* reason) { - transfers_.Cancel(peer_events_.get(), remote_id); + if (auto events = ControllerEvents(remote_id)) + transfers_.Cancel(events.get(), remote_id); preview_subscriptions_.Remove(remote_id); bool has_connected_controller = false; @@ -243,7 +245,15 @@ void GuiRuntime::HandleServerControllerDisconnected( #endif devices_.StopSpeakerCapturer(); start_mouse_controller_ = false; - start_keyboard_capturer_ = false; + { + std::shared_lock lock(remote_sessions_mutex_); + start_keyboard_capturer_ = std::any_of( + remote_sessions_.begin(), remote_sessions_.end(), [](const auto& item) { + const auto& session = item.second; + return session && !session->closing_ && + session->connection_established_ && !session->wall_managed_; + }); + } remote_client_id_.clear(); audio_capture_ = false; } @@ -294,10 +304,15 @@ int GuiRuntime::ForceSignalReconnect() { } void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { - if (!props || props->closing_.exchange(true)) return; + if (!props) return; + if (props->lan_session_ && !props->lan_session_->closing) { + if (lan_) lan_->Close(props->lan_session_); + return; + } + if (props->closing_.exchange(true)) return; MuteRemoteAudio(props); if (props->peer_events_) { - props->peer_events_->Deactivate(); + props->peer_events_->StopAccepting(); transfers_.Cancel(props->peer_events_.get()); } props->connection_established_ = false; @@ -350,7 +365,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask( [props, peer, frame_snapshot, video_width, video_height, platform, thumbnail = thumbnail_, queued_at]() mutable { - if (peer) { + if (props->peer_events_) props->peer_events_->Deactivate(); + if (peer && !props->lan_session_) { LOG_INFO("[{}] Background leave connection [{}]", props->local_id_, props->remote_id_); LeaveConnection(peer, props->remote_id_.c_str()); @@ -359,11 +375,20 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { if (thumbnail && props->was_connected_) { const char* nv12 = frame_snapshot && !frame_snapshot->empty() ? reinterpret_cast(frame_snapshot->data()) : nullptr; + const auto direct = props->lan_session_; + const Thumbnail::LanTarget target = + direct ? Thumbnail::LanTarget{direct->target.address, + direct->target.device_id, + direct->target.port} + : Thumbnail::LanTarget{}; + const auto record_id = direct + ? Thumbnail::LanRecordId(target.device_id) + : props->remote_id_; if (thumbnail->SaveToThumbnail( - nv12, video_width, video_height, props->remote_id_, + nv12, video_width, video_height, record_id, props->remote_host_name_, props->remember_password_ ? props->remote_password_ : "", - platform) != 0) { + platform, direct ? &target : nullptr) != 0) { LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_); } } @@ -375,6 +400,17 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { } void GuiRuntime::CloseServerController(const std::string& remote_id) { + if (remote_id.rfind("lan/", 0) == 0) { + std::shared_ptr session; + { + std::shared_lock lock(remote_sessions_mutex_); + if (auto it = lan_controllers_.find(remote_id); + it != lan_controllers_.end()) + session = it->second.session; + } + if (session && lan_) lan_->Close(session); + return; + } if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id)) return; controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask( @@ -392,20 +428,17 @@ void GuiRuntime::CloseConnectionPeer() { announcements_.Reset(); return; } - peer_events_->Deactivate(); + peer_events_->StopAccepting(); transfers_.Cancel(peer_events_.get()); announcements_.Reset(); std::vector controllers; { std::shared_lock lock(connection_status_mutex_); - for (const auto& [id, status] : connection_status_) controllers.push_back(id); + for (const auto& [id, status] : connection_status_) + if (id.rfind("lan/", 0) != 0) controllers.push_back(id); } for (const auto& id : controllers) HandleServerControllerDisconnected(id, "peer_closed"); - devices_.StopMouseController(); - devices_.StopScreenCapturer(); - devices_.StopSpeakerCapturer(); - devices_.StopKeyboardCapturer(); signal_connected_ = false; device_presence_cache_.SetSignalConnected(false); signal_status_ = SignalStatus::SignalClosed; @@ -418,6 +451,7 @@ void GuiRuntime::CloseConnectionPeer() { // these finish before the shared peer is destroyed. connection_peer_cleanup_ = session_cleanup_queue_.PostTask( [peer, handler = peer_events_, id = std::string(client_id_)]() mutable { + handler->Deactivate(); LOG_INFO("[{}] Background connection peer cleanup", id); LeaveConnection(peer, id.c_str()); DestroyPeer(&peer); @@ -551,6 +585,12 @@ std::shared_ptr GuiRuntime::FindRemoteSession( std::shared_lock lock(remote_sessions_mutex_); auto it = remote_sessions_.find(remote_id); if (it == remote_sessions_.end()) { + if (remote_id.rfind("lan-", 0) == 0) + for (const auto& [_, props] : remote_sessions_) + if (props->lan_session_ && !props->closing_ && + Thumbnail::LanRecordId(props->lan_session_->target.device_id) == + remote_id) + return props; return nullptr; } return it->second; diff --git a/apps/desktop/src/gui/runtime/device_presence_request.h b/apps/desktop/src/gui/runtime/device_presence_request.h index 9bb5cf21..2376991c 100644 --- a/apps/desktop/src/gui/runtime/device_presence_request.h +++ b/apps/desktop/src/gui/runtime/device_presence_request.h @@ -22,6 +22,7 @@ inline nlohmann::json BuildDevicePresenceRequest( const auto append = [&](const std::string& id) { // Recent connection keys contain Y/N plus display settings after the ID. const std::string pure_id = id.substr(0, id.find_first_of("YN")); + if (pure_id.rfind("lan-", 0) == 0 || pure_id.rfind("lan/", 0) == 0) return; if (!pure_id.empty() && std::find(devices.begin(), devices.end(), pure_id) == devices.end()) { devices.push_back(pure_id); diff --git a/apps/desktop/src/gui/runtime/gui_runtime.cpp b/apps/desktop/src/gui/runtime/gui_runtime.cpp index 2a86df01..d8f1c0be 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.cpp +++ b/apps/desktop/src/gui/runtime/gui_runtime.cpp @@ -70,6 +70,7 @@ GuiRuntime::~GuiRuntime() { windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed); if (windows_service_worker_) windows_service_worker_->Reset(); #endif + lan_.reset(); transfers_.Shutdown(); WaitForSessionCleanup(); } @@ -292,7 +293,7 @@ int GuiRuntime::CreateConnectionPeer() { LOG_INFO("Create peer [{}] instance failed", client_id_); } - if (0 == devices_.InitializeScreenCapturer()) { + if (0 == devices_.EnsureCaptureReady()) { const auto &displays = devices_.display_info_list(); for (size_t index = 0; index < displays.size(); ++index) { const std::string stream_id = MakeDisplayStreamId(index); @@ -334,10 +335,18 @@ void GuiRuntime::HandleRecentConnections() { // Record a successful connection before a preview or host info arrives. // The close path still updates its final thumbnail. Remember password // controls only credential storage, not whether this device is listed. + const auto direct = props->lan_session_; + const Thumbnail::LanTarget target = + direct ? Thumbnail::LanTarget{direct->target.address, + direct->target.device_id, + direct->target.port} + : Thumbnail::LanTarget{}; + const auto record_id = + direct ? Thumbnail::LanRecordId(target.device_id) : props->remote_id_; if (thumbnail_->SaveToThumbnail( - nullptr, 0, 0, props->remote_id_, host_name, + nullptr, 0, 0, record_id, host_name, props->remember_password_ ? props->remote_password_ : "", - platform) == 0) + platform, direct ? &target : nullptr) == 0) reload_recent_connections_ = true; else LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_); @@ -356,7 +365,8 @@ void GuiRuntime::HandleRecentConnections() { recent_connection_ids_.clear(); for (const auto &conn : recent_connections_) { - recent_connection_ids_.push_back(conn.first); + if (conn.second.lan.device_id.empty()) + recent_connection_ids_.push_back(conn.first); } need_to_send_recent_connections_ = true; } diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index 6e2a213c..fea29d9f 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -19,6 +19,7 @@ #include "features/input/keyboard_controller.h" #include "features/input/remote_input_dispatcher.h" #include "features/settings/settings_manager.h" +#include "network/lan/lan_session_manager.h" #include "privacy_controller.h" #include "runtime/gui_state.h" #include "runtime/announcement_inbox.h" @@ -50,6 +51,35 @@ class GuiRuntime : protected gui_detail::GuiState { static void SdlCaptureAudioIn(void* userdata, uint8_t* stream, int len); static void SdlCaptureAudioOut(void* userdata, uint8_t* stream, int len); + void InitializeLan(); + void ApplyLanSettings(); + bool LanAvailable() const; + void TickLan(); + void BeginShutdown(); + bool ShutdownDrained(); + void RegisterStreams(PeerPtr* peer, size_t display_count); + void ConnectLan(const std::string& address, unsigned port, + std::string password, bool remember_password = false); + int WithController( + const std::string& id, + const std::function& operation); + int SendControllerData(const std::string& id, const char* data, size_t size, + const char* label); + std::shared_ptr ControllerEvents(const std::string& id); + std::vector ConnectedControllers(); + std::unique_ptr lan_; + std::string lan_error_; + std::shared_ptr lan_connecting_; + bool lan_stopping_ = false; + bool stopping_ = false; + std::chrono::steady_clock::time_point shutdown_report_at_{}; + struct LanController { + std::shared_ptr session; + std::shared_ptr events; + }; + // Protected by remote_sessions_mutex_; snapshots acquire callback leases + // before releasing that lock, so removal cannot race a raw Peer use. + std::map lan_controllers_; int CreateConnectionPeer(); int ConnectTo(const std::string& remote_id, const char* password, bool remember_password, bool bypass_presence_check = false, diff --git a/apps/desktop/src/gui/runtime/lan_runtime.cpp b/apps/desktop/src/gui/runtime/lan_runtime.cpp new file mode 100644 index 00000000..1d471016 --- /dev/null +++ b/apps/desktop/src/gui/runtime/lan_runtime.cpp @@ -0,0 +1,236 @@ +#include + +#include "rd_log.h" +#include "runtime/gui_runtime.h" +#if defined(__linux__) && !defined(__OHOS__) +#include "platform/linux/headless/headless_console.h" +#endif + +namespace crossdesk { +void GuiRuntime::RegisterStreams(PeerPtr* peer, size_t displays) { + for (size_t i = 0; i < displays; ++i) { + const auto id = MakeDisplayStreamId(i); + AddVideoStream(peer, id.c_str()); + } + AddAudioStream(peer, audio_label_.c_str()); + AddDataStream(peer, data_label_.c_str(), false); + for (const auto& label : + {mouse_label_, keyboard_label_, control_data_label_, file_label_, + file_feedback_label_, clipboard_label_}) + AddDataStream(peer, label.c_str(), true); +} +void GuiRuntime::InitializeLan() { + if (unattended_host_ || stopping_ || lan_ || + !config_center_->IsIpDirectEnabled()) return; +#if defined(__linux__) && !defined(__OHOS__) + if (HeadlessConsole::Instance().active()) return; +#endif + lan_error_.clear(); + lan::SessionManager::Hooks hooks; + hooks.error = [this](const std::string& error) { + if (!lan_stopping_ && !stopping_) lan_error_ = error; + }; + hooks.prepare = [this](const std::shared_ptr& session) { + if (stopping_ || lan_stopping_ || devices_.EnsureCaptureReady() != 0) + throw std::runtime_error("Capture initialization failed"); + const size_t displays = devices_.display_info_list().size(); + auto events = std::make_shared(*this); + events->BindDirect(session); + // Direct initialization must not depend on cloud login/configuration having + // successfully populated the managed peer's Params snapshot. + Params params{}; + std::snprintf(params.log_path, sizeof(params.log_path), "%s", + dll_log_path_.c_str()); + params.hardware_acceleration = true; + params.native_video_output = true; + params.av1_encoding = config_center_->GetVideoEncodeFormat() == + ConfigCenter::VIDEO_ENCODE_FORMAT::AV1; + params.enable_srtp = true; + params.turn_mode = TurnMode::TurnDisabled; + params.video_content_type = VideoContentType::ScreenContent; + params.video_quality = VideoQuality::QualityHigh; + params.video_frame_rate = 60; + params.video_degradation_preference = + VideoDegradationPreference::MaintainResolution; + params.on_receive_video_frame = PeerEventHandler::OnReceiveVideoBuffer; + params.on_receive_audio_buffer = PeerEventHandler::OnReceiveAudioBuffer; + params.on_receive_data_buffer = PeerEventHandler::OnReceiveDataBuffer; + params.on_connection_status = PeerEventHandler::OnConnectionStatus; + params.on_net_status_report = PeerEventHandler::OnNetStatusReport; + params.user_id = session->local_id.c_str(); + params.user_data = events.get(); + if (!session->incoming) { + auto props = FindRemoteSession(session->key); + if (!props) throw std::runtime_error("LAN session cancelled"); + props->peer_events_ = events; + props->local_id_ = session->local_id; + } + return [this, session, events, params, displays] { + lan::SessionManager::Prepared result; + result.callback_owner = events; + result.drain_callbacks = [events] { events->Deactivate(); }; + result.peer = CreatePeer(¶ms); + events->SetDirectPeer(result.peer); + if (result.peer) RegisterStreams(result.peer, displays); + return result; + }; + }; + hooks.attached = [this](const std::shared_ptr& session) { + auto events = + std::static_pointer_cast(session->callback_owner); + std::unique_lock lock(remote_sessions_mutex_); + if (session->incoming) + lan_controllers_[session->key] = {session, events}; + else if (auto it = remote_sessions_.find(session->key); + it != remote_sessions_.end()) + it->second->peer_ = session->peer; + }; + hooks.connected = [this](const std::shared_ptr& session) { + auto events = + std::static_pointer_cast(session->callback_owner); + events->ApplyConnectionStatus(ConnectionStatus::Connected, session->key); + events->FlushEarlyData(); + }; + hooks.closed = [this](const std::shared_ptr& session) { + auto events = + std::static_pointer_cast(session->callback_owner); + bool attached = !session->incoming; + if (session->incoming) { + std::shared_lock lock(remote_sessions_mutex_); + attached = lan_controllers_.count(session->key) != 0; + } + if (events) { + if (attached) + events->ApplyConnectionStatus(ConnectionStatus::Closed, session->key); + events->StopAccepting(); + transfers_.Cancel(events.get()); + } + if (session->incoming) { + std::unique_lock lock(remote_sessions_mutex_); + lan_controllers_.erase(session->key); + } else if (auto props = FindRemoteSession(session->key)) + CloseRemoteSession(props); + }; + try { + lan_ = std::make_unique( + path_manager_->GetConfigPath() / "lan", std::move(hooks)); + } catch (const std::exception& error) { + lan_error_ = error.what(); + LOG_ERROR("LAN initialization failed: {}", error.what()); + } +} +void GuiRuntime::ApplyLanSettings() { + if (config_center_->IsIpDirectEnabled()) { + InitializeLan(); + } else { + lan_connecting_.reset(); + lan_error_.clear(); + if (lan_ && !lan_stopping_) { + lan_stopping_ = true; + lan_->Stop(); + } + } +} +bool GuiRuntime::LanAvailable() const { + return lan_ && !lan_stopping_ && !stopping_; +} +void GuiRuntime::TickLan() { + if (lan_) lan_->Tick(); + if (lan_ && lan_stopping_ && lan_->Quiescent()) { + // Drain async authentication, media preparation and callbacks before + // releasing the manager. A quick off/on waits here before rebinding. + lan_.reset(); + lan_stopping_ = false; + InitializeLan(); + } +} +void GuiRuntime::ConnectLan(const std::string& address, unsigned port, + std::string password, bool remember_password) { + if (!LanAvailable() || (lan_connecting_ && !lan_connecting_->closing)) + return; + try { + auto props = std::make_shared(); + props->remember_password_ = remember_password; + if (remember_password && lan::ValidPassword(password)) + std::memcpy(props->remote_password_, password.data(), password.size()); + auto session = lan_->Connect(address, port, std::move(password)); + lan_connecting_ = session; + props->lan_session_ = session; + props->remote_id_ = session->key; + props->connection_status_ = ConnectionStatus::Connecting; + props->connection_auth_.Complete(); + { + std::unique_lock lock(remote_sessions_mutex_); + remote_sessions_[session->key] = props; + } + focused_remote_id_ = session->key; + show_connection_status_window_ = false; + lan_error_.clear(); + } catch (const std::exception& error) { + lan_error_ = error.what(); + } +} +std::shared_ptr GuiRuntime::ControllerEvents( + const std::string& id) { + std::shared_lock lock(remote_sessions_mutex_); + auto it = lan_controllers_.find(id); + return it == lan_controllers_.end() + ? (id.rfind("lan/", 0) == 0 ? nullptr : peer_events_) + : it->second.events; +} +int GuiRuntime::WithController( + const std::string& id, + const std::function& operation) { + std::shared_lock lock(remote_sessions_mutex_); + auto it = lan_controllers_.find(id); + auto events = it == lan_controllers_.end() ? peer_events_ : it->second.events; + if (it == lan_controllers_.end() && id.rfind("lan/", 0) == 0) return -1; + PeerPtr* peer = + it == lan_controllers_.end() ? peer_ : it->second.session->peer; + if (!peer || !events || !events->DataAllowed()) return -1; + auto callback = events->EnterCallback(); + if (!callback) return -1; + const auto wire_id = events->WireId(id); + lock.unlock(); + return operation(peer, wire_id); +} +int GuiRuntime::SendControllerData(const std::string& id, const char* data, + size_t size, const char* label) { + return WithController(id, [&](PeerPtr* peer, const std::string& wire) { + return SendReliableDataFrameToPeer(peer, data, size, label, wire.data(), + wire.size()); + }); +} +std::vector GuiRuntime::ConnectedControllers() { + std::vector ids; + std::shared_lock lock(connection_status_mutex_); + ids.reserve(connection_status_.size()); + for (const auto& [id, status] : connection_status_) + if (status == ConnectionStatus::Connected) ids.push_back(id); + return ids; +} +void GuiRuntime::BeginShutdown() { + if (stopping_) return; + stopping_ = true; + shutdown_report_at_ = + std::chrono::steady_clock::now() + std::chrono::seconds(5); + if (lan_) lan_->Stop(); + CloseAllRemoteSessions(); +} +bool GuiRuntime::ShutdownDrained() { + TickLan(); + HandleSessionCleanup(); + const bool drained = + (!lan_ || lan_->Quiescent()) && !connection_peer_cleanup_.valid() && + session_cleanup_tasks_.empty() && controller_cleanup_tasks_.empty(); + if (!drained && std::chrono::steady_clock::now() >= shutdown_report_at_) { + LOG_WARN( + "Shutdown waiting: LAN [{}], cloud peer={}, viewers={}, controllers={}", + lan_ ? lan_->PendingWork() : "none", connection_peer_cleanup_.valid(), + session_cleanup_tasks_.size(), controller_cleanup_tasks_.size()); + shutdown_report_at_ = + std::chrono::steady_clock::now() + std::chrono::seconds(5); + } + return drained; +} +} // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 504d3c34..8dbee211 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -84,17 +84,52 @@ void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *side, } // namespace +bool PeerEventHandler::QueueEarlyData(const char* data, size_t size, + const char* label, size_t label_size) { + if (!direct_bound_) return false; + auto session = direct_.lock(); + if (!session || session->closing) return true; + std::lock_guard lock(early_data_mutex_); + if (session->authorized) return false; + // Keep bounded reliable metadata until the manager commits Connected. Mouse, + // keyboard, clipboard and files never enter this pre-authorization queue. + if (std::string_view(label, label_size) != owner_.control_data_label_) + return true; + if (size > 8192 || early_data_.size() >= 16 || + early_data_bytes_ + size > 65536) { + session->failed = true; + return true; + } + early_data_bytes_ += size; + early_data_.emplace_back(data, size); + return true; +} +void PeerEventHandler::FlushEarlyData() { + std::vector queued; + { + std::lock_guard lock(early_data_mutex_); + queued.swap(early_data_); + early_data_bytes_ = 0; + } + const auto session = direct_.lock(); + if (!session || !session->authorized || session->closing) return; + const auto& label = owner_.control_data_label_; + for (const auto& data : queued) + OnReceiveDataBuffer(data.data(), data.size(), session->remote_id.data(), + session->remote_id.size(), label.data(), label.size(), + this); +} + void PeerEventHandler::OnReceiveDataBuffer( const char *data, size_t size, const char *user_id, size_t user_id_size, const char *src_id, size_t src_id_size, void *user_data) { auto *handler = static_cast(user_data); if (!handler) return; auto callback = handler->EnterCallback(); - if (!callback) return; - GuiRuntime *runtime = handler ? &handler->owner_ : nullptr; - if (!runtime) { + if (!callback || handler->QueueEarlyData(data, size, src_id, src_id_size) || + !handler->DataAllowed()) return; - } + GuiRuntime* runtime = &handler->owner_; std::string source_id = std::string(src_id, src_id_size); // The service-owned host runs as SYSTEM. Do not expose filesystem or @@ -105,11 +140,13 @@ void PeerEventHandler::OnReceiveDataBuffer( source_id == runtime->clipboard_label_)) return; if (source_id == runtime->file_label_) { - runtime->transfers_.Receive(data, size, handler, std::string(user_id, user_id_size)); + runtime->transfers_.Receive(data, size, handler, + handler->ConnectionKey(user_id, user_id_size)); return; } else if (source_id == runtime->clipboard_label_) { if (size > 0) { - std::string remote_user_id(user_id, user_id_size); + std::string remote_user_id = + handler->ConnectionKey(user_id, user_id_size); auto props = runtime->FindRemoteSession(remote_user_id); if (props && !props->clipboard_allowed_.load()) { @@ -121,11 +158,15 @@ void PeerEventHandler::OnReceiveDataBuffer( } return; } else if (source_id == runtime->file_feedback_label_) { - runtime->transfers_.HandleAck(data, size, handler, std::string(user_id, user_id_size)); + runtime->transfers_.HandleAck( + data, size, handler, handler->ConnectionKey(user_id, user_id_size)); return; } - const std::string remote_id(user_id, user_id_size); + const std::string remote_id = handler->ConnectionKey(user_id, user_id_size); + const std::string wire_id = handler->WireId(remote_id); + PeerPtr* bound_peer = + handler->IsDirect() ? handler->DirectPeer() : runtime->peer_; std::string json_str(data, size); const auto preview_json = json_str.find("\"deskhub_preview") != std::string::npos @@ -145,29 +186,31 @@ void PeerEventHandler::OnReceiveDataBuffer( if (!previous || subscription->IsNewerThan(*previous)) { if (subscription->fps > 0) accepted = UpdateVideoSettings( - runtime->peer_, remote_id.data(), remote_id.size(), - static_cast(subscription->quality), - subscription->frame_rate, - static_cast( - subscription->EncoderPreference())) == 0; + bound_peer, wire_id.data(), wire_id.size(), + static_cast(subscription->quality), + subscription->frame_rate, + static_cast( + subscription->EncoderPreference())) == 0; if (accepted && (!previous || previous->max_bitrate_bps.value_or(0) != subscription->max_bitrate_bps.value_or(0))) accepted = UpdateVideoBitrateLimit( - runtime->peer_, remote_id.data(), remote_id.size(), + bound_peer, wire_id.data(), wire_id.size(), subscription->max_bitrate_bps.value_or(0)) == 0; // Rebalancing bandwidth alone must not force every device to send a // keyframe. The pause API requests its own keyframes on resume. if (accepted && (!previous || previous->width != subscription->width || previous->height != subscription->height)) - accepted = UpdateVideoResolution( - runtime->peer_, remote_id.data(), remote_id.size(), - subscription->width, subscription->height) == 0; + accepted = UpdateVideoResolution(bound_peer, wire_id.data(), + wire_id.size(), subscription->width, + subscription->height) == 0; if (accepted) - accepted = SetVideoSendPaused(runtime->peer_, remote_id.data(), - remote_id.size(), subscription->fps == 0) == 0; + accepted = + SetVideoSendPaused(bound_peer, wire_id.data(), wire_id.size(), + subscription->fps == 0) == 0; if (accepted) - accepted = SetAudioSendPaused(runtime->peer_, remote_id.data(), - remote_id.size(), !subscription->audio) == 0; + accepted = + SetAudioSendPaused(bound_peer, wire_id.data(), wire_id.size(), + !subscription->audio) == 0; if (accepted && subscription->audio) runtime->devices_.StartSpeakerCapturer(); } @@ -178,9 +221,9 @@ void PeerEventHandler::OnReceiveDataBuffer( {"request_id", subscription->request_id}, {"accepted", accepted}}.dump(); - SendReliableDataFrameToPeer(runtime->peer_, reply.data(), reply.size(), + SendReliableDataFrameToPeer(bound_peer, reply.data(), reply.size(), runtime->control_data_label_.c_str(), - remote_id.data(), remote_id.size()); + wire_id.data(), wire_id.size()); return; } if (preview_json.is_object() && @@ -253,7 +296,7 @@ void PeerEventHandler::OnReceiveDataBuffer( const auto& settings = remote_action.vs; remote_action.vs.accepted = UpdateVideoSettings( - runtime->peer_, remote_id.data(), remote_id.size(), + bound_peer, wire_id.data(), wire_id.size(), static_cast(settings.quality), settings.frame_rate, static_cast(settings.preference)) == 0; LOG_INFO("Video settings: peer={} source={} request={} quality={} fps={} " @@ -262,9 +305,9 @@ 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(); - SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(), + SendReliableDataFrameToPeer(bound_peer, message.data(), message.size(), runtime->control_data_label_.c_str(), - remote_id.data(), remote_id.size()); + wire_id.data(), wire_id.size()); return; } if (remote_action.type == ControlType::privacy_status) { @@ -407,8 +450,10 @@ void PeerEventHandler::OnReceiveDataBuffer( props->video_settings_request_tick_ = SDL_GetTicks(); settings.vs = props->video_settings_; const auto message = settings.to_json(); - SendReliableDataFrame(props->peer_, message.data(), message.size(), - props->control_data_label_.c_str()); + SendReliableDataFrame( + handler->IsDirect() ? handler->DirectPeer() : props->peer_, + message.data(), message.size(), + props->control_data_label_.c_str()); } } // client mode @@ -456,8 +501,8 @@ void PeerEventHandler::OnReceiveDataBuffer( // command arrives after it (e.g. when unmuting an inactive window). const auto subscription = runtime->preview_subscriptions_.Get(remote_id); const bool paused = subscription ? !subscription->audio : !remote_action.a; - const int ret = SetAudioSendPaused(runtime->peer_, remote_id.data(), - remote_id.size(), paused); + const int ret = SetAudioSendPaused(bound_peer, wire_id.data(), + wire_id.size(), paused); if (remote_action.a) runtime->devices_.StartSpeakerCapturer(); else if (ret != 0) diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index ed1e6011..c3ee32d7 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -49,6 +49,22 @@ constexpr HostPlatform kClientPlatform = HostPlatform::Unknown; } // namespace +std::string PeerEventHandler::ConnectionKey(const char* id, size_t size) const { + if (auto session = direct_.lock()) return session->key; + return direct_bound_ ? std::string{} : std::string(id, size); +} + +std::string PeerEventHandler::WireId(const std::string& key) const { + if (auto session = direct_.lock()) return session->remote_id; + return key; +} + +bool PeerEventHandler::DataAllowed() const { + if (!direct_bound_) return true; + auto session = direct_.lock(); + return session && session->authorized && !session->closing; +} + void PeerEventHandler::SendClientInfo(PeerPtr* peer, const std::string& client_id) { if (!peer) { @@ -74,8 +90,8 @@ void PeerEventHandler::OnSignalMessage(const char* message, size_t size, if (!handler) return; auto callback = handler->EnterCallback(); if (!callback) return; - GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; - if (!runtime || !message || size == 0) { + GuiRuntime* runtime = &handler->owner_; + if (!message || size == 0) { return; } std::string s(message, size); @@ -179,10 +195,7 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id, if (!handler) return; auto callback = handler->EnterCallback(); if (!callback) return; - GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; - if (!runtime) { - return; - } + GuiRuntime* runtime = &handler->owner_; std::string client_id(user_id, user_id_size); if (client_id == runtime->client_id_) { @@ -251,17 +264,30 @@ void PeerEventHandler::OnSignalStatus(SignalStatus status, const char* user_id, } void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, - const char* user_id, - const size_t user_id_size, - void* user_data) { + const char* user_id, size_t size, + void* context) { + auto* handler = static_cast(context); + if (!handler) return; + if (handler->direct_bound_) { + auto callback = handler->EnterCallback(); + if (callback) + if (auto session = handler->direct_.lock()) session->Status(status); + return; + } + DispatchConnectionStatus(status, user_id, size, context); +} + +void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status, + const char* user_id, + const size_t user_id_size, + void* user_data) { auto* handler = static_cast(user_data); if (!handler) return; auto callback = handler->EnterCallback(); if (!callback) return; - GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; - if (!runtime) return; + GuiRuntime* runtime = &handler->owner_; - std::string remote_id(user_id, user_id_size); + std::string remote_id = handler->ConnectionKey(user_id, user_id_size); if (status == ConnectionStatus::Connected) runtime->transfers_.Connected(handler, remote_id); else if (status == ConnectionStatus::Disconnected || status == ConnectionStatus::Failed || status == ConnectionStatus::Closed) runtime->transfers_.Cancel(handler, remote_id); @@ -474,8 +500,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, } std::string msg = remote_action.to_json(); const int ret = - SendReliableDataFrame(runtime->peer_, msg.data(), msg.size(), - runtime->control_data_label_.c_str()); + 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); @@ -516,21 +542,12 @@ void PeerEventHandler::OnNetStatusReport( if (!handler) return; auto callback = handler->EnterCallback(); if (!callback) return; - GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; - if (!runtime) { - return; - } + GuiRuntime* runtime = &handler->owner_; - if (strchr(client_id, '@') != nullptr && strchr(user_id, '-') == nullptr) { - std::string id, password; - const char* at_pos = strchr(client_id, '@'); - if (at_pos == nullptr) { - id = client_id; - password.clear(); - } else { - id.assign(client_id, at_pos - client_id); - password = at_pos + 1; - } + if (const char* at_pos = handler->IsDirect() ? nullptr : strchr(client_id, '@'); + at_pos && strchr(user_id, '-') == nullptr) { + const std::string id(client_id, at_pos - client_id); + const std::string password(at_pos + 1); bool is_self_hosted = runtime->config_center_->IsSelfHosted(); @@ -578,7 +595,7 @@ void PeerEventHandler::OnNetStatusReport( #endif } - std::string remote_id(user_id, user_id_size); + std::string remote_id = handler->ConnectionKey(user_id, user_id_size); auto props = runtime->FindRemoteSession(remote_id); if (!props) { bool report_codecs = false; @@ -597,9 +614,8 @@ void PeerEventHandler::OnNetStatusReport( net_traffic_stats->video_encoder); const auto message = action.to_json(); // Report only to this controller, after releasing the status lock. - SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(), - runtime->control_data_label_.c_str(), - remote_id.data(), remote_id.size()); + runtime->SendControllerData(remote_id, message.data(), message.size(), + runtime->control_data_label_.c_str()); } return; } diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.h b/apps/desktop/src/gui/runtime/peer_event_handler.h index 92650955..373c551b 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.h +++ b/apps/desktop/src/gui/runtime/peer_event_handler.h @@ -8,7 +8,10 @@ #define _PEER_EVENT_HANDLER_H_ #include +#include +#include #include +#include #include "minirtc.h" #include "runtime/session_lifecycle.h" @@ -16,12 +19,30 @@ namespace crossdesk { class GuiRuntime; +namespace lan { +struct Session; +} // Adapts MiniRTC C callbacks to the GUI runtime. class PeerEventHandler { public: explicit PeerEventHandler(GuiRuntime &owner); + void BindDirect(const std::shared_ptr& session) { + direct_ = session; + direct_bound_ = true; + } + std::string ConnectionKey(const char* id, size_t size) const; + std::string WireId(const std::string& key) const; + bool DataAllowed() const; + bool IsDirect() const { return direct_bound_; } + void SetDirectPeer(PeerPtr* peer) { direct_peer_ = peer; } + PeerPtr* DirectPeer() const { return direct_peer_; } + void FlushEarlyData(); + void ApplyConnectionStatus(ConnectionStatus status, const std::string& id) { + DispatchConnectionStatus(status, id.data(), id.size(), this); + } + void StopAccepting() { callbacks_.StopAccepting(); } bool IsActive() const { return callbacks_.IsActive(); } auto EnterCallback() { return callbacks_.Enter(); } void Deactivate() { callbacks_.Close(); } @@ -51,12 +72,22 @@ public: void *user_data); private: - static void SendClientInfo(PeerPtr *peer, const std::string &client_id); + static void DispatchConnectionStatus(ConnectionStatus status, const char* id, + size_t size, void* context); + static void SendClientInfo(PeerPtr* peer, const std::string& client_id); + bool QueueEarlyData(const char* data, size_t size, const char* label, + size_t label_size); + std::weak_ptr direct_; + bool direct_bound_ = false; + PeerPtr* direct_peer_ = nullptr; + std::mutex early_data_mutex_; + std::vector early_data_; + size_t early_data_bytes_ = 0; - GuiRuntime &owner_; + GuiRuntime& owner_; gui_detail::SessionCallbackGate callbacks_; }; } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp index 6a88e54c..4b51cd79 100644 --- a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp @@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer( auto* handler = static_cast(user_data); if (!handler) return; auto callback = handler->EnterCallback(); - if (!callback) return; - GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; - if (!runtime || !video_frame) { + if (!callback || !handler->DataAllowed()) return; + GuiRuntime* runtime = &handler->owner_; + if (!video_frame) { return; } - std::string remote_id(user_id, user_id_size); + std::string remote_id = handler->ConnectionKey(user_id, user_id_size); auto props = runtime->FindRemoteSession(remote_id); if (!props) return; if (props->connection_established_) { const auto timing_now = VideoLatencyFrame::Clock::now(); // Pair the clocks before preview conversion so its cost is included. - const auto local_now_us = GetSystemTimeMicros(props->peer_); + const auto local_now_us = GetSystemTimeMicros( + handler->IsDirect() ? handler->DirectPeer() : props->peer_); const auto* received_native = GetNativeVideoFrame(video_frame); props->received_video_stats_.Record( received_native ? received_native->width : video_frame->width, @@ -274,15 +275,15 @@ void PeerEventHandler::OnReceiveAudioBuffer( auto* handler = static_cast(user_data); if (!handler) return; auto callback = handler->EnterCallback(); - if (!callback) return; - GuiRuntime* runtime = handler ? &handler->owner_ : nullptr; - if (!runtime || runtime->unattended_host_) { + if (!callback || !handler->DataAllowed()) return; + GuiRuntime* runtime = &handler->owner_; + if (runtime->unattended_host_) { return; } std::lock_guard delivery_lock(runtime->audio_delivery_mutex_); const auto session = - runtime->FindRemoteSession(std::string(user_id, user_id_size)); + runtime->FindRemoteSession(handler->ConnectionKey(user_id, user_id_size)); if (!session || session->closing_ || !session->audio_allowed_.load()) return; runtime->audio_buffer_fresh_ = true; diff --git a/apps/desktop/src/gui/runtime/privacy_runtime.cpp b/apps/desktop/src/gui/runtime/privacy_runtime.cpp index f50e3aab..65a0923e 100644 --- a/apps/desktop/src/gui/runtime/privacy_runtime.cpp +++ b/apps/desktop/src/gui/runtime/privacy_runtime.cpp @@ -15,6 +15,10 @@ namespace crossdesk { bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) { + if (remote_id.rfind("lan/", 0) == 0) { + const auto events = ControllerEvents(remote_id); + if (!events || !events->DataAllowed()) return false; + } // Connections appear here only after the existing password/signalling/RTC // admission path. Outgoing/viewer sessions live in remote_sessions_ instead. std::shared_lock lock(connection_status_mutex_); @@ -109,9 +113,9 @@ void GuiRuntime::HandlePrivacy() { "A connected controller does not support privacy screen"); } } - if (!peer_ || (last_privacy_status_tick_ != 0 && - status.revision == last_privacy_revision_ && - now - last_privacy_status_tick_ < 1000)) + if (last_privacy_status_tick_ != 0 && + status.revision == last_privacy_revision_ && + now - last_privacy_status_tick_ < 1000) return; RemoteAction action{}; action.type = ControlType::privacy_status; @@ -119,9 +123,8 @@ void GuiRuntime::HandlePrivacy() { const auto message = action.to_json(); bool sent = true; for (const auto& id : controllers) { - const int result = SendReliableDataFrameToPeer( - peer_, message.data(), message.size(), control_data_label_.c_str(), - id.data(), id.size()); + const int result = SendControllerData(id, message.data(), message.size(), + control_data_label_.c_str()); if (result != 0) { sent = false; LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result); diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 80e0053e..8668cd40 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -31,7 +31,12 @@ #include "runtime/received_video_stats.h" #include "runtime/connection_auth_state.h" -namespace crossdesk { class PeerEventHandler; } +namespace crossdesk { +class PeerEventHandler; +namespace lan { +struct Session; +} +} // namespace crossdesk namespace crossdesk::gui_detail { @@ -105,6 +110,7 @@ struct RemoteSession { // Decoder-thread scratch memory, never shared with the UI. std::mutex gallery_conversion_mutex_; std::vector gallery_nv12_, gallery_scaled_, gallery_scratch_; + std::shared_ptr lan_session_; Params params_; PeerPtr* peer_ = nullptr; std::shared_ptr peer_events_; diff --git a/apps/desktop/src/gui/runtime/session_lifecycle.cpp b/apps/desktop/src/gui/runtime/session_lifecycle.cpp index ae7d8203..f695d784 100644 --- a/apps/desktop/src/gui/runtime/session_lifecycle.cpp +++ b/apps/desktop/src/gui/runtime/session_lifecycle.cpp @@ -37,6 +37,11 @@ void SessionCallbackGate::EndCallback() { } } +void SessionCallbackGate::StopAccepting() { + std::lock_guard lock(mutex_); + accepting_callbacks_ = false; +} + void SessionCallbackGate::Close() { std::unique_lock lock(mutex_); accepting_callbacks_ = false; diff --git a/apps/desktop/src/gui/runtime/session_lifecycle.h b/apps/desktop/src/gui/runtime/session_lifecycle.h index bb64495c..d21126e5 100644 --- a/apps/desktop/src/gui/runtime/session_lifecycle.h +++ b/apps/desktop/src/gui/runtime/session_lifecycle.h @@ -39,6 +39,7 @@ class SessionCallbackGate { CallbackGuard Enter(); // Called by the session owner, never from within this session's callbacks. + void StopAccepting(); void Close(); private: diff --git a/apps/desktop/src/gui/ui/group_filter.slint b/apps/desktop/src/gui/ui/group_filter.slint index c1934db0..3bcd1ec4 100644 --- a/apps/desktop/src/gui/ui/group_filter.slint +++ b/apps/desktop/src/gui/ui/group_filter.slint @@ -7,10 +7,19 @@ export component GroupFilter inherits Rectangle { in property <[string]> model; in property system-count: 2; in property enabled: true; + // An embedded picker shares its enclosing field's border and focus ring. + in property embedded: false; + in property show-indicator: true; + in property popup-min-width: 0px; + in property text-size: 12px; + in property text-weight: 400; + in property text-color: #30343b; in property normal-border-color: #c9cdd2; in property hover-border-color: root.normal-border-color; in property focus-border-color: #8db5df; in-out property current-index: 0; + out property has-focus: trigger-focus.has-focus || popup.is-open; + out property has-hover: root.enabled && trigger-touch.has-hover && !popup.is-open; callback selected(int); private property has-divider: root.system-count > 0 && root.model.length > root.system-count; private property list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px); @@ -25,8 +34,9 @@ export component GroupFilter inherits Rectangle { } width: 155px; height: 28px; - background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white; - border-width: 1px; + background: root.embedded ? (root.enabled && trigger-touch.has-hover ? #eef3fb : transparent) + : !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white; + border-width: root.embedded ? 0px : 1px; border-color: popup.is-open || trigger-focus.has-focus ? root.focus-border-color : trigger-touch.has-hover ? root.hover-border-color : root.normal-border-color; border-radius: UiRadius.control; @@ -52,16 +62,18 @@ export component GroupFilter inherits Rectangle { root.reveal-selection(); root.selected(root.current-index); } - function open-menu() { + public function open-menu() { if root.enabled && root.model.length > 0 { popup-open-timer.running = false; root.popup-animation-enabled = false; root.popup-progress = 0; root.reveal-selection(); + trigger-focus.focus(); popup.show(); popup-open-timer.running = true; } } + public function close-menu() { popup.close(); } // Defer until the popup has its initial frame; animate its contents only, // keeping the native popup bounds and scrolling geometry stable. popup-open-timer := Timer { @@ -87,14 +99,19 @@ export component GroupFilter inherits Rectangle { return reject; } Text { - x: 10px; width: parent.width - 36px; height: parent.height; + x: root.embedded ? 8px : 10px; + width: parent.width - (root.show-indicator + ? (root.embedded ? 26px : 36px) : (root.embedded ? 16px : 20px)); + height: parent.height; text: root.model[root.current-index]; - color: root.enabled ? #30343b : #92979f; - font-size: 12px; overflow: elide; vertical-alignment: center; + color: root.enabled ? root.text-color : #92979f; + font-size: root.text-size; font-weight: root.text-weight; + overflow: elide; vertical-alignment: center; accessible-role: none; } - Text { - x: parent.width - 25px; width: 16px; height: parent.height; + if root.show-indicator: Text { + x: parent.width - (root.embedded ? 16px : 25px); + width: root.embedded ? 10px : 16px; height: parent.height; text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px; color: #858c96; horizontal-alignment: center; vertical-alignment: center; @@ -107,12 +124,13 @@ export component GroupFilter inherits Rectangle { key-pressed(event) => { return root.handle-key(event); } trigger-touch := TouchArea { enabled: root.enabled; - clicked => { trigger-focus.focus(); root.open-menu(); } + mouse-cursor: pointer; + clicked => { root.open-menu(); } } } popup := PopupWindow { x: 0px; y: root.height + 4px; - width: root.width; height: root.visible-height + 8px; + width: max(root.width, root.popup-min-width); height: root.visible-height + 8px; close-policy: close-on-click-outside; forward-focus: popup-focus; popup-focus := FocusScope { diff --git a/apps/desktop/src/gui/ui/lan_strings.slint b/apps/desktop/src/gui/ui/lan_strings.slint new file mode 100644 index 00000000..a34bbf6d --- /dev/null +++ b/apps/desktop/src/gui/ui/lan_strings.slint @@ -0,0 +1,47 @@ +import { GalleryStrings } from "device_strings.slint"; + +export global LanStrings { + // Short localized lines let the warning dialog add leading even though + // Slint Text does not expose a line-height property. + public pure function access-error-lines(code: string) -> [string] { + if code == "LanPortInUse" { return [ + GalleryStrings.tr("此端口已被占用,", "This port is in use.", "Порт занят."), + GalleryStrings.tr("请更换端口。", "Choose another port.", "Выберите другой порт."), + GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.") + ]; } + if code == "LanPortCheckFailed" { return [ + GalleryStrings.tr("无法确认此端口是否可用,", "Could not check this port.", "Не удалось проверить порт."), + GalleryStrings.tr("请重试或检查本机防火墙。", "Retry or check the local firewall.", "Повторите попытку или проверьте брандмауэр."), + GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.") + ]; } + if code == "LanPortPermissionDenied" { return [ + GalleryStrings.tr("无法使用此端口,", "Cannot use this port.", "Порт недоступен."), + GalleryStrings.tr("请选择其他端口或检查系统权限。", "Choose another port or check system permissions.", "Смените порт или проверьте разрешения."), + GalleryStrings.tr("原设置已保留。", "Your previous settings are unchanged.", "Прежние настройки сохранены.") + ]; } + if code == "Selected LAN interface is unavailable" { return [ + GalleryStrings.tr("未找到可用的局域网连接。", "Connect to Wi-Fi or Ethernet,", "Подключитесь к Wi-Fi или Ethernet"), + GalleryStrings.tr("请连接 Wi-Fi 或网线,", "or select a local IP", "либо выберите IP"), + GalleryStrings.tr("或在主界面选择本机 IP。", "on the main screen.", "в главном окне.") + ]; } + // Keep all branches the same size for Slint's C++ model generator. + return ["", "", ""]; + } + public pure function error-message(code: string) -> string { + let lines = LanStrings.access-error-lines(code); + if lines[0] != "" { + let separator = GalleryStrings.tr("", " ", " "); + return lines[0] + separator + lines[1] + separator + lines[2]; + } + if code == "InvalidCredentials" { return GalleryStrings.tr("密码不正确,或对方密码已更新。请查看对方的局域网访问密码后重试。", "Check the other desktop's current LAN password and try again.", "Проверьте текущий пароль LAN другого компьютера и повторите попытку."); } + if code == "AuthenticationRateLimited" { return GalleryStrings.tr("尝试次数过多,请等待倒计时结束后重试。", "Too many attempts. Try again when the countdown ends.", "Слишком много попыток. Дождитесь окончания обратного отсчёта."); } + if code == "CredentialsChanged" { return GalleryStrings.tr("对方密码已更新,请使用新密码重试。", "The password changed. Try again with the new password.", "Пароль изменился. Повторите с новым паролем."); } + if code == "Busy" { return GalleryStrings.tr("设备正忙,请稍后重试。", "The desktop is busy. Try again shortly.", "Компьютер занят. Повторите позже."); } + if code == "LAN password must contain exactly 6 ASCII letters or digits" { return GalleryStrings.tr("访问密码必须为 6 位英文字母或数字,区分大小写。", "Use exactly 6 letters (A–Z, a–z) or digits (0–9). Case-sensitive.", "Пароль: ровно 6 латинских букв или цифр. Регистр учитывается."); } + if code == "ConnectionFailed" || code == "ConnectionTimeout" || code == "Timeout" || code == "ConnectionClosed" { return GalleryStrings.tr("无法连接,请检查对方 IP、程序是否运行,以及防火墙是否放行。", "Check the IP, whether the other desktop is running CrossDesk, and its firewall settings.", "Проверьте IP, работу CrossDesk на другом компьютере и брандмауэр."); } + if code == "Enter a numeric IPv4 address or IPv4:port" { return GalleryStrings.tr("请输入有效的 IP,例如 192.168.1.20;指定端口可用 IP:端口,端口范围 1–65535。", "Enter a valid IP such as 192.168.1.20, or IP:port (1–65535).", "Введите IP, например 192.168.1.20, или IP:порт (1–65535)."); } + if code == "Invalid LAN port" { return GalleryStrings.tr("请输入 1–65535 之间的整数端口。", "Enter a port number from 1 to 65535.", "Введите целый номер порта от 1 до 65535."); } + if code == "LAN password is not ready" { return GalleryStrings.tr("请点击主界面密码旁的修改按钮设置密码。", "Set a password using Edit next to the password on the main screen.", "Задайте пароль кнопкой редактирования в главном окне."); } + return code; + } +} diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 510c32a7..2cad8a3b 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -1,3 +1,4 @@ +import { LanStrings } from "lan_strings.slint"; import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint"; import { UiRadius } from "ui_radius.slint"; import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint"; @@ -22,6 +23,8 @@ export struct RecentConnection { host-name: string, group-names: string, online: bool, + lan: bool, + endpoint: string, connected: bool, thumbnail: image, has-thumbnail: bool, @@ -372,6 +375,172 @@ export component MainWindow inherits Window { private property wayland-corner-radius: 12px; in property local-id: ""; in property local-password: ""; + in property ip-direct-enabled: true; + changed ip-direct-enabled => { + if !root.ip-direct-enabled { + root.lan-port-dirty = false; + root.lan-port-editing = false; + root.lan-apply-pending = false; + root.lan-local-port = root.lan-listener-port; + root.lan-endpoint-error = ""; + root.lan-access-warning = ""; + root.lan-access-warning-endpoint = ""; + root.lan-listener-error = ""; + root.lan-open = false; + root.lan-connecting = false; + root.lan-listening = false; + root.lan-password-pending = false; + root.lan-retry-after = 0; + root.lan-access-password = ""; + root.lan-new-password = ""; + root.lan-message = ""; + lan-ip-picker.close-menu(); + if root.lan-mode { + root.reset-password-open = false; + root.set-connection-mode(false); + } + } + } + in-out property lan-open; + changed lan-open => { + if !root.lan-open { root.lan-access-password = ""; } + if root.lan-open { root.gallery-action("release", ""); } + } + in-out property lan-mode; + callback connection-mode-changed(bool); + changed lan-mode => { + root.recent-tooltip-visible = false; + recent-scroll.viewport-x = 0px; + if !root.lan-mode { + root.lan-port-editing = false; + if root.lan-port-dirty && root.lan-endpoint-error == "" { root.lan-apply-pending = true; } + } + } + private property id-connection-draft; + private property ip-connection-draft; + private property lan-password-visible: true; + private property active-password-visible: root.lan-mode ? root.lan-password-visible : root.password-visible; + private property active-password: root.lan-mode ? root.lan-new-password : root.local-password; + function set-connection-mode(lan: bool) { + recent-group-filter.close-menu(); + if root.lan-mode { root.ip-connection-draft = root.remote-id-input; } + else { root.id-connection-draft = root.remote-id-input; } + root.lan-mode = lan; + root.remote-id-input = root.lan-mode ? root.ip-connection-draft : root.id-connection-draft; + root.connection-input-error = ""; + root.copy-feedback = CopyFeedback.idle; + root.menu-open = false; + focus-sink.focus(); + } + function switch-connection-mode() { + if !root.ip-direct-enabled { return; } + root.set-connection-mode(!root.lan-mode); + root.connection-mode-changed(root.lan-mode); + } + function submit-connection() { + if root.connection-pending || root.lan-connecting { return; } + if root.lan-mode { + root.request-lan-connection(root.remote-id-input); + } else { root.connect-requested(root.remote-id-input); } + } + public function request-lan-connection(address: string) { + if !root.ip-direct-enabled || root.connection-pending || root.lan-connecting || root.lan-open { return; } + root.lan-access-password = ""; + root.lan-remember-password = false; + let error = root.lan-prepare-connect(address); + root.connection-input-error = error == "Enter a numeric IPv4 address or IPv4:port" + ? GalleryStrings.tr("请输入有效的 IPv4 或 IPv4:端口", "Enter a valid IPv4 or IPv4:port", "Укажите IPv4 или IPv4:порт") + : LanStrings.error-message(error); + if error == "" { + root.lan-open = true; + if root.lan-remember-password && root.lan-access-password != "" && root.lan-retry-after == 0 { + root.submit-connection-password(); + } + } + } + in-out property lan-listening; + in-out property lan-listener-ip; + in-out property lan-listener-port: "52888"; + out property lan-listener-address: root.lan-listener-ip == "" ? "" : root.lan-listener-ip + ":" + root.lan-listener-port; + in-out property <[string]> lan-interfaces; + in-out property lan-interface-index: 0; + in-out property lan-interface-draft-index: 0; + in-out property lan-local-port: "52888"; + in-out property lan-port-editing; + in-out property lan-port-dirty; + in-out property lan-endpoint-error; + in-out property lan-access-warning; + in-out property lan-access-warning-endpoint; + in-out property lan-listener-error; + function dismiss-lan-access-warning() { + root.lan-access-warning = ""; + root.lan-access-warning-endpoint = ""; + // Keep the failed draft ready to edit and validate it on every new blur. + if root.lan-mode && root.lan-port-dirty && root.sponsorship-unobstructed { + lan-port-editor.focus(); + } else { focus-sink.focus(); } + } + private property lan-apply-pending; + callback lan-configure(string, string) -> bool; + callback lan-select-address(string) -> bool; + function apply-lan-endpoint() -> bool { + root.lan-apply-pending = false; + if !root.ip-direct-enabled || root.lan-password-pending { return false; } + if root.lan-configure(root.lan-listener-ip, root.lan-local-port) { + root.lan-port-dirty = false; + return true; + } + root.lan-access-warning-endpoint = root.lan-listener-ip + + (root.lan-endpoint-error == "Invalid LAN port" ? "" : ":" + root.lan-local-port); + root.lan-access-warning = root.lan-endpoint-error; + return false; + } + Timer { + interval: 1ms; + running: root.lan-apply-pending; + triggered => { + root.lan-apply-pending = false; + if root.lan-port-dirty { root.apply-lan-endpoint(); } + } + } + in-out property lan-remote-address; + in-out property lan-remote-port: "52888"; + in-out property lan-password-pending; + in-out property lan-connecting; + in-out property lan-retry-after; + in-out property lan-access-password; + in-out property lan-remember-password; + in-out property lan-new-password; + in-out property lan-message; + callback lan-reset-password; + callback lan-copy-text(string) -> bool; + callback lan-cancel-connect; + callback lan-prepare-connect(string) -> string; + callback lan-connect(string, string, string, bool); + private property prompt-password-required: root.lan-open || root.connection-password-required; + private property prompt-validating: root.lan-open ? root.lan-connecting : root.connection-validating; + private property prompt-pending: root.lan-open ? root.lan-connecting : root.connection-pending; + private property prompt-status-text: !root.lan-open ? root.connection-status-text + : root.lan-connecting ? GalleryStrings.tr("正在连接…", "Connecting…", "Подключение…") + : root.lan-retry-after > 0 ? GalleryStrings.tr("请在 " + root.lan-retry-after + " 秒后重试", "Try again in " + root.lan-retry-after + "s", "Повторите через " + root.lan-retry-after + " с") + : root.lan-message == "InvalidCredentials" ? UiStrings.reinput-password + : root.lan-message == "" || root.lan-message == "AuthenticationRateLimited" ? UiStrings.input-password + : LanStrings.error-message(root.lan-message); + function submit-connection-password() { + if root.lan-open { + if root.lan-connecting || root.lan-retry-after > 0 || root.lan-access-password.character-count != 6 { return; } + root.lan-connect(root.lan-remote-address, root.lan-remote-port, root.lan-access-password, root.lan-remember-password); + root.lan-access-password = ""; + } else { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + } + function cancel-connection-prompt() { + if root.lan-open { + root.lan-cancel-connect(); + root.lan-access-password = ""; + root.lan-open = false; + focus-sink.focus(); + } else { root.connection-cancel(); } + } in-out property remote-id-input: ""; in property password-visible: true; in property password-change-pending: false; @@ -456,6 +625,8 @@ export component MainWindow inherits Window { in-out property capture-method-index: 0; in-out property codec-index: 0; in-out property turn-enabled: true; + in-out property ip-direct-enabled-draft: true; + in property settings-save-error; in-out property force-relay-enabled: false; changed turn-enabled => { if !root.turn-enabled { root.force-relay-enabled = false; } @@ -555,11 +726,11 @@ export component MainWindow inherits Window { && !root.update-confirm-open && !root.reset-password-open && !root.alias-open && !root.delete-open && !root.disconnect-open - && !root.device-group-open && !root.connection-dialog-open + && !root.device-group-open && !root.connection-dialog-open && !root.lan-open && !root.permission-dialog-open && !root.wayland-notice-open && !root.portable-service-dialog-open && !root.portable-service-suppressed-notice-open - && root.offline-warning == ""; + && root.offline-warning == "" && root.lan-access-warning == ""; out property can-show-sponsorship: !root.sponsorship-open && root.sponsorship-unobstructed; // Follow the first available update. Dismissing the dialog replaces this @@ -570,6 +741,10 @@ export component MainWindow inherits Window { } in-out property update-confirm-open: false; in-out property reset-password-open: false; + changed reset-password-open => { + root.new-password-input = ""; + root.reset-password-invalid = false; + } in-out property reset-password-invalid: false; in-out property new-password-input: ""; in-out property alias-open: false; @@ -926,9 +1101,21 @@ export component MainWindow inherits Window { // A compact application toolbar keeps global actions outside device cards. // Center the 28px buttons above the cards with 6px of space on each side. + connection-mode-button := WorkbenchButton { + visible: !root.gallery-mode && root.ip-direct-enabled; + x: 20px; y: 6px; + width: 120px; height: 28px; + text: root.lan-mode ? GalleryStrings.tr("IP 直连模式", "Direct IP mode", "Режим IP") : GalleryStrings.tr("ID 连接模式", "ID mode", "Режим ID"); + tooltip: root.lan-mode ? GalleryStrings.tr("切换到 ID 连接模式", "Switch to ID mode", "Переключить в режим ID") : GalleryStrings.tr("切换到 IP 直连模式", "Switch to direct IP mode", "Переключить в режим IP"); + accessible-label: self.text + " · " + self.tooltip; + enabled: !root.connection-pending && !root.lan-connecting; + z: 8; + clicked => { root.switch-connection-mode(); } + } open-gallery-button := WorkbenchButton { visible: !root.gallery-mode; - x: 20px; y: 6px; + x: root.ip-direct-enabled ? connection-mode-button.x + connection-mode-button.width + 8px : 20px; + y: connection-mode-button.y; width: 80px; height: 28px; text: GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub"); z: 8; @@ -1024,22 +1211,166 @@ export component MainWindow inherits Window { x: 0px; y: 0px; width: (parent.width - 16px) / 2; height: parent.height; Text { x: 16px; y: 14px; width: parent.width - 32px; height: 24px; overflow: elide; text: UiStrings.local-desktop; font-size: 16px; font-weight: 600; color: WorkbenchStyle.text; } - Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: GalleryStrings.tr("将 ID 和密码分享给连接方", "Share your ID and password to get connected", "Передайте ID и пароль для подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; } - Text { x: 16px; y: 68px; height: 18px; text: UiStrings.local-id; font-size: 12px; color: WorkbenchStyle.muted; } - Rectangle { + Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("将 IP 和密码分享给连接方", "Share your IP and password to get connected", "Передайте IP и пароль для управления") : GalleryStrings.tr("将 ID 和密码分享给连接方", "Share your ID and password to get connected", "Передайте ID и пароль для подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; } + local-address-label := Text { x: 16px; y: 68px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("本机 IP / 端口", "Local IP / Port", "IP / Порт") : UiStrings.local-id; font-size: 12px; color: WorkbenchStyle.muted; vertical-alignment: top; } + lan-switch-ip-metrics := Text { + visible: false; accessible-role: none; + text: GalleryStrings.tr("切换 IP", "Switch IP", "Сменить IP"); + font-size: 12px; font-weight: 500; + } + lan-edit-port-metrics := Text { + visible: false; accessible-role: none; + text: GalleryStrings.tr("修改端口", "Edit port", "Изменить порт"); + font-size: 12px; font-weight: 500; + } + lan-switch-ip-action := WorkbenchButton { + visible: root.lan-mode; + x: local-address-label.x + local-address-label.preferred-width + 4px; + y: local-address-label.y - 5px; + width: lan-switch-ip-metrics.preferred-width + 20px; height: 22px; + quiet: true; text: lan-switch-ip-metrics.text; + text-size: 12px; text-color: WorkbenchStyle.accent; + enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0; + clicked => { + if root.lan-port-dirty && !root.apply-lan-endpoint() { return; } + lan-ip-picker.open-menu(); + } + } + WorkbenchButton { + visible: root.lan-mode; + x: lan-switch-ip-action.x + lan-switch-ip-action.width + 2px; + y: lan-switch-ip-action.y; + width: lan-edit-port-metrics.preferred-width + 20px; height: 22px; + quiet: true; text: lan-edit-port-metrics.text; + text-size: 12px; text-color: WorkbenchStyle.accent; + enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0; + clicked => { + // Moving from the editor to this action is still editing. + root.lan-apply-pending = false; + lan-port-editor.focus(); + lan-port-editor.select-all(); + } + } + local-address-field := Rectangle { x: 16px; y: 90px; width: parent.width - 32px; height: 40px; + // Size the endpoint as one line; reserve five port digits so + // editing never shifts the IP or changes the font size. + private property lan-font-scale: min(1, + (self.width - 80px) / (lan-ip-metrics.preferred-width + lan-port-metrics.preferred-width)); + lan-ip-metrics := Text { + visible: false; accessible-role: none; + text: root.lan-interfaces[root.lan-interface-draft-index]; + font-size: 18px; font-weight: 600; + } + lan-port-metrics := Text { + visible: false; accessible-role: none; + text: "65535"; font-size: 18px; font-weight: 600; + } // Raise the whole field: a child's z cannot escape its // parent's stacking order above the following password row. z: 1; background: WorkbenchStyle.field-surface; border-radius: UiRadius.control; - border-width: 1px; border-color: WorkbenchStyle.border; - Text { x: 12px; width: parent.width - 64px; height: parent.height; text: root.local-id; font-size: 24px; font-weight: 600; letter-spacing: 1px; color: WorkbenchStyle.text; vertical-alignment: center; } + border-width: 1px; + border-color: !root.lan-mode ? WorkbenchStyle.border + : root.lan-endpoint-error != "" ? #d92d20 + : lan-ip-picker.has-focus || root.lan-port-editing ? WorkbenchStyle.accent + : WorkbenchStyle.border; + animate border-color { duration: 120ms; } + if !root.lan-mode: Text { x: 12px; width: parent.width - 64px; height: parent.height; text: root.local-id; font-size: 24px; font-weight: 600; letter-spacing: 1px; overflow: elide; color: WorkbenchStyle.text; vertical-alignment: center; } Rectangle { x: parent.width - 48px; y: 10px; width: 1px; height: parent.height - 20px; background: WorkbenchStyle.border; } + lan-ip-picker := GroupFilter { + visible: root.lan-mode; + x: 4px; y: 2px; + width: lan-ip-metrics.preferred-width * local-address-field.lan-font-scale + 16px; + height: parent.height - 4px; + embedded: true; text-size: 18px * local-address-field.lan-font-scale; + text-weight: 600; text-color: WorkbenchStyle.text; + show-indicator: false; + popup-min-width: 160px; + model: root.lan-interfaces; system-count: 0; + current-index <=> root.lan-interface-draft-index; + enabled: root.lan-mode && !root.lan-password-pending && root.lan-interfaces.length > 0; + normal-border-color: WorkbenchStyle.control-border; + hover-border-color: WorkbenchStyle.hover-border; + focus-border-color: WorkbenchStyle.accent; + accessible-label: GalleryStrings.tr("切换本机 IP", "Switch local IP", "Сменить локальный IP"); + selected(index) => { + root.copy-feedback = CopyFeedback.idle; + // Save the draft for its original IP before changing + // the display. Selecting an IP never rebinds sockets. + if root.lan-port-dirty && !root.apply-lan-endpoint() { + root.lan-interface-draft-index = root.lan-interface-index; + return; + } + if root.lan-select-address(root.lan-interfaces[index]) { + root.lan-interface-index = index; + } else { + root.lan-access-warning-endpoint = root.lan-interfaces[index]; + root.lan-access-warning = root.lan-endpoint-error; + root.lan-interface-draft-index = root.lan-interface-index; + } + } + } + if root.lan-mode: Text { + x: lan-ip-picker.x + lan-ip-picker.width - 4px; width: 6px; height: parent.height; + text: ":"; font-size: 18px * local-address-field.lan-font-scale; + font-weight: 600; color: WorkbenchStyle.text; + horizontal-alignment: center; vertical-alignment: center; + } + lan-port-field := Rectangle { + visible: root.lan-mode; + x: lan-ip-picker.x + lan-ip-picker.width + 2px; y: 2px; + width: lan-port-metrics.preferred-width * local-address-field.lan-font-scale + 4px; + height: parent.height - 4px; + background: lan-port-editor.has-focus ? WorkbenchStyle.surface + : lan-port-hover.has-hover ? WorkbenchStyle.hover-surface : transparent; + border-radius: UiRadius.control; + border-width: 1px; + border-color: lan-port-editor.has-focus ? WorkbenchStyle.accent : transparent; + animate background, border-color { duration: 120ms; } + lan-port-hover := TouchArea { + enabled: root.lan-mode && !root.lan-password-pending; + mouse-cursor: text; + FocusScope { + width: parent.width; height: parent.height; + key-pressed(event) => { + if event.text == Key.Escape { + root.lan-apply-pending = false; + root.lan-port-dirty = false; + root.lan-local-port = root.lan-listener-port; + root.lan-endpoint-error = ""; + focus-sink.focus(); + return accept; + } + return reject; + } + lan-port-editor := TextInput { + x: 2px; width: parent.width - 4px; height: parent.height; + text <=> root.lan-local-port; + font-size: 18px * local-address-field.lan-font-scale; font-weight: 600; + input-type: InputType.number; color: WorkbenchStyle.text; + enabled: root.lan-mode && !root.lan-password-pending; + accessible-label: GalleryStrings.tr("局域网端口", "LAN port", "Порт ЛВС"); + vertical-alignment: center; horizontal-alignment: left; + selection-foreground-color: white; selection-background-color: WorkbenchStyle.accent; + changed has-focus => { + root.lan-port-editing = self.has-focus; + // Opening the warning also moves focus: do not + // treat that as another attempt to save. + if !self.has-focus && root.lan-port-dirty && root.lan-access-warning == "" { root.lan-apply-pending = true; } + } + edited => { root.lan-port-dirty = true; root.lan-endpoint-error = ""; root.copy-feedback = CopyFeedback.idle; } + accepted => { if root.apply-lan-endpoint() { focus-sink.focus(); } } + } + } + } + } copy-button := WorkbenchButton { x: parent.width - 42px; y: 2px; width: 36px; quiet: true; icon: root.copy-feedback == CopyFeedback.success ? FontAwesomeIcons.check : FontAwesomeIcons.copy; icon-color: root.copy-feedback == CopyFeedback.success ? WorkbenchStyle.success : WorkbenchStyle.muted; - tooltip: GalleryStrings.tr("复制本机 ID", "Copy local ID", "Копировать ID"); + tooltip: root.lan-mode ? GalleryStrings.tr("复制 IP 和端口", "Copy IP and port", "Копировать IP и порт") : GalleryStrings.tr("复制本机 ID", "Copy local ID", "Копировать ID"); + enabled: !root.lan-mode || root.lan-listener-address != ""; tooltip-suppressed: root.copy-feedback != CopyFeedback.idle; // Let other controls receive the original click; moving // focus away from Copy dismisses its transient feedback. @@ -1047,35 +1378,61 @@ export component MainWindow inherits Window { if !self.has-focus { root.copy-feedback = CopyFeedback.idle; } } clicked => { - root.copy-feedback = root.copy-local-id() ? CopyFeedback.success : CopyFeedback.failed; + if root.lan-mode && root.lan-port-dirty && !root.apply-lan-endpoint() { return; } + let copied = root.lan-mode ? root.lan-copy-text(root.lan-listener-address) : root.copy-local-id(); + root.copy-feedback = copied ? CopyFeedback.success : CopyFeedback.failed; copy-timer.restart(); } } + private property edit-hint: !root.lan-mode || !root.sponsorship-unobstructed + || root.lan-password-pending ? "" + : lan-ip-picker.has-hover ? GalleryStrings.tr("点击切换本机 IP", "Click to switch the local IP", "Нажмите, чтобы сменить IP") + : lan-port-hover.has-hover && !lan-port-editor.has-focus ? GalleryStrings.tr("点击修改端口", "Click to edit the port", "Нажмите, чтобы изменить порт") : ""; + private property edit-hint-visible; + changed edit-hint => { local-address-field.edit-hint-visible = false; } + Timer { + interval: 450ms; + running: local-address-field.edit-hint != "" && !local-address-field.edit-hint-visible; + triggered => { local-address-field.edit-hint-visible = true; } + } + if local-address-field.edit-hint != "" && local-address-field.edit-hint-visible: Rectangle { + x: clamp((lan-ip-picker.has-hover ? lan-ip-picker.x + lan-ip-picker.width / 2 + : lan-port-field.x + lan-port-field.width / 2) - self.width / 2, 0px, parent.width - self.width); + y: parent.height + 6px; + width: min(parent.width, edit-hint-text.preferred-width + 20px); + height: max(28px, edit-hint-text.preferred-height + 12px); + background: #24324a; border-radius: UiRadius.control; z: 20; + edit-hint-text := Text { + x: 10px; y: 6px; width: parent.width - 20px; height: parent.height - 12px; + text: local-address-field.edit-hint; font-size: 12px; color: white; + wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; + } + } } Text { x: 16px; y: 138px; height: 18px; text: UiStrings.password; font-size: 12px; color: WorkbenchStyle.muted; } Rectangle { x: 16px; y: 158px; width: parent.width - 32px; height: 36px; background: WorkbenchStyle.field-surface; border-radius: UiRadius.control; border-width: 1px; border-color: WorkbenchStyle.border; - Text { x: 12px; width: parent.width - 132px; height: parent.height; text: root.password-visible ? root.local-password : "••••••"; font-size: 20px; font-weight: 500; letter-spacing: 2px; color: WorkbenchStyle.text; vertical-alignment: center; } + Text { x: 12px; width: parent.width - 132px; height: parent.height; text: root.active-password == "" ? "—" : root.active-password-visible ? root.active-password : "••••••"; font-size: 20px; font-weight: 500; letter-spacing: 2px; color: WorkbenchStyle.text; vertical-alignment: center; } Rectangle { x: parent.width - 118px; y: 8px; width: 1px; height: parent.height - 16px; background: WorkbenchStyle.border; } WorkbenchButton { x: parent.width - 112px; y: 0px; width: 36px; quiet: true; - icon: root.password-visible ? FontAwesomeIcons.eye : FontAwesomeIcons.eye-slash; - tooltip: root.password-visible ? GalleryStrings.tr("隐藏密码", "Hide password", "Скрыть пароль") : GalleryStrings.tr("显示密码", "Show password", "Показать пароль"); - clicked => { root.toggle-password-visibility(); } + icon: root.active-password-visible ? FontAwesomeIcons.eye : FontAwesomeIcons.eye-slash; + tooltip: root.active-password-visible ? GalleryStrings.tr("隐藏密码", "Hide password", "Скрыть пароль") : GalleryStrings.tr("显示密码", "Show password", "Показать пароль"); + clicked => { if root.lan-mode { root.lan-password-visible = !root.lan-password-visible; } else { root.toggle-password-visibility(); } } } WorkbenchButton { x: parent.width - 74px; y: 0px; width: 36px; quiet: true; icon: FontAwesomeIcons.rotate; tooltip: UiStrings.refresh-password; - enabled: !root.password-change-pending; - clicked => { root.refresh-password(); } + enabled: root.lan-mode ? !root.lan-password-pending : !root.password-change-pending; + clicked => { if root.lan-mode { root.lan-reset-password(); } else { root.refresh-password(); } } } WorkbenchButton { x: parent.width - 36px; y: 0px; width: 36px; quiet: true; icon: FontAwesomeIcons.pen; tooltip: GalleryStrings.tr("修改密码", "Change password", "Изменить пароль"); - enabled: !root.password-change-pending; + enabled: root.lan-mode ? !root.lan-password-pending : !root.password-change-pending; clicked => { root.reset-password-open = true; } } } @@ -1083,8 +1440,8 @@ export component MainWindow inherits Window { WorkbenchCard { x: local-card.width + 16px; y: 0px; width: local-card.width; height: parent.height; Text { x: 16px; y: 14px; width: parent.width - 32px; height: 24px; overflow: elide; text: UiStrings.remote-desktop; font-size: 16px; font-weight: 600; color: WorkbenchStyle.text; } - Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: GalleryStrings.tr("输入设备 ID,连接远程桌面", "Enter a device ID to start a remote session", "Введите ID для удалённого подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; } - Text { x: 16px; y: 68px; height: 18px; text: UiStrings.remote-id; font-size: 12px; color: WorkbenchStyle.muted; } + Text { x: 16px; y: 38px; width: parent.width - 32px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("输入 IP:端口,通过局域网连接", "Enter IP:port to connect over LAN", "Введите IP:порт для подключения по LAN") : GalleryStrings.tr("输入设备 ID,连接远程桌面", "Enter a device ID to start a remote session", "Введите ID для удалённого подключения"); font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; } + Text { x: 16px; y: 68px; height: 18px; text: root.lan-mode ? GalleryStrings.tr("远端 IP / 端口", "Remote IP / Port", "Удалённый IP / Порт") : UiStrings.remote-id; font-size: 12px; color: WorkbenchStyle.muted; } Rectangle { x: 16px; y: 90px; width: parent.width - 32px; height: 40px; background: white; border-radius: UiRadius.control; border-width: 1px; @@ -1092,40 +1449,42 @@ export component MainWindow inherits Window { animate border-color { duration: 120ms; } if root.remote-id-input == "": Text { x: 12px; width: parent.width - 24px; height: parent.height; - text: GalleryStrings.tr("请输入 9 位设备 ID", "Enter a 9-digit device ID", "Введите ID из 9 цифр"); + text: root.lan-mode ? "192.168.1.20:52888" : GalleryStrings.tr("请输入 9 位设备 ID", "Enter a 9-digit device ID", "Введите ID из 9 цифр"); font-size: 13px; color: #8290a4; vertical-alignment: center; } remote-id-editor := TextInput { x: 12px; width: parent.width - 24px; height: parent.height; - text <=> root.remote-id-input; font-size: 24px; font-weight: 600; letter-spacing: 1px; - input-type: InputType.number; color: WorkbenchStyle.text; - accessible-label: UiStrings.remote-id; + text <=> root.remote-id-input; font-size: root.lan-mode ? 18px : 24px; font-weight: 600; letter-spacing: root.lan-mode ? 0px : 1px; + input-type: root.lan-mode ? InputType.text : InputType.number; color: WorkbenchStyle.text; + accessible-label: root.lan-mode ? GalleryStrings.tr("远端 IP / 端口", "Remote IP / Port", "Удалённый IP / Порт") : UiStrings.remote-id; + enabled: !root.connection-pending && !root.lan-connecting; vertical-alignment: center; selection-foreground-color: white; selection-background-color: WorkbenchStyle.accent; edited => { root.connection-input-error = ""; // Enforce the digit limit before the next frame can // render the text from typing, paste, or replacement. - let formatted = root.format-remote-id(self.text); + let formatted = root.lan-mode ? self.text : root.format-remote-id(self.text); self.text = formatted; // Formatted IDs are ASCII, so these are UTF-8 offsets. self.set-selection-offsets( formatted.character-count, formatted.character-count); } - accepted => { if !root.connection-pending { root.connect-requested(root.remote-id-input); } } + accepted => { root.submit-connection(); } } } Text { x: 16px; y: 133px; width: parent.width - 32px; height: 24px; - text: root.connection-input-error; - font-size: 11px; color: #be3d42; wrap: word-wrap; + text: root.connection-input-error != "" ? root.connection-input-error + : root.lan-mode ? GalleryStrings.tr("不填端口时,默认使用 52888", "Port defaults to 52888 if omitted", "Без порта используется 52888") : ""; + font-size: 11px; color: root.connection-input-error != "" ? #be3d42 : WorkbenchStyle.muted; wrap: word-wrap; } WorkbenchButton { x: 16px; y: 158px; width: parent.width - 32px; height: 36px; primary: true; icon: FontAwesomeIcons.arrow-right-long; - text: root.connection-pending ? GalleryStrings.tr("正在连接…", "Connecting…", "Подключение…") : UiStrings.connect; - enabled: !root.connection-pending; - clicked => { root.connect-requested(root.remote-id-input); } + text: root.connection-pending || root.lan-connecting ? GalleryStrings.tr("正在连接…", "Connecting…", "Подключение…") : UiStrings.connect; + enabled: !root.connection-pending && !root.lan-connecting; + clicked => { root.submit-connection(); } } } } @@ -1137,6 +1496,7 @@ export component MainWindow inherits Window { Text { x: 20px; y: 0px; width: recent-group-filter.x - self.x - 12px; height: 32px; text: UiStrings.recent-connections; font-size: 15px; font-weight: 600; color: WorkbenchStyle.text; vertical-alignment: center; } recent-group-filter := GroupFilter { + visible: !root.lan-mode; x: parent.width - self.width - 20px; y: 0px; width: 155px; height: 32px; normal-border-color: WorkbenchStyle.control-border; @@ -1169,14 +1529,18 @@ export component MainWindow inherits Window { } Text { x: 84px; y: 36px; width: parent.width - 104px; height: 24px; - text: root.recent-group-index == 0 + text: root.lan-mode + ? GalleryStrings.tr("暂无 IP 直连设备", "No recent IP connections", "Нет недавних IP-подключений") + : root.recent-group-index == 0 ? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений") : GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений"); color: WorkbenchStyle.text; font-size: 14px; font-weight: 600; } Text { x: 84px; y: 68px; width: parent.width - 104px; height: 20px; - text: root.recent-group-index == 0 + text: root.lan-mode + ? GalleryStrings.tr("通过 IP 连接过的设备会显示在这里。", "Devices you connect to by IP will appear here.", "Здесь появятся устройства, подключённые по IP.") + : root.recent-group-index == 0 ? GalleryStrings.tr("连接过的设备会显示在这里,方便下次快速访问。", "Devices you connect to will appear here for quick access.", "Устройства появятся здесь после первого подключения.") : GalleryStrings.tr("切换设备分组,查看其他连接记录。", "Choose another group to see your other connections.", "Выберите другую группу для просмотра подключений."); color: WorkbenchStyle.muted; font-size: 12px; @@ -1202,7 +1566,7 @@ export component MainWindow inherits Window { width: (recent-scroll.width - 24px) / 3; height: 126px; background: transparent; - private property available: connection.online || connection.connected; + private property available: connection.lan || connection.online || connection.connected; private property content-width: self.width; private property scroll-offset: recent-scroll.viewport-x; private property device-id: connection.remote-id; @@ -1263,7 +1627,7 @@ export component MainWindow inherits Window { } presence-label := Text { x: 17px; width: self.preferred-width; height: parent.height; - text: connection-card.available ? UiStrings.online : UiStrings.offline; + text: connection.lan ? "LAN" : connection-card.available ? UiStrings.online : UiStrings.offline; color: connection-card.available ? WorkbenchStyle.success : WorkbenchStyle.muted; font-size: 10px; font-weight: 500; vertical-alignment: center; } @@ -1286,7 +1650,7 @@ export component MainWindow inherits Window { } Text { x: 12px; y: 24px; height: 16px; width: parent.width - 56px; - text: connection.remote-id; font-size: 11px; color: WorkbenchStyle.muted; + text: connection.lan ? connection.endpoint : connection.remote-id; font-size: 11px; color: WorkbenchStyle.muted; overflow: elide; } } @@ -1367,7 +1731,7 @@ export component MainWindow inherits Window { menu-width: root.compact-language ? 116px : root.language-index == 2 ? 156px : 128px; items: [ {label: GalleryStrings.tr("修改名称", "Rename", "Переименовать"), enabled: true}, - {label: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы"), enabled: true}, + {label: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы"), enabled: !connection.lan}, {label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись"), enabled: true}, ]; item-icons: [FontAwesomeIcons.pen, FontAwesomeIcons.tags, FontAwesomeIcons.trash-can]; @@ -1465,7 +1829,7 @@ export component MainWindow inherits Window { root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true; } } - // Server status and reconnect actions belong to the connection window. + // Show availability for the selected connection mode. if !root.gallery-mode: Rectangle { y: parent.height - 28px; height: 28px; @@ -1477,13 +1841,16 @@ export component MainWindow inherits Window { } Rectangle { x: 20px; y: 11px; width: 6px; height: 6px; border-radius: 3px; - background: root.signal-tls-error || root.signal-reconnecting ? #d6932b : root.signal-connected ? WorkbenchStyle.success : #d65252; + background: root.lan-mode ? (root.lan-message != "" || root.lan-listener-error != "" ? #d6932b : root.lan-listening ? WorkbenchStyle.success : WorkbenchStyle.muted) : root.signal-tls-error || root.signal-reconnecting ? #d6932b : root.signal-connected ? WorkbenchStyle.success : #d65252; } Text { x: 34px; y: 0px; height: parent.height; - text: root.signal-tls-error ? UiStrings.tls-error + width: parent.width - 160px; + overflow: elide; + text: root.lan-mode ? (root.lan-message != "" ? LanStrings.error-message(root.lan-message) : root.lan-listener-error != "" ? (root.lan-listener-address == "" ? "" : root.lan-listener-address + " · ") + LanStrings.error-message(root.lan-listener-error) : root.lan-listening ? GalleryStrings.tr("局域网模式已启用", "LAN mode enabled", "Режим LAN включён") : root.lan-password-pending ? GalleryStrings.tr("局域网模式准备中…", "Preparing LAN mode…", "Подготовка режима LAN…") : GalleryStrings.tr("局域网模式未就绪", "LAN mode not ready", "Режим LAN не готов")) + : root.signal-tls-error ? UiStrings.tls-error : root.signal-connected ? UiStrings.signal-connected : root.signal-reconnecting ? UiStrings.signal-reconnecting : UiStrings.signal-disconnected; @@ -1491,12 +1858,12 @@ export component MainWindow inherits Window { font-size: 11px; vertical-alignment: center; } - if root.signal-connected && !root.signal-tls-error: Text { + if root.lan-mode || (root.signal-connected && !root.signal-tls-error): Text { x: parent.width - self.preferred-width - 20px; height: parent.height; text: root.current-version; font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center; } - if !root.signal-connected && !root.signal-tls-error: WorkbenchButton { + if !root.lan-mode && !root.signal-connected && !root.signal-tls-error: WorkbenchButton { x: parent.width - self.width - 20px; y: (parent.height - self.height) / 2; width: max(64px, reconnect-label-metrics.preferred-width + 22px); @@ -1521,7 +1888,7 @@ export component MainWindow inherits Window { // Shared device details sit outside the clipped recent list and wall rail, // below the modal layer (z >= 39). if (root.gallery-mode ? gallery.hovered-device.id != "" : root.recent-tooltip-visible) - && !root.connection-dialog-open: device-tip := Rectangle { + && !root.connection-dialog-open && !root.lan-open: device-tip := Rectangle { private property device-name: root.gallery-mode ? gallery.hovered-device.name : root.recent-tooltip-name; private property host-name: root.gallery-mode ? gallery.hovered-device.host-name : root.recent-tooltip-host; private property device-id: root.gallery-mode ? gallery.hovered-device.id : root.recent-tooltip-id; @@ -1824,7 +2191,7 @@ export component MainWindow inherits Window { icon: self.error ? FontAwesomeIcons.xmark : FontAwesomeIcons.check; text: self.error ? GalleryStrings.tr("复制失败,请重试", "Couldn’t copy. Try again", "Не удалось скопировать. Повторите попытку") - : UiStrings.copied; + : root.lan-mode ? GalleryStrings.tr("IP 和端口已复制", "IP and port copied", "IP и порт скопированы") : UiStrings.copied; z: 30; } @@ -1854,6 +2221,7 @@ export component MainWindow inherits Window { alignment: center; spacing: 8px; PrimaryButton { text: UiStrings.ok; + enabled: root.lan-mode ? !root.lan-password-pending : !root.password-change-pending; clicked => { if (root.reset-password(root.new-password-input)) { root.new-password-input = ""; @@ -2047,32 +2415,41 @@ export component MainWindow inherits Window { } // Connection feedback follows the workbench spacing and action hierarchy. - if root.connection-dialog-open: Rectangle { + if root.connection-dialog-open || root.lan-open: Rectangle { background: #15223850; z: 48; - TouchArea { clicked => { focus-sink.focus(); } } + TouchArea { clicked => { if !root.lan-open { focus-sink.focus(); } } } WorkbenchCard { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 340px; - height: root.connection-password-required && !root.connection-validating ? 236px : 168px; - Text { - x: 24px; y: 24px; width: parent.width - 48px; height: 46px; - text: root.connection-status-text; + height: (root.prompt-password-required && !root.prompt-validating ? 236px : 168px) + + max(0px, connection-prompt-text.preferred-height - 46px); + connection-prompt-text := Text { + x: 24px; y: 24px; width: parent.width - 48px; height: max(46px, self.preferred-height); + text: root.prompt-status-text; color: WorkbenchStyle.text; font-size: 14px; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; } - if root.connection-password-required && !root.connection-validating: PasswordInput { - x: (parent.width - self.width) / 2; y: 86px; + if !root.lan-open && root.connection-password-required && !root.connection-validating: PasswordInput { + x: (parent.width - self.width) / 2; y: connection-prompt-text.y + connection-prompt-text.height + 16px; width: 164px; height: 36px; text-size: 16px; border-radius: UiRadius.control; border-color: WorkbenchStyle.control-border; text <=> root.connection-password; - accepted => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + accepted => { root.submit-connection-password(); } } - if root.connection-password-required && !root.connection-validating: Rectangle { - x: (parent.width - self.width) / 2; y: 134px; + if root.lan-open && !root.lan-connecting: PasswordInput { + x: (parent.width - self.width) / 2; y: connection-prompt-text.y + connection-prompt-text.height + 16px; + width: 164px; height: 36px; text-size: 16px; max-length: 6; + border-radius: UiRadius.control; border-color: WorkbenchStyle.control-border; + text <=> root.lan-access-password; + accepted => { root.submit-connection-password(); } + } + if root.prompt-password-required && !root.prompt-validating: Rectangle { + x: (parent.width - self.width) / 2; y: connection-prompt-text.y + connection-prompt-text.height + 64px; width: remember-label.preferred-width + 29px; height: 21px; - ImGuiCheckBox { x: 0px; y: 0px; checked <=> root.connection-remember-password; } + if root.lan-open: ImGuiCheckBox { x: 0px; y: 0px; checked <=> root.lan-remember-password; } + if !root.lan-open: ImGuiCheckBox { x: 0px; y: 0px; checked <=> root.connection-remember-password; } remember-label := Text { x: 29px; height: parent.height; text: UiStrings.remember-password; @@ -2080,21 +2457,25 @@ export component MainWindow inherits Window { } TouchArea { x: 29px; width: parent.width - 29px; height: parent.height; - clicked => { root.connection-remember-password = !root.connection-remember-password; } + clicked => { + if root.lan-open { root.lan-remember-password = !root.lan-remember-password; } + else { root.connection-remember-password = !root.connection-remember-password; } + } } } HorizontalLayout { x: 24px; y: parent.height - 60px; width: parent.width - 48px; height: 36px; spacing: 12px; alignment: center; - if root.connection-password-required || root.connection-pending: WorkbenchButton { + if root.prompt-password-required || root.prompt-pending: WorkbenchButton { text: UiStrings.cancel; - clicked => { root.connection-cancel(); } + clicked => { root.cancel-connection-prompt(); } } - if root.connection-password-required && !root.connection-validating: WorkbenchButton { + if root.prompt-password-required && !root.prompt-validating: WorkbenchButton { primary: true; text: UiStrings.ok; - clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } + enabled: !root.lan-open || (root.lan-retry-after == 0 && root.lan-access-password.character-count == 6); + clicked => { root.submit-connection-password(); } } - if !root.connection-password-required && !root.connection-pending: WorkbenchButton { + if !root.prompt-password-required && !root.prompt-pending: WorkbenchButton { primary: true; text: UiStrings.ok; clicked => { root.connection-acknowledge(); } } @@ -2253,7 +2634,7 @@ export component MainWindow inherits Window { if root.settings-tab == 0: settings-scroll := ScrollView { x: settings-dialog.content-inset; y: 108px; width: parent.width - 2 * settings-dialog.content-inset + settings-dialog.scrollbar-gutter; - height: parent.height - 174px; + height: parent.height - 174px - (root.settings-save-error == "" ? 0px : 28px); viewport-width: self.visible-width; viewport-height: settings-content.preferred-height; viewport-y <=> root.settings-scroll-y; @@ -2288,7 +2669,11 @@ export component MainWindow inherits Window { } } SettingsGroup { - title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3; + title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 4; + SettingsToggleRow { + label: GalleryStrings.tr("启用 IP 直连", "Enable direct IP", "Прямое IP-подключение"); + checked <=> root.ip-direct-enabled-draft; + } SettingsToggleRow { label: UiStrings.turn-relay; checked <=> root.turn-enabled; enabled: !root.settings-session-active; } SettingsToggleRow { label: UiStrings.force-relay; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; } SettingsToggleRow { @@ -2296,6 +2681,10 @@ export component MainWindow inherits Window { SettingsButton { x: 0px; y: 2px; width: parent.width - 60px; height: 32px; quiet: true; align-left: true; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } } } } + Text { + text: GalleryStrings.tr("关闭 IP 直连将隐藏模式按钮,并断开 IP 直连会话。", "Disabling direct IP hides the mode button and disconnects direct IP sessions.", "Отключение скроет кнопку режима и завершит прямые IP-сеансы."); + font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap; + } SettingsGroup { title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 1; SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; divider: false; } @@ -2324,12 +2713,18 @@ export component MainWindow inherits Window { } if root.settings-tab == 1: GallerySettings { x: settings-dialog.content-inset; y: 108px; - width: parent.width - 2 * settings-dialog.content-inset; height: parent.height - 174px; + width: parent.width - 2 * settings-dialog.content-inset; + height: parent.height - 174px - (root.settings-save-error == "" ? 0px : 28px); resolution-index <=> root.gallery-resolution-draft; frame-rate-index <=> root.gallery-frame-rate-draft; bandwidth-index <=> root.gallery-bandwidth-draft; error: root.gallery-settings-error; } + if root.settings-save-error != "": Text { + x: settings-dialog.content-inset; y: parent.height - 92px; + width: parent.width - 2 * settings-dialog.content-inset; height: 24px; + text: root.settings-save-error; font-size: 11px; color: #c53e3e; wrap: word-wrap; + } Rectangle { x: settings-dialog.content-inset; y: parent.height - 64px; width: parent.width - 2 * settings-dialog.content-inset; height: 1px; background: #e6ebf2; @@ -3104,4 +3499,79 @@ export component MainWindow inherits Window { z: 200; } } + // Each explicit validation failure opens one modal, without a focus loop. + if root.lan-access-warning != "": Rectangle { + width: parent.width; height: parent.height; + background: #00000042; + z: 300; + TouchArea { scroll-event(event) => { accept } } + lan-warning-focus := FocusScope { + width: parent.width; height: parent.height; + key-pressed(event) => { + if event.text == Key.Escape || event.text == Key.Return || event.text == " " { + root.dismiss-lan-access-warning(); + } else if event.text == Key.Tab { + self.focus(); + } + return accept; + } + Timer { + interval: 1ms; + running: true; + triggered => { + self.running = false; + // Defer focus until after Slint's accessibility update. + root.menu-open = false; + lan-ip-picker.close-menu(); + lan-warning-focus.focus(); + } + } + DialogSurface { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(root.compact-language ? 270px : 320px, parent.width - 40px); + // Let short messages stay compact; longer translations grow + // naturally without stretching the gap above the action. + height: lan-warning-content.preferred-height; + TouchArea { scroll-event(event) => { accept } } + lan-warning-content := VerticalLayout { + padding: 20px; spacing: 20px; + padding-top: 28px; padding-bottom: 28px; + if root.lan-access-warning-endpoint != "": Text { + text: root.lan-access-warning-endpoint; + font-size: 12px; font-weight: 600; color: WorkbenchStyle.text; + horizontal-alignment: center; wrap: word-wrap; + } + lan-warning-message := Rectangle { + private property <[string]> lines: LanStrings.access-error-lines(root.lan-access-warning); + height: warning-lines.preferred-height; + vertical-stretch: 0; + accessible-role: text; + accessible-label: LanStrings.error-message(root.lan-access-warning); + warning-lines := VerticalLayout { + width: parent.width; + height: self.preferred-height; + spacing: 6px; + for line in (lan-warning-message.lines[0] != "" ? lan-warning-message.lines : [LanStrings.error-message(root.lan-access-warning)]): Text { + text: line; font-size: ImGuiFontStyle.base; color: #30343b; + horizontal-alignment: center; wrap: word-wrap; + accessible-role: none; + } + } + } + HorizontalLayout { + height: 30px; alignment: center; + PrimaryButton { + text: UiStrings.ok; + accessible-role: button; + accessible-label: UiStrings.ok; + accessible-action-default => { root.dismiss-lan-access-warning(); } + clicked => { root.dismiss-lan-access-warning(); } + } + } + } + } + } + } + } 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 41ed4c55..aae116c4 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 @@ -197,8 +197,9 @@ void GuiRuntime::HandleWindowsServiceIntegration() { optimistic.available = local_service_available_; optimistic.interactive_stage = local_interactive_stage_; const auto message = BuildWindowsServiceStatusAction(optimistic).to_json(); - SendReliableDataFrame(peer_, message.data(), message.size(), - control_data_label_.c_str()); + for (const auto& id : ConnectedControllers()) + SendControllerData(id, message.data(), message.size(), + control_data_label_.c_str()); } result.reset(); } @@ -293,10 +294,11 @@ void GuiRuntime::HandleWindowsServiceIntegration() { RemoteAction remote_action = BuildWindowsServiceStatusAction(broadcast_status); std::string msg = remote_action.to_json(); - int ret = SendReliableDataFrame(peer_, msg.data(), msg.size(), - control_data_label_.c_str()); - if (ret != 0) { - LOG_WARN("Broadcast Windows service status failed, ret={}", ret); + for (const auto& id : ConnectedControllers()) { + const int ret = SendControllerData(id, msg.data(), msg.size(), + control_data_label_.c_str()); + if (ret != 0) + LOG_WARN("Windows service status send failed, peer={}, ret={}", id, ret); } #endif } diff --git a/apps/desktop/src/thumbnail/thumbnail.cpp b/apps/desktop/src/thumbnail/thumbnail.cpp index 3593872c..6dcb4273 100644 --- a/apps/desktop/src/thumbnail/thumbnail.cpp +++ b/apps/desktop/src/thumbnail/thumbnail.cpp @@ -118,7 +118,14 @@ bool IsRemoteId(const std::string& id) { [](unsigned char c) { return c >= '0' && c <= '9'; }); } +bool IsLanRecordId(const std::string& id) { + return id.size() == 68 && id.rfind("lan-", 0) == 0 && + std::all_of(id.begin() + 4, id.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); + }); +} std::string ConnectionKey(const Thumbnail::RecentConnection& connection) { + if (!connection.lan.device_id.empty()) return connection.remote_id; return connection.remote_id + (connection.remember_password ? "Y" : "N") + connection.remote_host_name + (connection.remember_password ? "@" + connection.password : ""); @@ -126,17 +133,37 @@ std::string ConnectionKey(const Thumbnail::RecentConnection& connection) { } // namespace +std::string Thumbnail::LanRecordId(const std::string& device_id) { + unsigned char digest[32]{}; + unsigned int size = 0; + const std::string input = "lan_direct/" + device_id; + if (EVP_Digest(input.data(), input.size(), digest, &size, EVP_sha256(), + nullptr) != 1 || + size != 32) + return {}; + std::ostringstream text; + text << "lan-" << std::hex << std::setfill('0'); + for (const auto byte : digest) text << std::setw(2) << unsigned(byte); + return text.str(); +} + bool Thumbnail::WriteRecord(const RecentConnection& connection) { + const auto password = connection.remember_password + ? AES_encrypt(connection.password, aes128_key_, aes128_iv_) : ""; + if (connection.remember_password && password.empty()) return false; const auto path = std::filesystem::path(save_path_) / (connection.remote_id + ".json"); const auto temporary = path.string() + ".tmp"; const nlohmann::json record = { + {"version", 2}, + {"kind", connection.lan.device_id.empty() ? "cloud_id" : "lan_direct"}, + {"lan", + {{"address", connection.lan.address}, + {"port", connection.lan.port}, + {"device_id", connection.lan.device_id}}}, {"remote_id", connection.remote_id}, {"host_name", connection.remote_host_name}, - {"password", - connection.remember_password - ? AES_encrypt(connection.password, aes128_key_, aes128_iv_) - : ""}, + {"password", password}, {"platform", HostPlatformName(connection.platform)}}; std::ofstream output(temporary, std::ios::binary | std::ios::trunc); output << record.dump(); @@ -167,14 +194,25 @@ bool Thumbnail::ReadRecord(const std::filesystem::path& path, if (!record.is_object()) return false; try { connection->remote_id = record.value("remote_id", ""); - if (!IsRemoteId(connection->remote_id) || - path.stem() != connection->remote_id) + const auto kind = record.value("kind", "cloud_id"); + if (kind == "lan_direct") { + const auto& target = record.at("lan"); + connection->lan = {target.at("address"), target.at("device_id"), + target.at("port")}; + if (!IsLanRecordId(connection->remote_id) || + connection->lan.device_id.size() != 32 || + connection->remote_id != LanRecordId(connection->lan.device_id) || + !connection->lan.port || connection->lan.port > 65535 || + connection->lan.address.size() > 15) + return false; + } else if (kind != "cloud_id" || !IsRemoteId(connection->remote_id)) return false; + if (path.stem() != connection->remote_id) return false; connection->remote_host_name = record.value("host_name", ""); const auto password = record.value("password", ""); - connection->remember_password = !password.empty(); connection->password = password.empty() ? "" : AES_decrypt(password, aes128_key_, aes128_iv_); + connection->remember_password = !connection->password.empty(); connection->platform = ParseHostPlatform(record.value("platform", "unknown")); const auto image = path.parent_path() / (connection->remote_id + ".png"); @@ -201,17 +239,21 @@ int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height, const std::string& remote_id, const std::string& host_name, const std::string& password, - HostPlatform platform) { + HostPlatform platform, const LanTarget* lan) { std::lock_guard lock(mutex_); // Successful connections are history even without saved credentials. - if (!IsRemoteId(remote_id)) return -1; + if (lan ? (!IsLanRecordId(remote_id) || + remote_id != LanRecordId(lan->device_id)) + : !IsRemoteId(remote_id)) + return -1; RecentConnection connection; ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"), &connection); connection.remote_id = remote_id; if (!host_name.empty()) connection.remote_host_name = host_name; + connection.lan = lan ? *lan : LanTarget{}; connection.password = password; - connection.remember_password = !password.empty(); + connection.remember_password = !connection.password.empty(); if (platform != HostPlatform::Unknown) connection.platform = platform; if (!WriteRecord(connection)) return -1; @@ -349,6 +391,12 @@ bool Thumbnail::DecodeImage(const std::filesystem::path& image_path, int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) { std::lock_guard lock(mutex_); + if (IsLanRecordId(filename_keyword)) { + for (const auto* extension : {".json", ".png"}) + std::filesystem::remove(std::filesystem::path(save_path_) / + (filename_keyword + extension)); + return 0; + } const auto remote_id = filename_keyword.substr(0, 9); if (!IsRemoteId(remote_id)) return -1; for (const auto& entry : std::filesystem::directory_iterator(save_path_)) { @@ -411,14 +459,14 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext, ctx = EVP_CIPHER_CTX_new(); if (!ctx) { LOG_ERROR("Error in EVP_CIPHER_CTX_new"); - return plaintext; + return {}; } ret = EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv); if (1 != ret) { LOG_ERROR("Error in EVP_EncryptInit_ex"); EVP_CIPHER_CTX_free(ctx); - return plaintext; + return {}; } ret = EVP_EncryptUpdate( @@ -428,7 +476,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext, if (1 != ret) { LOG_ERROR("Error in EVP_EncryptUpdate"); EVP_CIPHER_CTX_free(ctx); - return plaintext; + return {}; } ciphertext_len = len; @@ -436,7 +484,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext, if (1 != ret) { LOG_ERROR("Error in EVP_EncryptFinal_ex"); EVP_CIPHER_CTX_free(ctx); - return plaintext; + return {}; } ciphertext_len += len; @@ -448,7 +496,7 @@ std::string Thumbnail::AES_encrypt(const std::string& plaintext, if (1 != ret) { LOG_ERROR("Error in OPENSSL_buf2hexstr_ex"); EVP_CIPHER_CTX_free(ctx); - return plaintext; + return {}; } EVP_CIPHER_CTX_free(ctx); @@ -471,13 +519,13 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext, &ciphertext_buf_len, ciphertext.c_str(), '\0'); if (1 != ret) { LOG_ERROR("Error in OPENSSL_hexstr2buf_ex"); - return ciphertext; + return {}; } ctx = EVP_CIPHER_CTX_new(); if (!ctx) { LOG_ERROR("Error in EVP_CIPHER_CTX_new"); - return ciphertext; + return {}; } ret = EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv); @@ -485,7 +533,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext, LOG_ERROR("Error in EVP_DecryptInit_ex"); EVP_CIPHER_CTX_free(ctx); - return ciphertext; + return {}; } ret = EVP_DecryptUpdate(ctx, plaintext, &plaintext_len, ciphertext_buf, @@ -494,7 +542,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext, LOG_ERROR("Error in EVP_DecryptUpdate"); EVP_CIPHER_CTX_free(ctx); - return ciphertext; + return {}; } ret = @@ -503,7 +551,7 @@ std::string Thumbnail::AES_decrypt(const std::string& ciphertext, LOG_ERROR("Error in EVP_DecryptFinal_ex"); EVP_CIPHER_CTX_free(ctx); - return ciphertext; + return {}; } plaintext_len += plaintext_final_len; diff --git a/apps/desktop/src/thumbnail/thumbnail.h b/apps/desktop/src/thumbnail/thumbnail.h index e6f24a32..174cf501 100644 --- a/apps/desktop/src/thumbnail/thumbnail.h +++ b/apps/desktop/src/thumbnail/thumbnail.h @@ -20,12 +20,18 @@ namespace crossdesk { class Thumbnail { public: + struct LanTarget { + std::string address, device_id; + unsigned port = 52888; + }; + static std::string LanRecordId(const std::string& device_id); struct RecentConnection { std::filesystem::path image_path; std::string remote_id; std::string remote_host_name; std::string password; bool remember_password = false; + LanTarget lan; HostPlatform platform = HostPlatform::Unknown; }; @@ -45,9 +51,9 @@ class Thumbnail { // credentials (and clears any remembered password from a previous session). int SaveToThumbnail(const char* nv12, int width, int height, const std::string& remote_id, - const std::string& host_name, - const std::string& password, - HostPlatform platform = HostPlatform::Unknown); + const std::string& host_name, const std::string& password, + HostPlatform platform = HostPlatform::Unknown, + const LanTarget* lan = nullptr); int LoadThumbnail( std::vector>&