mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-07 14:49:09 +08:00
[feat] add dual-pane file transfers and directory browsing, refs #135
This commit is contained in:
@@ -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(¢er);
|
||||
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
|
||||
Reference in New Issue
Block a user