#include "browser.h" #include "../../db/db_manager.h" #include "../../transport/gui_bridge.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include "member_files/member_files.h" #include "mem.h" #include "debug_config.h" #include "json_flat.h" } static QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } // Внешний drag выдаёт только готовый локальный файл; обычный MIME дерева не подходит системному менеджеру. class MemberFileTree : public QTreeWidget { public: using QTreeWidget::QTreeWidget; std::function exportRequested; protected: void startDrag(Qt::DropActions) override { exportRequested(); } }; // Системный менеджер может запросить URI после drop. До завершения скачивания путь не выдаётся. class DeferredFileMimeData : public QMimeData { public: std::function localFile; QStringList formats() const override { return {QStringLiteral("text/uri-list")}; } bool hasFormat(const QString& format) const override { return format == QStringLiteral("text/uri-list"); } protected: #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QVariant retrieveData(const QString& format, QMetaType type) const override { #else QVariant retrieveData(const QString& format, QVariant::Type type) const override { #endif if (!hasFormat(format)) return {}; QString local = localFile(); if (local.isEmpty()) return {}; const_cast(this)->setUrls({QUrl::fromLocalFile(local)}); return QMimeData::retrieveData(format, type); } }; static QString quotaSize(quint64 bytes) { return bytes <= (quint64)std::numeric_limits::max() ? QLocale().formattedDataSize((qint64)bytes, 2) : text("%1 bytes").arg(QLocale().toString(bytes)); } static quint64 requestId() { quint64 id; do { id = QRandomGenerator::global()->generate64(); } while (!id); return id; } static bool copyPath(char* out, const QString& value) { QByteArray bytes = value.toUtf8(); if (bytes.size() > MF_PATH_MAX || bytes.contains('\0')) return false; memcpy(out, bytes.constData(), bytes.size()); out[bytes.size()] = 0; return true; } MemberFileBrowser::MemberFileBrowser(DbManager* db, QWidget* parent) : QDialog(parent), m_db(db) { m_uploadPool.setMaxThreadCount(1); setAttribute(Qt::WA_DeleteOnClose); setWindowTitle(text("Group files")); resize(940, 600); auto* layout = new QVBoxLayout(this); auto* selectors = new QHBoxLayout; m_servers = new QComboBox(this); m_owners = new QComboBox(this); selectors->addWidget(new QLabel(text("Media server:"), this)); selectors->addWidget(m_servers, 1); selectors->addWidget(new QLabel(text("Member:"), this)); selectors->addWidget(m_owners, 1); layout->addLayout(selectors); m_quota = new QLabel(text("Select a member to see storage quota"), this); m_quota->setObjectName("memberFilesQuota"); m_quota->setWordWrap(true); layout->addWidget(m_quota); auto* quotaTimer = new QTimer(this); quotaTimer->setInterval(2000); connect(quotaTimer, &QTimer::timeout, this, [this]() { if (isVisible()) requestQuota(); }); quotaTimer->start(); m_navigation = new QWidget(this); auto* navigation = new QHBoxLayout(m_navigation); navigation->setContentsMargins(0, 0, 0, 0); auto* up = new QPushButton(text("Up"), this); auto* reload = new QPushButton(text("Refresh"), this); m_location = new QLineEdit(this); m_location->setPlaceholderText(text("Path inside the member directory")); navigation->addWidget(up); navigation->addWidget(m_location, 1); navigation->addWidget(reload); layout->addWidget(m_navigation); auto* tree = new MemberFileTree(this); m_entries = tree; m_entries->setRootIsDecorated(false); tree->exportRequested = [this] { exportFile(false); }; m_entries->setDragEnabled(true); m_entries->setHeaderLabels({text("Name"), text("Type"), text("Size"), text("Modified")}); m_entries->header()->setSectionResizeMode(0, QHeaderView::Stretch); m_entries->setSelectionMode(QAbstractItemView::SingleSelection); layout->addWidget(m_entries, 1); auto* actions = new QHBoxLayout; m_upload = new QPushButton(text("Upload…"), this); m_download = new QPushButton(text("Download…"), this); m_mkdir = new QPushButton(text("New folder…"), this); m_rename = new QPushButton(text("Rename…"), this); m_remove = new QPushButton(text("Delete"), this); m_cancel = new QPushButton(text("Cancel transfer"), this); for (auto* button : {m_upload, m_download, m_mkdir, m_rename, m_remove, m_cancel}) actions->addWidget(button); layout->addLayout(actions); m_status = new QLabel(this); layout->addWidget(m_status); m_uploadStatus = new QLabel(this); m_uploadStatus->setObjectName("memberFilesUploadStatus"); m_uploadStatus->setWordWrap(true); layout->addWidget(m_uploadStatus); m_progress = new QProgressBar(this); m_progress->setRange(0, 1000); m_progress->hide(); layout->addWidget(m_progress); m_copyAction = new QAction(text("Copy file"), this); m_copyAction->setObjectName("memberFilesCopy"); m_copyAction->setShortcut(QKeySequence::Copy); m_copyAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); m_entries->addAction(m_copyAction); connect(m_copyAction, &QAction::triggered, this, [this] { exportFile(true); }); auto dropTargets = findChildren(); dropTargets.append(this); for (auto* target : dropTargets) { target->setAcceptDrops(true); target->installEventFilter(this); } m_dropHint = new QLabel(m_entries->viewport()); m_dropHint->setObjectName("memberFilesDropHint"); m_dropHint->setAttribute(Qt::WA_TransparentForMouseEvents); m_dropHint->setAlignment(Qt::AlignCenter); m_dropHint->setStyleSheet("QLabel { border: 2px dashed palette(highlight); background: palette(window); color: palette(text); }"); m_dropHint->setText(text("Copy to my files")); m_dropHint->hide(); DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "file browser: drop targets installed count=%d", int(dropTargets.size())); connect(reload, &QPushButton::clicked, this, [this]() { m_staleRetries = 0; refresh(); }); connect(up, &QPushButton::clicked, this, [this]() { if (m_path.isEmpty()) { m_owners->setCurrentIndex(0); return; } int slash = m_path.lastIndexOf('/'); navigate(slash < 0 ? QString() : m_path.left(slash)); }); connect(m_location, &QLineEdit::returnPressed, this, [this]() { navigate(m_location->text()); }); connect(m_servers, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { refresh(); }); connect(m_owners, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { navigate(QString()); }); connect(m_entries, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* item) { enter(item); }); connect(m_entries, &QTreeWidget::itemSelectionChanged, this, &MemberFileBrowser::updateActions); connect(m_upload, &QPushButton::clicked, this, &MemberFileBrowser::upload); connect(m_download, &QPushButton::clicked, this, [this]() { download(false); }); connect(m_mkdir, &QPushButton::clicked, this, [this]() { mutate(MF_MKDIR); }); connect(m_rename, &QPushButton::clicked, this, [this]() { mutate(MF_RENAME); }); connect(m_remove, &QPushButton::clicked, this, [this]() { mutate(MF_REMOVE_PATH); }); connect(m_cancel, &QPushButton::clicked, this, &MemberFileBrowser::cancel); m_entries->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_entries, &QWidget::customContextMenuRequested, this, [this](const QPoint& point) { auto* item = m_entries->itemAt(point); if (!item) return; m_entries->setCurrentItem(item); QMenu menu(this); auto* open = menu.addAction(text("Open")); auto* info = menu.addAction(text("Properties")); menu.addAction(m_copyAction); auto* selected = menu.exec(m_entries->viewport()->mapToGlobal(point)); if (selected == open) enter(item); else if (selected == info && owner()) { mf_message q{}; q.command = MF_STAT; q.owner = owner(); if (copyPath(q.path, selectedPath()) && post(q)) m_actions.insert(q.request_id, MF_STAT); } }); updateActions(); } MemberFileBrowser::~MemberFileBrowser() { cancel(); cancelRequest(m_listId); cancelRequest(m_quotaId); for (auto id : m_actions.keys()) cancelRequest(id); m_uploadPool.waitForDone(); } bool MemberFileBrowser::eventFilter(QObject* target, QEvent* event) { if (event->type() == QEvent::DragLeave) { m_dropHint->hide(); return true; } if (event->type() != QEvent::DragEnter && event->type() != QEvent::DragMove && event->type() != QEvent::Drop) return QDialog::eventFilter(target, event); auto* drop = static_cast(event); QString reason; if (drop->source() == m_entries) { m_dropHint->hide(); drop->ignore(); return true; } if (m_dropId || m_uploadBatchId || m_transferId || m_exportId) reason = text("Finish or cancel the current transfer first"); else if (!m_group || !server() || !m_db || !owner() || owner() != m_db->myNodeId() || !gui_bridge_is_uasync_ready()) reason = text("Upload unavailable"); else if (!drop->mimeData()->hasUrls() || drop->mimeData()->urls().isEmpty() || !(drop->possibleActions() & Qt::CopyAction)) reason = text("Drop local files to copy"); QStringList paths; if (reason.isEmpty()) for (const auto& url : drop->mimeData()->urls()) { if (!url.isLocalFile() || url.toLocalFile().isEmpty()) { reason = text("Only local files can be uploaded"); break; } paths.append(url.toLocalFile()); } if (!reason.isEmpty()) { if (event->type() != QEvent::DragMove) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: drop rejected group=%llu owner=%llu path=%s reason=%s", (unsigned long long)m_group, (unsigned long long)owner(), qPrintable(m_path), qPrintable(reason)); m_status->setText(reason); m_dropHint->hide(); drop->ignore(); return true; } drop->setDropAction(Qt::CopyAction); drop->accept(); if (event->type() != QEvent::Drop) { m_dropHint->setGeometry(m_entries->viewport()->rect()); m_dropHint->show(); m_dropHint->raise(); return true; } m_dropHint->hide(); m_dropId = requestId(); updateActions(); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: drop accepted group=%llu server=%llu owner=%llu path=%s files=%d", (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), qPrintable(m_path), int(paths.size())); quint64 id = m_dropId; QTimer::singleShot(0, this, [this, id, paths] { if (m_dropId != id) return; m_dropId = 0; updateActions(); beginUploads(paths, false); }); return true; } void MemberFileBrowser::setChannel(const QString& channel, quint64 selectedOwner) { if (m_dropId || m_uploadBatchId || m_transferId || m_exportId) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: context change blocked during transfer channel=%s", qPrintable(channel)); m_status->setText(text("Finish or cancel the current transfer first")); return; } bool valid = false; quint64 group = channel.toULongLong(&valid); if (!valid || !group || !m_db) return; cancelRequest(m_listId); m_listId = 0; m_group = group; m_path.clear(); m_servers->blockSignals(true); m_owners->blockSignals(true); m_servers->clear(); m_owners->clear(); m_owners->addItem(text("All members"), QVariant::fromValue(0)); QString sql = QString("SELECT node_id,adm_tags,length(adm_tags_sig) FROM peers_%1 WHERE deleted=0 AND source=0 ORDER BY node_id").arg(group); sqlite3_stmt* st = m_db->prepareOrNull(sql.toUtf8().constData()); if (st) { int rc; while ((rc = sqlite3_step(st)) == SQLITE_ROW) { quint64 id = (quint64)sqlite3_column_int64(st, 0); QString label = QString("%1 [%2]").arg(m_db->getDisplayName(id), QString::number(id)); m_owners->addItem(label, QVariant::fromValue(id)); const char* tags = (const char*)sqlite3_column_text(st, 1); char role[16]; if (tags && json_flat_get(tags, "storage", role, sizeof(role)) == 0 && !strcmp(role, "yes") && sqlite3_column_int(st, 2) == 64) m_servers->addItem(label, QVariant::fromValue(id)); } if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: member query failed rc=%d", rc); sqlite3_finalize(st); } int at = m_owners->findData(QVariant::fromValue(selectedOwner)); if (at >= 0) m_owners->setCurrentIndex(at); m_servers->blockSignals(false); m_owners->blockSignals(false); m_staleRetries = 0; refresh(); } quint64 MemberFileBrowser::owner() const { return m_owners->currentData().toULongLong(); } quint64 MemberFileBrowser::server() const { return m_servers->currentData().toULongLong(); } QString MemberFileBrowser::selectedPath() const { auto* item = m_entries->currentItem(); if (!item) return QString(); QString name = item->data(0, Qt::UserRole).toString(); return m_path.isEmpty() ? name : m_path + '/' + name; } QByteArray MemberFileBrowser::selectedVersion() const { auto* item = m_entries->currentItem(); return item ? item->data(0, Qt::UserRole + 2).toByteArray() : QByteArray(); } void MemberFileBrowser::updateActions() { auto* item = m_entries->currentItem(); bool own = owner() && owner() == m_db->myNodeId(); bool available = server() && gui_bridge_is_uasync_ready(); bool file = item && item->data(0, Qt::UserRole + 1).toInt() == MF_FILE; bool busy = m_dropId || m_uploadBatchId || m_transferId || m_exportId; m_servers->setEnabled(!busy); m_owners->setEnabled(!busy); m_navigation->setEnabled(!busy); m_entries->setEnabled(!busy); m_upload->setEnabled(available && own && !busy); m_mkdir->setEnabled(available && own && !busy); m_rename->setEnabled(available && own && item && !busy); m_remove->setEnabled(available && own && item && !busy); m_download->setEnabled(available && owner() && file && !busy); m_cancel->setEnabled(busy); m_copyAction->setEnabled(available && owner() && file && !busy); } bool MemberFileBrowser::post(mf_message& message) { if (!m_group || !server() || !gui_bridge_is_uasync_ready()) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI post rejected cmd=%s group=%llx server=%llx ready=%d", mf_command_text(message.command), (unsigned long long)m_group, (unsigned long long)server(), gui_bridge_is_uasync_ready()); return false; } auto* q = (mf_gui_request*)u_calloc(1, sizeof(mf_gui_request)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: request allocation failed"); return false; } message.request_id = requestId(); message.kind = MF_REQUEST; q->inst = gui_bridge_get_inst(); q->group = m_group; q->server = server(); q->message = message; DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI post id=%llu cmd=%s group=%llx server=%llx owner=%llx path=%s", (unsigned long long)message.request_id, mf_command_text(message.command), (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)message.owner, message.path); gui_bridge_post_uasync_fn(member_files_gui_request, q); return true; } void MemberFileBrowser::cancelRequest(quint64 id) { if (!id || !gui_bridge_is_uasync_ready()) return; auto* q = (mf_cancel_request*)u_calloc(1, sizeof(mf_cancel_request)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: cancel allocation failed"); return; } q->inst = gui_bridge_get_inst(); q->id = id; gui_bridge_post_uasync_fn(member_files_gui_cancel, q); } void MemberFileBrowser::requestQuota() { if (m_quotaId || !owner() || !server() || !gui_bridge_is_uasync_ready()) return; mf_message q{}; q.command = MF_QUOTA; q.owner = owner(); if (post(q)) m_quotaId = q.request_id; else { m_quota->setText(text("Quota unavailable")); DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: GUI quota request failed owner=%llu", (unsigned long long)owner()); } } void MemberFileBrowser::refresh() { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI refresh old_id=%llu group=%llx server=%llx owner=%llx path=%s", (unsigned long long)m_listId, (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), qPrintable(m_path)); cancelRequest(m_listId); m_listId = 0; m_entries->clear(); m_location->setText(m_path); updateActions(); cancelRequest(m_quotaId); m_quotaId = 0; m_refreshGeneration++; m_quota->setToolTip(QString()); m_quota->setText(owner() ? text("Loading quota…") : text("Select a member to see storage quota")); if (!server()) { m_status->setText(text("No media server in this group")); if (owner()) m_quota->setText(text("Quota unavailable")); return; } requestQuota(); mf_message q{}; q.command = MF_LIST; q.owner = owner(); if (!copyPath(q.path, m_path) || !post(q)) { m_status->setText(text("Unable to request directory")); return; } m_listId = q.request_id; m_entries->setSortingEnabled(false); m_status->setText(text("Loading directory…")); } void MemberFileBrowser::navigate(const QString& path) { QByteArray bytes = path.toUtf8(); if (!mf_path_valid(bytes.constData(), 1) || (!owner() && !path.isEmpty())) { m_status->setText(text("Invalid path")); return; } m_path = path; m_staleRetries = 0; refresh(); } void MemberFileBrowser::enter(QTreeWidgetItem* item) { if (item->data(0, Qt::UserRole + 1).toInt() == MF_FILE) { download(true); return; } if (!owner()) { int at = m_owners->findData(QVariant::fromValue(item->data(0, Qt::UserRole).toString().toULongLong())); if (at >= 0) m_owners->setCurrentIndex(at); } else navigate(selectedPath()); } void MemberFileBrowser::onResponse(const uint8_t* data, int length) { if (!data || length < 16) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI short response bytes=%d", length); return; } if (mf_get64(data) != m_group || mf_get64(data + 8) != server()) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI response ignored context group=%llx/%llx server=%llx/%llx", (unsigned long long)mf_get64(data), (unsigned long long)m_group, (unsigned long long)mf_get64(data + 8), (unsigned long long)server()); return; } mf_message r{}; if (mf_decode(data + 16, (size_t)length - 16, &r)) return; DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI response id=%llu list_id=%llu cmd=%s status=%s bytes=%u owner=%llx/%llx path=%s", (unsigned long long)r.request_id, (unsigned long long)m_listId, mf_command_text(r.command), mf_status_text(r.status), r.data_len, (unsigned long long)r.owner, (unsigned long long)owner(), r.path); if (m_uploadBatchId && r.request_id == m_uploadStatId) { m_uploadStatId = 0; QString local = m_uploadQueue.first(), name = QFileInfo(local).fileName(); QString remote = m_path.isEmpty() ? name : m_path + '/' + name; if (r.command != MF_STAT || r.owner != owner() || QString::fromUtf8(r.path) != remote) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: upload STAT context mismatch id=%llu", (unsigned long long)r.request_id); finishUploads(text("Invalid directory response")); return; } if (r.status != MF_OK && r.status != MF_NOT_FOUND) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload STAT failed path=%s status=%s", qPrintable(remote), mf_status_text(r.status)); finishUploads(text("Upload stopped: %1 — %2").arg(name, QString::fromUtf8(mf_status_text(r.status)))); return; } QByteArray version(16, '\0'); if (r.status == MF_OK) { if (r.type != MF_FILE) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload target is a directory path=%s", qPrintable(remote)); finishUploads(text("Upload stopped: %1 — %2").arg(name, text("A folder with this name already exists"))); return; } version = QByteArray((const char*)r.version, 16); quint64 batch = m_uploadBatchId; QPointer guard(this); auto answer = QMessageBox::question(this, text("Replace file"), text("Replace %1?").arg(name), QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, QMessageBox::No); if (!guard || m_uploadBatchId != batch) return; if (answer == QMessageBox::Cancel) { cancel(); return; } if (answer != QMessageBox::Yes) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: upload skipped path=%s", qPrintable(remote)); m_uploadSkipped++; m_uploadQueue.removeFirst(); nextUpload(); return; } } if (!startTransfer(remote, local, true, version)) finishUploads(text("Invalid path or chat is stopped")); return; } if (r.command == MF_QUOTA) { if (r.request_id != m_quotaId || r.owner != owner()) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI stale quota response id=%llu", (unsigned long long)r.request_id); return; } m_quotaId = 0; m_quota->setToolTip(QString()); if (r.status) { m_quota->setText(r.status == MF_BUSY ? text("Loading quota…") : text("Quota unavailable")); m_quota->setToolTip(QString::fromUtf8(mf_status_text(r.status))); if (r.status != MF_BUSY) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: GUI quota failed status=%s", mf_status_text(r.status)); return; } if (r.data_len != MF_QUOTA_BYTES || !mf_get64(r.data + 8)) { m_quota->setText(text("Quota unavailable")); DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "member_files: GUI malformed quota response bytes=%u", r.data_len); return; } quint64 used = mf_get64(r.data), limit = mf_get64(r.data + 8), reserved = mf_get64(r.data + 16); quint64 files = mf_get64(r.data + 24), directories = mf_get64(r.data + 32); QString label = text("Storage: %1 used / %2 total").arg(quotaSize(used), quotaSize(limit)); label += text(" · Files: %1 · Directories: %2").arg(QLocale().toString(files), QLocale().toString(directories)); if (reserved) label += text(" · Reserved for uploads: %1").arg(quotaSize(reserved)); m_quota->setText(label); m_quota->setToolTip(text("Used: %1 bytes\nLimit: %2 bytes\nReserved: %3 bytes\nFiles: %4\nDirectories: %5") .arg(used).arg(limit).arg(reserved).arg(files).arg(directories)); DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI quota owner=%llu used=%llu limit=%llu reserved=%llu files=%llu directories=%llu", (unsigned long long)owner(), (unsigned long long)used, (unsigned long long)limit, (unsigned long long)reserved, (unsigned long long)files, (unsigned long long)directories); return; } if (r.request_id == m_listId && r.owner == owner() && QString::fromUtf8(r.path) == m_path) { if (r.status) { m_listId = 0; if (r.status == MF_BUSY) { quint64 generation = m_refreshGeneration; m_status->setText(text("Loading directory…")); QTimer::singleShot(1000, this, [this, generation]() { if (generation == m_refreshGeneration) refresh(); }); return; } if (r.status == MF_STALE && m_staleRetries++ < 1) { refresh(); return; } m_entries->clear(); m_status->setText(QString::fromUtf8(mf_status_text(r.status))); return; } for (unsigned at = 0; at < r.data_len;) { if (r.data_len - at < MF_ENTRY_HEADER) { m_status->setText(text("Invalid directory response")); return; } const uint8_t* p = r.data + at; unsigned n = p[0] | ((unsigned)p[1] << 8); if (!n || n > MF_NAME_MAX || n > r.data_len - at - MF_ENTRY_HEADER || (p[2] != MF_FILE && p[2] != MF_DIRECTORY)) { m_status->setText(text("Invalid directory response")); return; } QString name = QString::fromUtf8((const char*)p + MF_ENTRY_HEADER, (int)n); QString label = owner() ? name : QString("%1 [%2]").arg(m_db->getDisplayName(name.toULongLong()), name); auto* item = new QTreeWidgetItem(m_entries, {label, p[2] == MF_DIRECTORY ? text("Folder") : text("File"), p[2] == MF_FILE ? QString::number(mf_get64(p + 3)) : QString(), mf_get64(p + 11) ? QDateTime::fromSecsSinceEpoch((qint64)mf_get64(p + 11)).toString(Qt::ISODate) : QString()}); item->setData(0, Qt::UserRole, name); item->setData(0, Qt::UserRole + 1, p[2]); item->setData(0, Qt::UserRole + 2, QByteArray((const char*)p + 19, 16)); item->setToolTip(0, name); at += MF_ENTRY_HEADER + n; } if (r.more) { mf_message q{}; q.command = MF_LIST; q.owner = owner(); q.cursor = r.cursor; memcpy(q.version, r.version, 16); copyPath(q.path, m_path); if (post(q)) m_listId = q.request_id; else { m_listId = 0; m_status->setText(text("Unable to request directory")); } } else { m_listId = 0; m_entries->setSortingEnabled(true); m_entries->sortItems(0, Qt::AscendingOrder); m_status->setText(text("%1 entries").arg(m_entries->topLevelItemCount())); updateActions(); DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI directory ready id=%llu entries=%d", (unsigned long long)r.request_id, m_entries->topLevelItemCount()); } return; } if (!m_actions.contains(r.request_id)) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "member_files: GUI response ignored stale id=%llu cmd=%s", (unsigned long long)r.request_id, mf_command_text(r.command)); return; } uint8_t command = m_actions.take(r.request_id); if (r.status && r.status != MF_CANCELLED) { QMessageBox::warning(this, text("Group files"), QString::fromUtf8(mf_status_text(r.status))); return; } if (command == MF_STAT && !r.status) { QMessageBox::information(this, text("Properties"), QString("%1\n%2: %3\n%4: %5\nUID: %6") .arg(QString::fromUtf8(r.path), text("Size"), QString::number(r.size), text("Modified"), QDateTime::fromSecsSinceEpoch((qint64)r.mtime).toString(Qt::ISODate), QString::number(r.owner))); } else refresh(); } void MemberFileBrowser::upload() { QPointer guard(this); QStringList paths = QFileDialog::getOpenFileNames(this, text("Upload file")); if (guard && !paths.isEmpty()) beginUploads(paths, false); } void MemberFileBrowser::uploadOwnFiles(const QString& channel, const QStringList& paths) { if (m_dropId || m_uploadBatchId || m_transferId || m_exportId) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: drop rejected while busy files=%d", int(paths.size())); QMessageBox::warning(this, text("Group files"), text("Finish or cancel the current transfer first")); return; } if (!channel.toULongLong() || !m_db || !m_db->myNodeId()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: drop has no group or own node channel=%s", qPrintable(channel)); QMessageBox::warning(this, text("Group files"), text("Select a group to upload files")); return; } setChannel(channel, m_db->myNodeId()); beginUploads(paths, true); } // Сервер выбирается до первого запроса; batch ID защищает вложенные циклы диалогов от отмены/закрытия окна. void MemberFileBrowser::beginUploads(const QStringList& paths, bool chooseServer) { if (paths.isEmpty()) return; if (m_dropId || m_uploadBatchId || m_transferId || m_exportId || !server() || !owner() || owner() != m_db->myNodeId() || !gui_bridge_is_uasync_ready()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload unavailable group=%llu server=%llu owner=%llu busy=%d ready=%d", (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), bool(m_dropId || m_uploadBatchId || m_transferId || m_exportId), gui_bridge_is_uasync_ready()); QMessageBox::warning(this, text("Group files"), !server() ? text("No media server in this group") : text("Upload unavailable")); return; } for (const auto& path : paths) { QFileInfo info(path); QString remote = m_path.isEmpty() ? info.fileName() : m_path + '/' + info.fileName(); QByteArray localBytes = path.toUtf8(), remoteBytes = remote.toUtf8(); if (!info.isAbsolute() || info.fileName().isEmpty() || localBytes.contains('\0') || localBytes.size() > MF_PATH_MAX || remoteBytes.contains('\0') || !mf_path_valid(remoteBytes.constData(), 0)) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: invalid upload path=%s", qPrintable(path)); QMessageBox::warning(this, text("Group files"), text("Invalid upload path: %1").arg(path)); return; } } m_uploadQueue = paths; m_uploadQueue.removeDuplicates(); m_uploadTotal = m_uploadQueue.size(); m_uploadDone = m_uploadSkipped = 0; m_uploadBatchId = requestId(); updateActions(); if (chooseServer) { QStringList labels; for (int i = 0; i < m_servers->count(); i++) labels.append(m_servers->itemText(i)); quint64 batch = m_uploadBatchId; QPointer guard(this); bool ok = false; QString selected = QInputDialog::getItem(this, text("Upload files"), text("Upload %1 file(s) to your root directory in group %2. Select media server:").arg(m_uploadTotal).arg(m_group), labels, m_servers->currentIndex(), false, &ok); if (!guard || m_uploadBatchId != batch) return; if (!ok) { cancel(); return; } m_servers->setCurrentIndex(labels.indexOf(selected)); } DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: upload queue started batch=%llu group=%llu server=%llu owner=%llu path=%s files=%d", (unsigned long long)m_uploadBatchId, (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), qPrintable(m_path), m_uploadTotal); m_uploadStatus->setText(text("Checking selected files…")); quint64 batch = m_uploadBatchId; QStringList sources = m_uploadQueue; m_uploadPool.start(QRunnable::create([this, batch, sources] { QString invalid; for (const auto& source : sources) { QFileInfo info(source); if (!info.isFile() || info.isSymLink() || !info.isReadable()) { invalid = source; break; } } QMetaObject::invokeMethod(this, [this, batch, invalid] { if (m_uploadBatchId != batch) return; if (!invalid.isEmpty()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: source rejected path=%s (not a readable regular file)", qPrintable(invalid)); finishUploads(text("Cannot upload %1. Select readable files, not folders or links.").arg(invalid)); return; } nextUpload(); }, Qt::QueuedConnection); })); } void MemberFileBrowser::nextUpload() { if (!m_uploadBatchId || m_transferId || m_uploadStatId) return; if (m_uploadQueue.isEmpty()) { finishUploads(text("Upload queue finished: %1 uploaded, %2 skipped").arg(m_uploadDone).arg(m_uploadSkipped)); refresh(); return; } QString name = QFileInfo(m_uploadQueue.first()).fileName(); m_uploadStatus->setText(text("Uploading %1 of %2: %3").arg(m_uploadDone + m_uploadSkipped + 1).arg(m_uploadTotal).arg(name)); mf_message q{}; q.command = MF_STAT; q.owner = owner(); if (!copyPath(q.path, m_path.isEmpty() ? name : m_path + '/' + name) || !post(q)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: upload STAT could not start name=%s", qPrintable(name)); finishUploads(text("Unable to request directory")); return; } m_uploadStatId = q.request_id; } void MemberFileBrowser::finishUploads(const QString& status) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: upload queue finished batch=%llu uploaded=%d skipped=%d remaining=%d status=%s", (unsigned long long)m_uploadBatchId, m_uploadDone, m_uploadSkipped, int(m_uploadQueue.size()), qPrintable(status)); cancelRequest(m_uploadStatId); m_uploadStatId = 0; m_uploadBatchId = 0; m_uploadQueue.clear(); m_uploadStatus->setText(status); updateActions(); } void MemberFileBrowser::download(bool open) { auto* item = m_entries->currentItem(); if (m_transferId || !owner() || !item || item->data(0, Qt::UserRole + 1).toInt() != MF_FILE) return; QString local = QFileDialog::getSaveFileName(this, text("Save file"), item->data(0, Qt::UserRole).toString()); if (local.isEmpty()) return; m_openAfter = open; startTransfer(selectedPath(), local, false, selectedVersion()); } void MemberFileBrowser::publishExport(const QString& local, bool clipboard) { auto* mime = new QMimeData; QUrl url = QUrl::fromLocalFile(local); mime->setUrls({url}); // Nautilus и KDE также получают явное действие copy, без удаления исходного файла. mime->setData("x-special/gnome-copied-files", QByteArray("copy\n") + url.toEncoded()); mime->setData("application/x-kde-cutselection", "0"); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: exporting local=%s clipboard=%d", qPrintable(local), clipboard); if (clipboard) { QGuiApplication::clipboard()->setMimeData(mime); m_status->setText(text("File copied. Paste it in your file manager.")); } else { auto* drag = new QDrag(m_entries); drag->setMimeData(mime); drag->exec(Qt::CopyAction, Qt::CopyAction); } } // Кеш сохраняется после закрытия окна: URI в системном буфере должен оставаться пригодным для вставки. void MemberFileBrowser::exportFile(bool clipboard) { if (!m_copyAction->isEnabled()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: export unavailable group=%llu owner=%llu busy=%d", (unsigned long long)m_group, (unsigned long long)owner(), bool(m_dropId || m_uploadBatchId || m_transferId || m_exportId)); m_status->setText(text("Select a file and finish the current transfer first")); return; } QString remote = selectedPath(); QByteArray version = selectedVersion(); if (version.size() != 16 || !mf_path_valid(remote.toUtf8().constData(), 0)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: invalid export entry path=%s version_bytes=%d", qPrintable(remote), int(version.size())); m_status->setText(text("Invalid directory response")); return; } QString key = QString("%1:%2:%3:%4:%5").arg(m_group).arg(server()).arg(owner()).arg(remote, QString::fromLatin1(version.toHex())); if (m_exportCache.contains(key)) { publishExport(m_exportCache.value(key), clipboard); return; } QString base = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); if (base.isEmpty()) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: no cache location for export"); m_status->setText(text("Cannot prepare a local copy")); return; } m_exportId = requestId(); m_exportKey = key; m_exportClipboard = clipboard; m_openAfter = false; updateActions(); m_status->setText(text("Downloading a local copy for your file manager…")); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: preparing export group=%llu server=%llu owner=%llu path=%s clipboard=%d", (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), qPrintable(remote), clipboard); quint64 id = m_exportId; QString name = QFileInfo(remote).fileName(); m_uploadPool.start(QRunnable::create([this, id, base, name, remote, version] { QString root = base + "/file-exports", local; if (QDir().mkpath(root)) { QTemporaryDir directory(root + "/XXXXXX"); if (directory.isValid()) { local = directory.filePath(name); directory.setAutoRemove(false); } } QMetaObject::invokeMethod(this, [this, id, local, remote, version] { if (m_exportId != id) return; if (local.isEmpty() || !startTransfer(remote, local, false, version)) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: export could not start remote=%s local=%s", qPrintable(remote), qPrintable(local)); m_exportId = 0; updateActions(); m_status->setText(text("Cannot prepare a local copy")); emit exportFinished(); } }, Qt::QueuedConnection); })); if (!clipboard) { QPointer guard(this); auto* mime = new DeferredFileMimeData; mime->localFile = [guard, id, key] { if (!guard) return QString(); if (guard->m_exportId == id) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "file browser: drag waiting for local copy id=%llu", (unsigned long long)id); QEventLoop wait; QObject::connect(guard, &MemberFileBrowser::exportFinished, &wait, &QEventLoop::quit); QObject::connect(guard, &QObject::destroyed, &wait, &QEventLoop::quit); wait.exec(); } QString local = guard ? guard->m_exportCache.value(key) : QString(); DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "file browser: drag data ready id=%llu local=%s", (unsigned long long)id, qPrintable(local)); return local; }; auto* drag = new QDrag(m_entries); drag->setMimeData(mime); drag->setParent(QCoreApplication::instance()); // MIME живёт до возврата из native drag, даже если окно файлов закрыли во вложенном цикле событий. connect(this, &QObject::destroyed, drag, [] { QDrag::cancel(); }); Qt::DropAction action = drag->exec(Qt::CopyAction, Qt::CopyAction); if (guard && m_exportId == id) cancel(); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: native drag finished action=%d", int(action)); } } bool MemberFileBrowser::startTransfer(const QString& remote, const QString& local, bool uploading, const QByteArray& version) { auto* q = (mf_transfer_request*)u_calloc(1, sizeof(mf_transfer_request)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: transfer allocation failed"); return false; } if (!copyPath(q->remote, remote) || !copyPath(q->local, local) || !mf_path_valid(q->remote, 0) || !gui_bridge_is_uasync_ready()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: transfer rejected remote=%s local=%s ready=%d", qPrintable(remote), qPrintable(local), gui_bridge_is_uasync_ready()); u_free(q); m_status->setText(text("Invalid path or chat is stopped")); return false; } q->inst = gui_bridge_get_inst(); q->id = requestId(); q->group = m_group; q->server = server(); q->owner = owner(); q->upload = uploading; if (version.size() == 16) memcpy(q->version, version.constData(), 16); m_transferId = q->id; m_transferGroup = q->group; m_transferServer = q->server; m_local = local; if (uploading) m_openAfter = false; m_progress->setRange(0, 0); m_progress->show(); m_status->setText(text("Preparing transfer…")); updateActions(); gui_bridge_post_uasync_fn(member_files_gui_transfer, q); return true; } void MemberFileBrowser::onTransfer(const uint8_t* data, int length) { if (length != 51 || mf_get64(data) != m_transferId) return; quint64 done = mf_get64(data + 32), total = mf_get64(data + 40); unsigned status = data[48] | ((unsigned)data[49] << 8); if (total) { m_progress->setRange(0, 1000); m_progress->setValue((int)((long double)done * 1000 / total)); } m_status->setText(text("Transferred %1 / %2 bytes").arg(done).arg(total)); if (data[50] != 2) return; m_transferId = 0; m_progress->hide(); updateActions(); if (m_exportId) { m_exportId = 0; updateActions(); if (status) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: export download failed local=%s status=%s", qPrintable(m_local), mf_status_text(status)); m_status->setText(QString::fromUtf8(mf_status_text(status))); } else { m_exportCache.insert(m_exportKey, m_local); DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: local copy ready path=%s", qPrintable(m_local)); if (m_exportClipboard) publishExport(m_local, true); else m_status->setText(text("Local copy ready")); } emit exportFinished(); return; } if (m_uploadBatchId) { if (status) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload failed local=%s status=%s", qPrintable(m_local), mf_status_text(status)); QString reason = status == MF_INVALID_PATH ? text("Only regular files with valid paths can be uploaded") : QString::fromUtf8(mf_status_text(status)); finishUploads(text("Upload stopped: %1 — %2").arg(QFileInfo(m_local).fileName(), reason)); } else { m_uploadDone++; m_uploadQueue.removeFirst(); nextUpload(); } if (!m_quotaId) requestQuota(); return; } if (status) m_status->setText(QString::fromUtf8(mf_status_text((int)status))); else { m_status->setText(text("Transfer completed")); if (m_openAfter && !QDesktopServices::openUrl(QUrl::fromLocalFile(m_local))) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: open failed path=%s", qPrintable(m_local)); m_status->setText(text("Unable to open file")); } if (m_group == m_transferGroup && server() == m_transferServer) refresh(); } m_openAfter = false; if (!m_quotaId) requestQuota(); } void MemberFileBrowser::mutate(uint8_t command) { if (!owner() || owner() != m_db->myNodeId()) return; mf_message q{}; q.command = command; q.owner = owner(); QString source = selectedPath(); QByteArray version = selectedVersion(); if (command == MF_MKDIR || command == MF_RENAME) { bool ok = false; QString name = QInputDialog::getText(this, command == MF_MKDIR ? text("New folder") : text("Rename"), text("Name:"), QLineEdit::Normal, command == MF_RENAME ? QFileInfo(source).fileName() : QString(), &ok); if (!ok || name.isEmpty()) return; QByteArray bytes = name.toUtf8(); if (name.contains('/') || !mf_path_valid(bytes.constData(), 0)) { m_status->setText(text("Invalid name")); return; } QString target = m_path.isEmpty() ? name : m_path + '/' + name; if (command == MF_MKDIR) source = target; else if (!copyPath(q.target, target)) return; } else if (!m_entries->currentItem() || QMessageBox::question(this, text("Delete"), text("Delete %1?").arg(source)) != QMessageBox::Yes) return; if (!copyPath(q.path, source)) return; if (version.size() == 16) memcpy(q.version, version.constData(), 16); if (post(q)) m_actions.insert(q.request_id, command); } void MemberFileBrowser::cancel() { if (m_exportId) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: export cancelled id=%llu", (unsigned long long)m_exportId); m_exportId = 0; m_status->setText(text("Copy cancelled")); updateActions(); emit exportFinished(); } if (m_dropId) { m_dropId = 0; m_uploadStatus->setText(text("Upload queue cancelled")); updateActions(); } if (m_uploadBatchId) finishUploads(text("Upload queue cancelled")); cancelRequest(m_transferId); }