Files
crossdesk/apps/desktop/src/gui/application/gui_application.cpp
T

5759 lines
222 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "application/gui_application.h"
#include <SDL3/SDL.h>
#include <openssl/rand.h>
#include <remote_action.h>
#include <slint.h>
#include <tinyfiledialogs.h>
#include <algorithm>
#include <cctype>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <nlohmann/json.hpp>
#include <optional>
#include <random>
#include <set>
#include <shared_mutex>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
#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 <windows.h>
#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 <GL/gl.h>
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <unistd.h>
#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<std::string> 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<std::string> 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<const ::Window*>(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<unsigned long> 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<const unsigned long*>(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<int>(size.width)) <=
kGeometryTolerance &&
std::abs(attributes.height - static_cast<int>(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<const ::Window*>(data);
for (unsigned long i = 0; i < count; ++i) {
if (X11GetWindowProcessId(display, windows[i]) !=
static_cast<unsigned long>(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<char*>(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<HICON>(
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<char>((high << 4) | low));
i += 2;
continue;
}
}
decoded.push_back(text[i]);
}
#if defined(_WIN32)
if (decoded.size() >= 3 && decoded[0] == '/' &&
std::isalpha(static_cast<unsigned char>(decoded[1])) &&
decoded[2] == ':') {
decoded.erase(decoded.begin());
}
#endif
return decoded;
}
std::optional<int> 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<unsigned long long>(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<const unsigned char*>(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<int>(key - 'a' + 'A');
}
if ((key >= 'A' && key <= 'Z') || (key >= '0' && key <= '9')) {
return static_cast<int>(key);
}
if (key >= 0xF704 && key <= 0xF71B) {
return 0x70 + static_cast<int>(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<Display, decltype(&XCloseDisplay)> 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<float>(root_x);
*y = static_cast<float>(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<int>(
std::lround(position.x / scale + size.width / (2.0f * scale))),
static_cast<int>(
std::lround(position.y / scale + size.height / (2.0f * scale)))};
#else
const SDL_Point center{
position.x + static_cast<int>(size.width / 2),
position.y + static_cast<int>(size.height / 2)};
#endif
const SDL_DisplayID display = SDL_GetDisplayForPoint(&center);
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<int>(size.width), static_cast<int>(size.height)};
}
const float scale = std::max(window.scale_factor(), 0.01f);
return {std::max(1, static_cast<int>(std::lround(logical_width * scale))),
std::max(1, static_cast<int>(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<float>(usable_bounds.x) +
(static_cast<float>(usable_bounds.w) - logical_width) / 2.0f;
const float target_y =
static_cast<float>(usable_bounds.y) +
(static_cast<float>(usable_bounds.h) - logical_height) / 2.0f;
window.set_position(
slint::LogicalPosition(slint::Point<float>{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<int32_t>{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 <typename WindowHandle>
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<int>(std::lround(
(pointer_x - state.pointer_start_x) * state.global_to_physical_scale));
const int delta_y = static_cast<int>(std::lround(
(pointer_y - state.pointer_start_y) * state.global_to_physical_scale));
const slint::PhysicalPosition target(slint::Point<int32_t>{
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<int>(std::lround((mouse_x - state.last_local_x) * scale));
const int delta_y =
static_cast<int>(std::lround((mouse_y - state.last_local_y) * scale));
if (delta_x != 0 || delta_y != 0) {
window.set_position(slint::PhysicalPosition(
slint::Point<int32_t>{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<std::string> gallery_ids;
std::set<std::string> gallery_hidden_active_ids;
struct GroupEditor {
std::string id;
std::shared_ptr<slint::VectorModel<ui::DeviceGroupOption>> options =
std::make_shared<slint::VectorModel<ui::DeviceGroupOption>>();
};
GroupEditor group_editors[2];
// Snapshot while the import picker is open; never includes credentials.
std::shared_ptr<slint::VectorModel<ui::GalleryRecentDevice>> gallery_recent_model =
std::make_shared<slint::VectorModel<ui::GalleryRecentDevice>>();
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<slint::VectorModel<ui::GalleryNetworkDevice>> gallery_network_model =
std::make_shared<slint::VectorModel<ui::GalleryNetworkDevice>>();
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<slint::PhysicalSize> observed_gallery_size;
struct Preview {
std::weak_ptr<RemoteSession> session;
uint64_t sequence = 0;
slint::Image image;
};
std::unordered_map<std::string, Preview> gallery_previews;
struct PendingPreview {
std::shared_ptr<RemoteSession> props;
PreviewSubscription demand;
bool needs_sync = false;
};
std::vector<PendingPreview> gallery_pending_previews;
std::vector<gallery::PreviewBandwidthDemand> 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<std::string> password;
bool remember_password = false;
enum class State { Queued, Running, AwaitingPassword, Finished };
State state = State::Queued;
size_t file_index = 0;
std::weak_ptr<RemoteSession> session;
std::chrono::steady_clock::time_point started{};
};
// Transient concurrency control only; completed operations are discarded.
std::vector<BatchOperation> batch_operations;
std::string saved_window_signature;
std::chrono::steady_clock::time_point window_saved{};
std::shared_ptr<slint::VectorModel<ui::GalleryTile>> gallery_model =
std::make_shared<slint::VectorModel<ui::GalleryTile>>();
std::shared_ptr<slint::VectorModel<ui::GalleryActionDevice>> gallery_selected_model =
std::make_shared<slint::VectorModel<ui::GalleryActionDevice>>();
float pointer_x = 0, pointer_y = 0;
std::set<int> 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<ui::MainWindow> main = ui::MainWindow::create();
slint::ComponentHandle<ui::MainWindow> wall = ui::MainWindow::create();
auto& Window(bool gallery) { return gallery ? wall : main; }
std::shared_ptr<slint::VectorModel<ui::ReleaseNoteBlock>>
release_note_blocks_model =
std::make_shared<slint::VectorModel<ui::ReleaseNoteBlock>>();
std::optional<slint::ComponentHandle<ui::StreamWindow>> stream;
std::optional<slint::ComponentHandle<ui::ServerWindow>> server;
std::optional<slint::ComponentHandle<ui::ServerStatusBar>> server_bar;
std::shared_ptr<slint::VectorModel<ui::RecentConnection>> recent_model =
std::make_shared<slint::VectorModel<ui::RecentConnection>>();
std::shared_ptr<slint::VectorModel<ui::StreamTab>> tab_model =
std::make_shared<slint::VectorModel<ui::StreamTab>>();
std::shared_ptr<slint::VectorModel<slint::SharedString>> display_model =
std::make_shared<slint::VectorModel<slint::SharedString>>();
std::shared_ptr<slint::VectorModel<ui::FileTransferEntry>> transfer_model =
std::make_shared<slint::VectorModel<ui::FileTransferEntry>>();
std::shared_ptr<slint::VectorModel<ui::NetworkStatsRow>> stats_model =
std::make_shared<slint::VectorModel<ui::NetworkStatsRow>>();
std::shared_ptr<slint::VectorModel<ui::ControllerEntry>> controller_model =
std::make_shared<slint::VectorModel<ui::ControllerEntry>>();
std::unique_ptr<SlintVideoPresenter> video_presenter;
std::vector<std::string> tab_order;
std::vector<std::string> tab_ids;
std::string tab_model_signature;
std::vector<std::string> 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<std::string> recent_group_filter;
std::vector<std::string> 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<WinTray> tray;
#elif defined(__APPLE__)
std::unique_ptr<MacTray> tray;
#elif defined(__linux__)
std::unique_ptr<LinuxTray> 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<PathManager>("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<ConfigCenter>(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<std::mutex> 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<UpdateChecker>(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<ConfigCenter::LANGUAGE>(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<ScopedUnsetEnvironmentVariable> wayland_display_guard;
std::optional<ScopedUnsetEnvironmentVariable> 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<SlintUi>();
ui_->video_presenter =
std::make_unique<SlintVideoPresenter>(*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<float>{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>{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<int32_t>{
int(saved.at("wall_x").get<float>() * scale),
int(saved.at("wall_y").get<float>() * scale)});
if (saved.contains("connection_x"))
ui_->connection_window.connection_position =
slint::PhysicalPosition(slint::Point<int32_t>{
int(saved.at("connection_x").get<float>() * scale),
int(saved.at("connection_y").get<float>() * 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<WinTray>(
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<MacTray>(
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<LinuxTray>(
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<int>(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<int>(button), static_cast<int>(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<int64_t> {
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<int64_t>()) != id.data() ||
std::to_string(item["revision"].get<int64_t>()) != 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<std::string> 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<int>(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<int>(button), static_cast<int>(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<int>(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<ui::ReleaseNoteBlock> 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<std::string> 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<std::string, std::chrono::steady_clock::time_point>
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<std::string> 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<CursorRecipient> 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<int>(outgoing.render_mode),
static_cast<int>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<ui::AnnouncementItem> rows;
bool selected_found = false;
for (const auto& item : state.items) {
ui::AnnouncementItem row;
const auto id = std::to_string(item["id"].get<int64_t>());
const auto revision = std::to_string(item["revision"].get<int64_t>());
row.id = UiText(id);
row.revision = UiText(revision);
row.title = UiText(item["title"].get<std::string>());
const auto timestamp =
static_cast<time_t>(item["updated_at"].get<int64_t>());
std::tm local{};
#ifdef _WIN32
localtime_s(&local, &timestamp);
#else
localtime_r(&timestamp, &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<bool>();
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<std::string>()));
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<slint::VectorModel<ui::AnnouncementItem>>(
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<std::mutex> 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<slint::SharedString> 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<slint::VectorModel<slint::SharedString>>(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<std::string, std::string> 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<int>(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<ui::RecentConnection> model;
model.reserve(recent_connections_.size());
for (const auto& [_, connection] : recent_connections_) {
if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter))
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<unsigned char> 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<slint::Rgba8Pixel> 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<unsigned long>(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<ui::StreamTab> tabs;
std::unordered_map<std::string, ui::StreamTab> 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<int>(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<int>(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<int>(
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<slint::SharedString> 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<ui::NetworkStatsRow> 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<RemoteSession::FileTransferInfo> 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<ui::FileTransferEntry> 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<int>(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<float>(info.sent_bytes) /
static_cast<float>(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<std::chrono::milliseconds>(
fps_now - props->last_time_)
.count();
if (elapsed >= 1000) {
props->fps_ = static_cast<int>(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<int>(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<int>(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<std::chrono::nanoseconds>(remaining).count();
const auto delay_ms = std::max<int64_t>(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<float>{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<float>{rect.x, rect.y}));
#else
window.set_position(slint::PhysicalPosition(slint::Point<int32_t>{
static_cast<int32_t>(std::lround(rect.x * scale)),
static_cast<int32_t>(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<ui::ControllerEntry> 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<std::string> 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<int>(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<float>(sent) /
static_cast<float>(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<ConfigCenter::LANGUAGE>(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<ConfigCenter::VIDEO_ENCODE_FORMAT>(
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<ScreenCaptureMethod>(
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<int>(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<int>(config_center_->GetLanguage());
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
localization_language_ = config_center_->GetLanguage();
video_encode_format_button_value_ = static_cast<int>(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<TurnMode>(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<int>(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<RemoteSession> 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<int>(ui_->tab_order.size()) ||
tab_width <= 0.0f) {
return;
}
const float slot_width = tab_width + 1.0f;
const int target =
std::clamp(static_cast<int>(std::floor(drop_x / slot_width)), 0,
static_cast<int>(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<std::string> 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<RemoteSession> 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::RemoteSession>
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<VideoPoint> 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<VideoPoint> 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<std::shared_ptr<RemoteSession>> 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<std::string> 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<ui::DeviceGroupOption> 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<std::string> 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<ui::GalleryRecentDevice> devices;
std::set<std::string> 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<std::string> 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<std::string> 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<ui::GalleryNetworkDevice> 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<int>(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<std::string, std::string> 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<slint::SharedString> 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<slint::VectorModel<slint::SharedString>>(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<std::string> 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<std::string> preview_ids;
std::vector<ui::GalleryTile> tiles;
std::vector<VideoLatencyFrame> 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<slint::Rgba8Pixel> 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<ui::GalleryActionDevice> 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<unsigned char>().swap(props->gallery_nv12_);
std::vector<unsigned char>().swap(props->gallery_scaled_);
std::vector<unsigned char>().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