[feat] integrate IP direct mode with desktop controls and history

This commit is contained in:
dijunkun
2026-10-25 00:54:13 +08:00
parent 2445f79f92
commit ae6f81e83a
28 changed files with 1617 additions and 273 deletions
@@ -68,6 +68,13 @@ int ConfigCenter::Load() {
language_ = static_cast<LANGUAGE>(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<long>(ScreenCaptureMethod::Auto));
@@ -201,6 +208,9 @@ int ConfigCenter::StoreValues(
int ConfigCenter::Save() {
ini_->SetLongValue(section_, "language", static_cast<long>(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<long>(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<long>(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_;
}
@@ -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;
@@ -1162,6 +1162,7 @@ int GuiApplication::Run() {
updater_ = std::make_unique<updater::Controller>(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<SlintUi>();
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<ShutdownSessionGuard>(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<WinTray>(
@@ -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<slint::SharedString> 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<slint::VectorModel<slint::SharedString>>(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<int>(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<slint::SharedString> 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<int>(connection.platform) << '\0'
<< settings_.RecentConnectionDisplayName(connection)
@@ -3072,7 +3296,9 @@ void GuiApplication::SyncMainWindow() {
std::vector<ui::RecentConnection> 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<unsigned char> 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<std::string> 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);
@@ -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 对你有帮助,欢迎赞助支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!", \
@@ -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;
@@ -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<const char *>(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<std::string> 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<const char *>(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 =
@@ -54,6 +54,7 @@ public:
void DestroyFactories();
int InitializeScreenCapturer();
int EnsureCaptureReady();
int StartScreenCapturer();
int StopScreenCapturer();
bool ReleaseRemoteMouseButtons();
@@ -94,13 +94,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> 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<file_transfer::Session>(
[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<FileReceiver> 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;
@@ -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;
}
@@ -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<RemoteSession> 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<RemoteSession> 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<RemoteSession> props) {
if (thumbnail && props->was_connected_) {
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
? reinterpret_cast<const char*>(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<RemoteSession> props) {
}
void GuiRuntime::CloseServerController(const std::string& remote_id) {
if (remote_id.rfind("lan/", 0) == 0) {
std::shared_ptr<lan::Session> 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<std::string> 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::RemoteSession> 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;
@@ -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);
+14 -4
View File
@@ -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;
}
@@ -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<int(PeerPtr*, const std::string&)>& operation);
int SendControllerData(const std::string& id, const char* data, size_t size,
const char* label);
std::shared_ptr<PeerEventHandler> ControllerEvents(const std::string& id);
std::vector<std::string> ConnectedControllers();
std::unique_ptr<lan::SessionManager> lan_;
std::string lan_error_;
std::shared_ptr<lan::Session> lan_connecting_;
bool lan_stopping_ = false;
bool stopping_ = false;
std::chrono::steady_clock::time_point shutdown_report_at_{};
struct LanController {
std::shared_ptr<lan::Session> session;
std::shared_ptr<PeerEventHandler> events;
};
// Protected by remote_sessions_mutex_; snapshots acquire callback leases
// before releasing that lock, so removal cannot race a raw Peer use.
std::map<std::string, LanController> lan_controllers_;
int CreateConnectionPeer();
int ConnectTo(const std::string& remote_id, const char* password,
bool remember_password, bool bypass_presence_check = false,
@@ -0,0 +1,236 @@
#include <display_stream_id.h>
#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<lan::Session>& 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<PeerEventHandler>(*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(&params);
events->SetDirectPeer(result.peer);
if (result.peer) RegisterStreams(result.peer, displays);
return result;
};
};
hooks.attached = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(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<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(session->callback_owner);
events->ApplyConnectionStatus(ConnectionStatus::Connected, session->key);
events->FlushEarlyData();
};
hooks.closed = [this](const std::shared_ptr<lan::Session>& session) {
auto events =
std::static_pointer_cast<PeerEventHandler>(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<lan::SessionManager>(
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<RemoteSession>();
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<PeerEventHandler> 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<int(PeerPtr*, const std::string&)>& 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<std::string> GuiRuntime::ConnectedControllers() {
std::vector<std::string> 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
@@ -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<std::string> 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<PeerEventHandler *>(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<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
subscription->EncoderPreference())) == 0;
bound_peer, wire_id.data(), wire_id.size(),
static_cast<VideoQuality>(subscription->quality),
subscription->frame_rate,
static_cast<VideoDegradationPreference>(
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<VideoQuality>(settings.quality), settings.frame_rate,
static_cast<VideoDegradationPreference>(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)
@@ -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<PeerEventHandler*>(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<PeerEventHandler*>(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;
}
@@ -8,7 +8,10 @@
#define _PEER_EVENT_HANDLER_H_
#include <cstddef>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#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<lan::Session>& 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<lan::Session> direct_;
bool direct_bound_ = false;
PeerPtr* direct_peer_ = nullptr;
std::mutex early_data_mutex_;
std::vector<std::string> early_data_;
size_t early_data_bytes_ = 0;
GuiRuntime &owner_;
GuiRuntime& owner_;
gui_detail::SessionCallbackGate callbacks_;
};
} // namespace crossdesk
#endif
#endif
@@ -38,20 +38,21 @@ void PeerEventHandler::OnReceiveVideoBuffer(
auto* handler = static_cast<PeerEventHandler*>(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<PeerEventHandler*>(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;
@@ -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);
@@ -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<unsigned char> gallery_nv12_, gallery_scaled_, gallery_scratch_;
std::shared_ptr<lan::Session> lan_session_;
Params params_;
PeerPtr* peer_ = nullptr;
std::shared_ptr<PeerEventHandler> peer_events_;
@@ -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;
@@ -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:
+28 -10
View File
@@ -7,10 +7,19 @@ export component GroupFilter inherits Rectangle {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
// An embedded picker shares its enclosing field's border and focus ring.
in property <bool> embedded: false;
in property <bool> show-indicator: true;
in property <length> popup-min-width: 0px;
in property <length> text-size: 12px;
in property <int> text-weight: 400;
in property <color> text-color: #30343b;
in property <color> normal-border-color: #c9cdd2;
in property <color> hover-border-color: root.normal-border-color;
in property <color> focus-border-color: #8db5df;
in-out property <int> current-index: 0;
out property <bool> has-focus: trigger-focus.has-focus || popup.is-open;
out property <bool> has-hover: root.enabled && trigger-touch.has-hover && !popup.is-open;
callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
private property <length> 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 {
+47
View File
@@ -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;
}
}
+534 -64
View File
@@ -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 <length> wayland-corner-radius: 12px;
in property <string> local-id: "";
in property <string> local-password: "";
in property <bool> 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 <bool> lan-open;
changed lan-open => {
if !root.lan-open { root.lan-access-password = ""; }
if root.lan-open { root.gallery-action("release", ""); }
}
in-out property <bool> 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 <string> id-connection-draft;
private property <string> ip-connection-draft;
private property <bool> lan-password-visible: true;
private property <bool> active-password-visible: root.lan-mode ? root.lan-password-visible : root.password-visible;
private property <string> 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 <bool> lan-listening;
in-out property <string> lan-listener-ip;
in-out property <string> lan-listener-port: "52888";
out property <string> lan-listener-address: root.lan-listener-ip == "" ? "" : root.lan-listener-ip + ":" + root.lan-listener-port;
in-out property <[string]> lan-interfaces;
in-out property <int> lan-interface-index: 0;
in-out property <int> lan-interface-draft-index: 0;
in-out property <string> lan-local-port: "52888";
in-out property <bool> lan-port-editing;
in-out property <bool> lan-port-dirty;
in-out property <string> lan-endpoint-error;
in-out property <string> lan-access-warning;
in-out property <string> lan-access-warning-endpoint;
in-out property <string> 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 <bool> 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 <string> lan-remote-address;
in-out property <string> lan-remote-port: "52888";
in-out property <bool> lan-password-pending;
in-out property <bool> lan-connecting;
in-out property <int> lan-retry-after;
in-out property <string> lan-access-password;
in-out property <bool> lan-remember-password;
in-out property <string> lan-new-password;
in-out property <string> 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 <bool> prompt-password-required: root.lan-open || root.connection-password-required;
private property <bool> prompt-validating: root.lan-open ? root.lan-connecting : root.connection-validating;
private property <bool> prompt-pending: root.lan-open ? root.lan-connecting : root.connection-pending;
private property <string> 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 <string> remote-id-input: "";
in property <bool> password-visible: true;
in property <bool> password-change-pending: false;
@@ -456,6 +625,8 @@ export component MainWindow inherits Window {
in-out property <int> capture-method-index: 0;
in-out property <int> codec-index: 0;
in-out property <bool> turn-enabled: true;
in-out property <bool> ip-direct-enabled-draft: true;
in property <string> settings-save-error;
in-out property <bool> 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 <bool> 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 <bool> update-confirm-open: false;
in-out property <bool> reset-password-open: false;
changed reset-password-open => {
root.new-password-input = "";
root.reset-password-invalid = false;
}
in-out property <bool> reset-password-invalid: false;
in-out property <string> new-password-input: "";
in-out property <bool> 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 <float> 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 <string> 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 <bool> 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 <bool> available: connection.online || connection.connected;
private property <bool> available: connection.lan || connection.online || connection.connected;
private property <length> content-width: self.width;
private property <length> scroll-offset: recent-scroll.viewport-x;
private property <string> 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 <string> device-name: root.gallery-mode ? gallery.hovered-device.name : root.recent-tooltip-name;
private property <string> host-name: root.gallery-mode ? gallery.hovered-device.host-name : root.recent-tooltip-host;
private property <string> 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(); }
}
}
}
}
}
}
}
@@ -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
}
+68 -20
View File
@@ -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;
+9 -3
View File
@@ -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<std::pair<std::string, Thumbnail::RecentConnection>>&