[feat] add desktop preview settings and platform placeholders

This commit is contained in:
dijunkun
2026-09-29 00:22:29 +08:00
parent 6a71a3173a
commit a83b6badd0
16 changed files with 342 additions and 123 deletions
+1
View File
@@ -147,6 +147,7 @@ sudo apt install "./crossdesk-linux-amd64-<version>.deb"
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 | | 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 | | 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 | | 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
| 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 | | 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 |
| 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 | | 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 |
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 | | 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
+1
View File
@@ -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 | | 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 | | 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 | | 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 | | 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 | | 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 | | Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
@@ -125,6 +125,8 @@ int ConfigCenter::Load() {
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_); enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
enable_privacy_screen_.store( enable_privacy_screen_.store(
ini_->GetBoolValue(section_, "enable_privacy_screen", true)); ini_->GetBoolValue(section_, "enable_privacy_screen", true));
save_remote_previews_.store(
ini_->GetBoolValue(section_, "save_remote_previews", true));
portable_service_prompt_suppressed_ = portable_service_prompt_suppressed_ =
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed", ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
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_daemon", enable_daemon_);
ini_->SetBoolValue(section_, "enable_privacy_screen", ini_->SetBoolValue(section_, "enable_privacy_screen",
enable_privacy_screen_.load()); enable_privacy_screen_.load());
ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load());
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_); portable_service_prompt_suppressed_);
@@ -344,6 +348,18 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
return 0; 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) { int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
portable_service_prompt_suppressed_ = suppressed; portable_service_prompt_suppressed_ = suppressed;
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed", ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
@@ -46,6 +46,7 @@ class ConfigCenter {
int SetAutostart(bool enable_autostart); int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon); int SetDaemon(bool enable_daemon);
int SetPrivacyScreen(bool enable_privacy_screen); int SetPrivacyScreen(bool enable_privacy_screen);
int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed); int SetPortableServicePromptSuppressed(bool suppressed);
int SetFileTransferSavePath(const std::string& path); int SetFileTransferSavePath(const std::string& path);
@@ -69,6 +70,7 @@ class ConfigCenter {
bool IsEnableAutostart() const; bool IsEnableAutostart() const;
bool IsEnableDaemon() const; bool IsEnableDaemon() const;
bool IsEnablePrivacyScreen() const; bool IsEnablePrivacyScreen() const;
bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const; bool IsPortableServicePromptSuppressed() const;
std::string GetFileTransferSavePath() const; std::string GetFileTransferSavePath() const;
@@ -97,6 +99,7 @@ class ConfigCenter {
bool enable_autostart_ = false; bool enable_autostart_ = false;
bool enable_daemon_ = false; bool enable_daemon_ = false;
std::atomic<bool> enable_privacy_screen_{true}; std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false; bool portable_service_prompt_suppressed_ = false;
std::string file_transfer_save_path_ = ""; std::string file_transfer_save_path_ = "";
}; };
@@ -1037,6 +1037,9 @@ void GuiApplication::InitializeLogger() { InitLogger(exec_log_path_); }
void GuiApplication::InitializeSettings() { void GuiApplication::InitializeSettings() {
settings_.Load(); settings_.Load();
if (thumbnail_->SetSavePreviews(config_center_->IsSaveRemotePreviews()) != 0) {
LOG_ERROR("Failed to clear saved remote previews");
}
settings_.LoadRecentConnectionAliases(); settings_.LoadRecentConnectionAliases();
localization_language_index_ = localization_language_index_ =
localization::detail::ClampLanguageIndex(language_button_value_); 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_self_hosted_enabled(enable_self_hosted_);
ui_->main->set_autostart_enabled(enable_autostart_); ui_->main->set_autostart_enabled(enable_autostart_);
ui_->main->set_daemon_enabled(enable_daemon_); ui_->main->set_daemon_enabled(enable_daemon_);
ui_->main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
ui_->main->set_privacy_on_connect_enabled( ui_->main->set_privacy_on_connect_enabled(
config_center_->IsEnablePrivacyScreen()); config_center_->IsEnablePrivacyScreen());
#ifdef _WIN32 #ifdef _WIN32
@@ -2334,9 +2338,11 @@ void GuiApplication::SyncMainWindow() {
} }
std::ostringstream signature_builder; std::ostringstream signature_builder;
signature_builder << config_center_->IsSaveRemotePreviews() << '\n';
for (const auto& [key, connection] : recent_connections_) { for (const auto& [key, connection] : recent_connections_) {
signature_builder << key << '\0' << connection.remote_id << '\0' signature_builder << key << '\0' << connection.remote_id << '\0'
<< connection.remote_host_name << '\0' << connection.remote_host_name << '\0'
<< static_cast<int>(connection.platform) << '\0'
<< settings_.RecentConnectionDisplayName(connection) << settings_.RecentConnectionDisplayName(connection)
<< '\0' << '\0'
<< device_presence_cache_.IsOnline(connection.remote_id) << device_presence_cache_.IsOnline(connection.remote_id)
@@ -2351,22 +2357,27 @@ void GuiApplication::SyncMainWindow() {
std::vector<ui::RecentConnection> model; std::vector<ui::RecentConnection> model;
model.reserve(recent_connections_.size()); model.reserve(recent_connections_.size());
for (const auto& [_, connection] : recent_connections_) { 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.remote_id = UiText(connection.remote_id);
item.host_name = UiText(connection.remote_host_name); item.host_name = UiText(connection.remote_host_name);
item.display_name = item.display_name =
UiText(settings_.RecentConnectionDisplayName(connection)); UiText(settings_.RecentConnectionDisplayName(connection));
item.online = device_presence_cache_.IsOnline(connection.remote_id); item.online = device_presence_cache_.IsOnline(connection.remote_id);
item.platform = UiText(HostPlatformName(connection.platform));
std::vector<unsigned char> rgba; std::vector<unsigned char> rgba;
int image_width = 0; int image_width = 0;
int image_height = 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, thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width,
&image_height)) { &image_height)) {
slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width, slint::SharedPixelBuffer<slint::Rgba8Pixel> pixels(image_width,
image_height); image_height);
std::memcpy(pixels.begin(), rgba.data(), rgba.size()); std::memcpy(pixels.begin(), rgba.data(), rgba.size());
item.thumbnail = slint::Image(std::move(pixels)); item.thumbnail = slint::Image(std::move(pixels));
item.has_thumbnail = true;
} }
model.push_back(std::move(item)); model.push_back(std::move(item));
} }
@@ -3259,6 +3270,13 @@ void GuiApplication::SaveSettingsFromUi() {
main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_force_relay_enabled(config_center_->IsForceRelay());
config_center_->SetAutostart(enable_autostart_); config_center_->SetAutostart(enable_autostart_);
config_center_->SetDaemon(enable_daemon_); 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 #ifdef _WIN32
if (!HasActiveSession()) { if (!HasActiveSession()) {
const auto capture_method = static_cast<ScreenCaptureMethod>( const auto capture_method = static_cast<ScreenCaptureMethod>(
@@ -79,6 +79,8 @@ struct TranslationRow {
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \ X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \ X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", 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_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \ X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \ X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \
@@ -236,7 +236,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
std::shared_ptr<std::vector<unsigned char>> frame_snapshot; std::shared_ptr<std::vector<unsigned char>> frame_snapshot;
int video_width = 0; int video_width = 0;
int video_height = 0; int video_height = 0;
{ const bool save_preview = config_center_->IsSaveRemotePreviews();
if (save_preview) {
std::lock_guard<std::mutex> lock(props->video_frame_mutex_); std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
frame_snapshot = props->front_frame_; frame_snapshot = props->front_frame_;
video_width = props->video_width_; video_width = props->video_width_;
@@ -249,7 +250,8 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
} }
} }
auto* native_renderer = video_renderer_.get(); 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<std::vector<unsigned char>>(); auto native_snapshot = std::make_shared<std::vector<unsigned char>>();
if (native_renderer->CopyLatestNv12(props->remote_id_, if (native_renderer->CopyLatestNv12(props->remote_id_,
native_snapshot.get(), &video_width, native_snapshot.get(), &video_width,
@@ -269,9 +271,14 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
std::unique_lock lock(remote_sessions_mutex_); std::unique_lock lock(remote_sessions_mutex_);
peer = std::exchange(props->peer_, nullptr); 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(); const auto queued_at = std::chrono::steady_clock::now();
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask( 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 { thumbnail = thumbnail_, queued_at]() mutable {
if (peer) { if (peer) {
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_, LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
@@ -279,12 +286,16 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
LeaveConnection(peer, props->remote_id_.c_str()); LeaveConnection(peer, props->remote_id_.c_str());
DestroyPeer(&peer); DestroyPeer(&peer);
} }
if (thumbnail && frame_snapshot && !frame_snapshot->empty() && if (thumbnail && props->was_connected_) {
video_width > 0 && video_height > 0) { const char* nv12 = frame_snapshot && !frame_snapshot->empty()
thumbnail->SaveToThumbnail( ? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
reinterpret_cast<char*>(frame_snapshot->data()), video_width, if (thumbnail->SaveToThumbnail(
video_height, props->remote_id_, props->remote_host_name_, nv12, video_width, video_height, props->remote_id_,
props->remember_password_ ? props->remote_password_ : ""); 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::milliseconds>( const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - queued_at).count(); std::chrono::steady_clock::now() - queued_at).count();
@@ -323,6 +323,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
std::lock_guard lock(props->remote_version_mutex_); std::lock_guard lock(props->remote_version_mutex_);
props->remote_host_info_received_ = true; props->remote_host_info_received_ = true;
props->remote_platform_ = remote_action.i.platform; props->remote_platform_ = remote_action.i.platform;
props->recent_platform_ = remote_action.i.platform;
props->remote_app_version_ = IsValidAppVersion(remote_action.i.app_version) props->remote_app_version_ = IsValidAppVersion(remote_action.i.app_version)
? remote_action.i.app_version : ""; ? remote_action.i.app_version : "";
} }
@@ -295,6 +295,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->need_to_create_stream_window_ = true; runtime->need_to_create_stream_window_ = true;
} }
props->connection_established_ = true; props->connection_established_ = true;
props->was_connected_ = true;
runtime->start_keyboard_capturer_ = true; runtime->start_keyboard_capturer_ = true;
break; break;
} }
@@ -336,7 +337,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
needs_native_snapshot = needs_native_snapshot =
!props->thumbnail_frame_ || props->thumbnail_frame_->empty(); !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* native_renderer = runtime->video_renderer_.get();
auto snapshot = std::make_shared<std::vector<unsigned char>>(); auto snapshot = std::make_shared<std::vector<unsigned char>>();
if (native_renderer && if (native_renderer &&
@@ -87,6 +87,7 @@ struct RemoteSession {
bool signal_connected_ = false; bool signal_connected_ = false;
SignalStatus signal_status_ = SignalStatus::SignalClosed; SignalStatus signal_status_ = SignalStatus::SignalClosed;
bool connection_established_ = false; bool connection_established_ = false;
std::atomic<bool> was_connected_{false};
bool rejoin_ = false; bool rejoin_ = false;
bool net_traffic_stats_button_pressed_ = false; bool net_traffic_stats_button_pressed_ = false;
bool enable_mouse_control_ = true; bool enable_mouse_control_ = true;
@@ -130,6 +131,8 @@ struct RemoteSession {
bool remote_host_info_received_ = false; bool remote_host_info_received_ = false;
std::string remote_app_version_; std::string remote_app_version_;
HostPlatform remote_platform_ = HostPlatform::Unknown; 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_update_dismissed_ = false;
bool remote_service_status_received_ = false; bool remote_service_status_received_ = false;
std::mutex video_settings_mutex_; std::mutex video_settings_mutex_;
+37 -3
View File
@@ -7,6 +7,8 @@ export struct RecentConnection {
host-name: string, host-name: string,
online: bool, online: bool,
thumbnail: image, thumbnail: image,
has-thumbnail: bool,
platform: string,
} }
export struct ReleaseNoteBlock { export struct ReleaseNoteBlock {
@@ -49,6 +51,8 @@ export global UiStrings {
in-out property <string> self-hosted: "Self-hosted server"; in-out property <string> self-hosted: "Self-hosted server";
in-out property <string> autostart: "Start with system"; in-out property <string> autostart: "Start with system";
in-out property <string> daemon: "Run in background"; in-out property <string> daemon: "Run in background";
in-out property <string> save-remote-previews: "Save remote screen previews";
in-out property <string> unknown-platform: "Unknown platform";
in-out property <string> privacy-on-connect: "Privacy screen on connect"; in-out property <string> privacy-on-connect: "Privacy screen on connect";
in-out property <string> file-save-path: "File save path"; in-out property <string> file-save-path: "File save path";
in-out property <string> default-desktop: "Desktop"; in-out property <string> default-desktop: "Desktop";
@@ -306,6 +310,7 @@ export component MainWindow inherits Window {
in-out property <bool> self-hosted-enabled: false; in-out property <bool> self-hosted-enabled: false;
in-out property <bool> autostart-enabled: false; in-out property <bool> autostart-enabled: false;
in-out property <bool> daemon-enabled: false; in-out property <bool> daemon-enabled: false;
in-out property <bool> save-remote-previews: true;
// Show the preference on every platform; only adapted hosts can change it. // Show the preference on every platform; only adapted hosts can change it.
in property <bool> privacy-setting-available: false; in property <bool> privacy-setting-available: false;
in-out property <bool> privacy-on-connect-enabled: true; in-out property <bool> privacy-on-connect-enabled: true;
@@ -864,13 +869,37 @@ export component MainWindow inherits Window {
private property <length> toolbar-width: 79px; private property <length> toolbar-width: 79px;
private property <length> toolbar-right-padding: 3px; private property <length> toolbar-right-padding: 3px;
Image { Rectangle {
x: 7px; x: 7px;
y: 7px; y: 7px;
width: connection-card.content-width; width: connection-card.content-width;
height: 84px; height: 84px;
source: connection.thumbnail; background: connection.platform == "windows" ? @linear-gradient(135deg, #1f61b3, #337ac4) :
image-fit: cover; 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 { Rectangle {
x: 7px; 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; } 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; } 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 { Rectangle {
height: 30px; 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; } 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; }
@@ -65,6 +65,8 @@ inline int ApplyMainWindowStrings(
Text(localization::self_hosted_server_config[language])); Text(localization::self_hosted_server_config[language]));
strings.set_autostart(Text(localization::enable_autostart[language])); strings.set_autostart(Text(localization::enable_autostart[language]));
strings.set_daemon(Text(localization::enable_daemon[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_privacy_on_connect(Text(localization::privacy_on_connect[language]));
strings.set_file_save_path( strings.set_file_save_path(
Text(localization::file_transfer_save_path[language])); Text(localization::file_transfer_save_path[language]));
+212 -103
View File
@@ -1,11 +1,17 @@
#include "thumbnail.h" #include "thumbnail.h"
#ifdef _WIN32
#include <windows.h>
#endif
#include <openssl/aes.h> #include <openssl/aes.h>
#include <openssl/crypto.h> #include <openssl/crypto.h>
#include <openssl/evp.h> #include <openssl/evp.h>
#include <openssl/rand.h> #include <openssl/rand.h>
#include <algorithm>
#include <chrono> #include <chrono>
#include <nlohmann/json.hpp>
#include <fstream> #include <fstream>
#include <iomanip> #include <iomanip>
#include <iostream> #include <iostream>
@@ -104,116 +110,217 @@ int Thumbnail::SetThumbnailDpiScale(float dpi_scale) {
return 0; 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<std::pair<std::string, RecentConnection>> connections;
return LoadThumbnailLocked(connections);
}
int Thumbnail::SaveToThumbnail(const char* nv12, int width, int height,
const std::string& remote_id, const std::string& remote_id,
const std::string& host_name, const std::string& host_name,
const std::string& password) { const std::string& password,
if (!rgba_buffer_) { HostPlatform platform) {
rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4]; 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) { const auto image = std::filesystem::path(save_path_) / (remote_id + ".png");
ScaleNv12ToABGR((char*)yuv420p, width, height, thumbnail_width_, if (save_previews_ && nv12 && width > 0 && height > 0) {
thumbnail_height_, rgba_buffer_); if (!rgba_buffer_) {
} else { rgba_buffer_ = new char[thumbnail_width_ * thumbnail_height_ * 4];
// 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<char>(0xFF);
} }
ScaleNv12ToABGR(const_cast<char*>(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; return 0;
} }
int Thumbnail::LoadThumbnail( int Thumbnail::LoadThumbnail(
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>& std::vector<std::pair<std::string, RecentConnection>>& recent_connections,
recent_connections,
int* width, int* height) { int* width, int* height) {
recent_connections.clear(); std::lock_guard lock(mutex_);
if (width) *width = thumbnail_width_; if (width) *width = thumbnail_width_;
if (height) *height = thumbnail_height_; if (height) *height = thumbnail_height_;
return LoadThumbnailLocked(recent_connections);
}
std::vector<std::filesystem::path> image_paths = int Thumbnail::LoadThumbnailLocked(
FindThumbnailPath(save_path_); std::vector<std::pair<std::string, RecentConnection>>& recent_connections) {
recent_connections.clear();
if (image_paths.size() == 0) { int result = 0;
return -1; std::map<std::string, RecentConnection> records;
} else { const auto paths = FindThumbnailPath(save_path_);
for (int i = 0; i < image_paths.size(); i++) { for (const auto& path : paths) {
// size_t pos1 = image_paths[i].string().find('/') + 1; if (path.extension() != ".json") continue;
std::string cipher_image_name = image_paths[i].filename().string(); RecentConnection connection;
std::string remote_id; if (ReadRecord(path, &connection))
std::string cipher_password; records.emplace(connection.remote_id, connection);
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;
} }
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, 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) { 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_)) { for (const auto& entry : std::filesystem::directory_iterator(save_path_)) {
if (entry.is_regular_file()) { const auto name = entry.path().filename().string();
const std::string filename = entry.path().filename().string(); if (entry.is_regular_file() && name.size() > 9 &&
std::string id_hostname = filename_keyword.substr(0, filename.find('@')); name.substr(0, 9) == remote_id &&
if (filename.find(id_hostname) != std::string::npos) { (name[9] == '.' || name[9] == 'Y' || name[9] == 'N')) {
std::filesystem::remove(entry.path()); std::filesystem::remove(entry.path());
}
} }
} }
return 0; return 0;
} }
@@ -280,6 +388,7 @@ std::vector<std::filesystem::path> Thumbnail::FindThumbnailPath(
} }
int Thumbnail::DeleteAllFilesInDirectory() { int Thumbnail::DeleteAllFilesInDirectory() {
std::lock_guard lock(mutex_);
if (std::filesystem::exists(save_path_) && if (std::filesystem::exists(save_path_) &&
std::filesystem::is_directory(save_path_)) { std::filesystem::is_directory(save_path_)) {
for (const auto& entry : std::filesystem::directory_iterator(save_path_)) { for (const auto& entry : std::filesystem::directory_iterator(save_path_)) {
+17 -2
View File
@@ -10,9 +10,12 @@
#include <cstring> #include <cstring>
#include <filesystem> #include <filesystem>
#include <map> #include <map>
#include <mutex>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include "remote_action.h"
namespace crossdesk { namespace crossdesk {
class Thumbnail { class Thumbnail {
@@ -23,6 +26,7 @@ class Thumbnail {
std::string remote_host_name; std::string remote_host_name;
std::string password; std::string password;
bool remember_password = false; bool remember_password = false;
HostPlatform platform = HostPlatform::Unknown;
}; };
public: public:
@@ -34,10 +38,14 @@ class Thumbnail {
public: public:
int SetThumbnailDpiScale(float dpi_scale); 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& remote_id,
const std::string& host_name, const std::string& host_name,
const std::string& password); const std::string& password,
HostPlatform platform = HostPlatform::Unknown);
int LoadThumbnail( int LoadThumbnail(
std::vector<std::pair<std::string, Thumbnail::RecentConnection>>& std::vector<std::pair<std::string, Thumbnail::RecentConnection>>&
@@ -69,6 +77,11 @@ class Thumbnail {
} }
private: private:
int LoadThumbnailLocked(
std::vector<std::pair<std::string, RecentConnection>>& connections);
bool WriteRecord(const RecentConnection& connection);
bool ReadRecord(const std::filesystem::path& path, RecentConnection* connection);
std::vector<std::filesystem::path> FindThumbnailPath( std::vector<std::filesystem::path> FindThumbnailPath(
const std::filesystem::path& directory); const std::filesystem::path& directory);
@@ -79,6 +92,8 @@ class Thumbnail {
unsigned char* iv); unsigned char* iv);
private: private:
std::mutex mutex_;
bool save_previews_ = true;
int thumbnail_width_ = 160; int thumbnail_width_ = 160;
int thumbnail_height_ = 90; int thumbnail_height_ = 90;
char* rgba_buffer_ = nullptr; char* rgba_buffer_ = nullptr;
+3 -2
View File
@@ -135,12 +135,13 @@ bool ConfigureDocumentationDemo(
std::cerr << "Missing documentation thumbnail: " << path << '\n'; std::cerr << "Missing documentation thumbnail: " << path << '\n';
return false; return false;
} }
crossdesk::ui::RecentConnection connection; crossdesk::ui::RecentConnection connection{};
connection.remote_id = device.id; connection.remote_id = device.id;
connection.display_name = device.name; connection.display_name = device.name;
connection.host_name = device.name; connection.host_name = device.name;
connection.online = true; connection.online = true;
connection.thumbnail = thumbnail; connection.thumbnail = thumbnail;
connection.has_thumbnail = true;
connections.emplace_back(std::move(connection)); connections.emplace_back(std::move(connection));
} }
window->set_recent_connections( window->set_recent_connections(
@@ -542,7 +543,7 @@ int main() {
assert(std::string(window->get_remote_id_input()).empty()); assert(std::string(window->get_remote_id_input()).empty());
std::vector<crossdesk::ui::RecentConnection> connections; std::vector<crossdesk::ui::RecentConnection> connections;
crossdesk::ui::RecentConnection connection; crossdesk::ui::RecentConnection connection{};
connection.remote_id = "987654321"; connection.remote_id = "987654321";
connection.display_name = "Office PC"; connection.display_name = "Office PC";
connection.host_name = "office-pc"; connection.host_name = "office-pc";
+1 -1
View File
@@ -386,7 +386,7 @@ function setup_targets()
target("thumbnail") target("thumbnail")
set_kind("object") set_kind("object")
add_packages("libyuv", "openssl3") add_packages("libyuv", "openssl3", "nlohmann_json")
add_deps("rd_log", "desktop_common") add_deps("rd_log", "desktop_common")
add_files("apps/desktop/src/thumbnail/*.cpp") add_files("apps/desktop/src/thumbnail/*.cpp")
add_includedirs("apps/desktop/src/thumbnail", {public = true}) add_includedirs("apps/desktop/src/thumbnail", {public = true})