/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "platform/linux/linux_xdp_file_dialog.h" #include "platform/platform_file_utilities.h" #include "platform/linux/specific_linux.h" #include "storage/localstorage.h" #include "base/qt_adapters.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Platform { namespace FileDialog { namespace XDP { namespace { const char *filterRegExp = "^(.*)\\(([a-zA-Z0-9_.,*? +;#\\-\\[\\]@\\{\\}/!<>\\$%&=^~:\\|]*)\\)$"; QStringList makeFilterList(const QString &filter) { QString f(filter); if (f.isEmpty()) return QStringList(); QString sep(QLatin1String(";;")); int i = f.indexOf(sep, 0); if (i == -1) { if (f.indexOf(QLatin1Char('\n'), 0) != -1) { sep = QLatin1Char('\n'); i = f.indexOf(sep, 0); } } return f.split(sep); } } // namespace bool Use(Type type) { return UseXDGDesktopPortal() && (type != Type::ReadFolder || CanOpenDirectoryWithPortal()); } bool Get( QPointer parent, QStringList &files, QByteArray &remoteContent, const QString &caption, const QString &filter, Type type, QString startFile) { static const auto docRegExp = QRegularExpression("^/run/user/\\d+/doc"); if (cDialogLastPath().isEmpty() || cDialogLastPath().contains(docRegExp)) { InitLastPath(); } XDPFileDialog dialog(parent, caption, QString(), filter); dialog.setModal(true); if (type == Type::ReadFile || type == Type::ReadFiles) { dialog.setFileMode((type == Type::ReadFiles) ? QFileDialog::ExistingFiles : QFileDialog::ExistingFile); dialog.setAcceptMode(QFileDialog::AcceptOpen); } else if (type == Type::ReadFolder) { dialog.setAcceptMode(QFileDialog::AcceptOpen); dialog.setFileMode(QFileDialog::Directory); dialog.setOption(QFileDialog::ShowDirsOnly); } else { dialog.setFileMode(QFileDialog::AnyFile); dialog.setAcceptMode(QFileDialog::AcceptSave); } if (startFile.isEmpty() || startFile.at(0) != '/') { startFile = cDialogLastPath() + '/' + startFile; } dialog.setDirectory(QFileInfo(startFile).absoluteDir().absolutePath()); dialog.selectFile(startFile); int res = dialog.exec(); if (res == QDialog::Accepted) { QStringList selectedFilesStrings; ranges::transform( dialog.selectedFiles(), ranges::back_inserter(selectedFilesStrings), [](const QUrl &url) { return url.path(); }); if (type == Type::ReadFiles) { files = selectedFilesStrings; } else { files = selectedFilesStrings.mid(0, 1); } QString path = files.isEmpty() ? QString() : QFileInfo(files.back()).absoluteDir().absolutePath(); if (!path.isEmpty() && !path.contains(docRegExp) && path != cDialogLastPath()) { cSetDialogLastPath(path); Local::writeSettings(); } return true; } files = QStringList(); remoteContent = QByteArray(); return false; } QDBusArgument &operator <<(QDBusArgument &arg, const XDPFileDialog::FilterCondition &filterCondition) { arg.beginStructure(); arg << filterCondition.type << filterCondition.pattern; arg.endStructure(); return arg; } const QDBusArgument &operator >>(const QDBusArgument &arg, XDPFileDialog::FilterCondition &filterCondition) { uint type; QString filterPattern; arg.beginStructure(); arg >> type >> filterPattern; filterCondition.type = (XDPFileDialog::ConditionType)type; filterCondition.pattern = filterPattern; arg.endStructure(); return arg; } QDBusArgument &operator <<(QDBusArgument &arg, const XDPFileDialog::Filter filter) { arg.beginStructure(); arg << filter.name << filter.filterConditions; arg.endStructure(); return arg; } const QDBusArgument &operator >>(const QDBusArgument &arg, XDPFileDialog::Filter &filter) { QString name; XDPFileDialog::FilterConditionList filterConditions; arg.beginStructure(); arg >> name >> filterConditions; filter.name = name; filter.filterConditions = filterConditions; arg.endStructure(); return arg; } class XDPFileDialogPrivate { public: XDPFileDialogPrivate() { } WId winId = 0; bool directoryMode = false; bool modal = false; bool multipleFiles = false; bool saveFile = false; QString acceptLabel; QString directory; QString title; QStringList nameFilters; QStringList mimeTypesFilters; // maps user-visible name for portal to full name filter QMap userVisibleToNameFilter; QString selectedMimeTypeFilter; QString selectedNameFilter; QStringList selectedFiles; }; XDPFileDialog::XDPFileDialog(QWidget *parent, const QString &caption, const QString &directory, const QString &filter) : QDialog(parent) , d_ptr(new XDPFileDialogPrivate()) , _windowTitle(caption) , _initialDirectory(directory) { Q_D(XDPFileDialog); auto filters = makeFilterList(filter); const int numFilters = filters.count(); _nameFilters.reserve(numFilters); for (int i = 0; i < numFilters; ++i) { _nameFilters << filters[i].simplified(); } accepted( ) | rpl::start_with_next([=] { Q_EMIT accept(); }, _lifetime); rejected( ) | rpl::start_with_next([=] { Q_EMIT reject(); }, _lifetime); } XDPFileDialog::~XDPFileDialog() { } void XDPFileDialog::initializeDialog() { Q_D(XDPFileDialog); if (_fileMode == QFileDialog::ExistingFiles) d->multipleFiles = true; if (_fileMode == QFileDialog::Directory || _options.testFlag(QFileDialog::ShowDirsOnly)) d->directoryMode = true; #if 0 // it is commented in GtkFileDialog for some reason, do the same if (options()->isLabelExplicitlySet(QFileDialog::Accept)) d->acceptLabel = options()->labelText(QFileDialog::Accept); #endif if (!_windowTitle.isEmpty()) d->title = _windowTitle; if (_acceptMode == QFileDialog::AcceptSave) d->saveFile = true; if (!_nameFilters.isEmpty()) d->nameFilters = _nameFilters; #if 0 // what is the right way to implement this? if (!options()->mimeTypeFilters().isEmpty()) d->mimeTypesFilters = options()->mimeTypeFilters(); if (!options()->initiallySelectedMimeTypeFilter().isEmpty()) d->selectedMimeTypeFilter = options()->initiallySelectedMimeTypeFilter(); #endif const auto initialNameFilter = _nameFilters.isEmpty() ? QString() : _nameFilters.front(); if (!initialNameFilter.isEmpty()) d->selectedNameFilter = initialNameFilter; setDirectory(_initialDirectory); } void XDPFileDialog::openPortal() { Q_D(XDPFileDialog); QDBusMessage message = QDBusMessage::createMethodCall( QLatin1String("org.freedesktop.portal.Desktop"), QLatin1String("/org/freedesktop/portal/desktop"), QLatin1String("org.freedesktop.portal.FileChooser"), d->saveFile ? QLatin1String("SaveFile") : QLatin1String("OpenFile")); QString parentWindowId = QLatin1String("x11:") + QString::number(d->winId, 16); QVariantMap options; if (!d->acceptLabel.isEmpty()) options.insert(QLatin1String("accept_label"), d->acceptLabel); options.insert(QLatin1String("modal"), d->modal); options.insert(QLatin1String("multiple"), d->multipleFiles); options.insert(QLatin1String("directory"), d->directoryMode); if (d->saveFile) { if (!d->directory.isEmpty()) options.insert(QLatin1String("current_folder"), QFile::encodeName(d->directory).append('\0')); if (!d->selectedFiles.isEmpty()) options.insert(QLatin1String("current_file"), QFile::encodeName(d->selectedFiles.first()).append('\0')); } // Insert filters qDBusRegisterMetaType(); qDBusRegisterMetaType(); qDBusRegisterMetaType(); qDBusRegisterMetaType(); FilterList filterList; auto selectedFilterIndex = filterList.size() - 1; d->userVisibleToNameFilter.clear(); if (!d->mimeTypesFilters.isEmpty()) { for (const QString &mimeTypefilter : d->mimeTypesFilters) { QMimeDatabase mimeDatabase; QMimeType mimeType = mimeDatabase.mimeTypeForName(mimeTypefilter); // Creates e.g. (1, "image/png") FilterCondition filterCondition; filterCondition.type = MimeType; filterCondition.pattern = mimeTypefilter; // Creates e.g. [((1, "image/png"))] FilterConditionList filterConditions; filterConditions << filterCondition; // Creates e.g. [("Images", [((1, "image/png"))])] Filter filter; filter.name = mimeType.comment(); filter.filterConditions = filterConditions; filterList << filter; if (!d->selectedMimeTypeFilter.isEmpty() && d->selectedMimeTypeFilter == mimeTypefilter) selectedFilterIndex = filterList.size() - 1; } } else if (!d->nameFilters.isEmpty()) { for (const QString &nameFilter : d->nameFilters) { // Do parsing: // Supported format is ("Images (*.png *.jpg)") QRegularExpression regexp(QString::fromLatin1(filterRegExp)); QRegularExpressionMatch match = regexp.match(nameFilter); if (match.hasMatch()) { QString userVisibleName = match.captured(1); QStringList filterStrings = match.captured(2).split(QLatin1Char(' '), base::QStringSkipEmptyParts); if (filterStrings.isEmpty()) { LOG(("XDP File Dialog Error: Filter %1 is empty and will be ignored.").arg(userVisibleName)); continue; } FilterConditionList filterConditions; for (const QString &filterString : filterStrings) { FilterCondition filterCondition; filterCondition.type = GlobalPattern; filterCondition.pattern = filterString; filterConditions << filterCondition; } Filter filter; filter.name = userVisibleName; filter.filterConditions = filterConditions; filterList << filter; d->userVisibleToNameFilter.insert(userVisibleName, nameFilter); if (!d->selectedNameFilter.isEmpty() && d->selectedNameFilter == nameFilter) selectedFilterIndex = filterList.size() - 1; } } } if (!filterList.isEmpty()) options.insert(QLatin1String("filters"), QVariant::fromValue(filterList)); if (selectedFilterIndex != -1) options.insert(QLatin1String("current_filter"), QVariant::fromValue(filterList[selectedFilterIndex])); options.insert(QLatin1String("handle_token"), QStringLiteral("qt%1").arg(QRandomGenerator::global()->generate())); // TODO choices a(ssa(ss)s) // List of serialized combo boxes to add to the file chooser. message << parentWindowId << d->title << options; QDBusPendingCall pendingCall = QDBusConnection::sessionBus().asyncCall(message); QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(pendingCall); connect(watcher, &QDBusPendingCallWatcher::finished, this, [this] (QDBusPendingCallWatcher *watcher) { QDBusPendingReply reply = *watcher; if (reply.isError()) { _reject.fire({}); } else { QDBusConnection::sessionBus().connect( nullptr, reply.value().path(), QLatin1String("org.freedesktop.portal.Request"), QLatin1String("Response"), this, SLOT(gotResponse(uint,QVariantMap))); } }); } bool XDPFileDialog::defaultNameFilterDisables() const { return false; } void XDPFileDialog::setDirectory(const QUrl &directory) { Q_D(XDPFileDialog); d->directory = directory.path(); } QUrl XDPFileDialog::directory() const { Q_D(const XDPFileDialog); return d->directory; } void XDPFileDialog::selectFile(const QUrl &filename) { Q_D(XDPFileDialog); d->selectedFiles << filename.path(); } QList XDPFileDialog::selectedFiles() const { Q_D(const XDPFileDialog); QList files; for (const QString &file : d->selectedFiles) { files << QUrl(file); } return files; } void XDPFileDialog::setFilter() { Q_D(XDPFileDialog); } void XDPFileDialog::selectMimeTypeFilter(const QString &filter) { Q_D(XDPFileDialog); } QString XDPFileDialog::selectedMimeTypeFilter() const { Q_D(const XDPFileDialog); return d->selectedMimeTypeFilter; } void XDPFileDialog::selectNameFilter(const QString &filter) { Q_D(XDPFileDialog); } QString XDPFileDialog::selectedNameFilter() const { Q_D(const XDPFileDialog); return d->selectedNameFilter; } int XDPFileDialog::exec() { Q_D(XDPFileDialog); bool deleteOnClose = testAttribute(Qt::WA_DeleteOnClose); setAttribute(Qt::WA_DeleteOnClose, false); bool wasShowModal = testAttribute(Qt::WA_ShowModal); setAttribute(Qt::WA_ShowModal, true); setResult(0); show(); QPointer guard = this; // HACK we have to avoid returning until we emit that the dialog was accepted or rejected QEventLoop loop; rpl::lifetime lifetime; accepted( ) | rpl::start_with_next([&] { loop.quit(); }, lifetime); rejected( ) | rpl::start_with_next([&] { loop.quit(); }, lifetime); loop.exec(); if (guard.isNull()) return QDialog::Rejected; setAttribute(Qt::WA_ShowModal, wasShowModal); return result(); } void XDPFileDialog::setVisible(bool visible) { if (visible) { if (testAttribute(Qt::WA_WState_ExplicitShowHide) && !testAttribute(Qt::WA_WState_Hidden)) { return; } } else if (testAttribute(Qt::WA_WState_ExplicitShowHide) && testAttribute(Qt::WA_WState_Hidden)) { return; } if (visible) { showHelper(windowFlags(), windowModality(), parentWidget() ? parentWidget()->windowHandle() : nullptr); } else { hideHelper(); } // Set WA_DontShowOnScreen so that QDialog::setVisible(visible) below // updates the state correctly, but skips showing the non-native version: setAttribute(Qt::WA_DontShowOnScreen); QDialog::setVisible(visible); } void XDPFileDialog::hideHelper() { Q_D(XDPFileDialog); } void XDPFileDialog::showHelper(Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent) { Q_D(XDPFileDialog); initializeDialog(); d->modal = windowModality != Qt::NonModal; d->winId = parent ? parent->winId() : 0; openPortal(); } void XDPFileDialog::gotResponse(uint response, const QVariantMap &results) { Q_D(XDPFileDialog); if (!response) { if (results.contains(QLatin1String("uris"))) d->selectedFiles = results.value(QLatin1String("uris")).toStringList(); if (results.contains(QLatin1String("current_filter"))) { const Filter selectedFilter = qdbus_cast(results.value(QStringLiteral("current_filter"))); if (!selectedFilter.filterConditions.empty() && selectedFilter.filterConditions[0].type == MimeType) { // s.a. XDPFileDialog::openPortal which basically does the inverse d->selectedMimeTypeFilter = selectedFilter.filterConditions[0].pattern; d->selectedNameFilter.clear(); } else { d->selectedNameFilter = d->userVisibleToNameFilter.value(selectedFilter.name); d->selectedMimeTypeFilter.clear(); } } _accept.fire({}); } else { _reject.fire({}); } } rpl::producer<> XDPFileDialog::accepted() { return _accept.events(); } rpl::producer<> XDPFileDialog::rejected() { return _reject.events(); } } // namespace XDP } // namespace FileDialog } // namespace Platform