[feat] unify persistent file transfer notifications

This commit is contained in:
dijunkun
2026-10-19 10:53:55 +08:00
parent 7662b80995
commit b2d3834944
14 changed files with 735 additions and 230 deletions
@@ -17,6 +17,7 @@
#include <ctime>
#include <filesystem>
#include <fstream>
#include <map>
#include <memory>
#include <mutex>
#include <nlohmann/json.hpp>
@@ -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<RemoteSession> session;
std::chrono::steady_clock::time_point started{};
};
@@ -994,8 +997,13 @@ struct GuiApplication::SlintUi {
std::make_shared<slint::VectorModel<ui::StreamTab>>();
std::shared_ptr<slint::VectorModel<slint::SharedString>> display_model =
std::make_shared<slint::VectorModel<slint::SharedString>>();
std::shared_ptr<slint::VectorModel<ui::FileTransferEntry>> transfer_model =
std::make_shared<slint::VectorModel<ui::FileTransferEntry>>();
struct TransferNotification {
std::optional<slint::ComponentHandle<ui::FileTransferNotification>> window;
RemoteSession::FileTransferInfo info;
bool dismissed = false;
};
std::map<uint32_t, TransferNotification> transfer_notifications;
SDL_DisplayID transfer_display = 0;
std::shared_ptr<slint::VectorModel<ui::NetworkStatsRow>> stats_model =
std::make_shared<slint::VectorModel<ui::NetworkStatsRow>>();
std::shared_ptr<slint::VectorModel<ui::ControllerEntry>> controller_model =
@@ -1015,6 +1023,8 @@ struct GuiApplication::SlintUi {
std::vector<std::string> recent_filter_groups;
slint::Timer timer;
slint::Timer server_file_dialog_timer;
std::shared_ptr<void> file_dialog_lifetime = std::make_shared<int>(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<RemoteSession::FileTransferInfo> file_list;
{
std::lock_guard lock(props->file_transfer_.file_transfer_list_mutex_);
file_list = props->file_transfer_.file_transfer_list_;
}
const auto transfer_priority = [](RemoteSession::FileTransferStatus status) {
switch (status) {
case RemoteSession::FileTransferStatus::Sending:
return 0;
case RemoteSession::FileTransferStatus::Completed:
return 1;
case RemoteSession::FileTransferStatus::Queued:
return 2;
case RemoteSession::FileTransferStatus::Failed:
return 3;
}
return 3;
};
std::stable_sort(file_list.begin(), file_list.end(),
[&](const auto& left, const auto& right) {
return transfer_priority(left.status) <
transfer_priority(right.status);
});
std::vector<ui::FileTransferEntry> transfers;
transfers.reserve(file_list.size());
for (const auto& info : file_list) {
ui::FileTransferEntry item;
item.name = UiText(info.file_name);
const auto status_index = static_cast<int>(info.status);
if (status_index == 0)
item.status = UiText(localization::queued[localization_language_index_]);
if (status_index == 1)
item.status = UiText(localization::sending[localization_language_index_]);
if (status_index == 2)
item.status =
UiText(localization::completed[localization_language_index_]);
if (status_index == 3)
item.status = UiText(localization::failed[localization_language_index_]);
item.progress = status_index == 2 ? 1.0f
: info.file_size == 0
? 0.0f
: std::clamp(static_cast<float>(info.sent_bytes) /
static_cast<float>(info.file_size),
0.0f, 1.0f);
item.speed = UiText(FormatTransferRate(info.rate_bps));
item.size = UiText(FormatFileSize(info.file_size));
transfers.push_back(std::move(item));
}
ui::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<float>(sent) /
static_cast<float>(total),
0.0f, 1.0f));
(*ui_->server)
->set_file_progress_text(
UiText(FormatTransferRate(transfer.file_send_rate_bps_.load())));
std::string current_file_name;
const uint32_t current_file_id = transfer.current_file_id_.load();
if (current_file_id != 0) {
std::lock_guard lock(transfer.file_transfer_list_mutex_);
for (const auto& info : transfer.file_transfer_list_) {
if (info.file_id == current_file_id) {
current_file_name = info.file_name;
break;
}
(*ui_->server)->set_sending_file(transfers_.global_state().file_sending_.load());
PositionServerWindows();
}
void GuiApplication::SyncFileTransferNotifications() {
using Status = RemoteSession::FileTransferStatus;
using namespace transfer_notification;
std::map<uint32_t, RemoteSession::FileTransferInfo> 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<uint32_t> 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<float>(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<int32_t>(std::lround(position.x * desktop_scale));
const auto y = static_cast<int32_t>(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<void(std::string)> completion) {
if (!ui_ || exit_) return;
if (ui_->file_dialog_open) {
completion({});
return;
}
ui_->file_dialog_open = true;
std::weak_ptr<void> 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();
@@ -8,6 +8,7 @@
#define _GUI_APPLICATION_H_
#include <chrono>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
@@ -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<RemoteSession> SelectedSession();
std::string OpenFileDialog(const std::string &title);
void OpenFileDialog(const std::string& title,
std::function<void(std::string)> completion);
bool OpenUrl(const std::string &url);
std::unique_ptr<SlintUi> ui_;
@@ -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"Контроллер:") \
@@ -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<RemoteSession>& 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<RemoteSession>& 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<RemoteSession> props) {
@@ -79,7 +86,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> 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<PeerEventHandler> events;
{
@@ -97,15 +104,17 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> 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<RemoteSession> 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<RemoteSession> props) {
}
void FileTransferManager::UpdateProgress(FileTransferState& state,
const std::shared_ptr<RemoteSession>& 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<uint32_t>(current * .7 + bitrate * .99 * .3)
: static_cast<uint32_t>(bitrate * .99);
} else {
const double seconds = std::chrono::duration<double>(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<uint32_t>((std::min)(measured,
static_cast<double>((std::numeric_limits<uint32_t>::max)())));
rate = current ? static_cast<uint32_t>(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<double>(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<uint32_t>((std::min)(measured,
static_cast<double>((std::numeric_limits<uint32_t>::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<uint32_t, size_t>& 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<FileReceiver>(); 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<uint32_t, size_t>{}]
(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<double>(elapsed).count();
const auto bytes = info.sent_bytes >= sample->second.bytes
? info.sent_bytes - sample->second.bytes : 0;
info.rate_bps = static_cast<uint32_t>((std::min)(bytes * 8.0 / seconds,
static_cast<double>((std::numeric_limits<uint32_t>::max)())));
sample->second = {now, info.sent_bytes};
}
}
} // namespace crossdesk
@@ -2,6 +2,7 @@
#define CROSSDESK_GUI_FILE_TRANSFER_MANAGER_H_
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <future>
#include <memory>
@@ -26,9 +27,12 @@ class FileTransferManager {
explicit FileTransferManager(GuiRuntime& owner);
~FileTransferManager();
FileTransferState& global_state();
FileTransferState& received_state();
FileTransferState& state_for(const std::shared_ptr<RemoteSession>& props);
void ProcessSelectedFile(const std::string& path, const std::shared_ptr<RemoteSession>& 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<RemoteSession>& 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<RemoteSession> props);
void UpdateProgress(FileTransferState& state, const std::shared_ptr<RemoteSession>& 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<uint32_t, size_t>& 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<std::future<void>> workers_;
@@ -59,6 +71,10 @@ class FileTransferManager {
std::unordered_map<PeerEventHandler*, std::set<std::string>> 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<uint32_t, ReceiveRateSample> receive_rate_samples_;
};
} // namespace crossdesk
@@ -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 <algorithm>
#include <cstddef>
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<size_t>(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
@@ -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<QueuedFile> 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<FileTransferInfo> file_transfer_list_;
std::mutex file_transfer_list_mutex_;
@@ -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";
@@ -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 <FileTransferEntry> entry;
in property <string> heading;
in property <string> remote-id;
in property <bool> completed;
in property <bool> failed;
in property <bool> 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;
}
}
}
@@ -3,6 +3,9 @@
#import <AppKit/AppKit.h>
#include <cmath>
#include <cstdint>
#include <memory>
#include <utility>
@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<void(std::string)> completion;
};
std::shared_ptr<FileDialogRequest> 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<void()>& 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<void(std::string)> completion) {
if (file_dialog_request) {
completion({});
return;
}
const auto request = std::make_shared<FileDialogRequest>(
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
+16 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include <functional>
#include <string>
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<void()>& 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<void(std::string)> 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
+3 -1
View File
@@ -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);
}
+3
View File
@@ -18,6 +18,7 @@ namespace crossdesk {
class FileReceiver {
public:
using OnSendAck = std::function<int(const FileTransferAck&)>;
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_;
};
+30 -3
View File
@@ -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<void(const std::string&, double, bool,
const std::string&)>;
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<void(const ReceiveState&)>;
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<std::mutex> 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_;