mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-25 06:13:57 +08:00
[feat] integrate IP direct mode with desktop controls and history
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(¶ms);
|
||||
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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>>&
|
||||
|
||||
Reference in New Issue
Block a user