From 5066fcda4801b7d18a2fe012d901bfc7b7c89148 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Thu, 18 Dec 2025 18:30:51 +0800 Subject: [PATCH] [feat] implement file transfer module --- src/gui/panels/remote_peer_panel.cpp | 1 + src/gui/render.cpp | 1 + src/gui/render.h | 2 + src/gui/render_callback.cpp | 16 +- src/gui/toolbars/control_bar.cpp | 27 +++ src/tools/file_transfer.cpp | 286 +++++++++++++++++++++++++++ src/tools/file_transfer.h | 99 ++++++++++ xmake.lua | 8 +- 8 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 src/tools/file_transfer.cpp create mode 100644 src/tools/file_transfer.h diff --git a/src/gui/panels/remote_peer_panel.cpp b/src/gui/panels/remote_peer_panel.cpp index 7c7eaa4..26c5b47 100644 --- a/src/gui/panels/remote_peer_panel.cpp +++ b/src/gui/panels/remote_peer_panel.cpp @@ -207,6 +207,7 @@ int Render::ConnectTo(const std::string& remote_id, const char* password, } AddAudioStream(props->peer_, props->audio_label_.c_str()); AddDataStream(props->peer_, props->data_label_.c_str()); + AddDataStream(props->peer_, props->file_label_.c_str()); props->connection_status_ = ConnectionStatus::Connecting; diff --git a/src/gui/render.cpp b/src/gui/render.cpp index 56bf940..d4e938c 100644 --- a/src/gui/render.cpp +++ b/src/gui/render.cpp @@ -714,6 +714,7 @@ int Render::CreateConnectionPeer() { AddAudioStream(peer_, audio_label_.c_str()); AddDataStream(peer_, data_label_.c_str()); + AddDataStream(peer_, file_label_.c_str()); return 0; } else { return -1; diff --git a/src/gui/render.h b/src/gui/render.h index 917ec0f..c677425 100644 --- a/src/gui/render.h +++ b/src/gui/render.h @@ -45,6 +45,7 @@ class Render { PeerPtr* peer_ = nullptr; std::string audio_label_ = "control_audio"; std::string data_label_ = "control_data"; + std::string file_label_ = "file"; std::string local_id_ = ""; std::string remote_id_ = ""; bool exit_ = false; @@ -464,6 +465,7 @@ class Render { std::string video_secondary_label_ = "secondary_display"; std::string audio_label_ = "audio"; std::string data_label_ = "data"; + std::string file_label_ = "file"; Params params_; SDL_AudioDeviceID input_dev_; SDL_AudioDeviceID output_dev_; diff --git a/src/gui/render_callback.cpp b/src/gui/render_callback.cpp index 96259b6..fe687a1 100644 --- a/src/gui/render_callback.cpp +++ b/src/gui/render_callback.cpp @@ -1,7 +1,11 @@ +#include #include +#include +#include #include #include "device_controller.h" +#include "file_transfer.h" #include "localization.h" #include "platform.h" #include "rd_log.h" @@ -304,6 +308,12 @@ void Render::OnReceiveDataBufferCb(const char* data, size_t size, return; } + // try to parse as file-transfer chunk first + static FileReceiver receiver; + if (receiver.OnData(data, size)) { + return; + } + std::string json_str(data, size); RemoteAction remote_action; @@ -485,12 +495,12 @@ void Render::OnConnectionStatusCb(ConnectionStatus status, const char* user_id, render->need_to_send_host_info_ = true; render->start_screen_capturer_ = true; render->start_speaker_capturer_ = true; - // #ifdef CROSSDESK_DEBUG +#ifdef CROSSDESK_DEBUG render->start_mouse_controller_ = false; render->start_keyboard_capturer_ = false; - // #else +#else render->start_mouse_controller_ = true; - // #endif +#endif if (std::all_of(render->connection_status_.begin(), render->connection_status_.end(), [](const auto& kv) { return kv.first.find("web") != std::string::npos; diff --git a/src/gui/toolbars/control_bar.cpp b/src/gui/toolbars/control_bar.cpp index b29326c..d6924ee 100644 --- a/src/gui/toolbars/control_bar.cpp +++ b/src/gui/toolbars/control_bar.cpp @@ -1,3 +1,9 @@ +#include +#include +#include +#include + +#include "file_transfer.h" #include "layout.h" #include "localization.h" #include "rd_log.h" @@ -197,6 +203,27 @@ int Render::ControlBar(std::shared_ptr& props) { std::string path = OpenFileDialog(title); if (!path.empty()) { LOG_INFO("Selected file: {}", path.c_str()); + + // Send selected file over file data channel in a background thread. + auto peer = props->peer_; + std::filesystem::path file_path = std::filesystem::path(path); + std::string file_label = file_label_; + + std::thread([peer, file_path, file_label]() { + FileSender sender; + int ret = sender.SendFile( + file_path, file_path.filename().string(), + [peer, file_label](const char* buf, size_t sz) -> int { + return SendDataFrame(peer, buf, sz, file_label.c_str(), true); + }); + + if (ret != 0) { + LOG_ERROR("FileSender::SendFile failed for [{}], ret={}", + file_path.string().c_str(), ret); + } else { + LOG_INFO("File send finished: {}", file_path.string().c_str()); + } + }).detach(); } } diff --git a/src/tools/file_transfer.cpp b/src/tools/file_transfer.cpp new file mode 100644 index 0000000..5cfd381 --- /dev/null +++ b/src/tools/file_transfer.cpp @@ -0,0 +1,286 @@ +#include "file_transfer.h" + +#include +#include + +#include "rd_log.h" + +namespace crossdesk { + +namespace { +std::atomic g_next_file_id{1}; +constexpr uint32_t kFileChunkMagic = 0x4A4E544D; // 'JNTM' +} // namespace + +uint32_t FileSender::NextFileId() { return g_next_file_id.fetch_add(1); } + +int FileSender::SendFile(const std::filesystem::path& path, + const std::string& label, const SendFunc& send, + std::size_t chunk_size) { + if (!send) { + LOG_ERROR("FileSender::SendFile: send function is empty"); + return -1; + } + + std::error_code ec; + if (!std::filesystem::exists(path, ec) || + !std::filesystem::is_regular_file(path, ec)) { + LOG_ERROR("FileSender::SendFile: file [{}] not found or not regular", + path.string().c_str()); + return -1; + } + + uint64_t total_size = std::filesystem::file_size(path, ec); + if (ec) { + LOG_ERROR("FileSender::SendFile: failed to get size of [{}]: {}", + path.string().c_str(), ec.message().c_str()); + return -1; + } + + std::ifstream ifs(path, std::ios::binary); + if (!ifs.is_open()) { + LOG_ERROR("FileSender::SendFile: failed to open [{}]", + path.string().c_str()); + return -1; + } + + const uint32_t file_id = NextFileId(); + uint64_t offset = 0; + bool is_first = true; + std::string file_name = label.empty() ? path.filename().string() : label; + + std::vector buffer; + buffer.resize(chunk_size); + + while (ifs && offset < total_size) { + uint64_t remaining = total_size - offset; + uint32_t to_read = + static_cast(std::min(remaining, chunk_size)); + + ifs.read(buffer.data(), static_cast(to_read)); + std::streamsize bytes_read = ifs.gcount(); + if (bytes_read <= 0) { + break; + } + + bool is_last = (offset + static_cast(bytes_read) >= total_size); + const std::string* name_ptr = is_first ? &file_name : nullptr; + + std::vector chunk = BuildChunk( + file_id, offset, total_size, buffer.data(), + static_cast(bytes_read), name_ptr, is_first, is_last); + + int ret = send(chunk.data(), chunk.size()); + if (ret != 0) { + LOG_ERROR("FileSender::SendFile: send failed for [{}], ret={}", + path.string().c_str(), ret); + return ret; + } + + offset += static_cast(bytes_read); + is_first = false; + } + + return 0; +} + +std::vector FileSender::BuildChunk(uint32_t file_id, uint64_t offset, + uint64_t total_size, const char* data, + uint32_t data_size, + const std::string* file_name, + bool is_first, bool is_last) { + FileChunkHeader header{}; + header.magic = kFileChunkMagic; + header.file_id = file_id; + header.offset = offset; + header.total_size = total_size; + header.chunk_size = data_size; + header.name_len = + (file_name && is_first) ? static_cast(file_name->size()) : 0; + header.flags = 0; + if (is_first) header.flags |= 0x01; + if (is_last) header.flags |= 0x02; + + std::size_t total_size_bytes = + sizeof(FileChunkHeader) + header.name_len + header.chunk_size; + + std::vector buffer; + buffer.resize(total_size_bytes); + + std::size_t offset_bytes = 0; + memcpy(buffer.data() + offset_bytes, &header, sizeof(FileChunkHeader)); + offset_bytes += sizeof(FileChunkHeader); + + if (header.name_len > 0 && file_name) { + memcpy(buffer.data() + offset_bytes, file_name->data(), header.name_len); + offset_bytes += header.name_len; + } + + if (header.chunk_size > 0 && data) { + memcpy(buffer.data() + offset_bytes, data, header.chunk_size); + } + + return buffer; +} + +// ---------- FileReceiver ---------- + +FileReceiver::FileReceiver() : output_dir_(GetDefaultDesktopPath()) {} + +FileReceiver::FileReceiver(const std::filesystem::path& output_dir) + : output_dir_(output_dir) { + std::error_code ec; + if (!output_dir_.empty()) { + std::filesystem::create_directories(output_dir_, ec); + if (ec) { + LOG_ERROR("FileReceiver: failed to create output dir [{}]: {}", + output_dir_.string().c_str(), ec.message().c_str()); + } + } +} + +std::filesystem::path FileReceiver::GetDefaultDesktopPath() { +#ifdef _WIN32 + const char* home_env = std::getenv("USERPROFILE"); +#else + const char* home_env = std::getenv("HOME"); +#endif + if (!home_env) { + return std::filesystem::path{}; + } + + std::filesystem::path desktop_path = + std::filesystem::path(home_env) / "Desktop"; + + std::error_code ec; + std::filesystem::create_directories(desktop_path, ec); + if (ec) { + LOG_ERROR("FileReceiver: failed to create desktop directory [{}]: {}", + desktop_path.string().c_str(), ec.message().c_str()); + } + + return desktop_path; +} + +bool FileReceiver::OnData(const char* data, size_t size) { + if (!data || size < sizeof(FileChunkHeader)) { + LOG_ERROR("FileReceiver::OnData: invalid buffer"); + return false; + } + + FileChunkHeader header{}; + memcpy(&header, data, sizeof(FileChunkHeader)); + + if (header.magic != kFileChunkMagic) { + return false; + } + + std::size_t header_and_name = + sizeof(FileChunkHeader) + static_cast(header.name_len); + if (size < header_and_name || + size < header_and_name + static_cast(header.chunk_size)) { + LOG_ERROR("FileReceiver::OnData: buffer too small for header + payload"); + return false; + } + + const char* name_ptr = data + sizeof(FileChunkHeader); + std::string file_name; + const std::string* file_name_ptr = nullptr; + if (header.name_len > 0) { + file_name.assign(name_ptr, + name_ptr + static_cast(header.name_len)); + file_name_ptr = &file_name; + } + + const char* payload = data + header_and_name; + std::size_t payload_size = + static_cast(header.chunk_size); // may be 0 + + return HandleChunk(header, payload, payload_size, file_name_ptr); +} + +bool FileReceiver::HandleChunk(const FileChunkHeader& header, + const char* payload, size_t payload_size, + const std::string* file_name) { + auto it = contexts_.find(header.file_id); + if (it == contexts_.end()) { + // new file context must start with first chunk. + if ((header.flags & 0x01) == 0) { + LOG_ERROR("FileReceiver: received non-first chunk for unknown file_id={}", + header.file_id); + return false; + } + + FileContext ctx; + ctx.total_size = header.total_size; + + std::string filename; + if (file_name && !file_name->empty()) { + filename = *file_name; + } else { + filename = "received_" + std::to_string(header.file_id); + } + + ctx.file_name = filename; + + std::filesystem::path save_path = output_dir_.empty() + ? std::filesystem::path(filename) + : output_dir_ / filename; + + // if file exists, append timestamp. + std::error_code ec; + if (std::filesystem::exists(save_path, ec)) { + auto now = std::chrono::system_clock::now(); + auto ts = std::chrono::duration_cast( + now.time_since_epoch()) + .count(); + save_path = save_path.parent_path() / + (save_path.stem().string() + "_" + std::to_string(ts) + + save_path.extension().string()); + } + + ctx.ofs.open(save_path, std::ios::binary | std::ios::trunc); + if (!ctx.ofs.is_open()) { + LOG_ERROR("FileReceiver: failed to open [{}] for writing", + save_path.string().c_str()); + return false; + } + + contexts_.emplace(header.file_id, std::move(ctx)); + it = contexts_.find(header.file_id); + } + + FileContext& ctx = it->second; + + if (payload_size > 0 && payload) { + ctx.ofs.seekp(static_cast(header.offset), std::ios::beg); + ctx.ofs.write(payload, static_cast(payload_size)); + if (!ctx.ofs.good()) { + LOG_ERROR("FileReceiver: write failed for file_id={}", header.file_id); + return false; + } + ctx.received += static_cast(payload_size); + } + + bool is_last = (header.flags & 0x02) != 0; + if (is_last || ctx.received >= ctx.total_size) { + ctx.ofs.close(); + + std::filesystem::path saved_path = + output_dir_.empty() ? std::filesystem::path(ctx.file_name) + : output_dir_ / ctx.file_name; + + LOG_INFO("FileReceiver: file received complete, file_id={}, size={}", + header.file_id, ctx.received); + + if (on_file_complete_) { + on_file_complete_(saved_path); + } + + contexts_.erase(header.file_id); + } + + return true; +} + +} // namespace crossdesk diff --git a/src/tools/file_transfer.h b/src/tools/file_transfer.h new file mode 100644 index 0000000..5a001ce --- /dev/null +++ b/src/tools/file_transfer.h @@ -0,0 +1,99 @@ +/* + * @Author: DI JUNKUN + * @Date: 2025-12-18 + * Copyright (c) 2025 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _FILE_TRANSFER_H_ +#define _FILE_TRANSFER_H_ + +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk { + +#pragma pack(push, 1) +struct FileChunkHeader { + uint32_t magic; // magic to identify file-transfer chunks + uint32_t file_id; // unique id per file transfer + uint64_t offset; // offset in file + uint64_t total_size; // total file size + uint32_t chunk_size; // payload size in this chunk + uint16_t name_len; // filename length (bytes), only set on first chunk + uint8_t flags; // bit0: is_first, bit1: is_last, others reserved +}; +#pragma pack(pop) + +class FileSender { + public: + using SendFunc = std::function; + + public: + FileSender() = default; + + // generate a new file id + static uint32_t NextFileId(); + + // synchronously send a file using the provided send function. + // `path` : full path to the local file. + // `label` : logical filename to send (usually path.filename()). + // `send` : callback that pushes one encoded chunk into the data channel. + // Return 0 on success, <0 on error. + int SendFile(const std::filesystem::path& path, const std::string& label, + const SendFunc& send, std::size_t chunk_size = 64 * 1024); + + // build a single encoded chunk buffer according to FileChunkHeader protocol. + static std::vector BuildChunk(uint32_t file_id, uint64_t offset, + uint64_t total_size, const char* data, + uint32_t data_size, + const std::string* file_name, + bool is_first, bool is_last); +}; + +class FileReceiver { + public: + struct FileContext { + std::string file_name; + uint64_t total_size = 0; + uint64_t received = 0; + std::ofstream ofs; + }; + + using OnFileComplete = + std::function; + + public: + // save to default desktop directory. + FileReceiver(); + + // save to a specified directory. + explicit FileReceiver(const std::filesystem::path& output_dir); + + // process one received data buffer (one chunk). + // return true if parsed and processed successfully, false otherwise. + bool OnData(const char* data, size_t size); + + void SetOnFileComplete(OnFileComplete cb) { on_file_complete_ = cb; } + + const std::filesystem::path& OutputDir() const { return output_dir_; } + + private: + static std::filesystem::path GetDefaultDesktopPath(); + + bool HandleChunk(const FileChunkHeader& header, const char* payload, + size_t payload_size, const std::string* file_name); + + private: + std::filesystem::path output_dir_; + std::unordered_map contexts_; + OnFileComplete on_file_complete_ = nullptr; +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/xmake.lua b/xmake.lua index 3b19f54..9af381b 100644 --- a/xmake.lua +++ b/xmake.lua @@ -169,13 +169,19 @@ target("version_checker") add_files("src/version_checker/*.cpp") add_includedirs("src/version_checker", {public = true}) +target("tools") + set_kind("object") + add_deps("rd_log") + add_files("src/tools/*.cpp") + add_includedirs("src/tools", {public = true}) + target("gui") set_kind("object") add_packages("libyuv", "tinyfiledialogs") add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"") add_deps("rd_log", "common", "assets", "config_center", "minirtc", "path_manager", "screen_capturer", "speaker_capturer", - "device_controller", "thumbnail", "version_checker") + "device_controller", "thumbnail", "version_checker", "tools") add_files("src/gui/*.cpp", "src/gui/panels/*.cpp", "src/gui/toolbars/*.cpp", "src/gui/windows/*.cpp") add_includedirs("src/gui", "src/gui/panels", "src/gui/toolbars",