/* 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/linux_desktop_environment.h" #include "platform/linux/specific_linux.h" #include "base/platform/base_platform_info.h" #include "base/platform/linux/base_linux_glibmm_helper.h" #include "storage/localstorage.h" #include "base/openssl_help.h" #include "base/qt_adapters.h" #include #include #include #include #include #include namespace Platform { namespace FileDialog { namespace XDP { namespace { constexpr auto kXDGDesktopPortalService = "org.freedesktop.portal.Desktop"_cs; constexpr auto kXDGDesktopPortalObjectPath = "/org/freedesktop/portal/desktop"_cs; constexpr auto kXDGDesktopPortalFileChooserInterface = "org.freedesktop.portal.FileChooser"_cs; constexpr auto kPropertiesInterface = "org.freedesktop.DBus.Properties"_cs; const char *filterRegExp = "^(.*)\\(([a-zA-Z0-9_.,*? +;#\\-\\[\\]@\\{\\}/!<>\\$%&=^~:\\|]*)\\)$"; auto QStringListToStd(const QStringList &list) { std::vector result; ranges::transform( list, ranges::back_inserter(result), &QString::toStdString); return result; } auto MakeFilterList(const QString &filter) { std::vector result; QString f(filter); if (f.isEmpty()) { return result; } 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); } } ranges::transform( f.split(sep), ranges::back_inserter(result), [](const QString &string) { return string.simplified().toStdString(); }); return result; } std::optional FileChooserPortalVersion() { try { const auto connection = Gio::DBus::Connection::get_sync( Gio::DBus::BusType::BUS_TYPE_SESSION); auto reply = connection->call_sync( std::string(kXDGDesktopPortalObjectPath), std::string(kPropertiesInterface), "Get", base::Platform::MakeGlibVariant(std::tuple{ Glib::ustring( std::string(kXDGDesktopPortalFileChooserInterface)), Glib::ustring("version"), }), std::string(kXDGDesktopPortalService)); return base::Platform::GlibVariantCast( base::Platform::GlibVariantCast( reply.get_child(0))); } catch (const Glib::Error &e) { static const auto NotSupportedErrors = { "org.freedesktop.DBus.Error.Disconnected", "org.freedesktop.DBus.Error.ServiceUnknown", }; const auto errorName = Gio::DBus::ErrorUtils::get_remote_error(e); if (ranges::contains(NotSupportedErrors, errorName)) { return std::nullopt; } LOG(("XDP File Dialog Error: %1").arg( QString::fromStdString(e.what()))); } catch (const std::exception &e) { LOG(("XDP File Dialog Error: %1").arg( QString::fromStdString(e.what()))); } return std::nullopt; } // This is a patched copy of file dialog from qxdgdesktopportal theme plugin. // It allows using XDP file dialog flexibly, // without relying on QT_QPA_PLATFORMTHEME variable. // // XDP file dialog is a dialog obtained via a DBus service // provided by the current desktop environment. class XDPFileDialog : public QDialog, public sigc::trackable { public: enum ConditionType : uint { GlobalPattern = 0, MimeType = 1 }; // Filters a(sa(us)) // Example: [('Images', [(0, '*.ico'), (1, 'image/png')]), ('Text', [(0, '*.txt')])] typedef std::tuple FilterCondition; typedef std::vector FilterConditionList; typedef std::tuple Filter; typedef std::vector FilterList; XDPFileDialog( QWidget *parent = nullptr, const QString &caption = QString(), const QString &directory = QString(), const QString &nameFilter = QString(), const QStringList &mimeTypeFilters = QStringList()); ~XDPFileDialog(); void setVisible(bool visible) override; void setWindowTitle(const QString &windowTitle) { _windowTitle = windowTitle.toStdString(); } void setAcceptLabel(const QString &acceptLabel) { _acceptLabel = acceptLabel.toStdString(); } void setAcceptMode(QFileDialog::AcceptMode acceptMode) { _acceptMode = acceptMode; } void setFileMode(QFileDialog::FileMode fileMode) { _fileMode = fileMode; } void setOption(QFileDialog::Option option, bool on = true) { if (on) { _options |= option; } else { _options &= ~option; } } bool defaultNameFilterDisables() const; QUrl directory() const; void setDirectory(const QUrl &directory); void selectFile(const QUrl &filename); QList selectedFiles() const; void setFilter(); void selectNameFilter(const QString &filter); QString selectedNameFilter() const; void selectMimeTypeFilter(const QString &filter); QString selectedMimeTypeFilter() const; int exec() override; private: void openPortal(); void gotResponse( const Glib::RefPtr &connection, const Glib::ustring &sender_name, const Glib::ustring &object_path, const Glib::ustring &interface_name, const Glib::ustring &signal_name, const Glib::VariantContainerBase ¶meters); void showHelper( Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent); void hideHelper(); rpl::producer<> accepted(); rpl::producer<> rejected(); Glib::RefPtr _dbusConnection; Glib::RefPtr _cancellable; uint _requestSignalId = 0; // Options WId _winId = 0; QFileDialog::Options _options; QFileDialog::AcceptMode _acceptMode = QFileDialog::AcceptOpen; QFileDialog::FileMode _fileMode = QFileDialog::ExistingFile; bool _modal = false; Glib::ustring _windowTitle = "Choose file"; Glib::ustring _acceptLabel; Glib::ustring _directory; std::vector _nameFilters; std::vector _mimeTypesFilters; // maps user-visible name for portal to full name filter std::map _userVisibleToNameFilter; Glib::ustring _selectedMimeTypeFilter; Glib::ustring _selectedNameFilter; std::vector _selectedFiles; rpl::event_stream<> _accept; rpl::event_stream<> _reject; rpl::lifetime _lifetime; }; XDPFileDialog::XDPFileDialog( QWidget *parent, const QString &caption, const QString &directory, const QString &nameFilter, const QStringList &mimeTypeFilters) : QDialog(parent) , _windowTitle(caption.toStdString()) , _directory(directory.toStdString()) , _nameFilters(MakeFilterList(nameFilter)) , _mimeTypesFilters(QStringListToStd(mimeTypeFilters)) , _selectedMimeTypeFilter(!_mimeTypesFilters.empty() ? _mimeTypesFilters[0] : Glib::ustring()) , _selectedNameFilter(!_nameFilters.empty() ? _nameFilters[0] : Glib::ustring()) { accepted( ) | rpl::start_with_next([=] { accept(); }, _lifetime); rejected( ) | rpl::start_with_next([=] { reject(); }, _lifetime); } XDPFileDialog::~XDPFileDialog() { if (_cancellable) { _cancellable->cancel(); } if (_dbusConnection && _requestSignalId != 0) { _dbusConnection->signal_unsubscribe(_requestSignalId); } } void XDPFileDialog::openPortal() { std::stringstream parentWindowId; if (IsX11()) { parentWindowId << "x11:" << std::hex << _winId; } std::map options; if (!_acceptLabel.empty()) { options["accept_label"] = Glib::Variant::create( _acceptLabel); } options["modal"] = Glib::Variant::create(_modal); options["multiple"] = Glib::Variant::create( _fileMode == QFileDialog::ExistingFiles); options["directory"] = Glib::Variant::create( _fileMode == QFileDialog::Directory || _options.testFlag(QFileDialog::ShowDirsOnly)); if (_acceptMode == QFileDialog::AcceptSave) { if (!_directory.empty()) { options["current_folder"] = Glib::Variant::create( _directory +'\0'); } if (!_selectedFiles.empty()) { options["current_file"] = Glib::Variant::create( _selectedFiles[0] + '\0'); options["current_name"] = Glib::Variant::create( Glib::path_get_basename(_selectedFiles[0])); } } // Insert filters FilterList filterList; auto selectedFilterIndex = filterList.size() - 1; _userVisibleToNameFilter.clear(); if (!_mimeTypesFilters.empty()) { for (const auto &mimeTypeFilter : _mimeTypesFilters) { const auto mimeType = QMimeDatabase().mimeTypeForName( QString::fromStdString(mimeTypeFilter)); // Creates e.g. (1, "image/png") const auto filterCondition = FilterCondition{ MimeType, mimeTypeFilter, }; // Creates e.g. [("Images", [((1, "image/png"))])] filterList.push_back({ mimeType.comment().toStdString(), FilterConditionList{filterCondition}, }); if (!_selectedMimeTypeFilter.empty() && _selectedMimeTypeFilter == mimeTypeFilter) { selectedFilterIndex = filterList.size() - 1; } } } else if (!_nameFilters.empty()) { for (const auto &nameFilter : _nameFilters) { // Do parsing: // Supported format is ("Images (*.png *.jpg)") const QRegularExpression regexp( QString::fromLatin1(filterRegExp)); const QRegularExpressionMatch match = regexp.match( QString::fromStdString(nameFilter)); if (match.hasMatch()) { const auto userVisibleName = match.captured(1).toStdString(); const auto filterStrings = QStringListToStd( match.captured(2).split( QLatin1Char(' '), base::QStringSkipEmptyParts)); if (filterStrings.empty()) { LOG(( "XDP File Dialog Error: " "Filter %1 is empty and will be ignored.") .arg(QString::fromStdString(userVisibleName))); continue; } FilterConditionList filterConditions; for (const auto &filterString : filterStrings) { filterConditions.push_back({ GlobalPattern, filterString, }); } filterList.push_back({ userVisibleName, filterConditions, }); _userVisibleToNameFilter[userVisibleName] = nameFilter; if (!_selectedNameFilter.empty() && _selectedNameFilter == nameFilter) { selectedFilterIndex = filterList.size() - 1; } } } } if (!filterList.empty()) { options["filters"] = Glib::Variant::create(filterList); } if (selectedFilterIndex != -1) { options["current_filter"] = Glib::Variant::create( filterList[selectedFilterIndex]); } const auto handleToken = Glib::ustring("tdesktop") + std::to_string(openssl::RandomValue()); options["handle_token"] = Glib::Variant::create( handleToken); // TODO choices a(ssa(ss)s) // List of serialized combo boxes to add to the file chooser. try { _dbusConnection = Gio::DBus::Connection::get_sync( Gio::DBus::BusType::BUS_TYPE_SESSION); auto uniqueName = _dbusConnection->get_unique_name(); uniqueName.erase(0, 1); uniqueName.replace(uniqueName.find('.'), 1, 1, '_'); const auto requestPath = Glib::ustring( "/org/freedesktop/portal/desktop/request/") + uniqueName + '/' + handleToken; _requestSignalId = _dbusConnection->signal_subscribe( sigc::mem_fun(this, &XDPFileDialog::gotResponse), {}, "org.freedesktop.portal.Request", "Response", requestPath); // synchronize functor deletion by this cancellable _cancellable = Gio::Cancellable::create(); _dbusConnection->call( std::string(kXDGDesktopPortalObjectPath), std::string(kXDGDesktopPortalFileChooserInterface), _acceptMode == QFileDialog::AcceptSave ? "SaveFile" : "OpenFile", base::Platform::MakeGlibVariant(std::tuple{ Glib::ustring(parentWindowId.str()), _windowTitle, options, }), [=](const Glib::RefPtr &result) { try { _dbusConnection->call_finish(result); } catch (const Glib::Error &e) { LOG(("XDP File Dialog Error: %1").arg( QString::fromStdString(e.what()))); crl::on_main([=] { _reject.fire({}); }); } }, _cancellable, std::string(kXDGDesktopPortalService)); } catch (const Glib::Error &e) { LOG(("XDP File Dialog Error: %1").arg( QString::fromStdString(e.what()))); _reject.fire({}); } } bool XDPFileDialog::defaultNameFilterDisables() const { return false; } void XDPFileDialog::setDirectory(const QUrl &directory) { _directory = directory.path().toStdString(); } QUrl XDPFileDialog::directory() const { return QString::fromStdString(_directory); } void XDPFileDialog::selectFile(const QUrl &filename) { _selectedFiles.push_back(filename.path().toStdString()); } QList XDPFileDialog::selectedFiles() const { QList files; ranges::transform( _selectedFiles, ranges::back_inserter(files), [](const Glib::ustring &string) { return QUrl(QString::fromStdString(string)); }); return files; } void XDPFileDialog::setFilter() { } void XDPFileDialog::selectMimeTypeFilter(const QString &filter) { } QString XDPFileDialog::selectedMimeTypeFilter() const { return QString::fromStdString(_selectedMimeTypeFilter); } void XDPFileDialog::selectNameFilter(const QString &filter) { } QString XDPFileDialog::selectedNameFilter() const { return QString::fromStdString(_selectedNameFilter); } int XDPFileDialog::exec() { 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() { } void XDPFileDialog::showHelper( Qt::WindowFlags windowFlags, Qt::WindowModality windowModality, QWindow *parent) { _modal = windowModality != Qt::NonModal; _winId = parent ? parent->winId() : 0; openPortal(); } void XDPFileDialog::gotResponse( const Glib::RefPtr &connection, const Glib::ustring &sender_name, const Glib::ustring &object_path, const Glib::ustring &interface_name, const Glib::ustring &signal_name, const Glib::VariantContainerBase ¶meters) { try { auto parametersCopy = parameters; const auto response = base::Platform::GlibVariantCast( parametersCopy.get_child(0)); const auto results = base::Platform::GlibVariantCast< std::map< Glib::ustring, Glib::VariantBase >>(parametersCopy.get_child(1)); if (!response) { if (const auto i = results.find("uris"); i != end(results)) { _selectedFiles = base::Platform::GlibVariantCast< std::vector>(i->second); } if (const auto i = results.find("current_filter"); i != end(results)) { auto selectedFilter = base::Platform::GlibVariantCast< Filter>(i->second); if (!std::get<1>(selectedFilter).empty() && std::get<0>( std::get<1>(selectedFilter)[0]) == MimeType) { // s.a. XDPFileDialog::openPortal // which basically does the inverse _selectedMimeTypeFilter = std::get<1>( std::get<1>(selectedFilter)[0]); _selectedNameFilter.clear(); } else { _selectedNameFilter = _userVisibleToNameFilter[std::get<0>(selectedFilter)]; _selectedMimeTypeFilter.clear(); } } _accept.fire({}); } else { _reject.fire({}); } } catch (const std::exception &e) { LOG(("XDP File Dialog Error: %1").arg( QString::fromStdString(e.what()))); _reject.fire({}); } } rpl::producer<> XDPFileDialog::accepted() { return _accept.events(); } rpl::producer<> XDPFileDialog::rejected() { return _reject.events(); } } // namespace bool Use(Type type) { static const auto ShouldUse = [&] { const auto envVar = qEnvironmentVariableIsSet("TDESKTOP_USE_PORTAL"); const auto confined = InFlatpak() || InSnap(); const auto notGtkBased = !DesktopEnvironment::IsGtkBased(); return confined || notGtkBased || envVar; }(); static const auto Version = FileChooserPortalVersion(); return ShouldUse && Version.has_value() && (type != Type::ReadFolder || *Version >= 3); } 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, QStringList()); 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; } } // namespace XDP } // namespace FileDialog } // namespace Platform