diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 3ef4df56..bb34e63d 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1,11 +1,9 @@ #include "application/gui_application.h" -#include - #include #include +#include #include -#include #include #include @@ -18,10 +16,13 @@ #include #include #include +#include #include #include +#include #include #include +#include #include #include #include @@ -33,6 +34,9 @@ #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 "localization.h" #include "platform.h" #include "platform/video_renderer.h" @@ -273,13 +277,11 @@ std::string FormatPeerId(const char* id) { } std::string FormatRemotePeerIdInput(std::string_view input) { - std::string digits; - digits.reserve(9); - for (const unsigned char ch : input) { - if (std::isdigit(ch) != 0 && digits.size() < 9) { - digits.push_back(static_cast(ch)); - } - } + 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, " "); } @@ -290,11 +292,7 @@ std::string FormatRemotePeerIdInput(std::string_view input) { } std::string CompactPeerId(std::string id) { - id.erase( - std::remove_if(id.begin(), id.end(), - [](unsigned char ch) { return std::isspace(ch) != 0; }), - id.end()); - return id; + return gallery::Workspace::NormalizeId(id); } std::string Trim(std::string value) { @@ -848,6 +846,51 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x, } // namespace struct GuiApplication::SlintUi { + gallery::Workspace gallery; + std::filesystem::path gallery_path; + std::vector gallery_ids; + std::set gallery_hidden_active_ids; + std::string group_edit_id; + std::shared_ptr> group_options = + std::make_shared>(); + // Snapshot while the import picker is open; never includes credentials. + std::shared_ptr> gallery_recent_model = + std::make_shared>(); + std::string gallery_focus, gallery_auth_id, gallery_requested_auth, + gallery_control_id; + std::chrono::steady_clock::time_point gallery_synced{}; + gallery::WindowState gallery_window; + gallery::FullscreenTransition gallery_transition; + int main_restore_ticks = 0; + float loaded_window_scale = 0; + std::optional observed_main_size; + struct Preview { + std::weak_ptr session; + uint64_t sequence = 0; + slint::Image image; + }; + std::unordered_map gallery_previews; + struct BatchOperation { + std::string id, action, file; + // Only explicit add-device requests carry credentials; never persisted. + std::optional password; + bool remember_password = false; + enum class State { Queued, Running, AwaitingPassword, Finished }; + State state = State::Queued; + size_t file_index = 0; + std::weak_ptr session; + std::chrono::steady_clock::time_point started{}; + }; + // Transient concurrency control only; completed operations are discarded. + std::vector batch_operations; + std::string saved_window_signature; + std::chrono::steady_clock::time_point window_saved{}; + std::shared_ptr> gallery_model = + std::make_shared>(); + std::shared_ptr> gallery_selected_model = + std::make_shared>(); + float pointer_x = 0, pointer_y = 0; + std::set pressed_mouse_buttons; uint64_t announcement_version = UINT64_MAX; std::string announcement_selected_id, announcement_selected_revision; slint::ComponentHandle main = ui::MainWindow::create(); @@ -879,6 +922,8 @@ struct GuiApplication::SlintUi { bool connection_password_initialized = false; bool portable_service_dialog_initialized = false; std::string recent_model_signature; + std::optional recent_group_filter; + std::vector recent_filter_groups; slint::Timer timer; slint::Timer server_file_dialog_timer; WindowDragState main_drag; @@ -971,6 +1016,9 @@ int GuiApplication::Run() { InitializeModules(); update_checker_ = std::make_unique(CROSSDESK_VERSION); InitializeUi(); + // Configure the initial geometry before the native window becomes visible. + // Subsequent ticks only acknowledge that placement and its resolved DPI. + RestoreMainWindowGeometry(true); ui_->timer.start(slint::TimerMode::Repeated, 16ms, [this] { Tick(); }); ScheduleNextVideoFrame(); @@ -981,7 +1029,8 @@ int GuiApplication::Run() { #if _WIN32 ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); #elif defined(__APPLE__) - ConfigureMainWindowChrome(ui_->main->window().appkit_view()); + ConfigureMainWindowChrome(ui_->main->window().appkit_view(), + ui_->main->get_gallery_mode()); #endif slint::run_event_loop(slint::EventLoopMode::RunUntilQuit); Cleanup(); @@ -1108,6 +1157,48 @@ void GuiApplication::InitializeUi() { 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_->main->set_gallery_tiles(ui_->gallery_model); + ui_->main->set_gallery_selected_devices(ui_->gallery_selected_model); + // Always start on Connection; saved wall geometry is restored only when + // the user switches to DeskHub, regardless of the previous launch's mode. + ui_->main->set_gallery_mode(false); + 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 = scale; + const int width = std::clamp(saved.value("width", 640), 640, 7680); + const int height = std::clamp(saved.value("height", 486), 486, 4320); + ui_->gallery_window.wall_size = + slint::LogicalSize(slint::Size{float(width), float(height)}); + ui_->gallery_window.wall_fullscreen = saved.value("fullscreen", false); + ui_->gallery_window.wall_maximized = saved.value("maximized", false); +#if defined(__APPLE__) + // Older state used Slint's cached zoom flag, which could misclassify the + // fixed-size connection window as maximized. Keep its geometry/fullscreen + // state, but only trust the maximized flag written by the native check. + if (saved.value("version", 1) < 2) + ui_->gallery_window.wall_maximized = false; +#endif + if (saved.contains("wall_x")) + ui_->gallery_window.wall_position = + slint::PhysicalPosition(slint::Point{ + int(saved.at("wall_x").get() * scale), + int(saved.at("wall_y").get() * scale)}); + if (saved.contains("connection_x")) + ui_->gallery_window.connection_position = + slint::PhysicalPosition(slint::Point{ + int(saved.at("connection_x").get() * scale), + int(saved.at("connection_y").get() * scale)}); + ui_->gallery_window.BeginRestore(); + } + } catch (...) { + LOG_WARN("Could not restore DeskHub window state"); + } ResetSettingsUi(); BindMainCallbacks(); InitializeSystemTray(); @@ -1130,7 +1221,8 @@ void GuiApplication::InitializeSystemTray() { #endif ui_->main->window().set_minimized(false); #if defined(__APPLE__) - ConfigureMainWindowChrome(ui_->main->window().appkit_view()); + ConfigureMainWindowChrome(ui_->main->window().appkit_view(), + ui_->main->get_gallery_mode()); 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. @@ -1224,6 +1316,50 @@ void GuiApplication::ResetSettingsUi() { } void GuiApplication::BindMainCallbacks() { + ui_->main->on_gallery_pointer([this](slint::PointerEventButton button, + slint::PointerEventKind kind, float x, + float y) { + if (!ui_->gallery_control_id.empty()) + SendPointerInput(static_cast(button), static_cast(kind), x, y); + }); + ui_->main->on_gallery_scroll([this](float dx, float dy, float x, float y) { + if (!ui_->gallery_control_id.empty()) SendScrollInput(dx, dy, x, y); + }); + ui_->main->on_gallery_key([this](slint::SharedString text, bool pressed, + bool ctrl, bool alt, bool shift, bool meta) { + if (!ui_->gallery_control_id.empty()) + SendKeyInput(std::string(text), pressed, ctrl, alt, shift, meta); + }); + ui_->main->on_change_mode([this](bool wall) { ChangeMainMode(wall); }); + ui_->main->on_toggle_gallery_fullscreen( + [this] { ToggleGalleryFullscreen(); }); + ui_->main->on_gallery_action( + [this](slint::SharedString action, slint::SharedString value) { + GalleryAction(std::string(action), std::string(value)); + }); + ui_->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); + }); + ui_->main->on_gallery_import_recent([this](slint::SharedString group) { + return ImportGalleryRecent(std::string(group)); + }); + ui_->main->on_device_group_action( + [this](slint::SharedString action, slint::SharedString value) { + DeviceGroupAction(std::string(action), std::string(value)); + }); + ui_->main->on_device_group_save([this](slint::SharedString group) { + return SaveDeviceGroups(std::string(group)); + }); + ui_->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(); + }); ui_->main->on_delete_announcement([this](slint::SharedString id, slint::SharedString revision) { const auto parse = [](const slint::SharedString& value) @@ -1295,7 +1431,9 @@ void GuiApplication::BindMainCallbacks() { 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"); @@ -1327,6 +1465,15 @@ void GuiApplication::BindMainCallbacks() { }); 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); @@ -1338,6 +1485,7 @@ void GuiApplication::BindMainCallbacks() { } settings_.SaveRecentConnectionAliases(); reload_recent_connections_ = true; + ui_->gallery_synced = {}; }); main->on_recent_delete([this](slint::SharedString id) { const std::string remote_id(id); @@ -1490,9 +1638,13 @@ void GuiApplication::BindStreamCallbacks() { { std::shared_lock lock(remote_sessions_mutex_); for (const auto& [id, _] : remote_sessions_) { - ids.push_back(id); + if (!_->wall_managed_) + ids.push_back(id); + else + _->tab_opened_ = false; } } + ReleaseControl(); for (const auto& id : ids) { CloseStreamTab(id); } @@ -1529,8 +1681,14 @@ void GuiApplication::BindStreamCallbacks() { 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) { CloseStreamTab(std::string(id)); }); + 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 || @@ -1601,10 +1759,7 @@ void GuiApplication::BindStreamCallbacks() { props->control_mouse_ = !props->control_mouse_; props->enable_mouse_control_ = props->control_mouse_; start_keyboard_capturer_ = props->control_mouse_; - if (!props->control_mouse_) { - devices_.StopKeyboardCapturer(); - keyboard_.ForceReleasePressedKeys(); - } + if (!props->control_mouse_) ReleaseControl(); }); stream->on_change_video_settings([this](int quality, int frame_rate_index, int preference) { @@ -1722,8 +1877,9 @@ void GuiApplication::BindStreamCallbacks() { 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) { SetStreamKeyboardFocus(focused); }); + stream->on_keyboard_focus_changed([this](bool focused) { + if (ui_->gallery_control_id.empty()) SetStreamKeyboardFocus(focused); + }); } void GuiApplication::BindServerCallbacks() { @@ -1886,11 +2042,13 @@ void GuiApplication::Tick() { SyncStreamKeyboardFocus(); #endif devices_.UpdateInteractions(); + if (!ui_->gallery_control_id.empty()) keyboard_.SendHeartbeat(false); ShareLocalCursorState(); CloseRemoteClosedTabs(); UpdateLocalization(); SyncMainWindow(); + SyncGallery(); SyncConnectionDialog(); SyncPlatformDialogs(); SyncStreamWindow(); @@ -2388,7 +2546,8 @@ void GuiApplication::SyncMainWindow() { #endif #if defined(__APPLE__) if (ui_->main_native_titlebar_attempts > 0) { - if (ConfigureMainWindowChrome(ui_->main->window().appkit_view())) { + if (ConfigureMainWindowChrome(ui_->main->window().appkit_view(), + ui_->main->get_gallery_mode())) { ui_->main_native_titlebar_attempts = 0; } else { --ui_->main_native_titlebar_attempts; @@ -2440,15 +2599,68 @@ void GuiApplication::SyncMainWindow() { show_offline_warning_window_ = false; } + // Keep this filter separate from DeskHub's current group, keyed by name so + // adding/removing groups does not silently select a different one by index. + const auto tr_group = [this](const char* zh, const char* en, const char* ru) { + return UiText(localization_language_index_ == 0 ? zh + : localization_language_index_ == 2 ? ru : en); + }; + std::vector recent_groups{ + tr_group("全部设备", "All devices", "Все устройства"), + tr_group("未分组", "Ungrouped", "Без группы")}; + int recent_group_index = 0; + if (ui_->recent_group_filter) { + if (ui_->recent_group_filter->empty()) recent_group_index = 1; + else { + const auto found = std::find(ui_->gallery.groups.begin(), + ui_->gallery.groups.end(), *ui_->recent_group_filter); + if (found == ui_->gallery.groups.end()) ui_->recent_group_filter.reset(); + else recent_group_index = int(found - ui_->gallery.groups.begin()) + 2; + } + } + ui_->recent_filter_groups = ui_->gallery.groups; + for (const auto& group : ui_->recent_filter_groups) + recent_groups.push_back(UiText(group)); + const auto old_recent_groups = ui_->main->get_recent_groups(); + bool recent_groups_changed = !old_recent_groups || + old_recent_groups->row_count() != recent_groups.size(); + if (!recent_groups_changed) + for (size_t i = 0; i < recent_groups.size(); ++i) + recent_groups_changed |= old_recent_groups->row_data(i) != recent_groups[i]; + if (recent_groups_changed) + ui_->main->set_recent_groups( + std::make_shared>(recent_groups)); + ui_->main->set_recent_group_index(recent_group_index); + + // Derive group labels from the shared workspace, including devices with + // multiple memberships. No separate grouping metadata is stored in history. + std::unordered_map group_labels; + for (const auto& member : ui_->gallery.members) { + if (member.group.empty()) continue; + auto& label = group_labels[member.id]; + if (!label.empty()) label += " · "; + label += member.group; + } + const auto is_connected = [this](const std::string& id) { + const auto props = FindRemoteSession(id); + return props && props->connection_status_ == ConnectionStatus::Connected && + !props->closing_; + }; std::ostringstream signature_builder; - signature_builder << config_center_->IsSaveRemotePreviews() << '\n'; + signature_builder << config_center_->IsSaveRemotePreviews() << '\n' + << bool(ui_->recent_group_filter) << '\0' + << ui_->recent_group_filter.value_or("") << '\n'; for (const auto& [key, connection] : recent_connections_) { signature_builder << key << '\0' << connection.remote_id << '\0' << connection.remote_host_name << '\0' << static_cast(connection.platform) << '\0' << settings_.RecentConnectionDisplayName(connection) << '\0' + << group_labels[connection.remote_id] << '\0' + << ui_->gallery.MatchesGroup(connection.remote_id, + ui_->recent_group_filter) << '\0' << device_presence_cache_.IsOnline(connection.remote_id) + << '\0' << is_connected(connection.remote_id) << '\n'; } const std::string signature = signature_builder.str(); @@ -2460,14 +2672,18 @@ void GuiApplication::SyncMainWindow() { std::vector model; model.reserve(recent_connections_.size()); for (const auto& [_, connection] : recent_connections_) { + if (!ui_->gallery.MatchesGroup(connection.remote_id, ui_->recent_group_filter)) + continue; // Slint-generated scalar fields have no defaults. With no image, // has_thumbnail must remain false so the remote platform is rendered. ui::RecentConnection item{}; item.remote_id = UiText(connection.remote_id); item.host_name = UiText(connection.remote_host_name); + item.group_names = UiText(group_labels[connection.remote_id]); item.display_name = UiText(settings_.RecentConnectionDisplayName(connection)); item.online = device_presence_cache_.IsOnline(connection.remote_id); + item.connected = is_connected(connection.remote_id); item.platform = UiText(HostPlatformName(connection.platform)); std::vector rgba; int image_width = 0; @@ -2501,7 +2717,8 @@ void GuiApplication::SyncConnectionDialog() { return; } - auto props = FindRemoteSession(focused_remote_id_); + auto props = FindRemoteSession( + ui_->gallery_auth_id.empty() ? focused_remote_id_ : ui_->gallery_auth_id); if (!props) { ui_->main->set_connection_dialog_open(false); return; @@ -2512,6 +2729,12 @@ void GuiApplication::SyncConnectionDialog() { } 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); + return; + } const auto auth = props->connection_auth_.Get(); const bool password_required = auth.password_required || (status == ConnectionStatus::IncorrectPassword && auth.validating); @@ -2632,12 +2855,16 @@ void GuiApplication::SyncXWaylandWindowActivation() { Display* display = ui_->x11_focus_display; const ::Window active_window = X11GetActiveWindow(display); if (!active_window) { + if (!ui_->gallery_control_id.empty()) + ReleaseControl(); SetStreamKeyboardFocus(false); return; } if (const auto process_id = X11GetWindowProcessId(display, active_window); process_id && *process_id != static_cast(getpid())) { + if (!ui_->gallery_control_id.empty()) + ReleaseControl(); ui_->main->set_window_active(false); if (ui_->stream) { (*ui_->stream)->set_window_active(false); @@ -2648,6 +2875,11 @@ void GuiApplication::SyncXWaylandWindowActivation() { ui_->main->set_window_active( X11WindowMatches(display, active_window, ui_->main->window())); + if (!ui_->gallery_control_id.empty()) { + if (!X11WindowMatches(display, active_window, ui_->main->window())) + ReleaseControl(); + return; + } if (ui_->stream) { const bool stream_active = X11WindowMatches(display, active_window, (*ui_->stream)->window()); @@ -2671,12 +2903,20 @@ void GuiApplication::SetStreamKeyboardFocus(bool focused) { focus_on_stream_window_ = focused; if (!focused) { - devices_.StopKeyboardCapturer(); - keyboard_.ForceReleasePressedKeys(); + ReleaseControl(); } } void GuiApplication::SyncStreamKeyboardFocus() { + if (ui_ && !ui_->gallery_control_id.empty()) { +#if defined(__APPLE__) + if (!IsMainWindowActive()) ReleaseControl(); +#elif defined(_WIN32) + if (GetForegroundWindow() != ui_->main->window().win32_hwnd()) + ReleaseControl(); +#endif + return; + } if (!ui_ || !ui_->stream || !(*ui_->stream)->window().is_visible()) { SetStreamKeyboardFocus(false); return; @@ -2692,10 +2932,14 @@ void GuiApplication::SyncStreamKeyboardFocus() { } void GuiApplication::SyncStreamWindow() { + if (!ui_->gallery_control_id.empty()) return; bool has_sessions = false; { std::shared_lock lock(remote_sessions_mutex_); - has_sessions = !remote_sessions_.empty(); + 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) { @@ -2727,6 +2971,13 @@ void GuiApplication::SyncStreamWindow() { 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 @@ -3103,6 +3354,7 @@ void GuiApplication::SyncStreamWindow() { } void GuiApplication::SyncStreamVideoFrame() { + if (ui_ && !ui_->gallery_control_id.empty()) return; if (!ui_ || !ui_->stream || !ui_->video_presenter) { return; } @@ -3598,12 +3850,21 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) { } void GuiApplication::ConnectFromUi(const std::string& input) { + ui_->gallery_auth_id.clear(); const std::string remote_id = CompactPeerId(input); - if (remote_id.empty()) { + 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->connection_established_) { + existing && !existing->closing_ && existing->connection_established_) { + ReleaseControl(); + existing->tab_opened_ = true; focused_remote_id_ = remote_id; need_to_create_stream_window_ = true; return; @@ -3627,10 +3888,8 @@ void GuiApplication::SelectStreamTab(int 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) { - devices_.StopKeyboardCapturer(); - keyboard_.ForceReleasePressedKeys(); - } + controlled_remote_id_ != selected_remote_id) + ReleaseControl(); focused_remote_id_ = selected_remote_id; controlled_remote_id_ = selected_remote_id; if (ui_->video_presenter) { @@ -3645,10 +3904,17 @@ void GuiApplication::SelectStreamTab(int index) { } std::shared_ptr selected_session; { + std::lock_guard delivery_lock(audio_delivery_mutex_); std::shared_lock lock(remote_sessions_mutex_); for (auto& [id, props] : remote_sessions_) { if (props) { props->tab_selected_ = id == focused_remote_id_; + props->clipboard_allowed_ = props->tab_selected_ && + props->control_mouse_ && + focus_on_stream_window_; + props->audio_allowed_ = + (props->tab_selected_ && props->audio_capture_button_pressed_ && + focus_on_stream_window_); } } const auto selected = remote_sessions_.find(selected_remote_id); @@ -3697,7 +3963,21 @@ void GuiApplication::CloseRemoteClosedTabs() { } void GuiApplication::CloseStreamTab(const std::string& remote_id) { + // A user disconnect also cancels work that could reconnect this device. + // Mark in place: this can run while SyncGallery is iterating the queue. + for (auto& operation : ui_->batch_operations) { + if (operation.id == remote_id) { + operation.state = SlintUi::BatchOperation::State::Finished; + operation.password.reset(); + } + } CancelPendingConnection(remote_id); + if (ui_->gallery_requested_auth == remote_id) + ui_->gallery_requested_auth.clear(); + if (ui_->gallery_auth_id == remote_id) { + ui_->gallery_auth_id.clear(); + show_connection_status_window_ = false; + } std::shared_ptr props; { std::shared_lock lock(remote_sessions_mutex_); @@ -3709,6 +3989,8 @@ void GuiApplication::CloseStreamTab(const std::string& remote_id) { if (!props) { return; } + if (controlled_remote_id_ == remote_id) + ReleaseControl(); keyboard_.ReleaseRemotePressedKeys(remote_id, "stream_tab_closed"); CloseRemoteSession(props); ResetRemoteSessionResources(props); @@ -3730,6 +4012,8 @@ void GuiApplication::CloseStreamTab(const std::string& remote_id) { std::shared_ptr GuiApplication::SelectedSession() { + if (ui_ && !ui_->gallery_control_id.empty()) + return FindRemoteSession(ui_->gallery_control_id); std::shared_lock lock(remote_sessions_mutex_); auto found = remote_sessions_.find(focused_remote_id_); if (found != remote_sessions_.end()) { @@ -3745,20 +4029,32 @@ GuiApplication::SelectedSession() { void GuiApplication::SendPointerInput(int button, int kind, float x, float y) { auto props = SelectedSession(); - if (!props || !props->peer_ || !props->control_mouse_ || !ui_->stream) { + if (!props || !props->peer_ || !props->control_mouse_ || + (!ui_->stream && ui_->gallery_control_id.empty())) { return; } std::optional point; { std::lock_guard lock(props->video_frame_mutex_); - point = MapVideoPoint(x, y, (*ui_->stream)->get_video_area_width(), - (*ui_->stream)->get_video_area_height(), + point = MapVideoPoint(x, y, + ui_->gallery_control_id.empty() + ? (*ui_->stream)->get_video_area_width() + : ui_->main->get_gallery_video_width(), + ui_->gallery_control_id.empty() + ? (*ui_->stream)->get_video_area_height() + : ui_->main->get_gallery_video_height(), props->video_width_, props->video_height_); } - if (!point) { + 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; @@ -3790,6 +4086,10 @@ void GuiApplication::SendPointerInput(int button, int kind, float x, float y) { 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(); { @@ -3803,14 +4103,20 @@ void GuiApplication::SendPointerInput(int button, int kind, float x, float y) { 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) { + if (!props || !props->peer_ || !props->control_mouse_ || + (!ui_->stream && ui_->gallery_control_id.empty())) { return; } std::optional point; { std::lock_guard lock(props->video_frame_mutex_); - point = MapVideoPoint(x, y, (*ui_->stream)->get_video_area_width(), - (*ui_->stream)->get_video_area_height(), + point = MapVideoPoint(x, y, + ui_->gallery_control_id.empty() + ? (*ui_->stream)->get_video_area_width() + : ui_->main->get_gallery_video_width(), + ui_->gallery_control_id.empty() + ? (*ui_->stream)->get_video_area_height() + : ui_->main->get_gallery_video_height(), props->video_width_, props->video_height_); } if (!point) { @@ -3882,6 +4188,1038 @@ bool GuiApplication::OpenUrl(const std::string& url) { return SDL_OpenURL(url.c_str()); } +void GuiApplication::ReleaseControl() { + const auto gallery_target = ui_->gallery_control_id; + devices_.StopKeyboardCapturer(); + 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()); + } + } + if (!ui_->gallery_control_id.empty()) { + ui_->gallery_control_id.clear(); + ui_->main->set_gallery_controlling(false); + } + ui_->pressed_mouse_buttons.clear(); + std::lock_guard delivery_lock(audio_delivery_mutex_); + devices_.ClearAudio(); + std::shared_lock lock(remote_sessions_mutex_); + for (const auto& [id, props] : remote_sessions_) { + ++props->input_generation_; + props->clipboard_allowed_ = false; + props->audio_allowed_ = false; + if (id == gallery_target) { + props->control_mouse_ = false; + props->enable_mouse_control_ = false; + props->audio_capture_button_pressed_ = false; + } + } + controlled_remote_id_.clear(); + start_keyboard_capturer_ = false; +} + +void GuiApplication::ChangeMainMode(bool wall) { + if (!ui_ || ui_->main->get_gallery_mode() == wall) return; + ui_->gallery_transition.Cancel(); + ui_->main->set_gallery_transitioning(false); + ui_->main->set_gallery_content_visible(true); + ReleaseControl(); + auto& window = ui_->main->window(); +#if defined(__APPLE__) + const bool fullscreen = IsMainWindowFullscreen(); + const bool maximized = IsMainWindowMaximized(); +#else + const bool fullscreen = window.is_fullscreen(); + const bool maximized = window.is_maximized(); +#endif + ui_->gallery_window.BeginModeChange(wall, window.position(), window.size(), + window.scale_factor(), fullscreen, maximized, + ui_->main->get_wayland_titlebar() ? 518.0f + : ui_->main->get_custom_titlebar() ? 517.0f : 486.0f); + LOG_INFO("DeskHub mode requested: {}; client={}x{} scale={}", + wall ? "wall" : "connection", window.size().width, + window.size().height, window.scale_factor()); + if (!wall) { +#if defined(__APPLE__) + SetMainWindowFullscreen(false); +#else + window.set_fullscreen(false); + window.set_maximized(false); +#endif + } + ui_->gallery.selected.clear(); + ui_->gallery_auth_id.clear(); + ui_->gallery_requested_auth.clear(); + show_connection_status_window_ = false; + ui_->main->invoke_reset_gallery_transient(); + ui_->main->set_gallery_fullscreen(false); + ui_->main->set_gallery_mode(wall); + if (wall) need_to_send_recent_connections_ = true; + // Geometry is recorded again only after the native window acknowledges it. + ui_->main_restore_ticks = 0; + ui_->main_native_titlebar_attempts = 30; + ui_->gallery_synced = {}; + show_password_ = false; +} + +void GuiApplication::ToggleGalleryFullscreen() { + if (!ui_ || !ui_->main->get_gallery_mode() || + ui_->gallery_transition.Active()) return; +#if defined(__APPLE__) + const bool fullscreen = IsMainWindowFullscreen(); +#else + const bool fullscreen = ui_->main->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()); + ReleaseControl(); + ui_->main->set_gallery_transitioning(true); + ui_->main->set_gallery_content_visible(false); + ui_->gallery_synced = {}; +} + +void GuiApplication::SaveGalleryWindowState() { + if (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 = std::max(ui_->main->window().scale_factor(), 1.0f); + nlohmann::json state{{"version", 2}, + {"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_->gallery_window.connection_position) { + state["connection_x"] = + ui_->gallery_window.connection_position->x / scale; + state["connection_y"] = + ui_->gallery_window.connection_position->y / 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_->main->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; + 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_->main->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_->main->set_gallery_notice(""); + return true; +} + +bool GuiApplication::ImportGalleryRecent(const std::string& input_group) { + std::vector ids; + for (size_t i = 0; i < ui_->gallery_recent_model->row_count(); ++i) { + const auto device = ui_->gallery_recent_model->row_data(i); + if (device && device->selected) ids.emplace_back(device->id); + } + if (ids.empty()) return false; + const auto group = Trim(input_group); + auto updated = ui_->gallery; + updated.AddMany(ids, group); + if (!updated.Save(ui_->gallery_path)) { + ui_->main->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; + } + ui_->gallery_focus.clear(); + ui_->gallery.selected.clear(); + ui_->main->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_->main->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_->main->set_gallery_notice(""); + return true; +} + +void GuiApplication::DeviceGroupAction(const std::string& action, + const std::string& value) { + ui_->main->set_device_group_error(""); + if (action == "close") { + ui_->group_edit_id.clear(); + ui_->group_options->set_vector({}); + } else if (action == "open") { + ui_->group_edit_id = value; + std::vector options; + options.push_back({"", true}); + for (const auto& name : ui_->gallery.groups) { + const bool selected = std::any_of( + ui_->gallery.members.begin(), ui_->gallery.members.end(), + [&](const auto& m) { return m.id == value && m.group == name; }); + options.push_back({UiText(name), selected}); + if (selected) options.front().selected = false; + } + ui_->group_options->set_vector(options); + ui_->main->set_device_group_options(ui_->group_options); + } else if (action == "toggle" && !ui_->group_edit_id.empty()) { + bool grouped = false; + for (size_t i = 1; i < ui_->group_options->row_count(); ++i) { + auto option = *ui_->group_options->row_data(i); + if (value.empty()) option.selected = false; + else if (std::string(option.name) == value) option.selected = !option.selected; + grouped |= option.selected; + ui_->group_options->set_row_data(i, option); + } + ui_->group_options->set_row_data(0, {"", !grouped}); + if (value.empty()) ui_->main->set_device_group_new_name(""); + } +} + +bool GuiApplication::SaveDeviceGroups(const std::string& new_group) { + const auto& id = ui_->group_edit_id; + if (!gallery::Workspace::ValidId(id)) return false; + std::vector names; + for (size_t i = 0; i < ui_->group_options->row_count(); ++i) { + const auto option = *ui_->group_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)) { + ui_->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_->main->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_->main->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 = {}; + ui_->main->set_device_group_error(""); + SyncGallery(); + return true; +} + +void GuiApplication::GalleryAction(const std::string& action, + const std::string& value) { + using OperationState = SlintUi::BatchOperation::State; + auto& wall = ui_->gallery; + ui_->gallery_synced = {}; + 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_->main->set_gallery_notice(""); + const int group_index = ui_->main->get_gallery_group_index(); + ui_->main->set_gallery_import_group(UiText( + group_index > 0 && group_index <= int(wall.groups.size()) + ? wall.groups[group_index - 1] : "")); + std::vector devices; + std::set seen; + for (const auto& [_, recent] : recent_connections_) { + if (!gallery::Workspace::ValidId(recent.remote_id) || + !seen.insert(recent.remote_id).second) + continue; + ui::GalleryRecentDevice device{}; + device.id = UiText(recent.remote_id); + device.name = UiText(settings_.RecentConnectionDisplayName(recent)); + device.online = device_presence_cache_.IsOnline(recent.remote_id); + devices.push_back(std::move(device)); + } + ui_->gallery_recent_model->set_vector(std::move(devices)); + ui_->main->set_gallery_recent_devices(ui_->gallery_recent_model); + ui_->main->set_gallery_recent_selected(0); + return; + } + if (action == "recent-close") { + ui_->gallery_recent_model->set_vector({}); + ui_->main->set_gallery_recent_selected(0); + ui_->main->set_gallery_notice(""); + return; + } + if (action == "recent-select" || action == "recent-all") { + const bool select_all = ui_->main->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_->main->set_gallery_recent_selected(count); + return; + } + if (action == "add-open") { + ui_->main->set_gallery_notice(""); + return; + } else if (action == "group") { + ReleaseControl(); + wall.page = 0; + wall.selected.clear(); + ui_->gallery_focus.clear(); + } else if (action == "selection") { + 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 main-window FocusScope owns keyboard input. Native capture stays off + // so local toolbar/menu events can never leak into this remote session. + start_keyboard_capturer_ = false; + ui_->main->set_gallery_controlling(true); + } else if (action == "remove") { + ReleaseControl(); + auto updated = wall; + const int group_index = ui_->main->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_->main->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 == "file") { + std::vector ids; + if (!value.empty()) + ids.push_back(value); + else + for (const auto& id : ui_->gallery_ids) + if (wall.selected.count(id)) ids.push_back(id); + if (ids.empty()) return; + if (action == "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::RestoreMainWindowGeometry(bool before_show) { + if (!ui_ || !ui_->gallery_window.RestoringNormal()) return; + auto& window = ui_->main->window(); + auto& placement = ui_->gallery_window; + const auto current_size = window.size(); + const bool wall_mode = ui_->main->get_gallery_mode(); + const float compact_height = ui_->main->get_wayland_titlebar() ? 518.0f + : ui_->main->get_custom_titlebar() ? 517.0f : 486.0f; + if (!before_show && ui_->loaded_window_scale > 0) { + const float ratio = window.scale_factor() / ui_->loaded_window_scale; + if (ui_->gallery_window.wall_position) { + ui_->gallery_window.wall_position->x *= ratio; + ui_->gallery_window.wall_position->y *= ratio; + } + if (ui_->gallery_window.connection_position) { + ui_->gallery_window.connection_position->x *= ratio; + ui_->gallery_window.connection_position->y *= ratio; + } + ui_->loaded_window_scale = 0; + } + auto target_size = placement.RestoreSize(wall_mode, window.scale_factor(), compact_height); + const auto& position = ui_->main->get_gallery_mode() + ? ui_->gallery_window.wall_position + : ui_->gallery_window.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 (ui_->gallery_window.wall_maximized && + !ui_->gallery_window.wall_fullscreen) { +#if defined(__APPLE__) + MaximizeMainWindowWithoutAnimation(); +#else + window.set_maximized(true); +#endif + } + if (ui_->gallery_window.wall_fullscreen) { +#if defined(__APPLE__) + ConfigureMainWindowChrome(window.appkit_view(), true); + SetMainWindowFullscreen(true); +#else + window.set_fullscreen(true); +#endif + } + } +} + +void GuiApplication::SyncGallery() { + using OperationState = SlintUi::BatchOperation::State; + if (!ui_) return; + auto& window = ui_->main->window(); + const auto current_size = window.size(); + if (ui_->observed_main_size && + (current_size.width != ui_->observed_main_size->width || + current_size.height != ui_->observed_main_size->height)) { + if (!ui_->gallery_control_id.empty()) ReleaseControl(); + // Native resize can redraw before the usual 200ms preview refresh. + ui_->gallery_synced = {}; + } + ui_->observed_main_size = current_size; + const bool visible = ui_->main->get_gallery_mode() && window.is_visible() && + !window.is_minimized(); + auto& placement = ui_->gallery_window; + const bool wall_mode = ui_->main->get_gallery_mode(); + auto& transition = ui_->gallery_transition; + if (!visible) transition.Cancel(); + const float compact_height = ui_->main->get_wayland_titlebar() ? 518.0f + : ui_->main->get_custom_titlebar() ? 517.0f : 486.0f; +#if defined(__APPLE__) + const bool fullscreen = IsMainWindowFullscreen(); + const bool maximized = IsMainWindowMaximized(); +#else + const bool fullscreen = window.is_fullscreen(); + const bool maximized = window.is_maximized(); +#endif + if (!transition.Active() && placement.RecoverUndersized(wall_mode, window.position(), current_size, + window.scale_factor(), window.is_visible(), + window.is_minimized(), fullscreen, maximized, + compact_height)) { + ui_->main_restore_ticks = 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_->main_restore_ticks == 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()) { + RestoreMainWindowGeometry(); + } 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() && !transition.Active()) { + placement.Observe(wall_mode, window.position(), current_size, window.scale_factor(), + fullscreen, maximized, compact_height); + } + if (wall_mode && !placement.Restoring()) { + if (fullscreen != ui_->main->get_gallery_fullscreen()) ReleaseControl(); + ui_->main->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) { + // Update custom chrome before native resizing; keep content hidden until + // both the compositor geometry and the gallery model have settled. + ui_->main->set_gallery_fullscreen(transition.Target()); +#if defined(__APPLE__) + if (!SetMainWindowFullscreen(transition.Target())) { + ui_->main->set_gallery_fullscreen(fullscreen); + transition.Reveal(now); + } else +#else + window.set_fullscreen(transition.Target()); +#endif + { + placement.wall_fullscreen = transition.Target(); + } + ui_->gallery_synced = {}; + } + ui_->main->set_gallery_transitioning(transition.Active()); + ui_->main->set_gallery_content_visible(transition.ContentVisible()); + 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_->main->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); + }; + // 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") { + 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::vector groups{ + UiText(tr("全部设备", "All devices", "Все устройства"))}; + for (const auto& group : wall.groups) groups.push_back(UiText(group)); + const auto old_groups = ui_->main->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_->main->set_gallery_groups( + std::make_shared>(groups)); + const int group_index = std::clamp(ui_->main->get_gallery_group_index(), 0, + int(wall.groups.size())); + auto ids = wall.Ids(group_index == 0 ? "" : wall.groups[group_index - 1]); + if (group_index == 0) { + std::shared_lock lock(remote_sessions_mutex_); + std::vector active; + for (const auto& [id, props] : remote_sessions_) + if (props && !props->closing_ && !ui_->gallery_hidden_active_ids.count(id) && + std::find(ids.begin(), ids.end(), id) == ids.end()) + active.push_back(id); + std::sort(active.begin(), active.end()); + ids.insert(ids.end(), active.begin(), active.end()); + } + 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, int((ui_->main->get_gallery_width() - 46) / 196)); + const int rows = + std::max(1, int((ui_->main->get_gallery_height() - 114) / 144)); + wall.ResizePage(columns * rows, ids.size()); + // Resizing/fullscreen changes page capacity. Keep the focused device in + // the page model so the rail can still display and reveal its current card. + 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_->main->set_gallery_columns(columns); + ui_->main->set_gallery_rows(rows); + ui_->main->set_gallery_page(wall.page); + ui_->main->set_gallery_pages( + std::max(1, (int(ids.size()) + wall.capacity - 1) / wall.capacity)); + ui_->main->set_gallery_count(ids.size()); + int connected = 0, selected_on_page = 0; + std::set preview_ids; + std::vector tiles; + const auto make_tile = [&](const std::string& id, bool preview) { + ui::GalleryTile tile{}; + tile.id = UiText(id); + tile.name = UiText(name(id)); + tile.selected = wall.selected.count(id); + auto props = FindRemoteSession(id); + 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); + props->gallery_preview_width_ = ui_->gallery_focus == id ? 960 : 320; + std::lock_guard lock(props->gallery_frame_mutex_); + if (props->gallery_rgba_) { + auto& cached = ui_->gallery_previews[id]; + if (cached.sequence != props->gallery_sequence_ || + cached.session.lock() != props) { + slint::SharedPixelBuffer pixels( + props->gallery_width_, props->gallery_height_); + std::memcpy(pixels.begin(), props->gallery_rgba_->data(), + props->gallery_rgba_->size()); + cached.image = slint::Image(std::move(pixels)); + cached.sequence = props->gallery_sequence_; + cached.session = props; + } + 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_->main->set_gallery_focused(ui_->gallery_focus.empty() + ? ui::GalleryTile{} + : make_tile(ui_->gallery_focus, true)); + ui_->main->set_gallery_connected(connected); + if (!ui_->main->get_gallery_adding() && !ui_->main->get_gallery_importing()) + ui_->main->set_gallery_notice(""); + ui_->main->set_gallery_selected(wall.selected.size()); + ui_->main->set_gallery_hidden_selected(wall.selected.size() - + selected_on_page); + std::vector selected_devices; + 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; + selected_devices.push_back(std::move(device)); + } + 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); + } + { + std::shared_lock lock(remote_sessions_mutex_); + for (const auto& [id, props] : remote_sessions_) { + if (!preview_ids.count(id)) { + props->gallery_preview_width_ = 0; + std::unique_lock conversion_lock(props->gallery_conversion_mutex_, + std::try_to_lock); + if (conversion_lock.owns_lock()) { + std::vector().swap(props->gallery_nv12_); + std::vector().swap(props->gallery_scaled_); + std::vector().swap(props->gallery_scratch_); + } + std::lock_guard frame_lock(props->gallery_frame_mutex_); + props->gallery_rgba_.reset(); + } + if (props->preview_subscription_supported_ && + props->connection_status_ == ConnectionStatus::Connected) { + // Every connected device retains its interactive video settings, + // including devices on other pages or behind a hidden wall window. + 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()); + } + const bool acknowledged = + props->preview_ack_.load() == props->preview_request_ && + props->preview_accepted_.load(); + if (!demand.SameSettings(props->preview_requested_) || + (!acknowledged && now - props->preview_request_at_ > 5s)) { + 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; + } + } + } + } + std::erase_if(ui_->gallery_previews, [&](const auto& item) { + return !preview_ids.count(item.first); + }); +} + void GuiApplication::Cleanup() { #ifdef __linux__ HeadlessConsole::Instance().BindSettings(nullptr, {}, {}); @@ -3890,6 +5228,10 @@ void GuiApplication::Cleanup() { SDL_Quit(); return; } + SaveGalleryWindowState(); +#if defined(__APPLE__) + SetMainWindowFullscreen(false); +#endif ui_->timer.stop(); ui_->server_file_dialog_timer.stop(); ui_->video_timer.stop(); diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index 31c2a771..fe4ff2c2 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -60,6 +60,18 @@ private: void HandlePasswordChangeResult(); void HandleCredentialRecovery(); void SyncMainWindow(); + void ChangeMainMode(bool gallery); + void ToggleGalleryFullscreen(); + void RestoreMainWindowGeometry(bool before_show = false); + void SyncGallery(); + void GalleryAction(const std::string& action, const std::string& value); + bool AddGalleryDevice(const std::string& id, const std::string& password, + const std::string& group, bool remember_password); + bool ImportGalleryRecent(const std::string& group); + void DeviceGroupAction(const std::string& action, const std::string& value); + bool SaveDeviceGroups(const std::string& new_group); + void ReleaseControl(); + void SaveGalleryWindowState(); void SyncAnnouncements(); void SyncConnectionDialog(); void SyncPlatformDialogs(); diff --git a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp index 14c42af4..0252fb8f 100644 --- a/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp +++ b/apps/desktop/src/gui/features/clipboard/clipboard_controller.cpp @@ -46,7 +46,8 @@ void ClipboardController::Shutdown() { pending_remote_text_.reset(); } -void ClipboardController::QueueRemoteText(const char *data, size_t size) { +void ClipboardController::QueueRemoteText(const char* data, size_t size, + const std::string& remote_id) { if (!events_enabled_.load(std::memory_order_acquire)) { return; } @@ -62,6 +63,11 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) { return; } + const auto session = + remote_id.empty() ? nullptr : owner_.FindRemoteSession(remote_id); + if (!remote_id.empty() && (!session || !session->clipboard_allowed_.load())) + return; + const auto generation = session ? session->input_generation_.load() : 0; { std::lock_guard lock(pending_mutex_); if (!events_enabled_.load(std::memory_order_relaxed)) { @@ -70,6 +76,9 @@ void ClipboardController::QueueRemoteText(const char *data, size_t size) { // Only the newest clipboard value matters. Replacing it also bounds the // amount of memory a busy or malicious peer can queue. pending_remote_text_ = std::string(data, size); + pending_remote_id_ = remote_id; + pending_session_ = session; + pending_generation_ = generation; } SDL_Event event{}; @@ -87,9 +96,22 @@ void ClipboardController::ApplyPendingRemoteText() { } std::optional pending_text; + std::string source_id; + std::weak_ptr source_session; + uint64_t generation = 0; { std::lock_guard lock(pending_mutex_); pending_text.swap(pending_remote_text_); + source_id.swap(pending_remote_id_); + source_session.swap(pending_session_); + generation = pending_generation_; + } + if (!source_id.empty()) { + auto props = owner_.FindRemoteSession(source_id); + if (!props || props != source_session.lock() || + !props->clipboard_allowed_.load() || + props->input_generation_.load() != generation) + return; } if (!pending_text || *pending_text == last_text_) { return; @@ -145,7 +167,7 @@ int ClipboardController::SendToPeers(const std::string &text) { std::shared_lock lock(owner_.remote_sessions_mutex_); for (const auto &[remote_id, props] : owner_.remote_sessions_) { if (!props || !props->peer_ || !props->connection_established_ || - !props->enable_mouse_control_) { + !props->clipboard_allowed_.load()) { continue; } diff --git a/apps/desktop/src/gui/features/clipboard/clipboard_controller.h b/apps/desktop/src/gui/features/clipboard/clipboard_controller.h index 938a244e..e4d4d6dc 100644 --- a/apps/desktop/src/gui/features/clipboard/clipboard_controller.h +++ b/apps/desktop/src/gui/features/clipboard/clipboard_controller.h @@ -10,10 +10,15 @@ #include #include #include +#include #include #include #include +namespace crossdesk::gui_detail { +struct RemoteSession; +} + namespace crossdesk { class GuiRuntime; @@ -29,7 +34,8 @@ public: void Initialize(); void Shutdown(); - void QueueRemoteText(const char *data, size_t size); + void QueueRemoteText(const char* data, size_t size, + const std::string& remote_id = {}); void ApplyPendingRemoteText(); void HandleLocalUpdate(); @@ -41,6 +47,9 @@ private: std::atomic events_enabled_{false}; std::mutex pending_mutex_; std::optional pending_remote_text_; + std::string pending_remote_id_; + std::weak_ptr pending_session_; + uint64_t pending_generation_ = 0; std::string last_text_; }; diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.cpp b/apps/desktop/src/gui/features/devices/session_device_manager.cpp index aaa27474..564548ca 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -585,6 +585,10 @@ int SessionDeviceManager::DestroyAudioOutput() { return 0; } +void SessionDeviceManager::ClearAudio() { + if (output_stream_) SDL_ClearAudioStream(output_stream_); +} + void SessionDeviceManager::PushAudio(const char *data, size_t size) { if (!output_stream_) { return; diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.h b/apps/desktop/src/gui/features/devices/session_device_manager.h index 640afa7a..34d1e4ad 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.h +++ b/apps/desktop/src/gui/features/devices/session_device_manager.h @@ -67,6 +67,7 @@ public: int InitializeAudioOutput(); int DestroyAudioOutput(); + void ClearAudio(); void PushAudio(const char *data, size_t size); bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code, diff --git a/apps/desktop/src/gui/features/gallery/gallery_fullscreen_transition.h b/apps/desktop/src/gui/features/gallery/gallery_fullscreen_transition.h new file mode 100644 index 00000000..7803c5f0 --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/gallery_fullscreen_transition.h @@ -0,0 +1,102 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _GALLERY_FULLSCREEN_TRANSITION_H_ +#define _GALLERY_FULLSCREEN_TRANSITION_H_ + +#include +#include + +namespace crossdesk::gallery { + +// Driven by the existing UI tick; never pumps events or creates nested timers. +class FullscreenTransition { +public: + using Clock = std::chrono::steady_clock; + enum class Action { Idle, Apply, Reveal, Finished }; + + bool Begin(bool fullscreen, Clock::time_point now) { + if (Active()) + return false; + target_ = fullscreen; + phase_ = Phase::FadingOut; + phase_at_ = now; + width_ = height_ = 0; + scale_ = 0; + geometry_changed_ = false; + return true; + } + + Action Advance(Clock::time_point now, uint32_t width, uint32_t height, + float scale, bool fullscreen, bool restoring) { + using namespace std::chrono_literals; + if (phase_ == Phase::FadingOut) { + if (restoring && now - phase_at_ >= 1500ms) { + Reveal(now); + return Action::Reveal; + } + // Slint fades out for 60ms; allow another frame before native resizing. + if (now - phase_at_ < 80ms || restoring) + return Action::Idle; + phase_ = Phase::Settling; + phase_at_ = stable_at_ = now; + width_ = width; + height_ = height; + scale_ = scale; + return Action::Apply; + } + if (phase_ == Phase::Settling) { + const bool changed = + width != width_ || height != height_ || scale != scale_; + if (changed && width > 0 && height > 0) + geometry_changed_ = true; + if (changed || fullscreen != target_ || restoring) { + width_ = width; + height_ = height; + scale_ = scale; + stable_at_ = now; + } + // Some backends report the requested flag before delivering new bounds. + // Also allow fullscreen modes whose client dimensions genuinely match. + if ((width > 0 && height > 0 && fullscreen == target_ && !restoring && + (geometry_changed_ || now - phase_at_ >= 300ms) && + now - stable_at_ >= 32ms) || + now - phase_at_ >= 1500ms) { + Reveal(now); + return Action::Reveal; + } + } + if (phase_ == Phase::FadingIn && now - phase_at_ >= 80ms) { + Cancel(); + return Action::Finished; + } + return Action::Idle; + } + + void Reveal(Clock::time_point now) { + phase_ = Phase::FadingIn; + phase_at_ = now; + } + void Cancel() { phase_ = Phase::Idle; } + bool Active() const { return phase_ != Phase::Idle; } + bool Target() const { return target_; } + bool ContentVisible() const { + return phase_ == Phase::Idle || phase_ == Phase::FadingIn; + } + +private: + enum class Phase { Idle, FadingOut, Settling, FadingIn }; + Phase phase_ = Phase::Idle; + bool target_ = false; + bool geometry_changed_ = false; + uint32_t width_ = 0, height_ = 0; + float scale_ = 0; + Clock::time_point phase_at_{}, stable_at_{}; +}; + +} // namespace crossdesk::gallery + +#endif diff --git a/apps/desktop/src/gui/features/gallery/gallery_storage.cpp b/apps/desktop/src/gui/features/gallery/gallery_storage.cpp new file mode 100644 index 00000000..80c96bf8 --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/gallery_storage.cpp @@ -0,0 +1,37 @@ +#include "gallery_storage.h" + +#include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif +namespace crossdesk::gallery { +bool WriteAtomically(const std::filesystem::path &path, + const std::string &text) { + auto temporary = path; + temporary += ".tmp"; + try { + { + std::ofstream output; + output.exceptions(std::ios::failbit | std::ios::badbit); + output.open(temporary, std::ios::binary | std::ios::trunc); + output.write(text.data(), text.size()); + output.close(); + } +#ifdef _WIN32 + if (MoveFileExW(temporary.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + return true; +#else + std::filesystem::rename(temporary, path); + return true; +#endif + } catch (...) { + } + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + return false; +} +} // namespace crossdesk::gallery diff --git a/apps/desktop/src/gui/features/gallery/gallery_storage.h b/apps/desktop/src/gui/features/gallery/gallery_storage.h new file mode 100644 index 00000000..8795ce02 --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/gallery_storage.h @@ -0,0 +1,18 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _GALLERY_STORAGE_H_ +#define _GALLERY_STORAGE_H_ + +#include +#include + +namespace crossdesk::gallery { +bool WriteAtomically(const std::filesystem::path &path, + const std::string &text); +} // namespace crossdesk::gallery + +#endif diff --git a/apps/desktop/src/gui/features/gallery/gallery_window_state.h b/apps/desktop/src/gui/features/gallery/gallery_window_state.h new file mode 100644 index 00000000..aa31ec79 --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/gallery_window_state.h @@ -0,0 +1,193 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _GALLERY_WINDOW_STATE_H_ +#define _GALLERY_WINDOW_STATE_H_ + +#include + +#include +#include +#include + +namespace crossdesk::gallery { + +struct WindowState { + bool wall_fullscreen = false; + bool wall_maximized = false; + // Store client dimensions in logical pixels, independent of the display DPI. + std::optional wall_size; + std::optional wall_position, connection_position; + + void BeginModeChange(bool wall, slint::PhysicalPosition position, + slint::PhysicalSize size, float scale, bool fullscreen, + bool maximized, float compact_height = 486) { + // Never capture a half-applied mode transition as the user's wall geometry. + Observe(!wall, position, size, scale, fullscreen, maximized, + compact_height); + if (wall) { + // Modes share the current desktop location. An old wall position must + // not undo a move made while the connection page was visible. + if (connection_position) + wall_position = connection_position; + } else { + // Fullscreen/maximized coordinates describe the screen, not the ordinary + // window. Return to the last normal anchor before restoring compact size. + if (wall_position) + connection_position = wall_position; + } + BeginRestore(); + } + + void Observe(bool wall, slint::PhysicalPosition position, + slint::PhysicalSize size, float scale, bool fullscreen, + bool maximized, float compact_height = 486) { + if (Restoring()) + return; + if (!wall) { + connection_position = position; + return; + } + if (!fullscreen && !maximized && IsUndersized(size, scale, compact_height)) + return; + scale = std::max(scale, 1.0f); + wall_fullscreen = fullscreen; + wall_maximized = !fullscreen && maximized; + if (!fullscreen && !maximized) { + wall_size = slint::LogicalSize( + slint::Size{size.width / scale, size.height / scale}); + wall_position = position; + } + } + + bool RecoverUndersized(bool wall, slint::PhysicalPosition position, + slint::PhysicalSize size, float scale, bool visible, + bool minimized, bool fullscreen, bool maximized, + float compact_height = 486) { + // Hidden/minimized/uninitialized windows can report temporary tiny bounds. + // Leave those alone, and let an in-flight restoration retain its target. + if (Restoring() || !visible || minimized || fullscreen || maximized || + size.width == 0 || size.height == 0 || + !IsUndersized(size, scale, compact_height)) + return false; + if (wall) { + wall_position = position; + wall_fullscreen = false; + wall_maximized = false; + } else { + connection_position = position; + } + // Keep the last valid logical size; only the current anchor is captured. + BeginRestore(); + return true; + } + + slint::PhysicalSize RestoreSize(bool wall, float scale, + float compact_height = 486) const { + const float width = + wall && wall_size ? std::max(640.0f, wall_size->width) : 640; + const float height = wall && wall_size + ? std::max(compact_height, wall_size->height) + : compact_height; + scale = std::max(scale, 1.0f); + return slint::PhysicalSize( + slint::Size{uint32_t(std::lround(width * scale)), + uint32_t(std::lround(height * scale))}); + } + + void BeginRestore() { + phase_ = Phase::Normal; + target_.reset(); + last_size_.reset(); + matches_ = 0; + } + + void PrepareForShow(slint::Window &window, bool wall, + slint::PhysicalSize size, + std::optional position, + float initial_y_offset = 0) { + const float scale = std::max(window.scale_factor(), 1.0f); + // The actual display scale is resolved when the native window is created. + // Give the backend logical geometry so its first visible frame is correct. + window.set_size(slint::LogicalSize( + slint::Size{size.width / scale, size.height / scale})); + if (position) { + (wall ? wall_position : connection_position) = position; + window.set_position(slint::LogicalPosition(slint::Point{ + position->x / scale, position->y / scale + initial_y_offset})); + } + } + bool Restoring() const { return phase_ != Phase::Idle; } + bool RestoringNormal() const { return phase_ == Phase::Normal; } + + // A set_size request can complete asynchronously, after layout constraints + // and the backing scale have changed. Acknowledge observations on later + // ticks. + bool NormalSizeRestored(slint::PhysicalSize actual, + slint::PhysicalSize target) { + if (!RestoringNormal()) + return false; + if (target_ && SameSize(*target_, target) && SameSize(actual, target)) + ++matches_; + else + matches_ = 0; + target_ = target; + if (matches_ < 2) + return false; + phase_ = Phase::Presentation; + matches_ = 0; + last_size_.reset(); + return true; + } + + bool FinishRestore(bool wall, slint::PhysicalSize actual, bool fullscreen, + bool maximized) { + if (phase_ != Phase::Presentation) + return false; + const bool want_fullscreen = wall && wall_fullscreen; + const bool want_maximized = wall && !want_fullscreen && wall_maximized; + const bool normal = !want_fullscreen && !want_maximized; + if (normal && !fullscreen && !maximized && target_ && + !SameSize(actual, *target_)) { + // A late constraint/DPI update can invalidate an earlier acknowledgement. + // Reissue the normal size instead of getting stuck or saving that size. + BeginRestore(); + return false; + } + const bool ready = fullscreen == want_fullscreen && + (want_fullscreen || maximized == want_maximized) && + (!normal || (target_ && SameSize(actual, *target_))); + if (ready && last_size_ && SameSize(actual, *last_size_)) + ++matches_; + else + matches_ = 0; + last_size_ = actual; + if (matches_ < 2) + return false; + phase_ = Phase::Idle; + return true; + } + +private: + enum class Phase { Idle, Normal, Presentation }; + Phase phase_ = Phase::Idle; + std::optional target_, last_size_; + int matches_ = 0; + static bool IsUndersized(slint::PhysicalSize size, float scale, + float compact_height) { + scale = std::max(scale, 1.0f); + return double(size.width) + 1 < 640 * scale || + double(size.height) + 1 < compact_height * scale; + } + static bool SameSize(slint::PhysicalSize a, slint::PhysicalSize b) { + return std::abs(double(a.width) - b.width) <= 1 && + std::abs(double(a.height) - b.height) <= 1; + } +}; + +} // namespace crossdesk::gallery + +#endif diff --git a/apps/desktop/src/gui/features/gallery/gallery_workspace.h b/apps/desktop/src/gui/features/gallery/gallery_workspace.h new file mode 100644 index 00000000..6ace61ce --- /dev/null +++ b/apps/desktop/src/gui/features/gallery/gallery_workspace.h @@ -0,0 +1,252 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-12 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _GALLERY_WORKSPACE_H_ +#define _GALLERY_WORKSPACE_H_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gallery_storage.h" + +namespace crossdesk::gallery { +struct Member { + std::string id; + std::string group; +}; + +// Layout contains no credentials and never reconnects devices when loaded. +struct Workspace { + // Group assignments are shared by history and the wall. Wall enrollment is + // independent, so assigning a group alone must never add a device to it. + std::vector members; + std::vector groups; + std::set selected; + int page = 0; + int capacity = 6; + + static std::string NormalizeId(const std::string &input) { + std::string result; + for (size_t i = 0; i < input.size();) { + const auto ch = static_cast(input[i]); + if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { + ++i; + continue; + } + if (i + 2 < input.size() && ch == 0xEF && + static_cast(input[i + 1]) == 0xBC && + static_cast(input[i + 2]) >= 0x90 && + static_cast(input[i + 2]) <= 0x99) { + result += char('0' + static_cast(input[i + 2]) - 0x90); + i += 3; + } else if (input.compare(i, 3, "\xe3\x80\x80") == 0) + i += 3; + else + result += input[i++]; + } + return result; + } + static bool ValidId(const std::string &id) { + return id.size() == 9 && + std::all_of(id.begin(), id.end(), + [](unsigned char c) { return c >= '0' && c <= '9'; }); + } + bool Add(const std::string &id, const std::string &group = {}) { + if (!ValidId(id)) + return false; + const bool joined = wall_ids_.insert(id).second; + // A device grouped earlier in history joins at the end when explicitly + // added, while retaining its existing group assignments. + if (joined) + std::stable_partition(members.begin(), members.end(), + [&](const auto &m) { return m.id != id; }); + if (group.empty() && HasAssignments(id)) + return joined; + return AddAssignment(id, group) || joined; + } + std::vector Ids(const std::string &group = {}) const { + std::vector ids; + std::set seen; + for (const auto &m : members) + if (wall_ids_.count(m.id) && (group.empty() || m.group == group) && + seen.insert(m.id).second) + ids.push_back(m.id); + return ids; + } + // History filtering uses group assignments, independently of wall enrollment. + // nullopt means all devices; an empty name means no named group. + bool MatchesGroup(const std::string &id, + const std::optional &group) const { + if (!group) + return true; + if (group->empty()) + return std::none_of(members.begin(), members.end(), [&](const auto &m) { + return m.id == id && !m.group.empty(); + }); + return std::any_of(members.begin(), members.end(), [&](const auto &m) { + return m.id == id && m.group == *group; + }); + } + size_t AddMany(const std::vector &ids, + const std::string &group = {}) { + size_t added = 0; + for (const auto &id : ids) { + // Without a target group, an existing membership already puts the device + // on the wall. An explicit group may reuse that same device/session. + if (group.empty() && wall_ids_.count(id)) + continue; + if (Add(id, group)) + ++added; + } + return added; + } + // Both editors replace the same group assignments without changing wall + // enrollment. Keep the position of devices already on the wall untouched. + bool SetGroups(const std::string &id, const std::vector &names) { + if (!ValidId(id)) + return false; + std::vector replacements; + std::set seen; + for (const auto &name : names) + if (!name.empty() && seen.insert(name).second) + replacements.push_back({id, name}); + if (replacements.empty()) + replacements.push_back({id, ""}); + std::vector updated; + bool inserted = false; + for (const auto &member : members) { + if (member.id != id) { + updated.push_back(member); + } else if (!inserted) { + updated.insert(updated.end(), replacements.begin(), replacements.end()); + inserted = true; + } + } + if (!inserted) + updated.insert(updated.end(), replacements.begin(), replacements.end()); + members = std::move(updated); + for (const auto &member : replacements) + if (!member.group.empty() && + std::find(groups.begin(), groups.end(), member.group) == groups.end()) + groups.push_back(member.group); + PruneGroups(); + return true; + } + void Remove(const std::string &id, const std::string &group = {}) { + if (group.empty()) { + // Removing from the wall leaves history's group assignments intact. + wall_ids_.erase(id); + } else if (MatchesGroup(id, group)) { + // Removing the last named group leaves an ungrouped wall member. + // Reuse the editor path so enrollment and card order stay unchanged. + std::vector remaining; + for (const auto &member : members) + if (member.id == id && member.group != group) + remaining.push_back(member.group); + SetGroups(id, remaining); + } + selected.erase(id); + } + void ResizePage(int new_capacity, int count) { + const int first = page * capacity; + capacity = std::max(1, new_capacity); + page = std::clamp(first / capacity, 0, std::max(0, (count - 1) / capacity)); + } + // The caller supplies every ID in the current filter, across all pages. + void ToggleSelectAll(const std::vector &ids) { + std::set targets(ids.begin(), ids.end()); + if (selected == targets) + selected.clear(); + else + selected = std::move(targets); + } + bool Load(const std::filesystem::path &path) { + try { + if (!std::filesystem::exists(path)) + return true; + std::ifstream file(path); + const auto doc = nlohmann::json::parse(file); + const int version = doc.at("version").get(); + if ((version != 1 && version != 2) || !doc.at("members").is_array()) + return false; + if (version == 2 && !doc.at("wall_ids").is_array()) + return false; + Workspace loaded; + for (const auto &member : doc.at("members")) { + const auto id = member.at("id").get(); + loaded.AddAssignment(id, member.value("group", "")); + // Every saved member in the old format was already on the wall. + if (version == 1 && ValidId(id)) + loaded.wall_ids_.insert(id); + } + if (version == 2) + for (const auto &value : doc.at("wall_ids")) { + const auto id = value.get(); + if (ValidId(id) && loaded.HasAssignments(id)) + loaded.wall_ids_.insert(id); + } + members = std::move(loaded.members); + groups = std::move(loaded.groups); + wall_ids_ = std::move(loaded.wall_ids_); + return true; + } catch (...) { + return false; + } + } + bool Save(const std::filesystem::path &path) const { + try { + nlohmann::json doc{{"version", 2}, + {"members", nlohmann::json::array()}, + {"wall_ids", wall_ids_}}; + for (const auto &m : members) + doc["members"].push_back({{"id", m.id}, {"group", m.group}}); + return WriteAtomically(path, doc.dump(2)); + } catch (...) { + return false; + } + } + +private: + std::set wall_ids_; + + bool HasAssignments(const std::string &id) const { + return std::any_of(members.begin(), members.end(), + [&](const auto &m) { return m.id == id; }); + } + bool AddAssignment(const std::string &id, const std::string &group) { + if (!ValidId(id)) + return false; + if (std::any_of(members.begin(), members.end(), [&](const auto &m) { + return m.id == id && m.group == group; + })) + return false; + if (!group.empty() && + std::find(groups.begin(), groups.end(), group) == groups.end()) + groups.push_back(group); + members.push_back({id, group}); + return true; + } + void PruneGroups() { + groups.erase(std::remove_if(groups.begin(), groups.end(), + [&](const auto &name) { + return std::none_of(members.begin(), + members.end(), + [&](const auto &m) { + return m.group == name; + }); + }), + groups.end()); + } +}; +} // namespace crossdesk::gallery + +#endif diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index 3766ae5e..d251705a 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -139,11 +139,12 @@ void PeerEventHandler::OnReceiveDataBuffer( std::string remote_user_id(user_id, user_id_size); auto props = runtime->FindRemoteSession(remote_user_id); - if (props && !props->enable_mouse_control_) { + if (props && !props->clipboard_allowed_.load()) { return; } - runtime->clipboard_.QueueRemoteText(data, size); + runtime->clipboard_.QueueRemoteText( + data, size, props ? remote_user_id : std::string{}); } return; } else if (source_id == runtime->file_feedback_label_) { diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 158bea86..52267e02 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -329,6 +329,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, props->net_traffic_stats_.Reset(); props->enable_mouse_control_ = false; props->preview_subscription_supported_ = false; + props->audio_allowed_ = false; + props->clipboard_allowed_ = false; runtime->ResetRemoteServiceStatus(*props); { std::lock_guard lock(props->video_settings_mutex_); diff --git a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp index 31244d5f..8e50cacc 100644 --- a/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_media_callbacks.cpp @@ -1,3 +1,6 @@ +#include + +#include #include #include #include @@ -5,6 +8,7 @@ #include #include +#include "nv12_scaler.h" #include "platform/video_renderer.h" #include "runtime/gui_runtime.h" #include "runtime/peer_event_handler.h" @@ -46,6 +50,68 @@ void PeerEventHandler::OnReceiveVideoBuffer( if (props->connection_established_) { const auto timing_now = VideoLatencyFrame::Clock::now(); + // Convert only visible thumbnails, off the UI thread. A session retains + // one latest RGBA image, not an unbounded queue of frames. + const int preview_width = props->gallery_preview_width_.load(); + // Multiple video streams may decode concurrently for the same session. + // Serialize access to the shared conversion buffers. Visible previews + // consume incoming frames at the negotiated interactive cadence. + std::unique_lock conversion_lock(props->gallery_conversion_mutex_, + std::defer_lock); + if (preview_width > 0) conversion_lock.lock(); + if (conversion_lock.owns_lock()) { + const auto* native = GetNativeVideoFrame(video_frame); + const int w = native ? native->width : video_frame->width; + const int h = native ? native->height : video_frame->height; + if (w > 0 && h > 0 && w <= 16384 && h <= 16384 && !(w % 2) && !(h % 2)) { + const size_t bytes = static_cast(w) * h * 3 / 2; + const uint8_t* data = + reinterpret_cast(video_frame->data); + if (native) { + props->gallery_nv12_.resize(bytes); + data = native->copy_to_nv12(native->owner, + props->gallery_nv12_.data(), bytes) == 0 + ? props->gallery_nv12_.data() + : nullptr; + } else if (video_frame->size < bytes) + data = nullptr; + if (data) { + const double scale = std::min( + {1.0, double(preview_width) / w, double(preview_width) / h}); + const int dw = std::max(2, int(w * scale) & ~1); + const int dh = std::max(2, int(h * scale) & ~1); + auto& scaled = props->gallery_scaled_; + scaled.resize(static_cast(dw) * dh * 3 / 2); + if (ScaleNv12ViaI420(data, w, data + w * h, w, w, h, scaled.data(), + dw, scaled.data() + dw * dh, dw, dw, dh, + libyuv::kFilterBilinear, + &props->gallery_scratch_) == 0) { + auto rgba = std::make_shared>( + static_cast(dw) * dh * 4); + if (libyuv::NV12ToABGR(scaled.data(), dw, scaled.data() + dw * dh, + dw, rgba->data(), dw * 4, dw, dh) == 0) { + std::lock_guard lock(props->gallery_frame_mutex_); + props->gallery_rgba_ = std::move(rgba); + props->gallery_width_ = dw; + props->gallery_height_ = dh; + props->gallery_frame_at_ = timing_now; + ++props->gallery_sequence_; + } + } + } + } + } + if (conversion_lock.owns_lock()) conversion_lock.unlock(); + // A wall-only session does not need a second full-resolution CPU buffer. + if (props->wall_managed_ && !props->tab_opened_) { + { + std::lock_guard lock(props->video_frame_mutex_); + props->video_width_ = video_frame->width; + props->video_height_ = video_frame->height; + } + props->streaming_ = true; + return; + } const auto timing = props->video_latency_->Frame( video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_), timing_now); @@ -205,6 +271,10 @@ void PeerEventHandler::OnReceiveAudioBuffer( return; } + std::lock_guard delivery_lock(runtime->audio_delivery_mutex_); + const auto session = + runtime->FindRemoteSession(std::string(user_id, user_id_size)); + if (!session || !session->audio_allowed_.load()) return; runtime->audio_buffer_fresh_ = true; runtime->devices_.PushAudio(data, size); diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 467e04a0..2fff633e 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -79,6 +79,18 @@ struct RemoteSession { uint32_t preview_request_ = 0; PreviewSubscription preview_requested_; std::chrono::steady_clock::time_point preview_request_at_{}; + std::atomic clipboard_allowed_{false}; + std::atomic input_generation_{0}; + std::atomic audio_allowed_{false}; + std::atomic gallery_preview_width_{0}; + std::mutex gallery_frame_mutex_; + std::shared_ptr> gallery_rgba_; + int gallery_width_ = 0, gallery_height_ = 0; + uint64_t gallery_sequence_ = 0; + std::chrono::steady_clock::time_point gallery_frame_at_{}; + // Decoder-thread scratch memory, never shared with the UI. + std::mutex gallery_conversion_mutex_; + std::vector gallery_nv12_, gallery_scaled_, gallery_scratch_; Params params_; PeerPtr* peer_ = nullptr; std::shared_ptr peer_events_; diff --git a/apps/desktop/src/gui/runtime/runtime_state.h b/apps/desktop/src/gui/runtime/runtime_state.h index a596c520..47252a1d 100644 --- a/apps/desktop/src/gui/runtime/runtime_state.h +++ b/apps/desktop/src/gui/runtime/runtime_state.h @@ -7,6 +7,8 @@ #ifndef _RUNTIME_STATE_H_ #define _RUNTIME_STATE_H_ +#include + #include #include #include @@ -19,10 +21,8 @@ #include #include -#include - -#include "config_center.h" #include "common/task_queue_lock_free.h" +#include "config_center.h" #include "path_manager.h" #include "runtime/device_presence_cache.h" #include "runtime/preview_subscriptions.h" @@ -38,6 +38,7 @@ namespace crossdesk::gui_detail { struct InfrastructureState { PreviewSubscriptions preview_subscriptions_; + std::mutex audio_delivery_mutex_; std::unique_ptr config_center_; ConfigCenter::LANGUAGE localization_language_ = ConfigCenter::LANGUAGE::CHINESE; diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index 85c846aa..2b677fd9 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -49,6 +49,7 @@ export global FontAwesomeIcons { out property signal: "\u{f012}"; out property shield-halved: "\u{f3ed}"; out property square-full: "\u{f45c}"; + out property tags: "\u{f02c}"; out property trash-can: "\u{f2ed}"; out property user-secret: "\u{f21b}"; out property volume-high: "\u{f028}"; @@ -59,6 +60,7 @@ export global FontAwesomeIcons { export component IconButton inherits Rectangle { in property icon; in property tooltip; + in property tooltip-above: false; in property enabled: true; in property danger: false; in property icon-size: 16px; @@ -103,7 +105,7 @@ export component IconButton inherits Rectangle { if touch.has-hover && root.tooltip != "": Rectangle { x: min(root.width - self.width, 0px); - y: root.height + 3px; + y: root.tooltip-above ? -self.height - 3px : root.height + 3px; width: tip.preferred-width + 12px; height: 24px; background: #22252be8; diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index d5faac40..7d0817bc 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -4,3 +4,7 @@ export { AnnouncementItem, MessageStrings } from "message_panel.slint"; export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint"; + +export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint"; +export { GalleryStrings } from "device_strings.slint"; +export { DeviceGroupOption } from "device_group_dialog.slint"; diff --git a/apps/desktop/src/gui/ui/device_card_actions.slint b/apps/desktop/src/gui/ui/device_card_actions.slint new file mode 100644 index 00000000..3c9970cf --- /dev/null +++ b/apps/desktop/src/gui/ui/device_card_actions.slint @@ -0,0 +1,54 @@ +import { IconButton, FontAwesomeIcons } from "common.slint"; +import { GalleryStrings } from "device_strings.slint"; + +export global DeviceCardStyle { + out property toolbar-width: 104px; +} + +component CardAction inherits IconButton { + width: 26px; height: 23px; + icon-size: 12px; icon-color: white; + hover-background: #ffffff2e; + pressed-background: #598cf273; + tooltip-above: true; +} + +// Shared by recent connections and DeskHub; callers supply only behavior. +export component DeviceCardActions inherits Rectangle { + in property remove-label; + in property connected: false; + callback edit-alias(); + callback edit-groups(); + callback remove-device(); + callback connect-device(); + callback disconnect-device(); + width: DeviceCardStyle.toolbar-width; height: 23px; + background: #4a4e55dc; border-radius: 5px; + HorizontalLayout { + spacing: 0px; + CardAction { + icon: FontAwesomeIcons.pen; + tooltip: GalleryStrings.tr("修改名称", "Rename", "Переименовать"); + clicked => { root.edit-alias(); } + } + CardAction { + icon: FontAwesomeIcons.tags; + tooltip: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы"); + clicked => { root.edit-groups(); } + } + CardAction { + icon: FontAwesomeIcons.trash-can; + tooltip: root.remove-label; + clicked => { root.remove-device(); } + } + CardAction { + icon: root.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long; + icon-size: 13px; + tooltip: root.connected ? GalleryStrings.tr("断开", "Disconnect", "Отключить") : GalleryStrings.tr("连接", "Connect", "Подключить"); + clicked => { + if root.connected { root.disconnect-device(); } + else { root.connect-device(); } + } + } + } +} diff --git a/apps/desktop/src/gui/ui/device_group_dialog.slint b/apps/desktop/src/gui/ui/device_group_dialog.slint new file mode 100644 index 00000000..47cacd17 --- /dev/null +++ b/apps/desktop/src/gui/ui/device_group_dialog.slint @@ -0,0 +1,87 @@ +import { ScrollView, LineEdit } from "std-widgets.slint"; +import { CompactButton, ImGuiCheckBox } from "common.slint"; +import { GalleryStrings } from "device_strings.slint"; + +export struct DeviceGroupOption { + name: string, + selected: bool, +} + +export component DeviceGroupDialog inherits Rectangle { + in property device-name; + in property device-id; + in property <[DeviceGroupOption]> options; + in property error; + in-out property new-group; + callback toggle-group(string); + callback save(string); + callback cancel(); + background: #00000040; + TouchArea {} + dialog := Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(360px, parent.width - 40px); + height: 208px + group-list.height + (root.error == "" ? 0px : 32px); + background: white; border-radius: 8px; + border-width: 1px; border-color: #b2b2b2; + TouchArea {} + Text { + x: 18px; y: 16px; height: 24px; + text: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы"); + color: #24272d; font-size: 17px; font-weight: 600; + } + Text { + x: 18px; y: 47px; width: parent.width - 36px; height: 18px; + text: root.device-name; color: #30343b; font-size: 12px; overflow: elide; + } + Text { + x: 18px; y: 67px; width: parent.width - 36px; height: 16px; + text: "ID " + root.device-id; color: #858c97; font-size: 11px; + } + group-list := Rectangle { + x: 18px; y: 95px; width: parent.width - 36px; + height: min(5, max(1, root.options.length)) * 32px + 2px; + background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: 5px; + ScrollView { + x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; + viewport-width: self.width; + VerticalLayout { + spacing: 0px; + for option[index] in root.options: Rectangle { + height: 32px; + background: row-touch.has-hover ? #eef4fc : transparent; + ImGuiCheckBox { + x: 9px; y: 6px; width: 20px; height: 20px; + checked: option.selected && (option.name != "" || root.new-group == ""); + } + Text { + x: 39px; width: parent.width - 48px; height: parent.height; + text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name; + color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center; + } + row-touch := TouchArea { clicked => { root.toggle-group(option.name); } } + } + } + } + } + LineEdit { + x: 18px; y: group-list.y + group-list.height + 12px; + width: parent.width - 36px; height: 30px; + text <=> root.new-group; + placeholder-text: GalleryStrings.tr("新建分组(可选)", "New group (optional)", "Новая группа (необязательно)"); + } + if root.error != "": Text { + x: 18px; y: group-list.y + group-list.height + 48px; + width: parent.width - 36px; height: 32px; + text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap; + } + HorizontalLayout { + x: 18px; y: parent.height - 46px; width: parent.width - 36px; height: 28px; + spacing: 10px; + Rectangle { horizontal-stretch: 1; } + CompactButton { width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.cancel(); } } + CompactButton { width: 88px; height: 28px; primary: true; text: GalleryStrings.tr("保存", "Save", "Сохранить"); clicked => { root.save(root.new-group); } } + } + } +} diff --git a/apps/desktop/src/gui/ui/device_strings.slint b/apps/desktop/src/gui/ui/device_strings.slint new file mode 100644 index 00000000..5f5dd794 --- /dev/null +++ b/apps/desktop/src/gui/ui/device_strings.slint @@ -0,0 +1,6 @@ +export global GalleryStrings { + in-out property language: 0; + public pure function tr(zh: string, en: string, ru: string) -> string { + return self.language == 0 ? zh : self.language == 2 ? ru : en; + } +} diff --git a/apps/desktop/src/gui/ui/gallery_view.slint b/apps/desktop/src/gui/ui/gallery_view.slint new file mode 100644 index 00000000..b1a622dc --- /dev/null +++ b/apps/desktop/src/gui/ui/gallery_view.slint @@ -0,0 +1,454 @@ +import { GalleryStrings } from "device_strings.slint"; +import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint"; +import { GroupFilter } from "group_filter.slint"; +import { ScrollView, LineEdit } from "std-widgets.slint"; +import { CompactButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint"; + +export struct GalleryTile { + id: string, + name: string, + status: string, + connected: bool, + online: bool, + presence-known: bool, + stale: bool, + selected: bool, + has-frame: bool, + frame: image, +} +export struct GalleryRecentDevice { + id: string, + name: string, + selected: bool, + online: bool, +} +export struct GalleryActionDevice { + id: string, + name: string, + connected: bool, +} +export component GalleryView inherits Rectangle { + in property controlling: false; + // Pointer mapping must use the exact image viewport, including its inset. + out property video-width: video.width / 1px; + out property video-height: video.height / 1px; + private property vertical-rail: focus-view.width > 840px; + private property rail-current-index: 0; + changed rail-current-index => { root.reveal-current(); } + changed width => { root.reveal-current(); } + changed height => { root.reveal-current(); } + function reveal-current() { + if root.vertical-rail { + rail.viewport-x = 0px; + let top = root.rail-current-index * 100px; + rail.viewport-y = clamp(rail.viewport-y, -top, rail.height - top - 92px); + rail.viewport-y = clamp(rail.viewport-y, min(0px, rail.height - rail.viewport-height), 0px); + } else { + rail.viewport-y = 0px; + let left = root.rail-current-index * 144px; + rail.viewport-x = clamp(rail.viewport-x, -left, rail.width - left - 136px); + rail.viewport-x = clamp(rail.viewport-x, min(0px, rail.width - rail.viewport-width), 0px); + } + } + callback pointer-input(PointerEventButton, PointerEventKind, length, length); + callback scroll-input(length, length, length, length); + callback key-input(string, bool, bool, bool, bool, bool); + in property <[GalleryTile]> tiles; + in property <[string]> groups; + in-out property group-index: 0; + in property columns: 3; + in property rows: 2; + in property page: 0; + in property pages: 1; + in property count: 0; + in property connected-count: 0; + in property selected-count: 0; + in property hidden-selected: 0; + in property notice; + in property focused; + in-out property batch: false; + in-out property adding: false; + in-out property importing: false; + in property <[GalleryRecentDevice]> recent-devices; + in property recent-selected: 0; + in property import-group; + private property confirming: false; + private property action-name; + private property action-code; + in property <[GalleryActionDevice]> selected-devices; + callback action(string, string); + callback add-device(string, string, string, bool) -> bool; + callback import-recent(string) -> bool; + callback edit-alias(string, string); + callback edit-groups(string, string); + callback remove-device(string); + background: white; + changed adding => { if root.adding { root.action("release", ""); } } + changed importing => { root.action(root.importing ? "release" : "recent-close", ""); } + public function reset-transient() { root.batch = false; root.adding = false; root.importing = false; root.confirming = false; } + function confirm-action(code: string, name: string) { + root.action-code = code; root.action-name = name; root.confirming = true; + } + toolbar := HorizontalLayout { + x: 29px; y: 12px; width: parent.width - 58px; height: 24px; + visible: root.focused.id == ""; + spacing: 8px; + GroupFilter { width: 155px; height: 24px; system-count: 1; model: root.groups; current-index <=> root.group-index; + selected(index) => { root.action("group", index); } + } + Rectangle { horizontal-stretch: 1; } + CompactButton { width: 82px; primary: root.batch; text: root.batch ? GalleryStrings.tr("完成", "Done", "Готово") : GalleryStrings.tr("批量选择", "Select", "Выбрать"); clicked => { root.batch = !root.batch; root.action("selection", ""); } } + CompactButton { width: 116px; text: GalleryStrings.tr("从最近连接添加", "Add from recent", "Из недавних"); clicked => { root.action("recent-open", ""); root.importing = true; } } + CompactButton { width: 90px; primary: true; text: GalleryStrings.tr("添加设备", "Add device", "Добавить"); clicked => { root.action("add-open", ""); root.adding = true; } } + } + grid := Rectangle { + x: toolbar.x; y: toolbar.y + toolbar.height + 12px; width: toolbar.width; + height: footer.y - 12px - self.y; + visible: root.focused.id == ""; + for tile[index] in root.tiles: Rectangle { + x: mod(index, root.columns) * (grid.width + 12px) / root.columns; + y: floor(index / root.columns) * (grid.height + 12px) / root.rows; + width: (grid.width - (root.columns - 1) * 12px) / root.columns; + height: (grid.height - (root.rows - 1) * 12px) / root.rows; + border-radius: 6px; background: tile.selected ? #eaf3ff : #eff0f2; + border-width: tile.selected ? 2px : 1px; + border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : #c9cdd2; + Rectangle { + x: 6px; y: 6px; width: parent.width - 12px; height: parent.height - 36px; + background: #24272d; + if tile.has-frame: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; } + if !tile.has-frame || !tile.connected || tile.stale: Text { + text: tile.status; color: #ffffff; font-size: 12px; + horizontal-alignment: center; vertical-alignment: center; + wrap: word-wrap; + } + } + Text { x: 9px; y: parent.height - 28px; width: (tile-touch.has-hover && !root.batch ? parent.width - DeviceCardStyle.toolbar-width - 6px : presence-badge.x) - 17px; height: 28px; text: tile.name; color: #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; } + presence-badge := Rectangle { + visible: root.batch || !tile-touch.has-hover; + x: parent.width - (root.batch ? 35px : 9px) - self.width; y: parent.height - 23px; + width: presence-label.preferred-width + 12px; height: 18px; + border-radius: 9px; background: tile.online ? #eaf6ee : #e2e5e9; + presence-label := Text { + x: 6px; height: parent.height; + text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); + color: tile.online ? #17854c : #62666d; font-size: 10px; vertical-alignment: center; + } + } + if root.batch: Rectangle { + x: parent.width - 27px; y: parent.height - 23px; + width: 18px; height: 18px; border-radius: 5px; border-width: 1px; + background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white; + border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8; + animate background, border-color { duration: 120ms; } + if tile.selected: Text { + text: FontAwesomeIcons.check; color: white; + font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px; + horizontal-alignment: center; vertical-alignment: center; + } + } + tile-touch := TouchArea { + clicked => { root.action(root.batch ? "select" : "focus", tile.id); } + if tile-touch.has-hover && !root.batch: DeviceCardActions { + x: parent.width - self.width - 6px; y: parent.height - 26px; + remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub"); + connected: tile.connected; + edit-alias => { root.action("release", ""); root.edit-alias(tile.id, tile.name); } + edit-groups => { root.edit-groups(tile.id, tile.name); } + remove-device => { root.action("release", ""); root.remove-device(tile.id); } + connect-device => { root.action("connect", tile.id); } + disconnect-device => { root.action("disconnect", tile.id); } + } + } + } + if root.count == 0: Text { + text: GalleryStrings.tr("添加设备,建立你的桌面墙", "Add devices to build your DeskHub", "Добавьте устройства в DeskHub"); + color: #808080; horizontal-alignment: center; vertical-alignment: center; + } + } + focus-view := Rectangle { + visible: root.focused.id != ""; + x: 29px; y: 7px; width: parent.width - 58px; + height: parent.height - 51px; + HorizontalLayout { + x: 0px; y: 0px; width: parent.width; height: 28px; spacing: 8px; + CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } } + Rectangle { + horizontal-stretch: 1; min-width: 0px; + Text { x: 0px; width: parent.width; height: parent.height; text: root.focused.name; color: #303943; font-size: 12px; font-weight: 600; overflow: elide; horizontal-alignment: left; vertical-alignment: center; } + } + Rectangle { + width: min(focus-state.preferred-width + 16px, 110px); height: 22px; + border-radius: 5px; + background: root.controlling ? #e7f1fd : #f0f2f5; + focus-state := Text { + x: 8px; width: parent.width - 16px; height: parent.height; + text: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление") + : root.focused.connected && root.focused.has-frame && !root.focused.stale + ? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status; + font-size: 10px; color: root.controlling ? #3e77b3 : #6b7480; vertical-alignment: center; overflow: elide; + } + } + CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } } + } + Rectangle { + x: 0px; y: 36px; width: root.vertical-rail ? parent.width - 158px : parent.width; + height: root.vertical-rail ? parent.height - 36px : parent.height - 152px; + background: #eff1f4; border-radius: 6px; + border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : #d5dbe3; + video := Rectangle { + x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; + background: #24272d; + if root.focused.has-frame: Image { + width: parent.width; height: parent.height; source: root.focused.frame; image-fit: contain; + opacity: root.focused.stale || !root.focused.connected ? 0.4 : 1; + } + video-focus := FocusScope { + property active-control: root.controlling; + changed active-control => { if self.active-control { self.focus(); } } + width: parent.width; height: parent.height; + focus-on-click: true; + focus-lost => { if root.controlling { root.action("release", ""); } } + key-pressed(event) => { + if root.controlling { + if event.text == Key.Escape && event.modifiers.shift && (event.modifiers.control || event.modifiers.meta) { + root.action("release", ""); + } else { root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); } + } + accept + } + key-released(event) => { + if root.controlling { root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); } + accept + } + TouchArea { + pointer-event(event) => { + if event.kind == PointerEventKind.down { video-focus.focus(); } + if root.controlling { root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); } + } + scroll-event(event) => { + if root.controlling { root.scroll-input(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); } + accept + } + } + } + if !root.focused.has-frame || root.focused.stale || !root.focused.connected: Text { text: root.focused.status; color: white; horizontal-alignment: center; vertical-alignment: center; } + } + } + rail := ScrollView { + x: root.vertical-rail ? parent.width - 148px : 0px; + y: root.vertical-rail ? 36px : parent.height - 106px; + width: root.vertical-rail ? 148px : parent.width; + height: root.vertical-rail ? parent.height - 36px : 106px; + // Reserve 14px below or 12px beside cards for the scrollbar. + viewport-width: root.vertical-rail ? self.width : max(self.width, root.tiles.length * 144px - 8px); + viewport-height: root.vertical-rail ? max(self.height, root.tiles.length * 100px - 8px) : self.height; + horizontal-scrollbar-policy: root.vertical-rail ? always-off : as-needed; + vertical-scrollbar-policy: root.vertical-rail ? as-needed : always-off; + for tile[index] in root.tiles: Rectangle { + private property current: tile.id == root.focused.id; + init => { if self.current { root.rail-current-index = index; root.reveal-current(); } } + changed current => { if self.current { root.rail-current-index = index; root.reveal-current(); } } + x: root.vertical-rail ? 0px : index * 144px; + y: root.vertical-rail ? index * 100px : 0px; + width: 136px; height: 92px; border-radius: 5px; + background: tile.id == root.focused.id ? #edf5ff : #f5f6f8; + border-width: tile.id == root.focused.id ? 2px : 1px; + border-color: tile.id == root.focused.id ? #76aeea : #d5dbe3; + Rectangle { + x: 5px; y: 5px; width: 126px; height: 52px; background: #24272d; + if tile.has-frame: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; } + if !tile.has-frame || tile.stale || !tile.connected: Text { + text: tile.status; color: #e2e6ed; font-size: 10px; overflow: elide; + horizontal-alignment: center; vertical-alignment: center; + } + } + Text { x: 7px; y: 61px; width: 122px; height: 16px; text: tile.name; color: #303943; font-size: 11px; overflow: elide; } + Text { + x: 7px; y: 78px; width: 86px; height: 12px; font-size: 9px; + text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно"); + color: tile.online ? #17854c : #7a828d; overflow: elide; + } + if tile.id == root.focused.id: Text { + x: 99px; y: 78px; width: 30px; height: 12px; + text: GalleryStrings.tr("当前", "Current", "Выбрано"); color: #4d83bd; font-size: 9px; horizontal-alignment: right; overflow: elide; + } + TouchArea { clicked => { root.action("focus", tile.id); } } + } + } + } + // Reuse the footer for selection actions so entering batch mode never + // changes card geometry or moves a device beneath the pointer. + footer := HorizontalLayout { + x: toolbar.x; y: parent.height - 36px; width: toolbar.width; height: 24px; spacing: 12px; + if root.batch: CompactButton { + width: 90px; enabled: root.count > 0; + text: root.count > 0 && root.selected-count == root.count + ? GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор") + : GalleryStrings.tr("全选", "Select all", "Выбрать все"); + clicked => { root.action("all-select", ""); } + } + Rectangle { + horizontal-stretch: 1; min-width: 0px; + Text { + y: 0px; width: parent.width; height: root.batch && root.hidden-selected > 0 ? 13px : parent.height; + text: root.batch ? GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.selected-count : root.connected-count + " / " + root.count + GalleryStrings.tr(" 台已连接", " connected", " подключено"); + color: #62666d; vertical-alignment: center; font-size: 12px; overflow: elide; + } + if root.batch && root.hidden-selected > 0: Text { + y: 14px; width: parent.width; height: 10px; + text: GalleryStrings.tr("跨页 ", "Other pages ", "Другие страницы ") + root.hidden-selected; + color: #80858d; font-size: 10px; overflow: elide; + } + } + if root.batch: HorizontalLayout { + width: 214px; spacing: 8px; + CompactButton { width: 58px; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } } + CompactButton { width: 62px; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } } + CompactButton { width: 78px; enabled: root.selected-count > 0; text: GalleryStrings.tr("分发文件", "Send file", "Файл"); clicked => { root.confirm-action("file", self.text); } } + } + HorizontalLayout { + width: 112px; height: 24px; spacing: 6px; + CompactButton { width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } } + Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; } + CompactButton { width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } } + } + } + if root.adding: Rectangle { + background: #00000042; + TouchArea {} + add-dialog := Rectangle { + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px; + background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2; + function submit() { + if root.add-device(device-id.text, password.text, group.text, remember.checked) { + password.text = ""; + root.adding = false; + } + } + Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("添加设备", "Add device", "Добавить устройство"); font-size: 16px; } + Text { x: 18px; y: 56px; width: 72px; height: 30px; text: GalleryStrings.tr("设备 ID", "Device ID", "ID"); vertical-alignment: center; font-size: 12px; } + device-id := LineEdit { + x: 96px; y: 56px; width: 286px; height: 30px; + placeholder-text: GalleryStrings.tr("9 位设备 ID", "9-digit device ID", "ID из 9 цифр"); + input-type: InputType.number; + accepted => { password.focus(); } + } + Text { x: 18px; y: 100px; width: 72px; height: 30px; text: GalleryStrings.tr("密码", "Password", "Пароль"); vertical-alignment: center; font-size: 12px; } + password := LineEdit { + x: 96px; y: 100px; width: 286px; height: 30px; + placeholder-text: GalleryStrings.tr("设备连接密码", "Device password", "Пароль устройства"); + input-type: InputType.password; + accepted => { group.focus(); } + } + Text { x: 18px; y: 144px; width: 72px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); vertical-alignment: center; font-size: 12px; } + group := LineEdit { + x: 96px; y: 144px; width: 286px; height: 30px; + placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); + accepted => { add-dialog.submit(); } + } + remember := ImGuiCheckBox { x: 96px; y: 188px; } + Text { x: 123px; y: 188px; height: 21px; text: GalleryStrings.tr("记住密码", "Remember password", "Запомнить пароль"); font-size: 12px; vertical-alignment: center; + TouchArea { clicked => { remember.checked = !remember.checked; } } + } + Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; } + CompactButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } } + CompactButton { + x: 254px; y: 260px; width: 128px; height: 28px; primary: true; + text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить"); + enabled: device-id.text != "" && password.text != ""; + clicked => { add-dialog.submit(); } + } + } + } + if root.importing: Rectangle { + background: #00000042; + TouchArea {} + Rectangle { + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px; + background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2; + Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; } + Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; } + ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-devices.length > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-devices.length; } + Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); font-size: 12px; vertical-alignment: center; } + TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-devices.length > 0; clicked => { root.action("recent-all", ""); } } + Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.recent-selected + " / " + root.recent-devices.length; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; } + Rectangle { + x: 18px; y: 102px; width: 404px; height: 160px; + border-width: 1px; border-color: #d8dadd; border-radius: 4px; clip: true; + recent-list := ScrollView { + x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; + viewport-width: self.width - 12px; + viewport-height: max(self.height, root.recent-devices.length * 44px); + for device[index] in root.recent-devices: Rectangle { + y: index * 44px; width: recent-list.viewport-width; height: 44px; + background: device.selected ? #edf5fe : row-touch.has-hover ? #f4f5f6 : white; + ImGuiCheckBox { x: 8px; y: 11px; checked: device.selected; } + Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; font-size: 12px; overflow: elide; vertical-alignment: center; } + Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: #808080; } + Rectangle { x: parent.width - 74px; y: 19px; width: 6px; height: 6px; border-radius: 3px; background: device.online ? #21b567 : #92969e; } + Text { + x: parent.width - 61px; y: 12px; width: 57px; height: 20px; + text: device.online ? GalleryStrings.tr("在线", "Online", "В сети") : GalleryStrings.tr("离线", "Offline", "Не в сети"); + color: device.online ? #17854c : #808080; font-size: 11px; vertical-alignment: center; + } + row-touch := TouchArea { clicked => { root.action("recent-select", device.id); } } + } + } + if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; } + import-group := LineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); } + Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; } + CompactButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } } + CompactButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } } + } + } + if root.confirming: Rectangle { + background: #00000042; TouchArea {} + Rectangle { + x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; + width: 360px; height: confirmation-list.height + 118px; + background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2; + Text { + x: 20px; y: 18px; width: parent.width - 132px; height: 24px; + text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name; + color: #24272d; font-size: 16px; font-weight: 600; vertical-alignment: center; + } + Rectangle { + x: parent.width - 108px; y: 20px; width: 88px; height: 22px; + background: #eff3f8; border-radius: 11px; + Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; } + } + confirmation-list := Rectangle { + x: 20px; y: 58px; width: parent.width - 40px; + height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px); + background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: 6px; clip: true; + confirmation-scroll := ScrollView { + x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; + viewport-width: self.width - 12px; + viewport-height: max(self.height, root.selected-devices.length * 52px); + for device[index] in root.selected-devices: Rectangle { + y: index * 52px; width: confirmation-scroll.viewport-width; height: 52px; + Text { x: 12px; y: 8px; width: parent.width - 32px - connection-badge.width; height: 18px; text: device.name; color: #303740; font-size: 12px; font-weight: 600; overflow: elide; vertical-alignment: center; } + Text { x: 12px; y: 29px; width: parent.width - 32px - connection-badge.width; height: 16px; text: "ID " + device.id; color: #8a929e; font-size: 11px; vertical-alignment: center; } + connection-badge := Rectangle { + x: parent.width - self.width - 12px; y: 16px; + width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px; + background: device.connected ? #eaf6ee : #f0f2f5; + Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; } + Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет связи"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; } + } + if index + 1 < root.selected-devices.length: Rectangle { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #edf0f3; } + } + } + } + CompactButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } } + CompactButton { + x: parent.width - 126px; y: parent.height - 46px; width: 108px; height: 28px; primary: true; + text: root.action-code == "disconnect" ? GalleryStrings.tr("确认断开", "Disconnect", "Отключить") : GalleryStrings.tr("确认执行", "Continue", "Продолжить"); + clicked => { root.confirming = false; root.action(root.action-code, ""); } + } + } + } +} diff --git a/apps/desktop/src/gui/ui/group_filter.slint b/apps/desktop/src/gui/ui/group_filter.slint new file mode 100644 index 00000000..5b40a156 --- /dev/null +++ b/apps/desktop/src/gui/ui/group_filter.slint @@ -0,0 +1,161 @@ +import { ScrollView } from "std-widgets.slint"; +import { FontAwesomeIcons } from "common.slint"; + +// Shared by history and DeskHub; system choices precede the named groups. +export component GroupFilter inherits Rectangle { + in property <[string]> model; + in property system-count: 2; + in property enabled: true; + in-out property current-index: 0; + callback selected(int); + private property has-divider: root.system-count > 0 && root.model.length > root.system-count; + private property list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px); + private property visible-height: min(root.list-height, 162px); + private property scroll-y: 0px; + private property popup-animation-enabled: false; + private property popup-progress: 0; + + animate popup-progress { + duration: 140ms; easing: ease-out; + enabled: root.popup-animation-enabled; + } + + width: 155px; height: 28px; + background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white; + border-width: 1px; + border-color: popup.is-open || trigger-focus.has-focus ? #8db5df : #c9cdd2; + border-radius: 4px; + animate background, border-color { duration: 100ms; easing: ease-out; } + forward-focus: trigger-focus; + accessible-role: combobox; + accessible-enabled: root.enabled; + accessible-expandable: true; + accessible-expanded: popup.is-open; + accessible-value: root.model[root.current-index]; + + function reveal-selection() { + let top = root.current-index * 26px + (root.has-divider && root.current-index >= root.system-count ? 6px : 0px); + if top < -root.scroll-y { root.scroll-y = -top; } + if top + 26px > -root.scroll-y + root.visible-height { + root.scroll-y = root.visible-height - top - 26px; + } + root.scroll-y = clamp(root.scroll-y, min(0px, root.visible-height - root.list-height), 0px); + } + function choose(index: int) { + if !root.enabled || root.model.length == 0 { return; } + root.current-index = clamp(index, 0, root.model.length - 1); + root.reveal-selection(); + root.selected(root.current-index); + } + function open-menu() { + if root.enabled && root.model.length > 0 { + popup-open-timer.running = false; + root.popup-animation-enabled = false; + root.popup-progress = 0; + root.reveal-selection(); + popup.show(); + popup-open-timer.running = true; + } + } + // Defer until the popup has its initial frame; animate its contents only, + // keeping the native popup bounds and scrolling geometry stable. + popup-open-timer := Timer { + interval: 16ms; + running: false; + triggered => { + self.running = false; + if popup.is-open { + root.popup-animation-enabled = true; + root.popup-progress = 1; + } + } + } + function handle-key(event: KeyEvent) -> EventResult { + if !root.enabled { return reject; } + if event.text == Key.UpArrow { root.choose(root.current-index - 1); return accept; } + if event.text == Key.DownArrow { root.choose(root.current-index + 1); return accept; } + if event.text == Key.Return || event.text == " " { + if popup.is-open { popup.close(); } else { root.open-menu(); } + return accept; + } + if event.text == Key.Escape && popup.is-open { popup.close(); return accept; } + return reject; + } + Text { + x: 10px; width: parent.width - 36px; height: parent.height; + text: root.model[root.current-index]; + color: root.enabled ? #30343b : #92979f; + font-size: 12px; overflow: elide; vertical-alignment: center; + accessible-role: none; + } + Text { + x: parent.width - 25px; width: 16px; height: parent.height; + text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free"; + font-weight: 900; font-size: 10px; color: #858c96; + horizontal-alignment: center; vertical-alignment: center; + transform-rotation: popup.is-open ? 180deg : 0deg; + animate transform-rotation { duration: 140ms; easing: ease-out; } + accessible-role: none; + } + trigger-focus := FocusScope { + enabled: root.enabled; + key-pressed(event) => { return root.handle-key(event); } + trigger-touch := TouchArea { + enabled: root.enabled; + clicked => { trigger-focus.focus(); root.open-menu(); } + } + } + popup := PopupWindow { + x: 0px; y: root.height + 4px; + width: root.width; height: root.visible-height + 8px; + close-policy: close-on-click-outside; + forward-focus: popup-focus; + popup-focus := FocusScope { + key-pressed(event) => { return root.handle-key(event); } + Rectangle { + y: (1 - root.popup-progress) * -4px; + width: parent.width; height: parent.height; + opacity: root.popup-progress; + background: white; border-width: 1px; + border-color: #c9cdd2; border-radius: 5px; + ScrollView { + x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px; + viewport-width: self.width; + viewport-height: root.list-height; + viewport-y <=> root.scroll-y; + Rectangle { + width: parent.width; height: root.list-height; + if root.has-divider: Rectangle { + x: 6px; y: root.system-count * 26px + 3px; + width: parent.width - 12px; height: 1px; background: #e9ebee; + } + for choice[index] in root.model: Rectangle { + y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px); + width: parent.width; height: 26px; border-radius: 3px; + background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent; + animate background { duration: 100ms; easing: ease-out; } + accessible-role: list-item; + accessible-label: choice; + Text { + x: 8px; width: parent.width - 34px; height: parent.height; + text: choice; color: #30343b; font-size: 12px; + overflow: elide; vertical-alignment: center; + accessible-role: none; + } + if index == root.current-index: Text { + x: parent.width - 24px; width: 16px; height: parent.height; + text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free"; + font-weight: 900; font-size: 10px; color: #4e87c0; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } + option-touch := TouchArea { + clicked => { root.choose(index); popup.close(); } + } + } + } + } + } + } + } +} diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index c5139109..b251ba4d 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -1,3 +1,8 @@ +import { GalleryStrings } from "device_strings.slint"; +import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint"; +import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint"; +import { GalleryView, GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint"; +import { GroupFilter } from "group_filter.slint"; import { LineEdit, ScrollView } from "std-widgets.slint"; import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint"; import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; @@ -6,7 +11,9 @@ export struct RecentConnection { remote-id: string, display-name: string, host-name: string, + group-names: string, online: bool, + connected: bool, thumbnail: image, has-thumbnail: bool, platform: string, @@ -286,20 +293,86 @@ export component MainWindow inherits Window { title: "CrossDesk"; icon: @image-url("../../../resources/linux/crossdesk_256x256.png"); preferred-width: 640px; - preferred-height: 450px + root.titlebar-height; + preferred-height: 486px + root.titlebar-height; min-width: 640px; - max-width: 640px; - min-height: 450px + root.titlebar-height; - max-height: 450px + root.titlebar-height; + max-width: root.gallery-mode ? 16384px : 640px; + min-height: 486px + root.titlebar-height; + max-height: root.gallery-mode ? 16384px : 486px + root.titlebar-height; no-frame: root.custom-titlebar; background: root.wayland-titlebar ? transparent : white; default-font-size: ImGuiFontStyle.base; + in-out property gallery-mode: false; + in property gallery-fullscreen: false; + in property gallery-transitioning: false; + in property gallery-content-visible: true; + changed gallery-transitioning => { + if root.gallery-transitioning { + focus-sink.focus(); + root.menu-open = false; + root.recent-tooltip-visible = false; + } + } + in-out property connection-input-error; + in property gallery-controlling: false; + out property gallery-video-width: gallery.video-width; + out property gallery-video-height: gallery.video-height; + callback gallery-pointer(PointerEventButton, PointerEventKind, length, length); + callback gallery-scroll(length, length, length, length); + callback gallery-key(string, bool, bool, bool, bool, bool); + in property <[GalleryTile]> gallery-tiles; + in property <[string]> gallery-groups; + in-out property gallery-group-index: 0; + in property gallery-columns: 3; + in property gallery-rows: 2; + in property gallery-page: 0; + in property gallery-pages: 1; + in property gallery-count: 0; + in property gallery-connected: 0; + in property gallery-selected: 0; + in property gallery-hidden-selected: 0; + in property gallery-notice; + in property <[GalleryActionDevice]> gallery-selected-devices; + in property gallery-focused; + in-out property gallery-batch: false; + in-out property gallery-adding: false; + in-out property gallery-importing: false; + in property <[GalleryRecentDevice]> gallery-recent-devices; + in property gallery-recent-selected: 0; + in property gallery-import-group; + out property gallery-width: root.width / 1px; + out property gallery-height: gallery.height / 1px; + callback change-mode(bool); + callback toggle-gallery-fullscreen(); + callback gallery-action(string, string); + callback gallery-add-device(string, string, string, bool) -> bool; + callback gallery-import-recent(string) -> bool; + in-out property device-group-open: false; + private property device-group-id; + private property device-group-name; + in property <[DeviceGroupOption]> device-group-options; + in property device-group-error; + in-out property device-group-new-name; + callback device-group-action(string, string); + callback device-group-save(string) -> bool; + function open-device-groups(id: string, name: string) { + root.gallery-action("release", ""); + focus-sink.focus(); + root.recent-tooltip-visible = false; + root.device-group-id = id; + root.device-group-name = name; + root.device-group-new-name = ""; + root.device-group-action("open", id); + root.device-group-open = true; + } + + public function reset-gallery-transient() { gallery.reset-transient(); root.menu-open = false; } in property custom-titlebar: false; in property wayland-titlebar: false; in property window-active: true; private property titlebar-height: - root.custom-titlebar ? (root.wayland-titlebar ? 32px : 31px) : 0px; + root.custom-titlebar && !root.gallery-fullscreen ? (root.wayland-titlebar ? 32px : 31px) : 0px; + private property content-header-height: 38px; private property wayland-corner-radius: 12px; in property local-id: ""; in property local-password: ""; @@ -310,6 +383,13 @@ export component MainWindow inherits Window { in property signal-reconnecting: false; in property signal-tls-error: false; in property <[RecentConnection]> recent-connections; + in property <[string]> recent-groups: [GalleryStrings.tr("全部设备", "All devices", "Все устройства"), GalleryStrings.tr("未分组", "Ungrouped", "Без группы")]; + in-out property recent-group-index: 0; + callback recent-group-selected(int); + changed recent-group-index => { + root.recent-tooltip-visible = false; + recent-scroll.viewport-x = 0px; + } in property <[AnnouncementItem]> announcements; in property announcement-unread; private property announcement-unread-label: @@ -389,13 +469,14 @@ export component MainWindow inherits Window { // as disabled so the fixed settings layout never turns into a blank row. in property hardware-codec-available: true; - callback copy-local-id; + callback copy-local-id() -> bool; callback toggle-password-visibility; callback refresh-password; callback reset-password(string) -> bool; callback format-remote-id(string) -> string; callback connect-requested(string); callback recent-connect(string); + callback recent-disconnect(string); callback recent-edit-alias(string, string); callback recent-delete(string); callback save-settings() -> bool; @@ -439,29 +520,28 @@ export component MainWindow inherits Window { in-out property alias-input: ""; in-out property delete-open: false; private property delete-remote-id: ""; + private property delete-from-gallery: false; private property copy-toast: false; private property compact-language: root.language-index == 0; private property recent-tooltip-visible: false; private property recent-tooltip-name: ""; private property recent-tooltip-host: ""; private property recent-tooltip-id: ""; + private property recent-tooltip-groups: ""; private property recent-tooltip-online: false; private property recent-tooltip-pointer-x: 0px; private property recent-tooltip-pointer-y: 0px; private property recent-tooltip-width: 260px; - private property recent-tooltip-height: - root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name - ? 80px : 64px; private property recent-tooltip-x: min( root.width - root.recent-tooltip-width - 8px, max(8px, root.recent-tooltip-pointer-x > root.width * 0.7 ? root.recent-tooltip-pointer-x - root.recent-tooltip-width - 12px : root.recent-tooltip-pointer-x + 12px)); - private property recent-tooltip-y: min( - root.height - root.titlebar-height - root.recent-tooltip-height - 8px, - max(8px, root.recent-tooltip-pointer-y - root.recent-tooltip-height - 8px)); + changed menu-open => { if root.menu-open { root.gallery-action("release", ""); } } + changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } } + changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } } changed settings-open => { if root.settings-open { root.settings-scroll-y = 0px; @@ -490,7 +570,7 @@ export component MainWindow inherits Window { root.wayland-titlebar ? root.wayland-corner-radius : 0px; clip: root.wayland-titlebar; - if root.custom-titlebar && root.wayland-titlebar: Rectangle { + if root.custom-titlebar && root.wayland-titlebar && !root.gallery-fullscreen: Rectangle { x: 0px; y: 0px; width: parent.width; @@ -583,7 +663,7 @@ export component MainWindow inherits Window { } } - if root.custom-titlebar && !root.wayland-titlebar: Rectangle { + if root.custom-titlebar && !root.wayland-titlebar && !root.gallery-fullscreen: Rectangle { x: 0px; y: 0px; width: root.width; @@ -673,6 +753,12 @@ export component MainWindow inherits Window { width: root.width; height: root.height - self.y; clip: true; + opacity: root.gallery-content-visible || !root.gallery-mode ? 1 : 0; + animate opacity { + duration: 60ms; + easing: ease-in-out; + enabled: root.gallery-mode; + } Rectangle { width: parent.width; @@ -714,10 +800,10 @@ export component MainWindow inherits Window { // App actions live in the content header; window controls and dragging // remain in the native or Windows-specific title bar above it. menu-button := IconButton { - x: parent.width - 40px; - y: 4px; - width: 32px; - height: 32px; + x: parent.width - mode-switch.x - self.width; + y: mode-switch.y; + width: mode-switch.height; + height: mode-switch.height; icon: FontAwesomeIcons.bars; icon-size: 14px; hover-background: #e1e5eb; @@ -726,7 +812,7 @@ export component MainWindow inherits Window { ? MessageStrings.unread + " " + root.announcement-unread + (root.update-available ? " · " + UiStrings.update-available : "") : root.update-available ? UiStrings.update-available : ""; z: 8; - clicked => { root.menu-open = !root.menu-open; } + clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; } } // Unread messages take precedence so the menu has only one indicator. if root.update-available && root.announcement-unread <= 0: Rectangle { @@ -767,7 +853,7 @@ export component MainWindow inherits Window { } TouchArea { mouse-cursor: pointer; - clicked => { root.menu-open = !root.menu-open; } + clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; } } } if root.announcement-unread > 0: Rectangle { @@ -794,10 +880,51 @@ export component MainWindow inherits Window { } TouchArea { mouse-cursor: pointer; - clicked => { root.menu-open = !root.menu-open; } + clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; } } } + mode-switch := Rectangle { + x: 29px; y: root.content-header-height - self.height; width: 164px; height: 24px; + background: #eef1f5; + border-width: 0.5px; border-color: #d5dbe3; border-radius: 6px; + Rectangle { + x: root.gallery-mode ? 82px : 2px; + y: 2px; width: 80px; height: parent.height - 4px; + background: @linear-gradient(180deg, #c4e0fd, #abd0f8); + border-width: 0.5px; border-color: #97bde5; border-radius: 4px; + drop-shadow-color: #315b8b20; + drop-shadow-blur: 2px; drop-shadow-offset-y: 1px; + Rectangle { + x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px; + border-width: 0.5px; border-color: #ffffff70; border-radius: 3px; + } + animate x { duration: 160ms; easing: ease-out; } + } + for label[index] in [GalleryStrings.tr("连接", "Connect", "Связь"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle { + x: 2px + index * 80px; y: 2px; width: 80px; height: parent.height - 4px; + Text { + text: label; + color: (index == 1) == root.gallery-mode ? #234b75 : #697483; + font-weight: (index == 1) == root.gallery-mode ? 600 : 400; + horizontal-alignment: center; vertical-alignment: center; font-size: 12px; + animate color { duration: 120ms; easing: ease-out; } + } + TouchArea { + clicked => { root.change-mode(index == 1); } + } + } + } + if root.gallery-mode: CompactButton { + x: menu-button.x - self.width - 8px; + y: mode-switch.y; + width: 87px; + height: mode-switch.height; + text: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit full", "Выйти") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран"); + clicked => { root.toggle-gallery-fullscreen(); } + } + Rectangle { + y: root.content-header-height; height: parent.height - self.y - 24px; visible: !root.gallery-mode; // Local and remote desktop panels. Rectangle { y: 0px; @@ -856,13 +983,14 @@ export component MainWindow inherits Window { y: 46px; width: 29px; height: 30px; - icon: FontAwesomeIcons.copy; + icon: root.copy-toast ? FontAwesomeIcons.check : FontAwesomeIcons.copy; icon-size: 16px; tooltip: UiStrings.copied; clicked => { - root.copy-local-id(); - root.copy-toast = true; - copy-timer.restart(); + if root.copy-local-id() { + root.copy-toast = true; + copy-timer.restart(); + } } } Rectangle { x: 8px; y: 88px; width: 240px; height: 1px; background: ImGuiLineStyle.separator; } @@ -921,7 +1049,7 @@ export component MainWindow inherits Window { } } - Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.divider; } + Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.border; } SectionTitle { x: 356px; y: 9px; width: 256px; height: 31px; text: UiStrings.remote-desktop; } Rectangle { @@ -959,10 +1087,11 @@ export component MainWindow inherits Window { // Run formatting after the input event has // finished so the caret state isn't reset by // a synchronous two-way binding rewrite. - edited => { remote-id-format-timer.restart(); } + edited => { root.connection-input-error = ""; remote-id-format-timer.restart(); } accepted => { root.connect-requested(root.remote-id-input); } } } + Text { x: 8px; y: 92px; width: 240px; height: 52px; text: root.connection-input-error; font-size: 12px; color: #a33832; wrap: word-wrap; } IconButton { x: 175px; y: 42px; @@ -982,7 +1111,7 @@ export component MainWindow inherits Window { // Recent connections: horizontal cards with thumbnail, state and actions. recent-panel := Rectangle { y: 234px; - height: 192px; + height: parent.height - self.y; background: white; border-width: 0px; border-color: ImGuiLineStyle.border; @@ -990,6 +1119,13 @@ export component MainWindow inherits Window { Rectangle { y: 0px; height: 1px; background: ImGuiLineStyle.border; } SectionTitle { x: 29px; y: 7px; height: 28px; text: UiStrings.recent-connections; } + GroupFilter { + x: parent.width - self.width - 29px; y: 8px; + width: 155px; height: 28px; + model: root.recent-groups; + current-index <=> root.recent-group-index; + selected(index) => { root.recent-group-selected(index); } + } recent-list := Rectangle { x: 29px; @@ -1002,6 +1138,13 @@ export component MainWindow inherits Window { border-radius: 10px; clip: true; + if root.recent-connections.length == 0: Text { + text: root.recent-group-index == 0 + ? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений") + : GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений"); + color: #808080; font-size: ImGuiFontStyle.base; + horizontal-alignment: center; vertical-alignment: center; + } recent-scroll := ScrollView { width: parent.width; height: parent.height; @@ -1018,7 +1161,7 @@ export component MainWindow inherits Window { height: 117px; background: transparent; private property content-width: self.width - 30px; - private property toolbar-width: 79px; + private property toolbar-width: DeviceCardStyle.toolbar-width; private property toolbar-right-padding: 3px; Rectangle { @@ -1113,12 +1256,19 @@ export component MainWindow inherits Window { image-touch := TouchArea { width: parent.width; height: 84px; + private property group-names: connection.group-names; + changed group-names => { + if self.has-hover && root.recent-tooltip-id == connection.remote-id { + root.recent-tooltip-groups = self.group-names; + } + } clicked => { root.recent-connect(connection.remote-id); } changed has-hover => { if self.has-hover { root.recent-tooltip-name = connection.display-name; root.recent-tooltip-host = connection.host-name; root.recent-tooltip-id = connection.remote-id; + root.recent-tooltip-groups = self.group-names; root.recent-tooltip-online = connection.online; root.recent-tooltip-pointer-x = recent-panel.x + recent-list.x + recent-scroll.x + recent-scroll.viewport-x @@ -1142,41 +1292,24 @@ export component MainWindow inherits Window { } } } - if card-touch.has-hover: Rectangle { + if card-touch.has-hover: DeviceCardActions { x: parent.width - self.width - connection-card.toolbar-right-padding; y: 85px; - width: connection-card.toolbar-width; - height: 23px; - background: #4a4e55dc; - border-radius: 5px; - HorizontalLayout { - spacing: 0px; - IconButton { - width: 26px; height: 23px; icon: FontAwesomeIcons.pen; icon-size: 12px; icon-color: white; - hover-background: #ffffff2e; - pressed-background: #598cf273; - clicked => { - root.alias-remote-id = connection.remote-id; - root.alias-input = connection.display-name; - root.alias-open = true; - } - } - IconButton { - width: 26px; height: 23px; icon: FontAwesomeIcons.trash-can; icon-size: 12px; icon-color: white; - hover-background: #ffffff2e; - pressed-background: #598cf273; - clicked => { - root.delete-remote-id = connection.remote-id; - root.delete-open = true; - } - } - IconButton { - width: 27px; height: 23px; icon: FontAwesomeIcons.arrow-right-long; icon-size: 13px; icon-color: white; - hover-background: #ffffff2e; - pressed-background: #598cf273; - clicked => { root.recent-connect(connection.remote-id); } - } + remove-label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись"); + connected: connection.connected; + edit-alias => { + root.alias-remote-id = connection.remote-id; + root.alias-input = connection.display-name; + root.alias-open = true; } + edit-groups => { root.open-device-groups(connection.remote-id, connection.display-name); } + remove-device => { + root.delete-remote-id = connection.remote-id; + root.delete-from-gallery = false; + root.delete-open = true; + } + connect-device => { root.recent-connect(connection.remote-id); } + disconnect-device => { root.recent-disconnect(connection.remote-id); } } } if index < root.recent-connections.length - 1: Rectangle { @@ -1192,9 +1325,38 @@ export component MainWindow inherits Window { } } + } + gallery := GalleryView { + y: root.content-header-height; width: parent.width; height: parent.height - self.y - 24px; + visible: root.gallery-mode; + controlling: root.gallery-controlling; + pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); } + scroll-input(dx, dy, x, y) => { root.gallery-scroll(dx, dy, x, y); } + key-input(text, pressed, ctrl, alt, shift, meta) => { root.gallery-key(text, pressed, ctrl, alt, shift, meta); } + tiles: root.gallery-tiles; groups: root.gallery-groups; + group-index <=> root.gallery-group-index; + columns: root.gallery-columns; rows: root.gallery-rows; + page: root.gallery-page; pages: root.gallery-pages; count: root.gallery-count; + connected-count: root.gallery-connected; selected-count: root.gallery-selected; + hidden-selected: root.gallery-hidden-selected; notice: root.gallery-notice; + selected-devices: root.gallery-selected-devices; + focused: root.gallery-focused; batch <=> root.gallery-batch; adding <=> root.gallery-adding; + importing <=> root.gallery-importing; recent-devices: root.gallery-recent-devices; + recent-selected: root.gallery-recent-selected; import-group: root.gallery-import-group; + action(code, value) => { root.gallery-action(code, value); } + add-device(id, password, group, remember) => { root.gallery-add-device(id, password, group, remember) } + import-recent(group) => { root.gallery-import-recent(group) } + edit-alias(id, name) => { + root.alias-remote-id = id; root.alias-input = name; root.alias-open = true; + } + edit-groups(id, name) => { root.open-device-groups(id, name); } + remove-device(id) => { + root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true; + } + } // Status bar. Rectangle { - y: 426px; + y: parent.height - 24px; height: 24px; background: #fafbfc; Rectangle { @@ -1255,9 +1417,11 @@ export component MainWindow inherits Window { // pointer can otherwise cover a dialog button and hide it from the user. if root.recent-tooltip-visible && !root.connection-dialog-open: Rectangle { x: root.recent-tooltip-x; - y: root.recent-tooltip-y; + y: min(root.height - root.titlebar-height - self.height - 8px, + max(8px, root.recent-tooltip-pointer-y - self.height - 8px)); width: root.recent-tooltip-width; - height: root.recent-tooltip-height; + height: (root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name + ? 80px : 64px) + tooltip-groups.preferred-height + 2px; background: #22252bf2; border-width: 1px; border-color: #ffffff38; @@ -1285,6 +1449,14 @@ export component MainWindow inherits Window { font-size: ImGuiFontStyle.small; overflow: elide; } + tooltip-groups := Text { + text: GalleryStrings.tr("分组", "Groups", "Группы") + ": " + + (root.recent-tooltip-groups == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : root.recent-tooltip-groups); + color: #e1e4e8; + font-size: ImGuiFontStyle.small; + wrap: word-wrap; + vertical-stretch: 0; + } Text { text: root.recent-tooltip-online ? UiStrings.online : UiStrings.offline; color: root.recent-tooltip-online ? #62d98b : #d1d5db; @@ -1536,14 +1708,32 @@ export component MainWindow inherits Window { } } + // One editor and one callback path for recent connections and DeskHub. + if root.device-group-open: DeviceGroupDialog { + z: 40; + device-id: root.device-group-id; + device-name: root.device-group-name; + options: root.device-group-options; + error: root.device-group-error; + new-group <=> root.device-group-new-name; + toggle-group(name) => { root.device-group-action("toggle", name); } + save(name) => { + if root.device-group-save(name) { + root.device-group-action("close", ""); + root.device-group-open = false; + } + } + cancel => { root.device-group-action("close", ""); root.device-group-open = false; } + } + // Recent connection alias modal. if root.alias-open: Rectangle { background: #00000042; z: 40; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.compact-language ? 205px : 160px; - y: 145px; + x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px; + y: root.gallery-mode ? (parent.height - self.height) / 2 : 145px; width: root.compact-language ? 230px : 320px; height: 160px; VerticalLayout { @@ -1588,16 +1778,16 @@ export component MainWindow inherits Window { z: 40; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: root.compact-language ? 205px : 160px; - y: 155px; + x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px; + y: root.gallery-mode ? (parent.height - self.height) / 2 : 155px; width: root.compact-language ? 230px : 320px; height: 140px; VerticalLayout { padding: 20px; spacing: 18px; - DialogPromptText { prompt: UiStrings.confirm-delete; } + DialogPromptText { prompt: root.delete-from-gallery ? (root.gallery-group-index > 0 ? GalleryStrings.tr("从当前分组移除此设备?", "Remove from this group?", "Убрать из этой группы?") : GalleryStrings.tr("从桌面墙移除此设备?", "Remove from DeskHub?", "Убрать из DeskHub?")) : UiStrings.confirm-delete; } HorizontalLayout { alignment: center; spacing: 8px; - PrimaryButton { text: UiStrings.ok; clicked => { root.recent-delete(root.delete-remote-id); root.delete-open = false; } } + PrimaryButton { text: UiStrings.ok; clicked => { if root.delete-from-gallery { root.gallery-action("remove", root.delete-remote-id); } else { root.recent-delete(root.delete-remote-id); } root.delete-open = false; } } SecondaryButton { text: UiStrings.cancel; clicked => { root.delete-open = false; } } } } @@ -1672,8 +1862,8 @@ export component MainWindow inherits Window { text: UiStrings.ok; clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); } } - if !root.connection-validating: ConnectionDialogButton { - x: 117px; + ConnectionDialogButton { + x: root.connection-validating ? (parent.width - self.width) / 2 : 117px; y: 118px; text: UiStrings.cancel; clicked => { root.connection-cancel(); } @@ -2265,6 +2455,13 @@ export component MainWindow inherits Window { } } + // Ignore clicks against moving cards and duplicate fullscreen requests. + if root.gallery-transitioning: TouchArea { + x: 0px; y: root.titlebar-height; + width: root.width; height: root.height - self.y; + z: 250; + } + if root.custom-titlebar: Rectangle { x: 0px; y: 0px; diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 06c5426e..76d0cfed 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -40,6 +40,7 @@ inline int ApplyMainWindowStrings( int language_index) { const int language = localization::detail::ClampLanguageIndex(language_index); + window->global().set_language(language); auto& strings = window->global(); auto& messages = window->global(); messages.set_messages(Text(localization::messages[language])); diff --git a/apps/desktop/src/platform/macos/gui/window_drag_mac.mm b/apps/desktop/src/platform/macos/gui/window_drag_mac.mm index fb5244a8..743de91d 100644 --- a/apps/desktop/src/platform/macos/gui/window_drag_mac.mm +++ b/apps/desktop/src/platform/macos/gui/window_drag_mac.mm @@ -2,6 +2,8 @@ #import +#include + @interface CrossDeskStreamFullscreenTarget : NSObject - (void)toggleStreamFullscreen:(id)sender; @end @@ -17,6 +19,12 @@ namespace crossdesk { namespace { +__weak NSWindow *main_window = nil; +bool main_fullscreen = false; +bool main_gallery = false; +NSRect main_restore_frame = NSZeroRect; +NSWindowStyleMask main_restore_style = NSWindowStyleMaskTitled; +NSApplicationPresentationOptions main_restore_presentation; __weak NSWindow *stream_window = nil; __weak NSView *stream_slint_view = nil; CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil; @@ -53,6 +61,14 @@ void SetTitlebarButtonsHidden(NSWindow *window, BOOL hidden) { [window standardWindowButton:NSWindowZoomButton].hidden = hidden; } +void HideMainWindowZoomButton(NSWindow *window) { + NSButton *button = [window standardWindowButton:NSWindowZoomButton]; + button.target = nil; + button.action = nil; + button.enabled = NO; + button.hidden = YES; +} + void CenterWindowOnScreen(NSWindow *window, NSScreen *screen) { if (window == nil || screen == nil) { return; @@ -80,7 +96,17 @@ bool StartNativeWindowDrag() { } } -bool ConfigureMainWindowChrome(void *opaque_slint_view) { +float MainWindowInitialPositionYOffset() { + @autoreleasepool { + const NSRect content = NSMakeRect(0, 0, 640, 486); + const NSRect frame = [NSWindow frameRectForContentRect:content + styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | + NSWindowStyleMaskMiniaturizable]; + return float(NSMaxY(frame) - NSMaxY(content)); + } +} + +bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) { @autoreleasepool { NSView *slint_view = (__bridge NSView *)opaque_slint_view; NSWindow *window = slint_view.window; @@ -91,9 +117,19 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) { NSButton *zoom_button = [window standardWindowButton:NSWindowZoomButton]; - if (zoom_button == nil || zoom_button.enabled) { - return false; - } + if (zoom_button == nil) return false; + main_window = window; + main_gallery = gallery; + if (main_fullscreen) return true; + if (gallery) + window.styleMask |= NSWindowStyleMaskResizable; + else + window.styleMask &= ~NSWindowStyleMaskResizable; + window.collectionBehavior = + (window.collectionBehavior & ~(NSWindowCollectionBehaviorFullScreenPrimary | + NSWindowCollectionBehaviorFullScreenAuxiliary | + NSWindowCollectionBehaviorFullScreenAllowsTiling)) | + NSWindowCollectionBehaviorFullScreenNone; // Winit creates a transparent AppKit backing even when Slint paints an // opaque client area. macOS 27 can expose that backing in the titlebar. @@ -104,7 +140,7 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) { window.titlebarAppearsTransparent = YES; window.opaque = YES; window.backgroundColor = NSColor.whiteColor; - zoom_button.hidden = YES; + HideMainWindowZoomButton(window); NSView *frame_view = window.contentView.superview; frame_view.needsLayout = YES; @@ -113,6 +149,71 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view) { } } +bool SetMainWindowFullscreen(bool fullscreen) { + @autoreleasepool { + if (!main_window || (fullscreen && !main_gallery)) return false; + if (main_fullscreen == fullscreen) return true; + if (fullscreen) { + NSScreen *screen = main_window.screen ?: NSScreen.mainScreen; + if (!screen) return false; + main_restore_frame = main_window.frame; + main_restore_style = main_window.styleMask; + main_restore_presentation = NSApp.presentationOptions; + main_fullscreen = true; + main_window.styleMask |= NSWindowStyleMaskFullSizeContentView; + main_window.titleVisibility = NSWindowTitleHidden; + main_window.movable = NO; + SetTitlebarButtonsHidden(main_window, YES); + NSApp.presentationOptions |= + NSApplicationPresentationAutoHideDock | NSApplicationPresentationAutoHideMenuBar; + // Let the next event-loop frame paint the updated Slint layout. A + // synchronous draw here exposes stale grid geometry during the resize. + [main_window setFrame:screen.frame display:NO animate:NO]; + } else { + main_fullscreen = false; + NSApp.presentationOptions = main_restore_presentation; + main_window.styleMask = main_restore_style; + main_window.titleVisibility = NSWindowTitleVisible; + main_window.movable = YES; + [main_window standardWindowButton:NSWindowCloseButton].hidden = NO; + [main_window standardWindowButton:NSWindowMiniaturizeButton].hidden = NO; + // The main window uses the in-app fullscreen action in both modes. + HideMainWindowZoomButton(main_window); + [main_window setFrame:main_restore_frame display:NO animate:NO]; + } + main_window.contentView.needsDisplay = YES; + return true; + } +} +bool IsMainWindowFullscreen() { return main_fullscreen; } +bool IsMainWindowActive() { return main_window && [NSApp isActive] && [main_window isKeyWindow]; } + +bool MaximizeMainWindowWithoutAnimation() { + @autoreleasepool { + if (!main_window || !main_gallery || main_fullscreen) return false; + NSScreen *screen = main_window.screen ?: NSScreen.mainScreen; + if (!screen) return false; + [main_window setFrame:screen.visibleFrame display:YES animate:NO]; + return true; + } +} + +bool IsMainWindowMaximized() { + @autoreleasepool { + if (!main_window || !main_gallery || main_fullscreen) return false; + NSScreen *screen = main_window.screen; + if (!screen) return false; + // isZoomed can report true for a constrained compact window. Only treat + // the ordinary window as maximized when it fills the usable screen. + const NSRect frame = main_window.frame; + const NSRect available = screen.visibleFrame; + return std::abs(frame.origin.x - available.origin.x) < 1 && + std::abs(frame.origin.y - available.origin.y) < 1 && + std::abs(frame.size.width - available.size.width) < 1 && + std::abs(frame.size.height - available.size.height) < 1; + } +} + bool ConfigureStreamWindowLiveResize(void *opaque_slint_view) { @autoreleasepool { NSView *slint_view = (__bridge NSView *)opaque_slint_view; diff --git a/apps/desktop/src/platform/window_drag.h b/apps/desktop/src/platform/window_drag.h index 3d62fbc3..61820e5d 100644 --- a/apps/desktop/src/platform/window_drag.h +++ b/apps/desktop/src/platform/window_drag.h @@ -8,10 +8,20 @@ namespace crossdesk { // left-mouse-down event. Returns false when no suitable native event exists. bool StartNativeWindowDrag(); -// Sets a fixed white native titlebar and hides the disabled zoom button on -// the fixed-size main window. |slint_view| is its AppKit NSView*. Returns false -// while the native window or its fixed-size constraints are not ready. -bool ConfigureMainWindowChrome(void* slint_view); +// Sets a white native titlebar and hides its zoom button in both main-window +// modes. DeskHub remains resizable. |slint_view| is its AppKit NSView*. +// Returns false while the native window or its titlebar controls are not ready. +bool ConfigureMainWindowChrome(void* slint_view, bool gallery = false); +// Winit's initial macOS position is a content origin, while subsequent moves +// use the outer frame. Return the logical titlebar inset for initial placement. +float MainWindowInitialPositionYOffset(); +bool SetMainWindowFullscreen(bool fullscreen); +bool IsMainWindowFullscreen(); +bool IsMainWindowActive(); +// AppKit's animated zoom spins a nested run loop and cannot be called from a +// Slint timer. Restore maximization by setting the visible screen frame directly. +bool MaximizeMainWindowWithoutAnimation(); +bool IsMainWindowMaximized(); // Configures live resize and replaces the stream window's native Space // fullscreen action with an immediate, single-window fullscreen transition. diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 985a71a3..da95327d 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -510,7 +510,7 @@ function setup_targets() "apps/desktop/src/platform/common/gui/service_status_runtime.cpp", "apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp", "apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp", - "apps/desktop/src/gui/features/settings/*.cpp") + "apps/desktop/src/gui/features/settings/*.cpp", "apps/desktop/src/gui/features/gallery/*.cpp") -- Only these translation units consume the application version. Keep -- version changes out of the flags for media and the rest of the GUI. add_files("apps/desktop/src/gui/application/gui_application.cpp", diff --git a/docs/deskhub.md b/docs/deskhub.md new file mode 100644 index 00000000..2d814bec --- /dev/null +++ b/docs/deskhub.md @@ -0,0 +1,53 @@ +# 桌面墙(DeskHub) + +主窗口标题栏下方可以切换“连接”和“桌面墙”,菜单位于同一行右侧。每次启动软件默认进入“连接”页,不恢复上次退出时的页面;保存的位置会在窗口显示前恢复。连接页保留原有布局和紧凑尺寸;桌面墙可以调整尺寸及全屏。普通窗口切换模式时保持左上角位置,仅分别记忆尺寸。切回连接页会退出全屏并恢复普通窗口位置与紧凑尺寸,再进入桌面墙会恢复它的尺寸及全屏状态。已断开的显示器所对应的位置会修正到可用屏幕内。 + +全屏按钮通过短暂淡出、等待窗口尺寸稳定、淡入完成切换,常规总时长约 0.2 秒;过渡期间屏蔽设备操作与重复全屏点击。窗口尺寸变化会立即更新网格,避免在新窗口中先显示旧的行列布局。过渡复用现有 UI 更新循环,不启动嵌套事件循环;失败或超时会恢复内容与交互。 + +可见的普通窗口若低于连接页最小尺寸,桌面墙恢复上次有效大小,连接页恢复固定大小;最小化、隐藏和全屏窗口不会触发这一恢复。窗口恢复以实际尺寸确认完成,期间不记录过渡尺寸;macOS 的最大化恢复使用无动画原生窗口调整,避免系统缩放动画嵌套事件循环。 + +## 使用 + +- 在桌面墙中点“添加设备”,填写设备 ID、密码和可选的分组名称,再点“添加并连接”。密码仅用于本次连接,勾选“记住密码”才会通过原有凭据机制保存;不会写入桌面墙配置。 +- “从最近连接添加”支持多选、全选和统一指定分组。导入后只加入桌面墙,使用“批量选择 → 连接”再发起连接。同一分组内重复设备会跳过;不指定分组时,已在桌面墙中的设备不会重复加入。 +- 连接成功后立即记录在连接页的“最近连接”中,无需等到断开,也不要求记住密码。失败的连接不会新增最近记录。 +- “全部设备”显示保存的成员及当前活动会话。分组成员与连接相互独立:断开后成员仍在,重启后不会自动连接。 +- 卡片名称栏以文字标签显示“在线/离线”,与画面中的连接状态分开,不遮挡预览。在线状态订阅包含全部分组中的设备;尚未收到结果、结果过期或服务器断连时显示“未知”,已有活动连接的设备仍显示在线。 +- 鼠标悬停时,名称栏右侧显示与近期连接一致的改名、修改分组、移除、连接/断开操作条,并显示按钮提示。两处的连接按钮均随实际会话状态切换:未连接时显示连接箭头,已连接时显示叉号,点击断开该设备;点卡片仍可查看设备。名称与近期连接共用别名;移除会先确认,只移除当前分组或桌面墙中的成员,不删除近期记录或断开会话。批量选择时隐藏操作条。 +- 两处的“修改分组”共用操作条组件、分组弹窗和保存逻辑。可以选择多个已有分组、填写新分组,或选“未分组”清除分组归属。分组与桌面墙成员独立保存:近期连接修改分组不会自动加入桌面墙,也不会自动连接;通过“从最近连接添加”或“添加设备”才保存为桌面墙成员,未指定新分组时保留已有分组。从“全部设备”移除成员后仍保留其分组信息;在特定分组中移除则取消对应分组归属,移除最后一个分组后仍保留在“全部设备”中。修改分组保留墙内设备的顺序,保存失败时保留弹窗和输入。 +- 近期连接的设备提示浮窗显示所属分组;多个分组并列显示并自动换行,没有分组时显示“未分组”。分组变更后自动刷新。 +- “近期连接”标题行右侧可按“全部设备”、未分组或已有分组筛选;一个设备可出现在它所属的多个分组中。筛选与桌面墙当前分组独立,不改变历史记录、在线状态订阅或桌面墙成员。分组修改后自动更新结果;当前分组被清空删除时回到全部设备。 +- 网格随窗口大小增加行列,紧凑窗口为 3 × 2;翻页不关闭连接。分组内顺序不会按在线状态自动重排。 +- 点卡片进入只读放大视图,点“接管操控”才开始发送键鼠。点“释放操控”或按 Ctrl/⌘ + Shift + Esc 可以释放。切换设备、翻页、打开本地操作面板、切换模式及窗口失焦也会释放。 +- 放大视图使用一行设备操作栏,只保留返回、设备名称、状态和接管/释放操控按钮;紧凑窗口的设备缩略图位于下方,宽窗口移到右侧。缩略图显示设备名、在线状态和当前设备标记,缺少画面或画面停滞时有状态占位;滚动条单独留出空间。调整尺寸后当前设备仍在列表中,选中时自动滚入可见区域。主预览和缩略图均完整等比显示,鼠标坐标按实际预览区域映射。 +- 在“近期连接”中点击已连接设备的卡片画面,可打开原有独立串流窗口,使用声音、显示器切换、隐私屏、文件传输等完整单会话工具。关闭墙内设备的独立窗口不会断开墙中的会话。 +- 批量选择复用固定底栏,设备卡片的位置和尺寸不变。底栏提供“全选”:选中当前筛选下的所有设备,跨页生效;全部选中后显示“取消全选”。翻页保留选择,切换分组清空选择;已选数量与跨页数量、连接/断开/分发文件及分页控件在同一底栏显示。断开和文件分发会先列出完整目标;提交后的操作目标不会跟随之后的选择变化。 + +## 预览与兼容性 + +桌面墙的所有已连接设备统一使用各自的交互画质、30/60 帧设置和降级策略。缩略图、放大查看、操控、翻页和隐藏窗口均不改变发送档位。新版两端通过 `supports_deskhub_preview` 同步订阅,视频按编码器协商的帧率发送,不再额外限帧;声音仍需明确开启。 + +为兼容旧版控制端,被控端仍接受暂停和 1/8 帧订阅;这些低帧率请求使用保持分辨率策略,避免套用交互视频的帧率与关键帧时延预算而反复缩小画面。当前桌面墙不再发出这些请求。 + +预览在解码回调中随收到的画面生成有界缩略图,可见桌面墙随 UI 刷新显示最新帧。UI 不处理全分辨率转换,只保留当前可见设备的最新预览,超过 5 秒未更新会标记停帧。画面保持比例,以完整矩形显示;外层圆角卡片与画面之间保留内边距,不裁切桌面内容或画面边角。实际显示帧率受设备性能与网络状况影响。 + +旧版被控端仍可连接,按其支持的交互视频策略发送。多设备同时发送交互视频会增加带宽和编解码负载。 + +## 批量操作 + +连接和文件分发最多并行启动 4 个操作。尚未开始连接的设备卡片显示“排队中”,轮到后自动切换为实际连接状态。断开立即执行,同时取消该设备排队中的操作及重连请求,不等待文件传输完成。单台等待密码不会阻塞其他设备,可以从对应设备的放大视图填写密码。连接状态显示在设备卡片上,文件传输详情可在该设备的独立串流窗口中查看。 + +批量操作仅在本次运行中执行,不提供任务列表、操作历史或重启后的恢复。成员资料与窗口状态保存在应用缓存目录的 `deskhub.json` 和 `deskhub.json.window` 中,不含设备密码和文件内容。 + +`deskhub.json` 第 2 版分别保存分组记录和桌面墙设备 ID。第 1 版配置仍可读取,原有桌面墙成员、分组和顺序会保留;旧版数据未记录加入来源,因此不会自动移除已有成员。 + +批量远程命令、重启尚未提供。实际大规模并发容量需要用真实设备测量。 + +## 验证 + +```sh +xmake build crossdesk +xmake run slint_ui_smoke_test +xmake run crossdesk_wire_test +xmake run keyboard_state_wire_test +```