diff --git a/.github/workflows/archlinux.yml b/.github/workflows/archlinux.yml index f48d30a3..6ecd4631 100644 --- a/.github/workflows/archlinux.yml +++ b/.github/workflows/archlinux.yml @@ -38,7 +38,8 @@ jobs: tar \ python \ nodejs \ - webkitgtk-6.0 \ + glib2 \ + sqlite \ curl \ crypto++ \ libssh \ @@ -53,7 +54,26 @@ jobs: zstd \ xz \ docker \ - docker-buildx + docker-buildx \ + alsa-lib \ + at-spi2-core \ + cairo \ + dbus \ + expat \ + libcups \ + libx11 \ + libxcb \ + libxcomposite \ + libxdamage \ + libxext \ + libxfixes \ + libxkbcommon \ + libxrandr \ + mesa \ + nspr \ + nss \ + pango \ + systemd-libs - uses: actions/checkout@v6 with: @@ -116,6 +136,11 @@ jobs: run: | cd "${{ env.BUILD_DIR }}" && tar -czf "nui-sftp-linux-frontend_${{ env.VERSION }}.tar.gz" frontend + # The CEF archive this build used, nui-sftp-deploy's prepare_release puts it into the PKGBUILD and the flatpak. + - name: Name CEF distribution + run: | + cp "${{ env.BUILD_DIR }}/text/cef_distribution.json" "${{ env.BUILD_DIR }}/nui-sftp-cef-distribution_${{ env.VERSION }}.json" + - name: Build standalone licenses tarball run: | # The cmake build already produced licenses.json (and embedded it @@ -133,6 +158,7 @@ jobs: run: | gh release upload "v${{ env.VERSION_CLEAN }}" "${{ env.BUILD_DIR }}/nui-sftp-linux-frontend_${{ env.VERSION }}.tar.gz" gh release upload "v${{ env.VERSION_CLEAN }}" "${{ env.BUILD_DIR }}/nui-sftp-licenses_${{ env.VERSION }}.tar.gz" + gh release upload "v${{ env.VERSION_CLEAN }}" "${{ env.BUILD_DIR }}/nui-sftp-cef-distribution_${{ env.VERSION }}.json" - name: Upload artifacts to MEGA S4 if: startsWith(github.ref, 'refs/tags/v') @@ -153,3 +179,4 @@ jobs: upload_if_absent "nui-sftp-linux-frontend_${{ env.VERSION }}.tar.gz" "s3://nui-sftp-releases/linux-frontend/" upload_if_absent "nui-sftp-licenses_${{ env.VERSION }}.tar.gz" "s3://nui-sftp-releases/licenses/" + upload_if_absent "nui-sftp-cef-distribution_${{ env.VERSION }}.json" "s3://nui-sftp-releases/cef-distribution/" diff --git a/CMakeLists.txt b/CMakeLists.txt index 0c1abe01..7d4fcec6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,14 +21,25 @@ option(BUILD_STATIC_LIBS "Build static libraries" ON) option(NUI_SFTP_ENABLE_TESTING "Enable testing for nui-sftp" OFF) option(OFFLINE_BUILD "Inform CMake that we are building for offline environments (flatpak, yocto), this will set some additional flags and dependencies" OFF) option(OMIT_FRONTEND_BUILD "Dont build the frontend, used primarily for flatpak, because using emscripten there is hell" OFF) +option(NUI_SFTP_FLATPAK "Install for Flatpak (CEF): the executable next to CEF in lib/nui-sftp, bin/nui-sftp starts it through zypak" OFF) option(FETCH_RAPIDFUZZ "Fetch rapidfuzz" ON) option(FETCH_SPDLOG "Fetch spdlog" ON) option(FETCH_EFSW "Fetch efsw" ON) option(NUI_SFT_ENABLE_LANGUAGE_TOOLING "Enable building language tooling, which includes a command line tool for checking language files" OFF) option(TAR_ARCHIVE_HAS_XZ "Enable xz (liblzma) compression in the tar-archive library" ON) -# Webkit from custom build +# Chromium (CEF) on Linux and macOS, WebView2 on Windows. native selects WebKitGTK or WKWebView instead. if (NOT EMSCRIPTEN) + if (WIN32) + set(NUI_SFTP_DEFAULT_BROWSER_ENGINE native) + else() + set(NUI_SFTP_DEFAULT_BROWSER_ENGINE cef) + endif() + set(NUI_BROWSER_ENGINE "${NUI_SFTP_DEFAULT_BROWSER_ENGINE}" CACHE STRING "Browser engine: cef or native") +endif() + +# Webkit from custom build +if (NOT EMSCRIPTEN AND NOT NUI_BROWSER_ENGINE STREQUAL "cef") if (WEBKIT_PATH) message(STATUS "Using custom WebKit build from ${WEBKIT_PATH}") set(ENV{PKG_CONFIG_PATH} "${WEBKIT_PATH}/lib/pkgconfig") @@ -49,7 +60,7 @@ endif() # Nui and webview both look up WebKitGTK, with different preferences they linked GTK 3 and GTK 4 at once. # One API for both: webkitgtk-6.0 (GTK 4) where it is installed, otherwise webkit2gtk-4.1 (GTK 3). -if (NOT EMSCRIPTEN AND UNIX AND NOT APPLE) +if (NOT EMSCRIPTEN AND UNIX AND NOT APPLE AND NOT NUI_BROWSER_ENGINE STREQUAL "cef") find_package(PkgConfig REQUIRED) if (WEBKIT_PATH) pkg_search_module(webkit REQUIRED IMPORTED_TARGET ${WEBKIT_PACKAGE_NAME}) @@ -81,6 +92,15 @@ include (${CMAKE_CURRENT_LIST_DIR}/_cmake/common_options.cmake) # Nui Dependency add_subdirectory("${CMAKE_SOURCE_DIR}/dependencies/Nui" EXCLUDE_FROM_ALL) +# The CEF distribution of this build. The release publishes it for the packages that fetch CEF themselves. +if (NOT EMSCRIPTEN AND NUI_BROWSER_ENGINE STREQUAL "cef") + configure_file( + "${CMAKE_CURRENT_LIST_DIR}/_cmake/configured_files/cef_distribution.json.in" + "${CMAKE_BINARY_DIR}/text/cef_distribution.json" + @ONLY + ) +endif() + # Add executable (sources are added later, depending on frontend/backend) add_executable(${PROJECT_NAME}) target_link_libraries(${PROJECT_NAME} PUBLIC core-target) @@ -106,7 +126,8 @@ if (WIN32) ) add_dependencies(${PROJECT_NAME} nui-sftp-icon) elseif (APPLE AND NOT EMSCRIPTEN) - # scripts/deploy.sh assembles nui-sftp.app from these, the build tree keeps the plain bin/ layout. + # With CEF the build tree has nui-sftp.app (nui_configure_application), with WKWebView the plain bin/ layout, from + # which scripts/deploy_macos.sh assembles the bundle. # Uint8Array.fromBase64 in static/source/index.js needs the WebKit of macOS 15.2. if (NOT CMAKE_OSX_DEPLOYMENT_TARGET OR CMAKE_OSX_DEPLOYMENT_TARGET VERSION_LESS "15.2") message(FATAL_ERROR "nui-sftp needs CMAKE_OSX_DEPLOYMENT_TARGET 15.2 or later, use _cmake/macos/toolchain.cmake.") @@ -119,6 +140,25 @@ elseif (APPLE AND NOT EMSCRIPTEN) @ONLY ) add_dependencies(${PROJECT_NAME} nui-sftp-icon) + # The licenses of CEF and of the Chromium components in it go into the bundle, the Linux packages install them. + set(NUI_SFTP_CEF_LICENSES "") + if (NUI_BROWSER_ENGINE STREQUAL "cef") + get_filename_component(NUI_SFTP_CEF_ROOT "${NUI_CEF_LIBRARY_DIR}" DIRECTORY) + configure_file("${NUI_SFTP_CEF_ROOT}/LICENSE.txt" "${CMAKE_BINARY_DIR}/generated/cef/CEF-LICENSE.txt" COPYONLY) + configure_file("${NUI_SFTP_CEF_ROOT}/CREDITS.html" "${CMAKE_BINARY_DIR}/generated/cef/CEF-CREDITS.html" COPYONLY) + set(NUI_SFTP_CEF_LICENSES + "${CMAKE_BINARY_DIR}/generated/cef/CEF-LICENSE.txt" + "${CMAKE_BINARY_DIR}/generated/cef/CEF-CREDITS.html" + ) + endif() + nui_configure_application( + TARGET ${PROJECT_NAME} + NAME nui-sftp + IDENTIFIER org.nuicpp.nui_sftp + INFO_PLIST "${CMAKE_BINARY_DIR}/generated/Info.plist" + ICON "${NUI_SFTP_ICNS}" + RESOURCES static/assets themes ${NUI_SFTP_CEF_LICENSES} + ) endif() # Third-party license drift check: fails the build if a CMake-declared dep @@ -186,14 +226,37 @@ else() ) endif() - # Copy module bin to frontend. + # Copy module bin to frontend, next to bin/ or into the resources of the app bundle. + if (APPLE AND NUI_BROWSER_ENGINE STREQUAL "cef") + set(NUI_SFTP_FRONTEND_DIRECTORY "$/Resources/frontend") + else() + set(NUI_SFTP_FRONTEND_DIRECTORY "$/../frontend") + endif() if (NOT OMIT_FRONTEND_BUILD) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_BINARY_DIR}/module_${PROJECT_NAME}/bin" "$/../frontend" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_BINARY_DIR}/module_${PROJECT_NAME}/bin" "${NUI_SFTP_FRONTEND_DIRECTORY}" VERBATIM ) endif() + # On macOS with CEF nui-sftp.app carries everything. Elsewhere bin/nui-sftp with frontend, assets and themes next to + # bin/ and the CEF runtime in lib/nui-sftp. Windows packages and WKWebView bundles are assembled by + # scripts/deploy.sh. + if (APPLE AND NUI_BROWSER_ENGINE STREQUAL "cef") + nui_install(TARGETS ${PROJECT_NAME}) + elseif (NOT WIN32) + set(NUI_SFTP_INSTALL_FLATPAK "") + if (NUI_SFTP_FLATPAK) + set(NUI_SFTP_INSTALL_FLATPAK FLATPAK_ZYPAK) + endif() + nui_install(TARGETS ${PROJECT_NAME} DESTINATION bin ENGINE_DESTINATION lib/${PROJECT_NAME} ${NUI_SFTP_INSTALL_FLATPAK}) + install(DIRECTORY "${CMAKE_SOURCE_DIR}/static/assets/" DESTINATION assets) + install(DIRECTORY "${CMAKE_SOURCE_DIR}/themes/" DESTINATION themes) + if (NOT OMIT_FRONTEND_BUILD) + install(DIRECTORY "${CMAKE_BINARY_DIR}/module_${PROJECT_NAME}/bin/" DESTINATION frontend) + endif() + endif() + if (NUI_SFTP_ENABLE_TESTING) include(./_cmake/dependencies/gtest.cmake) enable_testing() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a958f7af..9731adf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,11 +9,13 @@ how changes are validated in CI. nui-sftp is a cross-platform (Linux and Windows) SSH/SFTP workbench. The frontend is written in C++ and compiled to WebAssembly via Emscripten using [Nui](https://github.com/NuiCpp/Nui); the backend is a native C++ application -that hosts a WebKit/WebView2 webview and talks to libssh. Frontend and backend -communicate through Nui's RPC bridge. +that hosts the page in Chromium (Nui's CEF engine) on Linux and macOS and in +WebView2 on Windows, and talks to libssh. Frontend and backend communicate +through Nui's RPC bridge. Submodules pin most C++ dependencies. System packages (libssh, boost, fmt, -crypto++, webkitgtk on Linux) are pulled from the distro or MSYS2. +crypto++) are pulled from the distro or MSYS2. CEF is downloaded by the +configure step, see [Browser engine](#browser-engine). ## Reporting bugs and asking questions @@ -47,10 +49,15 @@ minimum packages CI installs are: ``` cmake ninja clang lld git python nodejs -webkitgtk-6.0 curl crypto++ libssh fmt boost boost-libs +curl crypto++ libssh fmt boost boost-libs glib2 nlohmann-json zlib bzip2 zstd xz ``` +`webkitgtk-6.0` is only needed for `-DNUI_BROWSER_ENGINE=native`. The CEF +library needs `nss`, `alsa-lib`, `at-spi2-core`, `libcups`, `mesa` and a few X11 +libraries, which desktop systems have; the full list for a bare container is +in [.github/workflows/archlinux.yml](.github/workflows/archlinux.yml). + Node.js 24 is required for the frontend build (Nui invokes `parcel` and `npm`). @@ -120,7 +127,26 @@ cmake --build build/clang_release --target nui-sftp ./scripts/deploy.sh ``` -The release configure omits tests and the XML tool by default. +The release configure omits tests and the XML tool by default. On Linux and +macOS `deploy.sh` runs `cmake --install`: `bin/nui-sftp` with `frontend/`, +`assets/` and `themes/` next to `bin/` and the CEF runtime in `lib/nui-sftp/` +(libcef is stripped while installing), on macOS `nui-sftp.app`. + +### Browser engine + +`NUI_BROWSER_ENGINE` is `cef` on Linux and macOS and `native` (WebView2) on +Windows. `-DNUI_BROWSER_ENGINE=native` builds the Linux and macOS versions with +WebKitGTK or WKWebView instead. The engine is described in +[dependencies/Nui/docs/cef_engine.md](dependencies/Nui/docs/cef_engine.md). + +- CEF: the configure step downloads the pinned minimal distribution once into + `~/.cache/nui/cef` (SHA256 checked) and shares it between build trees. + Offline builds pass `-DNUI_FETCH_CEF=OFF` with `-DNUI_CEF_ARCHIVE=` or + `-DNUI_CEF_SYSTEM=ON` (Arch: the `cef` package). +- All Chromium child processes run `nui-cef-helper`, built next to libcef in + `build//nui-cef/`. The main process never runs in a child, so option + parsing and the ForkPool stay as they are. +- On macOS the build tree has `bin/nui-sftp.app`, run the executable inside it. ### Standalone marketing webpage @@ -173,9 +199,11 @@ are sensible; override them only when needed. | `NUI_BUILD_XML_TOOL` | (via Nui) | Build the `xml-to-nui` SVG/XML converter | | `OMIT_FRONTEND_BUILD` | `OFF` | Skip the Emscripten frontend | | `OFFLINE_BUILD` | `OFF` | Adjust dependency fetching for sandboxed builds (Flatpak, Yocto) | +| `NUI_SFTP_FLATPAK` | `OFF` | Install for Flatpak: the executable next to CEF in `lib/nui-sftp`, `bin/nui-sftp` starts it through zypak | | `BUILD_WEBPAGE` | `OFF` | Build the marketing webpage module | | `FETCH_YAML_CPP` / `FETCH_RAPIDFUZZ` / `FETCH_SPDLOG` / `FETCH_EFSW` | `ON` | Fetch vs. use system version | -| `WEBKIT_PATH` | "" | Use a custom WebKit build instead of system pkg-config | +| `NUI_BROWSER_ENGINE` | `cef` (`native` on Windows) | Chromium through CEF, or the platform webview | +| `WEBKIT_PATH` | "" | Use a custom WebKit build instead of system pkg-config (native engine) | | `TAR_ARCHIVE_HAS_XZ` | `ON` | Enable liblzma compression in the tar-archive library | ## Debugging @@ -186,20 +214,26 @@ The backend is a normal native binary; debug it directly with gdb or lldb: gdb --args build/clang_debug/bin/nui-sftp ``` -Two caveats worth knowing about: +Worth knowing: + +- DevTools: debug builds (and release builds started with `--enable-dev-tools`) + serve the Chrome DevTools Protocol on the port in + `NUI_CEF_REMOTE_DEBUGGING_PORT`, open `http://127.0.0.1:` or + `chrome://inspect`. Extra Chromium switches go into `NUI_CEF_ARGUMENTS`, for + example `--ozone-platform=headless` for runs without a display. -- Tell the debugger to ignore `SIGUSR1` and `SIGXCPU`, otherwise the WebKit - and JS runtimes will repeatedly trip it. For gdb: +- With the native engine, tell the debugger to ignore `SIGUSR1` and `SIGXCPU`, + otherwise the WebKit and JS runtimes will repeatedly trip it. For gdb: ``` handle SIGUSR1 nostop noprint handle SIGXCPU nostop noprint pass ``` -- The WebKitGTK DMABUF renderer is disabled at startup unless every GPU uses - amdgpu, i915 or xe (see `Nui::configureWebKitGtkRendering`), the log states - why. Export `WEBKIT_DISABLE_DMABUF_RENDERER=0` to force it on or `=1` to - force it off. +- With the native engine, the WebKitGTK DMABUF renderer is disabled at startup + unless every GPU uses amdgpu, i915 or xe (see + `Nui::configureWebKitGtkRendering`), the log states why. Export + `WEBKIT_DISABLE_DMABUF_RENDERER=0` to force it on or `=1` to force it off. Test binaries live under `build/clang_debug/test/` and can be launched the same way. diff --git a/README.md b/README.md index 5eeb4be8..a99818f3 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ You can download and upload files and folders directly as tar archives, no manua - Box drag select for icon view. - Auto refresh after operation complete. - Resize left vs right. -- Upload drag and drop (within view and into the app (Linux caveat: only a single file, bug in webkitgtk)). +- Upload drag and drop (within view and into the app). - Download drag and drop (within view). - Searching files. - Sorting files. @@ -161,4 +161,3 @@ You can download and upload files and folders directly as tar archives, no manua # Known Problems - Cannot use mouse side keys for history navigation (yet) because of a bug in WebKit that shows them as "button 0". -- On WebKit Systems (Linux), the drag and drop from the desktop only supports single files and folders, not a list of items, because WebKit removed the functionality entirely because of an unfixed security issue. diff --git a/_cmake/common_options.cmake b/_cmake/common_options.cmake index 07d1b42a..e938a5d5 100644 --- a/_cmake/common_options.cmake +++ b/_cmake/common_options.cmake @@ -25,8 +25,8 @@ else() endif() target_compile_features(core-target INTERFACE cxx_std_23) -# Set for the macOS backend and for the frontend built for its WKWebView, where __APPLE__ is not defined. -if (APPLE OR (EMSCRIPTEN AND BROWSER_ENGINE STREQUAL "wkwebview")) +# Set for the macOS backend and for the frontend built for it, where __APPLE__ is not defined. +if (APPLE OR (EMSCRIPTEN AND NUI_SFTP_PLATFORM STREQUAL "macos")) target_compile_definitions(core-target INTERFACE NUI_SFTP_MACOS=1) endif() target_compile_definitions(core-target INTERFACE JSON_DIAGNOSTICS=1) \ No newline at end of file diff --git a/_cmake/configured_files/cef_distribution.json.in b/_cmake/configured_files/cef_distribution.json.in new file mode 100644 index 00000000..6e3cee40 --- /dev/null +++ b/_cmake/configured_files/cef_distribution.json.in @@ -0,0 +1,7 @@ +{ + "version": "@NUI_CEF_SELECTED_VERSION@", + "platform": "@NUI_CEF_SELECTED_PLATFORM@", + "archive": "@NUI_CEF_SELECTED_ARCHIVE@", + "url": "@NUI_CEF_SELECTED_URL@", + "sha256": "@NUI_CEF_SELECTED_SHA256@" +} diff --git a/_cmake/offline_build.cmake b/_cmake/offline_build.cmake index 3979df11..61e60f14 100644 --- a/_cmake/offline_build.cmake +++ b/_cmake/offline_build.cmake @@ -23,6 +23,7 @@ if(NOT EMSCRIPTEN) set(FETCH_EFSW OFF CACHE BOOL "Do not fetch efsw, assume it is in dependencies/efsw" FORCE) set(OMIT_FRONTEND_BUILD ON CACHE BOOL "Do not build the frontend, in offline environments emscripten is hard to use" FORCE) + set(NUI_FETCH_CEF OFF CACHE BOOL "Do not download CEF, pass NUI_CEF_ARCHIVE, NUI_CEF_ROOT or NUI_CEF_SYSTEM instead" FORCE) find_package(nlohmann_json REQUIRED) find_package(fmt REQUIRED) @@ -33,7 +34,9 @@ if(NOT EMSCRIPTEN) add_library(boost_mp11 INTERFACE IMPORTED GLOBAL) add_subdirectory("${CMAKE_SOURCE_DIR}/dependencies/portable-file-dialogs" EXCLUDE_FROM_ALL) - add_subdirectory("${CMAKE_SOURCE_DIR}/dependencies/webview" EXCLUDE_FROM_ALL) + if (NOT NUI_BROWSER_ENGINE STREQUAL "cef") + add_subdirectory("${CMAKE_SOURCE_DIR}/dependencies/webview" EXCLUDE_FROM_ALL) + endif() add_subdirectory("${CMAKE_SOURCE_DIR}/dependencies/yaml-cpp" EXCLUDE_FROM_ALL) add_subdirectory("${CMAKE_SOURCE_DIR}/dependencies/rapidfuzz" EXCLUDE_FROM_ALL) add_subdirectory("${CMAKE_SOURCE_DIR}/dependencies/spdlog" EXCLUDE_FROM_ALL) diff --git a/backend/include/backend/browser_engine.hpp b/backend/include/backend/browser_engine.hpp new file mode 100644 index 00000000..4a1f4160 --- /dev/null +++ b/backend/include/backend/browser_engine.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +/** + * @brief Name and version of the browser engine that renders the page, read at runtime. + * + * For example "Chromium 154.0.8037.98 (CEF 154.0.34)", "WebKitGTK 2.50.1" or "WebView2 141.0.3537.71". + */ +std::string browserEngineDescription(); diff --git a/backend/include/backend/cef/cef_hooks.hpp b/backend/include/backend/cef/cef_hooks.hpp new file mode 100644 index 00000000..b3553050 --- /dev/null +++ b/backend/include/backend/cef/cef_hooks.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include + +#include + +/** + * @brief CEF behaviour of the main window the page cannot provide on its own. + * @details Chromium hands dropped files to JavaScript without their paths. The drag handler reads them when a drag + * enters the window and hands them to the frontend when it claims the drop (NativeFileDrop::claim), like the + * WKWebView hooks on macOS. The hooks also strip the navigation entries from the context menu and keep the + * window on the app: a reload loses the RPC initialization, a file dropped outside a drop zone or a followed + * link would replace the app. + */ +class CefHooks +{ + public: + CefHooks(Nui::Window& window, Nui::RpcHub& hub); + ROAR_PIMPL_SPECIAL_FUNCTIONS(CefHooks); + + private: + struct Implementation; + std::unique_ptr impl_; +}; diff --git a/backend/include/backend/cef/main_cef.hpp b/backend/include/backend/cef/main_cef.hpp new file mode 100644 index 00000000..cfe75812 --- /dev/null +++ b/backend/include/backend/cef/main_cef.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +struct Main::PlatformSpecifics +{ + PlatformSpecifics(Nui::Window& window, Nui::RpcHub& hub); + + CefHooks cefHooks; +}; diff --git a/backend/include/backend/dropped_entry.hpp b/backend/include/backend/dropped_entry.hpp index cc60630b..024c3b4c 100644 --- a/backend/include/backend/dropped_entry.hpp +++ b/backend/include/backend/dropped_entry.hpp @@ -2,8 +2,11 @@ #include +#include + #include #include +#include /** * @brief Describes a file dropped onto the window from the desktop, with just enough detail for the transfer. A @@ -12,3 +15,10 @@ * inspected. */ std::optional droppedItemFromPath(std::filesystem::path const& path); + +/** + * @brief The SessionArea::onFilesDropped message for files the frontend handed to the backend. + * @param request The frontend's message, its isLeft, subdir and dropMetadata go back with the files. + * @param entries The dropped files. + */ +nlohmann::json droppedFilesMessage(nlohmann::json const& request, std::vector const& entries); diff --git a/backend/include/backend/inspector_environment.hpp b/backend/include/backend/inspector_environment.hpp index d06913fd..21e5f8bc 100644 --- a/backend/include/backend/inspector_environment.hpp +++ b/backend/include/backend/inspector_environment.hpp @@ -1,7 +1,7 @@ #pragma once /** - * @brief Removes the environment variables that make the webview serve a remote inspector. + * @brief Removes the environment variables that make the webview serve a remote inspector or DevTools. * * Must run before the webview and its child processes are created, and while the process is still single-threaded. * Logs a warning for every variable that was removed. diff --git a/backend/include/backend/linux/gtk_portal_parent_window.hpp b/backend/include/backend/linux/gtk_portal_parent_window.hpp new file mode 100644 index 00000000..d0f53516 --- /dev/null +++ b/backend/include/backend/linux/gtk_portal_parent_window.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +/** + * @brief Parent window identifier for xdg-desktop-portal calls of a WebKitGTK window. + * @details On Wayland the surface is exported through GDK (zxdg_exporter_v2), the export stays alive so the handle + * remains valid. On X11 it is the window id. + * @param nativeWindow The GtkWidget* of the window. + * @return "wayland:", "x11:", or empty when there is none, then the portal shows its dialog without parent. + */ +std::string gtkPortalParentWindow(void* nativeWindow); diff --git a/backend/include/backend/opener.hpp b/backend/include/backend/opener.hpp index 9b33641d..3c8c4553 100644 --- a/backend/include/backend/opener.hpp +++ b/backend/include/backend/opener.hpp @@ -11,7 +11,12 @@ class Opener { public: - explicit Opener(void* nativeWindow = nullptr); + /** + * @brief Connects to the platform's facilities for opening files. + * @param portalParentWindow Parent window of xdg-desktop-portal dialogs on Linux ("wayland:", + * "x11:" or empty), ignored on the other platforms. + */ + explicit Opener(std::string portalParentWindow = {}); ~Opener(); Opener(Opener const&) = delete; Opener& operator=(Opener const&) = delete; diff --git a/backend/source/backend/CMakeLists.txt b/backend/source/backend/CMakeLists.txt index c013189a..ea7d1e39 100644 --- a/backend/source/backend/CMakeLists.txt +++ b/backend/source/backend/CMakeLists.txt @@ -57,6 +57,7 @@ if (WIN32) windows/windows_drag_drop.cpp windows/main_windows.cpp windows/opener_windows.cpp + windows/browser_engine_windows.cpp ) elseif(APPLE) enable_language(OBJCXX) @@ -66,24 +67,26 @@ elseif(APPLE) file_tracking/instance_lock_nix.cpp pty/posix/pty.cpp pty/posix/process_list.cpp - mac/main_mac.mm mac/opener_mac.mm - mac/web_view_hooks.mm process/fork_pool.cpp ) set_source_files_properties( mac/main_mac.mm mac/opener_mac.mm mac/web_view_hooks.mm + mac/browser_engine_mac.mm PROPERTIES COMPILE_OPTIONS "-fobjc-arc" ) target_link_libraries( backend PRIVATE "-framework AppKit" - "-framework WebKit" "-framework UniformTypeIdentifiers" ) + if (NOT NUI_BROWSER_ENGINE STREQUAL "cef") + target_sources(backend PRIVATE mac/main_mac.mm mac/web_view_hooks.mm mac/browser_engine_mac.mm) + target_link_libraries(backend PRIVATE "-framework WebKit") + endif() else() target_sources( backend @@ -91,23 +94,38 @@ else() file_tracking/instance_lock_nix.cpp pty/posix/pty.cpp pty/posix/process_list.cpp - linux/main_linux.cpp linux/opener_linux.cpp process/fork_pool.cpp ) + if (NOT NUI_BROWSER_ENGINE STREQUAL "cef") + target_sources( + backend + PRIVATE + linux/main_linux.cpp + linux/gtk_portal_parent_window.cpp + linux/browser_engine_linux.cpp + ) + endif() # 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. + # Public: main.cpp names the app with g_set_prgname. WebKitGTK brought GLib along, CEF does not. find_package(PkgConfig REQUIRED) pkg_check_modules(gio REQUIRED IMPORTED_TARGET gio-2.0 gio-unix-2.0) target_link_libraries( backend - PRIVATE + PUBLIC PkgConfig::gio ) endif() +# Chromium on Linux and macOS: the hooks of the main window. +if (NUI_BROWSER_ENGINE STREQUAL "cef") + target_sources(backend PRIVATE cef/main_cef.cpp cef/cef_hooks.cpp cef/browser_engine_cef.cpp) + target_link_libraries(backend PUBLIC Nui::cef) +endif() + target_sources( ${PROJECT_NAME} PRIVATE @@ -156,14 +174,23 @@ target_link_libraries( nui_set_target_output_directories(${PROJECT_NAME}) -set(BROWSER_ENGINE "") -if (WIN32) +# The frontend is compiled per engine and per platform, the macOS frontend also runs in Chromium. +if (NUI_BROWSER_ENGINE STREQUAL "cef") + set(BROWSER_ENGINE "cef") +elseif (WIN32) set(BROWSER_ENGINE "webview2") -elseif(APPLE) +elseif (APPLE) set(BROWSER_ENGINE "wkwebview") else() set(BROWSER_ENGINE "webkitgtk") endif() +if (WIN32) + set(NUI_SFTP_PLATFORM "windows") +elseif (APPLE) + set(NUI_SFTP_PLATFORM "macos") +else() + set(NUI_SFTP_PLATFORM "linux") +endif() include(ProcessorCount) ProcessorCount(nproc) @@ -190,6 +217,7 @@ if (NOT OMIT_FRONTEND_BUILD) -DNUI_FETCH_FMT=ON -DNUI_FIND_FMT=OFF -DBROWSER_ENGINE=${BROWSER_ENGINE} + -DNUI_SFTP_PLATFORM=${NUI_SFTP_PLATFORM} # -DXML_TO_NUI_TOOL=$ -DOFFLINE_BUILD=${OFFLINE_BUILD} -DBACKEND_BUILD_TYPE=${BACKEND_BUILD_TYPE} diff --git a/backend/source/backend/cef/browser_engine_cef.cpp b/backend/source/backend/cef/browser_engine_cef.cpp new file mode 100644 index 00000000..626f4877 --- /dev/null +++ b/backend/source/backend/cef/browser_engine_cef.cpp @@ -0,0 +1,20 @@ +#include + +#include + +#include + +std::string browserEngineDescription() +{ + // The entries of the loaded libcef, which is not necessarily the one of the headers (system CEF). + return fmt::format( + "Chromium {}.{}.{}.{} (CEF {}.{}.{})", + cef_version_info(4), + cef_version_info(5), + cef_version_info(6), + cef_version_info(7), + cef_version_info(0), + cef_version_info(1), + cef_version_info(2) + ); +} diff --git a/backend/source/backend/cef/cef_hooks.cpp b/backend/source/backend/cef/cef_hooks.cpp new file mode 100644 index 00000000..6dceccbb --- /dev/null +++ b/backend/source/backend/cef/cef_hooks.cpp @@ -0,0 +1,245 @@ +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + /// Main::show navigates there. + constexpr std::string_view appOrigin = "nui://app.example/"; + + /** + * @brief Paths of the files of the last drag that entered the window. + */ + class DraggedFiles + { + public: + void remember(std::vector paths) + { + std::scoped_lock lock{mutex_}; + paths_ = std::move(paths); + } + + /** + * @brief Takes the remembered paths when their file names are @p names, the names the page got. + * @details The drag handler only learns about drags entering the window, not about drops, so the names keep a + * claim from taking the files of an earlier drag that never dropped. + */ + std::vector take(std::vector names) + { + std::scoped_lock lock{mutex_}; + auto paths = std::move(paths_); + paths_.clear(); + + std::vector pathNames; + pathNames.reserve(paths.size()); + for (auto const& path : paths) + pathNames.push_back(Paths::nativeToUtf8(path.filename())); + std::ranges::sort(pathNames); + std::ranges::sort(names); + if (pathNames != names) + return {}; + return paths; + } + + private: + std::mutex mutex_{}; + std::vector paths_{}; + }; + + class ContextMenuHandler : public CefContextMenuHandler + { + public: + void OnBeforeContextMenu( + CefRefPtr, + CefRefPtr, + CefRefPtr, + CefRefPtr model + ) override + { + for (auto const command : + {MENU_ID_BACK, + MENU_ID_FORWARD, + MENU_ID_RELOAD, + MENU_ID_RELOAD_NOCACHE, + MENU_ID_STOPLOAD, + MENU_ID_PRINT, + MENU_ID_VIEW_SOURCE}) + { + model->Remove(command); + } + removeStraySeparators(*model); + } + + private: + static bool isSeparator(CefMenuModel& model, std::size_t index) + { + return model.GetTypeAt(index) == MENUITEMTYPE_SEPARATOR; + } + + /// Leading, trailing and doubled separators are left over from the removed entries. + static void removeStraySeparators(CefMenuModel& model) + { + for (std::size_t index = 0u; index < model.GetCount();) + { + const bool stray = isSeparator(model, index) && + (index == 0u || index + 1u == model.GetCount() || isSeparator(model, index + 1u)); + if (stray) + model.RemoveAt(index); + else + ++index; + } + } + + IMPLEMENT_REFCOUNTING(ContextMenuHandler); + }; + + class DragHandler : public CefDragHandler + { + public: + explicit DragHandler(std::shared_ptr files) + : files_{std::move(files)} + {} + + bool OnDragEnter(CefRefPtr, CefRefPtr dragData, DragOperationsMask) override + { + std::vector cefPaths; + dragData->GetFilePaths(cefPaths); + + std::vector paths; + paths.reserve(cefPaths.size()); + for (auto const& path : cefPaths) + paths.emplace_back(Paths::nativeFromUtf8(path.ToString())); + files_->remember(std::move(paths)); + return false; + } + + private: + std::shared_ptr files_; + IMPLEMENT_REFCOUNTING(DragHandler); + }; + + class RequestHandler : public CefRequestHandler + { + public: + bool OnBeforeBrowse( + CefRefPtr, + CefRefPtr frame, + CefRefPtr request, + bool, + bool + ) override + { + if (!frame->IsMain()) + return false; + + // The app is one document: once it is loaded, a navigation, a reload too, would lose its RPC setup. + const auto url = request->GetURL().ToString(); + const bool appLoaded = frame->GetURL().ToString().starts_with(appOrigin); + if (!appLoaded && (url.starts_with(appOrigin) || url == "about:blank")) + return false; + + Log::warn("Blocked a navigation of the window to '{}'.", url); + return true; + } + + bool OnOpenURLFromTab( + CefRefPtr, + CefRefPtr, + CefString const& targetUrl, + WindowOpenDisposition, + bool + ) override + { + Log::warn("Blocked opening '{}' in a new window.", targetUrl.ToString()); + return true; + } + + private: + IMPLEMENT_REFCOUNTING(RequestHandler); + }; + + /** + * @brief File names the page sent with the claim. + */ + std::vector claimedNames(nlohmann::json const& request) + { + std::vector names; + if (auto const namesEntry = request.find("names"); namesEntry != request.end() && namesEntry->is_array()) + { + for (auto const& name : *namesEntry) + { + if (name.is_string()) + names.push_back(name.get()); + } + } + return names; + } + + /** + * @brief Answers NativeFileDrop::claim with the remembered files when the page dropped them. + */ + Nui::RpcHub::AutoUnregister registerDropClaim(Nui::RpcHub& hub, std::shared_ptr files) + { + return hub.autoRegisterFunction( + "NativeFileDrop::claim", + [&hub, files = std::move(files)](nlohmann::json const& arguments) + { + auto const& request = arguments.is_array() && !arguments.empty() ? arguments[0] : arguments; + + std::vector entries; + for (auto const& path : files->take(claimedNames(request))) + { + if (auto item = droppedItemFromPath(path); item) + entries.push_back(*std::move(item)); + } + if (entries.empty()) + { + Log::warn("NativeFileDrop::claim: no dropped files with these names are pending."); + return; + } + hub.callRemote("SessionArea::onFilesDropped", droppedFilesMessage(request, entries)); + } + ); + } +} + +struct CefHooks::Implementation +{ + explicit Implementation(Nui::RpcHub& hub) + : draggedFiles{std::make_shared()} + , claimRegistration{registerDropClaim(hub, draggedFiles)} + {} + + std::shared_ptr draggedFiles; + Nui::RpcHub::AutoUnregister claimRegistration; +}; + +CefHooks::CefHooks(Nui::Window& window, Nui::RpcHub& hub) + : impl_{std::make_unique(hub)} +{ + Nui::Cef::setClientHooks( + window, + Nui::Cef::ClientHooks{ + .contextMenuHandler = new ContextMenuHandler(), + .dragHandler = new DragHandler(impl_->draggedFiles), + .requestHandler = new RequestHandler(), + } + ); +} + +ROAR_PIMPL_SPECIAL_FUNCTIONS_IMPL(CefHooks); diff --git a/backend/source/backend/cef/main_cef.cpp b/backend/source/backend/cef/main_cef.cpp new file mode 100644 index 00000000..b59de2d0 --- /dev/null +++ b/backend/source/backend/cef/main_cef.cpp @@ -0,0 +1,5 @@ +#include + +Main::PlatformSpecifics::PlatformSpecifics(Nui::Window& window, Nui::RpcHub& hub) + : cefHooks{window, hub} +{} diff --git a/backend/source/backend/dropped_entry.cpp b/backend/source/backend/dropped_entry.cpp index 035ab7b3..18a0ca94 100644 --- a/backend/source/backend/dropped_entry.cpp +++ b/backend/source/backend/dropped_entry.cpp @@ -66,3 +66,16 @@ std::optional droppedItemFromPath(std::filesystem::path } return SharedData::DroppedItem{.path = *std::move(localPath), .entry = std::move(entry)}; } + +nlohmann::json droppedFilesMessage(nlohmann::json const& request, std::vector const& entries) +{ + auto message = nlohmann::json{ + {"entries", entries}, + {"dropMetadata", request.value("dropMetadata", "")}, + }; + if (request.contains("isLeft")) + message["isLeft"] = request["isLeft"]; + if (request.contains("subdir")) + message["subdir"] = request["subdir"]; + return message; +} diff --git a/backend/source/backend/inspector_environment.cpp b/backend/source/backend/inspector_environment.cpp index 4f8a0e7e..15073a68 100644 --- a/backend/source/backend/inspector_environment.cpp +++ b/backend/source/backend/inspector_environment.cpp @@ -17,6 +17,11 @@ namespace "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "WEBVIEW2_PIPE_FOR_SCRIPT_DEBUGGER", }; +#elif defined(NUI_ENGINE_CEF) + // Nui reads the port only for windows with debug enabled, the variable is removed all the same. + constexpr std::array inspectorVariables{ + "NUI_CEF_REMOTE_DEBUGGING_PORT", + }; #else constexpr std::array inspectorVariables{ "WEBKIT_INSPECTOR_SERVER", @@ -24,6 +29,11 @@ namespace }; #endif +#ifdef NUI_ENGINE_CEF + /// Chromium switches for every Nui window, a remote debugging switch among them serves DevTools as well. + constexpr char const* chromiumArgumentsVariable = "NUI_CEF_ARGUMENTS"; +#endif + void removeVariable(char const* name) { #ifdef _WIN32 @@ -46,4 +56,17 @@ void removeRemoteInspectorEnvironment() Log::warn("Removed {}='{}' from the environment, pass --enable-dev-tools to keep it.", name, std::string_view{value}); removeVariable(name); } + +#ifdef NUI_ENGINE_CEF + char const* const arguments = std::getenv(chromiumArgumentsVariable); + if (arguments != nullptr && std::string_view{arguments}.find("--remote-debugging") != std::string_view::npos) + { + Log::warn( + "Removed {}='{}' from the environment, pass --enable-dev-tools to keep it.", + chromiumArgumentsVariable, + std::string_view{arguments} + ); + removeVariable(chromiumArgumentsVariable); + } +#endif } diff --git a/backend/source/backend/linux/browser_engine_linux.cpp b/backend/source/backend/linux/browser_engine_linux.cpp new file mode 100644 index 00000000..7a5072ee --- /dev/null +++ b/backend/source/backend/linux/browser_engine_linux.cpp @@ -0,0 +1,16 @@ +#include + +#if __has_include() +# include +#else +# include +#endif + +#include + +std::string browserEngineDescription() +{ + return fmt::format( + "WebKitGTK {}.{}.{}", webkit_get_major_version(), webkit_get_minor_version(), webkit_get_micro_version() + ); +} diff --git a/backend/source/backend/linux/gtk_portal_parent_window.cpp b/backend/source/backend/linux/gtk_portal_parent_window.cpp new file mode 100644 index 00000000..c5ee0b70 --- /dev/null +++ b/backend/source/backend/linux/gtk_portal_parent_window.cpp @@ -0,0 +1,184 @@ +#include + +#include + +#include + +#if GTK_MAJOR_VERSION >= 4 +# ifdef GDK_WINDOWING_X11 +# include +# endif +# ifdef GDK_WINDOWING_WAYLAND +# include +# endif +#else +# ifdef GDK_WINDOWING_X11 +# include +# endif +# ifdef GDK_WINDOWING_WAYLAND +# include +# endif +#endif + +#include + +std::string gtkPortalParentWindow(void* nativeWindow) +{ + if (!nativeWindow) + { + Log::warn("Opener: nativeWindow is null, portal parent_window will be empty"); + return {}; + } + + auto* widget = static_cast(nativeWindow); + GdkDisplay* display = gtk_widget_get_display(widget); + if (!display) + { + Log::warn("Opener: failed to get GdkDisplay from widget"); + return {}; + } + +#if GTK_MAJOR_VERSION >= 4 + Log::debug("Opener: using GTK4 API to extract parent window handle"); + + GdkSurface* surface = gtk_native_get_surface(GTK_NATIVE(widget)); + if (!surface) + { + Log::warn("Opener: gtk_native_get_surface returned null — window may not yet be realized"); + return {}; + } + +# ifdef GDK_WINDOWING_WAYLAND + if (GDK_IS_WAYLAND_DISPLAY(display)) + { + Log::debug("Opener: Wayland display detected (GTK4), calling gdk_wayland_toplevel_export_handle"); + + struct ExportData + { + std::string handle; + bool done = false; + } data; + + gboolean const exported = gdk_wayland_toplevel_export_handle( + GDK_TOPLEVEL(surface), + [](GdkToplevel* /*toplevel*/, char const* handle, gpointer ptr) + { + auto* d = static_cast(ptr); + d->handle = std::string{"wayland:"} + (handle ? handle : ""); + d->done = true; + Log::debug("Opener: Wayland export handle callback fired, handle='{}'", d->handle); + }, + &data, + nullptr); + + if (!exported) + { + Log::warn("Opener: gdk_wayland_toplevel_export_handle returned FALSE"); + return {}; + } + + // Spin the GLib default main context until the compositor sends back the handle event. + // We are on the GTK main thread so this is safe; nested iterations are supported by GLib. + int iterations = 0; + while (!data.done) + { + g_main_context_iteration(nullptr, TRUE); + if (++iterations > 100) + { + Log::warn("Opener: timed out waiting for Wayland handle event after {} GLib iterations", iterations); + return {}; + } + } + + Log::info("Opener: Wayland parent window handle resolved in {} iteration(s): '{}'", iterations, data.handle); + return data.handle; + } +# endif // GDK_WINDOWING_WAYLAND + +# ifdef GDK_WINDOWING_X11 + if (GDK_IS_X11_DISPLAY(display)) + { + guint32 const xid = gdk_x11_surface_get_xid(surface); + auto result = "x11:" + std::to_string(xid); + Log::info("Opener: X11 parent window handle: '{}'", result); + return result; + } +# endif // GDK_WINDOWING_X11 + + Log::warn("Opener: GTK4 — display is neither Wayland nor X11, parent_window will be empty"); + +#else // GTK_MAJOR_VERSION < 4 + Log::debug("Opener: using GTK3 API to extract parent window handle"); + + GdkWindow* gdkWindow = gtk_widget_get_window(widget); + if (!gdkWindow) + { + Log::warn("Opener: gtk_widget_get_window returned null (window not yet realized?)"); + return {}; + } + +# ifdef GDK_WINDOWING_WAYLAND + if (GDK_IS_WAYLAND_DISPLAY(display)) + { + Log::debug("Opener: Wayland display detected (GTK3), calling gdk_wayland_window_export_handle"); + + struct ExportData + { + std::string handle; + bool done = false; + } data; + + gboolean const exported = gdk_wayland_window_export_handle( + gdkWindow, + [](GdkWindow* /*window*/, char const* handle, gpointer ptr) + { + auto* d = static_cast(ptr); + d->handle = std::string{"wayland:"} + (handle ? handle : ""); + d->done = true; + Log::debug("Opener: GTK3 Wayland export handle callback fired, handle='{}'", d->handle); + }, + &data, + nullptr); + + if (!exported) + { + Log::warn("Opener: gdk_wayland_window_export_handle returned FALSE (GTK3)"); + return {}; + } + + int iterations = 0; + while (!data.done) + { + g_main_context_iteration(nullptr, TRUE); + if (++iterations > 100) + { + Log::warn( + "Opener: GTK3 timed out waiting for Wayland handle event after {} iterations", iterations); + return {}; + } + } + + Log::info( + "Opener: GTK3 Wayland parent window handle resolved in {} iteration(s): '{}'", + iterations, + data.handle); + return data.handle; + } +# endif // GDK_WINDOWING_WAYLAND + +# ifdef GDK_WINDOWING_X11 + if (GDK_IS_X11_DISPLAY(display)) + { + Window const xid = gdk_x11_window_get_xid(gdkWindow); + auto result = "x11:" + std::to_string(xid); + Log::info("Opener: GTK3 X11 parent window handle: '{}'", result); + return result; + } +# endif // GDK_WINDOWING_X11 + + Log::warn("Opener: GTK3 — display is neither Wayland nor X11, parent_window will be empty"); + +#endif // GTK_MAJOR_VERSION + + return {}; +} diff --git a/backend/source/backend/linux/opener_linux.cpp b/backend/source/backend/linux/opener_linux.cpp index 35bfa943..6e6f2802 100644 --- a/backend/source/backend/linux/opener_linux.cpp +++ b/backend/source/backend/linux/opener_linux.cpp @@ -11,26 +11,6 @@ #include #include -#include - -#if GTK_MAJOR_VERSION >= 4 -# include -# include -# ifdef GDK_WINDOWING_X11 -# include -# endif -# ifdef GDK_WINDOWING_WAYLAND -# include -# endif -#else -# ifdef GDK_WINDOWING_X11 -# include -# endif -# ifdef GDK_WINDOWING_WAYLAND -# include -# endif -#endif - #include #include @@ -41,6 +21,7 @@ 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* registryInterface = "org.freedesktop.host.portal.Registry"; constexpr char const* documentsService = "org.freedesktop.portal.Documents"; constexpr char const* documentsObjectPath = "/org/freedesktop/portal/documents"; constexpr char const* documentsInterface = "org.freedesktop.portal.Documents"; @@ -56,178 +37,6 @@ namespace return std::filesystem::exists("/.flatpak-info", ec) && !ec; } - //--------------------------------------------------------------------------------------------------------------------- - // Extract the XDG portal parent_window identifier from the GtkWidget* native window. - // On Wayland this uses zxdg_exporter_v2 (via GDK's export_handle API) to get a stable - // exportable handle. The handle is then cached; the export is kept alive so it remains valid. - // On X11 we just read the XID, which is always available synchronously. - // - // Returns "" on failure (portal will still try to show the dialog, just without a parent). - //--------------------------------------------------------------------------------------------------------------------- - std::string extractParentWindowHandle(void* nativeWindow) - { - if (!nativeWindow) - { - Log::warn("Opener: nativeWindow is null, portal parent_window will be empty"); - return {}; - } - - auto* widget = static_cast(nativeWindow); - GdkDisplay* display = gtk_widget_get_display(widget); - if (!display) - { - Log::warn("Opener: failed to get GdkDisplay from widget"); - return {}; - } - -#if GTK_MAJOR_VERSION >= 4 - Log::debug("Opener: using GTK4 API to extract parent window handle"); - - GdkSurface* surface = gtk_native_get_surface(GTK_NATIVE(widget)); - if (!surface) - { - Log::warn("Opener: gtk_native_get_surface returned null — window may not yet be realized"); - return {}; - } - -# ifdef GDK_WINDOWING_WAYLAND - if (GDK_IS_WAYLAND_DISPLAY(display)) - { - Log::debug("Opener: Wayland display detected (GTK4), calling gdk_wayland_toplevel_export_handle"); - - struct ExportData - { - std::string handle; - bool done = false; - } data; - - gboolean const exported = gdk_wayland_toplevel_export_handle( - GDK_TOPLEVEL(surface), - [](GdkToplevel* /*toplevel*/, char const* handle, gpointer ptr) - { - auto* d = static_cast(ptr); - d->handle = std::string{"wayland:"} + (handle ? handle : ""); - d->done = true; - Log::debug("Opener: Wayland export handle callback fired, handle='{}'", d->handle); - }, - &data, - nullptr); - - if (!exported) - { - Log::warn("Opener: gdk_wayland_toplevel_export_handle returned FALSE"); - return {}; - } - - // Spin the GLib default main context until the compositor sends back the handle event. - // We are on the GTK main thread so this is safe; nested iterations are supported by GLib. - int iterations = 0; - while (!data.done) - { - g_main_context_iteration(nullptr, TRUE); - if (++iterations > 100) - { - Log::warn("Opener: timed out waiting for Wayland handle event after {} GLib iterations", iterations); - return {}; - } - } - - Log::info("Opener: Wayland parent window handle resolved in {} iteration(s): '{}'", iterations, data.handle); - return data.handle; - } -# endif // GDK_WINDOWING_WAYLAND - -# ifdef GDK_WINDOWING_X11 - if (GDK_IS_X11_DISPLAY(display)) - { - guint32 const xid = gdk_x11_surface_get_xid(surface); - auto result = "x11:" + std::to_string(xid); - Log::info("Opener: X11 parent window handle: '{}'", result); - return result; - } -# endif // GDK_WINDOWING_X11 - - Log::warn("Opener: GTK4 — display is neither Wayland nor X11, parent_window will be empty"); - -#else // GTK_MAJOR_VERSION < 4 - Log::debug("Opener: using GTK3 API to extract parent window handle"); - - GdkWindow* gdkWindow = gtk_widget_get_window(widget); - if (!gdkWindow) - { - Log::warn("Opener: gtk_widget_get_window returned null (window not yet realized?)"); - return {}; - } - -# ifdef GDK_WINDOWING_WAYLAND - if (GDK_IS_WAYLAND_DISPLAY(display)) - { - Log::debug("Opener: Wayland display detected (GTK3), calling gdk_wayland_window_export_handle"); - - struct ExportData - { - std::string handle; - bool done = false; - } data; - - gboolean const exported = gdk_wayland_window_export_handle( - gdkWindow, - [](GdkWindow* /*window*/, char const* handle, gpointer ptr) - { - auto* d = static_cast(ptr); - d->handle = std::string{"wayland:"} + (handle ? handle : ""); - d->done = true; - Log::debug("Opener: GTK3 Wayland export handle callback fired, handle='{}'", d->handle); - }, - &data, - nullptr); - - if (!exported) - { - Log::warn("Opener: gdk_wayland_window_export_handle returned FALSE (GTK3)"); - return {}; - } - - int iterations = 0; - while (!data.done) - { - g_main_context_iteration(nullptr, TRUE); - if (++iterations > 100) - { - Log::warn( - "Opener: GTK3 timed out waiting for Wayland handle event after {} iterations", iterations); - return {}; - } - } - - Log::info( - "Opener: GTK3 Wayland parent window handle resolved in {} iteration(s): '{}'", - iterations, - data.handle); - return data.handle; - } -# endif // GDK_WINDOWING_WAYLAND - -# ifdef GDK_WINDOWING_X11 - if (GDK_IS_X11_DISPLAY(display)) - { - Window const xid = gdk_x11_window_get_xid(gdkWindow); - auto result = "x11:" + std::to_string(xid); - Log::info("Opener: GTK3 X11 parent window handle: '{}'", result); - return result; - } -# endif // GDK_WINDOWING_X11 - - Log::warn("Opener: GTK3 — display is neither Wayland nor X11, parent_window will be empty"); - -#endif // GTK_MAJOR_VERSION - - return {}; - } -} - -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 @@ -323,6 +132,77 @@ namespace return 0; } + /** + * @brief A session bus connection of the opener's own, so the portal registration is its first portal call. + * @details GLib's GDBus rather than sd-bus: Ubuntu 24.04's xdg-dbus-proxy mangles the one-shot "AUTH EXTERNAL " + * handshake of sd_bus_open_user(), but accepts GDBus's multi-step flow unchanged. + */ + Utility::GObjectPtr openSessionBus() + { + GError* rawError = nullptr; + const Utility::GcharPtr address{g_dbus_address_get_for_bus_sync(G_BUS_TYPE_SESSION, nullptr, &rawError)}; + if (address) + { + Utility::GObjectPtr connection{g_dbus_connection_new_for_address_sync( + address.get(), + static_cast( + G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION + ), + nullptr, + nullptr, + &rawError + )}; + if (connection) + return connection; + } + Log::warn( + "Opener: could not connect to session bus ({}). Opening files via xdg-desktop-portal will be unavailable.", + Utility::consumeGError(rawError, "(no error object)") + ); + return {}; + } + + /** + * @brief Tells xdg-desktop-portal the app id behind the connection's calls (portal 1.19 and later). + * @details Without it the portal derives the id from the systemd scope, which Chromium renames to + * app-org.chromium.Chromium-.scope, so permissions and dialogs would belong to Chromium. Inside + * Flatpak the sandbox identifies the app. + */ + void registerPortalApplication(GDBusConnection* connection) + { + if (runningInFlatpak()) + return; + + char const* const applicationId = g_get_prgname(); + if (applicationId == nullptr) + return; + + GError* rawError = nullptr; + const Utility::GVariantPtr result{g_dbus_connection_call_sync( + connection, + portalService, + portalObjectPath, + registryInterface, + "Register", + g_variant_new("(sa{sv})", applicationId, nullptr), + nullptr, + G_DBUS_CALL_FLAGS_NONE, + /*timeout_msec*/ 2000, + /*cancellable*/ nullptr, + &rawError + )}; + if (!result) + { + Log::info( + "Opener: registering '{}' with xdg-desktop-portal failed, it identifies the app by its scope: {}", + applicationId, + Utility::consumeGError(rawError, "(no error object)") + ); + return; + } + Log::info("Opener: registered as '{}' with xdg-desktop-portal", applicationId); + } + /** * @brief Launch context that starts programs with the host environment instead of the AppImage one. */ @@ -372,32 +252,22 @@ namespace struct Opener::Implementation { - std::string parentWindow; // "wayland:HANDLE" | "x11:XID" | "" + /// "wayland:HANDLE", "x11:XID" or empty. + std::string parentWindow; Utility::GObjectPtr connection; - explicit Implementation(void* nativeWindow) - : parentWindow{extractParentWindowHandle(nativeWindow)} + explicit Implementation(std::string portalParentWindow) + : parentWindow{std::move(portalParentWindow)} + , connection{openSessionBus()} { - // 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.", - Utility::consumeGError(rawErr, "(no error object)")); - } + if (connection) + registerPortalApplication(connection.get()); Log::info("Opener: initialized with parentWindow='{}'", parentWindow); } }; -Opener::Opener(void* nativeWindow) - : impl_{std::make_unique(nativeWindow)} +Opener::Opener(std::string portalParentWindow) + : impl_{std::make_unique(std::move(portalParentWindow))} {} Opener::~Opener() = default; diff --git a/backend/source/backend/mac/browser_engine_mac.mm b/backend/source/backend/mac/browser_engine_mac.mm new file mode 100644 index 00000000..aa41a43b --- /dev/null +++ b/backend/source/backend/mac/browser_engine_mac.mm @@ -0,0 +1,12 @@ +#include + +#import + +std::string browserEngineDescription() +{ + NSString* const version = + [[NSBundle bundleWithIdentifier:@"com.apple.WebKit"] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; + if (version == nil) + return "WebKit"; + return std::string{"WebKit "} + version.UTF8String; +} diff --git a/backend/source/backend/mac/opener_mac.mm b/backend/source/backend/mac/opener_mac.mm index 974c4849..f2a20865 100644 --- a/backend/source/backend/mac/opener_mac.mm +++ b/backend/source/backend/mac/opener_mac.mm @@ -96,7 +96,7 @@ void chooseApplicationAndOpen(NSURL* url) struct Opener::Implementation {}; -Opener::Opener(void* /*nativeWindow*/) +Opener::Opener(std::string /*portalParentWindow*/) : impl_{std::make_unique()} {} diff --git a/backend/source/backend/mac/web_view_hooks.mm b/backend/source/backend/mac/web_view_hooks.mm index 9b374946..c2e54eb9 100644 --- a/backend/source/backend/mac/web_view_hooks.mm +++ b/backend/source/backend/mac/web_view_hooks.mm @@ -119,15 +119,7 @@ - (void)willOpenMenu:(NSMenu*)menu withEvent:(NSEvent*)event return; } - auto reply = nlohmann::json{ - {"entries", entries}, - {"dropMetadata", request.value("dropMetadata", "")}, - }; - if (request.contains("isLeft")) - reply["isLeft"] = request["isLeft"]; - if (request.contains("subdir")) - reply["subdir"] = request["subdir"]; - hub.callRemote("SessionArea::onFilesDropped", reply); + hub.callRemote("SessionArea::onFilesDropped", droppedFilesMessage(request, entries)); } ), })} diff --git a/backend/source/backend/main.cpp b/backend/source/backend/main.cpp index b459c268..806829dd 100644 --- a/backend/source/backend/main.cpp +++ b/backend/source/backend/main.cpp @@ -3,16 +3,23 @@ #ifdef _WIN32 # include +#elif defined(NUI_ENGINE_CEF) +# include +# include #elif defined(__APPLE__) # include # include #else +# include # include # include +#endif +#ifdef __linux__ # include #endif +#include #include #include #include @@ -28,6 +35,9 @@ #include #include #include +#ifdef NUI_ENGINE_CEF +# include +#endif #include #include #include @@ -64,6 +74,20 @@ volatile sig_atomic_t sigchld[10] = {0}; namespace { + /** + * @brief Parent window of the xdg-desktop-portal dialogs of the opener, empty where there is none. + */ + std::string portalParentWindow([[maybe_unused]] Nui::Window& window) + { +#if defined(NUI_ENGINE_CEF) + return Nui::Cef::portalParentWindow(window); +#elif defined(__linux__) + return gtkPortalParentWindow(window.getNativeWindow()); +#else + return {}; +#endif + } + auto makeResponse(int code, std::string const& reason, std::string body, std::string const& mimeType = ""s) { std::unordered_multimap headers = { @@ -183,6 +207,9 @@ namespace // Windows: Do urls to this custom scheme have an authority component? (For portability reasons, they // usually should have). .hasAuthorityComponent = true, + + // CEF: the handler only reads files, requests are served in parallel off the UI thread. + .handlerThread = SchemeHandlerThread::Any, }; } } @@ -300,7 +327,7 @@ void Main::registerRpc() } ); - opener_ = std::make_unique(window_.getNativeWindow()); + opener_ = std::make_unique(portalParentWindow(window_)); rpcSystem_ = std::make_unique(window_.getExecutor(), window_, hub_); tempDirInstanceManager_ = std::make_unique( window_.getExecutor(), @@ -425,6 +452,7 @@ void Main::registerVersionGetter() {"gitBranch", GIT_BRANCH}, {"localPlatform", Paths::compiledLocalPlatform() == Paths::LocalPlatform::Windows ? "windows" : "posix"}, + {"browserEngine", browserEngineDescription()}, } ); } @@ -535,8 +563,8 @@ int main(int const argc, char const* const* argv) #endif #ifdef __linux__ - // GTK 4 does not take it from argv like GTK 3, it names the X11 WM_CLASS and the Wayland app id. - // In a flatpak WebKit has to use the app id, the portal refuses to spawn its sandboxed web process otherwise. + // Names the X11 WM_CLASS and the Wayland app id: GTK 4 does not take it from argv like GTK 3, and Nui passes it to + // Chromium. In a flatpak WebKit has to use the app id, the portal refuses to spawn its sandboxed web process otherwise. const char* const flatpakApplicationId = std::getenv("FLATPAK_ID"); g_set_prgname(flatpakApplicationId != nullptr && *flatpakApplicationId != '\0' ? flatpakApplicationId : "nui-sftp"); #endif diff --git a/backend/source/backend/windows/browser_engine_windows.cpp b/backend/source/backend/windows/browser_engine_windows.cpp new file mode 100644 index 00000000..39aabdde --- /dev/null +++ b/backend/source/backend/windows/browser_engine_windows.cpp @@ -0,0 +1,37 @@ +#include + +#include + +#include + +#include +#include + +namespace +{ + // The WebView2 runtime in EdgeUpdate, as in Microsoft's "Detect if a WebView2 Runtime is already installed". + constexpr wchar_t const* machineRuntimeKey = + L"SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + constexpr wchar_t const* userRuntimeKey = + L"Software\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; + + std::wstring runtimeVersion(HKEY root, wchar_t const* key) + { + std::array buffer{}; + auto size = static_cast(buffer.size() * sizeof(wchar_t)); + if (RegGetValueW(root, key, L"pv", RRF_RT_REG_SZ, nullptr, buffer.data(), &size) != ERROR_SUCCESS) + return {}; + const std::wstring version{buffer.data()}; + return version == L"0.0.0.0" ? std::wstring{} : version; + } +} + +std::string browserEngineDescription() +{ + auto version = runtimeVersion(HKEY_LOCAL_MACHINE, machineRuntimeKey); + if (version.empty()) + version = runtimeVersion(HKEY_CURRENT_USER, userRuntimeKey); + if (version.empty()) + return "WebView2"; + return "WebView2 " + Paths::wideToUtf8(version); +} diff --git a/backend/source/backend/windows/opener_windows.cpp b/backend/source/backend/windows/opener_windows.cpp index 9c12b59e..9ee4fbee 100644 --- a/backend/source/backend/windows/opener_windows.cpp +++ b/backend/source/backend/windows/opener_windows.cpp @@ -71,7 +71,7 @@ std::optional Opener::riskOf(std::filesystem::path const& path) con struct Opener::Implementation {}; -Opener::Opener(void* /*nativeWindow*/) +Opener::Opener(std::string /*portalParentWindow*/) : impl_{std::make_unique()} {} diff --git a/dependencies/Nui b/dependencies/Nui index e827505d..99db4997 160000 --- a/dependencies/Nui +++ b/dependencies/Nui @@ -1 +1 @@ -Subproject commit e827505da1c088910573951d3d0cc9f0b4410711 +Subproject commit 99db4997993e8f2e48d95af8b02a01b0721b6ad6 diff --git a/frontend/include/frontend/events/frontend_events.hpp b/frontend/include/frontend/events/frontend_events.hpp index 4ac40c26..417c6275 100644 --- a/frontend/include/frontend/events/frontend_events.hpp +++ b/frontend/include/frontend/events/frontend_events.hpp @@ -18,6 +18,8 @@ struct AppVersion std::string version{}; std::string gitTag{}; std::string gitBranch{}; + /// Name and version of the browser engine, for example "Chromium 154.0.8037.98 (CEF 154.0.34)". + std::string browserEngine{}; }; /** diff --git a/frontend/source/frontend/licenses.cpp b/frontend/source/frontend/licenses.cpp index 1dc16e61..3ef8b540 100644 --- a/frontend/source/frontend/licenses.cpp +++ b/frontend/source/frontend/licenses.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -46,6 +47,23 @@ namespace return it->get(); } + /** + * @brief Whether the entry ships with this build: entries without browserEngines ship with every engine. + */ + bool shipsWithThisEngine(nlohmann::json const& entry) + { + const auto engines = entry.find("browserEngines"); + if (engines == entry.end() || !engines->is_array() || engines->empty()) + return true; + return std::ranges::any_of( + *engines, + [](nlohmann::json const& engine) + { + return engine.is_string() && engine.get() == STRINGIZE_EXPANDED(BROWSER_ENGINE); + } + ); + } + std::vector parseSection(nlohmann::json const& arr, std::string const& defaultRole) { std::vector out; @@ -54,6 +72,8 @@ namespace out.reserve(arr.size()); for (auto const& e : arr) { + if (!shipsWithThisEngine(e)) + continue; LicenseEntry entry{ readString(e, "name"), readString(e, "version"), @@ -334,7 +354,8 @@ Nui::ElementRenderer Licenses::about() const auto& version = impl_->events->appVersion.value(); return Nui::Elements::fragment( detailRow(language->get("licenses", "gitTag"), version.gitTag), - detailRow(language->get("licenses", "gitBranch"), version.gitBranch) + detailRow(language->get("licenses", "gitBranch"), version.gitBranch), + detailRow(language->get("licenses", "browserEngine"), version.browserEngine) ); } ), diff --git a/frontend/source/frontend/main_page.cpp b/frontend/source/frontend/main_page.cpp index 6e607615..1fe7e5d2 100644 --- a/frontend/source/frontend/main_page.cpp +++ b/frontend/source/frontend/main_page.cpp @@ -136,6 +136,8 @@ void MainPage::onSetupComplete() .version = response["version"].as(), .gitTag = response["gitTag"].as(), .gitBranch = response["gitBranch"].as(), + .browserEngine = response.hasOwnProperty("browserEngine") ? response["browserEngine"].as() + : std::string{}, }; Nui::globalEventContext.executeActiveEventsImmediately(); } diff --git a/licenses/README.md b/licenses/README.md index 43d18057..cbce2a60 100644 --- a/licenses/README.md +++ b/licenses/README.md @@ -16,3 +16,11 @@ This directory is the **source of truth** for C++ third-party attribution. ## Drift detection `scripts/check_licenses.py` greps the CMake source for `find_package`, `FetchContent_Declare`, and vendored `add_subdirectory(dependencies/...)` calls and diffs the extracted set against the names in `third_party.spdx.json`. CI fails on mismatch. + +## Engine specific packages + +An entry with `extension_browserEngines` (a list of `cef`, `webkitgtk`, `webview2`, `wkwebview`) ships only with those browser engines, the licenses page of a build shows it only for its own engine. Entries without the field ship everywhere. + +## npm packages without a license file + +Some npm packages (the `@lumino` ones) publish no license file. `scripts/build_licenses_bundle.py` takes their text from `NPM_TEXT_FALLBACKS` and fails the build for any other npm package without a text. diff --git a/licenses/texts/cef-LICENSE.txt b/licenses/texts/cef-LICENSE.txt new file mode 100644 index 00000000..b55d613d --- /dev/null +++ b/licenses/texts/cef-LICENSE.txt @@ -0,0 +1,29 @@ +// Copyright (c) 2008-2020 Marshall A. Greenblatt. Portions Copyright (c) +// 2006-2009 Google Inc. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the name Chromium Embedded +// Framework nor the names of its contributors may be used to endorse +// or promote products derived from this software without specific prior +// written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/texts/chromium-LICENSE.txt b/licenses/texts/chromium-LICENSE.txt new file mode 100644 index 00000000..926ed7e5 --- /dev/null +++ b/licenses/texts/chromium-LICENSE.txt @@ -0,0 +1,29 @@ +// Copyright 2015 The Chromium Authors +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google LLC nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The licenses of the third-party components in Chromium ship with nui-sftp as CEF-CREDITS.html. diff --git a/licenses/texts/lumino-LICENSE.txt b/licenses/texts/lumino-LICENSE.txt new file mode 100644 index 00000000..4deeeccc --- /dev/null +++ b/licenses/texts/lumino-LICENSE.txt @@ -0,0 +1,56 @@ +Copyright (c) 2019-2023 Project Jupyter Contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Copyright (c) 2014-2017, PhosphorJS Contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/texts/webview2-sdk-LICENSE.txt b/licenses/texts/webview2-sdk-LICENSE.txt new file mode 100644 index 00000000..45f02978 --- /dev/null +++ b/licenses/texts/webview2-sdk-LICENSE.txt @@ -0,0 +1,27 @@ +Copyright (C) Microsoft Corporation. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * The name of Microsoft Corporation, or the names of its contributors +may not be used to endorse or promote products derived from this +software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/licenses/third_party.spdx.json b/licenses/third_party.spdx.json index fbe6edcc..94d7b407 100644 --- a/licenses/third_party.spdx.json +++ b/licenses/third_party.spdx.json @@ -185,7 +185,8 @@ "software_copyrightText": "Copyright (c) 2017 Serge Zaitsev", "simplelicensing_declaredLicense": "MIT", "extension_licenseTextFile": "texts/webview-LICENSE.txt", - "extension_role": "vendored" + "extension_role": "vendored", + "extension_browserEngines": ["webkitgtk", "webview2", "wkwebview"] }, { @@ -193,8 +194,8 @@ "spdxId": "http://spdx.org/spdxdocs/nui-sftp/Package/interval-tree", "creationInfo": "_:creationInfo", "name": "interval-tree", - "software_packageVersion": "v2.3.2", - "software_downloadLocation": "https://github.com/5cript/interval-tree/archive/refs/tags/v2.3.2.tar.gz", + "software_packageVersion": "v3.1.4", + "software_downloadLocation": "https://github.com/5cript/interval-tree/archive/refs/tags/v3.1.4.tar.gz", "software_homePage": "https://github.com/5cript/interval-tree", "simplelicensing_declaredLicense": "BSL-1.0", "extension_licenseTextFile": "texts/interval-tree-LICENSE.txt", @@ -279,6 +280,48 @@ "extension_role": "fetched" }, + { + "type": "software_Package", + "spdxId": "http://spdx.org/spdxdocs/nui-sftp/Package/cef", + "creationInfo": "_:creationInfo", + "name": "Chromium Embedded Framework", + "software_packageVersion": "154.0.34", + "software_downloadLocation": "https://cef-builds.spotifycdn.com/index.html", + "software_homePage": "https://github.com/chromiumembedded/cef", + "software_copyrightText": "Copyright (c) 2008-2020 Marshall A. Greenblatt. Portions Copyright (c) 2006-2009 Google Inc.", + "simplelicensing_declaredLicense": "BSD-3-Clause", + "extension_licenseTextFile": "texts/cef-LICENSE.txt", + "extension_role": "fetched", + "extension_browserEngines": ["cef"] + }, + { + "type": "software_Package", + "spdxId": "http://spdx.org/spdxdocs/nui-sftp/Package/chromium", + "creationInfo": "_:creationInfo", + "name": "Chromium", + "software_packageVersion": "154.0.8037.98", + "software_downloadLocation": "https://source.chromium.org/chromium", + "software_homePage": "https://www.chromium.org", + "software_copyrightText": "Copyright 2015 The Chromium Authors", + "simplelicensing_declaredLicense": "BSD-3-Clause", + "extension_licenseTextFile": "texts/chromium-LICENSE.txt", + "extension_role": "fetched", + "extension_browserEngines": ["cef"] + }, + { + "type": "software_Package", + "spdxId": "http://spdx.org/spdxdocs/nui-sftp/Package/webview2-sdk", + "creationInfo": "_:creationInfo", + "name": "Microsoft WebView2 SDK", + "software_packageVersion": "latest", + "software_downloadLocation": "https://www.nuget.org/packages/Microsoft.Web.WebView2", + "software_homePage": "https://developer.microsoft.com/microsoft-edge/webview2/", + "software_copyrightText": "Copyright (C) Microsoft Corporation", + "simplelicensing_declaredLicense": "BSD-3-Clause", + "extension_licenseTextFile": "texts/webview2-sdk-LICENSE.txt", + "extension_role": "fetched", + "extension_browserEngines": ["webview2"] + }, { "type": "software_Package", "spdxId": "http://spdx.org/spdxdocs/nui-sftp/Package/openssl", @@ -420,7 +463,8 @@ "software_copyrightText": "Copyright (c) Apple Inc., Igalia S.L., and the WebKit contributors", "simplelicensing_declaredLicense": "LGPL-2.0-or-later AND BSD-2-Clause", "extension_licenseTextFile": "texts/webkitgtk-LICENSE.txt", - "extension_role": "system" + "extension_role": "system", + "extension_browserEngines": ["webkitgtk"] } ] } diff --git a/nui-file-explorer/source/nui-file-explorer/CMakeLists.txt b/nui-file-explorer/source/nui-file-explorer/CMakeLists.txt index 4726686c..04f43ffb 100644 --- a/nui-file-explorer/source/nui-file-explorer/CMakeLists.txt +++ b/nui-file-explorer/source/nui-file-explorer/CMakeLists.txt @@ -78,9 +78,14 @@ else() target_sources( nui-file-explorer-support PRIVATE - support/recent_files_linux.cpp support/default_places_posix.cpp ) + # GtkRecentManager needs GTK, which only WebKitGTK brings along. + if (NUI_BROWSER_ENGINE STREQUAL "cef") + target_sources(nui-file-explorer-support PRIVATE support/recent_files_unsupported.cpp) + else() + target_sources(nui-file-explorer-support PRIVATE support/recent_files_linux.cpp) + endif() endif() target_include_directories( diff --git a/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp b/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp index 9536ffda..b5f4334e 100644 --- a/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp +++ b/nui-file-explorer/source/nui-file-explorer/drop_event_extractor.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -15,6 +16,23 @@ using namespace std::string_literals; namespace NuiFileExplorer { + namespace + { + /** + * @brief Names of the dropped files, the backend checks them against the paths it remembered for the drag. + */ + nlohmann::json droppedFileNames(Nui::WebApi::FileList const& files) + { + auto names = nlohmann::json::array(); + for (int index = 0; index < files.length(); ++index) + { + if (const auto file = files.item(index); file) + names.push_back(file->name()); + } + return names; + } + } + std::optional extractDropEvent(Nui::WebApi::DragEvent event, ISideModel& sideModel, std::optional const& droppedOnItem) { @@ -54,12 +72,14 @@ namespace NuiFileExplorer return result; } - // WKWebView hides the paths, the backend read them from the dragging pasteboard. - if (files.length() > 0 && STRINGIZE_EXPANDED(BROWSER_ENGINE) == "wkwebview"s) + // WKWebView and Chromium hide the paths, the backend read them from the dragging pasteboard or the drag data. + if (files.length() > 0 && + (STRINGIZE_EXPANDED(BROWSER_ENGINE) == "wkwebview"s || STRINGIZE_EXPANDED(BROWSER_ENGINE) == "cef"s)) { nlohmann::json claim = { {"isLeft", sideModel.isLeft()}, {"dropMetadata", sideModel.dropMetadata()}, + {"names", droppedFileNames(files)}, }; if (result.internalDropSubdir) claim["subdir"] = *result.internalDropSubdir; diff --git a/scripts/build_licenses_bundle.py b/scripts/build_licenses_bundle.py index 2a08c6b3..266ed0bc 100644 --- a/scripts/build_licenses_bundle.py +++ b/scripts/build_licenses_bundle.py @@ -49,6 +49,12 @@ # in practice, but checked at generation time). HEADER_DELIM = "NUI_LIC_EMBED" +# License texts for npm packages that ship none, by package name prefix. Every npm package needs a text: the build +# fails for a new one without, add its license here. +NPM_TEXT_FALLBACKS = { + "@lumino/": TEXTS_DIR / "lumino-LICENSE.txt", +} + def build_cpp_section() -> list[dict]: doc = json.loads(SPDX_FILE.read_text(encoding="utf-8")) @@ -72,6 +78,7 @@ def build_cpp_section() -> list[dict]: "downloadLocation": element.get("software_downloadLocation", ""), "copyright": element.get("software_copyrightText", ""), "role": element.get("extension_role", ""), + "browserEngines": element.get("extension_browserEngines", []), "text": text, } ) @@ -122,6 +129,11 @@ def build_npm_section(npm_file: Path, host_root: Path | None) -> list[dict]: break except OSError: continue + if not text: + for prefix, fallback in NPM_TEXT_FALLBACKS.items(): + if name.startswith(prefix): + text = fallback.read_text(encoding="utf-8", errors="replace") + break out.append( { "name": name, @@ -132,6 +144,12 @@ def build_npm_section(npm_file: Path, host_root: Path | None) -> list[dict]: "text": text, } ) + without_text = sorted(f"{p['name']}@{p['version']}" for p in out if not p["text"].strip()) + if without_text: + raise SystemExit( + "npm packages without a license text: " + ", ".join(without_text) + + ". Add their license to NPM_TEXT_FALLBACKS in scripts/build_licenses_bundle.py." + ) out.sort(key=lambda p: p["name"].lower()) return out diff --git a/scripts/check_licenses.py b/scripts/check_licenses.py index 56e7490b..c5b8aec3 100644 --- a/scripts/check_licenses.py +++ b/scripts/check_licenses.py @@ -57,7 +57,7 @@ # Use this when the variant names don't collapse equal under normalisation. ALIASES = { "webview_raw": "webview", - "webview-binary-nui": "webview", + "webview-binary-nui": "Microsoft WebView2 SDK", "promise": "promise-cpp", "traits-library": "traits", "CryptoPP": "Crypto++", @@ -74,8 +74,13 @@ "simpleicons", # icon SVGs vendored in static/assets/icons "fontawesomefree", # icon SVGs vendored in static/assets/icons "twemoji", # icon SVG vendored in static/assets/icons + "chromiumembeddedframework", # downloaded by Nui's cef.cmake, not FetchContent + "chromium", # part of the CEF distribution } +# Values of extension_browserEngines: the entry ships only with these engines (BROWSER_ENGINE of the frontend). +BROWSER_ENGINES = {"cef", "webkitgtk", "webview2", "wkwebview"} + FIND_PACKAGE_RE = re.compile(r"\bfind_package\s*\(\s*([A-Za-z0-9_\-]+)") FETCH_CONTENT_RE = re.compile( r"\bFetchContent_Declare\s*\(\s*([A-Za-z0-9_\-]+)", re.MULTILINE @@ -172,6 +177,16 @@ def main() -> int: for name, element in sorted(spdx_packages.items()): display = element.get("name", name) + engines = element.get("extension_browserEngines", []) + if not isinstance(engines, list) or not engines and "extension_browserEngines" in element: + errors.append(f" - {display!r}: extension_browserEngines must be a non-empty list.") + else: + for engine in engines: + if engine not in BROWSER_ENGINES: + errors.append( + f" - {display!r}: unknown browser engine {engine!r} in extension_browserEngines, " + f"expected one of {sorted(BROWSER_ENGINES)}." + ) text_ref = element.get("extension_licenseTextFile") if not text_ref: errors.append(f" - {display!r} has no extension_licenseTextFile field.") diff --git a/scripts/deploy.sh b/scripts/deploy.sh index cfe35889..8f890016 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -27,47 +27,42 @@ OMIT_FRONTEND="${OMIT_FRONTEND:-false}" NOLINK="${NOLINK:-false}" -if [ "$(uname -s)" = "Darwin" ]; then +PLATFORM="$(uname -s)" + +if [ "${PLATFORM}" = "Darwin" ] && ! grep -qs '^NUI_BROWSER_ENGINE:[A-Z]*=cef$' "${BUILD_DIRECTORY}/CMakeCache.txt"; then INSTALL_TARGET="${INSTALL_TARGET}" BUILD_DIRECTORY="${BUILD_DIRECTORY}" SOURCE_DIRECTORY="${SOURCE_DIRECTORY}" \ OMIT_FRONTEND="${OMIT_FRONTEND}" bash "${SCRIPT_DIR}/deploy_macos.sh" exit 0 fi -# On Windows executeable is called nui-sftp.exe, look if that exsists and then use that as the source for the executable -# Also set a variable for future reference. - -if [ -f "${BUILD_DIRECTORY}/bin/nui-sftp.exe" ]; then - EXECUTABLE="${BUILD_DIRECTORY}/bin/nui-sftp.exe" - EXECUTABLE_NAME="nui-sftp.exe" - IS_WINDOWS=true -else - EXECUTABLE="${BUILD_DIRECTORY}/bin/nui-sftp" - EXECUTABLE_NAME="nui-sftp" - IS_WINDOWS=false +# Linux, and macOS with CEF: the install rules of the CMake project (nui_install). They leave out the frontend when the +# build has OMIT_FRONTEND_BUILD. +if [[ "${PLATFORM}" != *_NT-* ]]; then + echo -e "Installing to \e[32m${INSTALL_TARGET}\e[0m" + cmake --install "${BUILD_DIRECTORY}" --prefix "${INSTALL_TARGET}" + if [ "$NOLINK" = false ] && [ "${PLATFORM}" != "Darwin" ]; then + ln -sfn "./bin/nui-sftp" "${INSTALL_TARGET}/nui-sftp" + fi + exit 0 fi +# Windows (MSYS2): the executable with the clang runtime libraries of MSYS2 next to it. +EXECUTABLE="${BUILD_DIRECTORY}/bin/nui-sftp.exe" + echo -e "Installing to \e[32m${INSTALL_TARGET}\e[0m" -# Create the install directory if it doesn't exist -mkdir -p "${INSTALL_TARGET}" mkdir -p "${INSTALL_TARGET}/bin" mkdir -p "${INSTALL_TARGET}/frontend" -mkdir -p "${INSTALL_TARGET}/assets" -mkdir -p "${INSTALL_TARGET}/themes" mkdir -p "${INSTALL_TARGET}/assets/icons" +mkdir -p "${INSTALL_TARGET}/themes" -cp "${EXECUTABLE}" "${INSTALL_TARGET}/bin/${EXECUTABLE_NAME}" -if [ "$IS_WINDOWS" = true ]; then - # use ldd to copy dependencies from msys2 into the bin dir - ldd "${EXECUTABLE}" | grep "clang" | awk 'NF == 4 { system("cp " $3 " '"${INSTALL_TARGET}/bin/"'") }' -fi +cp "${EXECUTABLE}" "${INSTALL_TARGET}/bin/nui-sftp.exe" +ldd "${EXECUTABLE}" | grep "clang" | awk 'NF == 4 { system("cp " $3 " '"${INSTALL_TARGET}/bin/"'") }' # Assets (Images, icons, language files)... cp -r "${SOURCE_DIRECTORY}/static/assets/." "${INSTALL_TARGET}/assets" -cp "${SOURCE_DIRECTORY}/static/assets/icons/nui-sftp-logo.svg" "${INSTALL_TARGET}/assets/icons/" -# Generated Windows .ico (only present on Windows builds; used by the Inno Setup -# installer as SetupIconFile and Start Menu shortcut icon). +# Generated .ico, used by the Inno Setup installer as SetupIconFile and Start Menu shortcut icon. if [ -f "${BUILD_DIRECTORY}/generated/icons/nui-sftp.ico" ]; then cp "${BUILD_DIRECTORY}/generated/icons/nui-sftp.ico" "${INSTALL_TARGET}/assets/icons/" fi @@ -76,13 +71,3 @@ cp -r "${SOURCE_DIRECTORY}/themes/." "${INSTALL_TARGET}/themes" if [ "$OMIT_FRONTEND" = false ]; then cp -r "${BUILD_DIRECTORY}/module_nui-sftp/bin/." "${INSTALL_TARGET}/frontend" fi - -if [ "$NOLINK" = false ]; then - if [ "$IS_WINDOWS" = false ]; then - # Dont create a symlink on windows or if NOLINK is true - ln -s "./bin/${EXECUTABLE_NAME}" "${INSTALL_TARGET}/${EXECUTABLE_NAME}" - else - # TODO? - : - fi -fi diff --git a/static/assets/languages/de_DE.yaml b/static/assets/languages/de_DE.yaml index df281cc5..62bbae72 100644 --- a/static/assets/languages/de_DE.yaml +++ b/static/assets/languages/de_DE.yaml @@ -1115,6 +1115,7 @@ licenses: aboutTab: "Über" gitTag: "Tag" gitBranch: "Branch" + browserEngine: "Browser-Engine" allLicenses: "Alle Lizenzen" close: "Schließen" filterPlaceholder: "Pakete filtern..." diff --git a/static/assets/languages/en_US.yaml b/static/assets/languages/en_US.yaml index 529b7f2d..07cc8f70 100644 --- a/static/assets/languages/en_US.yaml +++ b/static/assets/languages/en_US.yaml @@ -1117,6 +1117,7 @@ licenses: aboutTab: "About" gitTag: "Tag" gitBranch: "Branch" + browserEngine: "Browser engine" allLicenses: "All licenses" close: "Close" filterPlaceholder: "Filter packages..." diff --git a/static/styles/licenses.css b/static/styles/licenses.css index 887bb798..4fa531f1 100644 --- a/static/styles/licenses.css +++ b/static/styles/licenses.css @@ -276,18 +276,18 @@ } .licenses-about-details { - display: flex; - flex-direction: column; - gap: 4px; -} - -.licenses-about-detail { display: grid; - grid-template-columns: auto auto; + grid-template-columns: max-content max-content; column-gap: 12px; + row-gap: 4px; color: gray; } +/* The rows share the columns of the details grid, so labels and values line up. */ +.licenses-about-detail { + display: contents; +} + .licenses-about-detail-value { font-family: monospace; -webkit-user-select: text; diff --git a/utility/include/utility/resources.hpp b/utility/include/utility/resources.hpp index 491a8d04..85bda0a3 100644 --- a/utility/include/utility/resources.hpp +++ b/utility/include/utility/resources.hpp @@ -9,7 +9,8 @@ bool isCanonical(std::filesystem::path const& path); /** * @brief Directory holding frontend, assets and themes for the running program. - * @details This is the parent of the program directory, or Contents/Resources inside a macOS app bundle. + * @details This is the parent of the program directory, Contents/Resources inside a macOS app bundle, or the prefix + * when the program is in lib/nui-sftp (Flatpak). * @param programDirectory Directory of the executable. */ std::filesystem::path resourceRoot(std::filesystem::path const& programDirectory); diff --git a/utility/source/utility/resources.cpp b/utility/source/utility/resources.cpp index 40733422..7bdf7fbb 100644 --- a/utility/source/utility/resources.cpp +++ b/utility/source/utility/resources.cpp @@ -214,10 +214,13 @@ findFilesInSearchPaths(std::filesystem::path const& relativeRoot, std::filesyste std::filesystem::path resourceRoot(std::filesystem::path const& programDirectory) { - const auto contents = programDirectory.parent_path(); - if (programDirectory.filename() == "MacOS" && contents.filename() == "Contents") - return contents / "Resources"; - return contents; + const auto parent = programDirectory.parent_path(); + if (programDirectory.filename() == "MacOS" && parent.filename() == "Contents") + return parent / "Resources"; + // Flatpak: nui_install(FLATPAK_ZYPAK) puts the executable next to CEF in lib/nui-sftp, its launcher in bin. + if (programDirectory.filename() == Constants::appName && parent.filename() == "lib") + return parent.parent_path(); + return parent; } std::optional diff --git a/utility/test/utility/main.cpp b/utility/test/utility/main.cpp index 3f59fd89..9c6fcdcf 100644 --- a/utility/test/utility/main.cpp +++ b/utility/test/utility/main.cpp @@ -13,6 +13,7 @@ #include "test_move_to_front.hpp" #include "test_permissions.hpp" #include "test_rename.hpp" +#include "test_resource_root.hpp" #include "test_link_safe_remove.hpp" #include "test_shell_integration.hpp" #include "test_typed_line_buffer.hpp" diff --git a/utility/test/utility/test_resource_root.hpp b/utility/test/utility/test_resource_root.hpp new file mode 100644 index 00000000..e0f8f60b --- /dev/null +++ b/utility/test/utility/test_resource_root.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include + +#include + +namespace Utility::Tests +{ + TEST(ResourceRootTests, ParentOfTheProgramDirectory) + { + EXPECT_EQ(resourceRoot("/opt/nui-sftp/bin"), std::filesystem::path{"/opt/nui-sftp"}); + } + + TEST(ResourceRootTests, ResourcesOfAMacAppBundle) + { + EXPECT_EQ( + resourceRoot("/Applications/nui-sftp.app/Contents/MacOS"), + std::filesystem::path{"/Applications/nui-sftp.app/Contents/Resources"} + ); + } + + TEST(ResourceRootTests, PrefixWhenTheProgramIsNextToCef) + { + EXPECT_EQ(resourceRoot("/app/lib/nui-sftp"), std::filesystem::path{"/app"}); + } + + TEST(ResourceRootTests, OtherDirectoriesInLibStayTheParent) + { + EXPECT_EQ(resourceRoot("/usr/lib/other"), std::filesystem::path{"/usr/lib"}); + } +}