diff --git a/README.md b/README.md index 4d14b62b..4f4242ed 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ sudo apt install "./crossdesk-linux-amd64-.deb" | 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 | | 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 | | 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 | +| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 | | 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 | | 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 | | 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 | diff --git a/README_EN.md b/README_EN.md index f826107b..04c69314 100644 --- a/README_EN.md +++ b/README_EN.md @@ -147,6 +147,7 @@ Open **☰ → Settings** in the top-right corner and click **OK** to save. Conf | Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options | | Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails | | Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option | +| Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved | | Privacy screen on connect | Automatically enable privacy when this computer is controlled remotely; the controller can toggle it from the session toolbar | | Self-Hosted Config | Set the server address and signaling port, then enable the checkbox | | Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI | diff --git a/apps/desktop/src/config_center/config_center.cpp b/apps/desktop/src/config_center/config_center.cpp index 996b6790..af9cf330 100644 --- a/apps/desktop/src/config_center/config_center.cpp +++ b/apps/desktop/src/config_center/config_center.cpp @@ -125,6 +125,8 @@ int ConfigCenter::Load() { enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_); enable_privacy_screen_.store( ini_->GetBoolValue(section_, "enable_privacy_screen", true)); + save_remote_previews_.store( + ini_->GetBoolValue(section_, "save_remote_previews", true)); portable_service_prompt_suppressed_ = ini_->GetBoolValue(section_, "portable_service_prompt_suppressed", portable_service_prompt_suppressed_); @@ -214,6 +216,8 @@ int ConfigCenter::Save() { ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_); ini_->SetBoolValue(section_, "enable_privacy_screen", enable_privacy_screen_.load()); + ini_->SetBoolValue(section_, "save_remote_previews", + save_remote_previews_.load()); ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", portable_service_prompt_suppressed_); @@ -344,6 +348,18 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) { return 0; } +int ConfigCenter::SetSaveRemotePreviews(bool enabled) { + if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) { + return -1; + } + save_remote_previews_.store(enabled); + return 0; +} + +bool ConfigCenter::IsSaveRemotePreviews() const { + return save_remote_previews_.load(); +} + int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) { portable_service_prompt_suppressed_ = suppressed; ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", diff --git a/apps/desktop/src/config_center/config_center.h b/apps/desktop/src/config_center/config_center.h index 50e77837..6ed294be 100644 --- a/apps/desktop/src/config_center/config_center.h +++ b/apps/desktop/src/config_center/config_center.h @@ -46,6 +46,7 @@ class ConfigCenter { int SetAutostart(bool enable_autostart); int SetDaemon(bool enable_daemon); int SetPrivacyScreen(bool enable_privacy_screen); + int SetSaveRemotePreviews(bool enabled); int SetPortableServicePromptSuppressed(bool suppressed); int SetFileTransferSavePath(const std::string& path); @@ -69,6 +70,7 @@ class ConfigCenter { bool IsEnableAutostart() const; bool IsEnableDaemon() const; bool IsEnablePrivacyScreen() const; + bool IsSaveRemotePreviews() const; bool IsPortableServicePromptSuppressed() const; std::string GetFileTransferSavePath() const; @@ -97,6 +99,7 @@ class ConfigCenter { bool enable_autostart_ = false; bool enable_daemon_ = false; std::atomic enable_privacy_screen_{true}; + std::atomic save_remote_previews_{true}; bool portable_service_prompt_suppressed_ = false; std::string file_transfer_save_path_ = ""; }; diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index cd447e74..7aa76d3a 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1037,6 +1037,9 @@ void GuiApplication::InitializeLogger() { InitLogger(exec_log_path_); } void GuiApplication::InitializeSettings() { settings_.Load(); + if (thumbnail_->SetSavePreviews(config_center_->IsSaveRemotePreviews()) != 0) { + LOG_ERROR("Failed to clear saved remote previews"); + } settings_.LoadRecentConnectionAliases(); localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_); @@ -1240,6 +1243,7 @@ void GuiApplication::ResetSettingsUi() { ui_->main->set_self_hosted_enabled(enable_self_hosted_); ui_->main->set_autostart_enabled(enable_autostart_); ui_->main->set_daemon_enabled(enable_daemon_); + ui_->main->set_save_remote_previews(config_center_->IsSaveRemotePreviews()); ui_->main->set_privacy_on_connect_enabled( config_center_->IsEnablePrivacyScreen()); #ifdef _WIN32 @@ -2334,9 +2338,11 @@ void GuiApplication::SyncMainWindow() { } std::ostringstream signature_builder; + signature_builder << config_center_->IsSaveRemotePreviews() << '\n'; for (const auto& [key, connection] : recent_connections_) { signature_builder << key << '\0' << connection.remote_id << '\0' << connection.remote_host_name << '\0' + << static_cast(connection.platform) << '\0' << settings_.RecentConnectionDisplayName(connection) << '\0' << device_presence_cache_.IsOnline(connection.remote_id) @@ -2351,22 +2357,27 @@ void GuiApplication::SyncMainWindow() { std::vector model; model.reserve(recent_connections_.size()); for (const auto& [_, connection] : recent_connections_) { - ui::RecentConnection item; + // Slint-generated scalar fields have no defaults. With no image, + // has_thumbnail must remain false so the remote platform is rendered. + ui::RecentConnection item{}; item.remote_id = UiText(connection.remote_id); item.host_name = UiText(connection.remote_host_name); item.display_name = UiText(settings_.RecentConnectionDisplayName(connection)); item.online = device_presence_cache_.IsOnline(connection.remote_id); + item.platform = UiText(HostPlatformName(connection.platform)); std::vector rgba; int image_width = 0; int image_height = 0; - if (!connection.image_path.empty() && + if (config_center_->IsSaveRemotePreviews() && + !connection.image_path.empty() && thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width, &image_height)) { slint::SharedPixelBuffer pixels(image_width, image_height); std::memcpy(pixels.begin(), rgba.data(), rgba.size()); item.thumbnail = slint::Image(std::move(pixels)); + item.has_thumbnail = true; } model.push_back(std::move(item)); } @@ -3259,6 +3270,13 @@ void GuiApplication::SaveSettingsFromUi() { main->set_force_relay_enabled(config_center_->IsForceRelay()); config_center_->SetAutostart(enable_autostart_); config_center_->SetDaemon(enable_daemon_); + if (config_center_->SetSaveRemotePreviews(main->get_save_remote_previews()) != 0) { + LOG_ERROR("Failed to save remote preview preference"); + } + if (thumbnail_->SetSavePreviews(config_center_->IsSaveRemotePreviews()) != 0) { + LOG_ERROR("Failed to clear saved remote previews"); + } + reload_recent_connections_ = true; #ifdef _WIN32 if (!HasActiveSession()) { const auto capture_method = static_cast( diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index b2020503..a8a2efdf 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -79,6 +79,8 @@ struct TranslationRow { X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \ X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \ X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \ + X(save_remote_previews, u8"保存远程画面预览", "Save remote screen previews", u8"Сохранять превью экрана") \ + X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \ X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \ X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \ X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \ diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index b042131d..c74d83bb 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -236,7 +236,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { std::shared_ptr> frame_snapshot; int video_width = 0; int video_height = 0; - { + const bool save_preview = config_center_->IsSaveRemotePreviews(); + if (save_preview) { std::lock_guard lock(props->video_frame_mutex_); frame_snapshot = props->front_frame_; video_width = props->video_width_; @@ -249,7 +250,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { } } auto* native_renderer = video_renderer_.get(); - if ((!frame_snapshot || frame_snapshot->empty()) && native_renderer) { + if (save_preview && (!frame_snapshot || frame_snapshot->empty()) && + native_renderer) { auto native_snapshot = std::make_shared>(); if (native_renderer->CopyLatestNv12(props->remote_id_, native_snapshot.get(), &video_width, @@ -269,9 +271,14 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { std::unique_lock lock(remote_sessions_mutex_); peer = std::exchange(props->peer_, nullptr); } + HostPlatform platform; + { + std::lock_guard lock(props->remote_version_mutex_); + platform = props->recent_platform_; + } const auto queued_at = std::chrono::steady_clock::now(); session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask( - [props, peer, frame_snapshot, video_width, video_height, + [props, peer, frame_snapshot, video_width, video_height, platform, thumbnail = thumbnail_, queued_at]() mutable { if (peer) { LOG_INFO("[{}] Background leave connection [{}]", props->local_id_, @@ -279,12 +286,16 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr props) { LeaveConnection(peer, props->remote_id_.c_str()); DestroyPeer(&peer); } - if (thumbnail && frame_snapshot && !frame_snapshot->empty() && - video_width > 0 && video_height > 0) { - thumbnail->SaveToThumbnail( - reinterpret_cast(frame_snapshot->data()), video_width, - video_height, props->remote_id_, props->remote_host_name_, - props->remember_password_ ? props->remote_password_ : ""); + if (thumbnail && props->was_connected_) { + const char* nv12 = frame_snapshot && !frame_snapshot->empty() + ? reinterpret_cast(frame_snapshot->data()) : nullptr; + if (thumbnail->SaveToThumbnail( + nv12, video_width, video_height, props->remote_id_, + props->remote_host_name_, + props->remember_password_ ? props->remote_password_ : "", + platform) != 0) { + LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_); + } } const auto elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - queued_at).count(); diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index fec93556..c031917c 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -323,6 +323,7 @@ void PeerEventHandler::OnReceiveDataBuffer( std::lock_guard lock(props->remote_version_mutex_); props->remote_host_info_received_ = true; props->remote_platform_ = remote_action.i.platform; + props->recent_platform_ = remote_action.i.platform; props->remote_app_version_ = IsValidAppVersion(remote_action.i.app_version) ? remote_action.i.app_version : ""; } diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index ce3ea772..f16e7567 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -295,6 +295,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, runtime->need_to_create_stream_window_ = true; } props->connection_established_ = true; + props->was_connected_ = true; runtime->start_keyboard_capturer_ = true; break; } @@ -336,7 +337,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, needs_native_snapshot = !props->thumbnail_frame_ || props->thumbnail_frame_->empty(); } - if (needs_native_snapshot) { + if (needs_native_snapshot && + runtime->config_center_->IsSaveRemotePreviews()) { auto* native_renderer = runtime->video_renderer_.get(); auto snapshot = std::make_shared>(); if (native_renderer && diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 9b098c45..c720f281 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -87,6 +87,7 @@ struct RemoteSession { bool signal_connected_ = false; SignalStatus signal_status_ = SignalStatus::SignalClosed; bool connection_established_ = false; + std::atomic was_connected_{false}; bool rejoin_ = false; bool net_traffic_stats_button_pressed_ = false; bool enable_mouse_control_ = true; @@ -130,6 +131,8 @@ struct RemoteSession { bool remote_host_info_received_ = false; std::string remote_app_version_; HostPlatform remote_platform_ = HostPlatform::Unknown; + // Retain history metadata after disconnect resets the live capabilities. + HostPlatform recent_platform_ = HostPlatform::Unknown; bool remote_update_dismissed_ = false; bool remote_service_status_received_ = false; std::mutex video_settings_mutex_; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 477f4009..751d3e43 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -7,6 +7,8 @@ export struct RecentConnection { host-name: string, online: bool, thumbnail: image, + has-thumbnail: bool, + platform: string, } export struct ReleaseNoteBlock { @@ -49,6 +51,8 @@ export global UiStrings { in-out property self-hosted: "Self-hosted server"; in-out property autostart: "Start with system"; in-out property daemon: "Run in background"; + in-out property save-remote-previews: "Save remote screen previews"; + in-out property unknown-platform: "Unknown platform"; in-out property privacy-on-connect: "Privacy screen on connect"; in-out property file-save-path: "File save path"; in-out property default-desktop: "Desktop"; @@ -306,6 +310,7 @@ export component MainWindow inherits Window { in-out property self-hosted-enabled: false; in-out property autostart-enabled: false; in-out property daemon-enabled: false; + in-out property save-remote-previews: true; // Show the preference on every platform; only adapted hosts can change it. in property privacy-setting-available: false; in-out property privacy-on-connect-enabled: true; @@ -864,13 +869,37 @@ export component MainWindow inherits Window { private property toolbar-width: 79px; private property toolbar-right-padding: 3px; - Image { + Rectangle { x: 7px; y: 7px; width: connection-card.content-width; height: 84px; - source: connection.thumbnail; - image-fit: cover; + background: connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) : + connection.platform == "macos" ? @linear-gradient(135deg, #6e4aad, #8c63c4) : + connection.platform == "linux" ? @linear-gradient(135deg, #a14f14, #ba631f) : + connection.platform == "ios" ? @linear-gradient(135deg, #146e66, #1f8278) : + @linear-gradient(135deg, #596675, #737d8c); + if connection.has-thumbnail: Image { + width: parent.width; + height: parent.height; + source: connection.thumbnail; + image-fit: cover; + } + if !connection.has-thumbnail: Text { + x: 8px; + width: parent.width - 16px; + height: parent.height; + text: connection.platform == "windows" ? "Windows" : + connection.platform == "macos" ? "macOS" : + connection.platform == "linux" ? "Linux" : + connection.platform == "ios" ? "iOS" : UiStrings.unknown-platform; + color: white; + font-size: 20px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } } Rectangle { x: 7px; @@ -1513,6 +1542,11 @@ export component MainWindow inherits Window { Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.force-relay; color: root.turn-enabled ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.force-relay-enabled; enabled: root.turn-enabled && !root.settings-session-active; } } + Rectangle { + height: 30px; + Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.save-remote-previews; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.save-remote-previews; } + } Rectangle { height: 30px; Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.privacy-on-connect; color: root.privacy-setting-available ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 99467fac..ba247511 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -65,6 +65,8 @@ inline int ApplyMainWindowStrings( Text(localization::self_hosted_server_config[language])); strings.set_autostart(Text(localization::enable_autostart[language])); strings.set_daemon(Text(localization::enable_daemon[language])); + strings.set_save_remote_previews(Text(localization::save_remote_previews[language])); + strings.set_unknown_platform(Text(localization::unknown_platform[language])); strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language])); strings.set_file_save_path( Text(localization::file_transfer_save_path[language])); diff --git a/apps/desktop/src/thumbnail/thumbnail.cpp b/apps/desktop/src/thumbnail/thumbnail.cpp index 992af47e..36a607d4 100644 --- a/apps/desktop/src/thumbnail/thumbnail.cpp +++ b/apps/desktop/src/thumbnail/thumbnail.cpp @@ -1,11 +1,17 @@ #include "thumbnail.h" +#ifdef _WIN32 +#include +#endif + #include #include #include #include +#include #include +#include #include #include #include @@ -104,116 +110,217 @@ int Thumbnail::SetThumbnailDpiScale(float dpi_scale) { return 0; } -int Thumbnail::SaveToThumbnail(const char* yuv420p, int width, int height, +namespace { + +bool IsRemoteId(const std::string& id) { + return id.size() == 9 && + std::all_of(id.begin(), id.end(), + [](unsigned char c) { return c >= '0' && c <= '9'; }); +} + +std::string ConnectionKey(const Thumbnail::RecentConnection& connection) { + return connection.remote_id + (connection.remember_password ? "Y" : "N") + + connection.remote_host_name + + (connection.remember_password ? "@" + connection.password : ""); +} + +} // namespace + +bool Thumbnail::WriteRecord(const RecentConnection& connection) { + const auto path = + std::filesystem::path(save_path_) / (connection.remote_id + ".json"); + const auto temporary = path.string() + ".tmp"; + const nlohmann::json record = { + {"remote_id", connection.remote_id}, + {"host_name", connection.remote_host_name}, + {"password", + connection.remember_password + ? AES_encrypt(connection.password, aes128_key_, aes128_iv_) + : ""}, + {"platform", HostPlatformName(connection.platform)}}; + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + output << record.dump(); + output.close(); + if (!output) return false; + std::error_code error; +#ifdef _WIN32 + // Windows rename does not replace an existing destination. + if (!MoveFileExW(std::filesystem::path(temporary).c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + std::filesystem::remove(temporary, error); + return false; + } +#else + std::filesystem::rename(temporary, path, error); + if (error) { + std::filesystem::remove(temporary, error); + return false; + } +#endif + return true; +} + +bool Thumbnail::ReadRecord(const std::filesystem::path& path, + RecentConnection* connection) { + std::ifstream input(path, std::ios::binary); + const auto record = nlohmann::json::parse(input, nullptr, false); + if (!record.is_object()) return false; + try { + connection->remote_id = record.value("remote_id", ""); + if (!IsRemoteId(connection->remote_id) || + 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->platform = + ParseHostPlatform(record.value("platform", "unknown")); + const auto image = path.parent_path() / (connection->remote_id + ".png"); + if (save_previews_ && std::filesystem::is_regular_file(image)) { + connection->image_path = image; + } + return true; + } catch (const nlohmann::json::exception&) { + return false; + } +} + +int Thumbnail::SetSavePreviews(bool enabled) { + std::lock_guard lock(mutex_); + save_previews_ = enabled; + if (enabled) return 0; + // Migrate legacy filenames before removing their image contents, preserving + // credentials, names and recency even when previews have never been enabled. + std::vector> connections; + return LoadThumbnailLocked(connections); +} + +int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height, const std::string& remote_id, const std::string& host_name, - const std::string& password) { - if (!rgba_buffer_) { - rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4]; - } + const std::string& password, + HostPlatform platform) { + std::lock_guard lock(mutex_); + // Preserve the existing opt-in via Remember password for recent connections. + if (password.empty()) return 0; + if (!IsRemoteId(remote_id)) return -1; + RecentConnection connection; + ReadRecord(std::filesystem::path(save_path_) / (remote_id + ".json"), + &connection); + connection.remote_id = remote_id; + connection.remote_host_name = host_name; + connection.password = password; + connection.remember_password = true; + if (platform != HostPlatform::Unknown) connection.platform = platform; + if (!WriteRecord(connection)) return -1; - if (yuv420p) { - ScaleNv12ToABGR((char*)yuv420p, width, height, thumbnail_width_, - thumbnail_height_, rgba_buffer_); - } else { - // If yuv420p is null, fill the buffer with black pixels - std::memset(rgba_buffer_, 0x00, thumbnail_width_ * thumbnail_height_ * 4); - for (int i = 0; i < thumbnail_width_ * thumbnail_height_; ++i) { - // Set alpha channel to opaque - rgba_buffer_[i * 4 + 3] = static_cast(0xFF); + const auto image = std::filesystem::path(save_path_) / (remote_id + ".png"); + if (save_previews_ && nv12 && width > 0 && height > 0) { + if (!rgba_buffer_) { + rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4]; } + ScaleNv12ToABGR(const_cast(nv12), width, height, thumbnail_width_, + thumbnail_height_, rgba_buffer_); + if (!stbi_write_png(image.string().c_str(), thumbnail_width_, + thumbnail_height_, 4, rgba_buffer_, + thumbnail_width_ * 4)) + return -1; + } else if (!save_previews_) { + std::error_code error; + std::filesystem::remove(image, error); + if (error) return -1; } - - std::string image_file_name; - if (password.empty()) { - return 0; - } else { - // delete the old thumbnail - std::string filename_with_remote_id = remote_id; - DeleteThumbnail(filename_with_remote_id); - } - - std::string cipher_password = AES_encrypt(password, aes128_key_, aes128_iv_); - image_file_name = remote_id + 'Y' + host_name + '@' + cipher_password; - std::string file_path = save_path_ + image_file_name; - stbi_write_png(file_path.data(), thumbnail_width_, thumbnail_height_, 4, - rgba_buffer_, thumbnail_width_ * 4); - return 0; } int Thumbnail::LoadThumbnail( - std::vector>& - recent_connections, + std::vector>& recent_connections, int* width, int* height) { - recent_connections.clear(); + std::lock_guard lock(mutex_); if (width) *width = thumbnail_width_; if (height) *height = thumbnail_height_; + return LoadThumbnailLocked(recent_connections); +} - std::vector image_paths = - FindThumbnailPath(save_path_); - - if (image_paths.size() == 0) { - return -1; - } else { - for (int i = 0; i < image_paths.size(); i++) { - // size_t pos1 = image_paths[i].string().find('/') + 1; - std::string cipher_image_name = image_paths[i].filename().string(); - std::string remote_id; - std::string cipher_password; - std::string remote_host_name; - std::string password; - std::string original_image_name; - bool remember_password = false; - - if (cipher_image_name.size() > 9 && 'Y' == cipher_image_name[9] && - cipher_image_name.size() >= 16) { - size_t pos_y = cipher_image_name.find('Y'); - size_t pos_at = cipher_image_name.find('@'); - - if (pos_y == std::string::npos || pos_at == std::string::npos || - pos_y >= pos_at) { - LOG_ERROR("Invalid filename"); - continue; - } - - remote_id = cipher_image_name.substr(0, pos_y); - remote_host_name = - cipher_image_name.substr(pos_y + 1, pos_at - pos_y - 1); - cipher_password = cipher_image_name.substr(pos_at + 1); - password = AES_decrypt(cipher_password, aes128_key_, aes128_iv_); - remember_password = true; - - original_image_name = remote_id + 'Y' + remote_host_name + "@" + - password; - } else { - size_t pos_n = cipher_image_name.find('N'); - // size_t pos_at = cipher_image_name.find('@'); - - if (pos_n == std::string::npos) { - LOG_ERROR("Invalid filename"); - continue; - } - - remote_id = cipher_image_name.substr(0, pos_n); - remote_host_name = cipher_image_name.substr(pos_n + 1); - - original_image_name = remote_id + 'N' + remote_host_name; - } - - std::string image_path = save_path_ + cipher_image_name; - Thumbnail::RecentConnection recent_connection; - recent_connection.remote_id = remote_id; - recent_connection.remote_host_name = remote_host_name; - recent_connection.password = password; - recent_connection.remember_password = remember_password; - recent_connection.image_path = image_path; - recent_connections.emplace_back( - std::make_pair(original_image_name, recent_connection)); - } - return 0; +int Thumbnail::LoadThumbnailLocked( + std::vector>& recent_connections) { + recent_connections.clear(); + int result = 0; + std::map records; + const auto paths = FindThumbnailPath(save_path_); + for (const auto& path : paths) { + if (path.extension() != ".json") continue; + RecentConnection connection; + if (ReadRecord(path, &connection)) + records.emplace(connection.remote_id, connection); } - return 0; + for (const auto& path : paths) { + const auto filename = path.filename().string(); + if (filename.size() < 10 || !IsRemoteId(filename.substr(0, 9)) || + (filename[9] != 'Y' && filename[9] != 'N')) + continue; + const auto remote_id = filename.substr(0, 9); + if (!records.count(remote_id)) { + RecentConnection connection; + connection.remote_id = remote_id; + connection.remember_password = filename[9] == 'Y'; + const auto separator = filename.find('@', 10); + if (connection.remember_password && separator == std::string::npos) + continue; + connection.remote_host_name = + filename.substr(10, connection.remember_password ? separator - 10 + : std::string::npos); + if (connection.remember_password) { + connection.password = AES_decrypt(filename.substr(separator + 1), + aes128_key_, aes128_iv_); + } + if (!WriteRecord(connection)) { + result = -1; + recent_connections.emplace_back(ConnectionKey(connection), connection); + continue; + } + const auto metadata = + std::filesystem::path(save_path_) / (remote_id + ".json"); + std::filesystem::last_write_time(metadata, + std::filesystem::last_write_time(path)); + records.emplace(remote_id, connection); + } + auto& connection = records.at(remote_id); + if (save_previews_ && connection.image_path.empty()) { + const auto image = + std::filesystem::path(save_path_) / (remote_id + ".png"); + std::error_code error; + std::filesystem::copy_file( + path, image, std::filesystem::copy_options::overwrite_existing, + error); + if (error) { + // Keep the original preview available and retry migration next load. + connection.image_path = path; + result = -1; + continue; + } + connection.image_path = image; + } + std::error_code error; + std::filesystem::remove(path, error); + if (error) result = -1; + } + // Sort by metadata timestamps, unaffected by preview deletion or migration. + for (const auto& path : FindThumbnailPath(save_path_)) { + if (path.extension() == ".json") { + const auto it = records.find(path.stem().string()); + if (it != records.end()) { + recent_connections.emplace_back(ConnectionKey(it->second), it->second); + } + } else if (!save_previews_ && path.extension() == ".png") { + std::error_code error; + std::filesystem::remove(path, error); + if (error) result = -1; + } + } + return result; } bool Thumbnail::DecodeImage(const std::filesystem::path& image_path, @@ -242,16 +349,17 @@ bool Thumbnail::DecodeImage(const std::filesystem::path& image_path, } int Thumbnail::DeleteThumbnail(const std::string& filename_keyword) { + std::lock_guard lock(mutex_); + 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_)) { - if (entry.is_regular_file()) { - const std::string filename = entry.path().filename().string(); - std::string id_hostname = filename_keyword.substr(0, filename.find('@')); - if (filename.find(id_hostname) != std::string::npos) { - std::filesystem::remove(entry.path()); - } + const auto name = entry.path().filename().string(); + if (entry.is_regular_file() && name.size() > 9 && + name.substr(0, 9) == remote_id && + (name[9] == '.' || name[9] == 'Y' || name[9] == 'N')) { + std::filesystem::remove(entry.path()); } } - return 0; } @@ -280,6 +388,7 @@ std::vector Thumbnail::FindThumbnailPath( } int Thumbnail::DeleteAllFilesInDirectory() { + std::lock_guard lock(mutex_); if (std::filesystem::exists(save_path_) && std::filesystem::is_directory(save_path_)) { for (const auto& entry : std::filesystem::directory_iterator(save_path_)) { diff --git a/apps/desktop/src/thumbnail/thumbnail.h b/apps/desktop/src/thumbnail/thumbnail.h index 5336f2d4..29fc3344 100644 --- a/apps/desktop/src/thumbnail/thumbnail.h +++ b/apps/desktop/src/thumbnail/thumbnail.h @@ -10,9 +10,12 @@ #include #include #include +#include #include #include +#include "remote_action.h" + namespace crossdesk { class Thumbnail { @@ -23,6 +26,7 @@ class Thumbnail { std::string remote_host_name; std::string password; bool remember_password = false; + HostPlatform platform = HostPlatform::Unknown; }; public: @@ -34,10 +38,14 @@ class Thumbnail { public: int SetThumbnailDpiScale(float dpi_scale); - int SaveToThumbnail(const char* yuv420p, int width, int height, + // Serialized with writes so disabling also clears any in-flight preview. + int SetSavePreviews(bool enabled); + + int SaveToThumbnail(const char* nv12, int width, int height, const std::string& remote_id, const std::string& host_name, - const std::string& password); + const std::string& password, + HostPlatform platform = HostPlatform::Unknown); int LoadThumbnail( std::vector>& @@ -69,6 +77,11 @@ class Thumbnail { } private: + int LoadThumbnailLocked( + std::vector>& connections); + bool WriteRecord(const RecentConnection& connection); + bool ReadRecord(const std::filesystem::path& path, RecentConnection* connection); + std::vector FindThumbnailPath( const std::filesystem::path& directory); @@ -79,6 +92,8 @@ class Thumbnail { unsigned char* iv); private: + std::mutex mutex_; + bool save_previews_ = true; int thumbnail_width_ = 160; int thumbnail_height_ = 90; char* rgba_buffer_ = nullptr; diff --git a/apps/desktop/tests/slint_ui_smoke_test.cpp b/apps/desktop/tests/slint_ui_smoke_test.cpp index ce799805..50439f3e 100644 --- a/apps/desktop/tests/slint_ui_smoke_test.cpp +++ b/apps/desktop/tests/slint_ui_smoke_test.cpp @@ -135,12 +135,13 @@ bool ConfigureDocumentationDemo( std::cerr << "Missing documentation thumbnail: " << path << '\n'; return false; } - crossdesk::ui::RecentConnection connection; + crossdesk::ui::RecentConnection connection{}; connection.remote_id = device.id; connection.display_name = device.name; connection.host_name = device.name; connection.online = true; connection.thumbnail = thumbnail; + connection.has_thumbnail = true; connections.emplace_back(std::move(connection)); } window->set_recent_connections( @@ -542,7 +543,7 @@ int main() { assert(std::string(window->get_remote_id_input()).empty()); std::vector connections; - crossdesk::ui::RecentConnection connection; + crossdesk::ui::RecentConnection connection{}; connection.remote_id = "987654321"; connection.display_name = "Office PC"; connection.host_name = "office-pc"; diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 151d245d..5e774d68 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -386,7 +386,7 @@ function setup_targets() target("thumbnail") set_kind("object") - add_packages("libyuv", "openssl3") + add_packages("libyuv", "openssl3", "nlohmann_json") add_deps("rd_log", "desktop_common") add_files("apps/desktop/src/thumbnail/*.cpp") add_includedirs("apps/desktop/src/thumbnail", {public = true})