From b2d3834944a873c7b4e478168128a22b1d33fce1 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Mon, 19 Oct 2026 10:53:55 +0800 Subject: [PATCH] [feat] unify persistent file transfer notifications --- .../src/gui/application/gui_application.cpp | 476 +++++++++++------- .../src/gui/application/gui_application.h | 5 +- .../assets/localization/localization_data.h | 5 + .../file_transfer/file_transfer_manager.cpp | 125 +++-- .../file_transfer/file_transfer_manager.h | 24 +- .../transfer_notification_layout.h | 48 ++ apps/desktop/src/gui/runtime/remote_session.h | 5 +- apps/desktop/src/gui/ui/crossdesk_ui.slint | 1 + .../gui/ui/file_transfer_notification.slint | 88 ++++ .../src/platform/macos/gui/window_drag_mac.mm | 129 ++++- apps/desktop/src/platform/window_drag.h | 19 +- apps/desktop/src/tools/file_transfer.cpp | 4 +- apps/desktop/src/tools/file_transfer.h | 3 + libs/controller/include/file_transfer_core.h | 33 +- 14 files changed, 735 insertions(+), 230 deletions(-) create mode 100644 apps/desktop/src/gui/features/file_transfer/transfer_notification_layout.h create mode 100644 apps/desktop/src/gui/ui/file_transfer_notification.slint diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index d241d03d..504d206c 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -34,10 +35,12 @@ #include "crossdesk_ui.h" #include "fa_solid_900.h" +#include "features/file_transfer/transfer_notification_layout.h" #include "features/gallery/gallery_fullscreen_transition.h" #include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_workspace.h" #include "features/gallery/preview_bandwidth.h" +#include "filesystem_utf8.h" #include "localization.h" #include "platform.h" #ifndef __OHOS__ @@ -961,7 +964,7 @@ struct GuiApplication::SlintUi { bool remember_password = false; enum class State { Queued, Running, AwaitingPassword, Finished }; State state = State::Queued; - size_t file_index = 0; + uint32_t file_id = 0; std::weak_ptr session; std::chrono::steady_clock::time_point started{}; }; @@ -994,8 +997,13 @@ struct GuiApplication::SlintUi { std::make_shared>(); std::shared_ptr> display_model = std::make_shared>(); - std::shared_ptr> transfer_model = - std::make_shared>(); + struct TransferNotification { + std::optional> window; + RemoteSession::FileTransferInfo info; + bool dismissed = false; + }; + std::map transfer_notifications; + SDL_DisplayID transfer_display = 0; std::shared_ptr> stats_model = std::make_shared>(); std::shared_ptr> controller_model = @@ -1015,6 +1023,8 @@ struct GuiApplication::SlintUi { std::vector recent_filter_groups; slint::Timer timer; slint::Timer server_file_dialog_timer; + std::shared_ptr file_dialog_lifetime = std::make_shared(0); + bool file_dialog_open = false; WindowDragState main_drag, gallery_drag; WindowDragState stream_drag; WindowDragState server_drag; @@ -1141,6 +1151,7 @@ int GuiApplication::Run() { ConfigureWindowsWindowIcons(ui_->main->window().win32_hwnd()); #elif defined(__APPLE__) ConfigureMainWindowChrome(ui_->main->window().appkit_view()); + PrepareNativeFileDialog(); #endif slint::run_event_loop(slint::EventLoopMode::RunUntilQuit); Cleanup(); @@ -2017,14 +2028,12 @@ void GuiApplication::BindStreamCallbacks() { if (!props) { return; } - transfers_.ProcessSelectedFile( - OpenFileDialog(localization::select_file[localization_language_index_]), - props, props->file_label_); - }); - stream->on_close_file_transfer([this] { - if (auto props = SelectedSession()) { - props->file_transfer_.file_transfer_window_visible_ = false; - } + OpenFileDialog(localization::select_file[localization_language_index_], + [this, props](std::string path) { + if (!path.empty() && !props->closing_ && + props->connection_status_ == ConnectionStatus::Connected) + transfers_.ProcessSelectedFile(path, props, props->file_label_); + }); }); stream->on_can_drop_file( [](const slint::DataTransfer& data) { return data.has_plain_text(); }); @@ -2140,32 +2149,37 @@ void GuiApplication::BindServerCallbacks() { if (selected_server_remote_id_.empty() || ui_->server_file_dialog_open) { return; } - // A native file picker runs a nested event loop. Keep the recipient stable - // if another controller connects or leaves while it is open. + // Keep the recipient stable if another controller connects or leaves while + // the asynchronous native picker is open. const std::string recipient = selected_server_remote_id_; ui_->server_file_dialog_open = true; (*ui_->server)->window().request_redraw(); - // TouchArea clears pressed only after its clicked callback returns. Allow - // that release and a repaint before entering the blocking native picker. - ui_->server_file_dialog_timer.start( - slint::TimerMode::SingleShot, std::chrono::milliseconds(32), - [this, recipient] { - const auto connected = [this, &recipient] { - std::shared_lock lock(connection_status_mutex_); - const auto found = connection_status_.find(recipient); - return found != connection_status_.end() && - found->second == ConnectionStatus::Connected; - }; - if (!connected()) { + const auto select_file = [this, recipient] { + const auto connected = [this, recipient] { + std::shared_lock lock(connection_status_mutex_); + const auto found = connection_status_.find(recipient); + return found != connection_status_.end() && + found->second == ConnectionStatus::Connected; + }; + if (!connected()) { + ui_->server_file_dialog_open = false; + return; + } + OpenFileDialog(localization::select_file[localization_language_index_], + [this, recipient, connected](std::string path) { ui_->server_file_dialog_open = false; - return; - } - const auto path = OpenFileDialog( - localization::select_file[localization_language_index_]); - ui_->server_file_dialog_open = false; - if (connected() && !path.empty()) - transfers_.ProcessSelectedFile(path, nullptr, file_label_, recipient); - }); + if (connected() && !path.empty()) + transfers_.ProcessSelectedFile(path, nullptr, file_label_, recipient); + }); + }; +#if defined(__APPLE__) + // The native picker is already dispatched outside the clicked callback. + select_file(); +#else + // Let TouchArea release and repaint before the blocking native picker. + ui_->server_file_dialog_timer.start( + slint::TimerMode::SingleShot, std::chrono::milliseconds(32), select_file); +#endif }); server->on_disconnect_controller([this](slint::SharedString id) { CloseServerController(std::string(id)); @@ -2287,6 +2301,7 @@ void GuiApplication::Tick() { SyncPlatformDialogs(); SyncStreamWindow(); SyncServerWindow(); + SyncFileTransferNotifications(); } void GuiApplication::PollUpdateCheck() { @@ -3240,7 +3255,6 @@ void GuiApplication::SyncStreamWindow() { ui_->localized_language = -1; (*ui_->stream)->set_tabs(ui_->tab_model); (*ui_->stream)->set_displays(ui_->display_model); - (*ui_->stream)->set_file_transfers(ui_->transfer_model); (*ui_->stream)->set_stats_rows(ui_->stats_model); BindStreamCallbacks(); (*ui_->stream)->show(); @@ -3535,60 +3549,6 @@ void GuiApplication::SyncStreamWindow() { : localization::connection_mode_relay [localization_language_index_])); - std::vector file_list; - { - std::lock_guard lock(props->file_transfer_.file_transfer_list_mutex_); - file_list = props->file_transfer_.file_transfer_list_; - } - const auto transfer_priority = [](RemoteSession::FileTransferStatus status) { - switch (status) { - case RemoteSession::FileTransferStatus::Sending: - return 0; - case RemoteSession::FileTransferStatus::Completed: - return 1; - case RemoteSession::FileTransferStatus::Queued: - return 2; - case RemoteSession::FileTransferStatus::Failed: - return 3; - } - return 3; - }; - std::stable_sort(file_list.begin(), file_list.end(), - [&](const auto& left, const auto& right) { - return transfer_priority(left.status) < - transfer_priority(right.status); - }); - - std::vector transfers; - transfers.reserve(file_list.size()); - for (const auto& info : file_list) { - ui::FileTransferEntry item; - item.name = UiText(info.file_name); - const auto status_index = static_cast(info.status); - if (status_index == 0) - item.status = UiText(localization::queued[localization_language_index_]); - if (status_index == 1) - item.status = UiText(localization::sending[localization_language_index_]); - if (status_index == 2) - item.status = - UiText(localization::completed[localization_language_index_]); - if (status_index == 3) - item.status = UiText(localization::failed[localization_language_index_]); - item.progress = status_index == 2 ? 1.0f - : info.file_size == 0 - ? 0.0f - : std::clamp(static_cast(info.sent_bytes) / - static_cast(info.file_size), - 0.0f, 1.0f); - item.speed = UiText(FormatTransferRate(info.rate_bps)); - item.size = UiText(FormatFileSize(info.file_size)); - transfers.push_back(std::move(item)); - } - ui::UpdateModelRows(*ui_->transfer_model, std::move(transfers)); - (*ui_->stream) - ->set_file_transfer_visible( - props->file_transfer_.file_transfer_window_visible_); - const auto fps_now = std::chrono::steady_clock::now(); bool refresh_stats = false; if (!props->net_traffic_stats_button_pressed_) { @@ -3872,7 +3832,7 @@ void GuiApplication::SyncServerWindow() { if (!show_controller_window && ui_->server) { (*ui_->server)->hide(); (*ui_->server_bar)->hide(); - // Keep handles alive through a native file dialog's nested event loop. + // Keep handles alive until the pending native file dialog completes. if (ui_->server_file_dialog_open) { server_window_collapsed_ = true; (*ui_->server_bar)->set_expanded(false); @@ -3940,37 +3900,177 @@ void GuiApplication::SyncServerWindow() { --ui_->server_native_window_attempts; } #endif - auto& transfer = transfers_.global_state(); - const auto sent = transfer.file_sent_bytes_.load(); - const auto total = transfer.file_total_bytes_.load(); - (*ui_->server) - ->set_file_transfer_visible(transfer.file_transfer_window_visible_); - (*ui_->server)->set_sending_file(transfer.file_sending_.load()); - (*ui_->server) - ->set_file_progress(total == 0 ? 0.0f - : std::clamp(static_cast(sent) / - static_cast(total), - 0.0f, 1.0f)); - (*ui_->server) - ->set_file_progress_text( - UiText(FormatTransferRate(transfer.file_send_rate_bps_.load()))); - std::string current_file_name; - const uint32_t current_file_id = transfer.current_file_id_.load(); - if (current_file_id != 0) { - std::lock_guard lock(transfer.file_transfer_list_mutex_); - for (const auto& info : transfer.file_transfer_list_) { - if (info.file_id == current_file_id) { - current_file_name = info.file_name; - break; - } + (*ui_->server)->set_sending_file(transfers_.global_state().file_sending_.load()); + PositionServerWindows(); +} + +void GuiApplication::SyncFileTransferNotifications() { + using Status = RemoteSession::FileTransferStatus; + using namespace transfer_notification; + std::map files; + const auto collect = [&](FileTransferState& state) { + std::lock_guard lock(state.file_transfer_list_mutex_); + for (const auto& info : state.file_transfer_list_) files.emplace(info.file_id, info); + }; + collect(transfers_.global_state()); + collect(transfers_.received_state()); + { + std::shared_lock lock(remote_sessions_mutex_); + for (const auto& [_, props] : remote_sessions_) { + if (props) collect(props->file_transfer_); } } - (*ui_->server)->set_current_file_name(UiText(current_file_name)); - (*ui_->server) - ->set_file_size_text(total == 0 ? slint::SharedString{} - : UiText(FormatFileSize(sent) + " / " + - FormatFileSize(total))); - PositionServerWindows(); + // DeskHub reserves each recipient's notification before a transfer slot is + // available. Keep its queued window (and dismissal) alive until dispatched. + std::set pending_files; + for (const auto& operation : ui_->batch_operations) + if (operation.file_id && + operation.state != SlintUi::BatchOperation::State::Finished) + pending_files.insert(operation.file_id); + // Retain the last progress independently of session lifetime. Results stay + // visible until dismissed, including after the connection is closed. + for (const auto& [id, info] : files) + ui_->transfer_notifications[id].info = info; + std::erase_if(ui_->transfer_notifications, [&](auto& item) { + if (!item.second.dismissed || files.count(item.first) || + pending_files.count(item.first)) return false; + if (item.second.window) (*item.second.window)->hide(); + return true; + }); + if (ui_->transfer_notifications.empty()) { + ui_->transfer_display = 0; + return; + } + if (!ui_->transfer_display) { + const auto& anchor = ui_->stream && (*ui_->stream)->window().is_visible() + ? (*ui_->stream)->window() + : ui_->gallery_shown ? ui_->wall->window() : ui_->main->window(); + ui_->transfer_display = DisplayForSlintWindow(anchor); + } + SDL_Rect bounds{}; + if (!SDL_GetDisplayUsableBounds(ui_->transfer_display, &bounds)) { + ui_->transfer_display = SDL_GetPrimaryDisplay(); + if (!SDL_GetDisplayUsableBounds(ui_->transfer_display, &bounds)) return; + } +#if defined(__APPLE__) + const float desktop_scale = 1; +#else + const float desktop_scale = std::max(0.01f, SDL_GetDisplayContentScale(ui_->transfer_display)); +#endif + const float screen_x = bounds.x / desktop_scale; + const float screen_y = bounds.y / desktop_scale; + const float screen_width = bounds.w / desktop_scale; + const float screen_height = bounds.h / desktop_scale; + const auto layout = Fit(screen_width, screen_height); + size_t slot = 0; + for (auto& [id, notification] : ui_->transfer_notifications) { + auto& info = notification.info; + if (!files.count(id) && !pending_files.count(id) && info.status != Status::Completed && + info.status != Status::Failed) { + info.status = Status::Failed; + info.rate_bps = 0; + } + const bool completed = info.status == Status::Completed; + const bool failed = info.status == Status::Failed; + if (notification.dismissed || slot >= layout.capacity()) { + if (notification.window) { + (*notification.window)->hide(); + notification.window.reset(); + } + continue; + } + const bool created = !notification.window; + if (created) { + notification.window.emplace(ui::FileTransferNotification::create()); + RegisterFontAwesome((*notification.window)->window()); + const auto dismiss = [this, id] { + const auto found = ui_->transfer_notifications.find(id); + if (found == ui_->transfer_notifications.end()) return; + found->second.dismissed = true; + if (found->second.window) (*found->second.window)->hide(); + }; + (*notification.window)->on_dismissed(dismiss); + (*notification.window)->window().on_close_requested([dismiss] { + dismiss(); + return slint::CloseRequestResponse::KeepWindowShown; + }); + } + auto& component = *notification.window; + ui::FileTransferEntry entry; + entry.name = UiText(info.file_name); + switch (info.status) { + case Status::Queued: entry.status = UiText(localization::queued[localization_language_index_]); break; + case Status::Sending: entry.status = UiText(localization::sending[localization_language_index_]); break; + case Status::Receiving: entry.status = UiText(localization::receiving[localization_language_index_]); break; + case Status::Completed: entry.status = UiText(localization::completed[localization_language_index_]); break; + case Status::Failed: entry.status = UiText(localization::failed[localization_language_index_]); break; + } + entry.progress = completed ? 1.0f : info.file_size == 0 ? 0.0f + : std::clamp(static_cast(info.sent_bytes) / info.file_size, 0.0f, 1.0f); + entry.speed = UiText(FormatTransferRate(info.rate_bps)); + entry.size = UiText(FormatFileSize(info.sent_bytes) + " / " + FormatFileSize(info.file_size)); + component->set_entry(entry); + auto display_id = info.remote_id; + std::erase_if(display_id, [](unsigned char c) { return c < '0' || c > '9'; }); + component->set_remote_id(UiText(display_id)); + component->set_heading(UiText(info.receiving + ? localization::file_receiving_progress[localization_language_index_] + : localization::file_sending_progress[localization_language_index_])); + component->set_transferring(info.status == Status::Sending || info.status == Status::Receiving); + component->set_completed(completed); + component->set_failed(failed); + auto& window = component->window(); + const auto size = window.size(); + const float scale = std::max(window.scale_factor(), 0.01f); + if (created || std::abs(size.width / scale - layout.width) > 0.5f || + std::abs(size.height / scale - layout.height) > 0.5f) { + window.set_size(slint::LogicalSize({layout.width, layout.height})); + } + const auto position = Place(layout, screen_x, screen_y, screen_width, screen_height, slot++); + if (CanUseGlobalPointerPosition()) { + const auto current = window.position(); +#if defined(__APPLE__) + if (created || std::abs(current.x / scale - position.x) > 0.5f || + std::abs(current.y / scale - position.y) > 0.5f) + window.set_position(slint::LogicalPosition({position.x, position.y})); +#else + const auto x = static_cast(std::lround(position.x * desktop_scale)); + const auto y = static_cast(std::lround(position.y * desktop_scale)); + if (created || current.x != x || current.y != y) + window.set_position(slint::PhysicalPosition({x, y})); +#endif + } + if (!window.is_visible()) { +#if defined(__APPLE__) + // Winit creates native windows on the next event-loop turn. Wait until + // it exists so show() and focus restoration happen synchronously. + if (!window.appkit_view()) continue; +#elif _WIN32 + if (!window.win32_hwnd()) continue; +#endif +#if _WIN32 + // Tool windows stay out of Alt-Tab and must not interrupt remote input. + const auto configure = [&] { + const HWND hwnd = window.win32_hwnd(); + if (!hwnd) return; + SetWindowLongPtr(hwnd, GWL_EXSTYLE, + (GetWindowLongPtr(hwnd, GWL_EXSTYLE) | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE) & ~WS_EX_APPWINDOW); + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED | SWP_NOACTIVATE); + }; + configure(); +#endif +#if defined(__APPLE__) + ShowFileTransferNotification(window.appkit_view(), [&window] { window.show(); }); +#else + component->show(); +#endif +#if _WIN32 + configure(); +#endif + } + } + if (slot == 0) ui_->transfer_display = 0; } bool GuiApplication::ValidateSelfHostedSettingsFromUi(bool gallery_window) { @@ -4490,13 +4590,27 @@ void GuiApplication::SendKeyInput(const std::string& text, bool pressed, } } -std::string GuiApplication::OpenFileDialog(const std::string& title) { +void GuiApplication::OpenFileDialog(const std::string& title, + std::function completion) { + if (!ui_ || exit_) return; + if (ui_->file_dialog_open) { + completion({}); + return; + } + ui_->file_dialog_open = true; + std::weak_ptr lifetime = ui_->file_dialog_lifetime; + auto finished = [this, lifetime, completion = std::move(completion)](std::string path) { + // Native callbacks can arrive after cancellation during application exit. + if (lifetime.expired()) return; + ui_->file_dialog_open = false; + if (!exit_) completion(std::move(path)); + }; #if defined(__APPLE__) - return OpenNativeFileDialog(title); + OpenNativeFileDialog(title, std::move(finished)); #else const char* path = tinyfd_openFileDialog(title.c_str(), "", 0, nullptr, nullptr, 0); - return path ? path : ""; + finished(path ? path : ""); #endif } @@ -5080,36 +5194,66 @@ void GuiApplication::GalleryAction(const std::string& action, 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; + // Capture the selection before the asynchronous file picker opens. + const auto enqueue = [this, ids, action, value](const std::string& file) { + RemoteSession::FileTransferInfo queued_file; + if (action == "file") { + queued_file.file_path = PathFromUtf8(file); + const auto name = queued_file.file_path.filename().u8string(); + queued_file.file_name.assign(name.begin(), name.end()); + std::error_code ec; + if (std::filesystem::is_regular_file(queued_file.file_path, ec)) { + const auto size = std::filesystem::file_size(queued_file.file_path, ec); + if (!ec) queued_file.file_size = size; + } else { + queued_file.status = RemoteSession::FileTransferStatus::Failed; } + if (ec) queued_file.status = RemoteSession::FileTransferStatus::Failed; } - 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)); + 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); + }); + // Repeated file selections are distinct transfers, even to a busy peer. + if (pending && action != "file") continue; + SlintUi::BatchOperation operation; + operation.id = id; + operation.action = action; + operation.file = file; + if (action == "file") { + operation.file_id = file_transfer::Session::NextFileId(); + auto& notification = ui_->transfer_notifications[operation.file_id]; + notification.info = queued_file; + notification.info.file_id = operation.file_id; + notification.info.remote_id = id; + if (queued_file.status == RemoteSession::FileTransferStatus::Failed) + operation.state = OperationState::Finished; + } + ui_->batch_operations.push_back(std::move(operation)); + } + SyncGallery(); + }; + if (action == "file") { + OpenFileDialog(std::string(tr("选择分发文件", "Select a file", "Выберите файл")), + [enqueue](std::string file) { + if (!file.empty()) enqueue(file); + }); + } else { + enqueue({}); } + return; } SyncGallery(); } @@ -5525,19 +5669,9 @@ void GuiApplication::SyncGallery() { 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; - } + if (!transfers_.ProcessSelectedFile(operation.file, props, + props->file_label_, {}, operation.file_id)) + operation.state = OperationState::Finished; } operation.session = props; } @@ -5574,13 +5708,12 @@ void GuiApplication::SyncGallery() { 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) + const auto file = std::find_if(files.begin(), files.end(), [&](const auto& info) { + return info.file_id == operation.file_id; + }); + if (file == files.end() || + file->status == RemoteSession::FileTransferStatus::Completed || + file->status == RemoteSession::FileTransferStatus::Failed) operation.state = OperationState::Finished; } } @@ -5914,11 +6047,18 @@ void GuiApplication::Cleanup() { SDL_Quit(); return; } + ui_->file_dialog_lifetime.reset(); +#if defined(__APPLE__) + CancelNativeFileDialog(); +#endif SaveGalleryWindowState(); #if defined(__APPLE__) SetGalleryWindowFullscreen(false); #endif ui_->wall->hide(); + for (auto& [_, notification] : ui_->transfer_notifications) + if (notification.window) (*notification.window)->hide(); + ui_->transfer_notifications.clear(); 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 d83708a1..a790f1e9 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -8,6 +8,7 @@ #define _GUI_APPLICATION_H_ #include +#include #include #include #include @@ -90,6 +91,7 @@ private: void SyncStreamKeyboardFocus(); void SetStreamKeyboardFocus(bool focused); void SyncServerWindow(); + void SyncFileTransferNotifications(); void SetServerPanelExpanded(bool expanded); void PositionServerWindows(); #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__) @@ -117,7 +119,8 @@ private: void Cleanup(); std::shared_ptr SelectedSession(); - std::string OpenFileDialog(const std::string &title); + void OpenFileDialog(const std::string& title, + std::function completion); bool OpenUrl(const std::string &url); std::unique_ptr ui_; diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index b2931721..0bf3df38 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -330,8 +330,13 @@ struct TranslationRow { u8"Выбрать файл для отправки") \ X(file_transfer_progress, u8"文件传输进度", "File Transfer Progress", \ u8"Прогресс передачи файлов") \ + X(file_sending_progress, u8"文件发送进度", "File Sending Progress", \ + u8"Отправка файла") \ + X(file_receiving_progress, u8"文件接收进度", "File Receiving Progress", \ + u8"Получение файла") \ X(queued, u8"队列中", "Queued", u8"В очереди") \ X(sending, u8"正在传输", "Sending", u8"Передача") \ + X(receiving, u8"正在接收", "Receiving", u8"Получение") \ X(completed, u8"已完成", "Completed", u8"Завершено") \ X(failed, u8"失败", "Failed", u8"Ошибка") \ X(controller, u8"控制端:", "Controller:", u8"Контроллер:") \ diff --git a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp index 56fff86b..38464b91 100644 --- a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp +++ b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.cpp @@ -32,35 +32,42 @@ void FileTransferManager::Shutdown() { } FileTransferManager::FileTransferState& FileTransferManager::global_state() { return global_state_; } +FileTransferManager::FileTransferState& FileTransferManager::received_state() { return received_state_; } FileTransferManager::FileTransferState& FileTransferManager::state_for(const std::shared_ptr& props) { return props ? props->file_transfer_ : global_state_; } -void FileTransferManager::ProcessSelectedFile(const std::string& path, +bool FileTransferManager::ProcessSelectedFile(const std::string& path, const std::shared_ptr& props, const std::string& file_label, - const std::string& remote_id) { - if (path.empty() || stopping_ || (props && props->closing_)) return; + const std::string& remote_id, uint32_t file_id) { + if (path.empty() || stopping_ || (props && props->closing_)) return false; const auto file_path = PathFromUtf8(path); std::error_code ec; - if (!std::filesystem::is_regular_file(file_path, ec)) return; + if (!std::filesystem::is_regular_file(file_path, ec)) return false; const auto size = std::filesystem::file_size(file_path, ec); - if (ec) return; + if (ec) return false; auto& state = state_for(props); + // Allocate once at enqueue time so queued and active notifications keep the + // same identity, including repeated sends of the same path. + const auto id = file_id ? file_id : file_transfer::Session::NextFileId(); { std::lock_guard lock(state.file_transfer_list_mutex_); FileTransferState::FileTransferInfo info; const auto name = file_path.filename().u8string(); info.file_name.assign(name.begin(), name.end()); + info.remote_id = props ? props->remote_id_ : remote_id; info.file_path = file_path; info.file_size = size; + info.file_id = id; info.status = FileTransferState::FileTransferStatus::Queued; state.file_transfer_list_.push_back(std::move(info)); } state.file_transfer_window_visible_ = true; { std::lock_guard lock(state.file_queue_mutex_); - state.file_send_queue_.push({file_path, file_label, props ? props->remote_id_ : remote_id}); + state.file_send_queue_.push({file_path, file_label, props ? props->remote_id_ : remote_id, id}); } ProcessQueue(props); + return true; } void FileTransferManager::ProcessQueue(std::shared_ptr props) { @@ -79,7 +86,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { }); workers_.push_back(std::async(std::launch::async, [this, props, file] { auto& state = state_for(props); - const auto id = file_transfer::Session::NextFileId(); + const auto id = file.file_id; PeerPtr* peer; std::shared_ptr events; { @@ -97,15 +104,17 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { }, file_transfer::Session::Progress{}); state.file_sent_bytes_ = 0; state.file_total_bytes_ = 0; state.file_send_rate_bps_ = 0; state.current_file_id_ = id; + const auto started_at = std::chrono::steady_clock::now(); + auto next_rate_update = started_at + std::chrono::seconds(1); { std::lock_guard lock(state.file_transfer_mutex_); - state.file_send_start_time_ = state.file_send_last_update_time_ = std::chrono::steady_clock::now(); + state.file_send_start_time_ = state.file_send_last_update_time_ = started_at; state.file_send_last_bytes_ = 0; } { std::lock_guard lock(state.file_transfer_list_mutex_); for (auto& info : state.file_transfer_list_) { - if (info.file_path == file.file_path && info.status == FileTransferState::FileTransferStatus::Queued) { + if (info.file_id == id) { info.status = FileTransferState::FileTransferStatus::Sending; info.file_id = id; info.sent_bytes = 0; break; @@ -133,8 +142,13 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { if (stopping_ || !events->IsActive() || (props && props->closing_)) session->Cancel(); const bool pumped = session->Pump(); progress = session->Snapshot(); - if (progress.acknowledged != previous || progress.status != file_transfer::Status::Sending) { - UpdateProgress(state, props, progress); previous = progress.acknowledged; + const auto now = std::chrono::steady_clock::now(); + const bool rate_due = now >= next_rate_update; + if (progress.acknowledged != previous || rate_due || + progress.status != file_transfer::Status::Sending) { + UpdateProgress(state, progress, now); + previous = progress.acknowledged; + if (rate_due) next_rate_update = now + std::chrono::seconds(1); } if (progress.status != file_transfer::Status::Sending) break; if (!pumped) { @@ -168,29 +182,27 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { } void FileTransferManager::UpdateProgress(FileTransferState& state, - const std::shared_ptr& props, const file_transfer::SendState& progress) { + const file_transfer::SendState& progress, + std::chrono::steady_clock::time_point now) { state.file_sent_bytes_ = progress.acknowledged; state.file_total_bytes_ = progress.total; - const auto now = std::chrono::steady_clock::now(); - const uint32_t current = state.file_send_rate_bps_.load(); - uint32_t rate = current; + uint32_t rate = state.file_send_rate_bps_.load(); { std::lock_guard lock(state.file_transfer_mutex_); - if (props) { - const auto bitrate = props->net_traffic_stats_.DataOutboundBitrate(); - if (bitrate) rate = current ? static_cast(current * .7 + bitrate * .99 * .3) - : static_cast(bitrate * .99); - } else { - const double seconds = std::chrono::duration(now - state.file_send_last_update_time_).count(); - if (seconds > 0 && progress.acknowledged > state.file_send_last_bytes_) { - const double measured = (progress.acknowledged - state.file_send_last_bytes_) * 8.0 / seconds; - const auto value = static_cast((std::min)(measured, - static_cast((std::numeric_limits::max)()))); - rate = current ? static_cast(current * .7 + value * .3) : value; - } + const auto elapsed = now - state.file_send_last_update_time_; + // Progress follows every ACK; speed uses one full sample per second for + // this file. Sample even without new ACKs so a stalled transfer reads zero. + if (elapsed >= std::chrono::seconds(1)) { + const double seconds = std::chrono::duration(elapsed).count(); + const auto bytes = progress.acknowledged >= state.file_send_last_bytes_ + ? progress.acknowledged - state.file_send_last_bytes_ : 0; + const double measured = bytes * 8.0 / seconds; + rate = static_cast((std::min)(measured, + static_cast((std::numeric_limits::max)()))); + state.file_send_rate_bps_ = rate; + state.file_send_last_bytes_ = progress.acknowledged; + state.file_send_last_update_time_ = now; } - state.file_send_last_bytes_ = progress.acknowledged; state.file_send_last_update_time_ = now; } - state.file_send_rate_bps_ = rate; std::lock_guard lock(state.file_transfer_list_mutex_); for (auto& info : state.file_transfer_list_) if (info.file_id == progress.id) { info.sent_bytes = progress.acknowledged; info.file_size = progress.total; info.rate_bps = rate; @@ -213,6 +225,41 @@ void FileTransferManager::HandleAck(const char* data, size_t size, wake_.notify_all(); } +void FileTransferManager::UpdateReceiveProgress( + const file_transfer::ReceiveState& progress, + std::unordered_map& notifications, + const std::string& remote_id) { + using Status = FileTransferState::FileTransferStatus; + std::lock_guard lock(received_state_.file_transfer_list_mutex_); + auto& files = received_state_.file_transfer_list_; + auto found = notifications.find(progress.id); + if (found == notifications.end()) { + FileTransferState::FileTransferInfo info; + // Wire IDs can collide between peers or with outgoing transfers. Give each + // reception a local notification identity from the same allocator as sends. + info.file_id = file_transfer::Session::NextFileId(); + info.remote_id = remote_id; + info.receiving = true; + receive_rate_samples_.emplace(info.file_id, + ReceiveRateSample{std::chrono::steady_clock::now(), 0}); + found = notifications.emplace(progress.id, files.size()).first; + files.push_back(std::move(info)); + } + auto& info = files[found->second]; + info.file_name = progress.name; + info.file_size = progress.total; + info.sent_bytes = progress.received; + switch (progress.status) { + case file_transfer::ReceiveStatus::Receiving: info.status = Status::Receiving; break; + case file_transfer::ReceiveStatus::Completed: info.status = Status::Completed; break; + case file_transfer::ReceiveStatus::Failed: info.status = Status::Failed; break; + } + if (info.status != Status::Receiving) { + receive_rate_samples_.erase(info.file_id); + notifications.erase(found); + } +} + void FileTransferManager::Receive(const char* data, size_t size, PeerEventHandler* handler, const std::string& remote) { const auto props = owner_.FindRemoteSession(remote); @@ -235,6 +282,12 @@ void FileTransferManager::Receive(const char* data, size_t size, if (!entry.session) { entry.session = std::make_shared(); entry.handler = events; entry.default_directory = entry.session->OutputDir(); + // The transfer engine serializes progress callbacks for this receiver. + entry.session->SetOnReceiveProgress( + [this, remote, notifications = std::unordered_map{}] + (const file_transfer::ReceiveState& progress) mutable { + UpdateReceiveProgress(progress, notifications, remote); + }); entry.session->SetOnSendAck([peer, events, remote](const FileTransferAck& ack) { auto callback = events->EnterCallback(); if (!callback) return -1; @@ -285,6 +338,20 @@ void FileTransferManager::Poll() { for (const auto& [remote, receiver] : peers) receivers.push_back(receiver.session); } for (const auto& receiver : receivers) receiver->Poll(); + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(received_state_.file_transfer_list_mutex_); + for (auto& info : received_state_.file_transfer_list_) { + const auto sample = receive_rate_samples_.find(info.file_id); + if (sample == receive_rate_samples_.end()) continue; + const auto elapsed = now - sample->second.updated; + if (elapsed < std::chrono::seconds(1)) continue; + const double seconds = std::chrono::duration(elapsed).count(); + const auto bytes = info.sent_bytes >= sample->second.bytes + ? info.sent_bytes - sample->second.bytes : 0; + info.rate_bps = static_cast((std::min)(bytes * 8.0 / seconds, + static_cast((std::numeric_limits::max)()))); + sample->second = {now, info.sent_bytes}; + } } } // namespace crossdesk diff --git a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.h b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.h index 9fbd8b92..f35ed8ff 100644 --- a/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.h +++ b/apps/desktop/src/gui/features/file_transfer/file_transfer_manager.h @@ -2,6 +2,7 @@ #define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_ #include +#include #include #include #include @@ -26,9 +27,12 @@ class FileTransferManager { explicit FileTransferManager(GuiRuntime& owner); ~FileTransferManager(); FileTransferState& global_state(); + FileTransferState& received_state(); FileTransferState& state_for(const std::shared_ptr& props); - void ProcessSelectedFile(const std::string& path, const std::shared_ptr& props, - const std::string& file_label, const std::string& remote_id = ""); + // A reserved ID keeps a DeskHub notification stable while waiting for a slot. + bool ProcessSelectedFile(const std::string& path, const std::shared_ptr& props, + const std::string& file_label, const std::string& remote_id = "", + uint32_t file_id = 0); void HandleAck(const char* data, size_t size, PeerEventHandler* handler, const std::string& remote); void Receive(const char* data, size_t size, PeerEventHandler* handler, const std::string& remote); void Connected(PeerEventHandler* handler, const std::string& remote); @@ -48,8 +52,16 @@ class FileTransferManager { std::filesystem::path default_directory; }; void ProcessQueue(std::shared_ptr props); - void UpdateProgress(FileTransferState& state, const std::shared_ptr& props, - const file_transfer::SendState& progress); + void UpdateProgress(FileTransferState& state, + const file_transfer::SendState& progress, + std::chrono::steady_clock::time_point now); + void UpdateReceiveProgress(const file_transfer::ReceiveState& progress, + std::unordered_map& notifications, + const std::string& remote_id); + struct ReceiveRateSample { + std::chrono::steady_clock::time_point updated; + uint64_t bytes = 0; + }; std::mutex workers_mutex_, sessions_mutex_, wake_mutex_; std::condition_variable wake_; std::vector> workers_; @@ -59,6 +71,10 @@ class FileTransferManager { std::unordered_map> disconnected_; GuiRuntime& owner_; FileTransferState global_state_; + // Keep received files separate from outgoing queues. + FileTransferState received_state_; + // Guarded by received_state_.file_transfer_list_mutex_. + std::unordered_map receive_rate_samples_; }; } // namespace crossdesk diff --git a/apps/desktop/src/gui/features/file_transfer/transfer_notification_layout.h b/apps/desktop/src/gui/features/file_transfer/transfer_notification_layout.h new file mode 100644 index 00000000..eddedd33 --- /dev/null +++ b/apps/desktop/src/gui/features/file_transfer/transfer_notification_layout.h @@ -0,0 +1,48 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-19 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _TRANSFER_NOTIFICATION_LAYOUT_H_ +#define _TRANSFER_NOTIFICATION_LAYOUT_H_ + +#include +#include + +namespace crossdesk::transfer_notification { + +constexpr float kWidth = 360; +constexpr float kHeight = 132; +constexpr float kMargin = 16; +constexpr float kGap = 10; + +struct Layout { + float width, height; + size_t rows, columns; + size_t capacity() const { return rows * columns; } +}; + +inline Layout Fit(float width, float height) { + const float card_width = std::min(kWidth, std::max(1.0f, width - 2 * kMargin)); + const float card_height = std::min(kHeight, std::max(1.0f, height - 2 * kMargin)); + const auto count = [](float available, float card) { + return static_cast(std::max(1.0f, (available - 2 * kMargin + kGap) / (card + kGap))); + }; + return {card_width, card_height, count(height, card_height), count(width, card_width)}; +} + +struct Position { float x, y; }; + +inline Position Place(const Layout& layout, float x, float y, + float width, float height, size_t index) { + // Fill upward from the bottom-right, then continue in a column to the left. + return {x + std::max(0.0f, width - kMargin - layout.width) - + (index / layout.rows) * (layout.width + kGap), + y + std::max(0.0f, height - kMargin - layout.height) - + (index % layout.rows) * (layout.height + kGap)}; +} + +} // namespace crossdesk::transfer_notification + +#endif diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 961ba4c6..df055540 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -51,20 +51,23 @@ struct FileTransferState { std::filesystem::path file_path; std::string file_label; std::string remote_id; + uint32_t file_id = 0; }; std::queue file_send_queue_; std::mutex file_queue_mutex_; - enum class FileTransferStatus { Queued, Sending, Completed, Failed }; + enum class FileTransferStatus { Queued, Sending, Completed, Failed, Receiving }; struct FileTransferInfo { std::string file_name; + std::string remote_id; std::filesystem::path file_path; uint64_t file_size = 0; FileTransferStatus status = FileTransferStatus::Queued; uint64_t sent_bytes = 0; uint32_t file_id = 0; uint32_t rate_bps = 0; + bool receiving = false; }; std::vector file_transfer_list_; std::mutex file_transfer_list_mutex_; diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index 6133fbee..48c95277 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -4,6 +4,7 @@ export { AnnouncementItem, MessageStrings } from "message_panel.slint"; export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, 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 { FileTransferNotification } from "file_transfer_notification.slint"; export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint"; export { GalleryStrings } from "device_strings.slint"; diff --git a/apps/desktop/src/gui/ui/file_transfer_notification.slint b/apps/desktop/src/gui/ui/file_transfer_notification.slint new file mode 100644 index 00000000..dc46c1ec --- /dev/null +++ b/apps/desktop/src/gui/ui/file_transfer_notification.slint @@ -0,0 +1,88 @@ +import { FileTransferEntry } from "stream_window.slint"; +import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint"; +import { WorkbenchStyle } from "workbench_controls.slint"; + +export component FileTransferNotification inherits Window { + title: "CrossDesk"; + icon: @image-url("../../../resources/linux/crossdesk_256x256.png"); + preferred-width: 360px; + preferred-height: 132px; + no-frame: true; + always-on-top: true; + background: transparent; + default-font-size: ImGuiFontStyle.base; + in property entry; + in property heading; + in property remote-id; + in property completed; + in property failed; + in property transferring; + callback dismissed; + + DialogSurface { + width: parent.width; height: parent.height; + background: WorkbenchStyle.surface; + border-color: WorkbenchStyle.separator; + Text { + x: 16px; y: 8px; + width: parent.width - 64px - (root.remote-id == "" ? 0px : 108px); + height: 28px; + text: root.heading; color: WorkbenchStyle.text; + font-size: ImGuiFontStyle.prominent; font-weight: 600; + vertical-alignment: center; overflow: elide; + } + if root.remote-id != "": Text { + x: parent.width - 148px; y: 8px; width: 100px; height: 28px; + text: root.remote-id; + color: WorkbenchStyle.muted; + font-size: ImGuiFontStyle.body; + horizontal-alignment: right; vertical-alignment: center; overflow: elide; + } + IconButton { + x: parent.width - 40px; y: 8px; width: 28px; height: 28px; + icon: FontAwesomeIcons.xmark; icon-size: 14px; + icon-color: WorkbenchStyle.muted; + hover-background: WorkbenchStyle.hover-surface; + pressed-background: WorkbenchStyle.pressed-surface; + clicked => { root.dismissed(); } + } + Rectangle { + x: 16px; y: 40px; width: parent.width - 32px; height: 1px; + background: WorkbenchStyle.separator; + } + Text { + x: 16px; y: 47px; width: parent.width - 32px; height: 18px; + text: root.entry.name; color: WorkbenchStyle.text; + font-size: ImGuiFontStyle.base; vertical-alignment: center; overflow: elide; + } + HorizontalLayout { + x: 16px; y: 68px; width: parent.width - 32px; height: 16px; spacing: 8px; + Text { + text: root.entry.status; + color: root.failed ? #d92d20 : root.completed ? WorkbenchStyle.success + : root.transferring ? WorkbenchStyle.accent : WorkbenchStyle.muted; + font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; + } + Text { + text: root.transferring ? round(root.entry.progress * 100) + "% " + root.entry.speed : root.completed ? "100%" : ""; + color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; + horizontal-alignment: right; vertical-alignment: center; + } + } + Rectangle { + x: 16px; y: 90px; width: parent.width - 32px; height: 6px; + background: WorkbenchStyle.separator; border-radius: 3px; + Rectangle { + x: 0px; y: 0px; + width: parent.width * max(0, min(1, root.entry.progress)); height: parent.height; + background: root.failed ? #d92d20 : root.completed ? WorkbenchStyle.success : WorkbenchStyle.accent; + border-radius: 3px; + } + } + Text { + x: 16px; y: 103px; width: parent.width - 32px; height: 17px; + text: root.entry.size; color: WorkbenchStyle.muted; + font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; + } + } +} 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 b782e791..a55b3c2c 100644 --- a/apps/desktop/src/platform/macos/gui/window_drag_mac.mm +++ b/apps/desktop/src/platform/macos/gui/window_drag_mac.mm @@ -3,6 +3,9 @@ #import #include +#include +#include +#include @interface CrossDeskStreamFullscreenTarget : NSObject - (void)toggleStreamFullscreen:(id)sender; @@ -40,6 +43,24 @@ BOOL stream_restore_zoom_hidden = NO; NSApplicationPresentationOptions stream_restore_presentation_options = NSApplicationPresentationDefault; +struct FileDialogRequest { + std::string title; + std::function completion; +}; +std::shared_ptr file_dialog_request; +NSOpenPanel *file_dialog = nil; +NSOpenPanel *prepared_file_dialog = nil; +uint64_t file_dialog_prepare_generation = 0; + +NSOpenPanel *CreateFileDialog() { + NSOpenPanel *panel = [NSOpenPanel openPanel]; + panel.canChooseFiles = YES; + panel.canChooseDirectories = NO; + panel.allowsMultipleSelection = NO; + panel.resolvesAliases = YES; + return panel; +} + void InstallStreamFullscreenButton(NSWindow *window) { if (window == nil) { return; @@ -358,41 +379,109 @@ bool IsStreamWindowFullscreen() { return stream_fullscreen; } -std::string OpenNativeFileDialog(const std::string &title) { +void ShowFileTransferNotification(void* slint_view, + const std::function& show) { @autoreleasepool { - NSOpenPanel *panel = [NSOpenPanel openPanel]; - panel.canChooseFiles = YES; - panel.canChooseDirectories = NO; - panel.allowsMultipleSelection = NO; - panel.resolvesAliases = YES; + NSWindow *previous_key = NSApp.keyWindow; + NSView *view = (__bridge NSView *)slint_view; + NSWindow *notification = view.window; + notification.excludedFromWindowsMenu = YES; + notification.hidesOnDeactivate = NO; + notification.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorFullScreenAuxiliary | + NSWindowCollectionBehaviorIgnoresCycle; + show(); + // Winit's show path calls makeKeyAndOrderFront even for frameless windows. + // Restore the input owner synchronously before the next event-loop turn. + if (notification.isKeyWindow) { + if (previous_key && previous_key != notification) [previous_key makeKeyWindow]; + else [notification resignKeyWindow]; + } + } +} + +void PrepareNativeFileDialog() { + const auto generation = ++file_dialog_prepare_generation; + // Creating NSOpenPanel initializes the system file browser and can take + // hundreds of milliseconds. Do that once after the main window is visible, + // without showing a panel, changing focus, or blocking the initial frame. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC), + dispatch_get_main_queue(), ^{ + if (generation != file_dialog_prepare_generation || + file_dialog_request || prepared_file_dialog) return; + NSOpenPanel *panel = CreateFileDialog(); + if (generation == file_dialog_prepare_generation && + !file_dialog_request && !prepared_file_dialog) + prepared_file_dialog = panel; + }); +} + +void OpenNativeFileDialog(const std::string& title, + std::function completion) { + if (file_dialog_request) { + completion({}); + return; + } + const auto request = std::make_shared( + FileDialogRequest{title, std::move(completion)}); + file_dialog_request = request; + // Leave the current Slint callback before AppKit creates or shows the panel. + // runModal would let display-link callbacks reenter Slint's timer dispatch. + dispatch_async(dispatch_get_main_queue(), ^{ + if (file_dialog_request != request) return; + NSOpenPanel *panel = prepared_file_dialog ?: CreateFileDialog(); + prepared_file_dialog = nil; + file_dialog = panel; NSString *prompt = - [[NSString alloc] initWithBytes:title.data() - length:title.size() + [[NSString alloc] initWithBytes:request->title.data() + length:request->title.size() encoding:NSUTF8StringEncoding]; - if (prompt != nil && prompt.length > 0) { - panel.title = prompt; - panel.message = prompt; - } + panel.title = prompt ?: @""; + panel.message = prompt ?: @""; // tinyfiledialogs uses an external osascript process on macOS, so its // chooser can appear without becoming the active window. Keep the picker // inside CrossDesk and explicitly activate both the app and panel. [NSApp activateIgnoringOtherApps:YES]; - NSWindow *owner = stream_window ?: NSApp.keyWindow ?: NSApp.mainWindow; + NSWindow *owner = NSApp.keyWindow ?: NSApp.mainWindow; if (owner != nil) { [owner makeKeyAndOrderFront:nil]; } NSScreen *target_screen = owner.screen ?: NSScreen.mainScreen; CenterWindowOnScreen(panel, target_screen); - [panel makeKeyAndOrderFront:nil]; + __weak NSOpenPanel *weak_panel = panel; + [panel beginWithCompletionHandler:^(NSModalResponse result) { + // Cancellation can complete a request before AppKit calls this block. + if (file_dialog_request != request) return; + NSOpenPanel *finished_panel = weak_panel; + const char *selected = result == NSModalResponseOK + ? finished_panel.URL.fileSystemRepresentation : nullptr; + const std::string path = selected ? selected : ""; + [finished_panel orderOut:nil]; + file_dialog = nil; + // AppKit has returned the panel to its configuration phase. Keep the + // browser alive for the next selection instead of rebuilding it. + prepared_file_dialog = finished_panel; + file_dialog_request.reset(); + auto finished = std::move(request->completion); + finished(path); + }]; + if (file_dialog_request == request) [panel makeKeyAndOrderFront:nil]; + }); +} - if ([panel runModal] != NSModalResponseOK || panel.URL == nil) { - return {}; - } - const char *path = panel.URL.fileSystemRepresentation; - return path != nullptr ? std::string(path) : std::string{}; - } +void CancelNativeFileDialog() { + ++file_dialog_prepare_generation; + prepared_file_dialog = nil; + const auto request = std::move(file_dialog_request); + if (!request) return; + NSOpenPanel *panel = file_dialog; + file_dialog = nil; + [panel cancel:nil]; + [panel orderOut:nil]; + auto finished = std::move(request->completion); + finished({}); } } // namespace crossdesk diff --git a/apps/desktop/src/platform/window_drag.h b/apps/desktop/src/platform/window_drag.h index 9238f06b..3b825a3a 100644 --- a/apps/desktop/src/platform/window_drag.h +++ b/apps/desktop/src/platform/window_drag.h @@ -1,5 +1,6 @@ #pragma once +#include #include namespace crossdesk { @@ -42,8 +43,20 @@ bool IsStreamWindowActive(); bool SetStreamWindowFullscreen(bool fullscreen); bool IsStreamWindowFullscreen(); -// Opens an application-modal macOS file picker. The panel is made key before -// entering its modal loop so it receives keyboard input immediately. -std::string OpenNativeFileDialog(const std::string &title); +// Shows a desktop transfer notification without replacing the current key +// window, including while the stream is fullscreen. +// |slint_view| must be the notification's existing AppKit NSView*. +void ShowFileTransferNotification(void* slint_view, + const std::function& show); + +// Schedule hidden initialization after the main window's initial display. +void PrepareNativeFileDialog(); +// Opens a macOS file picker asynchronously on the main thread, without a +// nested modal loop. Completion receives an empty path on cancel or failure. +void OpenNativeFileDialog(const std::string& title, + std::function completion); +// Cancels an open or pending picker, completes it once with an empty path, and +// releases the cached panel and pending initialization during application exit. +void CancelNativeFileDialog(); } // namespace crossdesk diff --git a/apps/desktop/src/tools/file_transfer.cpp b/apps/desktop/src/tools/file_transfer.cpp index 437d48fa..58bdf644 100644 --- a/apps/desktop/src/tools/file_transfer.cpp +++ b/apps/desktop/src/tools/file_transfer.cpp @@ -16,7 +16,9 @@ FileReceiver::FileReceiver(const std::filesystem::path& output_dir) FileTransferAck ack{}; return on_send_ack_ && DecodeFileTransferAck(data, size, &ack) ? on_send_ack_(ack) : 0; - }, {}) { + }, {}, {}, [this](const file_transfer::ReceiveState& progress) { + if (on_receive_progress_) on_receive_progress_(progress); + }) { session_.SetDirectory(output_dir); } diff --git a/apps/desktop/src/tools/file_transfer.h b/apps/desktop/src/tools/file_transfer.h index 2a070cf0..993f06f8 100644 --- a/apps/desktop/src/tools/file_transfer.h +++ b/apps/desktop/src/tools/file_transfer.h @@ -18,6 +18,7 @@ namespace crossdesk { class FileReceiver { public: using OnSendAck = std::function; + using OnReceiveProgress = file_transfer::Session::ReceiveProgress; FileReceiver(); explicit FileReceiver(const std::filesystem::path& output_dir); bool OnData(const char* data, size_t size) { return session_.Receive(data, size); } @@ -25,6 +26,7 @@ class FileReceiver { void Cancel() { session_.Cancel(); } // Set once, before the receiver is published to transport callbacks. void SetOnSendAck(OnSendAck cb) { on_send_ack_ = std::move(cb); } + void SetOnReceiveProgress(OnReceiveProgress cb) { on_receive_progress_ = std::move(cb); } std::filesystem::path OutputDir() const { std::lock_guard lock(directory_mutex_); return output_dir_; @@ -40,6 +42,7 @@ class FileReceiver { mutable std::mutex directory_mutex_; std::filesystem::path output_dir_; OnSendAck on_send_ack_; + OnReceiveProgress on_receive_progress_; file_transfer::Session session_; }; diff --git a/libs/controller/include/file_transfer_core.h b/libs/controller/include/file_transfer_core.h index a0c7a795..f6db2547 100644 --- a/libs/controller/include/file_transfer_core.h +++ b/libs/controller/include/file_transfer_core.h @@ -40,6 +40,14 @@ struct SendState { Status status = Status::Idle; }; +enum class ReceiveStatus { Receiving, Completed, Failed }; +struct ReceiveState { + uint32_t id = 0; + std::string name; + uint64_t total = 0, received = 0; + ReceiveStatus status = ReceiveStatus::Receiving; +}; + class Session { public: using Clock = std::chrono::steady_clock; @@ -48,8 +56,12 @@ class Session { // outside its mutex, allowing immediate/reentrant ACKs and concurrent peers. using Progress = std::function; - Session(Send send, Progress progress, Options options = {}) - : send_(std::move(send)), progress_(std::move(progress)), options_(options) {} + // Exact accepted bytes and terminal state, keyed by this peer's wire ID. + using ReceiveProgress = std::function; + Session(Send send, Progress progress, Options options = {}, + ReceiveProgress receive_progress = {}) + : send_(std::move(send)), progress_(std::move(progress)), + receive_progress_(std::move(receive_progress)), options_(options) {} ~Session() { Clear(); } static uint32_t NextFileId() { @@ -92,6 +104,7 @@ class Session { for (auto it = incoming_.begin(); it != incoming_.end();) { if (now - it->second.updated < timeout_) { ++it; continue; } expired.push_back({kFileAckMagic, it->first, it->second.received, it->second.total, 2}); + NotifyReceive(it->first, it->second, ReceiveStatus::Failed); Notify(it->second.name, -1, false, ""); Remove(it->second); it = incoming_.erase(it); } @@ -169,6 +182,8 @@ class Session { std::lock_guard lock(mutex_); closed_ = true; if (outgoing_) FinishSend(false); + for (const auto& [id, file] : incoming_) + NotifyReceive(id, file, ReceiveStatus::Failed); Clear(); } @@ -193,13 +208,23 @@ class Session { int percent = -1; Clock::time_point updated; }; + void NotifyReceive(uint32_t id, const Incoming& file, ReceiveStatus status) { + if (receive_progress_) + receive_progress_({id, file.name, file.total, file.received, status}); + } FileTransferAck ReceiveLocked(const FileChunkView& chunk, size_t size, Clock::time_point now) { const auto& h = chunk.header; auto it = incoming_.find(h.file_id); auto reject = [&]() { if (it != incoming_.end()) { + NotifyReceive(h.file_id, it->second, ReceiveStatus::Failed); Notify(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); - } else Notify(SafeName(chunk.file_name), -1, false, ""); + } else { + if (receive_progress_) + receive_progress_({h.file_id, SafeName(chunk.file_name), h.total_size, + 0, ReceiveStatus::Failed}); + Notify(SafeName(chunk.file_name), -1, false, ""); + } return FileTransferAck{kFileAckMagic, h.file_id, h.offset, h.total_size, 2}; }; const bool first = h.flags & 1, last = h.flags & 2; @@ -248,6 +273,7 @@ class Session { if (file.output.fail() || (complete && !Publish(file))) return reject(); file.updated = now; const FileTransferAck ack{kFileAckMagic, h.file_id, file.received, file.total, complete ? 1u : 0u}; + NotifyReceive(h.file_id, file, complete ? ReceiveStatus::Completed : ReceiveStatus::Receiving); if (complete) { const auto path = file.path.u8string(); Notify(file.name, 1, false, std::string(path.begin(), path.end())); @@ -375,6 +401,7 @@ class Session { mutable std::mutex mutex_; Send send_; Progress progress_; + ReceiveProgress receive_progress_; Options options_; bool closed_ = false; SendState state_;