mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-07 06:39:10 +08:00
[feat] add dual-pane file transfers and directory browsing, refs #135
This commit is contained in:
@@ -38,6 +38,8 @@
|
||||
#include "fa_solid_900.h"
|
||||
#include "features/about/about_catalog.h"
|
||||
#include "features/file_transfer/transfer_notification_layout.h"
|
||||
#include "features/file_transfer/transfer_notification_state.h"
|
||||
#include "features/file_transfer/file_browser_window.h"
|
||||
#include "features/gallery/gallery_fullscreen_transition.h"
|
||||
#include "features/gallery/gallery_window_state.h"
|
||||
#include "features/gallery/gallery_workspace.h"
|
||||
@@ -1064,9 +1066,10 @@ struct GuiApplication::SlintUi {
|
||||
struct TransferNotification {
|
||||
std::optional<slint::ComponentHandle<ui::FileTransferNotification>> window;
|
||||
RemoteSession::FileTransferInfo info;
|
||||
bool dismissed = false;
|
||||
transfer_notification::Presentation presentation;
|
||||
};
|
||||
std::map<uint32_t, TransferNotification> transfer_notifications;
|
||||
std::map<std::string, std::unique_ptr<FileBrowserWindow>> file_browsers;
|
||||
SDL_DisplayID transfer_display = 0;
|
||||
std::shared_ptr<slint::VectorModel<ui::NetworkStatsRow>> stats_model =
|
||||
std::make_shared<slint::VectorModel<ui::NetworkStatsRow>>();
|
||||
@@ -2327,16 +2330,7 @@ void GuiApplication::BindStreamCallbacks() {
|
||||
SyncAudioPlayback();
|
||||
});
|
||||
stream->on_select_file([this] {
|
||||
auto props = SelectedSession();
|
||||
if (!props) {
|
||||
return;
|
||||
}
|
||||
OpenFileDialog(localization::select_file[localization_language_index_],
|
||||
[this, props](std::string path) {
|
||||
if (!path.empty() && !props->closing_ &&
|
||||
props->connection_status_ == ConnectionStatus::Connected)
|
||||
transfers_.ProcessSelectedFile(path, props, props->file_label_);
|
||||
});
|
||||
if (auto props = SelectedSession()) OpenFileManager(props->remote_id_);
|
||||
});
|
||||
stream->on_can_drop_file(
|
||||
[](const slint::DataTransfer& data) { return data.has_plain_text(); });
|
||||
@@ -2449,40 +2443,7 @@ void GuiApplication::BindServerCallbacks() {
|
||||
selected_server_remote_id_ = ui_->controller_ids[index];
|
||||
});
|
||||
server->on_select_file([this] {
|
||||
if (selected_server_remote_id_.empty() || ui_->server_file_dialog_open) {
|
||||
return;
|
||||
}
|
||||
// Keep the recipient stable if another controller connects or leaves while
|
||||
// the asynchronous native picker is open.
|
||||
const std::string recipient = selected_server_remote_id_;
|
||||
ui_->server_file_dialog_open = true;
|
||||
(*ui_->server)->window().request_redraw();
|
||||
const auto select_file = [this, recipient] {
|
||||
const auto connected = [this, recipient] {
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
const auto found = connection_status_.find(recipient);
|
||||
return found != connection_status_.end() &&
|
||||
found->second == ConnectionStatus::Connected;
|
||||
};
|
||||
if (!connected()) {
|
||||
ui_->server_file_dialog_open = false;
|
||||
return;
|
||||
}
|
||||
OpenFileDialog(localization::select_file[localization_language_index_],
|
||||
[this, recipient, connected](std::string path) {
|
||||
ui_->server_file_dialog_open = false;
|
||||
if (connected() && !path.empty())
|
||||
transfers_.ProcessSelectedFile(path, nullptr, file_label_, recipient);
|
||||
});
|
||||
};
|
||||
#if defined(__APPLE__)
|
||||
// The native picker is already dispatched outside the clicked callback.
|
||||
select_file();
|
||||
#else
|
||||
// Let TouchArea release and repaint before the blocking native picker.
|
||||
ui_->server_file_dialog_timer.start(
|
||||
slint::TimerMode::SingleShot, std::chrono::milliseconds(32), select_file);
|
||||
#endif
|
||||
if (!selected_server_remote_id_.empty()) OpenFileManager(selected_server_remote_id_);
|
||||
});
|
||||
server->on_disconnect_controller([this](slint::SharedString id) {
|
||||
CloseServerController(std::string(id));
|
||||
@@ -2733,6 +2694,14 @@ void GuiApplication::Tick() {
|
||||
SyncStreamWindow();
|
||||
SyncServerWindow();
|
||||
SyncTrustedDevices();
|
||||
for (auto it = ui_->file_browsers.begin(); it != ui_->file_browsers.end();) {
|
||||
if (!it->second->Tick()) it = ui_->file_browsers.erase(it);
|
||||
else {
|
||||
if (it->second->Visible())
|
||||
it->second->SetPeerName(FileManagerPeerName(it->first));
|
||||
++it;
|
||||
}
|
||||
}
|
||||
SyncFileTransferNotifications();
|
||||
}
|
||||
|
||||
@@ -3222,6 +3191,8 @@ void GuiApplication::UpdateLocalization() {
|
||||
if (ui_->stream) {
|
||||
ui_localization::ApplyStreamWindowStrings(*ui_->stream, language);
|
||||
}
|
||||
for (auto& [_, browser] : ui_->file_browsers)
|
||||
browser->UpdateActionLabels(language);
|
||||
ui_->localized_language = language;
|
||||
}
|
||||
|
||||
@@ -4581,16 +4552,72 @@ void GuiApplication::CancelFileTransfer(uint32_t file_id) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string GuiApplication::FileManagerPeerName(const std::string& remote) {
|
||||
Thumbnail::RecentConnection device{};
|
||||
device.remote_id = remote;
|
||||
if (const auto props = FindRemoteSession(remote)) {
|
||||
if (const auto direct = props->lan_session_) {
|
||||
device.remote_id = Thumbnail::LanRecordId(direct->target.device_id);
|
||||
}
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
device.remote_host_name = props->remote_host_name_;
|
||||
} else {
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
if (const auto host = connection_host_names_.find(remote);
|
||||
host != connection_host_names_.end())
|
||||
device.remote_host_name = host->second;
|
||||
}
|
||||
if (device.remote_host_name.empty() || device.remote_host_name == "unknown") {
|
||||
for (const auto& [_, recent] : recent_connections_) {
|
||||
if (recent.remote_id == device.remote_id) {
|
||||
device.remote_host_name = recent.remote_host_name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto name = settings_.RecentConnectionDisplayName(device);
|
||||
// Keep transport IDs out of the header while peer information is arriving.
|
||||
return name == device.remote_id ? std::string{} : name;
|
||||
}
|
||||
|
||||
void GuiApplication::OpenFileManager(const std::string& remote) {
|
||||
if (!transfers_.OpenBrowser(remote)) return;
|
||||
if (!transfers_.BrowserSupported(remote)) {
|
||||
// Host information from older clients omits the capability. Use their
|
||||
// existing file protocol and picker directly, without a directory RPC.
|
||||
const auto generation = transfers_.browser().Generation(remote);
|
||||
OpenFileDialog(std::string(localization::select_file[localization_language_index_]),
|
||||
[this, remote, generation](std::string path) {
|
||||
if (path.empty() || !transfers_.BrowserConnected(remote) ||
|
||||
transfers_.browser().Generation(remote) != generation) return;
|
||||
const auto props = FindRemoteSession(remote);
|
||||
transfers_.ProcessSelectedFile(path, props,
|
||||
props ? props->file_label_ : file_label_, remote);
|
||||
});
|
||||
return;
|
||||
}
|
||||
auto& browser = ui_->file_browsers[remote];
|
||||
if (browser && !browser->Tick()) browser.reset();
|
||||
if (!browser) {
|
||||
const bool controller = static_cast<bool>(FindRemoteSession(remote));
|
||||
browser = std::make_unique<FileBrowserWindow>(transfers_, remote, controller,
|
||||
localization_language_index_);
|
||||
}
|
||||
browser->SetPeerName(FileManagerPeerName(remote));
|
||||
browser->Show();
|
||||
SyncFileTransferNotifications();
|
||||
}
|
||||
|
||||
void GuiApplication::SyncFileTransferNotifications() {
|
||||
using Status = RemoteSession::FileTransferStatus;
|
||||
using namespace transfer_notification;
|
||||
std::map<uint32_t, RemoteSession::FileTransferInfo> files;
|
||||
const auto collect = [&](FileTransferState& state) {
|
||||
std::lock_guard lock(state.file_transfer_list_mutex_);
|
||||
for (const auto& info : state.file_transfer_list_) files.emplace(info.file_id, info);
|
||||
for (const auto& info : state.file_transfer_list_)
|
||||
if (!info.receiving) files.emplace(info.file_id, info);
|
||||
};
|
||||
collect(transfers_.global_state());
|
||||
collect(transfers_.received_state());
|
||||
{
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
for (const auto& [_, props] : remote_sessions_) {
|
||||
@@ -4605,11 +4632,17 @@ void GuiApplication::SyncFileTransferNotifications() {
|
||||
operation.state != SlintUi::BatchOperation::State::Finished)
|
||||
pending_files.insert(operation.file_id);
|
||||
// Retain the last progress independently of session lifetime. Results stay
|
||||
// visible until dismissed, including after the connection is closed.
|
||||
// visible until dismissed or presented in the browser, even after disconnect.
|
||||
for (const auto& [id, info] : files)
|
||||
ui_->transfer_notifications[id].info = info;
|
||||
std::erase_if(ui_->transfer_notifications, [&](auto& item) {
|
||||
if (!item.second.dismissed || files.count(item.first) ||
|
||||
// Progress belongs to the sender. Remove any cached receive popup as well,
|
||||
// regardless of window visibility, peer version or final transfer status.
|
||||
if (item.second.info.receiving) {
|
||||
if (item.second.window) (*item.second.window)->hide();
|
||||
return true;
|
||||
}
|
||||
if ((!item.second.presentation.dismissed && !item.second.presentation.result_seen) || files.count(item.first) ||
|
||||
pending_files.count(item.first)) return false;
|
||||
if (item.second.window) (*item.second.window)->hide();
|
||||
return true;
|
||||
@@ -4649,7 +4682,11 @@ void GuiApplication::SyncFileTransferNotifications() {
|
||||
}
|
||||
const bool completed = info.status == Status::Completed;
|
||||
const bool failed = info.status == Status::Failed;
|
||||
if (notification.dismissed || slot >= layout.capacity()) {
|
||||
const auto browser = ui_->file_browsers.find(info.remote_id);
|
||||
const bool in_browser = browser != ui_->file_browsers.end() && browser->second->PresentsTransfers();
|
||||
const bool result_presented = in_browser && browser->second->PresentsResult(id, info.status);
|
||||
if (!notification.presentation.ShowNotification(in_browser, result_presented) ||
|
||||
slot >= layout.capacity()) {
|
||||
if (notification.window) {
|
||||
(*notification.window)->hide();
|
||||
notification.window.reset();
|
||||
@@ -4663,7 +4700,7 @@ void GuiApplication::SyncFileTransferNotifications() {
|
||||
const auto dismiss = [this, id] {
|
||||
const auto found = ui_->transfer_notifications.find(id);
|
||||
if (found == ui_->transfer_notifications.end()) return;
|
||||
found->second.dismissed = true;
|
||||
found->second.presentation.dismissed = true;
|
||||
if (found->second.window) (*found->second.window)->hide();
|
||||
};
|
||||
(*notification.window)->on_dismissed(dismiss);
|
||||
@@ -4693,15 +4730,12 @@ void GuiApplication::SyncFileTransferNotifications() {
|
||||
auto display_id = info.remote_id;
|
||||
std::erase_if(display_id, [](unsigned char c) { return c < '0' || c > '9'; });
|
||||
component->set_remote_id(UiText(display_id));
|
||||
component->set_heading(UiText(info.receiving
|
||||
? localization::file_receiving_progress[localization_language_index_]
|
||||
: localization::file_sending_progress[localization_language_index_]));
|
||||
component->set_transferring(info.status == Status::Sending || info.status == Status::Receiving);
|
||||
component->set_heading(UiText(localization::file_sending_progress[localization_language_index_]));
|
||||
component->set_transferring(info.status == Status::Sending);
|
||||
component->set_completed(completed);
|
||||
component->set_failed(failed);
|
||||
component->set_cancelled(info.status == Status::Cancelled || info.status == Status::Cancelling);
|
||||
component->set_can_cancel(!info.receiving &&
|
||||
(info.status == Status::Queued || info.status == Status::Sending));
|
||||
component->set_can_cancel(info.status == Status::Queued || info.status == Status::Sending);
|
||||
component->set_cancel_text(UiText(localization::cancel[localization_language_index_]));
|
||||
auto& window = component->window();
|
||||
const auto size = window.size();
|
||||
|
||||
@@ -95,6 +95,8 @@ private:
|
||||
void SyncServerWindow();
|
||||
void SyncTrustedDevices();
|
||||
void SyncFileTransferNotifications();
|
||||
void OpenFileManager(const std::string& remote);
|
||||
std::string FileManagerPeerName(const std::string& remote);
|
||||
void CancelFileTransfer(uint32_t file_id);
|
||||
void SetServerPanelExpanded(bool expanded);
|
||||
void PositionServerWindows();
|
||||
|
||||
@@ -521,6 +521,10 @@ inline constexpr TranslationRow kTranslationRows[] = {
|
||||
{"无法复制配对信息,请重试。", {"无法复制配对信息,请重试。", "Unable to copy pairing information. Please try again.", "Не удалось скопировать данные сопряжения. Повторите попытку.", "無法複製配對資訊,請重試。", "ペアリング情報をコピーできません。もう一度お試しください。", "페어링 정보를 복사할 수 없습니다. 다시 시도하세요.", "Kopplungsdaten konnten nicht kopiert werden. Bitte erneut versuchen.", "Impossible de copier les informations d’association. Réessayez.", "No se pudo copiar la información de emparejamiento. Inténtelo de nuevo.", "Não foi possível copiar as informações de pareamento. Tente novamente."}},
|
||||
{"重试更新", {"重试更新", "Retry update", "Повторить", "重試更新", "再試行", "다시 시도", "Erneut versuchen", "Réessayer", "Reintentar", "Tentar novamente"}},
|
||||
{"更新未完成,请手动下载安装。", {"更新未完成,请手动下载安装。", "Update did not complete. Download and install manually.", "Обновление не завершено. Скачайте и установите вручную.", "更新未完成,請手動下載安裝。", "アップデートが完了しませんでした。手動でダウンロードしてインストールしてください。", "업데이트가 완료되지 않았습니다. 수동으로 다운로드하여 설치하세요.", "Das Update wurde nicht abgeschlossen. Laden Sie es herunter und installieren Sie es manuell.", "La mise à jour n'a pas été effectuée. Téléchargez-la et installez-la manuellement.", "La actualización no se completó. Descárguela e instálela manualmente.", "A atualização não foi concluída. Baixe e instale manualmente."}},
|
||||
{"发送到远端", {"发送到远端", "Send to remote", "Отправить на удалённый ПК", "傳送到遠端", "リモートへ送信", "원격으로 보내기", "An Remote-PC senden", "Envoyer au PC distant", "Enviar al equipo remoto", "Enviar ao computador remoto"}},
|
||||
{"发送到本机", {"发送到本机", "Send to local", "Отправить на этот ПК", "傳送到本機", "この端末へ送信", "이 기기로 보내기", "An diesen PC senden", "Envoyer à ce PC", "Enviar a este equipo", "Enviar a este computador"}},
|
||||
{"清除已完成", {"清除已完成", "Clear completed", "Убрать завершённые", "清除已完成", "完了済みをクリア", "완료 항목 지우기", "Abgeschlossene entfernen", "Effacer les tâches terminées", "Limpiar completadas", "Limpar concluídas"}},
|
||||
{"打开目录", {"打开目录", "Open folder", "Открыть папку", "開啟目錄", "フォルダーを開く", "폴더 열기", "Ordner öffnen", "Ouvrir le dossier", "Abrir carpeta", "Abrir pasta"}},
|
||||
};
|
||||
|
||||
struct NamedTranslation { const char* name; const char* literal; };
|
||||
|
||||
@@ -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
|
||||
@@ -147,6 +147,8 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
source_id == runtime->clipboard_label_ ||
|
||||
source_id == web_clipboard::kStream))
|
||||
return;
|
||||
if (source_id == runtime->control_data_label_ &&
|
||||
runtime->transfers_.HandleBrowser(data, size, handler, remote_id)) return;
|
||||
if (source_id == web_clipboard::kStream) {
|
||||
runtime->clipboard_.ReceiveWeb(
|
||||
data, size, remote_id, handler);
|
||||
@@ -424,6 +426,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
|
||||
if (remote_action.type == ControlType::host_infomation) {
|
||||
if (source_id == runtime->control_data_label_) {
|
||||
runtime->transfers_.SetBrowserSupport(handler, remote_id,
|
||||
remote_action.i.supports_file_browser);
|
||||
}
|
||||
bool is_client_mode = false;
|
||||
std::shared_ptr<GuiRuntime::RemoteSession> props;
|
||||
{
|
||||
|
||||
@@ -359,6 +359,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
remote_action.i.supports_deskhub_fps15 = true;
|
||||
remote_action.i.supports_deskhub_bandwidth = true;
|
||||
remote_action.i.supports_file_browser = !runtime->unattended_host_;
|
||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||
remote_action.i.display_list[i]);
|
||||
@@ -528,6 +529,7 @@ void PeerEventHandler::DispatchConnectionStatus(ConnectionStatus status,
|
||||
remote_action.i.supports_deskhub_resolution = true;
|
||||
remote_action.i.supports_deskhub_fps15 = true;
|
||||
remote_action.i.supports_deskhub_bandwidth = true;
|
||||
remote_action.i.supports_file_browser = !runtime->unattended_host_;
|
||||
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
|
||||
LOG_INFO("Local display [{}:{}]", i + 1,
|
||||
remote_action.i.display_list[i]);
|
||||
|
||||
@@ -73,6 +73,7 @@ struct FileTransferState {
|
||||
uint32_t file_id = 0;
|
||||
uint32_t rate_bps = 0;
|
||||
bool receiving = false;
|
||||
uint32_t wire_file_id = 0;
|
||||
};
|
||||
std::vector<FileTransferInfo> file_transfer_list_;
|
||||
std::mutex file_transfer_list_mutex_;
|
||||
|
||||
@@ -89,6 +89,7 @@ export global FontAwesomeIcons {
|
||||
out property <string> angle-right: "\u{f105}";
|
||||
out property <string> angle-up: "\u{f106}";
|
||||
out property <string> arrow-right-long: "\u{f178}";
|
||||
out property <string> arrow-up: "\u{f062}";
|
||||
out property <string> arrow-up-right-from-square: "\u{f35d}";
|
||||
out property <string> bars: "\u{f0c9}";
|
||||
out property <string> check: "\u{f00c}";
|
||||
|
||||
@@ -14,3 +14,5 @@ export { GalleryNetworkDevice } from "gallery_network_dialog.slint";
|
||||
export { DeviceGroupOption } from "device_group_dialog.slint";
|
||||
|
||||
export { TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint";
|
||||
|
||||
export { FileBrowserWindow, BrowserEntry, BrowserTreeEntry, BrowserTask } from "file_browser.slint";
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
import { ScrollView, ListView, Spinner } from "std-widgets.slint";
|
||||
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard } from "workbench_controls.slint";
|
||||
import { FlatSurface, UiSurface, SurfaceSeparator } from "ui_surface.slint";
|
||||
import { UiRadius } from "ui_radius.slint";
|
||||
import { RoundedLineEdit } from "rounded_line_edit.slint";
|
||||
import { FontAwesomeIcons, ControlStyle, ImGuiFontStyle } from "common.slint";
|
||||
|
||||
export struct BrowserEntry {
|
||||
name: string, path: string, directory: bool, selected: bool,
|
||||
size: string, modified: string, link: bool,
|
||||
}
|
||||
export struct BrowserTreeEntry {
|
||||
name: string, path: string, depth: int, expanded: bool, selected: bool,
|
||||
}
|
||||
export struct BrowserTask {
|
||||
id: int, name: string, size: string, detail: string, status: string,
|
||||
progress: float, active: bool, failed: bool, can-cancel: bool, receiving: bool,
|
||||
}
|
||||
|
||||
component BrowserScrollView inherits Rectangle {
|
||||
// Fluent overlays a 14px scrollbar on its viewport. Keep a 4px gap between
|
||||
// that track and row content, including while the scrollbar expands on hover.
|
||||
out property <length> content-gutter: 18px;
|
||||
horizontal-stretch: 1;
|
||||
vertical-stretch: 1;
|
||||
clip: true;
|
||||
ScrollView {
|
||||
x: 0px; y: 0px; width: root.width; height: root.height;
|
||||
viewport-width: self.visible-width;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
@children
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the visual compact while retaining a generous pointer target.
|
||||
// Selection stays model-owned so refreshing rows cannot toggle it accidentally.
|
||||
component BrowserCheckBox inherits Rectangle {
|
||||
in property <bool> checked;
|
||||
in property <bool> indeterminate;
|
||||
in property <bool> enabled: true;
|
||||
in property <string> label;
|
||||
in property <bool> show-label: false;
|
||||
private property <bool> keyboard-focus: false;
|
||||
callback toggled;
|
||||
width: 24px; height: 24px;
|
||||
forward-focus: focus;
|
||||
accessible-role: checkbox;
|
||||
accessible-label: root.label;
|
||||
accessible-checked: root.checked;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-action-default => { if root.enabled { root.toggled(); } }
|
||||
box := FlatSurface {
|
||||
x: 4px; y: (parent.height - self.height) / 2; width: 16px; height: 16px;
|
||||
border-radius: 4px;
|
||||
background: !root.enabled ? #f4f6f9
|
||||
: touch.pressed ? #dce7fb
|
||||
: root.checked || root.indeterminate ? (touch.has-hover ? #dfe9fc : #edf2fc)
|
||||
: touch.has-hover ? #f5f8fe : white;
|
||||
outline-color: !root.enabled ? #dce2eb
|
||||
: touch.has-hover || touch.pressed ? #7d9edb
|
||||
: root.checked || root.indeterminate ? #a6bce6 : #b7c3d4;
|
||||
animate background, outline-color { duration: 100ms; }
|
||||
if root.checked && !root.indeterminate: Path {
|
||||
width: parent.width; height: parent.height;
|
||||
viewbox-width: 16; viewbox-height: 16;
|
||||
commands: "M 4 8 L 6.75 10.75 L 12 5.5";
|
||||
stroke: root.enabled ? WorkbenchStyle.accent : #aab5c6;
|
||||
stroke-width: 1.7px; stroke-line-cap: round; stroke-line-join: round;
|
||||
}
|
||||
if root.indeterminate: Rectangle {
|
||||
x: 4px; y: 7px; width: 8px; height: 2px; border-radius: 1px;
|
||||
background: root.enabled ? WorkbenchStyle.accent : #aab5c6;
|
||||
}
|
||||
}
|
||||
if root.show-label: Text {
|
||||
x: 30px; width: parent.width - self.x; height: parent.height;
|
||||
text: root.label; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base;
|
||||
vertical-alignment: center; overflow: elide; accessible-role: none;
|
||||
}
|
||||
if root.keyboard-focus && focus.has-focus: FlatSurface {
|
||||
x: 1px; y: box.y - 3px; width: 22px; height: 22px;
|
||||
border-radius: 6px; outline-color: UiSurface.focus;
|
||||
}
|
||||
focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
|
||||
focus-lost => { root.keyboard-focus = false; }
|
||||
key-pressed(event) => {
|
||||
if event.text == " " || event.text == Key.Return {
|
||||
root.keyboard-focus = true; root.toggled(); return accept;
|
||||
}
|
||||
reject
|
||||
}
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled; mouse-cursor: pointer;
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.down { root.keyboard-focus = false; }
|
||||
}
|
||||
clicked => { focus.focus(); root.keyboard-focus = false; root.toggled(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component BrowserSortHeader inherits FlatSurface {
|
||||
in property <string> text;
|
||||
in property <bool> chinese;
|
||||
in property <bool> active;
|
||||
in property <bool> descending;
|
||||
in property <bool> default-descending;
|
||||
in property <bool> right-aligned;
|
||||
in property <bool> enabled: true;
|
||||
private property <bool> keyboard-focus: false;
|
||||
private property <bool> next-descending: root.active ? !root.descending : root.default-descending;
|
||||
callback clicked;
|
||||
height: 28px;
|
||||
border-radius: UiRadius.control;
|
||||
outline-enabled: focus.has-focus && root.keyboard-focus;
|
||||
outline-color: UiSurface.focus;
|
||||
background: touch.pressed ? ControlStyle.pressed.with-alpha(0.3)
|
||||
: touch.has-hover && root.enabled ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||
forward-focus: focus;
|
||||
accessible-role: button;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-label: root.text + (root.active ? (root.descending ? (root.chinese ? ",降序" : ", descending") : (root.chinese ? ",升序" : ", ascending")) : "")
|
||||
+ (root.next-descending ? (root.chinese ? ",点击按降序排列" : ", sort descending") : (root.chinese ? ",点击按升序排列" : ", sort ascending"));
|
||||
accessible-action-default => { if root.enabled { root.clicked(); } }
|
||||
label := Text {
|
||||
x: root.right-aligned ? parent.width - self.width - (root.active ? 14px : 0px) : 0px;
|
||||
width: min(self.preferred-width, parent.width - (root.active ? 14px : 0px));
|
||||
height: parent.height;
|
||||
text: root.text; font-size: ImGuiFontStyle.body; font-weight: root.active ? 600 : 400;
|
||||
color: root.active ? WorkbenchStyle.text : WorkbenchStyle.muted;
|
||||
vertical-alignment: center; overflow: elide; accessible-role: none;
|
||||
}
|
||||
if root.active: Text {
|
||||
x: label.x + label.width + 4px; width: 10px; height: parent.height;
|
||||
text: root.descending ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-up;
|
||||
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px;
|
||||
color: WorkbenchStyle.muted; vertical-alignment: center; accessible-role: none;
|
||||
}
|
||||
focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
focus-gained(reason) => { root.keyboard-focus = reason == FocusReason.tab-navigation; }
|
||||
focus-lost => { root.keyboard-focus = false; }
|
||||
key-pressed(event) => {
|
||||
if event.text == " " || event.text == Key.Return {
|
||||
root.keyboard-focus = true; root.clicked(); return accept;
|
||||
}
|
||||
reject
|
||||
}
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled; mouse-cursor: pointer;
|
||||
pointer-event(event) => { if event.kind == PointerEventKind.down { root.keyboard-focus = false; } }
|
||||
clicked => { focus.focus(); root.keyboard-focus = false; root.clicked(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component BrowserPane inherits WorkbenchCard {
|
||||
in property <bool> chinese;
|
||||
in property <string> open-directory-text;
|
||||
in property <string> heading;
|
||||
in property <string> subtitle;
|
||||
in-out property <string> path;
|
||||
in property <string> current-path;
|
||||
in-out property <string> filter-text;
|
||||
in property <string> status;
|
||||
in property <string> error;
|
||||
in property <bool> busy;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> can-up;
|
||||
in property <bool> all-selected;
|
||||
in property <bool> show-hidden;
|
||||
in property <int> selected-count;
|
||||
in property <bool> destination;
|
||||
in property <bool> transfer-ready;
|
||||
in property <int> selection-revision;
|
||||
in property <int> drag-count;
|
||||
in property <bool> dragging;
|
||||
in property <[BrowserEntry]> entries;
|
||||
in property <[BrowserTreeEntry]> tree;
|
||||
in property <int> sort-column;
|
||||
in property <bool> sort-descending;
|
||||
private property <length> modified-column-width: 72px;
|
||||
private property <length> size-column-width: 56px;
|
||||
outline-color: root.destination ? UiSurface.hover : UiSurface.edge;
|
||||
callback navigate(string);
|
||||
callback up;
|
||||
callback select-entry(int);
|
||||
callback open-entry(int);
|
||||
callback open-directory(string);
|
||||
callback select-all;
|
||||
callback tree-open(int);
|
||||
callback tree-toggle(int);
|
||||
callback filter(string);
|
||||
callback toggle-hidden;
|
||||
callback sort(int);
|
||||
pure callback drag-data(string, int) -> data-transfer;
|
||||
callback drag-state(data-transfer, bool);
|
||||
callback can-drop(data-transfer) -> bool;
|
||||
callback dropped(data-transfer) -> bool;
|
||||
changed enabled => { if !root.enabled { directory-popup.close(); } }
|
||||
changed dragging => { if root.dragging { directory-popup.close(); } }
|
||||
changed current-path => { file-list.reset-scroll(); }
|
||||
changed filter-text => { file-list.reset-scroll(); }
|
||||
changed sort-column => { file-list.reset-scroll(); }
|
||||
changed sort-descending => { file-list.reset-scroll(); }
|
||||
VerticalLayout {
|
||||
padding-left: 14px; padding-right: 14px;
|
||||
padding-top: 12px; padding-bottom: 12px; spacing: 6px;
|
||||
HorizontalLayout {
|
||||
height: 24px; spacing: 8px;
|
||||
Text { text: root.heading; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.prominent; font-weight: 600; vertical-alignment: center; }
|
||||
Text {
|
||||
horizontal-stretch: 1;
|
||||
text: root.destination ? (root.chinese ? "接收目录" : "Destination")
|
||||
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项" : root.selected-count + " selected") : root.subtitle;
|
||||
color: root.destination ? WorkbenchStyle.accent : WorkbenchStyle.muted;
|
||||
font-size: ImGuiFontStyle.body; horizontal-alignment: right; vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
RoundedLineEdit {
|
||||
width: min(164px, root.width * 0.4); height: 24px;
|
||||
text <=> root.filter-text; tint-opacity: 0.15;
|
||||
placeholder-text: root.chinese ? "筛选名称…" : "Filter by name…";
|
||||
enabled: root.enabled && !root.busy && !root.dragging;
|
||||
edited => { root.filter(self.text); }
|
||||
accepted => { self.clear-focus(); }
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
height: 32px; spacing: 6px;
|
||||
WorkbenchButton {
|
||||
width: 32px; height: 32px; quiet: true; icon: FontAwesomeIcons.arrow-up;
|
||||
tooltip: root.chinese ? "打开上一级目录" : "Open parent folder";
|
||||
tooltip-above: true; tooltip-align-left: true;
|
||||
accessible-label: self.tooltip;
|
||||
enabled: root.can-up && !root.busy && root.enabled && !root.dragging;
|
||||
clicked => { root.up(); }
|
||||
}
|
||||
path-focus := FocusScope {
|
||||
horizontal-stretch: 1; height: 32px;
|
||||
forward-focus: path-editor;
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape {
|
||||
root.path = root.current-path;
|
||||
path-editor.clear-focus();
|
||||
return accept;
|
||||
}
|
||||
reject
|
||||
}
|
||||
path-editor := RoundedLineEdit {
|
||||
width: parent.width; height: parent.height; text <=> root.path;
|
||||
tint-opacity: 0.2;
|
||||
enabled: root.enabled && !root.dragging;
|
||||
placeholder-text: root.chinese ? "输入路径,按回车打开" : "Enter a folder path";
|
||||
accepted => { root.navigate(self.text); self.clear-focus(); }
|
||||
}
|
||||
}
|
||||
tree-trigger := WorkbenchButton {
|
||||
width: 28px; height: 32px; quiet: true; icon: FontAwesomeIcons.folder-open;
|
||||
enabled: root.enabled && !root.dragging;
|
||||
accessible-label: root.chinese ? "浏览目录树" : "Browse folder tree";
|
||||
clicked => { if directory-popup.is-open { directory-popup.close(); } else { directory-popup.show(); } }
|
||||
}
|
||||
}
|
||||
body := Rectangle {
|
||||
vertical-stretch: 1;
|
||||
drop-zone := DropArea {
|
||||
enabled: root.transfer-ready;
|
||||
can-drop(event) => { return root.can-drop(event.data) ? DragAction.copy : DragAction.none; }
|
||||
dropped(event) => { return root.dropped(event.data) ? DragAction.copy : DragAction.none; }
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
Rectangle {
|
||||
height: 28px; background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
|
||||
HorizontalLayout {
|
||||
padding-left: 6px; padding-right: 8px + file-list.content-gutter; spacing: 8px;
|
||||
BrowserCheckBox {
|
||||
width: 24px; height: 28px; checked: root.all-selected;
|
||||
indeterminate: root.selected-count > 0 && !root.all-selected;
|
||||
label: (root.chinese ? "选择全部" : "Select all")
|
||||
+ (self.indeterminate ? (root.chinese ? ",部分已选" : ", partially selected") : "");
|
||||
enabled: root.enabled && root.entries.length > 0 && !root.busy && root.error == "" && !root.dragging;
|
||||
toggled => { root.select-all(); }
|
||||
}
|
||||
BrowserSortHeader {
|
||||
horizontal-stretch: 1; text: root.chinese ? "名称" : "Name"; chinese: root.chinese;
|
||||
active: root.sort-column == 0; descending: root.sort-descending;
|
||||
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||
clicked => { root.sort(0); }
|
||||
}
|
||||
BrowserSortHeader {
|
||||
width: root.modified-column-width; text: root.chinese ? "修改日期" : "Modified"; chinese: root.chinese;
|
||||
active: root.sort-column == 1; descending: root.sort-descending; default-descending: true; right-aligned: true;
|
||||
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||
clicked => { root.sort(1); }
|
||||
}
|
||||
BrowserSortHeader {
|
||||
width: root.size-column-width; text: root.chinese ? "大小" : "Size"; chinese: root.chinese;
|
||||
active: root.sort-column == 2; descending: root.sort-descending; default-descending: true; right-aligned: true;
|
||||
enabled: root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||
clicked => { root.sort(2); }
|
||||
}
|
||||
}
|
||||
}
|
||||
file-list := Rectangle {
|
||||
out property <length> content-gutter: 18px;
|
||||
public function reset-scroll() { visible-files.viewport-y = 0px; }
|
||||
vertical-stretch: 1;
|
||||
clip: true;
|
||||
visible-files := ListView {
|
||||
width: parent.width; height: parent.height;
|
||||
viewport-width: self.visible-width;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
for entry[index] in root.entries: Rectangle {
|
||||
x: 0px; width: file-list.width - file-list.content-gutter;
|
||||
height: 28px;
|
||||
border-radius: UiRadius.control;
|
||||
background: entry.selected
|
||||
? (row-touch.pressed ? ControlStyle.selected-pressed.with-alpha(0.4) : row-touch.has-hover ? ControlStyle.selected-hover.with-alpha(0.35) : ControlStyle.selected.with-alpha(0.3))
|
||||
: row-touch.pressed ? ControlStyle.pressed.with-alpha(0.3) : row-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||
opacity: drag-source.dragging ? 0.55 : 1;
|
||||
folder-menu := ContextMenuArea {
|
||||
enabled: entry.directory && root.enabled && !root.busy && root.error == "" && !root.dragging;
|
||||
Menu {
|
||||
MenuItem {
|
||||
title: root.open-directory-text;
|
||||
activated => { root.open-directory(entry.path); }
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
padding-left: 6px; padding-right: 8px; spacing: 8px;
|
||||
Rectangle { width: 24px; }
|
||||
Text { width: 17px; text: entry.directory ? FontAwesomeIcons.folder-open : FontAwesomeIcons.file-lines; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 14px; color: entry.directory ? WorkbenchStyle.accent : ControlStyle.icon; vertical-alignment: center; }
|
||||
Text { text: entry.name; color: entry.link ? WorkbenchStyle.muted : entry.selected ? ControlStyle.selected-text : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; }
|
||||
Text { width: root.modified-column-width; text: entry.modified != "" ? entry.modified : "—"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; horizontal-alignment: right; }
|
||||
Text { width: root.size-column-width; text: entry.directory ? "—" : entry.size; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
|
||||
}
|
||||
drag-source := DragArea {
|
||||
// Only the row body starts drags; checkboxes remain selection controls.
|
||||
x: 34px; width: parent.width - 34px; height: parent.height;
|
||||
enabled: root.transfer-ready && !entry.link;
|
||||
allow-copy: true;
|
||||
data: root.drag-data(entry.path, root.selection-revision);
|
||||
changed dragging => { if self.dragging { root.drag-state(self.data, true); } }
|
||||
drag-finished(action) => { root.drag-state(self.data, false); }
|
||||
row-touch := TouchArea {
|
||||
enabled: root.enabled && !root.busy && root.error == "";
|
||||
mouse-cursor: entry.link ? MouseCursor.default : MouseCursor.grab;
|
||||
pointer-event(event) => {
|
||||
if event.button == PointerEventButton.right && event.kind == PointerEventKind.up && folder-menu.enabled {
|
||||
folder-menu.show({ x: self.mouse-x + 34px, y: self.mouse-y });
|
||||
}
|
||||
}
|
||||
clicked => { root.select-entry(index); }
|
||||
double-clicked => { root.open-entry(index); }
|
||||
}
|
||||
}
|
||||
BrowserCheckBox { x: 6px; y: (parent.height - self.height) / 2; width: 24px; checked: entry.selected; label: (root.chinese ? "选择 " : "Select ") + entry.name; enabled: !entry.link && root.enabled && !root.busy && root.error == "" && !root.dragging; toggled => { root.select-entry(index); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.entries.length == 0 && !root.busy && root.error == "": VerticalLayout {
|
||||
x: 12px; y: 34px; width: parent.width - 24px; height: parent.height - 40px;
|
||||
alignment: center; spacing: 10px;
|
||||
Text {
|
||||
text: root.filter-text != "" ? (root.chinese ? "没有匹配的项目" : "No matching items")
|
||||
: (root.chinese ? "此目录为空" : "This folder is empty");
|
||||
color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center;
|
||||
}
|
||||
if root.filter-text != "": HorizontalLayout {
|
||||
alignment: center;
|
||||
WorkbenchButton { width: 112px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "清空筛选" : "Clear filter"; clicked => { root.filter-text = ""; root.filter(""); } }
|
||||
}
|
||||
}
|
||||
if root.error != "" && !root.busy: Rectangle {
|
||||
width: parent.width; height: parent.height; background: WorkbenchStyle.surface;
|
||||
TouchArea {}
|
||||
VerticalLayout {
|
||||
padding: 16px; spacing: 10px; alignment: center;
|
||||
Text { text: root.chinese ? "无法读取目录" : "Unable to open folder"; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
|
||||
Text { text: root.error; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; }
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
WorkbenchButton { width: 128px; height: 28px; text-size: ImGuiFontStyle.base; text: root.chinese ? "打开主目录" : "Open home folder"; clicked => { root.navigate(""); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.busy: Rectangle {
|
||||
width: parent.width; height: parent.height; background: WorkbenchStyle.surface.with-alpha(0.9);
|
||||
VerticalLayout { alignment: center; spacing: 12px; Spinner { width: 24px; height: 24px; } Text { text: root.status; horizontal-alignment: center; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; } }
|
||||
}
|
||||
if drop-zone.has-drag: FlatSurface {
|
||||
width: parent.width; height: parent.height;
|
||||
background: ControlStyle.selected.with-alpha(0.3); outline-color: UiSurface.focus; border-radius: UiRadius.control;
|
||||
Rectangle {
|
||||
x: 8px; y: max(6px, parent.height - 68px); width: parent.width - 16px; height: 60px;
|
||||
background: ControlStyle.surface; border-radius: UiRadius.control;
|
||||
VerticalLayout {
|
||||
padding: 9px; spacing: 4px;
|
||||
Text { text: root.chinese ? "松手复制 " + root.drag-count + " 项到此目录" : "Release to copy " + root.drag-count + " items here"; color: WorkbenchStyle.accent; font-size: ImGuiFontStyle.base; font-weight: 600; horizontal-alignment: center; }
|
||||
Text { text: root.path; color: WorkbenchStyle.text; font-size: ImGuiFontStyle.body; overflow: elide; horizontal-alignment: center; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
height: 16px;
|
||||
text: root.busy ? root.status
|
||||
: root.path != root.current-path ? (root.chinese ? "Enter 打开路径 · Esc 还原" : "Enter to open · Esc to restore")
|
||||
: root.error != "" ? (root.chinese ? "目录不可用" : "Folder unavailable")
|
||||
: root.filter-text != "" ? (root.chinese ? "筛选结果 · " : "Filtered · ") + root.status : root.status;
|
||||
color: root.path != root.current-path ? WorkbenchStyle.accent : WorkbenchStyle.muted;
|
||||
font-size: ImGuiFontStyle.body; overflow: elide;
|
||||
}
|
||||
}
|
||||
directory-popup := PopupWindow {
|
||||
x: 14px; y: 78px; width: root.width - 28px; height: min(330px, max(180px, root.height - 90px));
|
||||
close-policy: close-on-click-outside;
|
||||
FlatSurface {
|
||||
width: parent.width; height: parent.height; background: WorkbenchStyle.surface; outline-color: UiSurface.edge; border-radius: UiRadius.menu;
|
||||
VerticalLayout {
|
||||
padding: 12px; spacing: 10px;
|
||||
HorizontalLayout {
|
||||
height: 28px; spacing: 8px;
|
||||
WorkbenchButton { text: root.chinese ? "主目录" : "Home"; quiet: true; height: 28px; clicked => { root.navigate(""); directory-popup.close(); } }
|
||||
WorkbenchButton { text: root.chinese ? "磁盘" : "Drives"; quiet: true; height: 28px; clicked => { root.navigate("@roots"); directory-popup.close(); } }
|
||||
}
|
||||
tree-list := Rectangle {
|
||||
out property <length> content-gutter: 18px;
|
||||
vertical-stretch: 1;
|
||||
clip: true;
|
||||
ListView {
|
||||
width: parent.width; height: parent.height;
|
||||
viewport-width: self.visible-width;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
for node[index] in root.tree: Rectangle {
|
||||
x: 0px; width: tree-list.width - tree-list.content-gutter;
|
||||
height: 29px; border-radius: UiRadius.control;
|
||||
background: node.selected ? ControlStyle.selected.with-alpha(0.3) : tree-touch.has-hover ? ControlStyle.hover.with-alpha(0.2) : transparent;
|
||||
Text { x: 24px + min(node.depth, 8) * 12px; width: parent.width - self.x - 4px; height: parent.height; text: node.name; color: node.selected ? WorkbenchStyle.accent : WorkbenchStyle.text; font-size: ImGuiFontStyle.base; vertical-alignment: center; overflow: elide; }
|
||||
tree-touch := TouchArea { clicked => { root.tree-open(index); directory-popup.close(); } }
|
||||
WorkbenchButton { x: min(node.depth, 8) * 12px; width: 22px; height: 28px; quiet: true; icon: node.expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; clicked => { root.tree-toggle(index); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
BrowserCheckBox { width: parent.width; show-label: true; label: root.chinese ? "显示隐藏项" : "Show hidden files"; checked: root.show-hidden; toggled => { root.toggle-hidden(); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component FileBrowserWindow inherits Window {
|
||||
title: root.chinese ? "文件传输" : "File transfer";
|
||||
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||
preferred-width: 900px; preferred-height: 620px;
|
||||
min-width: 760px; min-height: 500px;
|
||||
background: WorkbenchStyle.canvas;
|
||||
default-font-size: ImGuiFontStyle.base;
|
||||
in property <bool> chinese: true;
|
||||
in property <string> send-local-text: root.chinese ? "发送到本机" : "Send to local";
|
||||
in property <string> send-remote-text: root.chinese ? "发送到远端" : "Send to remote";
|
||||
in property <string> clear-completed-text: root.chinese ? "清除已完成" : "Clear completed";
|
||||
in property <string> open-directory-text: root.chinese ? "打开目录" : "Open folder";
|
||||
in property <bool> connected: true;
|
||||
in property <string> peer-name;
|
||||
in property <string> local-role;
|
||||
in property <string> remote-role;
|
||||
in-out property <string> local-path;
|
||||
in-out property <string> remote-path;
|
||||
in property <string> local-current-path;
|
||||
in property <string> remote-current-path;
|
||||
in-out property <string> local-filter;
|
||||
in-out property <string> remote-filter;
|
||||
in property <string> local-status;
|
||||
in property <string> remote-status;
|
||||
in property <string> local-error;
|
||||
in property <string> remote-error;
|
||||
in property <bool> local-busy;
|
||||
in property <bool> remote-busy;
|
||||
in property <bool> local-up;
|
||||
in property <bool> remote-up;
|
||||
in property <bool> local-all;
|
||||
in property <bool> remote-all;
|
||||
in property <bool> local-hidden;
|
||||
in property <bool> remote-hidden;
|
||||
in property <int> local-sort-column;
|
||||
in property <int> remote-sort-column;
|
||||
in property <bool> local-sort-descending;
|
||||
in property <bool> remote-sort-descending;
|
||||
in property <int> local-selected;
|
||||
in property <int> remote-selected;
|
||||
in property <[BrowserEntry]> local-entries;
|
||||
in property <[BrowserEntry]> remote-entries;
|
||||
in property <[BrowserTreeEntry]> local-tree;
|
||||
in property <[BrowserTreeEntry]> remote-tree;
|
||||
in property <[BrowserTask]> tasks;
|
||||
in property <string> task-summary;
|
||||
in property <string> notice;
|
||||
in property <bool> notice-error;
|
||||
in property <bool> can-upload;
|
||||
in property <bool> can-download;
|
||||
in property <bool> source-remote;
|
||||
in property <bool> transfer-ready;
|
||||
in property <int> selection-revision;
|
||||
in property <bool> drag-active;
|
||||
in property <int> drag-count;
|
||||
in-out property <bool> tasks-expanded: false;
|
||||
private property <length> task-list-max-height: min(144px, max(72px, root.height - 440px));
|
||||
private property <int> selected-count: root.local-selected + root.remote-selected;
|
||||
private property <bool> path-pending: root.local-path != root.local-current-path || root.remote-path != root.remote-current-path;
|
||||
private property <bool> notice-visible: false;
|
||||
changed notice => { root.notice-visible = root.notice != ""; }
|
||||
Timer {
|
||||
interval: 3500ms;
|
||||
running: root.notice-visible && !root.notice-error;
|
||||
triggered => { root.notice-visible = false; }
|
||||
}
|
||||
callback navigate(bool, string);
|
||||
callback refresh(bool);
|
||||
callback up(bool);
|
||||
callback select-entry(bool, int);
|
||||
callback open-entry(bool, int);
|
||||
callback open-directory(bool, string);
|
||||
callback select-all(bool);
|
||||
callback clear-selection;
|
||||
callback tree-open(bool, int);
|
||||
callback tree-toggle(bool, int);
|
||||
callback filter(bool, string);
|
||||
callback toggle-hidden(bool);
|
||||
callback sort(bool, int);
|
||||
callback transfer(bool);
|
||||
callback cancel-task(int);
|
||||
callback clear-finished;
|
||||
pure callback drag-data(bool, string, int) -> data-transfer;
|
||||
callback drag-state(data-transfer, bool);
|
||||
callback can-drop(bool, data-transfer) -> bool;
|
||||
callback dropped(bool, data-transfer) -> bool;
|
||||
callback cancel-drag;
|
||||
forward-focus: focus;
|
||||
SurfaceSeparator { x: 0px; y: 0px; width: parent.width; }
|
||||
focus := FocusScope {
|
||||
focus-on-click: true;
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape { root.cancel-drag(); return accept; }
|
||||
reject
|
||||
}
|
||||
focus-lost => { if root.drag-active { root.cancel-drag(); } }
|
||||
VerticalLayout {
|
||||
spacing: 12px; padding: 16px;
|
||||
HorizontalLayout {
|
||||
height: 32px; spacing: 12px;
|
||||
Text { text: root.chinese ? "文件传输" : "File transfer"; font-size: ImGuiFontStyle.section-title; font-weight: 600; color: WorkbenchStyle.text; vertical-alignment: center; horizontal-stretch: 1; }
|
||||
Text { text: root.peer-name; color: WorkbenchStyle.muted; vertical-alignment: center; font-size: ImGuiFontStyle.base; overflow: elide; }
|
||||
Rectangle {
|
||||
width: root.chinese ? 104px : 144px;
|
||||
WorkbenchButton {
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: parent.width; height: 28px; icon: FontAwesomeIcons.rotate;
|
||||
text: root.chinese ? "刷新目录" : "Refresh folders";
|
||||
text-size: ImGuiFontStyle.base;
|
||||
accessible-label: root.chinese ? "刷新双方目录" : "Refresh both folders";
|
||||
enabled: root.connected && !root.drag-active && !root.local-busy && !root.remote-busy;
|
||||
clicked => { root.refresh(false); root.refresh(true); }
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 1; spacing: 12px;
|
||||
BrowserPane {
|
||||
width: (parent.width - 12px) / 2;
|
||||
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "本机" : "Local"; subtitle: root.local-role;
|
||||
path <=> root.local-path; status: root.local-status; error: root.local-error; busy: root.local-busy;
|
||||
open-directory-text: root.open-directory-text;
|
||||
open-directory(path) => { root.open-directory(false, path); }
|
||||
current-path: root.local-current-path; filter-text <=> root.local-filter;
|
||||
can-up: root.local-up; all-selected: root.local-all; entries: root.local-entries; tree: root.local-tree;
|
||||
show-hidden: root.local-hidden; toggle-hidden => { root.toggle-hidden(false); }
|
||||
sort-column: root.local-sort-column; sort-descending: root.local-sort-descending;
|
||||
sort(column) => { root.sort(false, column); }
|
||||
selected-count: root.local-selected; destination: root.remote-selected > 0;
|
||||
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
|
||||
navigate(path) => { root.navigate(false, path); } up => { root.up(false); }
|
||||
select-entry(index) => { root.select-entry(false, index); } open-entry(index) => { root.open-entry(false, index); }
|
||||
select-all => { root.select-all(false); } tree-open(index) => { root.tree-open(false, index); } tree-toggle(index) => { root.tree-toggle(false, index); }
|
||||
filter(text) => { root.filter(false, text); }
|
||||
drag-data(path, revision) => { return root.drag-data(false, path, revision); }
|
||||
drag-state(data, active) => { root.drag-state(data, active); }
|
||||
can-drop(data) => { return root.can-drop(false, data); }
|
||||
dropped(data) => { return root.dropped(false, data); }
|
||||
}
|
||||
BrowserPane {
|
||||
width: (parent.width - 12px) / 2;
|
||||
horizontal-stretch: 1; chinese: root.chinese; heading: root.chinese ? "远端" : "Remote"; subtitle: root.remote-role; enabled: root.connected;
|
||||
path <=> root.remote-path; status: root.remote-status; error: root.remote-error; busy: root.remote-busy;
|
||||
open-directory-text: root.open-directory-text;
|
||||
open-directory(path) => { root.open-directory(true, path); }
|
||||
current-path: root.remote-current-path; filter-text <=> root.remote-filter;
|
||||
can-up: root.remote-up; all-selected: root.remote-all; entries: root.remote-entries; tree: root.remote-tree;
|
||||
show-hidden: root.remote-hidden; toggle-hidden => { root.toggle-hidden(true); }
|
||||
sort-column: root.remote-sort-column; sort-descending: root.remote-sort-descending;
|
||||
sort(column) => { root.sort(true, column); }
|
||||
selected-count: root.remote-selected; destination: root.local-selected > 0;
|
||||
transfer-ready: root.transfer-ready; selection-revision: root.selection-revision; dragging: root.drag-active; drag-count: root.drag-count;
|
||||
navigate(path) => { root.navigate(true, path); } up => { root.up(true); }
|
||||
select-entry(index) => { root.select-entry(true, index); } open-entry(index) => { root.open-entry(true, index); }
|
||||
select-all => { root.select-all(true); } tree-open(index) => { root.tree-open(true, index); } tree-toggle(index) => { root.tree-toggle(true, index); }
|
||||
filter(text) => { root.filter(true, text); }
|
||||
drag-data(path, revision) => { return root.drag-data(true, path, revision); }
|
||||
drag-state(data, active) => { root.drag-state(data, active); }
|
||||
can-drop(data) => { return root.can-drop(true, data); }
|
||||
dropped(data) => { return root.dropped(true, data); }
|
||||
}
|
||||
}
|
||||
WorkbenchCard {
|
||||
height: 83px + task-content.height;
|
||||
VerticalLayout {
|
||||
spacing: 0px; padding: 1px;
|
||||
HorizontalLayout {
|
||||
height: 48px; padding-left: 12px; padding-right: 12px; spacing: 12px;
|
||||
VerticalLayout {
|
||||
alignment: center; spacing: 3px; horizontal-stretch: 1;
|
||||
Text {
|
||||
text: root.path-pending ? (root.chinese ? "按 Enter 打开路径" : "Press Enter to open the path")
|
||||
: root.drag-active ? (root.chinese ? "松手复制 · Esc 取消" : "Release to copy · Esc to cancel")
|
||||
: root.notice-error ? root.notice
|
||||
: root.selected-count > 0 ? (root.chinese ? "已选 " + root.selected-count + " 项 · " + (root.source-remote ? "远端 → 本机" : "本机 → 远端")
|
||||
: (root.source-remote ? "Remote → Local" : "Local → Remote") + " · " + root.selected-count + " selected")
|
||||
: root.notice-visible ? root.notice
|
||||
: (root.chinese ? "选择文件,或拖到另一侧" : "Select files or drag to the other side");
|
||||
color: root.notice-error && !root.drag-active && !root.path-pending ? UiSurface.error
|
||||
: root.selected-count > 0 ? WorkbenchStyle.text : WorkbenchStyle.muted;
|
||||
font-size: ImGuiFontStyle.base; font-weight: 400; overflow: elide;
|
||||
}
|
||||
if root.selected-count > 0 && !root.path-pending && !root.drag-active && !root.notice-error: Text {
|
||||
text: (root.chinese ? "目标:" : "To: ") + (root.source-remote ? root.local-current-path : root.remote-current-path);
|
||||
color: WorkbenchStyle.muted;
|
||||
font-size: ImGuiFontStyle.body; overflow: elide;
|
||||
}
|
||||
}
|
||||
if root.selected-count > 0: Rectangle {
|
||||
width: root.chinese ? 76px : 100px;
|
||||
WorkbenchButton {
|
||||
y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true;
|
||||
text-size: ImGuiFontStyle.base; text: root.chinese ? "取消选择" : "Clear selection";
|
||||
enabled: !root.drag-active; clicked => { root.clear-selection(); }
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: max(root.chinese ? 148px : 160px, send-label-measure.preferred-width + 32px);
|
||||
send-label-measure := Text {
|
||||
visible: false;
|
||||
text: root.source-remote ? root.send-local-text : root.send-remote-text;
|
||||
font-size: ImGuiFontStyle.base; font-weight: 600;
|
||||
}
|
||||
WorkbenchButton {
|
||||
y: (parent.height - self.height) / 2; width: parent.width; height: 32px; primary: true;
|
||||
text-size: ImGuiFontStyle.base;
|
||||
enabled: !root.drag-active && (root.source-remote ? root.can-download : root.can-upload);
|
||||
text: send-label-measure.text;
|
||||
clicked => { root.transfer(root.source-remote); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; } }
|
||||
Rectangle {
|
||||
height: 32px;
|
||||
HorizontalLayout {
|
||||
padding-left: 10px; padding-right: 10px; spacing: 8px;
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
WorkbenchButton { x: 0px; width: min(parent.width, task-label.preferred-width + 46px); height: 32px; quiet: true; accessible-label: task-label.text; clicked => { root.tasks-expanded = !root.tasks-expanded; } }
|
||||
Text { x: 8px; width: 14px; height: parent.height; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: ImGuiFontStyle.base; color: ControlStyle.icon; vertical-alignment: center; text: root.tasks-expanded ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-right; }
|
||||
task-label := Text { x: 30px; width: parent.width - 40px; height: parent.height; font-size: ImGuiFontStyle.base; color: WorkbenchStyle.muted; vertical-alignment: center; overflow: elide; text: (root.chinese ? "传输任务" : "Transfers") + " · " + (root.tasks.length == 0 ? (root.chinese ? "暂无任务" : "No tasks") : root.task-summary != "" ? root.task-summary : root.tasks.length); }
|
||||
}
|
||||
if root.tasks-expanded: Rectangle {
|
||||
width: max(100px, clear-label-measure.preferred-width + 24px);
|
||||
clear-label-measure := Text {
|
||||
visible: false; text: root.clear-completed-text;
|
||||
font-size: ImGuiFontStyle.body; font-weight: 500;
|
||||
}
|
||||
WorkbenchButton { y: (parent.height - self.height) / 2; width: parent.width; height: 28px; quiet: true; text-size: ImGuiFontStyle.body; text: root.clear-completed-text; clicked => { root.clear-finished(); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
task-content := Rectangle {
|
||||
height: root.tasks-expanded ? min(root.task-list-max-height, root.tasks.length == 0 ? 40px : task-rows.preferred-height) : 0px;
|
||||
visible: root.tasks-expanded;
|
||||
clip: true;
|
||||
SurfaceSeparator { x: 12px; y: 0px; width: parent.width - 24px; }
|
||||
if root.tasks.length == 0: Text { text: root.chinese ? "暂无传输任务" : "No transfers yet"; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.base; horizontal-alignment: center; vertical-alignment: center; }
|
||||
task-list := BrowserScrollView {
|
||||
x: 16px; width: parent.width - 32px; height: parent.height;
|
||||
visible: root.tasks.length > 0;
|
||||
task-rows := VerticalLayout {
|
||||
spacing: 2px; padding-top: 4px; padding-bottom: 4px; padding-right: task-list.content-gutter;
|
||||
for task in root.tasks: Rectangle {
|
||||
height: task.active ? 46px : 32px;
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
Text { width: 18px; text: task.receiving ? "↓" : "↑"; color: WorkbenchStyle.accent; font-size: 16px; vertical-alignment: center; }
|
||||
VerticalLayout {
|
||||
padding-top: 5px; padding-bottom: 5px; spacing: 3px; alignment: center; horizontal-stretch: 1;
|
||||
Rectangle {
|
||||
height: 16px;
|
||||
task-name := Text {
|
||||
x: 0px;
|
||||
width: min(self.preferred-width, max(0px, parent.width - task-size.preferred-width - 8px));
|
||||
height: parent.height; text: task.name; color: WorkbenchStyle.text;
|
||||
font-size: ImGuiFontStyle.base; overflow: elide; vertical-alignment: center;
|
||||
}
|
||||
task-size := Text {
|
||||
x: task-name.width + 8px; width: self.preferred-width; height: parent.height;
|
||||
text: task.size; color: WorkbenchStyle.muted;
|
||||
font-size: ImGuiFontStyle.body; vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
if task.active: Text { text: task.detail; color: WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; overflow: elide; }
|
||||
}
|
||||
Rectangle { width: root.width < 820px ? 80px : 110px; Rectangle { x: 0px; y: (parent.height - 4px) / 2; width: parent.width; height: 4px; background: WorkbenchStyle.separator; border-radius: 2px; Rectangle { x: 0px; y: 0px; width: parent.width * task.progress; height: 4px; border-radius: 2px; background: task.failed ? UiSurface.error : WorkbenchStyle.accent; } } }
|
||||
Text { width: root.width < 820px ? 92px : 110px; text: task.status; color: task.failed ? UiSurface.error : WorkbenchStyle.muted; font-size: ImGuiFontStyle.body; vertical-alignment: center; horizontal-alignment: right; }
|
||||
Rectangle { width: 56px; if task.can-cancel: WorkbenchButton { y: (parent.height - self.height) / 2; width: 56px; height: 26px; text: root.chinese ? "取消" : "Cancel"; text-size: ImGuiFontStyle.body; quiet: true; clicked => { root.cancel-task(task.id); } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export component RoundedLineEdit inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
in property <length> font-size: 12px;
|
||||
in property <color> normal-outline-color: UiSurface.edge;
|
||||
in property <float> tint-opacity: 1;
|
||||
in property <brush> surrounding-background: white;
|
||||
callback accepted();
|
||||
callback edited();
|
||||
@@ -36,6 +37,7 @@ export component RoundedLineEdit inherits Rectangle {
|
||||
// A visual-only tint preserves the standard editor and its built-in actions.
|
||||
Rectangle {
|
||||
width: parent.width; height: parent.height;
|
||||
opacity: root.tint-opacity;
|
||||
background: !root.enabled ? transparent
|
||||
: editor.has-focus ? ControlStyle.input-focus-tint : ControlStyle.input-tint;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export component WorkbenchButton inherits FlatSurface {
|
||||
in property <string> icon;
|
||||
in property <string> tooltip;
|
||||
in property <bool> tooltip-above: false;
|
||||
in property <bool> tooltip-align-left: false;
|
||||
in property <bool> tooltip-suppressed: false;
|
||||
in property <color> icon-color: root.destructive ? white : root.primary
|
||||
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
|
||||
@@ -99,15 +100,12 @@ export component WorkbenchButton inherits FlatSurface {
|
||||
button-focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
focus-gained(reason) => {
|
||||
if reason == FocusReason.tab-navigation {
|
||||
root.keyboard-focus = true;
|
||||
} else if reason == FocusReason.pointer-click {
|
||||
root.keyboard-focus = false;
|
||||
}
|
||||
root.keyboard-focus = reason == FocusReason.tab-navigation;
|
||||
}
|
||||
focus-lost => { root.keyboard-focus = false; }
|
||||
key-pressed(event) => {
|
||||
root.keyboard-focus = true;
|
||||
if event.text == Key.Return || event.text == " " {
|
||||
root.keyboard-focus = true;
|
||||
root.clicked();
|
||||
return accept;
|
||||
}
|
||||
@@ -123,7 +121,7 @@ export component WorkbenchButton inherits FlatSurface {
|
||||
}
|
||||
// Keep focus for subsequent keyboard activation without leaving a
|
||||
// keyboard focus ring behind after a pointer click.
|
||||
clicked => { root.keyboard-focus = false; button-focus.focus(); root.clicked(); }
|
||||
clicked => { button-focus.focus(); root.keyboard-focus = false; root.clicked(); }
|
||||
}
|
||||
}
|
||||
tooltip-timer := Timer {
|
||||
@@ -132,7 +130,7 @@ export component WorkbenchButton inherits FlatSurface {
|
||||
triggered => { root.tooltip-visible = true; }
|
||||
}
|
||||
if root.tooltip-active && root.tooltip-visible: Rectangle {
|
||||
x: min(0px, root.width - self.width);
|
||||
x: root.tooltip-align-left ? 0px : min(0px, root.width - self.width);
|
||||
y: root.tooltip-above ? -self.height - 6px : root.height + 6px;
|
||||
width: min(260px, tip.preferred-width + 20px);
|
||||
height: max(28px, tip.preferred-height + 12px);
|
||||
|
||||
@@ -126,6 +126,42 @@ float MainWindowInitialPositionYOffset() {
|
||||
}
|
||||
}
|
||||
|
||||
bool CenterNativeWindowOnScreen(void *opaque_slint_view) {
|
||||
@autoreleasepool {
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
NSWindow *window = slint_view.window;
|
||||
NSScreen *screen = window.screen ?: NSScreen.mainScreen;
|
||||
if (!window || !screen) return false;
|
||||
CenterWindowOnScreen(window, screen);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureWhiteWindowChrome(void *opaque_slint_view) {
|
||||
@autoreleasepool {
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
NSWindow *window = slint_view.window;
|
||||
if (window == nil ||
|
||||
(window.styleMask & NSWindowStyleMaskTitled) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Winit creates a transparent AppKit backing even when Slint paints an
|
||||
// opaque client area. macOS 27 can expose that backing in the titlebar.
|
||||
// Use a fixed white backing instead of AppKit's theme-dependent titlebar
|
||||
// material. Aqua keeps the native title and controls readable on white.
|
||||
window.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
||||
window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
|
||||
window.titlebarAppearsTransparent = YES;
|
||||
window.opaque = YES;
|
||||
window.backgroundColor = NSColor.whiteColor;
|
||||
NSView *frame_view = window.contentView.superview;
|
||||
frame_view.needsLayout = YES;
|
||||
[frame_view layoutSubtreeIfNeeded];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
|
||||
@autoreleasepool {
|
||||
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
|
||||
@@ -150,21 +186,8 @@ bool ConfigureMainWindowChrome(void *opaque_slint_view, bool gallery) {
|
||||
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
|
||||
NSWindowCollectionBehaviorFullScreenNone;
|
||||
|
||||
// Winit creates a transparent AppKit backing even when Slint paints an
|
||||
// opaque client area. macOS 27 can expose that backing in the titlebar.
|
||||
// Use a fixed white backing instead of AppKit's theme-dependent titlebar
|
||||
// material. Aqua keeps the native title and controls readable on white.
|
||||
window.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
||||
window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua];
|
||||
window.titlebarAppearsTransparent = YES;
|
||||
window.opaque = YES;
|
||||
window.backgroundColor = NSColor.whiteColor;
|
||||
HideMainWindowZoomButton(window);
|
||||
|
||||
NSView *frame_view = window.contentView.superview;
|
||||
frame_view.needsLayout = YES;
|
||||
[frame_view layoutSubtreeIfNeeded];
|
||||
return true;
|
||||
return ConfigureWhiteWindowChrome(opaque_slint_view);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ namespace crossdesk {
|
||||
// left-mouse-down event. Returns false when no suitable native event exists.
|
||||
bool StartNativeWindowDrag();
|
||||
|
||||
// Gives a titled AppKit window an opaque white backing, preserving its resize
|
||||
// and fullscreen behavior. Returns false until its native window is ready.
|
||||
bool ConfigureWhiteWindowChrome(void* slint_view);
|
||||
// Centers the complete AppKit window frame in its screen's usable area.
|
||||
// Returns false until its native window and screen are ready.
|
||||
bool CenterNativeWindowOnScreen(void* slint_view);
|
||||
|
||||
// Configures the connection or DeskHub window with a white native titlebar.
|
||||
// Only DeskHub is resizable. |slint_view| is its AppKit NSView*.
|
||||
// Returns false while the native window or its titlebar controls are not ready.
|
||||
|
||||
@@ -24,6 +24,9 @@ class FileReceiver {
|
||||
bool OnData(const char* data, size_t size) { return session_.Receive(data, size); }
|
||||
void Poll() { session_.Pump(); }
|
||||
void Cancel() { session_.Cancel(); }
|
||||
bool ReserveDirectory(uint32_t id, const std::filesystem::path& directory) {
|
||||
return session_.ReserveDirectory(id, directory);
|
||||
}
|
||||
// Set once, before the receiver is published to transport callbacks.
|
||||
void SetOnSendAck(OnSendAck cb) { on_send_ack_ = std::move(cb); }
|
||||
void SetOnReceiveProgress(OnReceiveProgress cb) { on_receive_progress_ = std::move(cb); }
|
||||
|
||||
Reference in New Issue
Block a user