[feat] add file transfer cancellation controls

This commit is contained in:
dijunkun
2026-10-19 11:11:00 +08:00
parent b2d3834944
commit 7df0b6118e
11 changed files with 173 additions and 21 deletions
@@ -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<float>(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;
}
}
@@ -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__)
@@ -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"Устройство под управлением") \
@@ -111,11 +111,13 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> 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<RemoteSession> 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<RemoteSession> 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<RemoteSession> 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);
}
@@ -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();
@@ -56,7 +56,7 @@ struct FileTransferState {
std::queue<QueuedFile> 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;
@@ -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 <bool> completed;
in property <bool> failed;
in property <bool> transferring;
in property <bool> cancelled;
in property <bool> can-cancel;
in property <string> 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(); }
}
}
}
}
@@ -18,6 +18,9 @@ export global WorkbenchStyle {
export component WorkbenchButton inherits Rectangle {
in property <string> text;
in property <length> text-size: 13px;
in property <color> text-color: root.primary ? white : WorkbenchStyle.text;
in property <color> normal-border-color: WorkbenchStyle.control-border;
in property <string> icon;
in property <string> tooltip;
in property <bool> 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;
}
+44 -4
View File
@@ -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<std::mutex> 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<std::mutex> 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<char> packet;
{
std::lock_guard<std::mutex> 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<std::mutex> 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> outgoing_;
std::map<uint32_t, Incoming> incoming_;
std::map<uint32_t, Clock::time_point> cancelled_incoming_;
};
} // namespace crossdesk::file_transfer
+4
View File
@@ -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<char> 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<char> EncodeFileCancel(uint32_t file_id, uint64_t total_size);
std::array<char, sizeof(FileTransferAck)> EncodeFileTransferAck(
const FileTransferAck& ack);
+11
View File
@@ -39,6 +39,17 @@ std::vector<char> EncodeFileChunk(uint32_t file_id, uint64_t offset,
return output;
}
std::vector<char> 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<char> 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;