/* * @Author: DI JUNKUN * @Date: 2026-10-16 * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ #ifndef _FILE_TRANSFER_CORE_H_ #define _FILE_TRANSFER_CORE_H_ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace crossdesk::file_transfer { // UI, RTC peers and platform permissions stay in the adapters. Only mobile // adapters transfer ownership of disposable picker copies to this engine. struct Options { bool remove_source = false; bool receive_subdirectory = false; size_t max_incoming = 0; }; enum class Status { Idle, Sending, Completed, Failed }; struct SendState { uint32_t id = 0; uint64_t total = 0, acknowledged = 0; Status status = Status::Idle; }; class Session { public: using Clock = std::chrono::steady_clock; using Send = std::function; // Progress callbacks must not reenter the session. Transport callbacks run // outside its mutex, allowing immediate/reentrant ACKs and concurrent peers. using Progress = std::function; Session(Send send, Progress progress, Options options = {}) : send_(std::move(send)), progress_(std::move(progress)), options_(options) {} ~Session() { Clear(); } static uint32_t NextFileId() { static std::atomic next{1}; uint32_t id; do { id = next.fetch_add(1); } while (id == 0); return id; } void SetDirectory(std::filesystem::path directory) { std::lock_guard lock(mutex_); directory_ = std::move(directory); } bool Start(const std::filesystem::path& path, const std::string& name, Clock::time_point now = Clock::now(), uint32_t id = 0) { std::lock_guard lock(mutex_); if (closed_ || outgoing_ || !send_ || name.empty() || name.size() > UINT16_MAX) return false; std::error_code ec; if (!std::filesystem::is_regular_file(path, ec)) return false; const auto size = std::filesystem::file_size(path, ec); if (ec || size > INT64_MAX) return false; Outgoing file; file.input.open(path, std::ios::binary); if (!file.input) return false; file.id = id ? id : NextFileId(); file.name = name; file.path = path; file.total = size; file.updated = now; state_ = {file.id, size, 0, Status::Sending}; outgoing_ = std::move(file); Notify(name, 0, true, ""); return true; } // At most one chunk per pump; bound queued but unacknowledged bytes to 1 MiB. bool Pump(Clock::time_point now = Clock::now()) { std::vector expired; std::vector chunk; uint32_t id = 0; { std::lock_guard lock(mutex_); if (closed_) return false; 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}); Notify(it->second.name, -1, false, ""); Remove(it->second); it = incoming_.erase(it); } if (outgoing_) { auto& file = *outgoing_; if (now - file.updated >= timeout_) FinishSend(false); else if (!file.last && file.sent - file.acked < 1024 * 1024) { std::array payload{}; const auto size = static_cast((std::min)(payload.size(), file.total - file.sent)); if (size) file.input.read(payload.data(), size); if (size && file.input.gcount() != size) FinishSend(false); else { const bool first = file.sent == 0; const bool last = file.sent + size == file.total; chunk = EncodeFileChunk(file.id, file.sent, file.total, payload.data(), size, first ? &file.name : nullptr, first, last); if (chunk.empty()) FinishSend(false); else { id = file.id; // Reserve before calling transport: an ACK can arrive before Send returns. file.sent += size; file.last = last; } } } } } for (const auto& ack : expired) SendAck(ack); if (!chunk.empty() && send_(kFileStream, chunk.data(), chunk.size()) != 0) { std::lock_guard lock(mutex_); if (outgoing_ && outgoing_->id == id) FinishSend(false); } return !chunk.empty(); } void Ack(const char* data, size_t size, Clock::time_point now = Clock::now()) { FileTransferAck ack{}; if (!DecodeFileTransferAck(data, size, &ack)) return; std::lock_guard lock(mutex_); if (!outgoing_ || ack.file_id != outgoing_->id) return; auto& file = *outgoing_; if (ack.total_size != file.total || ack.acked_offset > file.sent || (ack.flags & ~3u)) return; if (ack.flags & 2) { FinishSend(false); return; } if (ack.flags & 1) { if (file.last && ack.acked_offset == file.total) { state_.acknowledged = file.total; FinishSend(true); } return; } if (ack.acked_offset <= file.acked) return; file.acked = ack.acked_offset; file.updated = now; state_.acknowledged = file.acked; Report(file.name, file.acked, file.total, true, file.percent); } bool Receive(const char* data, size_t size, Clock::time_point now = Clock::now()) { FileChunkView chunk; if (!DecodeFileChunk(data, size, &chunk)) return false; FileTransferAck ack; { std::lock_guard lock(mutex_); if (closed_) return false; ack = ReceiveLocked(chunk, size, now); } SendAck(ack); return (ack.flags & 2) == 0; } bool Sending() const { return Snapshot().status == Status::Sending; } SendState Snapshot() const { std::lock_guard lock(mutex_); return state_; } // A cancelled connection cannot be restarted by a late data callback. void Cancel() { std::lock_guard lock(mutex_); closed_ = true; if (outgoing_) FinishSend(false); Clear(); } private: struct Outgoing { uint32_t id = 0; std::string name; std::filesystem::path path; std::ifstream input; uint64_t total = 0, sent = 0, acked = 0; bool last = false; int percent = -1; Clock::time_point updated; }; struct Incoming { std::string name; std::filesystem::path path, partial, folder, directory; std::ofstream output; uint64_t total = 0, received = 0; bool first = false, last = false; std::map ranges; int percent = -1; Clock::time_point updated; }; 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()) { Notify(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); } else 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; if ((h.flags & ~3u) || h.total_size > INT64_MAX || (first && h.offset != 0) || chunk.payload_size > kFileChunkSize || size != sizeof(FileChunkHeader) + h.name_len + chunk.payload_size || (!first && h.name_len) || (!chunk.payload_size && !last) || last != (h.offset + chunk.payload_size == h.total_size) || (it == incoming_.end() && options_.max_incoming && incoming_.size() >= options_.max_incoming)) return reject(); if (it == incoming_.end()) { Incoming file; file.name = SafeName(chunk.file_name); file.total = h.total_size; file.directory = directory_.empty() ? std::filesystem::path(".") : directory_; std::error_code ec; std::filesystem::create_directories(file.directory, ec); if (ec || (directory_.empty() && options_.receive_subdirectory)) return reject(); // Reserve a unique staging directory even when two peers use the same ID. static std::atomic serial{0}; for (int attempt = 0; attempt < 100; ++attempt) { const auto token = std::to_string(h.file_id) + "-" + std::to_string(Clock::now().time_since_epoch().count()) + "-" + std::to_string(++serial); file.folder = file.directory / (options_.receive_subdirectory ? token : ".crossdesk-" + token); if (std::filesystem::create_directory(file.folder, ec)) break; if (ec || attempt == 99) return reject(); } file.path = file.folder / NamePath(file.name); file.partial = file.folder / ".partial"; file.output.open(file.partial, std::ios::binary | std::ios::trunc); if (!file.output) { Remove(file); return reject(); } it = incoming_.emplace(h.file_id, std::move(file)).first; } auto& file = it->second; if (h.total_size != file.total || (first && file.first) || !AddRange(file, h.offset, h.offset + chunk.payload_size)) return reject(); if (first) { file.first = true; file.name = SafeName(chunk.file_name); file.path = file.folder / NamePath(file.name); } file.last |= last; // KCP's RTP and timer callbacks can race: ACK only contiguous coverage. file.received = !file.ranges.empty() && file.ranges.begin()->first == 0 ? file.ranges.begin()->second : 0; const bool complete = file.first && file.last && file.received == file.total; file.output.seekp(static_cast(h.offset)); file.output.write(chunk.payload, chunk.payload_size); if (complete) { file.output.flush(); file.output.close(); } 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}; if (complete) { const auto path = file.path.u8string(); Notify(file.name, 1, false, std::string(path.begin(), path.end())); incoming_.erase(it); } else Report(file.name, file.received, file.total, false, file.percent); return ack; } bool Publish(Incoming& file) { std::error_code ec; if (options_.receive_subdirectory) { std::filesystem::rename(file.partial, file.path, ec); return !ec; } // Preserve the desktop's flat destination and timestamp suffix on conflict. // Hard-link publication is atomic and never overwrites another peer's file. const auto original = NamePath(file.name); const auto stamp = std::to_string(std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); for (unsigned attempt = 0; attempt < 1000; ++attempt) { auto name = original; if (attempt) { name = original.stem(); name += "_" + stamp; if (attempt > 1) name += "_" + std::to_string(attempt); name += original.extension(); } file.path = file.directory / name; ec.clear(); std::filesystem::create_hard_link(file.partial, file.path, ec); if (!ec) { Remove(file); return true; } if (ec == std::errc::file_exists) continue; // Some public folder providers disallow links. Exclusively create the // destination so a failed copy can remove only the file we just created. #if defined(_WIN32) std::FILE* output = _wfopen(file.path.c_str(), L"wbx"); #else std::FILE* output = std::fopen(file.path.c_str(), "wbx"); #endif if (!output) { if (errno == EEXIST) continue; return false; } std::ifstream input(file.partial, std::ios::binary); bool ok = input.is_open(); std::array buffer{}; while (ok && input) { input.read(buffer.data(), buffer.size()); const auto count = static_cast(input.gcount()); if (count && std::fwrite(buffer.data(), 1, count, output) != count) ok = false; } ok = ok && input.eof() && !input.bad(); input.close(); if (std::fclose(output) != 0) ok = false; if (!ok) { std::filesystem::remove(file.path, ec); return false; } Remove(file); return true; } return false; } static bool AddRange(Incoming& file, uint64_t start, uint64_t end) { if (start == end) return true; auto next = file.ranges.lower_bound(start); if (next != file.ranges.end() && next->first < end) return false; if (next != file.ranges.begin()) { auto previous = std::prev(next); if (previous->second > start) return false; if (previous->second == start) { start = previous->first; file.ranges.erase(previous); } } if (next != file.ranges.end() && next->first == end) { end = next->second; file.ranges.erase(next); } file.ranges.emplace(start, end); return file.ranges.size() <= 1024; } static std::filesystem::path NamePath(const std::string& name) { #if defined(__cpp_char8_t) return std::filesystem::path(std::u8string(name.begin(), name.end())); #else return std::filesystem::u8path(name); #endif } static std::string SafeName(std::string name) { const auto slash = name.find_last_of("/\\"); if (slash != std::string::npos) name.erase(0, slash + 1); for (auto& ch : name) if (static_cast(ch) < 32 || std::string(":*?\"<>|").find(ch) != std::string::npos) ch = '_'; while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back(); if (name.empty() || name == ".partial" || name.size() > 240) return "received_file"; return name; } static void Remove(Incoming& file) { if (file.output.is_open()) file.output.close(); std::error_code ec; std::filesystem::remove(file.partial, ec); std::filesystem::remove(file.folder, ec); } void SendAck(const FileTransferAck& ack) { if (!send_) return; const auto packet = EncodeFileTransferAck(ack); send_(kFileFeedbackStream, packet.data(), packet.size()); } void Notify(const std::string& name, double value, bool sending, const std::string& path) { if (progress_) progress_(name, value, sending, path); } void Report(const std::string& name, uint64_t offset, uint64_t total, bool sending, int& previous) { const double progress = total ? static_cast(offset) / total : 0; const int percent = static_cast(progress * 100); if (percent != previous) { previous = percent; Notify(name, (std::min)(progress, .99), sending, ""); } } void RemoveSource() { outgoing_->input.close(); if (options_.remove_source) { std::error_code ec; std::filesystem::remove(outgoing_->path, ec); } outgoing_.reset(); } void FinishSend(bool success) { state_.status = success ? Status::Completed : Status::Failed; Notify(outgoing_->name, success ? 1 : -1, true, ""); RemoveSource(); } void Clear() { if (outgoing_) RemoveSource(); for (auto& entry : incoming_) Remove(entry.second); incoming_.clear(); } const std::chrono::seconds timeout_{30}; mutable std::mutex mutex_; Send send_; Progress progress_; Options options_; bool closed_ = false; SendState state_; std::filesystem::path directory_; std::optional outgoing_; std::map incoming_; }; } // namespace crossdesk::file_transfer #endif