[feat] add dual-pane file transfers and directory browsing, refs #135

This commit is contained in:
dijunkun
2026-11-05 15:27:53 +08:00
parent 6f7c5a29ab
commit 751cf04434
26 changed files with 2624 additions and 87 deletions
@@ -0,0 +1,566 @@
#include "features/file_transfer/file_browser.h"
#include <file_transfer_core.h>
#include <algorithm>
#include <cctype>
#include <charconv>
#include <cstdlib>
#include <ctime>
#include <fstream>
#include <set>
#include <stdexcept>
#include "filesystem_utf8.h"
namespace crossdesk {
namespace {
using Json = FileBrowser::Json;
namespace fs = std::filesystem;
constexpr size_t kPageEntries = 512;
constexpr size_t kPageBytes = 45000;
std::string Utf8(const fs::path& path) {
const auto value = path.u8string();
return {value.begin(), value.end()};
}
fs::path Home() {
#ifdef _WIN32
const auto* home = std::getenv("USERPROFILE");
#else
const auto* home = std::getenv("HOME");
#endif
return home ? PathFromUtf8(home) : fs::current_path();
}
Json Failure(const std::string& reason) { return {{"ok", false}, {"error", reason}}; }
template <typename FileTime>
auto SystemTime(FileTime time, FileTime file_now, std::chrono::system_clock::time_point system_now) {
using Clock = typename FileTime::clock;
if constexpr (requires { Clock::to_sys(time); })
return std::chrono::time_point_cast<std::chrono::system_clock::duration>(Clock::to_sys(time));
else
return std::chrono::time_point_cast<std::chrono::system_clock::duration>(time - file_now + system_now);
}
std::pair<std::string, size_t> ParseCursor(const std::string& cursor) {
const auto split = cursor.find(':');
if (cursor.size() > 64 || split == std::string::npos || !split)
throw std::runtime_error("invalid_listing");
size_t offset = 0;
const auto end = cursor.data() + cursor.size();
const auto parsed = std::from_chars(cursor.data() + split + 1, end, offset);
if (parsed.ec != std::errc{} || parsed.ptr != end)
throw std::runtime_error("invalid_listing");
return {cursor.substr(0, split), offset};
}
Json Page(const Json& listing, size_t offset, const std::string& snapshot) {
const auto& entries = listing.at("entries");
if (offset > entries.size()) throw std::runtime_error("invalid_listing");
Json page{{"ok", true}, {"path", listing.at("path")}, {"parent", listing.at("parent")},
{"entries", Json::array()}, {"offset", offset}, {"total", entries.size()}};
size_t next = offset, bytes = 0;
while (next < entries.size() && next - offset < kPageEntries) {
const auto entry_bytes = entries[next].dump(-1, ' ', false, Json::error_handler_t::replace).size() + 1;
if (entry_bytes > kPageBytes) throw std::runtime_error("invalid_listing");
if (bytes + entry_bytes > kPageBytes) break;
page["entries"].push_back(entries[next++]);
bytes += entry_bytes;
}
page["truncated"] = next < entries.size();
page["next"] = next < entries.size() && !snapshot.empty()
? snapshot + ":" + std::to_string(next) : "";
return page;
}
std::string Text(const Json& value, const char* key) {
const auto& field = value.at(key);
if (!field.is_string()) throw std::runtime_error("invalid_request");
auto text = field.get<std::string>();
if (text.size() > 4096 || text.find('\0') != std::string::npos)
throw std::runtime_error("invalid_path");
return text;
}
fs::path ExistingDirectory(const std::string& path) {
if (path.empty()) throw std::runtime_error("invalid_path");
auto input = PathFromUtf8(path);
if (!input.is_absolute()) throw std::runtime_error("invalid_path");
std::error_code ec;
auto result = fs::canonical(input, ec);
if (ec || !fs::is_directory(result, ec)) throw std::runtime_error("directory_unavailable");
return result;
}
fs::path Destination(const fs::path& root, const std::string& relative) {
auto path = PathFromUtf8(relative);
if (path.is_absolute() || path.has_root_name()) throw std::runtime_error("invalid_path");
auto result = root;
for (const auto& component : path) {
if (component == "." || component == "..") throw std::runtime_error("invalid_path");
result /= component;
std::error_code ec;
// Do not follow links within a transferred folder hierarchy.
if (fs::is_symlink(fs::symlink_status(result, ec))) throw std::runtime_error("symbolic_link");
ec.clear();
fs::create_directory(result, ec);
if (ec && ec != std::errc::file_exists) throw std::runtime_error("directory_not_writable");
if (!fs::is_directory(result)) throw std::runtime_error("directory_not_writable");
}
return result;
}
}
FileBrowser::FileBrowser(Send send, Enqueue enqueue, Reserve reserve, Cancel cancel)
: send_(std::move(send)), enqueue_(std::move(enqueue)), reserve_(std::move(reserve)), cancel_(std::move(cancel)),
worker_([this] { Run(); }) {}
FileBrowser::~FileBrowser() { Stop(); }
void FileBrowser::Stop() {
{ std::lock_guard lock(mutex_); stopping_ = true; jobs_.clear();
for (const auto& [_, listing] : listings_) listing->cancelled = true;
listings_.clear();
}
wake_.notify_all();
if (worker_.joinable()) worker_.join();
}
void FileBrowser::Post(const std::string& peer, std::function<void()> work) {
std::lock_guard lock(mutex_);
if (stopping_ || jobs_.size() >= 512) return;
const auto generation = generations_[peer];
jobs_.push_back([this, peer, generation, work = std::move(work)] {
{ std::lock_guard lock(mutex_);
if (stopping_ || generations_[peer] != generation) return;
}
active_peer_ = peer; active_generation_ = generation;
work();
});
wake_.notify_one();
}
void FileBrowser::Run() {
for (;;) {
std::function<void()> work;
{
std::unique_lock lock(mutex_);
wake_.wait_for(lock, std::chrono::milliseconds(250), [this] { return stopping_ || !jobs_.empty(); });
if (stopping_) return;
if (!jobs_.empty()) { work = std::move(jobs_.front()); jobs_.pop_front(); }
}
try { if (work) work(); } catch (...) { /* Invalid peer data cannot kill the worker. */ }
std::erase_if(directory_snapshots_, [](const auto& entry) {
return Clock::now() >= entry.second.expires;
});
for (auto it = pending_.begin(); it != pending_.end();) {
if (Clock::now() < it->second.deadline) { ++it; continue; }
auto pending = std::move(it->second); it = pending_.erase(it);
if (Current(pending.peer, pending.generation)) {
active_peer_ = pending.peer; active_generation_ = pending.generation;
pending.complete(Failure("timeout"));
}
}
}
}
bool FileBrowser::Current(const std::string& peer, uint64_t generation) {
std::lock_guard lock(mutex_);
return !stopping_ && generations_[peer] == generation;
}
void FileBrowser::Report(const std::string& peer, Json result, bool notify_peer) {
if (!Current(peer, active_generation_)) return;
Publish(peer, 0, result);
if (notify_peer) {
result["op"] = "notice"; result["id"] = next_id_++;
SendMessage(peer, std::move(result));
}
}
bool FileBrowser::SendMessage(const std::string& peer, Json message) {
{ std::lock_guard lock(mutex_);
if (stopping_ || (peer == active_peer_ && generations_[peer] != active_generation_)) return false;
}
message["type"] = "file_browser"; message["version"] = 1;
return send_(peer, message);
}
void FileBrowser::Publish(const std::string& peer, uint32_t id, Json result) {
std::lock_guard lock(mutex_);
auto& events = events_[peer];
if (id) {
for (auto& event : events) {
if (event.id == id && event.result.value("loading", false)) {
event.result = std::move(result);
return;
}
}
}
if (events.size() < 512) events.push_back({id, std::move(result)});
}
std::vector<FileBrowser::Event> FileBrowser::TakeEvents(const std::string& peer) {
std::lock_guard lock(mutex_);
std::vector<Event> result; result.swap(events_[peer]); return result;
}
uint64_t FileBrowser::Generation(const std::string& peer) {
std::lock_guard lock(mutex_);
const auto found = generations_.find(peer);
return found == generations_.end() ? 0 : found->second;
}
void FileBrowser::Disconnect(const std::string& peer) {
{
std::lock_guard lock(mutex_);
++generations_[peer]; events_.erase(peer);
std::erase_if(listings_, [&](auto& entry) {
if (entry.second->peer != peer) return false;
entry.second->cancelled = true;
return true;
});
}
Post(peer, [this, peer] {
std::erase_if(pending_, [&](const auto& entry) { return entry.second.peer == peer; });
std::erase_if(directory_snapshots_, [&](const auto& entry) { return entry.first.first == peer; });
Publish(peer, 0, Failure("disconnected"));
});
}
void FileBrowser::Request(const std::string& peer, Json message,
std::function<void(const Json&)> complete) {
if (pending_.size() >= 256) { complete(Failure("busy")); return; }
const auto id = next_id_++;
message["id"] = id;
pending_.emplace(id, Pending{peer, active_generation_, Clock::now() + std::chrono::seconds(12), std::move(complete)});
if (!SendMessage(peer, std::move(message))) {
auto done = std::move(pending_.at(id).complete); pending_.erase(id);
done(Failure("disconnected"));
}
}
uint32_t FileBrowser::List(const std::string& peer, const std::string& path, bool local) {
const auto id = next_id_++;
auto listing = std::make_shared<Listing>();
listing->id = id; listing->peer = peer; listing->path = path;
{
std::lock_guard lock(mutex_);
listing->generation = generations_[peer];
listings_[id] = listing;
}
Post(peer, [this, listing, local] {
if (listing->cancelled) return;
if (local) FinishList(listing, Directory(listing->path, [listing] { return listing->cancelled.load(); }));
else ListPage(listing);
});
return id;
}
void FileBrowser::CancelList(const std::string& peer, uint32_t id) {
std::lock_guard lock(mutex_);
const auto found = listings_.find(id);
if (found != listings_.end() && found->second->peer == peer) {
found->second->cancelled = true;
listings_.erase(found);
}
if (const auto events = events_.find(peer); events != events_.end())
std::erase_if(events->second, [id](const auto& event) { return event.id == id; });
}
void FileBrowser::FinishList(const std::shared_ptr<Listing>& listing, Json result) {
std::lock_guard lock(mutex_);
if (listing->cancelled || stopping_ || generations_[listing->peer] != listing->generation) return;
listings_.erase(listing->id);
auto& events = events_[listing->peer];
std::erase_if(events, [&](const auto& event) { return event.id == listing->id; });
if (events.size() < 512) events.push_back({listing->id, std::move(result)});
}
void FileBrowser::ReleaseList(const std::string& peer, const std::string& cursor) {
if (!cursor.empty()) Request(peer, {{"op", "list_cancel"}, {"cursor", cursor}}, [](const Json&) {});
}
void FileBrowser::ListPage(const std::shared_ptr<Listing>& listing) {
if (listing->cancelled) { ReleaseList(listing->peer, listing->cursor); return; }
Json request{{"op", "list"}, {"path", listing->path}, {"pagination", 1}};
if (!listing->cursor.empty()) request["cursor"] = listing->cursor;
Request(listing->peer, std::move(request), [this, listing](const Json& page) {
std::string next;
try {
next = page.value("next", std::string{});
if (listing->cancelled) { ReleaseList(listing->peer, next.empty() ? listing->cursor : next); return; }
if (!page.value("ok", false)) {
ReleaseList(listing->peer, listing->cursor);
FinishList(listing, page); return;
}
const auto& entries = page.at("entries");
if (!entries.is_array() || entries.size() > 2000 || next.size() > 64)
throw std::runtime_error("invalid_listing");
if ((page.contains("offset") && !page["offset"].is_number_unsigned()) ||
(page.contains("total") && !page["total"].is_number_unsigned()) ||
(!next.empty() && !page.value("truncated", false)))
throw std::runtime_error("invalid_listing");
if (page.value("truncated", false) && next.empty())
throw std::runtime_error("pagination_unsupported");
const bool first = listing->result.is_null();
const size_t offset = first ? 0 : listing->result.at("entries").size();
if (page.value("offset", offset) != offset ||
(!first && (page.at("path") != listing->result.at("path") ||
page.at("parent") != listing->result.at("parent") ||
page.at("total") != listing->result.at("total"))))
throw std::runtime_error("invalid_listing");
const size_t total = page.value("total", entries.size());
if (total < offset || entries.size() > total - offset ||
(next.empty() ? offset + entries.size() != total : entries.empty() || offset + entries.size() >= total))
throw std::runtime_error("invalid_listing");
if (!next.empty() && ParseCursor(next).second != offset + entries.size())
throw std::runtime_error("invalid_listing");
if (first) {
listing->result = {{"ok", true}, {"path", page.at("path")}, {"parent", page.at("parent")},
{"entries", Json::array()}, {"total", total}, {"truncated", false}};
}
auto& all = listing->result["entries"].get_ref<Json::array_t&>();
all.insert(all.end(), entries.begin(), entries.end());
if (next.empty()) { FinishList(listing, std::move(listing->result)); return; }
listing->cursor = next;
Publish(listing->peer, listing->id, {{"ok", true}, {"loading", true},
{"loaded", all.size()}, {"total", total}});
// Yield between pages so newer navigation and transfer work can run.
Post(listing->peer, [this, listing] { ListPage(listing); });
} catch (const std::exception& error) {
ReleaseList(listing->peer, next.empty() ? listing->cursor : next);
FinishList(listing, Failure(std::string(error.what()) == "pagination_unsupported"
? "pagination_unsupported" : "invalid_listing"));
}
});
}
void FileBrowser::Copy(const std::string& peer, const std::vector<std::string>& paths,
const std::string& destination, bool download) {
Post(peer, [this, peer, paths, destination, download] {
if (download) Request(peer, {{"op", "copy"}, {"paths", paths}, {"destination", destination}},
[this, peer](const Json& result) { Publish(peer, 0, result); });
else Upload(peer, paths, destination);
});
}
void FileBrowser::CancelTransfer(const std::string& peer, uint32_t id) {
Post(peer, [this, peer, id] {
Request(peer, {{"op", "cancel"}, {"file_id", id}}, [this, peer](const Json& result) {
Publish(peer, 0, result);
});
});
}
void FileBrowser::Handle(const std::string& peer, Json message) {
Post(peer, [this, peer, message = std::move(message)] {
try {
if (message.at("version") != 1 || !message.at("id").is_number_unsigned()) return;
const auto wide_id = message.at("id").get<uint64_t>();
if (!wide_id || wide_id > UINT32_MAX) return;
const auto id = static_cast<uint32_t>(wide_id);
if (message.value("op", "") == "notice") {
if (message.at("ok").is_boolean()) Publish(peer, 0, message);
return;
}
if (message.value("op", "") == "reply") {
const auto it = pending_.find(id);
if (it == pending_.end() || it->second.peer != peer || !message.at("ok").is_boolean()) return;
const auto generation = it->second.generation;
auto done = std::move(it->second.complete); pending_.erase(it);
if (!Current(peer, generation)) return;
active_generation_ = generation;
done(message);
} else Serve(peer, message);
} catch (...) { }
});
}
FileBrowser::Json FileBrowser::Directory(const std::string& value, const std::function<bool()>& cancelled) {
try {
Json entries = Json::array();
if (value == "@roots") {
#ifdef _WIN32
for (char drive = 'A'; drive <= 'Z'; ++drive) {
const std::string root = std::string(1, drive) + ":\\";
std::error_code ec;
if (fs::is_directory(PathFromUtf8(root), ec))
entries.push_back({{"name", root}, {"path", root}, {"directory", true}, {"size", 0}, {"modified", ""}, {"link", false}});
}
#else
entries.push_back({{"name", "/"}, {"path", "/"}, {"directory", true}, {"size", 0}, {"modified", ""}, {"link", false}});
#endif
return {{"ok", true}, {"path", value}, {"parent", ""}, {"entries", entries}, {"truncated", false}};
}
auto path = ExistingDirectory(value.empty() ? Utf8(Home()) : value);
std::error_code ec;
fs::directory_iterator it(path, ec), end;
if (ec) return Failure("directory_unavailable");
// Reuse one clock offset for every entry so equal timestamps stay equal.
const auto file_now = fs::file_time_type::clock::now();
const auto system_now = std::chrono::system_clock::now();
for (; it != end; it.increment(ec)) {
if (cancelled && cancelled()) return Failure("cancelled");
if (ec) return Failure("directory_unavailable");
const auto name = Utf8(it->path().filename());
if (name.rfind(".crossdesk-", 0) == 0) continue;
std::error_code error;
const auto status = it->symlink_status(error);
if (error) continue;
const bool link = fs::is_symlink(status);
const bool directory = it->is_directory(error);
if (!directory && !link && !fs::is_regular_file(status)) continue;
uint64_t size = 0;
if (!directory && !link) { size = it->file_size(error); if (error) size = 0; }
std::string modified;
Json modified_ms = nullptr;
const auto time = it->last_write_time(error);
if (!error) {
const auto system_time = SystemTime(time, file_now, system_now);
modified_ms = std::chrono::duration_cast<std::chrono::milliseconds>(system_time.time_since_epoch()).count();
const auto stamp = std::chrono::system_clock::to_time_t(system_time);
std::tm tm{};
#ifdef _WIN32
localtime_s(&tm, &stamp);
#else
localtime_r(&stamp, &tm);
#endif
char buffer[32]; std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm);
modified = buffer;
}
Json entry{{"name", name}, {"directory", directory}, {"size", size}, {"modified", modified},
{"modified_ms", modified_ms}, {"link", link}};
entries.push_back(std::move(entry));
}
if (ec) return Failure("directory_unavailable");
std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) {
if (a["directory"] != b["directory"]) return a["directory"] == true;
auto first = a["name"].template get<std::string>(), second = b["name"].template get<std::string>();
const auto lower = [](unsigned char c) { return std::tolower(c); };
auto folded_first = first, folded_second = second;
std::transform(folded_first.begin(), folded_first.end(), folded_first.begin(), lower);
std::transform(folded_second.begin(), folded_second.end(), folded_second.begin(), lower);
return folded_first == folded_second ? first < second : folded_first < folded_second;
});
return {{"ok", true}, {"path", Utf8(path)}, {"parent", Utf8(path.parent_path())},
{"entries", std::move(entries)}, {"truncated", false}};
} catch (...) { return Failure("directory_unavailable"); }
}
FileBrowser::Json FileBrowser::DirectoryPage(const std::string& peer, const Json& message) {
const auto now = Clock::now();
const auto cursor = message.value("cursor", std::string{});
if (!cursor.empty()) {
const auto [token, offset] = ParseCursor(cursor);
const auto found = directory_snapshots_.find({peer, token});
if (found == directory_snapshots_.end() || found->second.generation != active_generation_ ||
now >= found->second.expires) return Failure("listing_expired");
auto page = Page(found->second.listing, offset, token);
if (page.at("next") == "") directory_snapshots_.erase(found);
else found->second.expires = now + std::chrono::seconds(30);
return page;
}
const auto generation = active_generation_;
auto listing = Directory(Text(message, "path"), [this, &peer, generation] { return !Current(peer, generation); });
if (!listing.value("ok", false)) return listing;
const bool paginated = message.value("pagination", Json{}) == 1;
const auto token = paginated ? std::to_string(next_snapshot_++) : "";
auto page = Page(listing, 0, token);
if (paginated && page.at("next") != "") {
const auto snapshots = std::count_if(directory_snapshots_.begin(), directory_snapshots_.end(),
[&](const auto& entry) { return entry.first.first == peer; });
if (snapshots >= 16) return Failure("busy");
directory_snapshots_.emplace(std::make_pair(peer, token),
DirectorySnapshot{std::move(listing), active_generation_, Clock::now() + std::chrono::seconds(30)});
}
return page;
}
void FileBrowser::Serve(const std::string& peer, const Json& message) {
Json result;
try {
const auto op = Text(message, "op");
if (op == "list") result = DirectoryPage(peer, message);
else if (op == "list_cancel") {
const auto [token, _] = ParseCursor(Text(message, "cursor"));
directory_snapshots_.erase({peer, token});
result = {{"ok", true}};
}
else if (op == "prepare") {
auto root = ExistingDirectory(Text(message, "destination"));
const auto& items = message.at("items");
if (!items.is_array() || items.empty() || items.size() > 64) throw std::runtime_error("invalid_request");
for (const auto& item : items) {
if (!Current(peer, active_generation_)) return;
if (!item.at("file_id").is_number_unsigned()) throw std::runtime_error("invalid_request");
const auto id = item.at("file_id").get<uint64_t>();
if (id > UINT32_MAX) throw std::runtime_error("invalid_request");
auto dir = Destination(root, Text(item, "directory"));
if (id && !reserve_(peer, static_cast<uint32_t>(id), dir)) throw std::runtime_error("directory_not_writable");
}
result = {{"ok", true}};
} else if (op == "cancel") {
const auto& id = message.at("file_id");
if (!id.is_number_unsigned() || id.get<uint64_t>() > UINT32_MAX || !id.get<uint32_t>())
throw std::runtime_error("invalid_request");
if (!cancel_ || !cancel_(peer, id.get<uint32_t>())) throw std::runtime_error("already_finished");
result = {{"ok", true}, {"message", "cancelling"}};
} else if (op == "copy") {
const auto& paths = message.at("paths");
if (!paths.is_array() || paths.empty() || paths.size() > 256) throw std::runtime_error("invalid_request");
std::vector<std::string> sources;
for (const auto& path : paths) sources.push_back(Text(Json{{"path", path}}, "path"));
Upload(peer, sources, Text(message, "destination"), true);
result = {{"ok", true}, {"message", "queued"}};
} else throw std::runtime_error("unsupported");
} catch (const std::exception& error) { result = Failure(error.what()); }
result["op"] = "reply"; result["id"] = message.at("id");
SendMessage(peer, std::move(result));
}
void FileBrowser::Upload(const std::string& peer, const std::vector<std::string>& paths,
const std::string& destination, bool notify_peer) {
std::vector<UploadItem> files;
try {
if (paths.empty() || paths.size() > 256 || destination.empty()) throw std::runtime_error("invalid_request");
std::set<fs::path> visited;
const auto add = [&](const fs::path& path, const fs::path& relative) {
if (!visited.insert(path).second) return;
if (files.size() >= 4096) throw std::runtime_error("too_many_files");
const auto status = fs::symlink_status(path);
if (fs::is_symlink(status)) throw std::runtime_error("symbolic_link");
if (fs::is_directory(status)) files.push_back({0, "", Utf8(relative)});
else if (fs::is_regular_file(status)) {
std::ifstream input(path, std::ios::binary);
if (!input) throw std::runtime_error("file_unreadable");
files.push_back({file_transfer::Session::NextFileId(), Utf8(path), Utf8(relative.parent_path())});
} else throw std::runtime_error("unsupported_file");
};
// Validate the entire selection before creating remote folders or sending.
for (const auto& source : paths) {
auto path = PathFromUtf8(source);
if (!path.is_absolute() || path.filename().empty()) throw std::runtime_error("invalid_path");
add(path, path.filename());
if (fs::is_directory(fs::symlink_status(path))) {
for (const auto& entry : fs::recursive_directory_iterator(path)) {
{ std::lock_guard lock(mutex_); if (stopping_ || generations_[peer] != active_generation_) return; }
add(entry.path(), entry.path().lexically_relative(path.parent_path()));
}
}
}
auto job = std::make_shared<UploadJob>();
job->files = std::move(files); job->destination = destination; job->notify_peer = notify_peer;
UploadNext(peer, job);
} catch (const std::exception& error) {
Publish(peer, 0, Failure(error.what()));
throw;
}
}
void FileBrowser::UploadNext(const std::string& peer, const std::shared_ptr<UploadJob>& job) {
if (!Current(peer, active_generation_)) return;
if (job->next == job->files.size()) {
Report(peer, {{"ok", true}, {"message", "queued"}}, job->notify_peer);
return;
}
const auto start = job->next;
Json items = Json::array();
size_t bytes = job->destination.size();
while (job->next < job->files.size() && items.size() < 32) {
const auto& file = job->files[job->next];
Json item{{"file_id", file.id}, {"directory", file.directory}};
const auto size = item.dump().size();
if (!items.empty() && bytes + size > 44000) break;
bytes += size; items.push_back(std::move(item)); ++job->next;
}
const auto end = job->next;
// Only one preparation batch per operation is in flight. Large folder
// copies do not exhaust pending requests or reserve thousands of paths.
Request(peer, {{"op", "prepare"}, {"destination", job->destination}, {"items", items}},
[this, peer, job, start, end](const Json& reply) {
if (!reply.value("ok", false)) { Report(peer, reply, job->notify_peer); return; }
for (auto index = start; index < end; ++index) {
if (!Current(peer, active_generation_)) return;
const auto& file = job->files[index];
if (file.id && !enqueue_(peer, file.path, file.id)) {
Report(peer, Failure("file_unreadable"), job->notify_peer); return;
}
}
UploadNext(peer, job);
});
}
} // namespace crossdesk
@@ -0,0 +1,116 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _FILE_BROWSER_H_
#define _FILE_BROWSER_H_
#include <nlohmann/json.hpp>
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <filesystem>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace crossdesk {
// Filesystem work is serialized off the UI and RTC threads. The adapter owns
// connection authorization and the existing reliable file transfer engine.
class FileBrowser {
public:
using Json = nlohmann::json;
using Send = std::function<bool(const std::string&, const Json&)>;
using Enqueue = std::function<bool(const std::string&, const std::string&, uint32_t)>;
using Reserve = std::function<bool(const std::string&, uint32_t, const std::filesystem::path&)>;
using Cancel = std::function<bool(const std::string&, uint32_t)>;
struct Event { uint32_t id; Json result; };
FileBrowser(Send send, Enqueue enqueue, Reserve reserve, Cancel cancel = {});
~FileBrowser();
uint32_t List(const std::string& peer, const std::string& path, bool local);
void CancelList(const std::string& peer, uint32_t id);
void Copy(const std::string& peer, const std::vector<std::string>& paths,
const std::string& destination, bool download);
void CancelTransfer(const std::string& peer, uint32_t id);
void Handle(const std::string& peer, Json message);
void Disconnect(const std::string& peer);
uint64_t Generation(const std::string& peer);
std::vector<Event> TakeEvents(const std::string& peer);
void Stop();
static Json Directory(const std::string& path, const std::function<bool()>& cancelled = {});
private:
using Clock = std::chrono::steady_clock;
struct Pending {
std::string peer;
uint64_t generation;
Clock::time_point deadline;
std::function<void(const Json&)> complete;
};
struct Listing {
uint32_t id;
uint64_t generation;
std::string peer, path, cursor;
Json result;
std::atomic<bool> cancelled{false};
};
struct DirectorySnapshot {
Json listing;
uint64_t generation;
Clock::time_point expires;
};
struct UploadItem { uint32_t id; std::string path, directory; };
struct UploadJob {
std::vector<UploadItem> files;
size_t next = 0;
std::string destination;
bool notify_peer = false;
};
void Post(const std::string& peer, std::function<void()> work);
void Run();
void Request(const std::string& peer, Json message,
std::function<void(const Json&)> complete);
void Publish(const std::string& peer, uint32_t id, Json result);
void Serve(const std::string& peer, const Json& message);
void ListPage(const std::shared_ptr<Listing>& listing);
void FinishList(const std::shared_ptr<Listing>& listing, Json result);
void ReleaseList(const std::string& peer, const std::string& cursor);
Json DirectoryPage(const std::string& peer, const Json& message);
void Upload(const std::string& peer, const std::vector<std::string>& paths,
const std::string& destination, bool notify_peer = false);
void UploadNext(const std::string& peer, const std::shared_ptr<UploadJob>& job);
bool Current(const std::string& peer, uint64_t generation);
void Report(const std::string& peer, Json result, bool notify_peer);
bool SendMessage(const std::string& peer, Json message);
Send send_;
Enqueue enqueue_;
Reserve reserve_;
Cancel cancel_;
std::mutex mutex_;
std::condition_variable wake_;
bool stopping_ = false;
std::deque<std::function<void()>> jobs_;
std::map<std::string, uint64_t> generations_;
std::map<std::string, std::vector<Event>> events_;
std::map<uint32_t, std::shared_ptr<Listing>> listings_;
// Worker-thread state only.
std::map<uint32_t, Pending> pending_;
std::map<std::pair<std::string, std::string>, DirectorySnapshot> directory_snapshots_;
uint64_t next_snapshot_ = 1;
std::string active_peer_;
uint64_t active_generation_ = 0;
std::atomic<uint32_t> next_id_{1};
std::thread worker_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,638 @@
#include "features/file_transfer/file_browser_window.h"
#include <algorithm>
#include <any>
#include <cctype>
#include <climits>
#include <cstdio>
extern unsigned char fa_solid_900_ttf[];
extern unsigned int fa_solid_900_ttf_len;
#include "ui/model_update.h"
#include "localization.h"
#include "application/window_geometry.h"
#if defined(__APPLE__)
#include "platform/window_drag.h"
#elif defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#else
#include <SDL3/SDL.h>
#endif
namespace crossdesk {
namespace {
slint::SharedString S(std::string_view value) { return slint::SharedString(value); }
std::string Size(uint64_t bytes) {
const char* units[] = {"B", "KB", "MB", "GB", "TB"};
double value = bytes; int unit = 0;
while (value >= 1024 && unit < 4) { value /= 1024; ++unit; }
char text[64]; std::snprintf(text, sizeof(text), unit ? "%.1f %s" : "%.0f %s", value, units[unit]);
return text;
}
std::string Join(const std::string& parent, const std::string& name) {
const bool windows = (parent.size() > 1 && parent[1] == ':') || parent.rfind("\\\\", 0) == 0;
return parent + (parent.empty() || parent.back() == '/' || parent.back() == '\\'
? "" : windows ? "\\" : "/") + name;
}
std::string Name(const std::string& path) {
const auto slash = path.find_last_of("/\\");
return slash == std::string::npos || slash == path.size() - 1 ? path : path.substr(slash + 1);
}
bool Matches(const std::string& text, const std::string& filter) {
return std::search(text.begin(), text.end(), filter.begin(), filter.end(),
[](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }) != text.end();
}
}
std::string FileBrowserWindow::Tr(const char* chinese, const char* english) const { return chinese_ ? chinese : english; }
FileBrowserWindow::FileBrowserWindow(FileTransferManager& transfers, std::string remote,
bool controller, int language)
: transfers_(transfers), remote_(std::move(remote)),
connection_generation_(transfers_.browser().Generation(remote_)),
chinese_(language == 0 || language == 3) {
transfers_.browser().TakeEvents(remote_);
window_->window().window_handle().register_font_from_data(
fa_solid_900_ttf, fa_solid_900_ttf_len);
window_->set_chinese(chinese_);
UpdateActionLabels(language);
SetPeerName("");
window_->set_local_role(S(controller ? Tr("控制端", "Controller") : Tr("被控端", "Host")));
window_->set_remote_role(S(controller ? Tr("被控端", "Host") : Tr("控制端", "Controller")));
window_->set_local_entries(panes_[0].rows); window_->set_local_tree(panes_[0].tree);
window_->set_remote_entries(panes_[1].rows); window_->set_remote_tree(panes_[1].tree);
window_->set_tasks(tasks_);
window_->on_navigate([this](bool remote, slint::SharedString path) { Navigate(remote, std::string(path)); });
window_->on_refresh([this](bool remote) { Navigate(remote, panes_[remote].path); });
window_->on_up([this](bool remote) { Navigate(remote, panes_[remote].parent); });
window_->on_filter([this](bool remote, slint::SharedString filter) {
auto& pane = panes_[remote]; pane.filter = std::string(filter); pane.selected.clear(); UpdatePane(remote);
});
window_->on_sort([this](bool remote, int column) {
auto& pane = panes_[remote];
if (column < 0 || column > 2 || pane.busy || !pane.loaded ||
!pane.error.empty() || window_->get_drag_active()) return;
const auto sort_column = static_cast<SortColumn>(column);
pane.sort_descending = pane.sort_column == sort_column ? !pane.sort_descending
: sort_column != SortColumn::Name;
pane.sort_column = sort_column;
SortEntries(pane);
UpdatePane(remote);
});
window_->on_toggle_hidden([this](bool remote) {
auto& pane = panes_[remote]; pane.show_hidden = !pane.show_hidden;
pane.selected.clear(); UpdatePane(remote); UpdateTree(remote);
});
window_->on_select_entry([this](bool remote, int index) {
auto& pane = panes_[remote];
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
auto row = *pane.rows->row_data(index);
if (row.link) return;
SelectSource(remote);
const std::string path(row.path);
if (!pane.selected.erase(path)) pane.selected.insert(path);
UpdatePane(remote);
});
window_->on_select_all([this](bool remote) {
SelectSource(remote);
auto& pane = panes_[remote];
bool all = true;
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
const auto row = *pane.rows->row_data(i);
if (!row.link && !pane.selected.count(std::string(row.path))) all = false;
}
for (size_t i = 0; i < pane.rows->row_count(); ++i) {
const auto row = *pane.rows->row_data(i);
if (all) pane.selected.erase(std::string(row.path));
else if (!row.link) pane.selected.insert(std::string(row.path));
}
UpdatePane(remote);
});
window_->on_clear_selection([this] {
if (window_->get_drag_active()) return;
for (int side = 0; side < 2; ++side) {
panes_[side].selected.clear();
UpdatePane(side);
}
});
window_->on_open_entry([this](bool remote, int index) {
auto& pane = panes_[remote];
if (index < 0 || index >= static_cast<int>(pane.rows->row_count())) return;
const auto row = *pane.rows->row_data(index);
if (row.directory) OpenDirectory(remote, std::string(row.path));
});
window_->on_open_directory([this](bool remote, slint::SharedString path) {
OpenDirectory(remote, std::string(path));
});
window_->on_tree_open([this](bool remote, int index) {
const auto& pane = panes_[remote];
if (index >= 0 && index < static_cast<int>(pane.tree->row_count()))
Navigate(remote, std::string(pane.tree->row_data(index)->path));
});
window_->on_tree_toggle([this](bool remote, int index) {
auto& pane = panes_[remote];
if (index < 0 || index >= static_cast<int>(pane.tree->row_count())) return;
const std::string path(pane.tree->row_data(index)->path);
if (!pane.expanded.erase(path)) {
pane.expanded.insert(path);
if (!pane.directories.count(path)) Navigate(remote, path, true);
}
UpdateTree(remote);
});
window_->on_drag_data([this](bool remote, slint::SharedString path, int) {
return MakeDragData(remote, std::string(path));
});
window_->on_drag_state([this](const slint::DataTransfer& data, bool active) {
if (!active) { CancelDrag(); return; }
const auto value = data.user_data();
const auto stored = std::any_cast<std::shared_ptr<DragPayload>>(&value);
if (!stored || !*stored) return;
const auto payload = DropPayload(!(*stored)->remote, data);
if (!payload) return;
window_->set_drag_active(true);
window_->set_drag_count(static_cast<int>(payload->paths->size()));
SelectSource(payload->remote);
panes_[payload->remote].selected = {payload->paths->begin(), payload->paths->end()};
UpdatePane(payload->remote);
});
window_->on_can_drop([this](bool remote, const slint::DataTransfer& data) {
return static_cast<bool>(DropPayload(remote, data));
});
window_->on_dropped([this](bool remote, const slint::DataTransfer& data) {
const auto payload = DropPayload(remote, data);
if (!payload) return false;
// The drag owns a snapshot of both directories and its selected paths.
// Consume before queueing so repeated release events cannot send twice.
payload->consumed = true;
return QueueTransfer(payload->remote, *payload->paths, payload->target_directory);
});
window_->on_cancel_drag([this] { CancelDrag(); });
window_->on_transfer([this](bool download) { Transfer(download); });
window_->on_cancel_task([this](int id) { transfers_.CancelBrowserTransfer(remote_, static_cast<uint32_t>(id)); });
window_->on_clear_finished([this] {
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
for (const auto& [id, task] : history_)
if (task.status == Status::Completed || task.status == Status::Failed || task.status == Status::Cancelled)
dismissed_.insert(id);
next_sync_ = {};
});
window_->window().on_close_requested([this] {
CancelDrag();
window_->hide(); return slint::CloseRequestResponse::KeepWindowShown;
});
Navigate(false, ""); Navigate(true, "");
}
void FileBrowserWindow::SetPeerName(const std::string& name) {
const auto text = S(name.empty() ? Tr("远端设备", "Remote device") : name);
if (window_->get_peer_name() != text) window_->set_peer_name(text);
}
void FileBrowserWindow::UpdateActionLabels(int language) {
window_->set_send_local_text(S(localization::Literal("发送到本机", language)));
window_->set_send_remote_text(S(localization::Literal("发送到远端", language)));
window_->set_clear_completed_text(S(localization::Literal("清除已完成", language)));
window_->set_open_directory_text(S(localization::Literal("打开目录", language)));
}
bool FileBrowserWindow::PresentsResult(uint32_t id,
FileTransferManager::FileTransferState::FileTransferStatus status) const {
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
if (!PresentsTransfers() ||
(status != Status::Completed && status != Status::Failed && status != Status::Cancelled))
return false;
const auto task = history_.find(id);
return task != history_.end() && !task->second.receiving && task->second.status == status &&
(window_->get_tasks_expanded() || dismissed_.count(id));
}
bool FileBrowserWindow::CenterOnScreen() {
auto& window = window_->window();
if (window.is_fullscreen() || window.is_maximized()) return true;
#if defined(__APPLE__)
return CenterNativeWindowOnScreen(window.appkit_view());
#elif defined(_WIN32)
const HWND hwnd = window.win32_hwnd();
if (!hwnd) return false;
MONITORINFO monitor{sizeof(MONITORINFO)};
RECT frame{};
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &monitor) ||
!GetWindowRect(hwnd, &frame)) return false;
const auto& bounds = monitor.rcWork;
const auto target = window_geometry::CenteredPosition(
{static_cast<int>(bounds.left), static_cast<int>(bounds.top),
static_cast<int>(bounds.right - bounds.left),
static_cast<int>(bounds.bottom - bounds.top)},
{static_cast<int>(frame.right - frame.left),
static_cast<int>(frame.bottom - frame.top)});
return SetWindowPos(hwnd, nullptr, target.x, target.y, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE) != 0;
#else
#if defined(__linux__) && !defined(__OHOS__)
// Native Wayland placement belongs to the compositor.
if (window.wayland_surface()) return true;
#endif
const auto position = window.position();
const auto size = window.size();
if (!size.width || !size.height) return false;
const SDL_Point center{position.x + static_cast<int>(size.width / 2),
position.y + static_cast<int>(size.height / 2)};
SDL_DisplayID display = SDL_GetDisplayForPoint(&center);
if (!display) display = SDL_GetPrimaryDisplay();
SDL_Rect bounds{};
if (!display || !SDL_GetDisplayUsableBounds(display, &bounds)) return false;
const auto target = window_geometry::CenteredPosition(
{bounds.x, bounds.y, bounds.w, bounds.h},
{static_cast<int>(size.width), static_cast<int>(size.height)});
window.set_position(slint::PhysicalPosition({target.x, target.y}));
return true;
#endif
}
void FileBrowserWindow::Show() {
if (!Tick()) return;
window_->window().set_minimized(false);
window_->show();
#if defined(__APPLE__)
// Winit may create the NSWindow after show() returns. Retry on UI ticks,
// including when a hidden file window is opened again.
native_titlebar_attempts_ =
ConfigureWhiteWindowChrome(window_->window().appkit_view()) ? 0 : 30;
#endif
// Always recenter a reopened window. Only retry until the native frame is
// ready; subsequent UI ticks must leave user-driven window movement alone.
center_attempts_ = CenterOnScreen() ? 0 : 30;
if (!panes_[1].busy && !panes_[1].error.empty()) Navigate(true, panes_[1].path);
}
void FileBrowserWindow::OpenDirectory(bool remote, const std::string& path) {
const auto& pane = panes_[remote];
if (!SessionConnected() || !pane.loaded || pane.busy || !pane.error.empty() ||
window_->get_drag_active()) return;
// Resolve the menu target by path, never by a row index that can change.
const auto entry = std::find_if(pane.entries.begin(), pane.entries.end(), [&](const auto& item) {
return item.row.directory && std::string_view(item.row.path) == path;
});
if (entry != pane.entries.end()) Navigate(remote, path);
}
void FileBrowserWindow::Navigate(bool remote, const std::string& path, bool tree) {
if (window_->get_drag_active()) CancelDrag();
if (remote && !transfers_.BrowserConnected(remote_)) { Notice(Error("disconnected"), true); return; }
auto& pane = panes_[remote];
for (auto it = requests_.begin(); it != requests_.end();) {
if (it->second.remote == remote && it->second.tree == tree &&
(!tree || it->second.path == path)) {
transfers_.browser().CancelList(remote_, it->first);
it = requests_.erase(it);
} else ++it;
}
const auto id = transfers_.browser().List(remote_, path, !remote);
requests_[id] = {remote, tree, path};
if (!tree) {
pane.request = id; pane.busy = true; pane.error.clear(); pane.loading.clear();
pane.selected.clear(); UpdatePane(remote);
}
}
void FileBrowserWindow::SortEntries(Pane& pane) {
std::sort(pane.entries.begin(), pane.entries.end(), [&](const Entry& a, const Entry& b) {
// Folder grouping and unknown dates stay consistent in either direction.
if (a.row.directory != b.row.directory) return a.row.directory;
int order = 0;
if (pane.sort_column == SortColumn::Modified) {
const bool a_known = a.modified_ms.has_value() || !a.modified.empty();
const bool b_known = b.modified_ms.has_value() || !b.modified.empty();
if (a_known != b_known) return a_known;
if (a.modified_ms && b.modified_ms)
order = *a.modified_ms < *b.modified_ms ? -1 : *a.modified_ms > *b.modified_ms ? 1 : 0;
else order = a.modified.compare(b.modified);
} else if (pane.sort_column == SortColumn::Size && !a.row.directory) {
order = a.bytes < b.bytes ? -1 : a.bytes > b.bytes ? 1 : 0;
}
if (!order) order = a.name_key.compare(b.name_key);
if (!order) order = std::string_view(a.row.name).compare(std::string_view(b.row.name));
if (!order) order = std::string_view(a.row.path).compare(std::string_view(b.row.path));
return pane.sort_descending ? order > 0 : order < 0;
});
}
void FileBrowserWindow::UpdatePane(bool remote) {
auto& pane = panes_[remote];
std::vector<ui::BrowserEntry> rows;
size_t selectable = 0;
rows.reserve(pane.entries.size());
for (const auto& item : pane.entries) {
auto entry = item.row;
if (!pane.show_hidden && std::string(entry.name).starts_with('.')) continue;
if (!Matches(std::string(entry.name), pane.filter)) continue;
entry.selected = pane.selected.count(std::string(entry.path));
if (!entry.link) ++selectable;
rows.push_back(std::move(entry));
}
ui::UpdateModelRows(*pane.rows, std::move(rows));
const auto summary = pane.busy
? pane.loading.empty() ? Tr("正在读取目录…", "Loading folder…") : pane.loading
: std::to_string(pane.rows->row_count()) + Tr(" 项", " items");
const bool up = pane.loaded && !pane.parent.empty() && pane.path != pane.parent;
const bool all = selectable > 0 && pane.selected.size() == selectable;
if (remote) {
window_->set_remote_sort_column(static_cast<int>(pane.sort_column));
window_->set_remote_sort_descending(pane.sort_descending);
window_->set_remote_current_path(S(pane.path));
window_->set_remote_filter(S(pane.filter));
window_->set_remote_hidden(pane.show_hidden);
window_->set_remote_busy(pane.busy); window_->set_remote_status(S(summary));
window_->set_remote_error(S(pane.error)); window_->set_remote_up(up);
window_->set_remote_selected(static_cast<int>(pane.selected.size())); window_->set_remote_all(all);
} else {
window_->set_local_sort_column(static_cast<int>(pane.sort_column));
window_->set_local_sort_descending(pane.sort_descending);
window_->set_local_current_path(S(pane.path));
window_->set_local_filter(S(pane.filter));
window_->set_local_hidden(pane.show_hidden);
window_->set_local_busy(pane.busy); window_->set_local_status(S(summary));
window_->set_local_error(S(pane.error)); window_->set_local_up(up);
window_->set_local_selected(static_cast<int>(pane.selected.size())); window_->set_local_all(all);
}
UpdateAvailability();
}
void FileBrowserWindow::UpdateTree(bool remote) {
auto& pane = panes_[remote];
std::vector<ui::BrowserTreeEntry> rows;
const auto append = [&](auto&& self, const std::string& path, const std::string& name, int depth) -> void {
if (depth > 24) return;
const bool expanded = pane.expanded.count(path);
rows.push_back({S(name), S(path), depth, expanded, pane.path == path});
if (expanded) {
const auto it = pane.directories.find(path);
if (it != pane.directories.end())
for (const auto& entry : it->second)
if (entry.directory && !entry.link && (pane.show_hidden || !std::string(entry.name).starts_with('.')))
self(self, std::string(entry.path), std::string(entry.name), depth + 1);
}
};
if (!pane.home.empty()) append(append, pane.home, Tr("主目录", "Home"), 0);
append(append, "@roots", Tr("磁盘", "Drives"), 0);
if (pane.loaded && pane.path != "@roots" && pane.path != pane.home &&
std::none_of(rows.begin(), rows.end(), [&](const auto& row) { return std::string(row.path) == pane.path; }))
append(append, pane.path, Name(pane.path), 0);
ui::UpdateModelRows(*pane.tree, std::move(rows));
}
bool FileBrowserWindow::TransferReady() const {
return window_->get_connected() && panes_[0].loaded && panes_[1].loaded &&
!panes_[0].busy && !panes_[1].busy && panes_[0].error.empty() && panes_[1].error.empty() &&
panes_[0].path != "@roots" && panes_[1].path != "@roots" &&
std::string(window_->get_local_path()) == panes_[0].path &&
std::string(window_->get_remote_path()) == panes_[1].path;
}
bool FileBrowserWindow::SessionConnected() const {
// A peer can reconnect with the same ID between UI ticks. Its old window
// must still close, even if the new connection is already usable.
return !disconnected_ && transfers_.BrowserConnected(remote_) &&
connection_generation_ == transfers_.browser().Generation(remote_);
}
void FileBrowserWindow::UpdateAvailability() {
const bool ready = TransferReady();
window_->set_transfer_ready(ready);
window_->set_can_upload(ready && !panes_[0].selected.empty());
window_->set_can_download(ready && !panes_[1].selected.empty());
// All selected rows share one immutable list, so selecting a large folder
// doesn't allocate a copy of the entire selection for every draggable row.
for (int side = 0; side < 2; ++side)
drag_selection_[side] = std::make_shared<const std::vector<std::string>>(
panes_[side].selected.begin(), panes_[side].selected.end());
// Callback bindings don't track C++ container mutations. Refresh the drag
// payload only when its selection/directory state changes, never every tick.
const int revision = window_->get_selection_revision();
window_->set_selection_revision(revision == INT_MAX ? 0 : revision + 1);
}
void FileBrowserWindow::SelectSource(bool remote) {
window_->set_source_remote(remote);
if (!panes_[!remote].selected.empty()) {
panes_[!remote].selected.clear(); UpdatePane(!remote);
}
}
slint::DataTransfer FileBrowserWindow::MakeDragData(bool remote, const std::string& path) const {
slint::DataTransfer result;
if (!TransferReady()) return result;
const auto& source = panes_[remote]; const auto& target = panes_[!remote];
const auto found = std::find_if(source.entries.begin(), source.entries.end(),
[&](const auto& entry) { return !entry.row.link && std::string(entry.row.path) == path; });
if (found == source.entries.end()) return result;
auto payload = std::make_shared<DragPayload>();
payload->owner = drag_owner_; payload->epoch = drag_epoch_; payload->remote = remote;
payload->source_request = source.request; payload->target_request = target.request;
payload->source_directory = source.path; payload->target_directory = target.path;
if (source.selected.count(path)) payload->paths = drag_selection_[remote];
else payload->paths = std::make_shared<const std::vector<std::string>>(std::initializer_list<std::string>{path});
// Opaque application data: a remote path must never masquerade as a local
// file URL when the pointer leaves this window or enters another session.
result.set_user_data(std::move(payload));
return result;
}
std::shared_ptr<FileBrowserWindow::DragPayload> FileBrowserWindow::DropPayload(
bool remote, const slint::DataTransfer& data) const {
const auto value = data.user_data();
const auto stored = std::any_cast<std::shared_ptr<DragPayload>>(&value);
if (!stored || !*stored) return {};
const auto& payload = *stored;
if (payload->owner.lock() != drag_owner_ || payload->epoch != drag_epoch_ ||
payload->consumed || payload->remote == remote || !payload->paths || payload->paths->empty() ||
!TransferReady() || !SessionConnected()) return {};
const auto& source = panes_[payload->remote]; const auto& target = panes_[remote];
if (source.request != payload->source_request || target.request != payload->target_request ||
source.path != payload->source_directory || target.path != payload->target_directory) return {};
return payload;
}
void FileBrowserWindow::CancelDrag() {
++drag_epoch_;
window_->set_drag_active(false); window_->set_drag_count(0);
UpdateAvailability();
}
bool FileBrowserWindow::QueueTransfer(bool download, const std::vector<std::string>& paths,
const std::string& destination) {
if (paths.empty() || !TransferReady() || !SessionConnected()) return false;
transfers_.browser().Copy(remote_, paths, destination, download);
Notice(Tr("正在准备传输 → ", "Preparing transfer → ") + destination, false);
panes_[download].selected.clear(); UpdatePane(download);
return true;
}
void FileBrowserWindow::Transfer(bool download) {
if (!TransferReady()) return;
auto& source = panes_[download];
const std::vector<std::string> paths(source.selected.begin(), source.selected.end());
QueueTransfer(download, paths, panes_[!download].path);
}
void FileBrowserWindow::Notice(const std::string& message, bool error) {
window_->set_notice(S(message)); window_->set_notice_error(error);
}
std::string FileBrowserWindow::Error(const std::string& code) const {
if (code == "timeout") return Tr("远端未响应,请确认两端已更新至支持文件浏览的版本后重试。", "Peer did not respond. Update both ends to a version that supports file browsing, then retry.");
if (code == "disconnected") return Tr("连接已断开,请重新连接后打开文件管理器。", "Disconnected. Reconnect and reopen the file manager.");
if (code == "browser_not_open") return Tr("请先在控制端打开本连接的文件管理器,再浏览控制端目录。", "Open this connection's file manager on the controller to enable browsing its folders.");
if (code == "directory_unavailable") return Tr("无法打开目录,请检查路径和访问权限后刷新。", "Cannot open folder. Check the path and access permissions, then refresh.");
if (code == "pagination_unsupported") return Tr("远端版本无法完整读取此目录,请更新远端后重试。", "Update the remote app to load every item in this folder.");
if (code == "invalid_listing" || code == "listing_expired") return Tr("目录列表未能完整加载,请刷新重试。", "The folder listing could not be fully loaded. Refresh to retry.");
if (code == "directory_not_writable") return Tr("目标目录不可写,请选择其他目录。", "Cannot write to the destination. Choose another folder.");
if (code == "symbolic_link") return Tr("所选内容包含符号链接,请选择实际文件或目录。", "Selection contains a symbolic link. Select the original files or folders.");
if (code == "too_many_files") return Tr("单次最多传输 4096 个文件与目录,请分批选择。", "Select at most 4096 files and folders per transfer.");
if (code == "file_unreadable") return Tr("文件无法读取,可能已移动或无访问权限。", "Cannot read a file. It may have moved or access was denied.");
if (code == "busy") return Tr("请求较多,请稍后重试。", "Too many pending requests. Try again shortly.");
if (code == "already_finished") return Tr("任务已结束,无需取消。", "This transfer has already finished.");
return Tr("操作失败,请检查所选路径与文件权限后重试。", "Operation failed. Check the paths and file permissions, then retry.");
}
bool FileBrowserWindow::Tick() {
using Status = FileTransferManager::FileTransferState::FileTransferStatus;
// Connection lifetime is checked every UI tick, outside the throttled
// directory/progress refresh. All native window operations stay on this thread.
if (!SessionConnected()) {
disconnected_ = true;
window_->set_connected(false);
CancelDrag();
requests_.clear();
window_->hide();
return false;
}
#if defined(__APPLE__)
if (native_titlebar_attempts_ > 0 && Visible()) {
if (ConfigureWhiteWindowChrome(window_->window().appkit_view()))
native_titlebar_attempts_ = 0;
else
--native_titlebar_attempts_;
}
#endif
if (center_attempts_ > 0 && Visible()) {
if (CenterOnScreen()) center_attempts_ = 0;
else --center_attempts_;
}
const auto now = std::chrono::steady_clock::now();
if (now < next_sync_) return true;
next_sync_ = now + std::chrono::milliseconds(150);
for (auto& event : transfers_.browser().TakeEvents(remote_)) {
if (!event.id) {
const bool ok = event.result.value("ok", false);
if (!ok || !window_->get_notice_error())
Notice(ok ? event.result.value("message", "") == "cancelling"
? Tr("正在取消传输…", "Cancelling transfer…")
: Tr("已加入传输队列", "Added to transfer queue")
: Error(event.result.value("error", "unknown")), !ok);
if (ok) refresh_at_ = now + std::chrono::seconds(1);
continue;
}
const auto found = requests_.find(event.id);
if (found == requests_.end()) continue;
const auto request = found->second;
auto& pane = panes_[request.remote];
if (!request.tree && pane.request != event.id) continue;
if (event.result.value("loading", false)) {
if (!request.tree) {
pane.loading = Tr("正在加载 ", "Loading ") + std::to_string(event.result.value("loaded", size_t{})) +
" / " + std::to_string(event.result.value("total", size_t{})) + Tr(" 项…", " items…");
if (request.remote) window_->set_remote_status(S(pane.loading));
else window_->set_local_status(S(pane.loading));
}
continue;
}
requests_.erase(found);
if (!request.tree) pane.busy = false;
if (!event.result.value("ok", false)) {
pane.error = Error(event.result.value("error", "unknown"));
if (request.tree) pane.expanded.erase(request.path);
} else {
try {
const auto path = event.result.at("path").get<std::string>();
const auto& entries = event.result.at("entries");
if (!entries.is_array()) throw std::runtime_error("invalid");
std::vector<ui::BrowserEntry> rows;
rows.reserve(entries.size());
std::vector<Entry> sortable;
if (!request.tree) sortable.reserve(entries.size());
for (const auto& entry : entries) {
ui::BrowserEntry row{};
const auto name = entry.at("name").get<std::string>();
if (name.empty() || name == "." || name == "..") continue;
// Paths from a different operating system are opaque to this UI.
row.name = S(name); row.path = S(path == "@roots" ? entry.at("path").get<std::string>() : Join(path, name));
row.directory = entry.at("directory").get<bool>(); row.link = entry.value("link", false);
const auto bytes = entry.at("size").get<uint64_t>();
const auto timestamp = entry.at("modified").get<std::string>();
row.size = S(Size(bytes));
auto modified = timestamp.substr(0, 10);
std::replace(modified.begin(), modified.end(), '-', '/');
row.modified = S(modified);
if (!request.tree) {
Entry item{row, name, timestamp, bytes, std::nullopt};
std::transform(item.name_key.begin(), item.name_key.end(), item.name_key.begin(),
[](unsigned char c) { return std::tolower(c); });
if (entry.contains("modified_ms") && entry["modified_ms"].is_number_integer())
item.modified_ms = entry["modified_ms"].get<int64_t>();
sortable.push_back(std::move(item));
}
rows.push_back(std::move(row));
}
pane.directories[path] = rows;
if (!request.tree) {
// A name filter belongs to the open folder. Preserve it on refresh,
// but don't silently hide entries after navigating somewhere else.
if (pane.path != path) pane.filter.clear();
pane.path = path; pane.parent = event.result.at("parent").get<std::string>();
if (request.path.empty()) pane.home = path;
pane.entries = std::move(sortable); pane.error.clear(); pane.loaded = true;
SortEntries(pane);
pane.expanded.insert(path);
}
} catch (...) { pane.error = Error("unknown"); }
}
if (!request.tree) {
if (request.remote) window_->set_remote_path(S(pane.path)); else window_->set_local_path(S(pane.path));
}
UpdatePane(request.remote); UpdateTree(request.remote);
}
bool new_tasks = false;
for (const auto& task : transfers_.BrowserTransfers(remote_)) {
new_tasks = new_tasks || (!task.receiving && !history_.count(task.file_id));
history_[task.file_id] = task;
}
if (new_tasks) window_->set_tasks_expanded(true);
std::vector<ui::BrowserTask> rows;
for (auto it = history_.rbegin(); it != history_.rend(); ++it) {
const auto& [id, task] = *it;
if (task.status == Status::Completed && completed_.insert(id).second)
refresh_at_ = now + std::chrono::milliseconds(500);
// Received files still trigger directory refresh, but only this computer's
// outgoing transfers appear in the task list and progress notifications.
if (task.receiving || dismissed_.count(id)) continue;
ui::BrowserTask row{};
row.id = static_cast<int>(id); row.name = S(task.file_name); row.receiving = task.receiving;
row.size = S(Size(task.file_size));
row.active = task.status == Status::Queued || task.status == Status::Sending || task.status == Status::Receiving || task.status == Status::Cancelling;
row.failed = task.status == Status::Failed;
row.can_cancel = task.status == Status::Queued || task.status == Status::Sending || task.status == Status::Receiving;
row.progress = task.status == Status::Completed ? 1.f : task.file_size ? std::clamp(float(task.sent_bytes) / task.file_size, 0.f, 1.f) : 0.f;
const auto detail = Tr("已传输 ", "Transferred ") + Size(task.sent_bytes) +
(task.rate_bps ? " · " + Size(task.rate_bps / 8) + "/s" : "");
row.detail = S(detail);
switch (task.status) {
case Status::Queued: row.status = S(Tr("排队中", "Queued")); break;
case Status::Sending: row.status = S(Tr("发送中 ", "Sending ") + std::to_string(int(row.progress * 100)) + "%"); break;
case Status::Receiving: row.status = S(Tr("接收中 ", "Receiving ") + std::to_string(int(row.progress * 100)) + "%"); break;
case Status::Completed: row.status = S(Tr("已完成", "Completed")); break;
case Status::Failed: row.status = S(Tr("失败 · 请重试", "Failed · Retry")); break;
case Status::Cancelling: row.status = S(Tr("正在取消", "Cancelling")); break;
case Status::Cancelled: row.status = S(Tr("已取消", "Cancelled")); break;
}
rows.push_back(row);
}
const auto active = std::count_if(rows.begin(), rows.end(), [](const auto& task) { return task.active; });
const auto failed = std::count_if(rows.begin(), rows.end(), [](const auto& task) { return task.failed; });
const auto summary = std::to_string(rows.size()) + Tr(" 项", " items") +
(active ? " · " + std::to_string(active) + Tr(" 项进行中", " active") : "") +
(failed ? " · " + std::to_string(failed) + Tr(" 项失败", " failed") : "");
window_->set_task_summary(S(summary));
ui::UpdateModelRows(*tasks_, std::move(rows));
if (!window_->get_drag_active() && refresh_at_ != std::chrono::steady_clock::time_point{} && now >= refresh_at_) {
refresh_at_ = {};
for (int side = 0; side < 2; ++side)
if (panes_[side].loaded && !panes_[side].busy && panes_[side].selected.empty() &&
std::string(side ? window_->get_remote_path() : window_->get_local_path()) == panes_[side].path)
Navigate(side, panes_[side].path);
}
const bool ready = TransferReady();
if (window_->get_transfer_ready() != ready) UpdateAvailability();
return true;
}
} // namespace crossdesk
@@ -0,0 +1,104 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _FILE_BROWSER_WINDOW_H_
#define _FILE_BROWSER_WINDOW_H_
#include "crossdesk_ui.h"
#include "features/file_transfer/file_transfer_manager.h"
#include <optional>
#include <set>
namespace crossdesk {
class FileBrowserWindow {
public:
FileBrowserWindow(FileTransferManager& transfers, std::string remote,
bool controller, int language);
void Show();
void SetPeerName(const std::string& name);
void UpdateActionLabels(int language);
// False means this connection ended; the UI owner should discard the window.
bool Tick();
bool Visible() const { return window_->window().is_visible(); }
bool PresentsTransfers() const { return Visible() && !window_->window().is_minimized(); }
bool PresentsResult(uint32_t id, FileTransferManager::FileTransferState::FileTransferStatus status) const;
private:
enum class SortColumn { Name, Modified, Size };
struct Entry {
ui::BrowserEntry row;
std::string name_key, modified;
uint64_t bytes = 0;
std::optional<int64_t> modified_ms;
};
struct Pane {
std::string path, parent, home, filter, error, loading;
bool loaded = false, busy = false, show_hidden = false;
uint32_t request = 0;
SortColumn sort_column = SortColumn::Name;
bool sort_descending = false;
std::vector<Entry> entries;
std::set<std::string> selected, expanded;
std::map<std::string, std::vector<ui::BrowserEntry>> directories;
std::shared_ptr<slint::VectorModel<ui::BrowserEntry>> rows =
std::make_shared<slint::VectorModel<ui::BrowserEntry>>();
std::shared_ptr<slint::VectorModel<ui::BrowserTreeEntry>> tree =
std::make_shared<slint::VectorModel<ui::BrowserTreeEntry>>();
};
struct Request { bool remote, tree; std::string path; };
struct DragPayload {
std::weak_ptr<int> owner;
uint64_t epoch = 0;
bool remote = false, consumed = false;
uint32_t source_request = 0, target_request = 0;
std::string source_directory, target_directory;
std::shared_ptr<const std::vector<std::string>> paths;
};
void Navigate(bool remote, const std::string& path, bool tree = false);
void OpenDirectory(bool remote, const std::string& path);
bool CenterOnScreen();
void UpdatePane(bool remote);
void SortEntries(Pane& pane);
void UpdateTree(bool remote);
bool TransferReady() const;
bool SessionConnected() const;
void UpdateAvailability();
void SelectSource(bool remote);
slint::DataTransfer MakeDragData(bool remote, const std::string& path) const;
std::shared_ptr<DragPayload> DropPayload(bool remote, const slint::DataTransfer& data) const;
void CancelDrag();
bool QueueTransfer(bool download, const std::vector<std::string>& paths,
const std::string& destination);
void Transfer(bool download);
void Notice(const std::string& message, bool error);
std::string Error(const std::string& code) const;
std::string Tr(const char* chinese, const char* english) const;
FileTransferManager& transfers_;
std::string remote_;
uint64_t connection_generation_;
bool chinese_;
bool disconnected_ = false;
int center_attempts_ = 0;
#if defined(__APPLE__)
int native_titlebar_attempts_ = 0;
#endif
slint::ComponentHandle<ui::FileBrowserWindow> window_ = ui::FileBrowserWindow::create();
Pane panes_[2];
std::shared_ptr<int> drag_owner_ = std::make_shared<int>(0);
std::shared_ptr<const std::vector<std::string>> drag_selection_[2];
uint64_t drag_epoch_ = 0;
std::map<uint32_t, Request> requests_;
std::set<uint32_t> dismissed_, completed_;
std::map<uint32_t, FileTransferManager::FileTransferState::FileTransferInfo> history_;
std::shared_ptr<slint::VectorModel<ui::BrowserTask>> tasks_ =
std::make_shared<slint::VectorModel<ui::BrowserTask>>();
std::chrono::steady_clock::time_point next_sync_{}, refresh_at_{};
};
} // namespace crossdesk
#endif
@@ -10,10 +10,31 @@
namespace crossdesk {
FileTransferManager::FileTransferManager(GuiRuntime& owner) : owner_(owner) {}
FileTransferManager::FileTransferManager(GuiRuntime& owner)
: owner_(owner), browser_(
[this](const auto& remote, const auto& message) { return SendBrowser(remote, message); },
[this](const auto& remote, const auto& path, uint32_t id) {
if (!BrowserConnected(remote)) return false;
auto props = owner_.FindRemoteSession(remote);
return ProcessSelectedFile(path, props, owner_.file_label_, remote, id);
},
[this](const auto& remote, uint32_t id, const auto& directory) {
if (!BrowserConnected(remote)) return false;
std::shared_ptr<PeerEventHandler> events;
{ std::lock_guard lock(browser_mutex_); events = browser_peers_[remote]; }
const auto receiver = ReceiverFor(events.get(), remote);
return receiver && receiver->ReserveDirectory(id, directory);
},
[this](const auto& remote, uint32_t id) {
if (!BrowserConnected(remote)) return false;
for (const auto& info : BrowserTransfers(remote))
if (!info.receiving && info.file_id == id) return CancelSend(id);
return false;
}) {}
FileTransferManager::~FileTransferManager() { Shutdown(); }
void FileTransferManager::Shutdown() {
browser_.Stop();
std::vector<std::future<void>> workers;
{
std::lock_guard lock(workers_mutex_);
@@ -147,6 +168,18 @@ void FileTransferManager::ProcessQueue(std::shared_ptr<RemoteSession> props) {
uint64_t previous = UINT64_MAX;
auto progress = file_transfer::SendState{id, 0, 0,
cancelled ? file_transfer::Status::Cancelled : file_transfer::Status::Failed};
if (cancelled && peer && events && (!props || !props->closing_) &&
(props || owner_.IsAuthorizedController(file.remote_id))) {
auto callback = events->EnterCallback();
if (callback && events->DataAllowed()) {
// A queued browser file already reserved a destination on the peer.
// Release that reservation even though no data chunk was ever sent.
const auto packet = EncodeFileCancel(id, 0);
const auto wire_id = events->WireId(file.remote_id);
SendReliableDataFrameToPeer(peer, packet.data(), packet.size(),
file.file_label.c_str(), wire_id.data(), wire_id.size());
}
}
if (started) {
do {
{
@@ -288,6 +321,7 @@ void FileTransferManager::UpdateReceiveProgress(
info.file_id = file_transfer::Session::NextFileId();
info.remote_id = remote_id;
info.receiving = true;
info.wire_file_id = progress.id;
receive_rate_samples_.emplace(info.file_id,
ReceiveRateSample{std::chrono::steady_clock::now(), 0});
found = notifications.emplace(progress.id, files.size()).first;
@@ -310,7 +344,7 @@ void FileTransferManager::UpdateReceiveProgress(
}
}
void FileTransferManager::Receive(const char* data, size_t size,
std::shared_ptr<FileReceiver> FileTransferManager::ReceiverFor(
PeerEventHandler* handler, const std::string& remote) {
const auto props = owner_.FindRemoteSession(remote);
PeerPtr* peer;
@@ -324,14 +358,14 @@ void FileTransferManager::Receive(const char* data, size_t size,
events = owner_.ControllerEvents(remote);
peer = events ? events->DirectPeer() : nullptr;
}
if (!peer || !events || events.get() != handler || (props && props->closing_)) return;
if (!peer || !events || events.get() != handler || (props && props->closing_)) return {};
std::shared_ptr<FileReceiver> receiver;
std::filesystem::path default_directory;
{
std::lock_guard lock(sessions_mutex_);
const auto closed = disconnected_.find(handler);
if (stopping_ || (closed != disconnected_.end() &&
(closed->second.count("") || closed->second.count(remote)))) return;
(closed->second.count("") || closed->second.count(remote)))) return {};
auto& entry = receivers_[handler][remote];
if (!entry.session) {
entry.session = std::make_shared<FileReceiver>(); entry.handler = events;
@@ -358,7 +392,12 @@ void FileTransferManager::Receive(const char* data, size_t size,
}
const auto configured = owner_.config_center_->GetFileTransferSavePath();
receiver->SetOutputDir(configured.empty() ? default_directory : PathFromUtf8(configured));
receiver->OnData(data, size);
return receiver;
}
void FileTransferManager::Receive(const char* data, size_t size,
PeerEventHandler* handler, const std::string& remote) {
if (auto receiver = ReceiverFor(handler, remote)) receiver->OnData(data, size);
}
void FileTransferManager::Connected(PeerEventHandler* handler, const std::string& remote) {
@@ -371,6 +410,17 @@ void FileTransferManager::Connected(PeerEventHandler* handler, const std::string
}
void FileTransferManager::Cancel(PeerEventHandler* handler, const std::string& remote) {
{
std::lock_guard lock(browser_mutex_);
for (auto it = browser_peers_.begin(); it != browser_peers_.end();) {
if (it->second.get() == handler && (remote.empty() || it->first == remote)) {
browser_.Disconnect(it->first);
browser_opened_.erase(it->first);
browser_supported_.erase(it->first);
it = browser_peers_.erase(it);
} else ++it;
}
}
std::lock_guard lock(sessions_mutex_);
disconnected_[handler].insert(remote);
for (auto& [id, sender] : senders_)
@@ -411,4 +461,129 @@ void FileTransferManager::Poll() {
}
}
void FileTransferManager::SetBrowserSupport(PeerEventHandler* handler,
const std::string& remote, bool supported) {
const auto props = owner_.FindRemoteSession(remote);
const auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (!events || events.get() != handler || !events->DataAllowed()) return;
std::lock_guard lock(browser_mutex_);
if (const auto previous = browser_peers_.find(remote);
previous != browser_peers_.end() && previous->second != events) {
browser_.Disconnect(remote);
browser_opened_.erase(remote);
}
browser_peers_[remote] = events;
if (supported) browser_supported_.insert(remote);
else browser_supported_.erase(remote);
}
bool FileTransferManager::BrowserSupported(const std::string& remote) {
std::lock_guard lock(browser_mutex_);
return browser_supported_.count(remote) != 0;
}
bool FileTransferManager::OpenBrowser(const std::string& remote) {
auto props = owner_.FindRemoteSession(remote);
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (!events || !events->DataAllowed() || owner_.unattended_host_ ||
(props ? props->closing_ || !props->connection_established_
: !owner_.IsAuthorizedController(remote))) return false;
std::lock_guard lock(browser_mutex_);
if (const auto previous = browser_peers_.find(remote);
previous != browser_peers_.end() && previous->second != events) {
browser_.Disconnect(remote);
browser_opened_.erase(remote);
browser_supported_.erase(remote);
}
browser_peers_[remote] = events;
// Opening the legacy file picker authorizes a send, not disk browsing.
if (browser_supported_.count(remote)) browser_opened_.insert(remote);
return true;
}
void FileTransferManager::CancelBrowserTransfer(const std::string& remote, uint32_t file_id) {
for (const auto& info : BrowserTransfers(remote)) {
if (info.file_id != file_id) continue;
if (info.receiving) browser_.CancelTransfer(remote, info.wire_file_id);
else CancelSend(file_id);
return;
}
}
bool FileTransferManager::BrowserConnected(const std::string& remote) {
auto props = owner_.FindRemoteSession(remote);
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (!events || !events->DataAllowed() || owner_.unattended_host_ ||
(props ? props->closing_ || !props->connection_established_
: !owner_.IsAuthorizedController(remote))) return false;
std::lock_guard lock(browser_mutex_);
const auto it = browser_peers_.find(remote);
return it != browser_peers_.end() && it->second == events;
}
bool FileTransferManager::SendBrowser(const std::string& remote, const FileBrowser::Json& message) {
if (!BrowserConnected(remote)) return false;
auto props = owner_.FindRemoteSession(remote);
std::shared_ptr<PeerEventHandler> events;
{ std::lock_guard lock(browser_mutex_); events = browser_peers_[remote]; }
if (!events) return false;
auto callback = events->EnterCallback();
if (!callback || !events->DataAllowed()) return false;
PeerPtr* peer = events->IsDirect() ? events->DirectPeer() : props ? props->peer_ : owner_.peer_;
const auto data = message.dump(-1, ' ', false, FileBrowser::Json::error_handler_t::replace);
if (!peer || data.size() > 60000) return false;
const auto wire_id = events->WireId(remote);
return SendReliableDataFrameToPeer(peer, data.data(), data.size(),
owner_.control_data_label_.c_str(), wire_id.data(), wire_id.size()) == 0;
}
bool FileTransferManager::HandleBrowser(const char* data, size_t size,
PeerEventHandler* handler, const std::string& remote) {
if (!data || size > 60000 || std::string_view(data, size).find("file_browser") == std::string_view::npos)
return false;
auto message = FileBrowser::Json::parse(data, data + size, nullptr, false);
if (!message.is_object() || message.value("type", FileBrowser::Json{}) != "file_browser") return false;
auto props = owner_.FindRemoteSession(remote);
auto events = props ? props->peer_events_ : owner_.ControllerEvents(remote);
if (owner_.unattended_host_ || !events || events.get() != handler || !events->DataAllowed() ||
(props ? props->closing_ || !props->connection_established_
: !owner_.IsAuthorizedController(remote))) return true;
bool opened;
{
std::lock_guard lock(browser_mutex_);
browser_peers_[remote] = events;
if (message.value("op", FileBrowser::Json{}) == "presentation") {
// Older browser builds synchronized window visibility. Ignore that
// optional event: progress now belongs exclusively to the local sender.
return true;
}
opened = browser_opened_.count(remote);
}
// A controlled computer cannot browse the controller's disk until that
// controller explicitly opens this peer's file manager in this connection.
const auto op = message.value("op", FileBrowser::Json{});
if (props && !opened && op != "reply") {
if (message.contains("id")) SendBrowser(remote,
{{"op", "reply"}, {"id", message["id"]}, {"type", "file_browser"},
{"version", 1}, {"ok", false}, {"error", "browser_not_open"}});
return true;
}
browser_.Handle(remote, std::move(message));
return true;
}
std::vector<FileTransferManager::FileTransferState::FileTransferInfo>
FileTransferManager::BrowserTransfers(const std::string& remote) {
std::vector<FileTransferState::FileTransferInfo> result;
const auto collect = [&](auto& state) {
std::lock_guard lock(state.file_transfer_list_mutex_);
for (const auto& info : state.file_transfer_list_)
if (info.remote_id == remote) result.push_back(info);
};
collect(global_state_); collect(received_state_);
if (auto props = owner_.FindRemoteSession(remote)) collect(props->file_transfer_);
std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) { return a.file_id > b.file_id; });
return result;
}
} // namespace crossdesk
@@ -13,6 +13,7 @@
#include <vector>
#include "file_transfer.h"
#include "features/file_transfer/file_browser.h"
#include "runtime/remote_session.h"
namespace crossdesk {
@@ -40,6 +41,15 @@ class FileTransferManager {
bool CancelSend(uint32_t file_id);
void Poll();
void Shutdown();
FileBrowser& browser() { return browser_; }
bool OpenBrowser(const std::string& remote);
void SetBrowserSupport(PeerEventHandler* handler, const std::string& remote, bool supported);
bool BrowserSupported(const std::string& remote);
bool BrowserConnected(const std::string& remote);
bool HandleBrowser(const char* data, size_t size, PeerEventHandler* handler,
const std::string& remote);
std::vector<FileTransferState::FileTransferInfo> BrowserTransfers(const std::string& remote);
void CancelBrowserTransfer(const std::string& remote, uint32_t file_id);
private:
struct Sender {
@@ -53,6 +63,8 @@ class FileTransferManager {
std::filesystem::path default_directory;
};
void ProcessQueue(std::shared_ptr<RemoteSession> props);
std::shared_ptr<FileReceiver> ReceiverFor(PeerEventHandler* handler, const std::string& remote);
bool SendBrowser(const std::string& remote, const FileBrowser::Json& message);
void UpdateProgress(FileTransferState& state,
const file_transfer::SendState& progress,
std::chrono::steady_clock::time_point now);
@@ -76,6 +88,11 @@ class FileTransferManager {
FileTransferState received_state_;
// Guarded by received_state_.file_transfer_list_mutex_.
std::unordered_map<uint32_t, ReceiveRateSample> receive_rate_samples_;
std::mutex browser_mutex_;
std::map<std::string, std::shared_ptr<PeerEventHandler>> browser_peers_;
std::set<std::string> browser_opened_;
std::set<std::string> browser_supported_;
FileBrowser browser_;
};
} // namespace crossdesk
@@ -0,0 +1,26 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-11-05
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TRANSFER_NOTIFICATION_STATE_H_
#define _TRANSFER_NOTIFICATION_STATE_H_
namespace crossdesk::transfer_notification {
struct Presentation {
bool dismissed = false;
bool result_seen = false;
bool ShowNotification(bool browser_visible, bool result_presented) {
// Hiding an active transfer is temporary. A result already presented in
// the browser must not pop up again when that window closes.
result_seen = result_seen || result_presented;
return !dismissed && !result_seen && !browser_visible;
}
};
} // namespace crossdesk::transfer_notification
#endif