diff --git a/backend/include/backend/opener.hpp b/backend/include/backend/opener.hpp index b717da8f..9585bd92 100644 --- a/backend/include/backend/opener.hpp +++ b/backend/include/backend/opener.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -24,6 +26,13 @@ class Opener */ std::expected openInFileManager(std::filesystem::path const& path); + /** + * @brief Probe the platform's external-open facilities and report which calls are expected + * to succeed. Called once at startup by the frontend via RPC so it can gray out + * "Open" / "Open With" / "Open in File Manager" menu entries and warn the user. + */ + SharedData::OpenerCapabilities capabilities() const; + private: struct Implementation; std::unique_ptr impl_; diff --git a/backend/include/backend/rpc_filesystem.hpp b/backend/include/backend/rpc_filesystem.hpp index 5efac663..967208e1 100644 --- a/backend/include/backend/rpc_filesystem.hpp +++ b/backend/include/backend/rpc_filesystem.hpp @@ -29,6 +29,7 @@ class RpcFilesystem : public RpcHelper::StrandRpc void registerWriteFile(); void registerOpen(); void registerOpenInFileManager(); + void registerOpenerCapabilities(); private: Persistence::LocalFilesystemOptions options_; diff --git a/backend/source/backend/CMakeLists.txt b/backend/source/backend/CMakeLists.txt index ac98737c..97bb13a8 100644 --- a/backend/source/backend/CMakeLists.txt +++ b/backend/source/backend/CMakeLists.txt @@ -51,10 +51,16 @@ else() linux/opener_linux.cpp process/fork_pool.cpp ) + # GLib's GDBus replaces sd-bus for portal calls: Ubuntu 24.04's xdg-dbus-proxy + # mangles sd-bus's one-shot AUTH EXTERNAL handshake but accepts GDBus's multi-step + # negotiation, so the app can talk to portals without --socket=session-bus. + # gio-unix-2.0 brings in GUnixFDList for file-descriptor passing. + find_package(PkgConfig REQUIRED) + pkg_check_modules(gio REQUIRED IMPORTED_TARGET gio-2.0 gio-unix-2.0) target_link_libraries( backend PRIVATE - SDBusCpp::sdbus-c++ + PkgConfig::gio ) endif() diff --git a/backend/source/backend/linux/opener_linux.cpp b/backend/source/backend/linux/opener_linux.cpp index c2f6ab15..c03b9e36 100644 --- a/backend/source/backend/linux/opener_linux.cpp +++ b/backend/source/backend/linux/opener_linux.cpp @@ -1,6 +1,10 @@ #include -#include +#include +#include + +#include +#include #include #include @@ -33,6 +37,20 @@ namespace constexpr char const* portalService = "org.freedesktop.portal.Desktop"; constexpr char const* portalObjectPath = "/org/freedesktop/portal/desktop"; constexpr char const* openUriInterface = "org.freedesktop.portal.OpenURI"; + constexpr char const* documentsService = "org.freedesktop.portal.Documents"; + constexpr char const* documentsObjectPath = "/org/freedesktop/portal/documents"; + constexpr char const* documentsInterface = "org.freedesktop.portal.Documents"; + + /** + * @brief True when running inside a flatpak sandbox. xdg-desktop-portal routes fd-based + * OpenFile calls through the Documents portal only in this case, so we include the + * Documents portal probe in the capability decision only for sandboxed runs. + */ + bool runningInFlatpak() + { + std::error_code ec; + return std::filesystem::exists("/.flatpak-info", ec) && !ec; + } //--------------------------------------------------------------------------------------------------------------------- // Extract the XDG portal parent_window identifier from the GtkWidget* native window. @@ -204,34 +222,146 @@ namespace } } +namespace +{ + /** + * @brief Invoke a fd-taking portal method (OpenURI.OpenFile / OpenURI.OpenDirectory). + * Takes ownership of @p fd -- the FdGuard closes it on every path. Pass + * @p withAsk = false when the method takes no "ask" option (OpenDirectory). + */ + std::expected callPortalFdMethod( + GDBusConnection* connection, + char const* method, + std::string const& parentWindow, + Utility::FdGuard fd, + bool withAsk, + bool askValue) + { + Utility::GObjectPtr fdList{g_unix_fd_list_new()}; + GError* rawErr = nullptr; + const gint fdHandle = g_unix_fd_list_append(fdList.get(), fd.get(), &rawErr); + // GUnixFDList dup'd the fd on append; our copy is closed by FdGuard at scope exit. + if (fdHandle < 0) + return std::unexpected{Utility::consumeGError(rawErr, "g_unix_fd_list_append failed")}; + + GVariantBuilder optsBuilder; + g_variant_builder_init(&optsBuilder, G_VARIANT_TYPE("a{sv}")); + if (withAsk) + g_variant_builder_add(&optsBuilder, "{sv}", "ask", g_variant_new_boolean(askValue)); + + GVariant* const params = g_variant_new( + "(sh@a{sv})", parentWindow.c_str(), fdHandle, g_variant_builder_end(&optsBuilder)); + + rawErr = nullptr; + Utility::GVariantPtr result{g_dbus_connection_call_with_unix_fd_list_sync( + connection, + portalService, + portalObjectPath, + openUriInterface, + method, + params, // floating; sunk by the call + G_VARIANT_TYPE("(o)"), + G_DBUS_CALL_FLAGS_NONE, + /*timeout_msec*/ 30000, + fdList.get(), + /*out_fd_list*/ nullptr, + /*cancellable*/ nullptr, + &rawErr)}; + + if (!result) + return std::unexpected{Utility::consumeGError(rawErr, "portal call failed")}; + + gchar const* handlePath = nullptr; + g_variant_get(result.get(), "(&o)", &handlePath); + Log::info("Opener: portal.{} succeeded, request handle='{}'", method, handlePath ? handlePath : "?"); + return {}; + } + + /** + * @brief Fetch a uint32 property from a portal interface via org.freedesktop.DBus.Properties. + * Returns 0 when the property is unreachable (interface absent, service not running, + * call error) -- capabilities() treats 0 as "not present". + */ + std::uint32_t + probePortalVersion(GDBusConnection* connection, char const* service, char const* objectPath, char const* iface) + { + if (!connection) + return 0; + + GError* rawErr = nullptr; + Utility::GVariantPtr result{g_dbus_connection_call_sync( + connection, + service, + objectPath, + "org.freedesktop.DBus.Properties", + "Get", + g_variant_new("(ss)", iface, "version"), + G_VARIANT_TYPE("(v)"), + G_DBUS_CALL_FLAGS_NONE, + /*timeout_msec*/ 2000, + /*cancellable*/ nullptr, + &rawErr)}; + + if (!result) + { + Log::warn( + "Opener::capabilities: {} version probe failed: {}", + iface, + Utility::consumeGError(rawErr, "(no error object)")); + return 0; + } + + GVariant* innerRaw = nullptr; + g_variant_get(result.get(), "(v)", &innerRaw); + Utility::GVariantPtr inner{innerRaw}; + if (inner && g_variant_is_of_type(inner.get(), G_VARIANT_TYPE_UINT32)) + return g_variant_get_uint32(inner.get()); + return 0; + } + + /** + * @brief Ask the system's default handler (via the shared MIME DB / GAppInfo) to open + * @p path. Used for directories in openInFileManager, where the portal's + * OpenDirectory method is spec'd to open the fd's *parent* -- not useful when + * the user already picked the directory itself. + */ + std::expected launchDefaultForPath(std::filesystem::path const& path) + { + GError* uriErrRaw = nullptr; + Utility::GcharPtr uri{g_filename_to_uri(path.c_str(), nullptr, &uriErrRaw)}; + if (!uri) + return std::unexpected{Utility::consumeGError(uriErrRaw, "g_filename_to_uri failed")}; + + GError* launchErrRaw = nullptr; + const gboolean launched = g_app_info_launch_default_for_uri(uri.get(), nullptr, &launchErrRaw); + Log::info("Opener: launching file manager for URI '{}'", uri.get()); + if (!launched) + return std::unexpected{Utility::consumeGError(launchErrRaw, "launch failed")}; + return {}; + } +} + struct Opener::Implementation { std::string parentWindow; // "wayland:HANDLE" | "x11:XID" | "" - std::unique_ptr connection; - std::unique_ptr openUriProxy; + Utility::GObjectPtr connection; explicit Implementation(void* nativeWindow) : parentWindow{extractParentWindowHandle(nativeWindow)} { - try - { - connection = sdbus::createSessionBusConnection(); - openUriProxy = sdbus::createProxy( - *connection, sdbus::ServiceName{portalService}, sdbus::ObjectPath{portalObjectPath}); - } - catch (sdbus::Error const& err) + // Use GLib's GDBus rather than sd-bus: Ubuntu 24.04's xdg-dbus-proxy mangles the AUTH + // handshake that sd-bus's sd_bus_open_user() performs (one-shot "AUTH EXTERNAL "), + // but accepts GDBus's multi-step flow unchanged. The shared session-bus connection + // returned by g_bus_get_sync() is the same one GTK/WebKit are already using, so this + // doesn't open a second socket. + GError* rawErr = nullptr; + connection.reset(g_bus_get_sync(G_BUS_TYPE_SESSION, /*cancellable*/ nullptr, &rawErr)); + if (!connection) { Log::warn( "Opener: could not connect to session bus ({}). " "Opening files via xdg-desktop-portal will be unavailable.", - err.getMessage()); - } - catch (std::exception const& err) - { - Log::warn( - "Opener: unexpected error connecting to session bus ({}). " - "Opening files via xdg-desktop-portal will be unavailable.", - err.what()); + Utility::consumeGError(rawErr, "(no error object)")); } Log::info("Opener: initialized with parentWindow='{}'", parentWindow); } @@ -251,7 +381,7 @@ std::expected Opener::openFile(std::filesystem::path const& p { Log::info("Opener: openFile path='{}' openWith={} parentWindow='{}'", path.string(), openWith, impl_->parentWindow); - if (!impl_->openUriProxy) + if (!impl_->connection) { constexpr auto const* msg = "Session bus is unavailable; cannot open files via xdg-desktop-portal. " @@ -260,32 +390,63 @@ std::expected Opener::openFile(std::filesystem::path const& p return std::unexpected{std::string{msg}}; } - int const fd = ::open(path.c_str(), O_RDONLY); - if (fd == -1) + Utility::FdGuard fd{::open(path.c_str(), O_RDONLY)}; + if (!fd.valid()) { Log::error("Opener: failed to open fd for '{}': {}", path.string(), std::strerror(errno)); return std::unexpected{std::string{"Failed to open file: "} + std::strerror(errno)}; } - std::map options; - options.emplace("ask", sdbus::Variant{openWith}); + auto result = callPortalFdMethod( + impl_->connection.get(), "OpenFile", impl_->parentWindow, std::move(fd), /*withAsk*/ true, openWith); + if (!result) + Log::error("Opener: OpenURI.OpenFile portal call failed: {}", result.error()); + return result; +} - try +SharedData::OpenerCapabilities Opener::capabilities() const +{ + SharedData::OpenerCapabilities caps; + + if (!impl_->connection) { - sdbus::ObjectPath handle; - impl_->openUriProxy->callMethod("OpenFile") - .onInterface(openUriInterface) - .withArguments(impl_->parentWindow, sdbus::UnixFd{fd}, options) - .storeResultsTo(handle); - Log::info("Opener: OpenURI.OpenFile portal call succeeded, request handle='{}'", static_cast(handle)); + caps.canOpenFile = false; + caps.canOpenInFileManager = false; + caps.reason = "D-Bus session bus is unavailable."; + return caps; } - catch (sdbus::Error const& err) + + // 1) OpenURI interface must exist on xdg-desktop-portal. + const std::uint32_t openUriVersion = probePortalVersion( + impl_->connection.get(), portalService, portalObjectPath, openUriInterface); + Log::info("Opener::capabilities: OpenURI version={}", openUriVersion); + if (openUriVersion == 0) { - Log::error("Opener: OpenURI.OpenFile portal call failed: {}", err.getMessage()); - return std::unexpected{err.getMessage()}; + caps.canOpenFile = false; + caps.canOpenInFileManager = false; + caps.reason = "xdg-desktop-portal OpenURI interface is unavailable."; + return caps; } - return {}; + // 2) Inside flatpak, OpenFile(fd) goes through the Documents portal; if it's not reachable + // the call will fail even though OpenURI is present. Outside flatpak the portal can open + // the fd directly, so skip the probe. + if (runningInFlatpak()) + { + const std::uint32_t documentsVersion = probePortalVersion( + impl_->connection.get(), documentsService, documentsObjectPath, documentsInterface); + Log::info("Opener::capabilities: Documents portal version={}", documentsVersion); + if (documentsVersion == 0) + { + caps.canOpenFile = false; + caps.reason = "xdg-document-portal is unavailable; " + "file-descriptor-based opening is not possible in this flatpak."; + // openInFileManager for directories uses g_app_info_launch_default_for_uri, which + // does not require the Documents portal, so leave canOpenInFileManager untouched. + } + } + + return caps; } std::expected Opener::openInFileManager(std::filesystem::path const& path) @@ -293,7 +454,7 @@ std::expected Opener::openInFileManager(std::filesystem::path Log::info( "Opener: openInFileManager path='{}' parentWindow='{}'", path.string(), impl_->parentWindow); - if (!impl_->openUriProxy) + if (!impl_->connection) { constexpr auto const* msg = "Session bus is unavailable; cannot open file manager via xdg-desktop-portal. " @@ -307,65 +468,25 @@ std::expected Opener::openInFileManager(std::filesystem::path if (isDirectory) { - // Directories: bypass the portal. The portal's OpenURI method is unreliable for - // file:// URIs (some backends reject it, others silently drop it), and OpenDirectory - // is spec'd to open the *parent* of its fd — not useful when the user picked a dir. - // g_app_info_launch_default_for_uri talks directly to the user's preferred file - // manager via the shared MIME DB, which is exactly what we want here. - GError* uriError = nullptr; - gchar* const uri = g_filename_to_uri(path.c_str(), nullptr, &uriError); - if (!uri) - { - const std::string msg = uriError ? uriError->message : "g_filename_to_uri failed"; - Log::error("Opener: failed to build file URI for '{}': {}", path.string(), msg); - if (uriError) - g_error_free(uriError); - return std::unexpected{msg}; - } - - GError* launchError = nullptr; - const gboolean launched = g_app_info_launch_default_for_uri(uri, nullptr, &launchError); - Log::info("Opener: launching file manager for URI '{}'", uri); - g_free(uri); - if (!launched) - { - const std::string msg = launchError ? launchError->message : "launch failed"; - Log::error("Opener: g_app_info_launch_default_for_uri failed: {}", msg); - if (launchError) - g_error_free(launchError); - return std::unexpected{msg}; - } - - return {}; + auto result = launchDefaultForPath(path); + if (!result) + Log::error("Opener: g_app_info_launch_default_for_uri failed for '{}': {}", path.string(), result.error()); + return result; } // File (or nonexistent — portal will reject that): OpenDirectory opens the // file's parent directory in the file manager. Most file managers highlight // the file, which is the common "reveal in file manager" behavior. - int const fd = ::open(path.c_str(), O_RDONLY); - if (fd == -1) + Utility::FdGuard fd{::open(path.c_str(), O_RDONLY)}; + if (!fd.valid()) { Log::error("Opener: failed to open fd for '{}': {}", path.string(), std::strerror(errno)); return std::unexpected{std::string{"Failed to open file: "} + std::strerror(errno)}; } - const std::map options; - try - { - sdbus::ObjectPath handle; - impl_->openUriProxy->callMethod("OpenDirectory") - .onInterface(openUriInterface) - .withArguments(impl_->parentWindow, sdbus::UnixFd{fd}, options) - .storeResultsTo(handle); - Log::info( - "Opener: OpenURI.OpenDirectory portal call succeeded, request handle='{}'", - static_cast(handle)); - } - catch (sdbus::Error const& err) - { - Log::error("Opener: OpenURI.OpenDirectory portal call failed: {}", err.getMessage()); - return std::unexpected{err.getMessage()}; - } - - return {}; + auto result = callPortalFdMethod( + impl_->connection.get(), "OpenDirectory", impl_->parentWindow, std::move(fd), /*withAsk*/ false, false); + if (!result) + Log::error("Opener: OpenURI.OpenDirectory portal call failed: {}", result.error()); + return result; } diff --git a/backend/source/backend/rpc_filesystem.cpp b/backend/source/backend/rpc_filesystem.cpp index 662b6840..b31fa24e 100644 --- a/backend/source/backend/rpc_filesystem.cpp +++ b/backend/source/backend/rpc_filesystem.cpp @@ -138,6 +138,7 @@ RpcFilesystem::RpcFilesystem( registerWriteFile(); registerOpen(); registerOpenInFileManager(); + registerOpenerCapabilities(); } void RpcFilesystem::registerRemove() @@ -664,6 +665,23 @@ void RpcFilesystem::registerOpen() ); } +void RpcFilesystem::registerOpenerCapabilities() +{ + on("RpcFilesystem::openerCapabilities") + .perform( + [this](RpcHelper::RpcOnce&& reply) + { + const auto caps = opener_->capabilities(); + Log::info( + "RpcFilesystem::openerCapabilities: canOpenFile={}, canOpenInFileManager={}, reason='{}'", + caps.canOpenFile, + caps.canOpenInFileManager, + caps.reason); + reply({{"success", true}, {"capabilities", nlohmann::json(caps)}}); + } + ); +} + void RpcFilesystem::registerOpenInFileManager() { on("RpcFilesystem::openInFileManager") diff --git a/backend/source/backend/windows/opener_windows.cpp b/backend/source/backend/windows/opener_windows.cpp index d5300972..14215802 100644 --- a/backend/source/backend/windows/opener_windows.cpp +++ b/backend/source/backend/windows/opener_windows.cpp @@ -113,6 +113,13 @@ std::expected Opener::openFile(std::filesystem::path const& p return {}; } +SharedData::OpenerCapabilities Opener::capabilities() const +{ + // ShellExecuteW / SHOpenFolderAndSelectItems are always available on any supported Windows; + // there's no probe short of trying the call with a real file, so report success. + return SharedData::OpenerCapabilities{}; +} + std::expected Opener::openInFileManager(std::filesystem::path const& path) { const auto pathStrU16 = path.native(); diff --git a/frontend/include/frontend/dialog/input_dialog.hpp b/frontend/include/frontend/dialog/input_dialog.hpp index ce80f85a..29179c5c 100644 --- a/frontend/include/frontend/dialog/input_dialog.hpp +++ b/frontend/include/frontend/dialog/input_dialog.hpp @@ -18,6 +18,7 @@ class InputDialog std::string whatFor{}; std::string prompt{}; std::string headerText{}; + std::string initialValue{}; bool isPassword{false}; std::function const&)> onConfirm; }; diff --git a/frontend/include/frontend/file_explorer/local_side_model.hpp b/frontend/include/frontend/file_explorer/local_side_model.hpp index 9f18b6a3..724a1bc4 100644 --- a/frontend/include/frontend/file_explorer/local_side_model.hpp +++ b/frontend/include/frontend/file_explorer/local_side_model.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -100,6 +101,10 @@ class LocalSideModel { return true; } + SharedData::OpenerCapabilities openerCapabilities() const override + { + return openerCaps_; + } void setRemoteModel(SideModel* model); bool isComplete() const override; void navigateTo(std::filesystem::path const& path) override; @@ -131,8 +136,17 @@ class LocalSideModel ); private: + /** + * @brief Ask the backend once which external-open operations the platform can perform + * (xdg-desktop-portal probe on Linux). The result is stashed in @ref openerCaps_ + * and drives the context menu's disabled-state. On failure a one-shot warning + * dialog is opened via @ref confirmDialog_. + */ + void probeOpenerCapabilities(); + SideModel* remoteModel_{nullptr}; NuiFileExplorer::PathSuggestionCache pathSuggestionCache_; std::shared_ptr>> favorites_; std::function)> onFavoritesChanged_; + SharedData::OpenerCapabilities openerCaps_{}; }; \ No newline at end of file diff --git a/frontend/include/frontend/file_explorer/side_model.hpp b/frontend/include/frontend/file_explorer/side_model.hpp index 66d551a2..6b7f2eb5 100644 --- a/frontend/include/frontend/file_explorer/side_model.hpp +++ b/frontend/include/frontend/file_explorer/side_model.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include @@ -95,6 +96,17 @@ class SideModel : public NuiFileExplorer::ISideModel dropMetadata_ = value; } + /** + * @brief Current snapshot of the platform opener probe result. Defaults to all-capable; + * LocalSideModel overrides this once the backend probe completes. RemoteSideModel + * reads it through its localModel_ pointer so remote "Open" / "Open With" / watch + * variants can be grayed out in lockstep. + */ + virtual SharedData::OpenerCapabilities openerCapabilities() const + { + return {}; + } + protected: // Design smell: virtual bool isComplete() const; diff --git a/frontend/source/frontend/dialog/input_dialog.cpp b/frontend/source/frontend/dialog/input_dialog.cpp index d2207432..fe6cb31d 100644 --- a/frontend/source/frontend/dialog/input_dialog.cpp +++ b/frontend/source/frontend/dialog/input_dialog.cpp @@ -64,15 +64,20 @@ Nui::ElementRenderer InputDialog::dialogBody() if (!impl_->isPassword.value()) return std::string{"Text"}; return impl_->showPassword.value() ? std::string{"Text"} : std::string{"Password"}; }), + "input"_event = [this](Nui::WebApi::Event event){ + impl_->inputText = event.target()["value"].as(); + }, "keydown"_event = [this](Nui::WebApi::KeyboardEvent event){ dialogButtonContainerKeydown(event); if (event.key() == "Enter") { event.preventDefault(); + impl_->inputText = event.target()["value"].as(); impl_->confirmOnClose = true; impl_->dialog->close(); } } - } + }, + .dontUpdateValue = true, }), div{class_ = "input-dialog-toggle-btn-wrapper"}( observe(impl_->isPassword, impl_->showPassword), @@ -106,7 +111,7 @@ void InputDialog::open(OpenOptions const& options) impl_->showPassword = false; impl_->confirmOnClose = false; impl_->whatFor = options.whatFor; - impl_->inputText.clear(); + impl_->inputText = options.initialValue; Nui::globalEventContext.executeActiveEventsImmediately(); impl_->dialog->open({ diff --git a/frontend/source/frontend/file_explorer/local_side_model.cpp b/frontend/source/frontend/file_explorer/local_side_model.cpp index 354395d3..6b51fc7b 100644 --- a/frontend/source/frontend/file_explorer/local_side_model.cpp +++ b/frontend/source/frontend/file_explorer/local_side_model.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -121,7 +122,56 @@ LocalSideModel::LocalSideModel( return paths; }() )} -{} +{ + probeOpenerCapabilities(); +} + +void LocalSideModel::probeOpenerCapabilities() +{ + Nui::RpcClient::callWithBackChannel( + "RpcFilesystem::openerCapabilities", + [this](Nui::val val) + { + if (!val.hasOwnProperty("success") || !val["success"].as()) + { + Log::warn("RpcFilesystem::openerCapabilities failed or unavailable; assuming capable."); + return; + } + + SharedData::OpenerCapabilities caps{}; + try + { + Nui::convertFromVal(val["capabilities"], caps); + } + catch (std::exception const& e) + { + Log::warn("Failed to parse openerCapabilities response: {}", e.what()); + return; + } + + openerCaps_ = caps; + Log::info( + "Opener capabilities: canOpenFile={}, canOpenInFileManager={}, reason='{}'", + caps.canOpenFile, + caps.canOpenInFileManager, + caps.reason + ); + + if (!caps.canOpenFile || !caps.canOpenInFileManager) + { + confirmDialog_->open({ + .styleVariant = ScriptNuiComponents::StyleVariant::Warning, + .headerText = language->get("localSideModel", "portalUnavailableHeader"), + .text = fmt::format( + fmt::runtime(language->get("localSideModel", "portalUnavailableText")), caps.reason + ), + .buttons = ConfirmDialog::Button::Ok, + .neverShowAgainId = "localSideModel.portalUnavailable", + }); + } + } + ); +} void LocalSideModel::setOnFavoritesChanged(std::function)> callback) { @@ -653,10 +703,13 @@ void LocalSideModel::onRename(NuiFileExplorer::Item const& item) .whatFor = "Rename", .prompt = "Enter the new name for " + item.path.filename().string(), .headerText = "Rename " + item.path.filename().string(), + .initialValue = item.path.filename().string(), .isPassword = false, - .onConfirm = [doRename](std::optional const& name) + .onConfirm = [doRename, item](std::optional const& name) { - if (!name) + if (!name || name->empty()) + return; + if (*name == item.path.filename().string()) return; Log::info("Renaming item to: {}", *name); @@ -1216,7 +1269,7 @@ LocalSideModel::contextMenuItems(std::vector const& selec { onOpen(selectedItems.front(), false); }, - !singleItem + !singleItem || !openerCaps_.canOpenFile ), Snc::PopupMenu::item( language->get("fileExplorer", "contextMenu", "openWith"), @@ -1225,7 +1278,7 @@ LocalSideModel::contextMenuItems(std::vector const& selec { onOpen(selectedItems.front(), true); }, - !singleItem + !singleItem || !openerCaps_.canOpenFile ), Snc::PopupMenu::item( language->get("fileExplorer", "contextMenu", "openInFileManager"), @@ -1234,7 +1287,7 @@ LocalSideModel::contextMenuItems(std::vector const& selec { onOpenInFileManager(selectedItems.front()); }, - !singleItem + !singleItem || !openerCaps_.canOpenInFileManager ), Snc::PopupMenu::separator(), Snc::PopupMenu::item( diff --git a/frontend/source/frontend/file_explorer/remote_side_model.cpp b/frontend/source/frontend/file_explorer/remote_side_model.cpp index 58d83701..2fd1d09a 100644 --- a/frontend/source/frontend/file_explorer/remote_side_model.cpp +++ b/frontend/source/frontend/file_explorer/remote_side_model.cpp @@ -192,6 +192,11 @@ RemoteSideModel::contextMenuItems(std::vector const& sele items.push_back(Snc::PopupMenu::separator()); } + // "Open" variants on the remote side ultimately route the downloaded file through the + // local Opener (xdg-desktop-portal / ShellExecute), so gate them on the same capability + // the LocalSideModel probes at startup. + const bool canOpen = localModel_ ? localModel_->openerCapabilities().canOpenFile : true; + const std::vector baseItems = { Snc::PopupMenu::item( language->get("fileExplorer", "contextMenu", "download"), @@ -219,7 +224,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele { onDownloadAndOpen(selectedItems.front(), false); }, - !singleItem || fileTracking_ == nullptr + !singleItem || fileTracking_ == nullptr || !canOpen ), Snc::PopupMenu::item( language->get("fileExplorer", "contextMenu", "openWith"), @@ -228,7 +233,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele { onDownloadAndOpen(selectedItems.front(), true); }, - !singleItem || fileTracking_ == nullptr + !singleItem || fileTracking_ == nullptr || !canOpen ), Snc::PopupMenu::item( language->get("fileExplorer", "contextMenu", "openAndWatch"), @@ -237,7 +242,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele { onWatchDownloadAndOpen(selectedItems.front(), false); }, - !singleItem || fileTracking_ == nullptr + !singleItem || fileTracking_ == nullptr || !canOpen ), Snc::PopupMenu::item( language->get("fileExplorer", "contextMenu", "openWithAndWatch"), @@ -246,7 +251,7 @@ RemoteSideModel::contextMenuItems(std::vector const& sele { onWatchDownloadAndOpen(selectedItems.front(), true); }, - !singleItem || fileTracking_ == nullptr + !singleItem || fileTracking_ == nullptr || !canOpen ), Snc::PopupMenu::separator(), Snc::PopupMenu::item( @@ -1156,11 +1161,19 @@ void RemoteSideModel::onRename(NuiFileExplorer::Item const& item) .headerText = fmt::format( fmt::runtime(language->get("remoteSideModel", "renameWithItem")), item.path.filename().string() ), + .initialValue = item.path.filename().string(), .isPassword = false, .onConfirm = [this, item](std::optional const& name) { - if (!name) + if (!name || name->empty()) + return; + if (*name == item.path.filename().string()) return; + if (name->find('/') != std::string::npos) + { + Log::error("Invalid name (cannot contain slashes): {}", *name); + return; + } Log::info("Renaming item to: {}", *name); diff --git a/frontend/source/frontend/session_components/file_explorer_panel.cpp b/frontend/source/frontend/session_components/file_explorer_panel.cpp index 723fcee2..fd267ea3 100644 --- a/frontend/source/frontend/session_components/file_explorer_panel.cpp +++ b/frontend/source/frontend/session_components/file_explorer_panel.cpp @@ -29,7 +29,8 @@ using namespace Nui::Attributes; namespace { - NuiFileExplorer::FileGrid buildFileGrid(FileExplorerPanel::Params& params) + NuiFileExplorer::FileGrid + buildFileGrid(FileExplorerPanel::Params& params, Persistence::SessionOptions const& engineOptions) { auto* filePropertyDialog = params.filePropertyDialog; auto* confirmDialog = params.confirmDialog; @@ -38,7 +39,7 @@ namespace auto const& uiOptions = params.uiOptions; auto* stateHolder = params.stateHolder; - if (std::holds_alternative(params.engineOptions.engine)) + if (std::holds_alternative(engineOptions.engine)) { return { { @@ -76,7 +77,7 @@ namespace ), std::make_unique( uiOptions, - std::get(params.engineOptions.engine).remoteFavorites, + std::get(engineOptions.engine).remoteFavorites, confirmDialog, inputDialog, filePropertyDialog, @@ -136,7 +137,7 @@ struct FileExplorerPanel::Implementation , uiOptions{params.uiOptions} , engineOptions{std::move(params.engineOptions)} , isInLostConnectionState{params.isInLostConnectionState} - , fileGrid{buildFileGrid(params)} + , fileGrid{buildFileGrid(params, engineOptions)} {} }; diff --git a/shared_data/include/shared_data/opener_capabilities.hpp b/shared_data/include/shared_data/opener_capabilities.hpp new file mode 100644 index 00000000..78d936d7 --- /dev/null +++ b/shared_data/include/shared_data/opener_capabilities.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include + +namespace SharedData +{ + /** + * @brief Result of a runtime probe of the platform's "open file externally" facilities. + * @details On Linux this reflects whether xdg-desktop-portal's OpenURI interface (and, + * under flatpak, the Documents portal it depends on) are reachable. On Windows + * these are always @c true -- ShellExecuteW has no discoverable failure mode + * ahead of the call. + */ + struct OpenerCapabilities + { + bool canOpenFile{true}; + bool canOpenInFileManager{true}; + /// Empty on success; a short human-readable diagnostic otherwise (suitable for UI display). + std::string reason{}; + }; + BOOST_DESCRIBE_STRUCT(OpenerCapabilities, (), (canOpenFile, canOpenInFileManager, reason)) +} diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index fe80ce4b..7b5f71df 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -80,6 +80,8 @@ localSideModel: createItemFailedTitle: "Erstellung fehlgeschlagen" failedToOpenFile: "Datei konnte nicht geöffnet werden." openInFileManagerFailed: "Im Dateimanager öffnen fehlgeschlagen" + portalUnavailableHeader: "Externes Öffnen nicht verfügbar" + portalUnavailableText: "Das Desktop-Portal zum Öffnen von Dateien in anderen Anwendungen ist nicht verfügbar. \"Öffnen\", \"Öffnen mit\" und \"Im Dateimanager öffnen\" wurden deaktiviert.\n\n{}" watchAndDownloadHeader: "Beobachten & Herunterladen?" watchAndDownloadSingle: "'{}' in ein temporäres Verzeichnis herunterladen und auf Änderungen beobachten?" watchAndDownloadMultiple: "{} Elemente in ein temporäres Verzeichnis herunterladen und auf Änderungen beobachten?" diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index b2400d9a..5554abd9 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -136,6 +136,8 @@ localSideModel: createDirectoryFailedTitle: "Create Directory Failed" failedToOpenFile: "Failed to open file." openInFileManagerFailed: "Failed to Open in File Manager" + portalUnavailableHeader: "External Open Unavailable" + portalUnavailableText: "The desktop portal used to open files in other applications is not available. \"Open\", \"Open With\", and \"Open in File Manager\" have been disabled.\n\n{}" watchAndDownloadHeader: "Watch & Download?" watchAndDownloadSingle: "Download '{}' to a temporary directory and watch it for changes?" watchAndDownloadMultiple: "Download {} items to a temporary directory and watch them for changes?" diff --git a/utility/include/utility/fd_guard.hpp b/utility/include/utility/fd_guard.hpp new file mode 100644 index 00000000..ac4d31a4 --- /dev/null +++ b/utility/include/utility/fd_guard.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include + +namespace Utility +{ + /** + * @brief Owning POSIX file descriptor. Closes on destruction; non-copyable, movable. + * @details Lets callers ::open() a file once and pass the guard around without risking + * a double-close or a leak on an early return. Move-only by design -- if you + * need to share an fd, dup() it explicitly first. + */ + class FdGuard + { + public: + FdGuard() noexcept = default; + explicit FdGuard(int fd) noexcept + : fd_{fd} + {} + ~FdGuard() noexcept + { + reset(); + } + FdGuard(FdGuard const&) = delete; + FdGuard& operator=(FdGuard const&) = delete; + FdGuard(FdGuard&& other) noexcept + : fd_{other.fd_} + { + other.fd_ = -1; + } + FdGuard& operator=(FdGuard&& other) noexcept + { + if (this != &other) + { + reset(); + fd_ = other.fd_; + other.fd_ = -1; + } + return *this; + } + + [[nodiscard]] int get() const noexcept + { + return fd_; + } + [[nodiscard]] bool valid() const noexcept + { + return fd_ != -1; + } + + /** + * @brief Relinquish ownership of the fd to the caller; the guard becomes empty + * and will not close it. Use when transferring ownership across an API + * boundary that takes responsibility for closing. + */ + [[nodiscard]] int release() noexcept + { + const int fd = fd_; + fd_ = -1; + return fd; + } + + void reset(int fd = -1) noexcept + { + if (fd_ != -1) + ::close(fd_); + fd_ = fd; + } + + private: + int fd_{-1}; + }; +} diff --git a/utility/include/utility/glib_raii.hpp b/utility/include/utility/glib_raii.hpp new file mode 100644 index 00000000..7805f8d4 --- /dev/null +++ b/utility/include/utility/glib_raii.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +#include +#include + +namespace Utility +{ + /** + * @brief RAII wrappers for the GLib / GIO resources that callers tend to repeat + * conditional cleanup for. Each deleter is a no-op on null so the aliases + * can be default-constructed, reset from raw GLib output parameters, and + * moved around without `if (x) g_*_free(x);` at every call site. + */ + struct GErrorDeleter + { + void operator()(GError* e) const noexcept + { + if (e) + g_error_free(e); + } + }; + struct GVariantDeleter + { + void operator()(GVariant* v) const noexcept + { + if (v) + g_variant_unref(v); + } + }; + struct GFreeDeleter + { + void operator()(void* p) const noexcept + { + if (p) + g_free(p); + } + }; + struct GObjectDeleter + { + template + void operator()(T* o) const noexcept + { + if (o) + g_object_unref(o); + } + }; + + using GErrorPtr = std::unique_ptr; + using GVariantPtr = std::unique_ptr; + using GcharPtr = std::unique_ptr; + + /** + * @brief Owning unique_ptr for any GObject-derived type (GDBusConnection, GUnixFDList, + * GDBusProxy, etc.). Use directly: `Utility::GObjectPtr`. + */ + template + using GObjectPtr = std::unique_ptr; + + /** + * @brief Adopt a raw GError* output and collapse it to a message string. The error + * object is freed at scope exit. Pass @p fallback for the case where the + * GLib call set @c result=nullptr without populating an error (rare but + * allowed by the API contract for many functions). + */ + inline std::string consumeGError(GError* raw, char const* fallback) + { + GErrorPtr err{raw}; + return err && err->message ? std::string{err->message} : std::string{fallback}; + } +}