diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 504d206c..b49d4e56 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -3904,6 +3904,29 @@ void GuiApplication::SyncServerWindow() { PositionServerWindows(); } +void GuiApplication::CancelFileTransfer(uint32_t file_id) { + using Status = RemoteSession::FileTransferStatus; + const auto found = ui_->transfer_notifications.find(file_id); + if (found == ui_->transfer_notifications.end()) return; + auto& info = found->second.info; + if (info.receiving || (info.status != Status::Queued && info.status != Status::Sending)) + return; + // DeskHub tasks waiting for a concurrency slot have no sender yet. + for (auto& operation : ui_->batch_operations) { + if (operation.file_id == file_id && + operation.state == SlintUi::BatchOperation::State::Queued) { + operation.state = SlintUi::BatchOperation::State::Finished; + info.status = Status::Cancelled; + info.rate_bps = 0; + return; + } + } + if (transfers_.CancelSend(file_id)) { + info.status = Status::Cancelling; + info.rate_bps = 0; + } +} + void GuiApplication::SyncFileTransferNotifications() { using Status = RemoteSession::FileTransferStatus; using namespace transfer_notification; @@ -3966,7 +3989,7 @@ void GuiApplication::SyncFileTransferNotifications() { 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.status != Status::Cancelled) { info.status = Status::Failed; info.rate_bps = 0; } @@ -3990,6 +4013,7 @@ void GuiApplication::SyncFileTransferNotifications() { if (found->second.window) (*found->second.window)->hide(); }; (*notification.window)->on_dismissed(dismiss); + (*notification.window)->on_cancel_requested([this, id] { CancelFileTransfer(id); }); (*notification.window)->window().on_close_requested([dismiss] { dismiss(); return slint::CloseRequestResponse::KeepWindowShown; @@ -4004,6 +4028,8 @@ void GuiApplication::SyncFileTransferNotifications() { 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; + case Status::Cancelling: entry.status = UiText(localization::cancelling[localization_language_index_]); break; + case Status::Cancelled: entry.status = UiText(localization::cancelled[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); @@ -4019,6 +4045,10 @@ void GuiApplication::SyncFileTransferNotifications() { component->set_transferring(info.status == Status::Sending || info.status == Status::Receiving); component->set_completed(completed); component->set_failed(failed); + component->set_cancelled(info.status == Status::Cancelled || info.status == Status::Cancelling); + component->set_can_cancel(!info.receiving && + (info.status == Status::Queued || info.status == Status::Sending)); + component->set_cancel_text(UiText(localization::cancel[localization_language_index_])); auto& window = component->window(); const auto size = window.size(); const float scale = std::max(window.scale_factor(), 0.01f); @@ -5713,7 +5743,8 @@ void GuiApplication::SyncGallery() { }); if (file == files.end() || file->status == RemoteSession::FileTransferStatus::Completed || - file->status == RemoteSession::FileTransferStatus::Failed) + file->status == RemoteSession::FileTransferStatus::Failed || + file->status == RemoteSession::FileTransferStatus::Cancelled) operation.state = OperationState::Finished; } } diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index a790f1e9..bb3db1f2 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -92,6 +92,7 @@ private: void SetStreamKeyboardFocus(bool focused); void SyncServerWindow(); void SyncFileTransferNotifications(); + void CancelFileTransfer(uint32_t file_id); void SetServerPanelExpanded(bool expanded); void PositionServerWindows(); #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__) diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 0bf3df38..0e4b89e7 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -339,6 +339,8 @@ struct TranslationRow { X(receiving, u8"正在接收", "Receiving", u8"Получение") \ X(completed, u8"已完成", "Completed", u8"Завершено") \ X(failed, u8"失败", "Failed", u8"Ошибка") \ + X(cancelling, u8"正在取消", "Cancelling", u8"Отмена…") \ + X(cancelled, u8"已取消", "Cancelled", u8"Отменено") \ X(controller, u8"控制端:", "Controller:", u8"Контроллер:") \ X(server_controlled, u8"被控中", "Controlled", u8"Под управлением") \ X(server_heading, u8"此设备被控中", "This device is controlled", 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 38464b91..cd5e7f4e 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 @@ -111,11 +111,13 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { state.file_send_start_time_ = state.file_send_last_update_time_ = started_at; state.file_send_last_bytes_ = 0; } + bool cancelled = false; { std::lock_guard lock(state.file_transfer_list_mutex_); for (auto& info : state.file_transfer_list_) { if (info.file_id == id) { - info.status = FileTransferState::FileTransferStatus::Sending; + cancelled = info.status == FileTransferState::FileTransferStatus::Cancelled; + if (!cancelled) info.status = FileTransferState::FileTransferStatus::Sending; info.file_id = id; info.sent_bytes = 0; break; } @@ -128,7 +130,7 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { const auto closed = disconnected_.find(events.get()); const bool disconnected = closed != disconnected_.end() && (closed->second.count("") || closed->second.count(file.remote_id)); - if (!stopping_ && peer && events && events->IsActive() && !disconnected && + if (!cancelled && !stopping_ && peer && events && events->IsActive() && !disconnected && (!props || !props->closing_) && !file.remote_id.empty()) { started = session->Start(file.file_path, std::string(name.begin(), name.end()), file_transfer::Session::Clock::now(), id); @@ -136,10 +138,19 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { } } uint64_t previous = UINT64_MAX; - auto progress = file_transfer::SendState{id, 0, 0, file_transfer::Status::Failed}; + auto progress = file_transfer::SendState{id, 0, 0, + cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed}; if (started) { do { - if (stopping_ || !events->IsActive() || (props && props->closing_)) session->Cancel(); + { + std::lock_guard lock(state.file_transfer_list_mutex_); + for (const auto& info : state.file_transfer_list_) if (info.file_id == id) { + cancelled = info.status == FileTransferState::FileTransferStatus::Cancelling; + break; + } + } + if (cancelled) session->CancelSend(); + else if (stopping_ || !events->IsActive() || (props && props->closing_)) session->Cancel(); const bool pumped = session->Pump(); progress = session->Snapshot(); const auto now = std::chrono::steady_clock::now(); @@ -164,8 +175,14 @@ void FileTransferManager::ProcessQueue(std::shared_ptr props) { { std::lock_guard lock(state.file_transfer_list_mutex_); for (auto& info : state.file_transfer_list_) if (info.file_id == id) { + const bool cancelled = progress.status == file_transfer::Status::Cancelled || + info.status == FileTransferState::FileTransferStatus::Cancelled || + info.status == FileTransferState::FileTransferStatus::Cancelling; info.status = progress.status == file_transfer::Status::Completed - ? FileTransferState::FileTransferStatus::Completed : FileTransferState::FileTransferStatus::Failed; + ? FileTransferState::FileTransferStatus::Completed + : cancelled ? FileTransferState::FileTransferStatus::Cancelled + : FileTransferState::FileTransferStatus::Failed; + info.rate_bps = 0; if (info.status == FileTransferState::FileTransferStatus::Completed) info.sent_bytes = info.file_size; break; } @@ -210,6 +227,30 @@ void FileTransferManager::UpdateProgress(FileTransferState& state, } } +bool FileTransferManager::CancelSend(uint32_t file_id) { + using Status = FileTransferState::FileTransferStatus; + const auto cancel = [file_id](FileTransferState& state) { + std::lock_guard lock(state.file_transfer_list_mutex_); + for (auto& info : state.file_transfer_list_) { + if (info.file_id != file_id || info.receiving) continue; + if (info.status != Status::Queued && info.status != Status::Sending) return false; + info.status = info.status == Status::Queued ? Status::Cancelled : Status::Cancelling; + info.rate_bps = 0; + return true; + } + return false; + }; + bool cancelled = cancel(global_state_); + if (!cancelled) { + std::shared_lock lock(owner_.remote_sessions_mutex_); + for (const auto& [_, props] : owner_.remote_sessions_) { + if (props && cancel(props->file_transfer_)) { cancelled = true; break; } + } + } + if (cancelled) wake_.notify_all(); + return cancelled; +} + void FileTransferManager::HandleAck(const char* data, size_t size, PeerEventHandler* handler, const std::string& remote) { FileTransferAck ack{}; @@ -253,8 +294,10 @@ void FileTransferManager::UpdateReceiveProgress( 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; + case file_transfer::ReceiveStatus::Cancelled: info.status = Status::Cancelled; break; } if (info.status != Status::Receiving) { + info.rate_bps = 0; receive_rate_samples_.erase(info.file_id); notifications.erase(found); } 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 f35ed8ff..16e14724 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 @@ -37,6 +37,7 @@ class FileTransferManager { void Receive(const char* data, size_t size, PeerEventHandler* handler, const std::string& remote); void Connected(PeerEventHandler* handler, const std::string& remote); void Cancel(PeerEventHandler* handler, const std::string& remote = ""); + bool CancelSend(uint32_t file_id); void Poll(); void Shutdown(); diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index df055540..80e0053e 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -56,7 +56,7 @@ struct FileTransferState { std::queue file_send_queue_; std::mutex file_queue_mutex_; - enum class FileTransferStatus { Queued, Sending, Completed, Failed, Receiving }; + enum class FileTransferStatus { Queued, Sending, Completed, Failed, Receiving, Cancelling, Cancelled }; struct FileTransferInfo { std::string file_name; diff --git a/apps/desktop/src/gui/ui/file_transfer_notification.slint b/apps/desktop/src/gui/ui/file_transfer_notification.slint index dc46c1ec..1f47f702 100644 --- a/apps/desktop/src/gui/ui/file_transfer_notification.slint +++ b/apps/desktop/src/gui/ui/file_transfer_notification.slint @@ -1,6 +1,6 @@ import { FileTransferEntry } from "stream_window.slint"; import { DialogSurface, FontAwesomeIcons, IconButton, ImGuiFontStyle } from "common.slint"; -import { WorkbenchStyle } from "workbench_controls.slint"; +import { WorkbenchStyle, WorkbenchButton } from "workbench_controls.slint"; export component FileTransferNotification inherits Window { title: "CrossDesk"; @@ -17,7 +17,11 @@ export component FileTransferNotification inherits Window { in property completed; in property failed; in property transferring; + in property cancelled; + in property can-cancel; + in property cancel-text; callback dismissed; + callback cancel-requested; DialogSurface { width: parent.width; height: parent.height; @@ -75,14 +79,26 @@ export component FileTransferNotification inherits Window { 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; + background: root.failed ? #d92d20 : root.completed ? WorkbenchStyle.success + : root.cancelled ? WorkbenchStyle.muted : 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; + HorizontalLayout { + x: 16px; y: 100px; width: parent.width - 32px; height: 24px; spacing: 12px; + Text { + text: root.entry.size; color: WorkbenchStyle.muted; + font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; + horizontal-stretch: 1; + } + if root.can-cancel: WorkbenchButton { + width: 72px; height: 24px; horizontal-stretch: 0; + text: root.cancel-text; + text-size: ImGuiFontStyle.base; + text-color: WorkbenchStyle.muted; + normal-border-color: WorkbenchStyle.border; + clicked => { root.cancel-requested(); } + } } } } diff --git a/apps/desktop/src/gui/ui/workbench_controls.slint b/apps/desktop/src/gui/ui/workbench_controls.slint index 52acac65..4f494603 100644 --- a/apps/desktop/src/gui/ui/workbench_controls.slint +++ b/apps/desktop/src/gui/ui/workbench_controls.slint @@ -18,6 +18,9 @@ export global WorkbenchStyle { export component WorkbenchButton inherits Rectangle { in property text; + in property text-size: 13px; + in property text-color: root.primary ? white : WorkbenchStyle.text; + in property normal-border-color: WorkbenchStyle.control-border; in property icon; in property tooltip; in property tooltip-above: false; @@ -45,7 +48,7 @@ export component WorkbenchButton inherits Rectangle { border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent) : root.primary ? WorkbenchStyle.accent : touch.has-hover ? WorkbenchStyle.hover-border - : root.quiet ? transparent : WorkbenchStyle.control-border; + : root.quiet ? transparent : root.normal-border-color; background: root.soft ? (root.primary ? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff) @@ -77,9 +80,9 @@ export component WorkbenchButton inherits Rectangle { } if root.text != "": Text { text: root.text; - font-size: 13px; + font-size: root.text-size; font-weight: root.primary ? 600 : 500; - color: root.primary ? white : WorkbenchStyle.text; + color: root.text-color; vertical-alignment: center; accessible-role: none; } diff --git a/libs/controller/include/file_transfer_core.h b/libs/controller/include/file_transfer_core.h index f6db2547..6ec77056 100644 --- a/libs/controller/include/file_transfer_core.h +++ b/libs/controller/include/file_transfer_core.h @@ -33,14 +33,14 @@ struct Options { size_t max_incoming = 0; }; -enum class Status { Idle, Sending, Completed, Failed }; +enum class Status { Idle, Sending, Completed, Failed, Cancelled }; struct SendState { uint32_t id = 0; uint64_t total = 0, acknowledged = 0; Status status = Status::Idle; }; -enum class ReceiveStatus { Receiving, Completed, Failed }; +enum class ReceiveStatus { Receiving, Completed, Failed, Cancelled }; struct ReceiveState { uint32_t id = 0; std::string name; @@ -101,6 +101,10 @@ class Session { { std::lock_guard lock(mutex_); if (closed_) return false; + for (auto it = cancelled_incoming_.begin(); it != cancelled_incoming_.end();) { + if (now - it->second >= timeout_) it = cancelled_incoming_.erase(it); + else ++it; + } 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}); @@ -177,6 +181,20 @@ class Session { std::lock_guard lock(mutex_); return state_; } + // Stop only the outgoing file. Keep the peer usable for subsequent sends. + // The adapter calls this between pumps; late/reordered chunks are discarded + // by the receiver's cancellation tombstone. + bool CancelSend() { + std::vector packet; + { + std::lock_guard lock(mutex_); + if (!outgoing_) return false; + packet = EncodeFileCancel(outgoing_->id, outgoing_->total); + FinishSend(false, true); + } + if (send_) send_(kFileStream, packet.data(), packet.size()); + return true; + } // A cancelled connection cannot be restarted by a late data callback. void Cancel() { std::lock_guard lock(mutex_); @@ -215,6 +233,25 @@ class Session { FileTransferAck ReceiveLocked(const FileChunkView& chunk, size_t size, Clock::time_point now) { const auto& h = chunk.header; auto it = incoming_.find(h.file_id); + if (h.flags == kFileCancelFlag) { + const FileTransferAck ack{kFileAckMagic, h.file_id, 0, h.total_size, 2}; + if (size != sizeof(FileChunkHeader) || h.offset || h.name_len || + h.chunk_size || h.total_size > INT64_MAX || + (it != incoming_.end() && h.total_size != it->second.total)) + return ack; + cancelled_incoming_[h.file_id] = now; + if (it != incoming_.end()) { + NotifyReceive(h.file_id, it->second, ReceiveStatus::Cancelled); + Notify(it->second.name, -1, false, ""); + Remove(it->second); + incoming_.erase(it); + } + return ack; + } + // KCP receive callbacks can be reordered. Do not recreate a cancelled + // staging file or a second notification when an in-flight chunk arrives. + if (cancelled_incoming_.count(h.file_id)) + return {kFileAckMagic, h.file_id, 0, h.total_size, 2}; auto reject = [&]() { if (it != incoming_.end()) { NotifyReceive(h.file_id, it->second, ReceiveStatus::Failed); @@ -387,8 +424,9 @@ class Session { } outgoing_.reset(); } - void FinishSend(bool success) { - state_.status = success ? Status::Completed : Status::Failed; + void FinishSend(bool success, bool cancelled = false) { + state_.status = success ? Status::Completed + : cancelled ? Status::Cancelled : Status::Failed; Notify(outgoing_->name, success ? 1 : -1, true, ""); RemoveSource(); } @@ -396,6 +434,7 @@ class Session { if (outgoing_) RemoveSource(); for (auto& entry : incoming_) Remove(entry.second); incoming_.clear(); + cancelled_incoming_.clear(); } const std::chrono::seconds timeout_{30}; mutable std::mutex mutex_; @@ -408,6 +447,7 @@ class Session { std::filesystem::path directory_; std::optional outgoing_; std::map incoming_; + std::map cancelled_incoming_; }; } // namespace crossdesk::file_transfer diff --git a/libs/wire/include/file_transfer_format.h b/libs/wire/include/file_transfer_format.h index cdfd66a4..6a8edc77 100644 --- a/libs/wire/include/file_transfer_format.h +++ b/libs/wire/include/file_transfer_format.h @@ -17,6 +17,7 @@ namespace crossdesk { inline constexpr uint32_t kFileChunkMagic = 0x4A4E544D; // 'JNTM' inline constexpr uint32_t kFileAckMagic = 0x4A4E5443; // 'JNTC' +inline constexpr uint8_t kFileCancelFlag = 0x04; #pragma pack(push, 1) struct FileChunkHeader { @@ -59,6 +60,9 @@ std::vector EncodeFileChunk(uint32_t file_id, uint64_t offset, bool DecodeFileChunk(const char* data, std::size_t size, FileChunkView* output); +// A header-only cancellation travels on the same reliable stream as chunks. +std::vector EncodeFileCancel(uint32_t file_id, uint64_t total_size); + std::array EncodeFileTransferAck( const FileTransferAck& ack); diff --git a/libs/wire/src/file_transfer.cpp b/libs/wire/src/file_transfer.cpp index eaff0df8..76098513 100644 --- a/libs/wire/src/file_transfer.cpp +++ b/libs/wire/src/file_transfer.cpp @@ -39,6 +39,17 @@ std::vector EncodeFileChunk(uint32_t file_id, uint64_t offset, return output; } +std::vector EncodeFileCancel(uint32_t file_id, uint64_t total_size) { + FileChunkHeader header{}; + header.magic = kFileChunkMagic; + header.file_id = file_id; + header.total_size = total_size; + header.flags = kFileCancelFlag; + std::vector output(sizeof(header)); + std::memcpy(output.data(), &header, sizeof(header)); + return output; +} + bool DecodeFileChunk(const char* data, std::size_t size, FileChunkView* output) { if (!data || !output || size < sizeof(FileChunkHeader)) return false;