#include "application/gui_application.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "crossdesk_ui.h" #include "fa_solid_900.h" #include "features/gallery/gallery_fullscreen_transition.h" #include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_workspace.h" #include "features/gallery/preview_bandwidth.h" #include "localization.h" #include "platform.h" #include "platform/video_renderer.h" #include "rendering/slint_video_presenter.h" #include "rendering/video_geometry.h" #include "runtime/privacy_control_state.h" #if _WIN32 #include #include "platform/windows/gui/slint_backend.h" #include "platform/windows/gui/tray/win_tray.h" #include "platform/windows/gui/window_icons.h" #elif defined(__APPLE__) #include "platform/macos/gui/tray/mac_tray.h" #include "platform/window_drag.h" #elif defined(__linux__) #include #include #include #include #include "platform/linux/gui/cuda_graphics.h" #include "platform/linux/gui/tray/linux_tray.h" #include "platform/linux/headless/headless_console.h" // Xlib defines Status as a macro, which collides with UpdateChecker::Status. #undef Status #endif #include "rd_log.h" #include "server_window_state.h" #include "signal_server_settings.h" #include "signal_server_address.h" #include "ui/announcement_text.h" #include "ui/release_notes_text.h" #include "ui/ui_localization.h" #include "update_checker.h" #include "window_geometry.h" namespace crossdesk { namespace { using namespace std::chrono_literals; std::optional GenerateRandomPassword(std::string_view current) { constexpr std::string_view alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; constexpr auto unbiased_limit = 256 - 256 % alphabet.size(); std::string password; do { password.clear(); while (password.size() < 6) { unsigned char value; if (RAND_bytes(&value, sizeof(value)) != 1) { return std::nullopt; } // Reject the incomplete bucket so every character is equally likely. if (value < unbiased_limit) { password.push_back(alphabet[value % alphabet.size()]); } } } while (password == current || password.find_first_not_of("0123456789") == std::string::npos); return password; } std::string CreatePasswordChangeRequestId(uint64_t sequence) { const auto timestamp = std::chrono::system_clock::now() .time_since_epoch() .count(); std::random_device random; return std::to_string(timestamp) + "-" + std::to_string(random()) + "-" + std::to_string(sequence); } #if defined(__linux__) && !defined(__APPLE__) bool HasNonEmptyEnvironmentVariable(const char* name) { const char* value = std::getenv(name); return value != nullptr && value[0] != '\0'; } class ScopedUnsetEnvironmentVariable { public: explicit ScopedUnsetEnvironmentVariable(const char* name) : name_(name) { if (const char* value = std::getenv(name_)) { original_value_ = value; } unsetenv(name_); } ~ScopedUnsetEnvironmentVariable() { if (original_value_) { setenv(name_, original_value_->c_str(), 1); } else { unsetenv(name_); } } ScopedUnsetEnvironmentVariable(const ScopedUnsetEnvironmentVariable&) = delete; ScopedUnsetEnvironmentVariable& operator=( const ScopedUnsetEnvironmentVariable&) = delete; private: const char* name_; std::optional original_value_; }; ::Window X11GetActiveWindow(Display* display) { if (!display) { return 0; } const ::Window root = DefaultRootWindow(display); const Atom active_window_atom = XInternAtom(display, "_NET_ACTIVE_WINDOW", True); if (active_window_atom != None) { Atom actual_type = None; int actual_format = 0; unsigned long item_count = 0; unsigned long bytes_after = 0; unsigned char* data = nullptr; if (XGetWindowProperty(display, root, active_window_atom, 0, 1, False, XA_WINDOW, &actual_type, &actual_format, &item_count, &bytes_after, &data) == Success && actual_type == XA_WINDOW && actual_format == 32 && item_count == 1 && data) { const ::Window active_window = *reinterpret_cast(data); XFree(data); return active_window; } if (data) { XFree(data); } } ::Window focused_window = 0; int revert_to = RevertToNone; XGetInputFocus(display, &focused_window, &revert_to); return focused_window == None || focused_window == PointerRoot ? 0 : focused_window; } std::optional X11GetWindowProcessId(Display* display, ::Window x11_window) { if (!display || !x11_window) { return std::nullopt; } const Atom process_id_atom = XInternAtom(display, "_NET_WM_PID", True); if (process_id_atom == None) { return std::nullopt; } Atom actual_type = None; int actual_format = 0; unsigned long item_count = 0; unsigned long bytes_after = 0; unsigned char* data = nullptr; const int status = XGetWindowProperty( display, x11_window, process_id_atom, 0, 1, False, XA_CARDINAL, &actual_type, &actual_format, &item_count, &bytes_after, &data); if (status != Success || actual_type != XA_CARDINAL || actual_format != 32 || item_count != 1 || !data) { if (data) { XFree(data); } return std::nullopt; } const auto process_id = *reinterpret_cast(data); XFree(data); return process_id; } bool X11WindowMatches(Display* display, ::Window x11_window, const slint::Window& slint_window) { if (!display || !x11_window) { return false; } XWindowAttributes attributes; if (!XGetWindowAttributes(display, x11_window, &attributes)) { return false; } int root_x = 0; int root_y = 0; ::Window child = 0; if (!XTranslateCoordinates(display, x11_window, DefaultRootWindow(display), 0, 0, &root_x, &root_y, &child)) { return false; } const auto position = slint_window.position(); const auto size = slint_window.size(); constexpr int kGeometryTolerance = 4; return std::abs(root_x - position.x) <= kGeometryTolerance && std::abs(root_y - position.y) <= kGeometryTolerance && std::abs(attributes.width - static_cast(size.width)) <= kGeometryTolerance && std::abs(attributes.height - static_cast(size.height)) <= kGeometryTolerance; } void X11ActivateGalleryWindow(Display* display) { if (!display) return; Atom type = None; int format = 0; unsigned long count = 0, remaining = 0; unsigned char* data = nullptr; const auto root = DefaultRootWindow(display); if (XGetWindowProperty(display, root, XInternAtom(display, "_NET_CLIENT_LIST", False), 0, 4096, False, XA_WINDOW, &type, &format, &count, &remaining, &data) != Success || !data) { if (data) XFree(data); return; } if (type == XA_WINDOW && format == 32) { const auto* windows = reinterpret_cast(data); for (unsigned long i = 0; i < count; ++i) { if (X11GetWindowProcessId(display, windows[i]) != static_cast(getpid())) continue; unsigned char* name = nullptr; unsigned long length = 0; Atom name_type = None; int name_format = 0; XGetWindowProperty(display, windows[i], XInternAtom(display, "_NET_WM_NAME", False), 0, 256, False, XInternAtom(display, "UTF8_STRING", False), &name_type, &name_format, &length, &remaining, &name); const bool matches = name && name_format == 8 && std::string_view(reinterpret_cast(name), length).starts_with("CrossDesk "); if (name) XFree(name); if (!matches) continue; XEvent event{}; event.xclient.type = ClientMessage; event.xclient.window = windows[i]; event.xclient.message_type = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); event.xclient.format = 32; event.xclient.data.l[0] = 1; event.xclient.data.l[1] = CurrentTime; XSendEvent(display, root, False, SubstructureRedirectMask | SubstructureNotifyMask, &event); XFlush(display); break; } } XFree(data); } #endif #if _WIN32 HICON LoadSlintTrayIcon() { HMODULE module = GetModuleHandleW(nullptr); HICON icon = reinterpret_cast( LoadImageW(module, L"IDI_ICON1", IMAGE_ICON, 0, 0, LR_DEFAULTSIZE)); return icon ? icon : LoadIconW(nullptr, IDI_APPLICATION); } #endif slint::SharedString UiText(std::string_view value) { return slint::SharedString(value); } void RegisterFontAwesome(slint::Window& window) { if (const auto error = window.window_handle().register_font_from_data( fa_solid_900_ttf, fa_solid_900_ttf_len)) { LOG_ERROR("Failed to register Font Awesome for Slint window: {}", error->data()); } } std::string FormatPeerId(const char* id) { if (!id) { return {}; } std::string compact(id); if (compact.size() != 9) { return compact; } compact.insert(3, " "); compact.insert(7, " "); return compact; } std::string FormatRemotePeerIdInput(std::string_view input) { std::string digits = gallery::Workspace::NormalizeId(std::string(input)); if (digits.size() > 9 || !std::all_of(digits.begin(), digits.end(), [](unsigned char c) { return c >= '0' && c <= '9'; })) return digits; if (digits.size() > 3) { digits.insert(3, " "); } if (digits.size() > 7) { digits.insert(7, " "); } return digits; } std::string CompactPeerId(std::string id) { return gallery::Workspace::NormalizeId(id); } std::string Trim(std::string value) { const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch) != 0; }); const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) { return std::isspace(ch) != 0; }).base(); if (first >= last) { return {}; } return std::string(first, last); } std::string DecodeDroppedPath(std::string text) { const size_t line_end = text.find_first_of("\r\n"); if (line_end != std::string::npos) { text.resize(line_end); } text = Trim(std::move(text)); if (text.rfind("file://localhost/", 0) == 0) { text.erase(0, 16); text.insert(text.begin(), '/'); } else if (text.rfind("file://", 0) == 0) { text.erase(0, 7); } std::string decoded; decoded.reserve(text.size()); auto hex_value = [](char value) -> int { if (value >= '0' && value <= '9') return value - '0'; if (value >= 'a' && value <= 'f') return value - 'a' + 10; if (value >= 'A' && value <= 'F') return value - 'A' + 10; return -1; }; for (size_t i = 0; i < text.size(); ++i) { if (text[i] == '%' && i + 2 < text.size()) { const int high = hex_value(text[i + 1]); const int low = hex_value(text[i + 2]); if (high >= 0 && low >= 0) { decoded.push_back(static_cast((high << 4) | low)); i += 2; continue; } } decoded.push_back(text[i]); } #if defined(_WIN32) if (decoded.size() >= 3 && decoded[0] == '/' && std::isalpha(static_cast(decoded[1])) && decoded[2] == ':') { decoded.erase(decoded.begin()); } #endif return decoded; } std::optional ParsePort(const slint::SharedString& text) { const std::string value(text); int port = 0; const auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), port); if (error != std::errc{} || end != value.data() + value.size() || port <= 0 || port > 65535) { return std::nullopt; } return port; } std::string ConnectionStatusText(ConnectionStatus status, int language) { switch (status) { case ConnectionStatus::Connected: return localization::p2p_connected[language]; case ConnectionStatus::Connecting: return localization::p2p_connecting[language]; case ConnectionStatus::Gathering: return localization::p2p_gathering[language]; case ConnectionStatus::Disconnected: return localization::p2p_disconnected[language]; case ConnectionStatus::NoSuchTransmissionId: return localization::no_such_id[language]; case ConnectionStatus::IncorrectPassword: return localization::reinput_password[language]; case ConnectionStatus::Closed: return localization::p2p_closed[language]; case ConnectionStatus::RemoteUnavailable: return localization::device_offline[language]; case ConnectionStatus::Failed: default: return localization::p2p_failed[language]; } } std::string FormatBitrate(uint32_t bits_per_second) { char text[48]; if (bits_per_second < 1000) { std::snprintf(text, sizeof(text), "%u bps", bits_per_second); } else if (bits_per_second < 1000000) { std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000); } else { std::snprintf(text, sizeof(text), "%.1f mbps", bits_per_second / 1000000.0); } return text; } std::string FormatTransferRate(uint32_t bits_per_second) { return bits_per_second == 0 ? std::string{} : FormatBitrate(bits_per_second); } std::string FormatLossRate(float loss_rate) { if (loss_rate < 0.01f) { return "0%"; } char text[24]; std::snprintf(text, sizeof(text), "%.0f%%", loss_rate * 100.0f); return text; } std::string FormatFileSize(uint64_t bytes) { char text[48]; if (bytes < 1024) { std::snprintf(text, sizeof(text), "%llu B", static_cast(bytes)); } else if (bytes < 1024 * 1024) { std::snprintf(text, sizeof(text), "%.2f KB", bytes / 1024.0); } else if (bytes < 1024ULL * 1024ULL * 1024ULL) { std::snprintf(text, sizeof(text), "%.2f MB", bytes / (1024.0 * 1024.0)); } else { std::snprintf(text, sizeof(text), "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)); } return text; } uint32_t DecodeFirstUtf8CodePoint(const std::string& text) { if (text.empty()) { return 0; } const auto* bytes = reinterpret_cast(text.data()); if (bytes[0] < 0x80) { return bytes[0]; } if ((bytes[0] & 0xE0) == 0xC0 && text.size() >= 2) { return ((bytes[0] & 0x1F) << 6) | (bytes[1] & 0x3F); } if ((bytes[0] & 0xF0) == 0xE0 && text.size() >= 3) { return ((bytes[0] & 0x0F) << 12) | ((bytes[1] & 0x3F) << 6) | (bytes[2] & 0x3F); } if ((bytes[0] & 0xF8) == 0xF0 && text.size() >= 4) { return ((bytes[0] & 0x07) << 18) | ((bytes[1] & 0x3F) << 12) | ((bytes[2] & 0x3F) << 6) | (bytes[3] & 0x3F); } return 0; } int SlintKeyToWindowsVk(const std::string& text) { const uint32_t key = DecodeFirstUtf8CodePoint(text); if (key >= 'a' && key <= 'z') { return static_cast(key - 'a' + 'A'); } if ((key >= 'A' && key <= 'Z') || (key >= '0' && key <= '9')) { return static_cast(key); } if (key >= 0xF704 && key <= 0xF71B) { return 0x70 + static_cast(key - 0xF704); } switch (key) { case 0x08: return 0x08; case 0x09: case 0x19: return 0x09; case 0x0A: return 0x0D; case 0x1B: return 0x1B; case 0x7F: return 0x2E; case 0x10: return 0xA0; case 0x15: return 0xA1; case 0x11: return 0xA2; case 0x16: return 0xA3; case 0x12: return 0xA4; case 0x13: return 0xA5; case 0x17: return 0x5B; case 0x18: return 0x5C; case 0x14: return 0x14; case 0x20: return 0x20; case 0xF700: return 0x26; case 0xF701: return 0x28; case 0xF702: return 0x25; case 0xF703: return 0x27; case 0xF727: return 0x2D; case 0xF729: return 0x24; case 0xF72B: return 0x23; case 0xF72C: return 0x21; case 0xF72D: return 0x22; case 0xF72F: return 0x91; case 0xF730: return 0x13; case 0xF731: return 0x2C; case 0xF734: return 0xB2; case 0xF735: return 0x5D; case 0xF748: return 0xA6; case ';': case ':': return 0xBA; case '\'': case '"': return 0xDE; case '`': case '~': return 0xC0; case ',': case '<': return 0xBC; case '.': case '>': return 0xBE; case '/': case '?': return 0xBF; case '\\': case '|': return 0xDC; case '[': case '{': return 0xDB; case ']': case '}': return 0xDD; case '-': case '_': return 0xBD; case '=': case '+': return 0xBB; case '!': return '1'; case '@': return '2'; case '#': return '3'; case '$': return '4'; case '%': return '5'; case '^': return '6'; case '&': return '7'; case '*': return '8'; case '(': return '9'; case ')': return '0'; default: return -1; } } struct WindowDragState { bool active = false; bool use_global_pointer = false; float pointer_start_x = 0; float pointer_start_y = 0; float last_local_x = 0; float last_local_y = 0; float global_to_physical_scale = 1; slint::PhysicalPosition window_start; slint::PhysicalPosition last_target; #if defined(__linux__) && !defined(__APPLE__) std::unique_ptr x11_display{nullptr, XCloseDisplay}; slint::Timer pointer_timer; #endif bool ReadGlobalPointer(float* x, float* y, bool* left_pressed = nullptr) { #if defined(__linux__) && !defined(__APPLE__) const char* driver = SDL_GetCurrentVideoDriver(); if (driver && std::strcmp(driver, "x11") == 0) { // Slint can dispatch motion before SDL pumps its XInput2 events, and // pointer warps do not emit raw motion at all. Query X11 directly so // dragging never depends on SDL's cached global pointer position. if (!x11_display) { x11_display.reset(XOpenDisplay(nullptr)); } if (!x11_display) { return false; } ::Window root, child; int root_x, root_y, local_x, local_y; unsigned int buttons; if (!XQueryPointer(x11_display.get(), DefaultRootWindow(x11_display.get()), &root, &child, &root_x, &root_y, &local_x, &local_y, &buttons)) { return false; } if (left_pressed) *left_pressed = (buttons & Button1Mask) != 0; *x = static_cast(root_x); *y = static_cast(root_y); return true; } #endif const auto buttons = SDL_GetGlobalMouseState(x, y); if (left_pressed) *left_pressed = (buttons & SDL_BUTTON_LMASK) != 0; return true; } }; bool CanUseGlobalPointerPosition() { const char* driver = SDL_GetCurrentVideoDriver(); if (driver == nullptr) { return false; } #if defined(__linux__) && !defined(__APPLE__) // Wayland deliberately does not expose global pointer coordinates or allow // clients to position top-level windows. return std::strcmp(driver, "wayland") != 0; #else return true; #endif } constexpr float kStreamWindowLogicalWidth = 1280.0f; constexpr float kStreamWindowLogicalHeight = 720.0f; constexpr float kWaylandTitlebarLogicalHeight = 32.0f; float StreamWindowLogicalHeight(bool custom_titlebar) { return kStreamWindowLogicalHeight + (custom_titlebar ? kWaylandTitlebarLogicalHeight : 0.0f); } SDL_DisplayID DisplayForSlintWindow(const slint::Window& window) { const auto position = window.position(); const auto size = window.size(); #if defined(__APPLE__) const float scale = std::max(window.scale_factor(), 0.01f); const SDL_Point center{ static_cast( std::lround(position.x / scale + size.width / (2.0f * scale))), static_cast( std::lround(position.y / scale + size.height / (2.0f * scale)))}; #else const SDL_Point center{ position.x + static_cast(size.width / 2), position.y + static_cast(size.height / 2)}; #endif const SDL_DisplayID display = SDL_GetDisplayForPoint(¢er); return display != 0 ? display : SDL_GetPrimaryDisplay(); } #if !defined(__APPLE__) window_geometry::PhysicalSize PhysicalWindowSize(const slint::Window& window, float logical_width, float logical_height) { const auto size = window.size(); if (size.width > 0 && size.height > 0) { return {static_cast(size.width), static_cast(size.height)}; } const float scale = std::max(window.scale_factor(), 0.01f); return {std::max(1, static_cast(std::lround(logical_width * scale))), std::max(1, static_cast(std::lround(logical_height * scale)))}; } window_geometry::PhysicalRect PhysicalDisplayBounds(const SDL_Rect& bounds) { return {bounds.x, bounds.y, bounds.w, bounds.h}; } #endif bool PositionWindowAtCenter(slint::Window& window, const slint::Window& anchor, float logical_width, float logical_height) { if (!CanUseGlobalPointerPosition()) { return false; } const SDL_DisplayID display = DisplayForSlintWindow(anchor); SDL_Rect usable_bounds{}; if (display == 0 || !SDL_GetDisplayUsableBounds(display, &usable_bounds)) { LOG_WARN("Unable to obtain usable display bounds for stream window: {}", SDL_GetError()); return false; } #if defined(__APPLE__) const float target_x = static_cast(usable_bounds.x) + (static_cast(usable_bounds.w) - logical_width) / 2.0f; const float target_y = static_cast(usable_bounds.y) + (static_cast(usable_bounds.h) - logical_height) / 2.0f; window.set_position( slint::LogicalPosition(slint::Point{target_x, target_y})); #else const auto target = window_geometry::CenteredPosition( PhysicalDisplayBounds(usable_bounds), PhysicalWindowSize(window, logical_width, logical_height)); window.set_position(slint::PhysicalPosition( slint::Point{target.x, target.y})); #endif return true; } #if _WIN32 bool ConfigureWindowsServerWindow(slint::Window& window) { const HWND hwnd = window.win32_hwnd(); if (!hwnd) { return false; } LONG_PTR ex_style = GetWindowLongPtr(hwnd, GWL_EXSTYLE); ex_style |= WS_EX_TOOLWINDOW; ex_style &= ~WS_EX_APPWINDOW; SetWindowLongPtr(hwnd, GWL_EXSTYLE, ex_style); return SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED | SWP_NOACTIVATE) != FALSE; } #endif template void DragWindow(WindowHandle& component, int phase, float mouse_x, float mouse_y, WindowDragState& state) { if (phase == 1) { #if defined(__APPLE__) // Let AppKit run the native move loop. This preserves macOS window // snapping, Spaces, multi-display, and high-DPI behavior without manually // updating the window position for every pointer event. if (!StartNativeWindowDrag()) { LOG_WARN("Unable to start native macOS window drag"); } state.active = false; return; #endif auto& window = component->window(); state.active = true; state.last_local_x = mouse_x; state.last_local_y = mouse_y; state.window_start = window.position(); state.last_target = state.window_start; state.use_global_pointer = CanUseGlobalPointerPosition() && state.ReadGlobalPointer(&state.pointer_start_x, &state.pointer_start_y); if (state.use_global_pointer) { #if defined(__APPLE__) // Cocoa reports global pointer positions in logical screen points, // while Slint/winit accepts physical window positions. state.global_to_physical_scale = window.scale_factor(); #else state.global_to_physical_scale = 1.0f; #endif } #if defined(__linux__) && !defined(__APPLE__) if (state.use_global_pointer && state.x11_display) { // Moving the window can leave consecutive pointer events at identical // local coordinates, so Slint need not emit another TouchArea::moved. // Follow the global pointer throughout the drag, including those moves. state.pointer_timer.start( slint::TimerMode::Repeated, std::chrono::milliseconds(16), [weak = slint::ComponentWeakHandle(component), &state] { if (auto window = weak.lock()) { DragWindow(*window, 2, 0, 0, state); } else { state.active = false; state.pointer_timer.stop(); } }); } #endif return; } if (phase == 0) { #if defined(__linux__) && !defined(__APPLE__) state.pointer_timer.stop(); if (state.use_global_pointer && state.x11_display) { DragWindow(component, 2, mouse_x, mouse_y, state); } #endif state.active = false; return; } if (!state.active || phase != 2) { return; } auto& window = component->window(); if (state.use_global_pointer) { float pointer_x = 0; float pointer_y = 0; if (!state.ReadGlobalPointer(&pointer_x, &pointer_y)) { return; } const int delta_x = static_cast(std::lround( (pointer_x - state.pointer_start_x) * state.global_to_physical_scale)); const int delta_y = static_cast(std::lround( (pointer_y - state.pointer_start_y) * state.global_to_physical_scale)); const slint::PhysicalPosition target(slint::Point{ state.window_start.x + delta_x, state.window_start.y + delta_y}); if (target.x != state.last_target.x || target.y != state.last_target.y) { window.set_position(target); state.last_target = target; } return; } // Retain the previous behavior for backends without global pointer access. const float scale = window.scale_factor(); const auto position = window.position(); const int delta_x = static_cast(std::lround((mouse_x - state.last_local_x) * scale)); const int delta_y = static_cast(std::lround((mouse_y - state.last_local_y) * scale)); if (delta_x != 0 || delta_y != 0) { window.set_position(slint::PhysicalPosition( slint::Point{position.x + delta_x, position.y + delta_y})); } state.last_local_x = mouse_x; state.last_local_y = mouse_y; } } // namespace struct GuiApplication::SlintUi { gallery::Workspace gallery; std::filesystem::path gallery_path; std::vector gallery_ids; std::set gallery_hidden_active_ids; struct GroupEditor { std::string id; std::shared_ptr> options = std::make_shared>(); }; GroupEditor group_editors[2]; // Snapshot while the import picker is open; never includes credentials. std::shared_ptr> gallery_recent_model = std::make_shared>(); std::string gallery_focus, gallery_auth_id, gallery_requested_auth, gallery_control_id; std::chrono::steady_clock::time_point gallery_synced{}; std::chrono::steady_clock::time_point gallery_network_synced{}; std::shared_ptr> gallery_network_model = std::make_shared>(); gallery::WindowState connection_window, gallery_window; gallery::FullscreenTransition gallery_transition; int restore_ticks[2] = {}; float loaded_window_scale[2] = {}; bool gallery_shown = false; std::optional observed_gallery_size; struct Preview { std::weak_ptr session; uint64_t sequence = 0; slint::Image image; }; std::unordered_map gallery_previews; struct PendingPreview { std::shared_ptr props; PreviewSubscription demand; bool needs_sync = false; }; std::vector gallery_pending_previews; std::vector gallery_bandwidth_demands; gallery::PreviewBandwidthAllocator gallery_bandwidth_allocator; struct BatchOperation { std::string id, action, file; // Only explicit add-device requests carry credentials; never persisted. std::optional password; bool remember_password = false; enum class State { Queued, Running, AwaitingPassword, Finished }; State state = State::Queued; size_t file_index = 0; std::weak_ptr session; std::chrono::steady_clock::time_point started{}; }; // Transient concurrency control only; completed operations are discarded. std::vector batch_operations; std::string saved_window_signature; std::chrono::steady_clock::time_point window_saved{}; std::shared_ptr> gallery_model = std::make_shared>(); std::shared_ptr> gallery_selected_model = std::make_shared>(); float pointer_x = 0, pointer_y = 0; std::set pressed_mouse_buttons; uint64_t announcement_version = UINT64_MAX; std::string announcement_selected_id, announcement_selected_revision; // Share the window shell and dialogs, but keep each component's UI state and // native window independent. Only the wall instance ever uses gallery mode. slint::ComponentHandle main = ui::MainWindow::create(); slint::ComponentHandle wall = ui::MainWindow::create(); auto& Window(bool gallery) { return gallery ? wall : main; } std::shared_ptr> release_note_blocks_model = std::make_shared>(); std::optional> stream; std::optional> server; std::optional> server_bar; std::shared_ptr> recent_model = std::make_shared>(); std::shared_ptr> tab_model = std::make_shared>(); std::shared_ptr> display_model = std::make_shared>(); std::shared_ptr> transfer_model = std::make_shared>(); std::shared_ptr> stats_model = std::make_shared>(); std::shared_ptr> controller_model = std::make_shared>(); std::unique_ptr video_presenter; std::vector tab_order; std::vector tab_ids; std::string tab_model_signature; std::vector controller_ids; std::string controller_model_signature; std::string connection_dialog_remote_id; bool connection_password_initialized = false; bool connection_dialog_gallery = false; bool portable_service_dialog_initialized = false; std::string recent_model_signature; std::optional recent_group_filter; std::vector recent_filter_groups; slint::Timer timer; slint::Timer server_file_dialog_timer; WindowDragState main_drag, gallery_drag; WindowDragState stream_drag; WindowDragState server_drag; int main_native_titlebar_attempts = 30; int gallery_native_titlebar_attempts = 0; int stream_live_resize_configuration_attempts = 0; int stream_initial_position_attempts = 0; SDL_DisplayID server_display = 0; float server_anchor = 0.3f; float server_drag_anchor = 0.3f; bool server_drag_moved = false; bool server_pointer_pressed = false; bool server_file_dialog_open = false; bool server_suspended_for_dialog = false; server_window_state::EdgeLayout server_layout{}; #if _WIN32 int server_native_window_attempts = 0; #endif int localized_language = -1; std::chrono::steady_clock::time_point last_clipboard_poll{}; slint::Timer video_timer; #if _WIN32 std::unique_ptr tray; #elif defined(__APPLE__) std::unique_ptr tray; #elif defined(__linux__) std::unique_ptr tray; Display* x11_focus_display = nullptr; std::chrono::steady_clock::time_point last_x11_activation_poll{}; #endif ~SlintUi() { #if defined(__linux__) && !defined(__APPLE__) if (x11_focus_display) { XCloseDisplay(x11_focus_display); } #endif } }; GuiApplication::GuiApplication() = default; GuiApplication::~GuiApplication() = default; int GuiApplication::Run() { path_manager_ = std::make_unique("CrossDesk"); if (!path_manager_) { return -1; } exec_log_path_ = path_manager_->GetLogPath().string(); dll_log_path_ = exec_log_path_; InitializeLogger(); cache_path_ = path_manager_->GetCachePath().string(); announcements_.SetStorageDirectory(path_manager_->GetCachePath() / "announcement_reads"); config_center_ = std::make_unique(cache_path_ + "/config.ini"); LOG_INFO("CrossDesk version: {} (Slint UI)", CROSSDESK_VERSION); strncpy(signal_server_ip_self_, config_center_->GetSignalServerHost().c_str(), sizeof(signal_server_ip_self_) - 1); const int signal_port = config_center_->GetSignalServerPort(); if (signal_port > 0 && signal_port <= 65535) { std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%d", signal_port); } InitializeSettings(); #ifdef __linux__ HeadlessConsole::Instance().BindSettings( config_center_.get(), [this] { std::lock_guard lock(password_change_mutex_); return HasActiveSession() || password_change_pending_ || credential_recovery_in_progress_; }, [this](SettingEffect effect) { ApplySettingsFromConfig(effect == SettingEffect::reconnect); }); #endif if (!InitializeSDL()) { return -1; } #if _WIN32 const auto backend = ConfigureWindowsSlintBackend(); if (!backend.success) { LOG_ERROR("{}", backend.diagnostic); SDL_Quit(); return -1; } LOG_INFO("Slint backend: {}; {}", backend.backend, backend.diagnostic); #endif InitializeModules(); update_checker_ = std::make_unique(CROSSDESK_VERSION); InitializeUi(); // Configure the initial geometry before the native window becomes visible. // Subsequent ticks only acknowledge that placement and its resolved DPI. RestoreWindowGeometry(true); ui_->timer.start(slint::TimerMode::Repeated, 16ms, [this] { Tick(); }); ScheduleNextVideoFrame(); // The native tray icon is managed outside Slint, so Slint cannot count it // when deciding whether the last hidden window should stop the event loop. // Keep the loop alive until the tray's explicit Exit action requests quit. ui_->main->show(); #if _WIN32 ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); #elif defined(__APPLE__) ConfigureMainWindowChrome(ui_->main->window().appkit_view()); #endif slint::run_event_loop(slint::EventLoopMode::RunUntilQuit); Cleanup(); return 0; } void GuiApplication::InitializeLogger() { InitLogger(exec_log_path_); } void GuiApplication::InitializeSettings() { settings_.Load(); if (thumbnail_->SetSavePreviews(config_center_->IsSaveRemotePreviews()) != 0) { LOG_ERROR("Failed to clear saved remote previews"); } settings_.LoadRecentConnectionAliases(); localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_); language_button_value_ = localization_language_index_; localization_language_ = static_cast(localization_language_index_); privacy_.SetText({ localization::privacy_screen_unlock_hint[localization_language_index_]}); } bool GuiApplication::InitializeSDL() { #if defined(__linux__) && !defined(__APPLE__) ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec()); // Standard Wayland top-level windows cannot choose an absolute screen // position. Use the session's XWayland compatibility server for the GUI so // auxiliary windows such as the controlled-side status window can be // placed deterministically. Capture and input still detect the surrounding // Wayland session through XDG_SESSION_TYPE and continue to use the portal. const char* requested_video_driver = std::getenv("SDL_VIDEODRIVER"); const bool video_driver_allows_x11 = requested_video_driver == nullptr || requested_video_driver[0] == '\0' || std::strcmp(requested_video_driver, "x11") == 0; use_xwayland_gui_ = IsWaylandSession() && HasNonEmptyEnvironmentVariable("DISPLAY") && video_driver_allows_x11; if (use_xwayland_gui_) { setenv("SDL_VIDEODRIVER", "x11", 1); LOG_INFO( "Wayland session detected; using XWayland for positioned GUI " "windows while retaining Wayland portal capture and input"); } #endif if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) { LOG_ERROR("SDL initialization failed: {}", SDL_GetError()); return false; } #if defined(__linux__) && !defined(__APPLE__) const char* active_video_driver = SDL_GetCurrentVideoDriver(); // X11 permits the manual move/position operations used by the frameless // main window. Keep native Wayland decorations as a compatibility fallback. use_x11_custom_titlebar_ = active_video_driver != nullptr && std::strcmp(active_video_driver, "x11") == 0; #endif if (const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(0)) { screen_width_ = mode->w; screen_height_ = mode->h; } clipboard_.Initialize(); return true; } void GuiApplication::InitializeModules() { if (modules_inited_) { return; } // Renderer construction can log device, shader, or pipeline failures. // Initialize it after Run() has selected the application's log directory. video_renderer_ = CreateVideoRenderer(); devices_.Initialize(); CreateConnectionPeer(); modules_inited_ = true; } void GuiApplication::InitializeUi() { #if defined(__APPLE__) // Slint's renderer must be selected before the first component creates the // process-wide backend. The macOS package contains Skia (Metal) and the // software fallback, but no OpenGL window renderer. const char* requested_slint_backend = std::getenv("SLINT_BACKEND"); if (requested_slint_backend == nullptr || requested_slint_backend[0] == '\0') { if (setenv("SLINT_BACKEND", "winit-skia", 1) == 0) { LOG_INFO("Using Slint winit-skia renderer with the macOS Metal backend"); } else { LOG_WARN("Unable to select the Slint Metal backend through SLINT_BACKEND"); } } else { LOG_INFO("Using requested Slint backend: {}", requested_slint_backend); } #endif #if defined(__linux__) && !defined(__APPLE__) // Detect the desktop session before temporarily hiding Wayland variables // for the XWayland GUI backend below. const bool is_wayland_session = IsWaylandSession(); // Winit prefers Wayland whenever WAYLAND_DISPLAY/WAYLAND_SOCKET is present. // Hide them only while Slint selects its process-wide window backend, then // restore the environment before the rest of the application starts. std::optional wayland_display_guard; std::optional wayland_socket_guard; if (use_xwayland_gui_) { wayland_display_guard.emplace("WAYLAND_DISPLAY"); wayland_socket_guard.emplace("WAYLAND_SOCKET"); } // Match crossdesk.desktop even when the Debian launcher runs crossdesk-bin. // Set this before creating any windows so X11 and Wayland share the same id. slint::set_xdg_app_id("crossdesk"); #endif ui_ = std::make_unique(); ui_->video_presenter = std::make_unique(*video_renderer_); #if _WIN32 ui_->main->set_custom_titlebar(true); #elif defined(__linux__) ui_->main->set_custom_titlebar(use_x11_custom_titlebar_); ui_->main->set_wayland_titlebar(use_xwayland_gui_); // Set this only at startup so dismissing the notice lasts until exit. ui_->main->set_wayland_notice_open(is_wayland_session); #endif ui_->main->set_release_note_blocks(ui_->release_note_blocks_model); RegisterFontAwesome(ui_->main->window()); ui_->main->set_recent_connections(ui_->recent_model); ui_->gallery_path = std::filesystem::path(cache_path_) / "deskhub.json"; if (!ui_->gallery.Load(ui_->gallery_path)) LOG_WARN("Could not load DeskHub workspace; original file retained"); ui_->wall->set_gallery_tiles(ui_->gallery_model); ui_->wall->set_gallery_selected_devices(ui_->gallery_selected_model); ui_->wall->set_gallery_network_devices(ui_->gallery_network_model); ui_->wall->set_gallery_mode(true); ui_->wall->set_custom_titlebar(ui_->main->get_custom_titlebar()); ui_->wall->set_wayland_titlebar(ui_->main->get_wayland_titlebar()); ui_->wall->set_release_note_blocks(ui_->release_note_blocks_model); ui_->wall->set_update_open(false); RegisterFontAwesome(ui_->wall->window()); // The first DeskHub window is 960 x 720 logical pixels including its titlebar. // Custom titlebars are part of the Slint client; AppKit's titlebar is not. auto default_gallery_size = slint::LogicalSize(slint::Size{960.0f, 720.0f}); #if defined(__APPLE__) default_gallery_size.height -= MainWindowInitialPositionYOffset(); #endif ui_->gallery_window.wall_size = default_gallery_size; try { std::ifstream file(ui_->gallery_path.string() + ".window"); if (file) { const auto saved = nlohmann::json::parse(file); const float scale = std::max(ui_->main->window().scale_factor(), 1.0f); ui_->loaded_window_scale[0] = ui_->loaded_window_scale[1] = scale; const int width = std::clamp( saved.value("width", int(default_gallery_size.width)), 640, 7680); const int height = std::clamp( saved.value("height", int(default_gallery_size.height)), 486, 4320); ui_->gallery_window.wall_size = slint::LogicalSize(slint::Size{float(width), float(height)}); ui_->gallery_window.wall_fullscreen = saved.value("fullscreen", false); ui_->gallery_window.wall_maximized = saved.value("maximized", false); #if defined(__APPLE__) // Older state used Slint's cached zoom flag, which could misclassify the // fixed-size connection window as maximized. Keep its geometry/fullscreen // state, but only trust the maximized flag written by the native check. if (saved.value("version", 1) < 2) ui_->gallery_window.wall_maximized = false; #endif if (saved.contains("wall_x")) ui_->gallery_window.wall_position = slint::PhysicalPosition(slint::Point{ int(saved.at("wall_x").get() * scale), int(saved.at("wall_y").get() * scale)}); if (saved.contains("connection_x")) ui_->connection_window.connection_position = slint::PhysicalPosition(slint::Point{ int(saved.at("connection_x").get() * scale), int(saved.at("connection_y").get() * scale)}); const float tile_width = saved.value("tile_width", 216.0f); if (std::isfinite(tile_width)) // The rendered grid constrains the restored width to available space. ui_->wall->set_gallery_tile_width(std::max(tile_width, 184.0f)); const float focus_rail_height = saved.value("focus_rail_height", 70.875f); if (std::isfinite(focus_rail_height)) ui_->wall->set_gallery_focus_rail_height(std::max(focus_rail_height, 54.0f)); ui_->connection_window.BeginRestore(); } } catch (...) { LOG_WARN("Could not restore DeskHub window state"); } ResetSettingsUi(); ResetSettingsUi(true); BindMainCallbacks(); BindMainCallbacks(true); InitializeSystemTray(); UpdateLocalization(); SyncMainWindow(); } void GuiApplication::InitializeSystemTray() { #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) if (!ui_) { return; } auto show_window = [this] { if (!ui_) { return; } ui_->main->show(); #if _WIN32 ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); #endif ui_->main->window().set_minimized(false); #if defined(__APPLE__) ConfigureMainWindowChrome(ui_->main->window().appkit_view()); MacActivateWindow(ui_->main->window().appkit_view()); // A window restored from the tray may only obtain its NSWindow after the // callback returns, so let the regular UI tick configure it. ui_->main_native_titlebar_attempts = 30; #endif }; auto hide_window = [this] { if (ui_) { ui_->main->hide(); } }; auto open_settings = [this, show_window] { if (!ui_) { return; } show_window(); ui_->main->set_settings_open(true); }; auto exit_app = [this] { exit_ = true; slint::quit_event_loop(); }; #if _WIN32 ui_->tray = std::make_unique( std::move(show_window), std::move(hide_window), std::move(open_settings), std::move(exit_app), LoadSlintTrayIcon(), L"CrossDesk", localization_language_index_); #elif defined(__APPLE__) ui_->tray = std::make_unique( std::move(show_window), std::move(hide_window), std::move(open_settings), std::move(exit_app), "CrossDesk", localization_language_index_); #elif defined(__linux__) ui_->tray = std::make_unique( std::move(show_window), std::move(hide_window), std::move(open_settings), std::move(exit_app), "CrossDesk", localization_language_index_); #endif #endif } bool GuiApplication::MinimizeMainWindowToTray() { if (!ui_) { return false; } #if _WIN32 || defined(__APPLE__) if (!ui_->tray) { return false; } ui_->tray->MinimizeToTray(); return true; #elif defined(__linux__) return ui_->tray && ui_->tray->MinimizeToTray(); #else return false; #endif } void GuiApplication::ResetSettingsUi(bool gallery_window) { if (!ui_) { return; } auto& main = ui_->Window(gallery_window); main->set_language_index(language_button_value_); main->set_codec_index(video_encode_format_button_value_); const bool hardware_codec_available = ConfigCenter::IsHardwareVideoCodecAvailable(); main->set_hardware_codec_available(hardware_codec_available); main->set_hardware_codec_enabled(hardware_codec_available && enable_hardware_video_codec_); main->set_turn_enabled(enable_turn_); main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_self_hosted_enabled(enable_self_hosted_); main->set_autostart_enabled(enable_autostart_); main->set_daemon_enabled(enable_daemon_); main->set_save_remote_previews(config_center_->IsSaveRemotePreviews()); main->set_privacy_on_connect_enabled( config_center_->IsEnablePrivacyScreen()); #ifdef _WIN32 main->set_capture_method_visible(true); main->set_capture_method_index( static_cast(config_center_->GetScreenCaptureMethod())); #endif #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) main->set_privacy_setting_available(true); #else main->set_privacy_setting_available(false); #endif main->set_file_save_path(file_transfer_save_path_buf_); main->set_server_host(signal_server_ip_self_); main->set_server_port(signal_server_port_self_); main->set_self_host_settings_error(""); main->set_gallery_resolution_index(ui_->gallery.stream_settings.resolution); main->set_gallery_frame_rate_index(ui_->gallery.stream_settings.FrameRateIndex()); main->set_gallery_resolution_draft(ui_->gallery.stream_settings.resolution); main->set_gallery_frame_rate_draft(ui_->gallery.stream_settings.FrameRateIndex()); main->set_gallery_bandwidth_index(ui_->gallery.stream_settings.BandwidthIndex()); main->set_gallery_bandwidth_draft(ui_->gallery.stream_settings.BandwidthIndex()); main->set_gallery_settings_error(""); } void GuiApplication::BindMainCallbacks(bool gallery_window) { auto* main = ui_->Window(gallery_window).operator->(); main->on_gallery_pointer([this](slint::PointerEventButton button, slint::PointerEventKind kind, float x, float y) { if (!ui_->gallery_control_id.empty() && !ui_->gallery_transition.Active()) SendPointerInput(static_cast(button), static_cast(kind), x, y); }); main->on_gallery_scroll([this](float dx, float dy, float x, float y) { if (!ui_->gallery_control_id.empty() && !ui_->gallery_transition.Active()) SendScrollInput(dx, dy, x, y); }); main->on_gallery_key([this](slint::SharedString text, bool pressed, bool ctrl, bool alt, bool shift, bool meta) { if (!ui_->gallery_control_id.empty() && !ui_->gallery_transition.Active()) SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta); }); main->on_open_gallery([this] { ShowGalleryWindow(); }); main->on_toggle_gallery_fullscreen( [this] { ToggleGalleryFullscreen(); }); main->on_gallery_action( [this, main](slint::SharedString action, slint::SharedString value) { GalleryAction(std::string(action), std::string(value), main->get_gallery_mode()); }); main->on_gallery_swap([this, main](slint::SharedString source, slint::SharedString target) { if (!main->get_gallery_mode()) return true; auto updated = ui_->gallery; if (!updated.SwapPositions(std::string(source), std::string(target), ui_->gallery_ids)) return true; if (!updated.Save(ui_->gallery_path)) return false; ui_->gallery = std::move(updated); ui_->gallery_synced = {}; SyncGallery(); return true; }); main->on_gallery_add_device( [this](slint::SharedString id, slint::SharedString password, slint::SharedString group, bool remember) { return AddGalleryDevice(std::string(id), std::string(password), std::string(group), remember); }); main->on_gallery_import_recent([this](slint::SharedString group) { return ImportGalleryRecent(std::string(group)); }); main->on_device_group_action( [this, main](slint::SharedString action, slint::SharedString value) { DeviceGroupAction(std::string(action), std::string(value), main->get_gallery_mode()); }); main->on_device_group_save([this, main](slint::SharedString group) { return SaveDeviceGroups(std::string(group), main->get_gallery_mode()); }); main->on_recent_group_selected([this](int index) { if (index == 1) ui_->recent_group_filter = ""; else if (index >= 2 && index - 2 < int(ui_->recent_filter_groups.size())) ui_->recent_group_filter = ui_->recent_filter_groups[index - 2]; else ui_->recent_group_filter.reset(); SyncMainWindow(); }); main->on_delete_announcement([this](slint::SharedString id, slint::SharedString revision) { const auto parse = [](const slint::SharedString& value) -> std::optional { const std::string_view text(value.data()); int64_t number = 0; const auto result = std::from_chars(text.data(), text.data() + text.size(), number); if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || number <= 0) return std::nullopt; return number; }; const auto announcement_id = parse(id); const auto announcement_revision = parse(revision); if (!announcement_id || !announcement_revision) return; announcements_.Dismiss(*announcement_id, *announcement_revision); SyncAnnouncements(); }); main->on_refresh_announcements([this] { announcements_.Refresh(); }); main->on_open_announcement_link([this](slint::SharedString link) { const std::string url(link); if (announcement_text::IsWebUrl(url)) OpenUrl(url); }); main->on_load_more_announcements([this] { announcements_.LoadMore(); SyncAnnouncements(); }); main->on_open_announcement([this](slint::SharedString id, slint::SharedString revision) { const auto snapshot = announcements_.Get(); for (const auto& item : snapshot.items) { if (std::to_string(item["id"].get()) != id.data() || std::to_string(item["revision"].get()) != revision.data()) continue; ui_->announcement_selected_id = id.data(); ui_->announcement_selected_revision = revision.data(); ui_->announcement_version = UINT64_MAX; announcements_.Read(item["id"], item["revision"]); SyncAnnouncements(); break; } }); main->window().on_close_requested([this, main] { // Keep the application and all active sessions alive. The tray helper // ensures that supported platforms still provide a way to restore the // main window; Slint hides it regardless of tray availability. if (main->get_gallery_mode()) HideGalleryWindow(); else MinimizeMainWindowToTray(); return slint::CloseRequestResponse::HideWindow; }); main->on_main_title_drag([this, main](int phase, float x, float y) { if (ui_) { DragWindow(ui_->Window(main->get_gallery_mode()), phase, x, y, main->get_gallery_mode() ? ui_->gallery_drag : ui_->main_drag); } }); main->on_minimize_main_window([this, main] { if (ui_) { main->window().set_minimized(true); } }); main->on_close_main_window([this, main] { if (!ui_) { return; } if (main->get_gallery_mode()) HideGalleryWindow(); else MinimizeMainWindowToTray(); main->hide(); }); main->on_copy_local_id([this] { if (!SDL_SetClipboardText(client_id_)) { LOG_WARN("Copy local id failed: {}", SDL_GetError()); return false; } return true; }); main->on_signal_reconnect_requested([this] { LOG_INFO("Signal reconnect requested from the status bar"); if (ForceSignalReconnect() != 0) { LOG_WARN("Signal reconnect could not be started"); } }); main->on_toggle_password_visibility([this, main] { 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_refresh_password([this] { const auto password = GenerateRandomPassword(password_saved_); if (!password) { LOG_ERROR("Could not generate a random password"); offline_warning_text_ = localization::failed[localization_language_index_]; show_offline_warning_window_ = true; return; } RequestPasswordChange(*password); }); main->on_connect_requested( [this](slint::SharedString id) { ConnectFromUi(std::string(id)); }); main->on_format_remote_id([](slint::SharedString value) { return UiText(FormatRemotePeerIdInput(std::string_view(value))); }); main->on_recent_connect( [this](slint::SharedString id) { ConnectFromUi(std::string(id)); }); main->on_recent_disconnect([this](slint::SharedString id) { const std::string remote_id(id); if (auto props = FindRemoteSession(remote_id); props && props->connection_status_ == ConnectionStatus::Connected && !props->closing_) { CloseStreamTab(remote_id); SyncMainWindow(); } }); main->on_recent_edit_alias( [this](slint::SharedString id, slint::SharedString alias) { const std::string remote_id(id); const std::string trimmed = Trim(std::string(alias)); if (trimmed.empty()) { settings_.EraseRecentConnectionAlias(remote_id); } else { settings_.SetRecentConnectionAlias(remote_id, trimmed); } settings_.SaveRecentConnectionAliases(); reload_recent_connections_ = true; ui_->gallery_synced = {}; }); main->on_recent_delete([this](slint::SharedString id) { const std::string remote_id(id); const auto connection = std::find_if( recent_connections_.begin(), recent_connections_.end(), [&](const auto& entry) { return entry.second.remote_id == remote_id; }); if (connection != recent_connections_.end()) { thumbnail_->DeleteThumbnail(connection->first); settings_.EraseRecentConnectionAlias(remote_id); settings_.SaveRecentConnectionAliases(); reload_recent_connections_ = true; } }); main->on_browse_save_path([this, main] { const char* folder = tinyfd_selectFolderDialog( localization::file_transfer_save_path[localization_language_index_] .c_str(), file_transfer_save_path_buf_[0] ? file_transfer_save_path_buf_ : nullptr); if (folder) { main->set_file_save_path(folder); } }); main->on_save_settings([this, main] { return SaveSettingsFromUi(main->get_gallery_mode()); }); main->on_cancel_settings([this, main] { ResetSettingsUi(main->get_gallery_mode()); }); main->on_save_self_hosted_settings([this, main] { // Stage the endpoint until the surrounding settings dialog is confirmed. return ValidateSelfHostedSettingsFromUi(main->get_gallery_mode()); }); main->on_cancel_self_hosted_settings([this, main] { main->set_self_host_settings_error(""); const std::string host = config_center_->GetSignalServerHost(); main->set_server_host(UiText(host)); const int signal_port = config_center_->GetSignalServerPort(); main->set_server_port(signal_port > 0 ? UiText(std::to_string(signal_port)) : slint::SharedString{}); }); main->on_open_download([this] { const auto& latest = update_checker_->latest(); OpenUrl(latest && !latest->download_url.empty() ? latest->download_url : "https://crossdesk.cn"); }); main->on_check_for_updates([this] { update_checker_->RequestCheck(); SyncMainWindow(); }); main->on_connection_cancel([this] { const auto props = FindRemoteSession(ui_->connection_dialog_remote_id); if (!props) { show_connection_status_window_ = false; return; } CloseStreamTab(props->remote_id_); re_enter_remote_id_ = true; show_connection_status_window_ = false; }); main->on_connection_acknowledge([this] { const auto props = FindRemoteSession(ui_->connection_dialog_remote_id); if (props) { const ConnectionStatus status = props->connection_status_.load(); if (status == ConnectionStatus::NoSuchTransmissionId || status == ConnectionStatus::RemoteUnavailable) { CloseStreamTab(props->remote_id_); re_enter_remote_id_ = true; } } show_connection_status_window_ = false; }); main->on_connection_submit_password([this](slint::SharedString value, bool remember) { const auto props = FindRemoteSession(ui_->connection_dialog_remote_id); const std::string password(value); if (!props || password.empty() || password.size() > 6) { return; } if (!props->connection_auth_.Begin(password)) return; std::memset(props->remote_password_, 0, sizeof(props->remote_password_)); std::memcpy(props->remote_password_, password.data(), password.size()); props->remember_password_ = remember; props->rejoin_ = true; need_to_rejoin_ = true; }); main->on_request_screen_recording_permission([this] { #ifdef __APPLE__ OpenScreenRecordingPreferences(); mac_screen_recording_permission_requested_ = true; RefreshMacPermissionStatus(true); #endif }); main->on_request_accessibility_permission([this] { #ifdef __APPLE__ OpenAccessibilityPreferences(); mac_accessibility_permission_requested_ = true; RefreshMacPermissionStatus(true); #endif }); main->on_install_portable_service([this, main] { #if _WIN32 && CROSSDESK_PORTABLE portable_service_do_not_remind_ = false; main->set_portable_service_do_not_remind(false); if (main->get_gallery_mode()) ui_->main->show(); StartPortableWindowsServiceInstall(); #endif }); main->on_cancel_portable_service([this, main] { #if _WIN32 && CROSSDESK_PORTABLE if (portable_service_install_state_.load(std::memory_order_acquire) == PortableServiceInstallState::installing) { return; } portable_service_do_not_remind_ = main->get_portable_service_do_not_remind(); if (portable_service_do_not_remind_) { portable_service_prompt_suppressed_ = true; config_center_->SetPortableServicePromptSuppressed(true); show_portable_service_prompt_suppressed_window_ = true; } show_portable_service_install_window_ = false; #endif }); main->on_acknowledge_portable_service([this] { #if _WIN32 && CROSSDESK_PORTABLE show_portable_service_install_window_ = false; portable_service_installed_ = portable_service_install_state_.load(std::memory_order_acquire) == PortableServiceInstallState::succeeded; JoinPortableWindowsServiceInstallThread(); #endif }); main->on_acknowledge_portable_service_suppressed([this] { #if _WIN32 && CROSSDESK_PORTABLE show_portable_service_prompt_suppressed_window_ = false; #endif }); } void GuiApplication::BindStreamCallbacks() { if (!ui_->stream) { return; } auto& stream = *ui_->stream; stream->on_dismiss_remote_update([this] { if (auto props = SelectedSession()) { std::lock_guard lock(props->remote_version_mutex_); props->remote_update_dismissed_ = true; } }); auto close_stream_sessions = [this] { std::vector ids; { std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, _] : remote_sessions_) { if (!_->wall_managed_) ids.push_back(id); else _->tab_opened_ = false; } } ReleaseControl(); for (const auto& id : ids) { CloseStreamTab(id); } if (ui_ && ui_->stream) { (*ui_->stream)->hide(); } }; stream->window().on_close_requested([close_stream_sessions] { close_stream_sessions(); return slint::CloseRequestResponse::HideWindow; }); stream->on_stream_title_drag([this](int phase, float x, float y) { if (ui_ && ui_->stream) { DragWindow(*ui_->stream, phase, x, y, ui_->stream_drag); } }); stream->on_minimize_stream_window([this] { if (ui_ && ui_->stream) { (*ui_->stream)->window().set_minimized(true); } }); stream->on_toggle_maximize_stream_window([this] { if (!ui_ || !ui_->stream) { return; } auto& stream = *ui_->stream; auto& window = stream->window(); const bool maximize = !window.is_maximized(); stream->set_window_maximized(maximize); window.set_maximized(maximize); }); stream->on_close_stream_window(close_stream_sessions); stream->on_select_tab([this](int index) { SelectStreamTab(index); }); stream->on_reorder_tab([this](int from, float drop_x, float tab_width) { ReorderStreamTab(from, drop_x, tab_width); }); stream->on_close_tab([this](slint::SharedString id) { auto props = FindRemoteSession(std::string(id)); if (props && props->wall_managed_) { ReleaseControl(); props->tab_opened_ = false; } else CloseStreamTab(std::string(id)); }); stream->on_switch_display([this](int index) { auto props = SelectedSession(); if (!props || index < 0 || index >= static_cast(props->display_info_list_.size())) { return; } props->selected_display_ = index; { std::lock_guard lock(props->remote_cursor_state_mutex_); props->cursor_presentation_ = {}; } RemoteAction action{}; action.type = ControlType::display_id; action.d = index; const std::string message = action.to_json(); SendReliableDataFrame(props->peer_, message.c_str(), message.size(), props->control_data_label_.c_str()); }); stream->on_send_shortcut([this](slint::SharedString shortcut) { auto props = SelectedSession(); if (!props || !props->peer_) { return; } RemoteAction action{}; action.type = ControlType::service_command; action.c.flag = std::string(shortcut) == "Ctrl+Alt+Del" ? ServiceCommandFlag::send_sas : ServiceCommandFlag::lock_workstation; const std::string message = action.to_json(); SendReliableDataFrame(props->peer_, message.c_str(), message.size(), props->control_data_label_.c_str()); }); stream->on_toggle_privacy([this] { auto props = SelectedSession(); if (!props || !props->peer_ || !props->control_mouse_ || props->connection_status_.load() != ConnectionStatus::Connected) return; RemoteAction action{}; action.type = ControlType::privacy_command; { std::lock_guard lock(props->privacy_status_mutex_); const auto& status = props->privacy_status_; const uint64_t now = SDL_GetTicks(); const auto controls = GetPrivacyControlState( status, props->privacy_status_received_, props->privacy_command_pending_, now - props->privacy_status_tick_, now - props->privacy_command_tick_); if (!controls.can_toggle) return; action.pc.flag = controls.active ? PrivacyCommandFlag::disable : PrivacyCommandFlag::enable; action.pc.block_local_input = true; props->privacy_command_pending_ = true; props->privacy_command_tick_ = now; props->privacy_request_revision_ = status.revision; } const auto message = action.to_json(); if (SendReliableDataFrame(props->peer_, message.data(), message.size(), props->control_data_label_.c_str()) != 0) { std::lock_guard lock(props->privacy_status_mutex_); props->privacy_command_pending_ = false; props->privacy_status_received_ = false; LOG_WARN("Privacy command send failed, remote_id={}", props->remote_id_); } }); stream->on_toggle_mouse_control([this] { auto props = SelectedSession(); if (!props || !props->connection_established_) { return; } props->control_mouse_ = !props->control_mouse_; props->enable_mouse_control_ = props->control_mouse_; start_keyboard_capturer_ = props->control_mouse_; if (!props->control_mouse_) ReleaseControl(); }); stream->on_change_video_settings([this](int quality, int frame_rate_index, int preference) { auto props = SelectedSession(); if (!props || !props->peer_ || props->connection_status_.load() != ConnectionStatus::Connected) return; RemoteAction action{}; action.type = ControlType::video_settings; action.vs = {quality, frame_rate_index == 0 ? 30 : 60, preference}; if (frame_rate_index < 0 || frame_rate_index > 1 || !ValidVideoSettings(action.vs)) return; { std::lock_guard lock(props->video_settings_mutex_); if (!props->video_settings_supported_) return; action.vs.request_id = props->video_settings_.request_id + 1; const auto message = action.to_json(); if (SendReliableDataFrame(props->peer_, message.data(), message.size(), props->control_data_label_.c_str()) != 0) return; props->video_settings_ = action.vs; props->video_settings_pending_ = true; props->video_settings_failed_ = false; props->video_settings_request_tick_ = SDL_GetTicks(); } }); stream->on_toggle_audio([this] { auto props = SelectedSession(); if (!props || !props->connection_established_) { return; } if (props->audio_capture_button_pressed_) MuteRemoteAudio(props); else UnmuteRemoteAudio(props); SyncAudioPlayback(); }); stream->on_select_file([this] { auto props = SelectedSession(); if (!props) { return; } transfers_.ProcessSelectedFile( OpenFileDialog(localization::select_file[localization_language_index_]), props, props->file_label_); }); stream->on_close_file_transfer([this] { if (auto props = SelectedSession()) { props->file_transfer_.file_transfer_window_visible_ = false; } }); stream->on_can_drop_file( [](const slint::DataTransfer& data) { return data.has_plain_text(); }); stream->on_file_dropped([this](const slint::DataTransfer& data) { const auto text = data.plain_text(); auto props = SelectedSession(); if (!text || !props) { return; } const std::string path = DecodeDroppedPath(std::string(*text)); if (!path.empty()) { transfers_.ProcessSelectedFile(path, props, props->file_label_, props->remote_id_); } }); stream->on_toggle_stats([this] { if (auto props = SelectedSession()) { props->net_traffic_stats_button_pressed_ = !props->net_traffic_stats_button_pressed_; } }); stream->on_toggle_fullscreen([this] { auto& stream = *ui_->stream; #if defined(__APPLE__) const bool enter_fullscreen = !IsStreamWindowFullscreen(); // Update Slint's fullscreen layout before AppKit applies the new frame so // the first fullscreen draw cannot briefly contain the windowed tab strip. stream->set_fullscreen_enabled(enter_fullscreen); if (!SetStreamWindowFullscreen(enter_fullscreen)) { stream->set_fullscreen_enabled(!enter_fullscreen); return; } #else auto& window = stream->window(); const bool enter_fullscreen = !window.is_fullscreen(); #endif fullscreen_button_pressed_ = enter_fullscreen; #if defined(__APPLE__) video_frame_dirty_.store(true, std::memory_order_release); #else stream->set_fullscreen_enabled(enter_fullscreen); window.request_redraw(); window.set_fullscreen(enter_fullscreen); #endif }); stream->on_disconnect([this] { if (auto props = SelectedSession()) { CloseStreamTab(props->remote_id_); } }); stream->on_pointer_input([this](slint::PointerEventButton button, slint::PointerEventKind kind, float x, float y) { SendPointerInput(static_cast(button), static_cast(kind), x, y); }); stream->on_scroll_input([this](float dx, float dy, float x, float y) { SendScrollInput(dx, dy, x, y); }); stream->on_key_input([this](slint::SharedString text, bool pressed, bool control, bool alt, bool shift, bool meta) { SendKeyInput(std::string(text), pressed, control, alt, shift, meta); }); stream->on_keyboard_focus_changed([this](bool focused) { if (ui_->gallery_control_id.empty()) SetStreamKeyboardFocus(focused); }); } void GuiApplication::BindServerCallbacks() { if (!ui_->server) { return; } auto& server = *ui_->server; auto& bar = *ui_->server_bar; bar->on_title_drag([this](int phase, float, float) { auto& drag = ui_->server_drag; if (phase == 1) { ui_->server_drag_moved = false; (*ui_->server_bar)->set_dragged(false); drag.active = CanUseGlobalPointerPosition() && drag.ReadGlobalPointer(&drag.pointer_start_x, &drag.pointer_start_y); ui_->server_drag_anchor = ui_->server_anchor; } else if (phase == 0) { PositionServerWindows(); drag.active = false; } }); bar->on_toggle_panel([this] { SetServerPanelExpanded(server_window_collapsed_); }); bar->on_move_anchor([this](float delta) { ui_->server_anchor = std::clamp(ui_->server_anchor + delta, 0.0f, 1.0f); PositionServerWindows(); }); const auto dismiss = [this] { SetServerPanelExpanded(false); }; bar->on_dismiss_panel(dismiss); server->on_dismiss_panel(dismiss); server->window().on_close_requested([dismiss] { dismiss(); return slint::CloseRequestResponse::KeepWindowShown; }); bar->window().on_close_requested([dismiss] { dismiss(); return slint::CloseRequestResponse::KeepWindowShown; }); server->on_controller_selected([this](int index) { if (index < 0 || index >= static_cast(ui_->controller_ids.size())) { return; } selected_server_remote_id_ = ui_->controller_ids[index]; }); server->on_select_file([this] { if (selected_server_remote_id_.empty() || ui_->server_file_dialog_open) { return; } // A native file picker runs a nested event loop. Keep the recipient stable // if another controller connects or leaves while it is open. const std::string recipient = selected_server_remote_id_; ui_->server_file_dialog_open = true; (*ui_->server)->window().request_redraw(); // TouchArea clears pressed only after its clicked callback returns. Allow // that release and a repaint before entering the blocking native picker. ui_->server_file_dialog_timer.start( slint::TimerMode::SingleShot, std::chrono::milliseconds(32), [this, recipient] { const auto connected = [this, &recipient] { std::shared_lock lock(connection_status_mutex_); const auto found = connection_status_.find(recipient); return found != connection_status_.end() && found->second == ConnectionStatus::Connected; }; if (!connected()) { ui_->server_file_dialog_open = false; return; } const auto path = OpenFileDialog( localization::select_file[localization_language_index_]); ui_->server_file_dialog_open = false; if (connected() && !path.empty()) transfers_.ProcessSelectedFile(path, nullptr, file_label_, recipient); }); }); server->on_disconnect_controller([this](slint::SharedString id) { CloseServerController(std::string(id)); }); server->on_disconnect_all([this] { // Work from the confirmed UI snapshot; a later arrival is a new session. const auto ids = ui_->controller_ids; for (const auto& id : ids) CloseServerController(id); }); } void GuiApplication::Tick() { if (exit_) { slint::quit_event_loop(); return; } PollUpdateCheck(); HandleSessionCleanup(); HandlePasswordChangeResult(); HandleCredentialRecovery(); if (!peer_) { signal_connection_indicator_.Reset(); CreateConnectionPeer(); } #ifdef __linux__ HeadlessConsole::Instance().Poll( signal_connected_.load(), [this](const std::string& password) { RequestPasswordChange(password); }, [this] { const auto password = GenerateRandomPassword(password_saved_); if (password) RequestPasswordChange(*password); else HeadlessConsole::Instance().NotifyKey("console_random_failed"); }, [this] { exit_ = true; }); #endif SDL_Event event; #if defined(__APPLE__) // Slint's winit backend owns the macOS AppKit event loop. SDL_PollEvent() // implicitly pumps Cocoa events, so calling it from this Slint timer would // re-enter winit's event handler and abort the process. Only drain events // that are already in SDL's queue; clipboard changes are polled below and // tray/window actions use direct Slint callbacks on macOS. while (SDL_PeepEvents(&event, 1, SDL_GETEVENT, SDL_EVENT_FIRST, SDL_EVENT_LAST) > 0) { #else while (SDL_PollEvent(&event)) { #endif if (event.type == SDL_EVENT_QUIT) { exit_ = true; } else if (event.type == SDL_EVENT_CLIPBOARD_UPDATE) { clipboard_.HandleLocalUpdate(); } } clipboard_.ApplyPendingRemoteText(); #if defined(__linux__) && !defined(__APPLE__) if (ui_->tray) { ui_->tray->ProcessEvents(); } #endif const auto now = std::chrono::steady_clock::now(); if (now - ui_->last_clipboard_poll >= 250ms) { clipboard_.HandleLocalUpdate(); ui_->last_clipboard_poll = now; } if (need_to_rejoin_ && now - last_rejoin_check_time_ >= 1s) { last_rejoin_check_time_ = now; HandlePendingRejoins(); } const bool was_reloading_recent_connections = reload_recent_connections_; HandleRecentConnections(); if (was_reloading_recent_connections && !reload_recent_connections_) { ui_->recent_model_signature.clear(); } HandleConnectionStatusChange(); HandlePendingPresenceProbe(); HandlePresenceProbeTimeout(); HandlePrivacy(); HandleWindowsServiceIntegration(); #if defined(__linux__) && !defined(__APPLE__) SyncXWaylandWindowActivation(); #else SyncStreamKeyboardFocus(); #endif devices_.UpdateInteractions(); if (!ui_->gallery_control_id.empty()) keyboard_.SendHeartbeat(false); ShareLocalCursorState(); CloseRemoteClosedTabs(); UpdateLocalization(); SyncMainWindow(); SyncGallery(); SyncConnectionDialog(); SyncPlatformDialogs(); SyncStreamWindow(); SyncServerWindow(); } void GuiApplication::PollUpdateCheck() { // The first tick runs after show()/event-loop startup. Poll never waits for // networking and all application/model changes remain on this UI thread. if (!update_checker_->Poll() || update_checker_->status() == UpdateChecker::Status::Failed) { return; } const auto& info = *update_checker_->latest(); latest_version_ = "v" + info.version; release_name_ = info.release_name; release_notes_ = info.release_notes; release_date_ = info.release_date; update_available_ = update_checker_->update_available(); std::vector blocks; for (const auto& parsed : release_notes_text::Parse(release_notes_)) { ui::ReleaseNoteBlock block; block.content = parsed.content; block.section_gap = parsed.section_gap; blocks.push_back(std::move(block)); } ui_->release_note_blocks_model->set_vector(std::move(blocks)); } void GuiApplication::ShareLocalCursorState() { constexpr auto kCursorEchoSuppressionInterval = 300ms; constexpr auto kCursorStateHeartbeatInterval = 500ms; constexpr auto kCursorPositionShareInterval = 16ms; constexpr float kCursorPositionEpsilon = 0.00005f; std::vector connected_controllers; { std::shared_lock lock(connection_status_mutex_); connected_controllers.reserve(connection_status_.size()); for (const auto& [remote_id, status] : connection_status_) { if (status == ConnectionStatus::Connected) { connected_controllers.push_back(remote_id); } } } if (!is_server_mode_ || !peer_ || connected_controllers.empty()) { cursor_delivery_states_.clear(); return; } CursorState sampled{}; const bool sample_ok = cursor_state_provider_.Sample( devices_.display_info_list(), selected_display_, &sampled); if (!sample_ok) { sampled.render_mode = CursorRenderMode::unknown; if (sampled.hidden_reason == CursorHiddenReason::unspecified) { sampled.hidden_reason = CursorHiddenReason::sampling_failed; } } else if (sampled.render_mode == CursorRenderMode::legacy) { // Providers without platform-specific presentation metadata only need to // sample visible/shape; normalize that common case once for every peer. sampled.render_mode = sampled.visible ? CursorRenderMode::separate : CursorRenderMode::hidden; sampled.hidden_reason = sampled.visible ? CursorHiddenReason::unspecified : CursorHiddenReason::system_hidden; } const auto now = std::chrono::steady_clock::now(); std::unordered_map last_input_times; { std::lock_guard lock(remote_pointer_input_mutex_); for (const auto& remote_id : connected_controllers) { const auto input_it = last_remote_pointer_input_time_.find(remote_id); if (input_it != last_remote_pointer_input_time_.end()) { last_input_times.emplace(remote_id, input_it->second); } } } std::unordered_set connected_ids(connected_controllers.begin(), connected_controllers.end()); std::erase_if(cursor_delivery_states_, [&](const auto& entry) { return connected_ids.find(entry.first) == connected_ids.end(); }); struct CursorRecipient { std::string remote_id; bool include_position = true; }; std::vector recipients; for (const auto& remote_id : connected_controllers) { auto& delivery = cursor_delivery_states_[remote_id]; const auto input_it = last_input_times.find(remote_id); const bool suppress_position = input_it != last_input_times.end() && now - input_it->second < kCursorEchoSuppressionInterval; delivery.feedback_pending = delivery.feedback_pending || suppress_position; const CursorState& previous = delivery.last_sent; const bool shape_changed = !delivery.has_sent || sampled.render_mode != previous.render_mode || sampled.hidden_reason != previous.hidden_reason || (sample_ok && (sampled.visible != previous.visible || sampled.shape != previous.shape || std::abs(sampled.visual_offset_x - previous.visual_offset_x) > kCursorPositionEpsilon || std::abs(sampled.visual_offset_y - previous.visual_offset_y) > kCursorPositionEpsilon)); // Preserve sub-point cursor motion. At 10x client zoom, the old roughly // one-logical-pixel threshold became several visible phone points. const bool position_changed = sample_ok && (!delivery.has_sent || sampled.position_valid != previous.position_valid || sampled.display_id != previous.display_id || (sampled.position_valid && previous.position_valid && (std::abs(sampled.x - previous.x) > kCursorPositionEpsilon || std::abs(sampled.y - previous.y) > kCursorPositionEpsilon))); const bool position_update_due = position_changed && (delivery.last_sent_time.time_since_epoch().count() == 0 || now - delivery.last_sent_time >= kCursorPositionShareInterval); const bool heartbeat_due = delivery.last_sent_time.time_since_epoch().count() == 0 || now - delivery.last_sent_time >= kCursorStateHeartbeatInterval; // Position feedback waits for a good sample and the echo-suppression // interval. Appearance changes and heartbeats are independent of both. const bool include_position = sample_ok && !suppress_position; if (shape_changed || heartbeat_due || (include_position && (delivery.feedback_pending || position_update_due))) { recipients.push_back({remote_id, include_position}); } } if (recipients.empty()) return; sampled.seq = ++cursor_state_sequence_; for (const auto& recipient : recipients) { auto& delivery = cursor_delivery_states_[recipient.remote_id]; CursorState outgoing = sampled; if (!sample_ok) { // Explicitly report unknown to new receivers, but retain the last // presentation for peers that only understand visible/shape. outgoing = delivery.has_sent ? delivery.last_sent : CursorState{}; if (!delivery.has_sent) { outgoing.visible = true; outgoing.shape = RemoteCursorShape::default_cursor; } outgoing.seq = sampled.seq; outgoing.render_mode = sampled.render_mode; outgoing.hidden_reason = sampled.hidden_reason; } outgoing.position_update = recipient.include_position; if (!recipient.include_position) { // Keep legacy receivers at their last acknowledged position as well. // New receivers honor position_update=false and leave their optimistic // local position untouched. outgoing.position_valid = delivery.has_sent && delivery.last_sent.position_valid; outgoing.x = delivery.has_sent ? delivery.last_sent.x : 0.5f; outgoing.y = delivery.has_sent ? delivery.last_sent.y : 0.5f; outgoing.display_id = delivery.has_sent ? delivery.last_sent.display_id : -1; } RemoteAction action{}; 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()); if (result != 0) { LOG_WARN("Send cursor state to [{}] failed, ret={}", recipient.remote_id, result); continue; } if (!delivery.has_sent || delivery.last_sent.render_mode != outgoing.render_mode || delivery.last_sent.hidden_reason != outgoing.hidden_reason) { LOG_DEBUG("Send cursor presentation to [{}]: mode={}, reason={}", recipient.remote_id, static_cast(outgoing.render_mode), static_cast(outgoing.hidden_reason)); } delivery.last_sent = outgoing; delivery.has_sent = true; if (recipient.include_position) { delivery.feedback_pending = false; } delivery.last_sent_time = now; } } bool GuiApplication::RequestPasswordChange(const std::string &password) { const auto notify = [](const std::string& message) { #ifdef __linux__ HeadlessConsole::Instance().NotifyKey(message.c_str()); #else (void)message; #endif }; if (password.size() != 6 || password.find_first_not_of("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") != std::string::npos) { notify("console_password_invalid"); return false; } if (!peer_ || !signal_connected_) { offline_warning_text_ = localization::signal_disconnected[localization_language_index_]; show_offline_warning_window_ = true; notify("console_password_offline"); return true; } std::string request_id; { std::lock_guard lock(password_change_mutex_); if (password_change_pending_) { notify("console_password_pending"); return true; } request_id = CreatePasswordChangeRequestId(++next_password_change_request_id_); password_change_pending_ = true; password_change_result_ready_ = false; password_change_succeeded_ = false; password_change_result_uncertain_ = false; password_change_requested_at_ = std::chrono::steady_clock::now(); pending_password_change_request_id_ = request_id; pending_local_password_ = password; password_change_error_.clear(); } const bool self_hosted = config_center_->IsSelfHosted(); const std::string server_host = self_hosted ? config_center_->GetSignalServerHost() : config_center_->GetDefaultServerHost(); const int server_port = self_hosted ? config_center_->GetSignalServerPort() : config_center_->GetDefaultSignalServerPort(); const std::string pending_identity = std::string(client_id_) + "@" + password; if (!settings_.StagePendingPasswordChange( pending_identity, self_hosted, server_host, server_port, request_id)) { std::lock_guard lock(password_change_mutex_); password_change_pending_ = false; pending_password_change_request_id_.clear(); pending_local_password_.clear(); password_change_error_.clear(); offline_warning_text_ = localization::failed[localization_language_index_]; show_offline_warning_window_ = true; LOG_ERROR("Could not durably stage password change"); notify("console_password_stage_failed"); return true; } const nlohmann::json request = {{"type", "change_password"}, {"request_id", request_id}, {"new_password", password}}; const std::string message = request.dump(); if (SendSignalMessage(peer_, message.data(), message.size()) != 0) { { std::lock_guard lock(password_change_mutex_); password_change_pending_ = false; pending_password_change_request_id_.clear(); pending_local_password_.clear(); offline_warning_text_ = localization::signal_disconnected[localization_language_index_]; show_offline_warning_window_ = true; } if (!settings_.ClearPendingPasswordChange()) { LOG_WARN("Could not clear unsent pending password change"); } notify("console_password_send_failed"); } else { notify("console_password_submitted"); } return true; } void GuiApplication::HandlePasswordChangeResult() { bool succeeded = false; bool uncertain = false; std::string error; { std::lock_guard lock(password_change_mutex_); if (password_change_pending_ && !password_change_result_ready_ && std::chrono::steady_clock::now() - password_change_requested_at_ >= std::chrono::seconds(10)) { password_change_result_ready_ = true; password_change_succeeded_ = false; password_change_result_uncertain_ = true; password_change_error_ = "Server did not respond"; } if (!password_change_result_ready_) { return; } succeeded = password_change_succeeded_; uncertain = password_change_result_uncertain_; error = password_change_error_; password_change_pending_ = false; password_change_result_ready_ = false; password_change_succeeded_ = false; password_change_result_uncertain_ = false; pending_password_change_request_id_.clear(); pending_local_password_.clear(); password_change_error_.clear(); } if (!succeeded) { #ifdef __linux__ HeadlessConsole::Instance().NotifyKey( uncertain ? "console_password_uncertain" : "console_password_rejected"); #endif if (uncertain) { LOG_WARN("Password change outcome is unknown: {}; re-authenticating", error); } else { LOG_WARN("Password change failed: {}", error); if (!settings_.ClearPendingPasswordChange()) { LOG_WARN("Could not clear rejected pending password change"); } } offline_warning_text_ = localization::failed[localization_language_index_]; if (!error.empty()) { offline_warning_text_ += ": " + error; } show_offline_warning_window_ = true; if (uncertain && peer_) { CloseConnectionPeer(); } return; } if (!settings_.PromotePendingPasswordChange()) { // The pending credential was persisted before the request was sent, so it // remains sufficient for recovery even when promotion cannot be written. LOG_WARN("Password changed on server; pending recovery record retained"); } LOG_INFO("Password changed successfully for [{}]", client_id_); #ifdef __linux__ HeadlessConsole::Instance().NotifyKey("console_password_confirmed"); #endif if (peer_) { CloseConnectionPeer(); } } void GuiApplication::HandleCredentialRecovery() { bool retry_active = false; bool promote_pending = false; bool clear_pending = false; { std::lock_guard lock(password_change_mutex_); retry_active = credential_recovery_retry_active_; promote_pending = credential_recovery_promote_pending_; clear_pending = credential_recovery_clear_pending_; credential_recovery_retry_active_ = false; credential_recovery_promote_pending_ = false; credential_recovery_clear_pending_ = false; if (retry_active) { credential_recovery_attempt_pending_ = false; } } if (retry_active) { LOG_INFO("Pending credential was not accepted; retrying active credential"); if (peer_) { CloseConnectionPeer(); } return; } if (promote_pending) { #ifdef __linux__ HeadlessConsole::Instance().NotifyKey("console_password_recovered"); #endif if (!settings_.PromotePendingPasswordChange()) { LOG_WARN("Recovered credential is active but promotion remains pending"); } else { LOG_INFO("Recovered password change with pending credential"); } } else if (clear_pending) { #ifdef __linux__ HeadlessConsole::Instance().NotifyKey("console_password_retained"); #endif if (!settings_.ClearPendingPasswordChange()) { LOG_WARN("Active credential recovered but pending record could not be " "cleared"); } else { LOG_INFO("Password change was not committed; retained active credential"); } } else { return; } std::lock_guard lock(password_change_mutex_); credential_recovery_in_progress_ = false; credential_recovery_attempt_pending_ = false; } void GuiApplication::UpdateLocalization() { const int language = localization::detail::ClampLanguageIndex(localization_language_index_); if (!ui_ || ui_->localized_language == language) { return; } ui_localization::ApplyMainWindowStrings(ui_->main, language); ui_localization::ApplyStreamWindowStrings(ui_->main, language); ui_localization::ApplyMainWindowStrings(ui_->wall, language); ui_localization::ApplyStreamWindowStrings(ui_->wall, language); if (ui_->stream) { ui_localization::ApplyStreamWindowStrings(*ui_->stream, language); } ui_->localized_language = language; } void GuiApplication::SyncAnnouncements() { if (signal_connected_) { announcements_.Configure(params_.signal_server_ip, params_.signal_server_port, client_id_); } const auto request = announcements_.TakePendingRequest(); if (!request.is_null()) { const auto payload = request.dump(); if (!peer_ || SendSignalMessage(peer_, payload.data(), payload.size()) != 0) announcements_.Failed(request["request_id"]); } if (ui_->announcement_version == announcements_.Version()) return; const auto state = announcements_.Get(); ui_->announcement_version = state.version; for (const auto& main : {ui_->main, ui_->wall}) { main->set_announcement_unread(state.unread); main->set_announcement_total(state.total); main->set_announcement_loading(state.loading); main->set_announcement_failed(state.failed); main->set_announcement_read_save_failed(state.read_save_failed); main->set_announcement_delete_failed(state.delete_failed); main->set_announcement_loaded(state.loaded); std::vector rows; bool selected_found = false; for (const auto& item : state.items) { ui::AnnouncementItem row; const auto id = std::to_string(item["id"].get()); const auto revision = std::to_string(item["revision"].get()); row.id = UiText(id); row.revision = UiText(revision); row.title = UiText(item["title"].get()); const auto timestamp = static_cast(item["updated_at"].get()); std::tm local{}; #ifdef _WIN32 localtime_s(&local, ×tamp); #else localtime_r(×tamp, &local); #endif char date[32]{}; std::strftime(date, sizeof(date), "%Y-%m-%d %H:%M", &local); row.date = UiText(date); row.read = item["read"].get(); if (id == ui_->announcement_selected_id && revision == ui_->announcement_selected_revision) { selected_found = true; main->set_announcement_selected_id(row.id); main->set_announcement_selected_title(row.title); main->set_announcement_selected_body( announcement_text::Format(item["body"].get())); main->set_announcement_selected_date(row.date); main->set_announcement_selected_read(row.read); } rows.push_back(std::move(row)); } if (!selected_found) { ui_->announcement_selected_id.clear(); main->set_announcement_selected_id(""); main->set_announcement_selected_body(slint::StyledText{}); } main->set_announcements( std::make_shared>( std::move(rows))); } } void GuiApplication::SyncMainWindow() { if (!ui_) { return; } SyncAnnouncements(); #if _WIN32 ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); #endif #if defined(__APPLE__) if (ui_->main_native_titlebar_attempts > 0) { if (ConfigureMainWindowChrome(ui_->main->window().appkit_view())) { ui_->main_native_titlebar_attempts = 0; } else { --ui_->main_native_titlebar_attempts; } } if (ui_->gallery_native_titlebar_attempts > 0) { if (ConfigureMainWindowChrome(ui_->wall->window().appkit_view(), true)) ui_->gallery_native_titlebar_attempts = 0; else --ui_->gallery_native_titlebar_attempts; } #endif if (re_enter_remote_id_) { ui_->main->invoke_reset_remote_id(); re_enter_remote_id_ = false; } ui_->main->set_local_id(UiText(FormatPeerId(client_id_))); ui_->main->set_local_password(password_saved_); ui_->main->set_password_visible(show_password_); { std::lock_guard lock(password_change_mutex_); ui_->main->set_password_change_pending(password_change_pending_); } const bool signal_online = signal_connected_.load(); const bool signal_reconnecting = signal_connection_indicator_.Update(signal_online); for (const auto& main : {ui_->main, ui_->wall}) { main->set_signal_connected(signal_online); main->set_signal_reconnecting(signal_reconnecting); main->set_signal_tls_error(signal_status_ == SignalStatus::SignalTlsCertError); main->set_update_available(update_available_); switch (update_checker_->status()) { case UpdateChecker::Status::Idle: main->set_update_check_status(ui::UpdateCheckStatus::Idle); break; case UpdateChecker::Status::Checking: main->set_update_check_status(ui::UpdateCheckStatus::Checking); break; case UpdateChecker::Status::UpToDate: main->set_update_check_status(ui::UpdateCheckStatus::UpToDate); break; case UpdateChecker::Status::Available: main->set_update_check_status(ui::UpdateCheckStatus::Available); break; case UpdateChecker::Status::Failed: main->set_update_check_status(ui::UpdateCheckStatus::Failed); break; } main->set_current_version(CROSSDESK_VERSION); main->set_latest_version(UiText(latest_version_)); main->set_release_name(UiText(release_name_)); main->set_release_date(UiText(release_date_)); main->set_settings_session_active(HasActiveSession()); } if (show_offline_warning_window_) { ui_->main->set_offline_warning(UiText(offline_warning_text_)); show_offline_warning_window_ = false; } // Keep this filter separate from DeskHub's current group, keyed by name so // adding/removing groups does not silently select a different one by index. const auto tr_group = [this](const char* zh, const char* en, const char* ru) { return UiText(localization_language_index_ == 0 ? zh : localization_language_index_ == 2 ? ru : en); }; std::vector recent_groups{ tr_group("全部设备", "All devices", "Все устройства"), tr_group("未分组", "Ungrouped", "Без группы")}; int recent_group_index = 0; if (ui_->recent_group_filter) { if (ui_->recent_group_filter->empty()) recent_group_index = 1; else { const auto found = std::find(ui_->gallery.groups.begin(), ui_->gallery.groups.end(), *ui_->recent_group_filter); if (found == ui_->gallery.groups.end()) ui_->recent_group_filter.reset(); else recent_group_index = int(found - ui_->gallery.groups.begin()) + 2; } } ui_->recent_filter_groups = ui_->gallery.groups; for (const auto& group : ui_->recent_filter_groups) recent_groups.push_back(UiText(group)); const auto old_recent_groups = ui_->main->get_recent_groups(); bool recent_groups_changed = !old_recent_groups || old_recent_groups->row_count() != recent_groups.size(); if (!recent_groups_changed) for (size_t i = 0; i < recent_groups.size(); ++i) recent_groups_changed |= old_recent_groups->row_data(i) != recent_groups[i]; if (recent_groups_changed) ui_->main->set_recent_groups( std::make_shared>(recent_groups)); ui_->main->set_recent_group_index(recent_group_index); // Derive group labels from the shared workspace, including devices with // multiple memberships. No separate grouping metadata is stored in history. std::unordered_map group_labels; for (const auto& member : ui_->gallery.members) { if (member.group.empty()) continue; auto& label = group_labels[member.id]; if (!label.empty()) label += " · "; label += member.group; } const auto is_connected = [this](const std::string& id) { const auto props = FindRemoteSession(id); return props && props->connection_status_ == ConnectionStatus::Connected && !props->closing_; }; std::ostringstream signature_builder; signature_builder << config_center_->IsSaveRemotePreviews() << '\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.remote_host_name << '\0' << static_cast(connection.platform) << '\0' << settings_.RecentConnectionDisplayName(connection) << '\0' << group_labels[connection.remote_id] << '\0' << ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter) << '\0' << device_presence_cache_.IsOnline(connection.remote_id) << '\0' << is_connected(connection.remote_id) << '\n'; } const std::string signature = signature_builder.str(); if (ui_->recent_model_signature == signature) { return; } ui_->recent_model_signature = signature; std::vector model; model.reserve(recent_connections_.size()); for (const auto& [_, connection] : recent_connections_) { if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter)) continue; // Slint-generated scalar fields have no defaults. With no image, // has_thumbnail must remain false so the remote platform is rendered. ui::RecentConnection item{}; item.remote_id = UiText(connection.remote_id); item.host_name = UiText(connection.remote_host_name); item.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.connected = is_connected(connection.remote_id); item.platform = UiText(HostPlatformName(connection.platform)); std::vector rgba; int image_width = 0; int image_height = 0; if (config_center_->IsSaveRemotePreviews() && !connection.image_path.empty() && thumbnail_->DecodeImage(connection.image_path, &rgba, &image_width, &image_height)) { slint::SharedPixelBuffer pixels(image_width, image_height); std::memcpy(pixels.begin(), rgba.data(), rgba.size()); item.thumbnail = slint::Image(std::move(pixels)); item.has_thumbnail = true; } model.push_back(std::move(item)); } // LoadThumbnail already orders connections by last update time. Keep that // order within each presence group while placing online devices first. std::stable_partition( model.begin(), model.end(), [](const ui::RecentConnection& connection) { return connection.online; }); ui_->recent_model->set_vector(std::move(model)); } void GuiApplication::SyncConnectionDialog() { if (!ui_ || !show_connection_status_window_) { if (ui_) { ui_->main->set_connection_dialog_open(false); ui_->wall->set_connection_dialog_open(false); ui_->connection_password_initialized = false; } return; } const bool gallery_dialog = !ui_->gallery_auth_id.empty(); if (ui_->connection_dialog_gallery != gallery_dialog) ui_->connection_password_initialized = false; ui_->connection_dialog_gallery = gallery_dialog; auto& dialog = ui_->Window(gallery_dialog); ui_->Window(!gallery_dialog)->set_connection_dialog_open(false); auto props = FindRemoteSession( ui_->gallery_auth_id.empty() ? focused_remote_id_ : ui_->gallery_auth_id); if (!props) { ui_->main->set_connection_dialog_open(false); ui_->wall->set_connection_dialog_open(false); return; } if (ui_->connection_dialog_remote_id != props->remote_id_) { ui_->connection_password_initialized = false; } ui_->connection_dialog_remote_id = props->remote_id_; const ConnectionStatus status = props->connection_status_.load(); if (status == ConnectionStatus::Connected && !ui_->gallery_auth_id.empty()) { ui_->gallery_auth_id.clear(); show_connection_status_window_ = false; ui_->main->set_connection_dialog_open(false); ui_->wall->set_connection_dialog_open(false); return; } const auto auth = props->connection_auth_.Get(); const bool password_required = auth.password_required || (status == ConnectionStatus::IncorrectPassword && auth.validating); const bool pending = !password_required && (status == ConnectionStatus::Connecting || status == ConnectionStatus::Gathering); std::string text; if (password_required) { text = auth.validating ? localization::validate_password[localization_language_index_] : auth.incorrect_password ? localization::reinput_password[localization_language_index_] : localization::input_password[localization_language_index_]; } else if (auth.error == minirtc::JoinFailureKind::Throttled) { text = localization::connection_auth_throttled[localization_language_index_]; } else if (auth.error == minirtc::JoinFailureKind::Busy) { text = localization::connection_service_busy[localization_language_index_]; } else if (auth.error == minirtc::JoinFailureKind::NotAuthenticated) { text = localization::connection_not_authenticated[localization_language_index_]; } else { text = ConnectionStatusText(status, localization_language_index_); } if (auth.retry_after > 0) { char retry_text[256]; std::snprintf(retry_text, sizeof(retry_text), localization::connection_retry_after[localization_language_index_].c_str(), auth.retry_after); text += "\n" + std::string(retry_text); } dialog->set_connection_dialog_open(true); dialog->set_connection_status_text(UiText(text)); dialog->set_connection_pending(pending); dialog->set_connection_password_required(password_required); dialog->set_connection_validating(auth.validating); if (password_required && !auth.validating && !ui_->connection_password_initialized) { dialog->set_connection_password(props->remote_password_); dialog->set_connection_remember_password(props->remember_password_); ui_->connection_password_initialized = true; } else if (!password_required) { ui_->connection_password_initialized = false; } } void GuiApplication::SyncPlatformDialogs() { #if _WIN32 && CROSSDESK_PORTABLE CheckPortableWindowsService(); const PortableServiceInstallState state = portable_service_install_state_.load(std::memory_order_acquire); if (state == PortableServiceInstallState::succeeded) { portable_service_installed_ = true; } ui_->main->set_portable_service_settings_visible(true); ui_->main->set_portable_service_dialog_open( show_portable_service_install_window_); ui_->main->set_portable_service_suppressed_notice_open( show_portable_service_prompt_suppressed_window_); ui_->main->set_portable_service_installed(portable_service_installed_); ui_->wall->set_portable_service_settings_visible(true); ui_->wall->set_portable_service_installed(portable_service_installed_); ui_->wall->set_portable_service_installing( state == PortableServiceInstallState::installing); ui_->main->set_portable_service_installing( state == PortableServiceInstallState::installing); ui_->main->set_portable_service_succeeded( state == PortableServiceInstallState::succeeded); const std::string* status = nullptr; if (state == PortableServiceInstallState::installing) { status = &localization::installing_windows_service[localization_language_index_]; } else if (state == PortableServiceInstallState::succeeded) { status = &localization::windows_service_install_success [localization_language_index_]; } else if (state == PortableServiceInstallState::failed) { status = &localization::windows_service_install_failed [localization_language_index_]; } ui_->main->set_portable_service_status(status ? UiText(*status) : slint::SharedString{}); if (show_portable_service_install_window_) { if (!ui_->portable_service_dialog_initialized) { ui_->main->set_portable_service_do_not_remind( portable_service_do_not_remind_); ui_->portable_service_dialog_initialized = true; } } else { ui_->portable_service_dialog_initialized = false; } #else ui_->main->set_portable_service_settings_visible(false); ui_->main->set_portable_service_dialog_open(false); ui_->main->set_portable_service_suppressed_notice_open(false); #endif #ifdef __APPLE__ RefreshMacPermissionStatus(false); show_request_permission_window_ = !mac_screen_recording_permission_granted_ || !mac_accessibility_permission_granted_; ui_->main->set_permission_dialog_open(show_request_permission_window_); ui_->main->set_screen_recording_granted( mac_screen_recording_permission_granted_); ui_->main->set_accessibility_granted(mac_accessibility_permission_granted_); #else ui_->main->set_permission_dialog_open(false); #endif } #if defined(__linux__) && !defined(__APPLE__) void GuiApplication::SyncXWaylandWindowActivation() { if (!ui_ || !use_xwayland_gui_) { return; } const auto now = std::chrono::steady_clock::now(); if (now - ui_->last_x11_activation_poll < 100ms) { return; } ui_->last_x11_activation_poll = now; if (!ui_->x11_focus_display) { ui_->x11_focus_display = XOpenDisplay(nullptr); } Display* display = ui_->x11_focus_display; const ::Window active_window = X11GetActiveWindow(display); if (!active_window) { if (!ui_->gallery_control_id.empty()) ReleaseControl(); SetStreamKeyboardFocus(false); return; } if (const auto process_id = X11GetWindowProcessId(display, active_window); process_id && *process_id != static_cast(getpid())) { if (!ui_->gallery_control_id.empty()) ReleaseControl(); ui_->main->set_window_active(false); ui_->wall->set_window_active(false); if (ui_->stream) { (*ui_->stream)->set_window_active(false); } SetStreamKeyboardFocus(false); return; } ui_->main->set_window_active( X11WindowMatches(display, active_window, ui_->main->window())); ui_->wall->set_window_active( X11WindowMatches(display, active_window, ui_->wall->window())); if (!ui_->gallery_control_id.empty()) { if (!X11WindowMatches(display, active_window, ui_->wall->window())) ReleaseControl(); return; } if (ui_->stream) { const bool stream_active = X11WindowMatches(display, active_window, (*ui_->stream)->window()); (*ui_->stream)->set_window_active(stream_active); SetStreamKeyboardFocus(stream_active); } else { SetStreamKeyboardFocus(false); } } #endif void GuiApplication::SetStreamKeyboardFocus(bool focused) { // The local upgrade dialog owns input even while the native window is active. if (focused && ui_ && ui_->stream && (*ui_->stream)->get_remote_update_visible()) { focused = false; } if (focus_on_stream_window_ == focused) { return; } focus_on_stream_window_ = focused; if (!focused) { ReleaseControl(); } } void GuiApplication::SyncStreamKeyboardFocus() { if (ui_ && !ui_->gallery_control_id.empty()) { #if defined(__APPLE__) if (!IsGalleryWindowActive()) ReleaseControl(); #elif defined(_WIN32) if (GetForegroundWindow() != ui_->wall->window().win32_hwnd()) ReleaseControl(); #endif return; } if (!ui_ || !ui_->stream || !(*ui_->stream)->window().is_visible()) { SetStreamKeyboardFocus(false); return; } #if _WIN32 const HWND stream_hwnd = (*ui_->stream)->window().win32_hwnd(); SetStreamKeyboardFocus(stream_hwnd != nullptr && GetForegroundWindow() == stream_hwnd); #elif defined(__APPLE__) SetStreamKeyboardFocus(IsStreamWindowActive()); #endif } void GuiApplication::SyncStreamWindow() { if (!ui_->gallery_control_id.empty()) return; bool has_sessions = false; { std::shared_lock lock(remote_sessions_mutex_); has_sessions = std::any_of(remote_sessions_.begin(), remote_sessions_.end(), [](const auto& item) { return item.second && item.second->tab_opened_; }); } if (!has_sessions) need_to_create_stream_window_ = false; if (need_to_create_stream_window_ && !ui_->stream) { ui_->stream.emplace(ui::StreamWindow::create()); #if defined(__linux__) (*ui_->stream)->set_custom_titlebar(use_xwayland_gui_); #endif RegisterFontAwesome((*ui_->stream)->window()); ConfigureStreamVideoRenderer(); // Slint globals belong to a component tree. Force the next localization // pass to initialize the newly-created, independent stream window tree. ui_->localized_language = -1; (*ui_->stream)->set_tabs(ui_->tab_model); (*ui_->stream)->set_displays(ui_->display_model); (*ui_->stream)->set_file_transfers(ui_->transfer_model); (*ui_->stream)->set_stats_rows(ui_->stats_model); BindStreamCallbacks(); (*ui_->stream)->show(); // Repeat after the next layout pass because the backend resolves the // initial DPI and preferred size asynchronously on some platforms. ui_->stream_initial_position_attempts = 2; #if defined(__APPLE__) ui_->stream_live_resize_configuration_attempts = 30; #endif stream_window_created_ = true; stream_window_inited_ = true; need_to_create_stream_window_ = false; } if (!ui_->stream) { return; } // A wall takeover hides an existing stream window without destroying it. // Opening a recent device must also restore that existing window. if (need_to_create_stream_window_) { (*ui_->stream)->window().set_minimized(false); (*ui_->stream)->show(); need_to_create_stream_window_ = false; } #if _WIN32 ConfigureWindowsWindowIcons((*ui_->stream)->window().win32_hwnd()); #endif (*ui_->stream)->set_window_maximized((*ui_->stream)->window().is_maximized()); #if defined(__APPLE__) if (ui_->stream_live_resize_configuration_attempts > 0) { if (ConfigureStreamWindowLiveResize( (*ui_->stream)->window().appkit_view())) { ui_->stream_live_resize_configuration_attempts = 0; } else { --ui_->stream_live_resize_configuration_attempts; } } #endif if (ui_->video_presenter && ui_->video_presenter->EnsureAttached()) { video_frame_dirty_.store(true, std::memory_order_release); } if (ui_->stream_initial_position_attempts > 0) { if (PositionWindowAtCenter( (*ui_->stream)->window(), ui_->main->window(), kStreamWindowLogicalWidth, StreamWindowLogicalHeight((*ui_->stream)->get_custom_titlebar()))) { --ui_->stream_initial_position_attempts; } else { ui_->stream_initial_position_attempts = 0; } } if (!has_sessions) { SetStreamKeyboardFocus(false); #if defined(__APPLE__) void* stream_view = (*ui_->stream)->window().appkit_view(); SetStreamWindowFullscreen(false); UnregisterStreamWindow(stream_view); #endif if (ui_->video_presenter) { ui_->video_presenter->Detach(); } (*ui_->stream)->hide(); ui_->stream.reset(); stream_window_created_ = false; stream_window_inited_ = false; ui_->stream_initial_position_attempts = 0; ui_->stream_live_resize_configuration_attempts = 0; focused_remote_id_.clear(); controlled_remote_id_.clear(); ui_->tab_order.clear(); ui_->tab_ids.clear(); ui_->tab_model_signature.clear(); return; } std::vector tabs; std::unordered_map tabs_by_id; { std::shared_lock lock(remote_sessions_mutex_); tabs_by_id.reserve(remote_sessions_.size()); for (const auto& [id, props] : remote_sessions_) { if (!props || !props->tab_opened_) { continue; } ui::StreamTab tab; tab.remote_id = UiText(id); tab.title = UiText( props->remote_host_name_.empty() ? id : props->remote_host_name_); tab.connected = props->connection_status_.load() == ConnectionStatus::Connected; tabs_by_id.emplace(id, std::move(tab)); } } std::erase_if(ui_->tab_order, [&tabs_by_id](const std::string& id) { return !tabs_by_id.contains(id); }); for (const auto& [id, _] : tabs_by_id) { if (std::find(ui_->tab_order.begin(), ui_->tab_order.end(), id) == ui_->tab_order.end()) { ui_->tab_order.push_back(id); } } ui_->tab_ids = ui_->tab_order; tabs.reserve(ui_->tab_ids.size()); std::ostringstream tab_signature_builder; for (const auto& id : ui_->tab_ids) { if (auto found = tabs_by_id.find(id); found != tabs_by_id.end()) { tab_signature_builder << id << '\0' << std::string(found->second.title) << '\0' << found->second.connected << '\n'; tabs.push_back(std::move(found->second)); } } const std::string tab_signature = tab_signature_builder.str(); if (ui_->tab_model_signature != tab_signature) { ui_->tab_model_signature = tab_signature; ui_->tab_model->set_vector(std::move(tabs)); #if defined(__APPLE__) // Adding or removing a tab changes the native video's top inset. video_frame_dirty_.store(true, std::memory_order_release); #endif } int selected_index = 0; if (focused_remote_id_.empty() && !ui_->tab_ids.empty()) { focused_remote_id_ = ui_->tab_ids.front(); } for (int i = 0; i < static_cast(ui_->tab_ids.size()); ++i) { if (ui_->tab_ids[i] == focused_remote_id_) { selected_index = i; break; } } (*ui_->stream)->set_selected_tab(selected_index); SelectStreamTab(selected_index); auto props = SelectedSession(); if (!props) { return; } const ConnectionStatus status = props->connection_status_.load(); bool remote_update_available = false; HostPlatform remote_platform = HostPlatform::Unknown; if (status == ConnectionStatus::Connected) { std::lock_guard lock(props->remote_version_mutex_); remote_platform = props->remote_platform_; // Wait for host_info: a version still in transit is not a legacy peer. if (props->remote_host_info_received_ && !props->remote_update_dismissed_) { remote_update_available = props->remote_app_version_.empty() || (update_checker_->latest() && AvailableAppUpdate(props->remote_app_version_, *update_checker_->latest()).has_value()); } } (*ui_->stream)->set_remote_update_visible(remote_update_available); if (remote_update_available) { SetStreamKeyboardFocus(false); } const bool waiting_for_frame = status == ConnectionStatus::Connected && !(*ui_->stream)->get_has_frame(); // Once the peer is connected, the frame-waiting hint replaces the // connection status. Keeping these states mutually exclusive also avoids a // transient overlap when both properties are synchronized in one tick. (*ui_->stream) ->set_status_text(UiText( status == ConnectionStatus::Connected ? std::string{} : ConnectionStatusText(status, localization_language_index_))); (*ui_->stream) ->set_receiving_text(UiText( waiting_for_frame ? localization::receiving_screen[localization_language_index_] : std::string{})); (*ui_->stream)->set_mouse_control_enabled(props->control_mouse_); { std::lock_guard lock(props->privacy_status_mutex_); const auto& status = props->privacy_status_; const uint64_t now = SDL_GetTicks(); const auto controls = GetPrivacyControlState( status, props->privacy_status_received_, props->privacy_command_pending_, now - props->privacy_status_tick_, now - props->privacy_command_tick_); const bool connected = props->connection_status_.load() == ConnectionStatus::Connected; (*ui_->stream)->set_privacy_active(controls.active); (*ui_->stream)->set_privacy_off(connected && controls.off); (*ui_->stream)->set_privacy_unsupported(controls.unsupported); (*ui_->stream)->set_privacy_can_toggle( props->control_mouse_ && connected && controls.can_toggle); const int language = localization_language_index_; std::string text = !connected || !props->privacy_status_received_ ? localization::privacy_screen[language] : controls.unsupported ? localization::privacy_unsupported[language] : controls.enabled ? localization::privacy_on[language] : controls.off ? localization::privacy_off[language] : localization::privacy_screen[language]; (*ui_->stream)->set_privacy_status_text(UiText(text)); } int remote_cursor_shape = static_cast(RemoteCursorShape::default_cursor); bool remote_cursor_active = false; { std::lock_guard lock(props->remote_cursor_state_mutex_); remote_cursor_active = status == ConnectionStatus::Connected && props->control_mouse_ && props->remote_cursor_state_received_; if (remote_cursor_active) { remote_cursor_shape = static_cast( props->cursor_presentation_.Resolve(SDL_GetTicks(), remote_platform)); } } (*ui_->stream)->set_remote_cursor_active(remote_cursor_active); (*ui_->stream)->set_remote_cursor_shape(remote_cursor_shape); (*ui_->stream)->set_audio_enabled(props->audio_capture_button_pressed_); { std::lock_guard lock(props->video_settings_mutex_); if (props->video_settings_pending_ && SDL_GetTicks() - props->video_settings_request_tick_ > 5000) { props->video_settings_pending_ = false; props->video_settings_failed_ = true; const auto request_id = props->video_settings_.request_id; props->video_settings_ = props->applied_video_settings_; props->video_settings_.request_id = request_id; } (*ui_->stream) ->set_video_settings_enabled(status == ConnectionStatus::Connected && props->video_settings_supported_); (*ui_->stream) ->set_video_settings_feedback(UiText( props->video_settings_failed_ ? localization::video_settings_failed [localization_language_index_] : props->video_settings_pending_ ? localization::video_settings_pending [localization_language_index_] : "")); (*ui_->stream)->set_video_quality_index(props->video_settings_.quality); (*ui_->stream) ->set_frame_rate_index(props->video_settings_.frame_rate == 30 ? 0 : 1); (*ui_->stream) ->set_adaptation_policy_index(props->video_settings_.preference); } #if defined(__APPLE__) fullscreen_button_pressed_ = IsStreamWindowFullscreen(); #else fullscreen_button_pressed_ = (*ui_->stream)->window().is_fullscreen(); #endif (*ui_->stream)->set_fullscreen_enabled(fullscreen_button_pressed_); (*ui_->stream)->set_stats_visible(props->net_traffic_stats_button_pressed_); std::vector displays; displays.reserve(props->display_info_list_.size()); for (size_t index = 0; index < props->display_info_list_.size(); ++index) { displays.emplace_back(UiText(localization::FormatDisplayLabel( index, props->display_info_list_[index].name, localization_language_index_))); } ui_->display_model->set_vector(std::move(displays)); (*ui_->stream)->set_selected_display(props->selected_display_); const auto net = props->net_traffic_stats_.Load(); (*ui_->stream)->set_stats_encryption_enabled( status == ConnectionStatus::Connected && net.srtp_active); std::vector stats_rows; stats_rows.reserve(4); const auto append_stats_row = [&](const std::string& label, const auto& inbound, const auto& outbound) { ui::NetworkStatsRow row; row.label = UiText(label); row.inbound = UiText(FormatBitrate(inbound.bitrate)); row.outbound = UiText(FormatBitrate(outbound.bitrate)); row.loss_rate = UiText(FormatLossRate(inbound.loss_rate)); stats_rows.push_back(std::move(row)); }; append_stats_row(localization::video[localization_language_index_], net.video_inbound_stats, net.video_outbound_stats); append_stats_row(localization::audio[localization_language_index_], net.audio_inbound_stats, net.audio_outbound_stats); append_stats_row(localization::data[localization_language_index_], net.data_inbound_stats, net.data_outbound_stats); append_stats_row(localization::total[localization_language_index_], net.total_inbound_stats, net.total_outbound_stats); ui_->stats_model->set_vector(std::move(stats_rows)); (*ui_->stream) ->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" + std::to_string(props->video_height_))); (*ui_->stream) ->set_stats_connection_mode( UiText(props->traversal_mode_ == TraversalMode::P2P ? localization::connection_mode_direct [localization_language_index_] : localization::connection_mode_relay [localization_language_index_])); std::vector file_list; { std::lock_guard lock(props->file_transfer_.file_transfer_list_mutex_); file_list = props->file_transfer_.file_transfer_list_; } const auto transfer_priority = [](RemoteSession::FileTransferStatus status) { switch (status) { case RemoteSession::FileTransferStatus::Sending: return 0; case RemoteSession::FileTransferStatus::Completed: return 1; case RemoteSession::FileTransferStatus::Queued: return 2; case RemoteSession::FileTransferStatus::Failed: return 3; } return 3; }; std::stable_sort(file_list.begin(), file_list.end(), [&](const auto& left, const auto& right) { return transfer_priority(left.status) < transfer_priority(right.status); }); std::vector transfers; transfers.reserve(file_list.size()); for (const auto& info : file_list) { ui::FileTransferEntry item; item.name = UiText(info.file_name); const auto status_index = static_cast(info.status); if (status_index == 0) item.status = UiText(localization::queued[localization_language_index_]); if (status_index == 1) item.status = UiText(localization::sending[localization_language_index_]); if (status_index == 2) item.status = UiText(localization::completed[localization_language_index_]); if (status_index == 3) item.status = UiText(localization::failed[localization_language_index_]); item.progress = status_index == 2 ? 1.0f : info.file_size == 0 ? 0.0f : std::clamp(static_cast(info.sent_bytes) / static_cast(info.file_size), 0.0f, 1.0f); item.speed = UiText(FormatTransferRate(info.rate_bps)); item.size = UiText(FormatFileSize(info.file_size)); transfers.push_back(std::move(item)); } ui_->transfer_model->set_vector(std::move(transfers)); (*ui_->stream) ->set_file_transfer_visible( props->file_transfer_.file_transfer_window_visible_); const auto fps_now = std::chrono::steady_clock::now(); bool refresh_stats = false; if (!props->net_traffic_stats_button_pressed_) { props->fps_ = 0; props->frame_count_ = 0; props->last_time_ = {}; } else if (props->last_time_.time_since_epoch().count() == 0) { props->last_time_ = fps_now; refresh_stats = true; } else { const auto elapsed = std::chrono::duration_cast( fps_now - props->last_time_) .count(); if (elapsed >= 1000) { props->fps_ = static_cast(props->frame_count_ * 1000 / elapsed); props->frame_count_ = 0; props->last_time_ = fps_now; refresh_stats = true; } } (*ui_->stream)->set_stats_fps(UiText(std::to_string(props->fps_))); if (!props->net_traffic_stats_button_pressed_ || status != ConnectionStatus::Connected) { props->video_latency_snapshot_.reset(); } else if (refresh_stats) { props->video_latency_snapshot_ = props->video_latency_->Get(fps_now); } const auto& latency = props->video_latency_snapshot_; (*ui_->stream) ->set_stats_video_latency(UiText( latency ? std::to_string( static_cast(std::lround(latency->average_ms))) + " ms" : "—")); const auto display_rtt = props->net_traffic_stats_.SmoothedRttMs(fps_now); const bool rtt_available = props->net_traffic_stats_button_pressed_ && status == ConnectionStatus::Connected && display_rtt.has_value(); std::string connection_latency = "—"; if (rtt_available) { connection_latency = *display_rtt < 1 ? "<1 ms" : std::to_string(static_cast(std::lround(*display_rtt))) + " ms"; } (*ui_->stream)->set_stats_connection_latency(UiText(connection_latency)); if (refresh_stats && status == ConnectionStatus::Connected) { LOG_DEBUG("Latency stats: remote={} video_ms={} video_samples={} " "rtt_raw_ms={} rtt_display_ms={}", props->remote_id_, latency ? latency->average_ms : -1, latency ? latency->samples : 0, net.rtt_ms, display_rtt.value_or(-1)); } } void GuiApplication::SyncStreamVideoFrame() { if (ui_ && !ui_->gallery_control_id.empty()) return; if (!ui_ || !ui_->stream || !ui_->video_presenter) { return; } auto props = SelectedSession(); if (!props) { return; } SlintVideoPresenter::Frame frame; frame.remote_id = props->remote_id_; { std::lock_guard lock(props->video_frame_mutex_); frame.nv12 = props->front_frame_; frame.width = props->video_width_; frame.height = props->video_height_; frame.sequence = props->video_frame_sequence_; frame.timing = props->video_frame_timing_; } const auto result = ui_->video_presenter->Present(frame); if (result.width > 0 && result.height > 0) { (*ui_->stream) ->set_stats_resolution(UiText(std::to_string(result.width) + "x" + std::to_string(result.height))); } if (result.new_frame) { ++props->frame_count_; } } void GuiApplication::ConfigureStreamVideoRenderer() { if (!ui_ || !ui_->stream || !ui_->video_presenter) { return; } ui_->video_presenter->PrepareWindow( *ui_->stream, [this] { SlintVideoPresenter::SurfaceState state; state.selected_stream = focused_remote_id_; state.fullscreen = fullscreen_button_pressed_; return state; }, [this] { video_frame_dirty_.store(true, std::memory_order_release); }); } void GuiApplication::ScheduleNextVideoFrame() { if (!ui_) { return; } constexpr auto frame_interval = std::chrono::nanoseconds(1'000'000'000 / 60); const auto now = std::chrono::steady_clock::now(); if (next_video_frame_time_ == std::chrono::steady_clock::time_point{} || now - next_video_frame_time_ > 250ms) { next_video_frame_time_ = now; } if (now >= next_video_frame_time_) { const bool frame_dirty = video_frame_dirty_.exchange(false, std::memory_order_acq_rel); const bool native_render_needed = ui_->stream && ui_->video_presenter && ui_->video_presenter->NeedsRedraw(); if (frame_dirty || native_render_needed) { SyncStreamVideoFrame(); } do { next_video_frame_time_ += frame_interval; } while (next_video_frame_time_ <= now); } const auto after_render = std::chrono::steady_clock::now(); const auto remaining = std::max(next_video_frame_time_ - after_render, std::chrono::steady_clock::duration::zero()); const auto delay_ns = std::chrono::duration_cast(remaining).count(); const auto delay_ms = std::max(1, (delay_ns + 999'999) / 1'000'000); ui_->video_timer.start(slint::TimerMode::SingleShot, std::chrono::milliseconds(delay_ms), [this] { ScheduleNextVideoFrame(); }); } void GuiApplication::SetServerPanelExpanded(bool expanded) { if (!ui_->server || !ui_->server_bar) return; server_window_collapsed_ = !expanded; (*ui_->server_bar)->set_expanded(expanded); if (expanded) { PositionServerWindows(); (*ui_->server)->show(); #if _WIN32 ConfigureWindowsServerWindow((*ui_->server)->window()); #endif PositionServerWindows(); } else { (*ui_->server)->set_confirming_disconnect(false); (*ui_->server)->hide(); } } void GuiApplication::PositionServerWindows() { if (!ui_->server || !ui_->server_bar) return; auto& bar = *ui_->server_bar; auto& panel = *ui_->server; const float scale = std::max(bar->window().scale_factor(), 0.01f); SDL_Rect bounds{}; if (!SDL_GetDisplayUsableBounds(ui_->server_display, &bounds)) { ui_->server_display = SDL_GetPrimaryDisplay(); if (!SDL_GetDisplayUsableBounds(ui_->server_display, &bounds)) return; } // Cocoa/SDL uses points; Windows and X11 report desktop pixels. #if defined(__APPLE__) const float desktop_scale = 1; #else const float desktop_scale = scale; #endif const server_window_state::Rect screen{ bounds.x / desktop_scale, bounds.y / desktop_scale, bounds.w / desktop_scale, bounds.h / desktop_scale}; float pointer_x = 0, pointer_y = 0; bool pressed = false; const bool pointer_valid = CanUseGlobalPointerPosition() && ui_->server_drag.ReadGlobalPointer(&pointer_x, &pointer_y, &pressed); if (pointer_valid && ui_->server_drag.active) { const float delta = (pointer_y - ui_->server_drag.pointer_start_y) / desktop_scale; if (std::abs(delta) >= 4) { ui_->server_drag_moved = true; bar->set_dragged(true); } if (ui_->server_drag_moved) { const float travel = std::max(1.0f, screen.height - 52.0f); ui_->server_anchor = std::clamp(ui_->server_drag_anchor + delta / travel, 0.0f, 1.0f); } if (!pressed) ui_->server_drag.active = false; } ui_->server_layout = server_window_state::PlacePanel( screen, ui_->server_anchor, bar->get_bar_width(), panel->get_content_height()); const auto& layout = ui_->server_layout; bar->set_opens_above(layout.above); const auto apply = [scale](slint::Window& window, const server_window_state::Rect& rect) { const auto size = window.size(); const float own_scale = std::max(window.scale_factor(), 0.01f); if (std::abs(size.width / own_scale - rect.width) > 0.5f || std::abs(size.height / own_scale - rect.height) > 0.5f) { window.set_size(slint::LogicalSize(slint::Size{rect.width, rect.height})); } if (!CanUseGlobalPointerPosition()) return; const auto position = window.position(); if (std::abs(position.x / scale - rect.x) <= 0.5f && std::abs(position.y / scale - rect.y) <= 0.5f) return; #if defined(__APPLE__) window.set_position(slint::LogicalPosition(slint::Point{rect.x, rect.y})); #else window.set_position(slint::PhysicalPosition(slint::Point{ static_cast(std::lround(rect.x * scale)), static_cast(std::lround(rect.y * scale))})); #endif }; apply(bar->window(), layout.bar); apply(panel->window(), layout.panel); if (pointer_valid) { const float x = pointer_x / desktop_scale; const float y = pointer_y / desktop_scale; if (pressed && !ui_->server_pointer_pressed && !server_window_collapsed_ && !ui_->server_file_dialog_open && !ui_->server_drag.active && !server_window_state::Contains(layout.bar, x, y) && !server_window_state::Contains(layout.panel, x, y)) { SetServerPanelExpanded(false); } ui_->server_pointer_pressed = pressed; } } void GuiApplication::SyncServerWindow() { // The connection map is authoritative. Lifecycle flags record callback // intent, but callbacks for different controllers can cross each other, so // the final decision must reflect the current connected-controller set. need_to_create_server_window_.exchange(false, std::memory_order_acq_rel); need_to_destroy_server_window_.exchange(false, std::memory_order_acq_rel); bool has_connected_controller = false; { std::shared_lock lock(connection_status_mutex_); has_connected_controller = std::any_of(connection_status_.begin(), connection_status_.end(), [](const auto& entry) { return entry.second == ConnectionStatus::Connected; }); } // Privacy protects local display output while preserving capture. // Keep the panel alive throughout privacy transitions so remote // controllers can still see and operate it without resetting its UI state. bool show_controller_window = has_connected_controller; #if !defined(_WIN32) && !defined(__APPLE__) && !defined(__linux__) // Preserve the existing panel behavior on other platforms. const auto privacy_status = privacy_.Snapshot(); const bool privacy_covering = privacy_status.overlay_active || privacy_status.state == PrivacyState::starting || privacy_status.state == PrivacyState::stopping; show_controller_window = show_controller_window && !privacy_covering; #endif if (show_controller_window && !ui_->server) { ui_->server.emplace(ui::ServerWindow::create()); ui_->server_bar.emplace(ui::ServerStatusBar::create()); RegisterFontAwesome((*ui_->server)->window()); RegisterFontAwesome((*ui_->server_bar)->window()); (*ui_->server)->set_controllers(ui_->controller_model); server_window_collapsed_ = true; ui_->server_display = DisplayForSlintWindow(ui_->main->window()); ui_->server_anchor = 0.3f; ui_->server_drag.active = false; ui_->server_drag_moved = false; ui_->server_pointer_pressed = false; ui_->server_suspended_for_dialog = false; BindServerCallbacks(); ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_); ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_); (*ui_->server_bar)->show(); #if _WIN32 ui_->server_native_window_attempts = 30; #endif server_window_created_ = true; server_window_inited_ = true; } if (!show_controller_window && ui_->server) { (*ui_->server)->hide(); (*ui_->server_bar)->hide(); // Keep handles alive through a native file dialog's nested event loop. if (ui_->server_file_dialog_open) { server_window_collapsed_ = true; (*ui_->server_bar)->set_expanded(false); (*ui_->server)->set_confirming_disconnect(false); ui_->server_suspended_for_dialog = true; return; } ui_->server.reset(); ui_->server_bar.reset(); ui_->server_drag.active = false; server_window_created_ = false; server_window_inited_ = false; #if _WIN32 ui_->server_native_window_attempts = 0; #endif } if (!ui_->server) return; if (ui_->server_suspended_for_dialog) { (*ui_->server_bar)->show(); ui_->server_suspended_for_dialog = false; } std::vector controllers; { std::shared_lock lock(connection_status_mutex_); controllers.reserve(connection_status_.size()); for (const auto& [id, status] : connection_status_) { if (status != ConnectionStatus::Connected) continue; const auto host = connection_host_names_.find(id); const std::string name = host != connection_host_names_.end() && !host->second.empty() ? host->second : id; controllers.push_back({UiText(id), UiText(name)}); } } // Stable ordering and model identity preserve radio selection, hover and // scrolling while the 16 ms UI timer reconciles asynchronous peer events. std::sort(controllers.begin(), controllers.end(), [](const auto& a, const auto& b) { return std::string(a.remote_id) < std::string(b.remote_id); }); std::vector ids; std::string signature; for (const auto& controller : controllers) { ids.emplace_back(controller.remote_id); signature += std::string(controller.remote_id) + '\0' + std::string(controller.display_name) + '\0'; } if (ids != ui_->controller_ids) (*ui_->server)->set_confirming_disconnect(false); ui_->controller_ids = std::move(ids); if (signature != ui_->controller_model_signature) { ui_->controller_model->set_vector(std::move(controllers)); ui_->controller_model_signature = std::move(signature); } const int selected = server_window_state::ReconcileSelectedController( ui_->controller_ids, &selected_server_remote_id_); (*ui_->server)->set_selected_controller(selected); (*ui_->server_bar)->set_controller_count(static_cast(ui_->controller_ids.size())); ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_); ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_); #if _WIN32 if (ui_->server_native_window_attempts > 0) { if (ConfigureWindowsServerWindow((*ui_->server_bar)->window())) ui_->server_native_window_attempts = 0; else --ui_->server_native_window_attempts; } #endif auto& transfer = transfers_.global_state(); const auto sent = transfer.file_sent_bytes_.load(); const auto total = transfer.file_total_bytes_.load(); (*ui_->server) ->set_file_transfer_visible(transfer.file_transfer_window_visible_); (*ui_->server)->set_sending_file(transfer.file_sending_.load()); (*ui_->server) ->set_file_progress(total == 0 ? 0.0f : std::clamp(static_cast(sent) / static_cast(total), 0.0f, 1.0f)); (*ui_->server) ->set_file_progress_text( UiText(FormatTransferRate(transfer.file_send_rate_bps_.load()))); std::string current_file_name; const uint32_t current_file_id = transfer.current_file_id_.load(); if (current_file_id != 0) { std::lock_guard lock(transfer.file_transfer_list_mutex_); for (const auto& info : transfer.file_transfer_list_) { if (info.file_id == current_file_id) { current_file_name = info.file_name; break; } } } (*ui_->server)->set_current_file_name(UiText(current_file_name)); (*ui_->server) ->set_file_size_text(total == 0 ? slint::SharedString{} : UiText(FormatFileSize(sent) + " / " + FormatFileSize(total))); PositionServerWindows(); } bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) { auto& main = ui_->Window(gallery_window); const auto address = minirtc::ParseSignalServerAddress( std::string(main->get_server_host())); if (!address) { main->set_self_host_settings_error(UiText( localization::self_hosted_address_invalid[localization_language_index_])); return false; } const auto port = address->port ? address->port : ParsePort(main->get_server_port()); if (!port) { main->set_self_host_settings_error(UiText( localization::self_hosted_port_invalid[localization_language_index_])); return false; } main->set_server_host(UiText(address->Address())); main->set_server_port(UiText(std::to_string(*port))); main->set_self_host_settings_error(""); return true; } bool GuiApplication::SaveSettingsFromUi(bool gallery_window) { const auto previous_server = GetSignalServerSettings(*config_center_); auto& main = ui_->Window(gallery_window); const bool has_endpoint = main->get_self_hosted_enabled() || !std::string(main->get_server_host()).empty() || !std::string(main->get_server_port()).empty(); if (has_endpoint && !ValidateSelfHostedSettingsFromUi(gallery_window)) { main->set_settings_tab(0); main->set_self_host_settings_open(true); return false; } const auto wall_settings = gallery::StreamSettings::FromIndices( main->get_gallery_resolution_draft(), main->get_gallery_frame_rate_draft(), main->get_gallery_bandwidth_draft()); const bool wall_changed = wall_settings && (wall_settings->resolution != ui_->gallery.stream_settings.resolution || wall_settings->frame_rate != ui_->gallery.stream_settings.frame_rate || wall_settings->bandwidth_mbps != ui_->gallery.stream_settings.bandwidth_mbps); auto updated_wall = ui_->gallery; if (wall_settings) updated_wall.stream_settings = *wall_settings; // Persist the draft first, but leave live sessions unchanged until the // common save succeeds. Restore the previous wall file if it fails. if (!wall_settings || (wall_changed && !updated_wall.Save(ui_->gallery_path))) { main->set_gallery_settings_error(UiText( localization_language_index_ == 0 ? "保存失败,请重试" : localization_language_index_ == 2 ? "Не удалось сохранить" : "Could not save; try again")); main->set_settings_tab(1); return false; } if ((has_endpoint && config_center_->SetServerEndpoint( std::string(main->get_server_host()), *ParsePort(main->get_server_port())) != 0) || config_center_->SetSelfHosted(main->get_self_hosted_enabled()) != 0) { if (wall_changed && !ui_->gallery.Save(ui_->gallery_path)) LOG_ERROR("Failed to restore DeskHub settings after common save failure"); main->set_self_host_settings_error(UiText( localization::self_hosted_save_failed[localization_language_index_])); main->set_settings_tab(0); main->set_self_host_settings_open(true); 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); enable_hardware_video_codec_ = ConfigCenter::IsHardwareVideoCodecAvailable() && main->get_hardware_codec_enabled(); enable_turn_ = main->get_turn_enabled(); enable_self_hosted_ = main->get_self_hosted_enabled(); enable_autostart_ = main->get_autostart_enabled(); enable_daemon_ = main->get_daemon_enabled(); localization_language_ = static_cast(language_button_value_); localization_language_index_ = language_button_value_; config_center_->SetLanguage(localization_language_); privacy_.SetText( {localization::privacy_screen_unlock_hint[localization_language_index_]}); config_center_->SetVideoEncodeFormat( static_cast( video_encode_format_button_value_)); config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_); config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled()); main->set_force_relay_enabled(config_center_->IsForceRelay()); config_center_->SetAutostart(enable_autostart_); config_center_->SetDaemon(enable_daemon_); if (config_center_->SetSaveRemotePreviews(main->get_save_remote_previews()) != 0) { LOG_ERROR("Failed to save remote preview preference"); } if (thumbnail_->SetSavePreviews(config_center_->IsSaveRemotePreviews()) != 0) { LOG_ERROR("Failed to clear saved remote previews"); } reload_recent_connections_ = true; #ifdef _WIN32 if (!HasActiveSession()) { const auto previous_capture_method = config_center_->GetScreenCaptureMethod(); const auto capture_method = static_cast( std::clamp(main->get_capture_method_index(), 0, 3)); if (config_center_->SetScreenCaptureMethod(capture_method) != 0) { LOG_ERROR("Failed to save screen capture method"); } else if (capture_method != previous_capture_method) { // Reconfigure only capture; its backend does not own signaling. devices_.StopScreenCapturer(); screen_capturer_is_started_ = false; if (devices_.InitializeScreenCapturer() != 0) { LOG_ERROR("Failed to apply screen capture method"); } } } main->set_capture_method_index( static_cast(config_center_->GetScreenCaptureMethod())); #endif #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) != 0) { main->set_privacy_on_connect_enabled( config_center_->IsEnablePrivacyScreen()); } #endif const std::string path(main->get_file_save_path()); std::memset(file_transfer_save_path_buf_, 0, sizeof(file_transfer_save_path_buf_)); std::strncpy(file_transfer_save_path_buf_, path.c_str(), sizeof(file_transfer_save_path_buf_) - 1); config_center_->SetFileTransferSavePath(path); if (wall_changed) { ui_->gallery.stream_settings = *wall_settings; std::shared_lock lock(remote_sessions_mutex_); for (const auto& [_, props] : remote_sessions_) if (props && !props->closing_ && props->wall_managed_) props->preview_needs_sync_ = true; ui_->gallery_synced = {}; } ApplySettingsFromConfig(previous_server != GetSignalServerSettings(*config_center_)); ResetSettingsUi(gallery_window); return true; } void GuiApplication::ApplySettingsFromConfig(bool reconnect) { language_button_value_ = static_cast(config_center_->GetLanguage()); localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_); localization_language_ = config_center_->GetLanguage(); video_encode_format_button_value_ = static_cast(config_center_->GetVideoEncodeFormat()); enable_hardware_video_codec_ = config_center_->IsHardwareVideoCodec(); enable_turn_ = config_center_->IsEnableTurn(); enable_self_hosted_ = config_center_->IsSelfHosted(); enable_autostart_ = config_center_->IsEnableAutostart(); enable_daemon_ = config_center_->IsEnableDaemon(); privacy_.SetText({localization::privacy_screen_unlock_hint[localization_language_index_]}); const auto path = config_center_->GetFileTransferSavePath(); std::snprintf(file_transfer_save_path_buf_, sizeof(file_transfer_save_path_buf_), "%s", path.c_str()); const auto host = config_center_->GetSignalServerHost(); std::snprintf(signal_server_ip_self_, sizeof(signal_server_ip_self_), "%s", host.c_str()); const int port = config_center_->GetSignalServerPort(); const auto port_text = port > 0 ? std::to_string(port) : std::string{}; std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str()); language_button_value_last_ = language_button_value_; video_encode_format_button_value_last_ = video_encode_format_button_value_; enable_hardware_video_codec_last_ = enable_hardware_video_codec_; enable_turn_last_ = enable_turn_; enable_self_hosted_last_ = enable_self_hosted_; enable_autostart_last_ = enable_autostart_; enable_daemon_last_ = enable_daemon_; file_transfer_save_path_last_ = path; #ifdef __linux__ HeadlessConsole::Instance().SetLanguage(localization_language_index_); #endif if (ui_) { ui_->localized_language = -1; if (!ui_->main->get_settings_open()) ResetSettingsUi(); if (!ui_->wall->get_settings_open()) ResetSettingsUi(true); } if (reconnect && !HasActiveSession()) { // Tick recreates the peer after asynchronous cleanup, using the committed // configuration. Keep the application and its console running throughout. CloseAllRemoteSessions(); } else { const bool hardware = config_center_->IsHardwareVideoCodec(); const bool av1 = config_center_->GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1; const auto turn = static_cast(config_center_->GetTurnMode()); if (hardware != params_.hardware_acceleration || av1 != params_.av1_encoding || turn != params_.turn_mode) { // Update the listening peer for incoming connections and the template // copied by ConnectTo for outgoing connections. Existing sessions retain // the codec/ICE snapshot with which they negotiated. if (!peer_ || UpdateConnectionSettings(peer_, hardware, av1, turn) == 0) { params_.hardware_acceleration = hardware; params_.av1_encoding = av1; params_.turn_mode = turn; } else { LOG_ERROR("Failed to apply connection settings without reconnecting"); } } } } void GuiApplication::ConnectFromUi(const std::string& input) { ui_->gallery_auth_id.clear(); const std::string remote_id = CompactPeerId(input); if (!gallery::Workspace::ValidId(remote_id)) { ui_->main->set_connection_input_error( UiText(localization_language_index_ == 0 ? "请输入有效的 9 位设备 ID" : localization_language_index_ == 2 ? "Введите корректный 9-значный ID устройства" : "Enter a valid 9-digit device ID")); return; } ui_->main->set_connection_input_error(""); if (auto existing = FindRemoteSession(remote_id); existing && !existing->closing_ && existing->connection_established_) { ReleaseControl(); existing->tab_opened_ = true; focused_remote_id_ = remote_id; need_to_create_stream_window_ = true; return; } for (const auto& [_, connection] : recent_connections_) { if (connection.remote_id == remote_id) { ConnectTo(remote_id, connection.password.c_str(), connection.remember_password); return; } } ConnectTo(remote_id, "", false); } void GuiApplication::SelectStreamTab(int index) { if (!ui_ || index < 0 || index >= static_cast(ui_->tab_ids.size())) { return; } const std::string selected_remote_id = ui_->tab_ids[index]; const bool selection_changed = focused_remote_id_ != selected_remote_id; bool renderer_selection_changed = false; if (!controlled_remote_id_.empty() && controlled_remote_id_ != selected_remote_id) ReleaseControl(); focused_remote_id_ = selected_remote_id; controlled_remote_id_ = selected_remote_id; if (ui_->video_presenter) { renderer_selection_changed = ui_->video_presenter->SelectStream(selected_remote_id); } if (renderer_selection_changed) { video_frame_dirty_.store(true, std::memory_order_release); } if (selection_changed && ui_->stream) { (*ui_->stream)->set_has_frame(false); } std::shared_ptr selected_session; { std::lock_guard delivery_lock(audio_delivery_mutex_); std::shared_lock lock(remote_sessions_mutex_); for (auto& [id, props] : remote_sessions_) { if (props) { props->tab_selected_ = id == focused_remote_id_; props->clipboard_allowed_ = props->tab_selected_ && props->control_mouse_ && focus_on_stream_window_; } } const auto selected = remote_sessions_.find(selected_remote_id); if (selected != remote_sessions_.end()) { selected_session = selected->second; } start_keyboard_capturer_ = selected_session && selected_session->control_mouse_ && selected_session->connection_status_.load() == ConnectionStatus::Connected; } SyncAudioPlayback(); } void GuiApplication::ReorderStreamTab(int from, float drop_x, float tab_width) { if (!ui_ || from < 0 || from >= static_cast(ui_->tab_order.size()) || tab_width <= 0.0f) { return; } const float slot_width = tab_width + 1.0f; const int target = std::clamp(static_cast(std::floor(drop_x / slot_width)), 0, static_cast(ui_->tab_order.size()) - 1); if (target == from) { return; } std::string moved = std::move(ui_->tab_order[from]); ui_->tab_order.erase(ui_->tab_order.begin() + from); ui_->tab_order.insert(ui_->tab_order.begin() + target, std::move(moved)); ui_->tab_ids = ui_->tab_order; SelectStreamTab(target); } void GuiApplication::CloseRemoteClosedTabs() { std::vector closed; { std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, props] : remote_sessions_) { if (props && props->remote_close_pending_.exchange(false)) { closed.push_back(id); } } } // CloseRemoteSession drains transport callbacks and takes the session map // lock. Do this on the UI thread, outside both the callback and map lock. for (const auto& id : closed) CloseStreamTab(id); } void GuiApplication::CloseStreamTab(const std::string& remote_id) { // A user disconnect also cancels work that could reconnect this device. // Mark in place: this can run while SyncGallery is iterating the queue. for (auto& operation : ui_->batch_operations) { if (operation.id == remote_id) { operation.state = SlintUi::BatchOperation::State::Finished; operation.password.reset(); } } CancelPendingConnection(remote_id); if (ui_->gallery_requested_auth == remote_id) ui_->gallery_requested_auth.clear(); if (ui_->gallery_auth_id == remote_id) { ui_->gallery_auth_id.clear(); show_connection_status_window_ = false; } std::shared_ptr props; { std::shared_lock lock(remote_sessions_mutex_); if (const auto found = remote_sessions_.find(remote_id); found != remote_sessions_.end()) { props = found->second; } } if (!props) { return; } if (controlled_remote_id_ == remote_id) ReleaseControl(); keyboard_.ReleaseRemotePressedKeys(remote_id, "stream_tab_closed"); CloseRemoteSession(props); ResetRemoteSessionResources(props); { std::unique_lock lock(remote_sessions_mutex_); remote_sessions_.erase(remote_id); } if (ui_->video_presenter) { ui_->video_presenter->ForgetStream(remote_id); } if (focused_remote_id_ == remote_id) { focused_remote_id_.clear(); controlled_remote_id_.clear(); if (ui_->video_presenter && ui_->video_presenter->SelectStream({})) { video_frame_dirty_.store(true, std::memory_order_release); } } } std::shared_ptr GuiApplication::SelectedSession() { if (ui_ && !ui_->gallery_control_id.empty()) return FindRemoteSession(ui_->gallery_control_id); std::shared_lock lock(remote_sessions_mutex_); auto found = remote_sessions_.find(focused_remote_id_); if (found != remote_sessions_.end()) { return found->second; } for (const auto& [_, props] : remote_sessions_) { if (props && props->tab_selected_) { return props; } } return nullptr; } void GuiApplication::SendPointerInput(int button, int kind, float x, float y) { auto props = SelectedSession(); if (!props || !props->peer_ || !props->control_mouse_ || (!ui_->stream && ui_->gallery_control_id.empty())) { return; } std::optional point; { std::lock_guard lock(props->video_frame_mutex_); point = MapVideoPoint(x, y, ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_width() : ui_->wall->get_gallery_video_width(), ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_height() : ui_->wall->get_gallery_video_height(), props->video_width_, props->video_height_); } if (kind == 0) { ReleaseControl(); return; } if (kind == 2 && !ui_->pressed_mouse_buttons.count(button)) return; if (!point) { if (kind != 2) return; point = VideoPoint{ui_->pointer_x, ui_->pointer_y}; } RemoteAction action{}; action.type = ControlType::mouse; action.m.x = point->x; action.m.y = point->y; // Slint enum order: Cancel=0, Down=1, Up=2, Move=3 and // Other=0, Left=1, Right=2, Middle=3. if (kind == 3) { action.m.flag = MouseFlag::move; } else if (kind == 1) { if (button == 1) action.m.flag = MouseFlag::left_down; else if (button == 2) action.m.flag = MouseFlag::right_down; else if (button == 3) action.m.flag = MouseFlag::middle_down; else return; } else if (kind == 2) { if (button == 1) action.m.flag = MouseFlag::left_up; else if (button == 2) action.m.flag = MouseFlag::right_up; else if (button == 3) action.m.flag = MouseFlag::middle_up; else return; } else { return; } if (kind == 1) ui_->pressed_mouse_buttons.insert(button); if (kind == 2) ui_->pressed_mouse_buttons.erase(button); ui_->pointer_x = point->x; ui_->pointer_y = point->y; controlled_remote_id_ = props->remote_id_; const std::string message = action.to_json(); { std::lock_guard lock(props->remote_cursor_state_mutex_); props->cursor_presentation_.NoteInput(SDL_GetTicks()); } SendReliableDataFrame(props->peer_, message.c_str(), message.size(), props->mouse_label_.c_str()); } void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x, float y) { auto props = SelectedSession(); if (!props || !props->peer_ || !props->control_mouse_ || (!ui_->stream && ui_->gallery_control_id.empty())) { return; } std::optional point; { std::lock_guard lock(props->video_frame_mutex_); point = MapVideoPoint(x, y, ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_width() : ui_->wall->get_gallery_video_width(), ui_->gallery_control_id.empty() ? (*ui_->stream)->get_video_area_height() : ui_->wall->get_gallery_video_height(), props->video_width_, props->video_height_); } if (!point) { return; } RemoteAction action{}; action.type = ControlType::mouse; action.m.x = point->x; action.m.y = point->y; if (std::abs(delta_y) >= std::abs(delta_x)) { action.m.flag = MouseFlag::wheel_vertical; action.m.s = delta_y > 0 ? 1 : delta_y < 0 ? -1 : 0; } else { action.m.flag = MouseFlag::wheel_horizontal; action.m.s = delta_x > 0 ? 1 : delta_x < 0 ? -1 : 0; } if (action.m.s == 0) { return; } { std::lock_guard lock(props->remote_cursor_state_mutex_); props->cursor_presentation_.NoteInput(SDL_GetTicks()); } controlled_remote_id_ = props->remote_id_; const std::string message = action.to_json(); SendReliableDataFrame(props->peer_, message.c_str(), message.size(), props->mouse_label_.c_str()); } void GuiApplication::SendKeyInput(const std::string& text, bool pressed, bool control, bool alt, bool shift, bool meta) { (void)control; (void)alt; (void)shift; (void)meta; auto props = SelectedSession(); if (!props || !props->peer_ || !props->control_mouse_ || props->connection_status_.load() != ConnectionStatus::Connected) { return; } controlled_remote_id_ = props->remote_id_; focused_remote_id_ = props->remote_id_; // Native capture forwards the event independently. Slint keyboard events // are used only by platforms whose native capture backend is unavailable. if (keyboard_capturer_is_started_ && !keyboard_capturer_uses_window_events_) { return; } const int virtual_key = SlintKeyToWindowsVk(text); if (virtual_key >= 0) { keyboard_.SendKeyCommand(virtual_key, pressed); } } std::string GuiApplication::OpenFileDialog(const std::string& title) { #if defined(__APPLE__) return OpenNativeFileDialog(title); #else const char* path = tinyfd_openFileDialog(title.c_str(), "", 0, nullptr, nullptr, 0); return path ? path : ""; #endif } bool GuiApplication::OpenUrl(const std::string& url) { return SDL_OpenURL(url.c_str()); } void GuiApplication::SyncAudioPlayback() { const bool wall_visible = ui_ && ui_->wall->window().is_visible() && !ui_->wall->window().is_minimized(); const bool stream_active = ui_ && ui_->stream && focus_on_stream_window_ && (*ui_->stream)->window().is_visible() && !(*ui_->stream)->window().is_minimized(); std::lock_guard delivery_lock(audio_delivery_mutex_); std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, props] : remote_sessions_) { const bool allowed = !props->closing_ && props->connection_status_ == ConnectionStatus::Connected && props->audio_capture_button_pressed_ && ((wall_visible && props->wall_managed_) || (stream_active && props->tab_opened_ && props->tab_selected_)); if (props->audio_allowed_.exchange(allowed) != allowed) props->preview_needs_sync_ = true; if (!allowed) devices_.ClearAudio(id); } } void GuiApplication::ReleasePressedInput() { keyboard_.ForceReleasePressedKeys(); if (auto props = FindRemoteSession(controlled_remote_id_); props && props->peer_) { for (int button : ui_->pressed_mouse_buttons) { RemoteAction action{}; action.type = ControlType::mouse; action.m.x = ui_->pointer_x; action.m.y = ui_->pointer_y; action.m.flag = button == 1 ? MouseFlag::left_up : button == 2 ? MouseFlag::right_up : MouseFlag::middle_up; const auto message = action.to_json(); SendReliableDataFrame(props->peer_, message.data(), message.size(), props->mouse_label_.c_str()); } } ui_->pressed_mouse_buttons.clear(); } void GuiApplication::ReleaseControl() { const auto gallery_target = ui_->gallery_control_id; devices_.StopKeyboardCapturer(); ReleasePressedInput(); if (!ui_->gallery_control_id.empty()) { ui_->gallery_control_id.clear(); ui_->wall->set_gallery_controlling(false); } SyncAudioPlayback(); std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, props] : remote_sessions_) { ++props->input_generation_; props->clipboard_allowed_ = false; if (id == gallery_target) { props->control_mouse_ = false; props->enable_mouse_control_ = false; } } controlled_remote_id_.clear(); start_keyboard_capturer_ = false; } void GuiApplication::ShowGalleryWindow() { if (!ui_) return; auto& window = ui_->wall->window(); if (!window.is_visible()) { ReleaseControl(); std::vector> sessions; { std::shared_lock lock(remote_sessions_mutex_); for (const auto& [_, props] : remote_sessions_) if (props && !props->closing_) sessions.push_back(props); } for (const auto& props : sessions) MuteRemoteAudio(props); if (!ui_->gallery_shown) { ui_->gallery_window.BeginRestore(); RestoreWindowGeometry(true, true); ui_->gallery_shown = true; } need_to_send_recent_connections_ = true; ui_->wall->show(); } window.set_minimized(false); #if defined(__APPLE__) ConfigureMainWindowChrome(window.appkit_view(), true); MacActivateWindow(window.appkit_view()); ui_->gallery_native_titlebar_attempts = 30; #elif defined(_WIN32) ConfigureWindowsWindowIcons(window.win32_hwnd()); SetForegroundWindow(window.win32_hwnd()); #else if (!ui_->x11_focus_display) ui_->x11_focus_display = XOpenDisplay(nullptr); X11ActivateGalleryWindow(ui_->x11_focus_display); #endif ui_->gallery_synced = {}; SyncGallery(); } void GuiApplication::HideGalleryWindow() { if (!ui_) return; ReleaseControl(); ui_->wall->invoke_reset_gallery_transient(); ui_->wall->set_alias_open(false); ui_->wall->set_delete_open(false); ui_->wall->set_device_group_open(false); ui_->wall->set_self_host_settings_open(false); ui_->wall->set_settings_open(false); ui_->wall->set_about_open(false); ui_->wall->set_messages_open(false); ResetSettingsUi(true); ui_->gallery_requested_auth.clear(); if (!ui_->gallery_auth_id.empty()) { ui_->gallery_auth_id.clear(); show_connection_status_window_ = false; ui_->wall->set_connection_dialog_open(false); } SyncWindowGeometry(true); ui_->gallery_transition.Cancel(); ui_->wall->set_gallery_transitioning(false); ui_->window_saved = {}; SaveGalleryWindowState(); #if defined(__APPLE__) // Restore application presentation options while the wall is hidden. Its // remembered fullscreen preference is applied again on the next open. if (IsGalleryWindowFullscreen()) { SetGalleryWindowFullscreen(false); ui_->gallery_window.BeginRestore(); } #endif ui_->wall->hide(); ui_->gallery_synced = {}; SyncGallery(); } void GuiApplication::ToggleGalleryFullscreen() { if (!ui_ || !ui_->wall->window().is_visible() || ui_->gallery_transition.Active()) return; #if defined(__APPLE__) const bool fullscreen = IsGalleryWindowFullscreen(); #else const bool fullscreen = ui_->wall->window().is_fullscreen(); #endif const bool target = !(ui_->gallery_window.Restoring() ? ui_->gallery_window.wall_fullscreen : fullscreen); ui_->gallery_transition.Begin(target, std::chrono::steady_clock::now()); // Fullscreen only changes presentation. End held gestures before blocking // input, but retain the controlled session and its keyboard focus. if (!ui_->gallery_control_id.empty()) ReleasePressedInput(); ui_->wall->set_gallery_transitioning(true); ui_->gallery_synced = {}; } void GuiApplication::SaveGalleryWindowState() { if (ui_->wall->get_gallery_resizing() || ui_->connection_window.Restoring() || (ui_->wall->window().is_visible() && ui_->gallery_window.Restoring()) || ui_->gallery_transition.Active()) return; const auto now = std::chrono::steady_clock::now(); if (now - ui_->window_saved > 1s || exit_) { const float scale = ui_->loaded_window_scale[1] > 0 ? ui_->loaded_window_scale[1] : std::max(ui_->wall->window().scale_factor(), 1.0f); const float connection_scale = std::max(ui_->main->window().scale_factor(), 1.0f); nlohmann::json state{{"version", 2}, {"tile_width", ui_->wall->get_gallery_tile_width()}, {"focus_rail_height", ui_->wall->get_gallery_focus_rail_height()}, {"fullscreen", ui_->gallery_window.wall_fullscreen}, {"maximized", ui_->gallery_window.wall_maximized}}; if (ui_->gallery_window.wall_size) { state["width"] = int(std::lround(ui_->gallery_window.wall_size->width)); state["height"] = int(std::lround(ui_->gallery_window.wall_size->height)); } if (ui_->gallery_window.wall_position) { state["wall_x"] = ui_->gallery_window.wall_position->x / scale; state["wall_y"] = ui_->gallery_window.wall_position->y / scale; } if (ui_->connection_window.connection_position) { state["connection_x"] = ui_->connection_window.connection_position->x / connection_scale; state["connection_y"] = ui_->connection_window.connection_position->y / connection_scale; } const auto text = state.dump(); if (text != ui_->saved_window_signature && gallery::WriteAtomically(ui_->gallery_path.string() + ".window", text)) ui_->saved_window_signature = text; ui_->window_saved = now; } } bool GuiApplication::AddGalleryDevice(const std::string& input, const std::string& password, const std::string& group, bool remember_password) { const auto error = [this](const char* zh, const char* en, const char* ru) { ui_->wall->set_gallery_notice(UiText( localization_language_index_ == 0 ? zh : localization_language_index_ == 2 ? ru : en)); return false; }; const auto id = gallery::Workspace::NormalizeId(input); if (!gallery::Workspace::ValidId(id)) return error("请输入有效的 9 位设备 ID", "Enter a valid 9-digit device ID", "Введите корректный 9-значный ID устройства"); if (password.empty() || password.size() > 6 || password.find_first_not_of( "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") != std::string::npos) return error("请输入不超过 6 位的字母或数字密码", "Use a password of 1–6 letters or digits", "Пароль: 1–6 латинских букв или цифр"); if (!signal_connected_) return error("尚未连接服务器,请稍后重试", "Not connected to the server; try again shortly", "Нет соединения с сервером. Повторите позже"); using State = SlintUi::BatchOperation::State; for (const auto& operation : ui_->batch_operations) if (operation.id == id && operation.action == "connect" && (operation.state == State::Queued || operation.state == State::Running)) return error("该设备正在连接,请稍候", "This device is already connecting", "Устройство уже подключается"); auto updated = ui_->gallery; updated.Add(id, Trim(group)); if (!updated.Save(ui_->gallery_path)) return error("保存失败,请重试", "Could not save; try again", "Не удалось сохранить"); ui_->gallery = std::move(updated); ui_->gallery_hidden_active_ids.erase(id); if (auto props = FindRemoteSession(id)) { props->wall_managed_ = true; MuteRemoteAudio(props); } for (auto& operation : ui_->batch_operations) if (operation.id == id && operation.action == "connect" && operation.state == State::AwaitingPassword) operation.state = State::Finished; SlintUi::BatchOperation operation; operation.id = id; operation.action = "connect"; operation.password = password; operation.remember_password = remember_password; ui_->batch_operations.push_back(std::move(operation)); ui_->gallery_requested_auth = id; ui_->gallery_focus.clear(); ui_->wall->set_gallery_group_index(0); const auto ids = ui_->gallery.Ids(); ui_->gallery.page = (std::find(ids.begin(), ids.end(), id) - ids.begin()) / ui_->gallery.capacity; ui_->gallery_synced = {}; ui_->wall->set_gallery_notice(""); return true; } bool GuiApplication::ImportGalleryRecent(const std::string& input_group) { std::vector ids; for (size_t i = 0; i < ui_->gallery_recent_model->row_count(); ++i) { const auto device = ui_->gallery_recent_model->row_data(i); if (device && device->selected) ids.emplace_back(device->id); } if (ids.empty()) return false; const auto group = Trim(input_group); auto updated = ui_->gallery; updated.AddMany(ids, group); if (!updated.Save(ui_->gallery_path)) { ui_->wall->set_gallery_notice(UiText( localization_language_index_ == 0 ? "保存失败,请重试" : localization_language_index_ == 2 ? "Не удалось сохранить" : "Could not save; try again")); return false; } ui_->gallery = std::move(updated); // Import only membership. Existing recent credentials remain in their own // store and are looked up by the normal connect action when requested. for (const auto& id : ids) { ui_->gallery_hidden_active_ids.erase(id); if (auto props = FindRemoteSession(id)) { props->wall_managed_ = true; MuteRemoteAudio(props); } } ui_->gallery_focus.clear(); ui_->gallery.selected.clear(); ui_->wall->set_gallery_batch(false); const int group_index = group.empty() ? 0 : int(std::find(ui_->gallery.groups.begin(), ui_->gallery.groups.end(), group) - ui_->gallery.groups.begin()) + 1; ui_->wall->set_gallery_group_index(group_index); const auto visible = ui_->gallery.Ids(group); ui_->gallery.page = (std::find(visible.begin(), visible.end(), ids.front()) - visible.begin()) / ui_->gallery.capacity; ui_->gallery_synced = {}; ui_->wall->set_gallery_notice(""); return true; } void GuiApplication::DeviceGroupAction(const std::string& action, const std::string& value, bool gallery_window) { auto& editor = ui_->group_editors[gallery_window]; auto& main = ui_->Window(gallery_window); main->set_device_group_error(""); if (action == "close") { editor.id.clear(); editor.options->set_vector({}); } else if (action == "open") { editor.id = value; std::vector options; options.push_back({"", true}); for (const auto& name : ui_->gallery.groups) { const bool selected = std::any_of( ui_->gallery.members.begin(), ui_->gallery.members.end(), [&](const auto& m) { return m.id == value && m.group == name; }); options.push_back({UiText(name), selected}); if (selected) options.front().selected = false; } editor.options->set_vector(options); main->set_device_group_options(editor.options); } else if (action == "toggle" && !editor.id.empty()) { bool grouped = false; for (size_t i = 1; i < editor.options->row_count(); ++i) { auto option = *editor.options->row_data(i); if (value.empty()) option.selected = false; else if (std::string(option.name) == value) option.selected = !option.selected; grouped |= option.selected; editor.options->set_row_data(i, option); } editor.options->set_row_data(0, {"", !grouped}); if (value.empty()) main->set_device_group_new_name(""); } } bool GuiApplication::SaveDeviceGroups(const std::string& new_group, bool gallery_window) { auto& editor = ui_->group_editors[gallery_window]; auto& main = ui_->Window(gallery_window); const auto& id = editor.id; if (!gallery::Workspace::ValidId(id)) return false; std::vector names; for (size_t i = 0; i < editor.options->row_count(); ++i) { const auto option = *editor.options->row_data(i); if (option.selected && !option.name.empty()) names.emplace_back(option.name); } if (const auto name = Trim(new_group); !name.empty()) names.push_back(name); auto updated = ui_->gallery; if (!updated.SetGroups(id, names) || !updated.Save(ui_->gallery_path)) { main->set_device_group_error(UiText( localization_language_index_ == 0 ? "保存失败,请重试" : localization_language_index_ == 2 ? "Не удалось сохранить" : "Could not save; try again")); return false; } // Removing an empty group shifts indices. Retain the filter by name instead // of accidentally switching to a different group at the old index. const int index = ui_->wall->get_gallery_group_index(); const auto current = index > 0 && index <= int(ui_->gallery.groups.size()) ? ui_->gallery.groups[index - 1] : std::string{}; ui_->gallery = std::move(updated); const auto found = std::find(ui_->gallery.groups.begin(), ui_->gallery.groups.end(), current); ui_->wall->set_gallery_group_index( found == ui_->gallery.groups.end() ? 0 : int(found - ui_->gallery.groups.begin()) + 1); // Group edits never enroll or unhide a device, or take ownership of an // existing session. Only the explicit wall-add actions do that. ui_->gallery_synced = {}; main->set_device_group_error(""); SyncGallery(); return true; } void GuiApplication::GalleryAction(const std::string& action, const std::string& value, bool gallery_window) { using OperationState = SlintUi::BatchOperation::State; auto& wall = ui_->gallery; ui_->gallery_synced = {}; if (action == "settings-open") { ReleaseControl(); ResetSettingsUi(gallery_window); return; } if (action == "network-open") { ReleaseControl(); ui_->gallery_network_synced = {}; return; } const auto tr = [this](const char* zh, const char* en, const char* ru) { return UiText(localization_language_index_ == 0 ? zh : localization_language_index_ == 2 ? ru : en); }; if (action == "recent-open") { ReleaseControl(); need_to_send_recent_connections_ = true; ui_->wall->set_gallery_notice(""); const int group_index = ui_->wall->get_gallery_group_index(); ui_->wall->set_gallery_import_group(UiText( group_index > 0 && group_index <= int(wall.groups.size()) ? wall.groups[group_index - 1] : "")); std::vector devices; std::set seen; for (const auto& [_, recent] : recent_connections_) { if (!gallery::Workspace::ValidId(recent.remote_id) || !seen.insert(recent.remote_id).second) continue; ui::GalleryRecentDevice device{}; device.id = UiText(recent.remote_id); device.name = UiText(settings_.RecentConnectionDisplayName(recent)); device.online = device_presence_cache_.IsOnline(recent.remote_id); devices.push_back(std::move(device)); } ui_->gallery_recent_model->set_vector(std::move(devices)); ui_->wall->set_gallery_recent_devices(ui_->gallery_recent_model); ui_->wall->set_gallery_recent_selected(0); return; } if (action == "recent-close") { ui_->gallery_recent_model->set_vector({}); ui_->wall->set_gallery_recent_selected(0); ui_->wall->set_gallery_notice(""); return; } if (action == "recent-select" || action == "recent-all") { const bool select_all = ui_->wall->get_gallery_recent_selected() < int(ui_->gallery_recent_model->row_count()); int count = 0; for (size_t i = 0; i < ui_->gallery_recent_model->row_count(); ++i) { auto device = *ui_->gallery_recent_model->row_data(i); if (action == "recent-all" || std::string(device.id) == value) { device.selected = action == "recent-all" ? select_all : !device.selected; ui_->gallery_recent_model->set_row_data(i, device); } if (device.selected) ++count; } ui_->wall->set_gallery_recent_selected(count); return; } if (action == "add-open") { ui_->wall->set_gallery_notice(""); return; } else if (action == "group") { ReleaseControl(); wall.page = 0; wall.selected.clear(); ui_->gallery_focus.clear(); } else if (action == "selection" || action == "view-mode") { ReleaseControl(); wall.selected.clear(); ui_->gallery_focus.clear(); } else if (action == "select") { if (!wall.selected.erase(value)) wall.selected.insert(value); } else if (action == "all-select") { wall.ToggleSelectAll(ui_->gallery_ids); } else if (action == "page") { ReleaseControl(); wall.page += value == "next" ? 1 : -1; ui_->gallery_focus.clear(); } else if (action == "focus") { ReleaseControl(); ui_->gallery_focus = value; } else if (action == "control") { ReleaseControl(); auto props = FindRemoteSession(value); if (!props || props->connection_status_ != ConnectionStatus::Connected) return; { std::lock_guard lock(props->gallery_frame_mutex_); if (!props->gallery_rgba_ || std::chrono::steady_clock::now() - props->gallery_frame_at_ > 5s) return; } ui_->gallery_control_id = value; controlled_remote_id_ = value; props->control_mouse_ = true; props->enable_mouse_control_ = true; props->clipboard_allowed_ = true; if (ui_->stream) (*ui_->stream)->hide(); // The gallery window FocusScope owns keyboard input. Native capture stays // off so local toolbar/menu events never leak into this remote session. start_keyboard_capturer_ = false; ui_->wall->set_gallery_controlling(true); } else if (action == "remove") { ReleaseControl(); auto updated = wall; const int group_index = ui_->wall->get_gallery_group_index(); const std::string group = group_index > 0 && group_index <= int(wall.groups.size()) ? wall.groups[group_index - 1] : ""; updated.Remove(value, group); if (updated.Save(ui_->gallery_path)) { wall = std::move(updated); const auto retained = std::find(wall.groups.begin(), wall.groups.end(), group); ui_->wall->set_gallery_group_index( retained == wall.groups.end() ? 0 : int(retained - wall.groups.begin()) + 1); // Only removing from All devices hides an active session. Removing a // group assignment still leaves the device enrolled in the wall. if (group.empty()) ui_->gallery_hidden_active_ids.insert(value); ui_->gallery_focus.clear(); } } else if (action == "release") { ReleaseControl(); } else if (action == "connect" || action == "disconnect" || action == "mute" || action == "unmute" || action == "file") { std::vector ids; if (!value.empty()) ids.push_back(value); else for (const auto& id : ui_->gallery_ids) if (wall.selected.count(id)) ids.push_back(id); if (ids.empty()) return; if (action == "mute" || action == "unmute") { for (const auto& id : ids) { if (auto props = FindRemoteSession(id); props && !props->closing_ && props->connection_status_ == ConnectionStatus::Connected) { if (action == "mute") { MuteRemoteAudio(props); } else { UnmuteRemoteAudio(props); } } } SyncGallery(); return; } if (action == "connect" && !value.empty()) ui_->gallery_requested_auth = value; ReleaseControl(); if (action == "disconnect") { // Disconnect must not wait for long-running file transfers to finish. for (const auto& id : ids) CloseStreamTab(id); SyncGallery(); return; } // Retain IDs before the modal file picker can process another UI event. std::string file; if (action == "file") { file = OpenFileDialog( std::string(tr("选择分发文件", "Select a file", "Выберите файл"))); if (file.empty()) return; } for (const auto& id : ids) { if (action == "connect" && !value.empty()) { if (auto props = FindRemoteSession(id); props && props->connection_auth_.Get().password_required) { ui_->gallery_auth_id = id; show_connection_status_window_ = true; } } const bool pending = std::any_of(ui_->batch_operations.begin(), ui_->batch_operations.end(), [&](const auto& t) { return t.id == id && t.action == action && (t.state == OperationState::Queued || t.state == OperationState::Running || t.state == OperationState::AwaitingPassword); }); if (pending) continue; SlintUi::BatchOperation operation; operation.id = id; operation.action = action; operation.file = file; ui_->batch_operations.push_back(std::move(operation)); } } SyncGallery(); } void GuiApplication::RestoreWindowGeometry(bool before_show, bool gallery_window) { if (!ui_) return; auto& placement = gallery_window ? ui_->gallery_window : ui_->connection_window; if (!placement.RestoringNormal()) return; auto& component = ui_->Window(gallery_window); auto& window = component->window(); const auto current_size = window.size(); const bool wall_mode = gallery_window; const float compact_height = component->get_minimum_window_height(); if (!before_show && ui_->loaded_window_scale[gallery_window] > 0) { const float ratio = window.scale_factor() / ui_->loaded_window_scale[gallery_window]; if (placement.wall_position) { placement.wall_position->x *= ratio; placement.wall_position->y *= ratio; } if (placement.connection_position) { placement.connection_position->x *= ratio; placement.connection_position->y *= ratio; } ui_->loaded_window_scale[gallery_window] = 0; } auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); const auto& position = gallery_window ? placement.wall_position : placement.connection_position; auto restore_position = position; if (position) { auto safe_position = *position; // Clamp the requested geometry, never a stale size read immediately // after set_size while the compositor is still applying constraints. auto safe_size = target_size; SDL_Rect available{}; int display_count = 0; SDL_DisplayID* displays = SDL_GetDisplays(&display_count); bool found = false; for (int i = 0; displays && i < display_count; ++i) { SDL_Rect bounds{}; if (!SDL_GetDisplayUsableBounds(displays[i], &bounds)) continue; #if defined(__APPLE__) const float scale = window.scale_factor(); bounds.x *= scale; bounds.y *= scale; bounds.w *= scale; bounds.h *= scale; #endif if (i == 0) available = bounds; if (position->x < bounds.x + bounds.w - 60 && position->x + int(safe_size.width) > bounds.x + 60 && position->y >= bounds.y && position->y < bounds.y + bounds.h - 60) { available = bounds; found = true; break; } } SDL_free(displays); // Preserve a reachable window's anchor, including intentional edge // placement. Only recover coordinates when its display is unavailable. if (!found && available.w > 0 && available.h > 0) { const auto margin = uint32_t(32 * window.scale_factor()); const auto minimum = placement.RestoreSize(false, window.scale_factor(), compact_height); safe_size.width = std::max(minimum.width, std::min(safe_size.width, uint32_t(available.w))); safe_size.height = std::max(minimum.height, std::min( safe_size.height, uint32_t(std::max(1, available.h - int(margin))))); safe_position.x = std::clamp( safe_position.x, available.x, available.x + std::max(0, available.w - int(safe_size.width))); safe_position.y = std::clamp( safe_position.y, available.y, available.y + std::max(0, available.h - int(safe_size.height) - int(margin))); if (wall_mode) target_size = safe_size; } restore_position = safe_position; } if (before_show) { float initial_y_offset = 0; #if defined(__APPLE__) initial_y_offset = MainWindowInitialPositionYOffset(); #endif placement.PrepareForShow(window, wall_mode, target_size, restore_position, initial_y_offset); return; } const bool normal_restored = placement.NormalSizeRestored(current_size, target_size); if (!normal_restored) window.set_size(target_size); if (restore_position) window.set_position(*restore_position); if (normal_restored && wall_mode) { if (placement.wall_maximized && !placement.wall_fullscreen) { #if defined(__APPLE__) MaximizeGalleryWindowWithoutAnimation(); #else window.set_maximized(true); #endif } if (placement.wall_fullscreen) { #if defined(__APPLE__) ConfigureMainWindowChrome(window.appkit_view(), true); SetGalleryWindowFullscreen(true); #else window.set_fullscreen(true); #endif } } } void GuiApplication::SyncWindowGeometry(bool gallery_window) { auto& component = ui_->Window(gallery_window); auto& window = component->window(); if (!window.is_visible() || window.is_minimized()) return; const auto current_size = window.size(); auto& placement = gallery_window ? ui_->gallery_window : ui_->connection_window; const bool wall_mode = gallery_window; const bool transitioning = gallery_window && ui_->gallery_transition.Active(); const float compact_height = component->get_minimum_window_height(); #if defined(__APPLE__) const bool fullscreen = gallery_window && IsGalleryWindowFullscreen(); const bool maximized = gallery_window && IsGalleryWindowMaximized(); #else const bool fullscreen = window.is_fullscreen(); const bool maximized = window.is_maximized(); #endif if (!transitioning && placement.RecoverUndersized(wall_mode, window.position(), current_size, window.scale_factor(), window.is_visible(), window.is_minimized(), fullscreen, maximized, compact_height)) { ui_->restore_ticks[gallery_window] = 0; const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); LOG_WARN("DeskHub undersized window: wall={} client={}x{} scale={}; restoring={}x{}", wall_mode, current_size.width, current_size.height, window.scale_factor(), target.width, target.height); } if (placement.Restoring() && ++ui_->restore_ticks[gallery_window] == 120) { const auto target = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); LOG_WARN("DeskHub window restore delayed: wall={} client={}x{} target={}x{} scale={} fullscreen={} maximized={}", wall_mode, current_size.width, current_size.height, target.width, target.height, window.scale_factor(), fullscreen, maximized); } if (placement.RestoringNormal()) { RestoreWindowGeometry(false, gallery_window); } else if (placement.Restoring()) { if (placement.FinishRestore(wall_mode, current_size, fullscreen, maximized)) LOG_INFO("DeskHub window restored: {}; client={}x{} scale={} fullscreen={} maximized={}", wall_mode ? "wall" : "connection", current_size.width, current_size.height, window.scale_factor(), fullscreen, maximized); } else if (window.is_visible() && !window.is_minimized() && !transitioning) { placement.Observe(wall_mode, window.position(), current_size, window.scale_factor(), fullscreen, maximized, compact_height); } } void GuiApplication::SyncGallery() { using OperationState = SlintUi::BatchOperation::State; if (!ui_) return; SyncAudioPlayback(); SyncWindowGeometry(false); SyncWindowGeometry(true); auto& window = ui_->wall->window(); const auto current_size = window.size(); if (ui_->observed_gallery_size && (current_size.width != ui_->observed_gallery_size->width || current_size.height != ui_->observed_gallery_size->height)) { if (!ui_->gallery_control_id.empty() && !ui_->gallery_transition.Active()) ReleaseControl(); // Native resize can redraw before the usual 200ms preview refresh. ui_->gallery_synced = {}; } ui_->observed_gallery_size = current_size; const bool wall_mode = window.is_visible(); const bool visible = wall_mode && !window.is_minimized(); auto& placement = ui_->gallery_window; auto& transition = ui_->gallery_transition; if (!visible) transition.Cancel(); #if defined(__APPLE__) const bool fullscreen = IsGalleryWindowFullscreen(); #else const bool fullscreen = window.is_fullscreen(); #endif if (wall_mode && !placement.Restoring()) { if (fullscreen != ui_->wall->get_gallery_fullscreen() && !transition.Active()) ReleaseControl(); ui_->wall->set_gallery_fullscreen(fullscreen); } if (!ui_->gallery_control_id.empty()) { auto controlled = FindRemoteSession(ui_->gallery_control_id); if (!visible || !controlled || controlled->closing_ || controlled->connection_status_ != ConnectionStatus::Connected) ReleaseControl(); } const auto now = std::chrono::steady_clock::now(); const auto action = transition.Advance(now, current_size.width, current_size.height, window.scale_factor(), fullscreen, placement.Restoring()); if (action == gallery::FullscreenTransition::Action::Apply) { // Keep previews visible while native resizing and the gallery model catch // up. Only input and geometry persistence wait for the window to settle. ui_->wall->set_gallery_fullscreen(transition.Target()); #if defined(__APPLE__) if (!SetGalleryWindowFullscreen(transition.Target())) { ui_->wall->set_gallery_fullscreen(fullscreen); transition.Cancel(); } else #else window.set_fullscreen(transition.Target()); #endif { placement.wall_fullscreen = transition.Target(); } ui_->gallery_synced = {}; } ui_->wall->set_gallery_transitioning(transition.Active()); if (!transition.Active() && now - ui_->gallery_synced < (visible ? 16ms : 200ms)) return; ui_->gallery_synced = now; // Track every group, including devices that have never connected, without // replacing the recent-device subscription or starting an interactive probe. const auto presence_ids = ui_->gallery.Ids(); if (gallery_presence_ids_ != presence_ids) { gallery_presence_ids_ = presence_ids; need_to_send_recent_connections_ = true; } if (ui_->wall->get_gallery_importing()) { // Keep the picker selection and ordering stable when presence changes. for (size_t i = 0; i < ui_->gallery_recent_model->row_count(); ++i) { auto device = *ui_->gallery_recent_model->row_data(i); const bool online = device_presence_cache_.IsOnline(std::string(device.id)); if (device.online != online) { device.online = online; ui_->gallery_recent_model->set_row_data(i, device); } } } const auto tr = [this](const char* zh, const char* en, const char* ru) -> std::string { return localization_language_index_ == 0 ? zh : localization_language_index_ == 2 ? ru : en; }; const auto name = [this](const std::string& id) { for (const auto& [_, recent] : recent_connections_) if (recent.remote_id == id) return settings_.RecentConnectionDisplayName(recent); Thumbnail::RecentConnection device{}; device.remote_id = id; if (auto props = FindRemoteSession(id)) { std::lock_guard lock(props->remote_version_mutex_); device.remote_host_name = props->remote_host_name_; } return settings_.RecentConnectionDisplayName(device); }; if (visible && ui_->wall->get_gallery_network_open() && now - ui_->gallery_network_synced >= 1s) { ui_->gallery_network_synced = now; // This list follows live connections, independently of the wall's current // group and page. Never show saved, queued or still-connecting devices. std::vector connected_ids; { std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, props] : remote_sessions_) if (props && !props->closing_ && props->connection_status_ == ConnectionStatus::Connected) connected_ids.push_back(id); } std::sort(connected_ids.begin(), connected_ids.end()); std::vector devices; for (const auto& id : connected_ids) { const auto props = FindRemoteSession(id); if (!props || props->closing_ || props->connection_status_ != ConnectionStatus::Connected) continue; ui::GalleryNetworkDevice device{}; device.name = UiText(name(id)); device.id = UiText(FormatPeerId(id.c_str())); const auto mode = props->traversal_mode_.load(); device.mode = UiText(mode == TraversalMode::P2P ? tr("直连", "Direct", "Напрямую") : mode == TraversalMode::UnknownMode ? tr("等待统计", "Awaiting stats", "Ждём данные") : tr("中继", "Relay", "Ретрансляция")); device.inbound = device.outbound = device.loss = "—"; device.video_latency = "—"; // Prefer the wall's own frame path. An independently displayed stream // may still supply a measurement for a device outside the current page. auto video_latency = props->gallery_video_latency_->Get(now); if (!video_latency) video_latency = props->video_latency_->Get(now); if (video_latency) { const auto ms = video_latency->average_ms; device.video_latency = UiText(ms < 1 ? "<1 ms" : std::to_string(static_cast(std::lround(ms))) + " ms"); } const auto video = props->received_video_stats_.Get(now); device.resolution = video.width > 0 && video.height > 0 ? UiText(std::to_string(video.width) + " × " + std::to_string(video.height)) : UiText("—"); device.fps = video.fps ? UiText(std::to_string(*video.fps) + " FPS") : UiText("—"); const bool has_report = props->net_traffic_stats_.HasRecentReport(now); const auto stats = props->net_traffic_stats_.Load(); // Use negotiated transport state, never the local encryption preference. // Missing/stale reports cannot confirm either encrypted or plaintext. device.encryption_known = has_report; device.encryption_enabled = has_report && stats.srtp_active; if (mode != TraversalMode::UnknownMode && has_report) { device.inbound = UiText(FormatBitrate(stats.total_inbound_stats.bitrate)); device.outbound = UiText(FormatBitrate(stats.total_outbound_stats.bitrate)); // MiniRTC's total loss is a sum of media fractions, not an aggregate. // Report video loss explicitly, and only after receiving video packets. if (stats.video_inbound_stats.rtp_packet_count > 0 && std::isfinite(stats.video_inbound_stats.loss_rate)) { char loss[24]; std::snprintf(loss, sizeof(loss), "%.1f%%", std::clamp(stats.video_inbound_stats.loss_rate, 0.0f, 1.0f) * 100.0f); device.loss = UiText(loss); } } devices.push_back(std::move(device)); } if (ui_->gallery_network_model->row_count() != devices.size()) ui_->gallery_network_model->set_vector(std::move(devices)); else for (size_t i = 0; i < devices.size(); ++i) ui_->gallery_network_model->set_row_data(i, devices[i]); } // Limit simultaneous operations without keeping a user-facing operation list // or operation history. Targets stay fixed when the selection changes. int running = std::count_if( ui_->batch_operations.begin(), ui_->batch_operations.end(), [](const auto& t) { return t.state == OperationState::Running; }); for (auto& operation : ui_->batch_operations) { if (operation.state == OperationState::Queued && running < 4) { if (operation.action == "connect" && session_cleanup_tasks_.count(operation.id)) continue; operation.started = now; operation.state = OperationState::Running; ++running; auto props = FindRemoteSession(operation.id); if (operation.action == "connect") { if (props && !props->closing_) { // A wall connect can reuse an already connected/connecting peer. props->wall_managed_ = true; MuteRemoteAudio(props); } const auto status = props ? props->connection_status_.load() : ConnectionStatus::Closed; const bool usable = props && props->peer_ && !props->closing_; const bool connecting = usable && (status == ConnectionStatus::Connecting || status == ConnectionStatus::Gathering); if (!signal_connected_) operation.state = OperationState::Finished; else if (!(usable && status == ConnectionStatus::Connected) && (!connecting || operation.password)) { std::string password = operation.password.value_or(""); 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; } // 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); props = FindRemoteSession(operation.id); if (props && props->closing_) { // ConnectTo retained these credentials in pending_reconnects_. operation.password.reset(); operation.state = OperationState::Queued; --running; continue; } operation.password.reset(); } } else if (!props || !props->peer_ || props->connection_status_ != ConnectionStatus::Connected) { operation.state = OperationState::Finished; } else if (operation.action == "file") { { std::lock_guard lock(props->file_transfer_.file_transfer_list_mutex_); operation.file_index = props->file_transfer_.file_transfer_list_.size(); } transfers_.ProcessSelectedFile(operation.file, props, props->file_label_); { std::lock_guard lock(props->file_transfer_.file_transfer_list_mutex_); if (operation.file_index >= props->file_transfer_.file_transfer_list_.size()) operation.state = OperationState::Finished; } } operation.session = props; } auto props = operation.session.lock(); if (props && ui_->gallery_requested_auth == operation.id && props->connection_auth_.Get().password_required) { ui_->gallery_auth_id = operation.id; ui_->gallery_requested_auth.clear(); show_connection_status_window_ = true; } if (operation.state == OperationState::Running || operation.state == OperationState::AwaitingPassword) { if (!props || props->closing_) operation.state = OperationState::Finished; else if (operation.action == "connect") { const auto status = props->connection_status_.load(); const auto auth = props->connection_auth_.Get(); if (status == ConnectionStatus::Connected) operation.state = OperationState::Finished; else if (auth.password_required && !auth.validating) operation.state = OperationState::AwaitingPassword; else if (status != ConnectionStatus::Connecting && status != ConnectionStatus::Gathering && !auth.validating) operation.state = OperationState::Finished; else if (operation.state == OperationState::AwaitingPassword) { operation.state = OperationState::Running; operation.started = now; } else if (now - operation.started > 30s) { operation.state = OperationState::Finished; CloseStreamTab(operation.id); } } else if (props->connection_status_ != ConnectionStatus::Connected) operation.state = OperationState::Finished; else if (operation.action == "file") { std::lock_guard lock(props->file_transfer_.file_transfer_list_mutex_); const auto& files = props->file_transfer_.file_transfer_list_; if (operation.file_index >= files.size()) operation.state = OperationState::Finished; else if (files[operation.file_index].status == gui_detail::FileTransferState::FileTransferStatus::Completed) operation.state = OperationState::Finished; else if (files[operation.file_index].status == gui_detail::FileTransferState::FileTransferStatus::Failed) operation.state = OperationState::Finished; } } } std::erase_if(ui_->batch_operations, [](const auto& operation) { return operation.state == OperationState::Finished; }); SaveGalleryWindowState(); auto& wall = ui_->gallery; std::unordered_map group_labels; for (const auto& member : wall.members) { if (member.group.empty()) continue; auto& label = group_labels[member.id]; if (!label.empty()) label += " · "; label += member.group; } std::vector groups{ UiText(tr("全部设备", "All devices", "Все устройства"))}; for (const auto& group : wall.groups) groups.push_back(UiText(group)); const auto old_groups = ui_->wall->get_gallery_groups(); bool changed_groups = !old_groups || old_groups->row_count() != groups.size(); if (!changed_groups) for (size_t i = 0; i < groups.size(); ++i) changed_groups |= old_groups->row_data(i) != groups[i]; if (changed_groups) ui_->wall->set_gallery_groups( std::make_shared>(groups)); const int group_index = std::clamp(ui_->wall->get_gallery_group_index(), 0, int(wall.groups.size())); auto ids = wall.Ids(group_index == 0 ? "" : wall.groups[group_index - 1]); if (group_index == 0) { std::shared_lock lock(remote_sessions_mutex_); std::vector active; for (const auto& [id, props] : remote_sessions_) if (props && !props->closing_ && !ui_->gallery_hidden_active_ids.count(id) && std::find(ids.begin(), ids.end(), id) == ids.end()) active.push_back(id); std::sort(active.begin(), active.end()); ids.insert(ids.end(), active.begin(), active.end()); } ids = wall.OrderIds(ids); ui_->gallery_ids = ids; std::erase_if(wall.selected, [&](const auto& id) { return std::find(ids.begin(), ids.end(), id) == ids.end(); }); const int columns = std::max(1, ui_->wall->get_gallery_columns()); const int rows = std::max(1, ui_->wall->get_gallery_rows()); wall.ResizePage(columns * rows, ids.size()); // Resizing/fullscreen changes page capacity. Keep the focused device in // the page model so the rail shows the other devices on the same page. if (!ui_->gallery_focus.empty()) { const auto focused = std::find(ids.begin(), ids.end(), ui_->gallery_focus); if (focused != ids.end()) wall.page = std::distance(ids.begin(), focused) / wall.capacity; else { ReleaseControl(); ui_->gallery_focus.clear(); } } ui_->wall->set_gallery_page(wall.page); ui_->wall->set_gallery_pages( std::max(1, (int(ids.size()) + wall.capacity - 1) / wall.capacity)); ui_->wall->set_gallery_count(ids.size()); int connected = 0, selected_on_page = 0; const auto gallery_viewport = visible && !ui_->gallery_focus.empty() ? gallery::PreviewResolution::Viewport( ui_->wall->get_gallery_video_width(), ui_->wall->get_gallery_video_height(), window.scale_factor()) : gallery::PreviewResolution{}; const auto gallery_preview_viewport = visible ? gallery::PreviewResolution::Viewport( ui_->wall->get_gallery_preview_width(), ui_->wall->get_gallery_preview_height(), window.scale_factor()) : gallery::PreviewResolution{}; std::set preview_ids; std::vector tiles; std::vector submitted_previews; const auto make_tile = [&](const std::string& id, bool preview) { ui::GalleryTile tile{}; tile.id = UiText(id); tile.name = UiText(name(id)); tile.group_names = UiText(group_labels[id]); tile.selected = wall.selected.count(id); auto props = FindRemoteSession(id); for (const auto& [_, recent] : recent_connections_) { if (recent.remote_id != id) continue; tile.host_name = UiText(recent.remote_host_name); break; } if (props && tile.host_name.empty()) { std::lock_guard lock(props->remote_version_mutex_); tile.host_name = UiText(props->remote_host_name_); } tile.connected = props && props->connection_status_ == ConnectionStatus::Connected && !props->closing_; const auto online = device_presence_cache_.GetOnline(id); tile.online = tile.connected || online.value_or(false); tile.presence_known = tile.connected || online.has_value(); tile.status = UiText( tile.connected ? tr("等待画面", "Waiting for video", "Ожидание видео") : tr("未连接", "Not connected", "Нет соединения")); const bool queued = !tile.connected && std::any_of( ui_->batch_operations.begin(), ui_->batch_operations.end(), [&](const auto& operation) { return operation.id == id && operation.action == "connect" && operation.state == OperationState::Queued; }); // A queued retry takes precedence over errors from the previous session. if (queued) tile.status = UiText(tr("排队中", "Queued", "В очереди")); else if (props && props->connection_auth_.Get().password_required) tile.status = UiText(tr("需要密码", "Password required", "Нужен пароль")); else if (props && !tile.connected) tile.status = UiText(ConnectionStatusText( props->connection_status_.load(), localization_language_index_)); if (preview && visible && tile.connected) { preview_ids.insert(id); const auto viewport = ui_->gallery_focus == id ? gallery_viewport : gallery_preview_viewport; props->gallery_preview_width_ = std::max({320, viewport.width, viewport.height}); std::lock_guard lock(props->gallery_frame_mutex_); if (props->gallery_rgba_) { auto& cached = ui_->gallery_previews[id]; if (cached.sequence != props->gallery_sequence_ || cached.session.lock() != props) { slint::SharedPixelBuffer pixels( props->gallery_width_, props->gallery_height_); std::memcpy(pixels.begin(), props->gallery_rgba_->data(), props->gallery_rgba_->size()); cached.image = slint::Image(std::move(pixels)); cached.sequence = props->gallery_sequence_; cached.session = props; submitted_previews.push_back(props->gallery_frame_timing_); } tile.frame = cached.image; tile.has_frame = true; tile.stale = now - props->gallery_frame_at_ > 5s; tile.status = UiText(tile.stale ? tr("画面已停留", "Frame stale", "Кадр устарел") : tr("只读预览", "View only", "Просмотр")); } } return tile; }; for (size_t i = 0; i < ids.size(); ++i) { const auto props = FindRemoteSession(ids[i]); if (props && props->connection_status_ == ConnectionStatus::Connected && !props->closing_) { ++connected; } if (i >= size_t(wall.page * wall.capacity) && i < size_t((wall.page + 1) * wall.capacity)) { if (wall.selected.count(ids[i])) ++selected_on_page; tiles.push_back(make_tile(ids[i], true)); } } if (ui_->gallery_model->row_count() != tiles.size()) ui_->gallery_model->set_vector(std::move(tiles)); else for (size_t i = 0; i < tiles.size(); ++i) ui_->gallery_model->set_row_data(i, tiles[i]); ui_->wall->set_gallery_focused(ui_->gallery_focus.empty() ? ui::GalleryTile{} : make_tile(ui_->gallery_focus, true)); // Count each newly submitted image once, including conversion and UI queue // time. Slint image submission does not confirm physical screen presentation. for (const auto& timing : submitted_previews) timing.MarkSubmitted(); ui_->wall->set_gallery_connected(connected); if (!ui_->wall->get_gallery_adding() && !ui_->wall->get_gallery_importing()) ui_->wall->set_gallery_notice(""); ui_->wall->set_gallery_selected(wall.selected.size()); ui_->wall->set_gallery_hidden_selected(wall.selected.size() - selected_on_page); std::vector selected_devices; bool selected_audio_available = false, selected_audio_muted = true; for (const auto& id : ids) if (wall.selected.count(id)) { ui::GalleryActionDevice device{}; device.id = UiText(id); device.name = UiText(name(id)); const auto props = FindRemoteSession(id); device.connected = props && !props->closing_ && props->connection_status_ == ConnectionStatus::Connected; if (device.connected) { selected_audio_available = true; selected_audio_muted &= !props->audio_capture_button_pressed_; } selected_devices.push_back(std::move(device)); } ui_->wall->set_gallery_selected_audio_available(selected_audio_available); ui_->wall->set_gallery_selected_audio_muted(selected_audio_available && selected_audio_muted); if (ui_->gallery_selected_model->row_count() != selected_devices.size()) ui_->gallery_selected_model->set_vector(std::move(selected_devices)); else for (size_t i = 0; i < selected_devices.size(); ++i) { const auto old = *ui_->gallery_selected_model->row_data(i); const auto& device = selected_devices[i]; if (old.id != device.id || old.name != device.name || old.connected != device.connected) ui_->gallery_selected_model->set_row_data(i, device); } auto& pending_previews = ui_->gallery_pending_previews; auto& bandwidth_demands = ui_->gallery_bandwidth_demands; pending_previews.clear(); bandwidth_demands.clear(); const bool stream_window_visible = ui_->stream && (*ui_->stream)->window().is_visible() && !(*ui_->stream)->window().is_minimized(); const auto stream_viewport = stream_window_visible ? gallery::PreviewResolution::Viewport( (*ui_->stream)->get_video_area_width(), (*ui_->stream)->get_video_area_height(), (*ui_->stream)->window().scale_factor()) : gallery::PreviewResolution{}; { std::shared_lock lock(remote_sessions_mutex_); pending_previews.reserve(remote_sessions_.size()); bandwidth_demands.reserve(remote_sessions_.size()); for (const auto& [id, props] : remote_sessions_) { if (!props || props->closing_) continue; if (!preview_ids.count(id)) { props->gallery_preview_width_ = 0; std::unique_lock conversion_lock(props->gallery_conversion_mutex_, std::try_to_lock); if (conversion_lock.owns_lock()) { std::vector().swap(props->gallery_nv12_); std::vector().swap(props->gallery_scaled_); std::vector().swap(props->gallery_scratch_); } std::lock_guard frame_lock(props->gallery_frame_mutex_); props->gallery_rgba_.reset(); } if (props->preview_subscription_supported_ && props->connection_status_ == ConnectionStatus::Connected) { if (wall_mode && !ui_->gallery_hidden_active_ids.count(id)) props->wall_managed_ = true; // Keep session quality and audio; wall bandwidth, resolution and // cadence requests never overwrite session preferences. PreviewSubscription demand; { std::lock_guard settings_lock(props->video_settings_mutex_); demand = PreviewSubscription::Interactive( props->video_settings_.quality, props->video_settings_.frame_rate, props->video_settings_.preference, props->audio_allowed_.load()); } if (props->wall_managed_) wall.stream_settings.ApplyFrameRate(demand, props->preview_fps15_supported_); const bool needs_sync = props->preview_needs_sync_.exchange(false); const bool stream_visible = stream_window_visible && props->tab_opened_ && id == focused_remote_id_; if (props->preview_resolution_supported_) { const auto wall_viewport = !preview_ids.count(id) ? gallery::PreviewResolution{} : ui_->gallery_focus == id ? gallery_viewport : gallery_preview_viewport; const auto resolution = props->preview_resolution_.Update( wall.stream_settings.Resolution( props->wall_managed_, wall_viewport, stream_visible ? stream_viewport : gallery::PreviewResolution{}), now, needs_sync); demand.version = 2; demand.width = resolution.width; demand.height = resolution.height; } if (props->wall_managed_ && !preview_ids.count(id) && !stream_visible) demand.fps = 0; gallery::PreviewBandwidthDemand bandwidth; if (props->preview_bandwidth_supported_) { demand.max_bitrate_bps = 0; if (props->wall_managed_ && demand.fps > 0) { bandwidth = { gallery::PreviewBandwidthDemand::Estimate( demand.width, demand.height, demand.fps == 60 ? demand.frame_rate : demand.fps), stream_visible || (visible && ui_->gallery_focus == id)}; } } bandwidth_demands.push_back(bandwidth); pending_previews.push_back({props, demand, needs_sync}); } } } const auto& allocations = ui_->gallery_bandwidth_allocator.Allocate( bandwidth_demands, wall.stream_settings.bandwidth_mbps * 1'000'000); for (size_t i = 0; i < pending_previews.size(); ++i) { if (bandwidth_demands[i].desired_bps == 0) continue; auto& demand = pending_previews[i].demand; // Quantize down to avoid resending subscriptions for sub-kbit rounding. const int bitrate = allocations[i] / 1000 * 1000; demand.max_bitrate_bps = bitrate; if (!bitrate) demand.fps = 0; } for (auto& pending : pending_previews) { auto& props = pending.props; auto& demand = pending.demand; const bool acknowledged = props->preview_ack_.load() == props->preview_request_ && props->preview_accepted_.load(); if (pending.needs_sync || !demand.SameSettings(props->preview_requested_) || (!acknowledged && now - props->preview_request_at_ > 5s)) { demand.request_id = ++props->preview_request_; if (!demand.request_id) demand.request_id = ++props->preview_request_; const auto message = demand.ToJson().dump(); if (SendReliableDataFrame(props->peer_, message.data(), message.size(), props->control_data_label_.c_str()) == 0) props->preview_requested_ = demand; props->preview_request_at_ = now; } } // Reuse capacity without retaining disconnected sessions between refreshes. pending_previews.clear(); std::erase_if(ui_->gallery_previews, [&](const auto& item) { return !preview_ids.count(item.first); }); } void GuiApplication::Cleanup() { #ifdef __linux__ HeadlessConsole::Instance().BindSettings(nullptr, {}, {}); #endif if (!ui_) { SDL_Quit(); return; } SaveGalleryWindowState(); #if defined(__APPLE__) SetGalleryWindowFullscreen(false); #endif ui_->wall->hide(); ui_->timer.stop(); ui_->server_file_dialog_timer.stop(); ui_->video_timer.stop(); #if _WIN32 && CROSSDESK_PORTABLE JoinPortableWindowsServiceInstallThread(); #endif privacy_.Disable(); clipboard_.Shutdown(); keyboard_.ForceReleasePressedKeys(); keyboard_.ReleaseAllRemotePressedKeys("application_exit"); devices_.DestroyDevices(); privacy_.Shutdown(); devices_.DestroyFactories(); CloseAllRemoteSessions(); WaitForSessionCleanup(); devices_.DestroyAudioOutput(); if (ui_->stream) { #if defined(__APPLE__) void* stream_view = (*ui_->stream)->window().appkit_view(); SetStreamWindowFullscreen(false); UnregisterStreamWindow(stream_view); #endif if (ui_->video_presenter) { ui_->video_presenter->Detach(); } (*ui_->stream)->hide(); ui_->stream.reset(); } if (ui_->server) { (*ui_->server)->hide(); (*ui_->server_bar)->hide(); } #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) if (ui_->tray) { ui_->tray->RemoveTrayIcon(); ui_->tray.reset(); } #endif ui_.reset(); if (update_checker_) { update_checker_->Stop(); } SDL_Quit(); } } // namespace crossdesk