diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml new file mode 100644 index 00000000..7a83d8be --- /dev/null +++ b/.github/workflows/linux-packages.yml @@ -0,0 +1,328 @@ +# Builds the Linux .deb once, repackages it as a Flatpak and as a Snap, and +# starts each package headless to check that it runs inside its sandbox. + +name: Linux Packages + +on: + pull_request: + paths: + - "packaging/**" + - "snap/**" + - ".github/workflows/linux-packages.yml" + - "src-tauri/tauri.conf.json" + - "src-tauri/Cargo.toml" + - "src-tauri/donutbrowser.desktop" + push: + branches: + - main + paths: + - "packaging/**" + - "snap/**" + - ".github/workflows/linux-packages.yml" + - "src-tauri/tauri.conf.json" + - "src-tauri/Cargo.toml" + - "src-tauri/donutbrowser.desktop" + workflow_dispatch: + inputs: + check-wayfern: + description: "Also download Wayfern (about 1 GB) and start it inside each sandbox" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: linux-packages-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + APP_ID: com.donutbrowser.DonutBrowser + FLATPAK_MANIFEST: packaging/flatpak/com.donutbrowser.DonutBrowser.yml + FLATPAK_LINT_EXCEPTIONS: packaging/flatpak/flatpak-builder-lint-exceptions.json + METAINFO: packaging/linux/com.donutbrowser.DonutBrowser.metainfo.xml + +jobs: + deb: + name: Build .deb + runs-on: ubuntu-22.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 #v6.1.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0 + with: + node-version-file: .node-version + cache: "pnpm" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master + with: + toolchain: stable + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils + + - name: Rust cache + uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2 + with: + workdir: ./src-tauri + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + # beforeBuildCommand builds the donut-proxy sidecar, downloads the + # verified Xray-core sidecar and builds the frontend. + - name: Build the .deb + env: + NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }} + run: | + BUILD_TAG="nightly-$(git show -s --format=%cs HEAD)-${GITHUB_SHA::7}" + export BUILD_TAG + pnpm tauri build --bundles deb + + - name: Collect the .deb + run: | + mkdir -p linux-deb + cp src-tauri/target/release/bundle/deb/*.deb linux-deb/ + ls -l linux-deb + test "$(find linux-deb -name '*.deb' | wc -l)" -eq 1 + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: donut-deb + path: linux-deb/*.deb + if-no-files-found: error + retention-days: 7 + + flatpak: + name: Build Flatpak + needs: deb + runs-on: ubuntu-24.04 + timeout-minutes: 45 + container: + # flatpak, flatpak-builder, flatpak-builder-lint and appstreamcli, with + # org.gnome.Platform//50 and org.gnome.Sdk//50 already installed. + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + options: --privileged + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #v8.0.1 + with: + name: donut-deb + path: linux-deb + + - name: Put the .deb where the manifest expects it + run: mv linux-deb/*.deb packaging/donut.deb + + - name: Validate the AppStream metadata + run: appstreamcli validate --no-net --explain "$METAINFO" + + - name: Lint the manifest + run: flatpak-builder-lint --exceptions --user-exceptions "$FLATPAK_LINT_EXCEPTIONS" manifest "$FLATPAK_MANIFEST" + + - name: Restore the flatpak-builder cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 #v6.1.0 + with: + path: .flatpak-builder + key: flatpak-builder-${{ hashFiles('packaging/flatpak/**') }} + restore-keys: flatpak-builder- + + # No --install-deps-from: the image already has the runtime and the SDK, + # and updating a runtime in place fails on the container's overlay + # filesystem ("Invalid cross-device link"). + - name: Build the Flatpak + run: | + flatpak-builder --repo=repo --disable-rofiles-fuse --force-clean \ + --default-branch=master build-dir "$FLATPAK_MANIFEST" + + - name: Lint the build + run: flatpak-builder-lint --exceptions --user-exceptions "$FLATPAK_LINT_EXCEPTIONS" repo repo + + - name: Create the bundle + run: | + flatpak build-bundle --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo \ + repo Donut.flatpak "$APP_ID" master + ls -l Donut.flatpak + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: donut-flatpak-x86_64 + path: Donut.flatpak + if-no-files-found: error + + flatpak-smoke: + name: Test Flatpak + needs: flatpak + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #v8.0.1 + with: + name: donut-flatpak-x86_64 + path: flatpak-bundle + + - name: Install Flatpak and the test tools + run: | + sudo apt-get update + sudo apt-get install -y flatpak xvfb imagemagick dbus + # Ubuntu 24.04 can restrict unprivileged user namespaces, which + # bubblewrap needs to build the sandbox. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + + - name: Prepare a user runtime directory + run: | + dir="/run/user/$(id -u)" + sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "$dir" + echo "XDG_RUNTIME_DIR=$dir" >> "$GITHUB_ENV" + + - name: Install the bundle + run: | + flatpak install --user -y --noninteractive flatpak-bundle/Donut.flatpak + flatpak info --user "$APP_ID" + + - name: Check that every library resolves inside the sandbox + run: | + # shellcheck disable=SC2016 # expanded by the shell inside the sandbox + dbus-run-session -- flatpak run --command=sh "$APP_ID" -c ' + for f in /app/bin/donutbrowser /app/bin/donut-proxy /app/lib/libayatana-appindicator3.so.1; do + echo "== $f" + /lib64/ld-linux-x86-64.so.2 --list "$f" + done' 2>&1 | tee libraries.txt + if grep "not found" libraries.txt || ! grep -q "=>" libraries.txt; then + echo "::error::A library does not resolve inside the Flatpak sandbox." + exit 1 + fi + + - name: Start the app on a virtual display + run: bash packaging/linux/smoke-test.sh smoke 45 flatpak run "$APP_ID" + + - name: Stop the app and collect its logs + if: always() + run: | + flatpak kill "$APP_ID" 2>/dev/null || true + mkdir -p smoke + cp -r "$HOME/.var/app/$APP_ID/data/com.donutbrowser/logs" smoke/app-logs 2>/dev/null || true + cp libraries.txt smoke/ 2>/dev/null || true + + - name: Download Wayfern + if: ${{ inputs.check-wayfern }} + run: | + url="$(curl -fsSL https://donutbrowser.com/wayfern.json | jq -r '.downloads["linux-x64"]')" + curl -fsSL --retry 5 -o "$RUNNER_TEMP/wayfern.tar.xz" "$url" + + - name: Start Wayfern inside the sandbox + if: ${{ inputs.check-wayfern }} + run: bash packaging/linux/wayfern-check.sh flatpak "$RUNNER_TEMP/wayfern.tar.xz" smoke/wayfern + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + if: always() + with: + name: flatpak-smoke-test + path: smoke + if-no-files-found: ignore + retention-days: 14 + + snap: + name: Build and test Snap + needs: deb + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c #v8.0.1 + with: + name: donut-deb + path: linux-deb + + - name: Put the .deb where snapcraft.yaml expects it + run: mv linux-deb/*.deb packaging/donut.deb + + - name: Install Snapcraft + run: sudo snap install snapcraft --classic + + # The runner is Ubuntu 24.04, the same system as the core24 base, so + # the snap is built on the host rather than in an LXD instance. + - name: Build the snap + run: | + sudo snapcraft pack --destructive-mode --verbosity=brief + snap_file="$(ls donutbrowser_*.snap)" + echo "SNAP_FILE=$snap_file" >> "$GITHUB_ENV" + ls -l "$snap_file" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: donut-snap-amd64 + path: ${{ env.SNAP_FILE }} + if-no-files-found: error + + - name: Prepare a user runtime directory + run: | + dir="/run/user/$(id -u)" + sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "$dir" + echo "XDG_RUNTIME_DIR=$dir" >> "$GITHUB_ENV" + + - name: Install the snap + run: | + sudo apt-get update + sudo apt-get install -y xvfb imagemagick dbus + sudo snap install gnome-46-2404 mesa-2404 gtk-common-themes + sudo snap install --dangerous "$SNAP_FILE" + snap connections donutbrowser + + - name: Check that every library resolves inside the snap + run: | + # shellcheck disable=SC2016 # expanded by the shell inside the sandbox + snap run --shell donutbrowser -c ' + for f in "$SNAP/usr/bin/donutbrowser" "$SNAP/usr/bin/donut-proxy"; do + echo "== $f" + /lib64/ld-linux-x86-64.so.2 --list "$f" + done' 2>&1 | tee libraries.txt + if grep "not found" libraries.txt || ! grep -q "=>" libraries.txt; then + echo "::error::A library does not resolve inside the snap." + exit 1 + fi + + - name: Start the app on a virtual display + run: bash packaging/linux/smoke-test.sh smoke 45 snap run donutbrowser + + - name: Stop the app and collect its logs + if: always() + run: | + pkill -f /snap/donutbrowser/ 2>/dev/null || true + mkdir -p smoke + cp -r "$HOME/snap/donutbrowser/current/.local/share/com.donutbrowser/logs" smoke/app-logs 2>/dev/null || true + cp libraries.txt smoke/ 2>/dev/null || true + sudo journalctl --no-pager -k --grep 'apparmor="DENIED"' 2>/dev/null | grep donutbrowser > smoke/apparmor-denials.txt || true + echo "AppArmor denials: $(wc -l < smoke/apparmor-denials.txt)" + + - name: Download Wayfern + if: ${{ inputs.check-wayfern }} + run: | + url="$(curl -fsSL https://donutbrowser.com/wayfern.json | jq -r '.downloads["linux-x64"]')" + curl -fsSL --retry 5 -o "$RUNNER_TEMP/wayfern.tar.xz" "$url" + + - name: Start Wayfern inside the snap + if: ${{ inputs.check-wayfern }} + run: bash packaging/linux/wayfern-check.sh snap "$RUNNER_TEMP/wayfern.tar.xz" smoke/wayfern + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + if: always() + with: + name: snap-smoke-test + path: smoke + if-no-files-found: ignore + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index d7f6bd63..27e196a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,8 @@ donutbrowser/ │ ├── python/ # `donutbrowser` (stdlib only, pytest) │ └── node/ # `@donutbrowser/sdk` (ESM TypeScript, node --test) ├── patches/ # pnpm compatibility patches for secured dependencies +├── packaging/ # Flatpak manifest, shared AppStream metainfo, Linux package smoke tests +├── snap/ # snapcraft.yaml (strict confinement, repackages the .deb) ├── flake.nix # Nix development environment └── .github/workflows/ # CI/CD pipelines ``` diff --git a/README.md b/README.md index 6207ccb1..f94b07b9 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,17 @@ If that gives an EGL display error, add `WEBKIT_DISABLE_DMABUF_RENDERER=1` or `G +### Flatpak and Snap + +The [Linux Packages](https://github.com/zhom/donutbrowser/actions/workflows/linux-packages.yml) workflow builds an x86_64 Flatpak bundle and Snap from the `.deb`. Download `donut-flatpak-x86_64` or `donut-snap-amd64` from a successful run, unzip it, and install it: + +```bash +flatpak install --user Donut.flatpak +sudo snap install --dangerous donutbrowser_*.snap +``` + +The Snap leaves the microphone, the camera and the keyring (used by profile import) disconnected until you run `sudo snap connect donutbrowser:audio-record`, `donutbrowser:camera` or `donutbrowser:password-manager-service`. The Flatpak cannot change the default browser, so choose Donut in your system settings instead. + ### Nix ```bash diff --git a/_typos.toml b/_typos.toml index fc353c6c..cb55a987 100644 --- a/_typos.toml +++ b/_typos.toml @@ -7,6 +7,8 @@ extend-exclude = [ # Auto-generated from commit subjects by release.yml; typos here originate # in commit messages, which are immutable, so don't spell-check it. "CHANGELOG.md", + # Upstream patches for the Flatpak's tray libraries, applied verbatim. + "packaging/flatpak/patches/", ] [default.extend-words] diff --git a/packaging/.gitignore b/packaging/.gitignore new file mode 100644 index 00000000..ff93b236 --- /dev/null +++ b/packaging/.gitignore @@ -0,0 +1,2 @@ +# The .deb that the Flatpak manifest and snap/snapcraft.yaml repackage. +/donut.deb diff --git a/packaging/flatpak/com.donutbrowser.DonutBrowser.yml b/packaging/flatpak/com.donutbrowser.DonutBrowser.yml new file mode 100644 index 00000000..bd334b3d --- /dev/null +++ b/packaging/flatpak/com.donutbrowser.DonutBrowser.yml @@ -0,0 +1,166 @@ +# Flatpak manifest for Donut Browser. +# +# This repackages the .deb that `pnpm tauri build --bundles deb` produces. It +# takes only Donut's own files from it (the app, the donut-proxy and xray +# sidecars, the desktop entry and the icons) and links against the WebKitGTK, +# GTK and OpenSSL of the GNOME runtime. Nothing from the build host's /usr/lib +# is copied: those libraries would shadow the runtime's and break it. +# +# Build it locally (x86_64) after `pnpm tauri build --bundles deb`: +# cp src-tauri/target/release/bundle/deb/*.deb packaging/donut.deb +# flatpak-builder --user --install --install-deps-from=flathub --force-clean \ +# build-dir packaging/flatpak/com.donutbrowser.DonutBrowser.yml +id: com.donutbrowser.DonutBrowser +runtime: org.gnome.Platform +runtime-version: "50" +sdk: org.gnome.Sdk +command: donutbrowser +rename-desktop-file: Donut.desktop +rename-icon: donutbrowser + +finish-args: + # Proxies, VPNs, sync and Wayfern downloads. The local API, the MCP server and + # the per-profile proxy workers listen on loopback ports. + - --share=network + # X11 shared memory, for the app and for Wayfern under XWayland. + - --share=ipc + - --socket=wayland + - --socket=fallback-x11 + - --device=dri + # Audio playback and microphone for the websites Wayfern opens. + - --socket=pulseaudio + # Wayfern saves downloads here, and cookie, profile and proxy exports go here. + - --filesystem=xdg-download + # The tray icon is written to $XDG_RUNTIME_DIR/tray-icon, and the desktop's + # tray host reads it from the same path outside the sandbox. + - --filesystem=xdg-run/tray-icon:create + - --talk-name=org.kde.StatusNotifierWatcher + # Profile import reads the source browser's "Safe Storage" secret. + - --talk-name=org.freedesktop.secrets + # Web notifications from the sites Wayfern opens. + - --talk-name=org.freedesktop.Notifications + # tauri-plugin-single-instance hands the second launch (a clicked link) to the + # first one over this name, which is derived from the Tauri identifier. + - --own-name=com.donutbrowser.SingleInstance + +cleanup: + - /include + - /lib/pkgconfig + - /share/man + - "*.la" + - "*.a" + +modules: + # The tray (tray-icon, through libappindicator-sys) dlopens + # libayatana-appindicator3 at startup and panics when it is missing. The GNOME + # runtime does not ship it. These five modules are Flathub's + # shared-modules/libayatana-appindicator (commit cb9ec602), flattened. + - name: intltool + cleanup: + - "*" + sources: + - type: archive + url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz + mirror-urls: + - https://gstreamer.freedesktop.org/src/mirror/intltool-0.51.0.tar.gz + - https://mirror-hk.koddos.net/lfs/lfs-packages/12.0/intltool-0.51.0.tar.gz + sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd + - type: patch + path: patches/intltool-perl5.26-regex-fixes.patch + + - name: libdbusmenu + buildsystem: autotools + build-options: + cflags: -Wno-error + config-opts: + - --with-gtk=3 + - --disable-dumper + - --disable-static + - --disable-tests + - --disable-gtk-doc + - --enable-introspection=no + - --disable-vala + cleanup: + - "*.la" + - /include + - /lib/pkgconfig + - /libexec + - /share/doc + - /share/gtk-doc + sources: + - type: archive + url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz + mirror-urls: + - https://sources.suse.com/SUSE:SLFO:Main:Build/libdbusmenu/a5b3e4717f09f90f9cd5568a5f933e8d/libdbusmenu-16.04.0.tar.gz + - https://mirror.math.princeton.edu/pub/frugalware/frugalware-2.1/source/xlib/libdbusmenu/libdbusmenu-16.04.0.tar.gz + sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a + - type: patch + path: patches/libdbusmenu-fix-HAVE_VALGRIND-AM_CONDITIONAL.patch + + - name: ayatana-ido + buildsystem: cmake-ninja + config-opts: + - -DENABLE_INTROSPECTION=OFF + cleanup: + - /include + - /lib/pkgconfig + sources: + - type: git + url: https://github.com/AyatanaIndicators/ayatana-ido.git + tag: 0.10.4 + commit: f968079b09e2310fefc3fc307359025f1c74b3eb + - type: patch + path: patches/ayatana-ido-make-introspection-configurable.patch + + - name: libayatana-indicator + buildsystem: cmake-ninja + cleanup: + - /include + - /lib/pkgconfig + - /libexec + - /share + sources: + - type: git + url: https://github.com/AyatanaIndicators/libayatana-indicator.git + tag: 0.9.4 + commit: 611bb384b73fa6311777ba4c41381a06f5b99dad + + - name: libayatana-appindicator + buildsystem: cmake-ninja + config-opts: + - -DENABLE_BINDINGS_MONO=NO + - -DENABLE_BINDINGS_VALA=NO + - -DENABLE_GTKDOC=NO + cleanup: + - /include + - /lib/pkgconfig + sources: + - type: git + url: https://github.com/AyatanaIndicators/libayatana-appindicator.git + tag: 0.5.94 + commit: 31e8bb083b307e1cc96af4874a94707727bd1e79 + + - name: donutbrowser + buildsystem: simple + sources: + # CI puts the .deb here; see the note at the top for a local build. + - type: file + path: ../donut.deb + - type: file + path: ../linux/com.donutbrowser.DonutBrowser.metainfo.xml + - type: file + path: ../../LICENSE + build-commands: + - ar x donut.deb + - mkdir deb && tar -xf data.tar.* -C deb + - install -Dm755 -t /app/bin deb/usr/bin/donutbrowser deb/usr/bin/donut-proxy deb/usr/bin/xray + # Tauri resolves its resource directory to /lib/. + - mkdir -p /app/lib && cp -r deb/usr/lib/Donut /app/lib/Donut + - install -Dm644 -t /app/share/applications deb/usr/share/applications/Donut.desktop + - | + for size in 32x32 128x128 512x512; do + install -Dm644 "deb/usr/share/icons/hicolor/$size/apps/donutbrowser.png" \ + "/app/share/icons/hicolor/$size/apps/donutbrowser.png" + done + - install -Dm644 -t /app/share/metainfo com.donutbrowser.DonutBrowser.metainfo.xml + - install -Dm644 -t /app/share/licenses/com.donutbrowser.DonutBrowser LICENSE diff --git a/packaging/flatpak/flatpak-builder-lint-exceptions.json b/packaging/flatpak/flatpak-builder-lint-exceptions.json new file mode 100644 index 00000000..9bccbc8b --- /dev/null +++ b/packaging/flatpak/flatpak-builder-lint-exceptions.json @@ -0,0 +1,7 @@ +{ + "com.donutbrowser.DonutBrowser": { + "finish-args-own-name-com.donutbrowser.SingleInstance": "tauri-plugin-single-instance derives its session bus name from the Tauri identifier com.donutbrowser, which is not under the Flatpak app ID", + "appstream-screenshots-not-mirrored-in-ostree": "Only the Flathub build pipeline mirrors screenshots; CI builds keep the upstream URL", + "appstream-external-screenshot-url": "Only the Flathub build pipeline mirrors screenshots; CI builds keep the upstream URL" + } +} diff --git a/packaging/flatpak/patches/ayatana-ido-make-introspection-configurable.patch b/packaging/flatpak/patches/ayatana-ido-make-introspection-configurable.patch new file mode 100644 index 00000000..1277e76e --- /dev/null +++ b/packaging/flatpak/patches/ayatana-ido-make-introspection-configurable.patch @@ -0,0 +1,44 @@ +From 8a09e6ad33c58c017c0c8fd756da036fc39428ea Mon Sep 17 00:00:00 2001 +From: Alexander Koskovich +Date: Sun, 29 Sep 2024 13:47:54 -0400 +Subject: [PATCH 1/1] Make introspection configurable + +--- + CMakeLists.txt | 1 + + src/CMakeLists.txt | 4 ++++ + 2 files changed, 5 insertions(+) + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 0e13fcd..f3e9ec0 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -12,6 +12,7 @@ endif(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + option(ENABLE_TESTS "Enable all tests and checks" OFF) + option(ENABLE_COVERAGE "Enable coverage reports (includes enabling all tests and checks)" OFF) + option(ENABLE_WERROR "Treat all build warnings as errors" OFF) ++option(ENABLE_INTROSPECTION "Enable introspection" ON) + + if(ENABLE_COVERAGE) + set(ENABLE_TESTS ON) +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index 5b3638d..aca9481 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -108,6 +108,8 @@ install(TARGETS "ayatana-ido3-0.4" LIBRARY DESTINATION "${CMAKE_INSTALL_FULL_LIB + + # AyatanaIdo3-0.4.gir + ++if (ENABLE_INTROSPECTION) ++ + find_package(GObjectIntrospection REQUIRED QUIET) + + if (INTROSPECTION_FOUND) +@@ -183,3 +185,5 @@ if (INTROSPECTION_FOUND) + endif () + + endif () ++ ++endif () +-- +2.46.2 + diff --git a/packaging/flatpak/patches/intltool-perl5.26-regex-fixes.patch b/packaging/flatpak/patches/intltool-perl5.26-regex-fixes.patch new file mode 100644 index 00000000..234b82bd --- /dev/null +++ b/packaging/flatpak/patches/intltool-perl5.26-regex-fixes.patch @@ -0,0 +1,59 @@ +Description: Escape "{", to prevent complaints from perl 5.22 and 5.26 +Author: Roderich Schupp +Author: gregor herrmann +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=788705 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=826471 +Bug-Upstream: https://bugs.launchpad.net/intltool/+bug/1490906 + +Index: intltool-0.51.0/intltool-update.in +=================================================================== +--- intltool-0.51.0.orig/intltool-update.in 2017-07-23 17:24:35.113169465 +0200 ++++ intltool-0.51.0/intltool-update.in 2017-07-23 17:24:35.109169052 +0200 +@@ -1062,13 +1062,13 @@ + } + } + +- if ($str =~ /^(.*)\${?([A-Z_]+)}?(.*)$/) ++ if ($str =~ /^(.*)\$\{?([A-Z_]+)}?(.*)$/) + { + my $rest = $3; + my $untouched = $1; + my $sub = ""; + # Ignore recursive definitions of variables +- $sub = $varhash{$2} if defined $varhash{$2} and $varhash{$2} !~ /\${?$2}?/; ++ $sub = $varhash{$2} if defined $varhash{$2} and $varhash{$2} !~ /\$\{?$2}?/; + + return SubstituteVariable ("$untouched$sub$rest"); + } +@@ -1190,10 +1190,10 @@ + $name =~ s/\(+$//g; + $version =~ s/\(+$//g; + +- $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\${?AC_PACKAGE_NAME}?/); +- $varhash{"PACKAGE"} = $name if (not $name =~ /\${?PACKAGE}?/); +- $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\${?AC_PACKAGE_VERSION}?/); +- $varhash{"VERSION"} = $version if (not $name =~ /\${?VERSION}?/); ++ $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\$\{?AC_PACKAGE_NAME}?/); ++ $varhash{"PACKAGE"} = $name if (not $name =~ /\$\{?PACKAGE}?/); ++ $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\$\{?AC_PACKAGE_VERSION}?/); ++ $varhash{"VERSION"} = $version if (not $name =~ /\$\{?VERSION}?/); + } + + if ($conf_source =~ /^AC_INIT\(([^,\)]+),([^,\)]+)[,]?([^,\)]+)?/m) +@@ -1219,11 +1219,11 @@ + $version =~ s/\(+$//g; + $bugurl =~ s/\(+$//g if (defined $bugurl); + +- $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\${?AC_PACKAGE_NAME}?/); +- $varhash{"PACKAGE"} = $name if (not $name =~ /\${?PACKAGE}?/); +- $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\${?AC_PACKAGE_VERSION}?/); +- $varhash{"VERSION"} = $version if (not $name =~ /\${?VERSION}?/); +- $varhash{"PACKAGE_BUGREPORT"} = $bugurl if (defined $bugurl and not $bugurl =~ /\${?\w+}?/); ++ $varhash{"PACKAGE_NAME"} = $name if (not $name =~ /\$\{?AC_PACKAGE_NAME}?/); ++ $varhash{"PACKAGE"} = $name if (not $name =~ /\$\{?PACKAGE}?/); ++ $varhash{"PACKAGE_VERSION"} = $version if (not $name =~ /\$\{?AC_PACKAGE_VERSION}?/); ++ $varhash{"VERSION"} = $version if (not $name =~ /\$\{?VERSION}?/); ++ $varhash{"PACKAGE_BUGREPORT"} = $bugurl if (defined $bugurl and not $bugurl =~ /\$\{?\w+}?/); + } + + # \s makes this not work, why? diff --git a/packaging/flatpak/patches/libdbusmenu-fix-HAVE_VALGRIND-AM_CONDITIONAL.patch b/packaging/flatpak/patches/libdbusmenu-fix-HAVE_VALGRIND-AM_CONDITIONAL.patch new file mode 100644 index 00000000..411d7e90 --- /dev/null +++ b/packaging/flatpak/patches/libdbusmenu-fix-HAVE_VALGRIND-AM_CONDITIONAL.patch @@ -0,0 +1,51 @@ +From 729546c51806a1b3ea6cb6efb7a115b1baa811f1 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Stefan=20Br=C3=BCns?= +Date: Mon, 18 Nov 2019 19:58:53 +0100 +Subject: [PATCH 1/1] Fix HAVE_VALGRIND AM_CONDITIONAL + +The AM_CONDITIONAL should also be run with --disable-tests, otherwise +HAVE_VALGRIND is undefined. +--- + configure | 4 ++-- + configure.ac | 2 +- + 2 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/configure b/configure +index 831a3bb..8913b9b 100644 +--- a/configure ++++ b/configure +@@ -14801,6 +14801,8 @@ else + { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5 + $as_echo "yes" >&6; } + have_valgrind=yes ++fi ++ + fi + if test "x$have_valgrind" = "xyes"; then + HAVE_VALGRIND_TRUE= +@@ -14811,8 +14813,6 @@ else + fi + + +-fi +- + + + +diff --git a/configure.ac b/configure.ac +index ace54d1..cbd38a6 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -120,8 +120,8 @@ PKG_CHECK_MODULES(DBUSMENUTESTS, json-glib-1.0 >= $JSON_GLIB_REQUIRED_VERSION + [have_tests=yes] + ) + PKG_CHECK_MODULES(DBUSMENUTESTSVALGRIND, valgrind, have_valgrind=yes, have_valgrind=no) +-AM_CONDITIONAL([HAVE_VALGRIND], [test "x$have_valgrind" = "xyes"]) + ]) ++AM_CONDITIONAL([HAVE_VALGRIND], [test "x$have_valgrind" = "xyes"]) + + AC_SUBST(DBUSMENUTESTS_CFLAGS) + AC_SUBST(DBUSMENUTESTS_LIBS) +-- +2.46.2 + diff --git a/packaging/linux/com.donutbrowser.DonutBrowser.metainfo.xml b/packaging/linux/com.donutbrowser.DonutBrowser.metainfo.xml new file mode 100644 index 00000000..d06e581b --- /dev/null +++ b/packaging/linux/com.donutbrowser.DonutBrowser.metainfo.xml @@ -0,0 +1,67 @@ + + + + com.donutbrowser.DonutBrowser + CC0-1.0 + AGPL-3.0-only + + Donut Browser + Open source anti-detect browser + + Donut Browser + + + +

+ Donut Browser manages any number of isolated browser profiles. Each profile + keeps its own fingerprint, cookies, extensions and data, and runs on + Wayfern, a Chromium-based engine that Donut downloads on first use. +

+
    +
  • Per-profile HTTP, HTTPS, SOCKS4 and SOCKS5 proxies, and WireGuard configs
  • +
  • Per-profile DNS blocklists for ads and trackers
  • +
  • Profile import from Chrome, Edge, Brave and other Chromium browsers
  • +
  • Cookie and extension management
  • +
  • A local REST API and a Model Context Protocol server for automation
  • +
  • Optional end-to-end encrypted sync to Donut cloud or a self-hosted server
  • +
+
+ + com.donutbrowser.DonutBrowser.desktop + + donutbrowser + + + https://donutbrowser.com + https://github.com/zhom/donutbrowser/issues + https://github.com/zhom/donutbrowser + https://github.com/zhom/donutbrowser/blob/main/CONTRIBUTING.md + + + + https://raw.githubusercontent.com/zhom/donutbrowser/main/assets/donut-preview.png + The profile list + + + + + Network + WebBrowser + + + + keyboard + pointing + + + 768 + + + + + + + https://github.com/zhom/donutbrowser/releases/tag/v0.31.2 + + +
diff --git a/packaging/linux/smoke-test.sh b/packaging/linux/smoke-test.sh new file mode 100755 index 00000000..344bc21a --- /dev/null +++ b/packaging/linux/smoke-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Start a packaged Donut Browser on a virtual display and check that it stays +# up. .github/workflows/linux-packages.yml runs it for the Flatpak and the Snap. +# +# smoke-test.sh [args...] +# +# XDG_RUNTIME_DIR must point to a directory owned by the current user. The +# script starts a private session bus at $XDG_RUNTIME_DIR/bus, the path both +# sandboxes let the app reach, and an Xvfb display. It fails when the app exits +# before have passed or prints a Rust panic. The app's output and a +# screenshot are left in . A screenshot with almost no colours +# (a blank window) is reported as a warning. +set -euo pipefail + +out="$1" +seconds="$2" +shift 2 +mkdir -p "$out" + +: "${XDG_RUNTIME_DIR:?XDG_RUNTIME_DIR must be set}" +bus="$XDG_RUNTIME_DIR/bus" +rm -f "$bus" +dbus-daemon --session --address="unix:path=$bus" --nofork --nopidfile & +dbus_pid=$! +export DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" + +display_number=99 +Xvfb ":$display_number" -screen 0 1600x1000x24 -nolisten tcp >"$out/xvfb.log" 2>&1 & +xvfb_pid=$! +export DISPLAY=":$display_number" + +trap 'kill "$xvfb_pid" "$dbus_pid" 2>/dev/null || true' EXIT + +for _ in $(seq 1 100); do + if [ -S "$bus" ] && [ -S "/tmp/.X11-unix/X$display_number" ]; then + break + fi + sleep 0.1 +done + +echo "Starting: $*" +"$@" >"$out/app-output.log" 2>&1 & +app_pid=$! + +sleep "$seconds" + +failed=0 +if kill -0 "$app_pid" 2>/dev/null; then + echo "The app is still running after ${seconds}s." +else + code=0 + wait "$app_pid" || code=$? + echo "::error::The app exited within ${seconds}s with exit code $code." + failed=1 +fi + +if command -v import >/dev/null 2>&1; then + import -window root "$out/screenshot.png" || true + colours="$(identify -format '%k' "$out/screenshot.png" 2>/dev/null || echo 0)" + echo "The screenshot has $colours colours." + if [ "$colours" -lt 64 ]; then + echo "::warning::The screenshot has only $colours colours. The window may be blank." + fi +fi + +kill "$app_pid" 2>/dev/null || true +for _ in $(seq 1 50); do + kill -0 "$app_pid" 2>/dev/null || break + sleep 0.1 +done +kill -9 "$app_pid" 2>/dev/null || true + +if grep -n "panicked at" "$out/app-output.log"; then + echo "::error::The app panicked." + failed=1 +fi + +echo "--- app output (last 100 lines)" +tail -n 100 "$out/app-output.log" || true +exit "$failed" diff --git a/packaging/linux/wayfern-check.sh b/packaging/linux/wayfern-check.sh new file mode 100755 index 00000000..a1553d2d --- /dev/null +++ b/packaging/linux/wayfern-check.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Check that the Wayfern build Donut downloads can run inside a package's +# sandbox: its libraries resolve there, and it starts headless and answers on +# a DevTools port, which is how Donut drives it. +# +# wayfern-check.sh flatpak|snap +# +# is the Wayfern Linux .tar.xz. It is unpacked into the app's own +# data directory, the one place both sandboxes let the app execute files from. +# XDG_RUNTIME_DIR must point to a directory owned by the current user. +set -euo pipefail + +kind="$1" +archive="$2" +out="$3" +mkdir -p "$out" + +case "$kind" in + flatpak) + app=com.donutbrowser.DonutBrowser + dir="$HOME/.var/app/$app/data/wayfern-check" + in_sandbox() { flatpak run --command=sh "$app" -c "$1"; } + ;; + snap) + dir="$HOME/snap/donutbrowser/common/wayfern-check" + in_sandbox() { snap run --shell donutbrowser -c "$1"; } + ;; + *) + echo "usage: $0 flatpak|snap " >&2 + exit 2 + ;; +esac + +: "${XDG_RUNTIME_DIR:?XDG_RUNTIME_DIR must be set}" +if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then + bus="$XDG_RUNTIME_DIR/bus" + rm -f "$bus" + dbus-daemon --session --address="unix:path=$bus" --nofork --nopidfile & + dbus_pid=$! + trap 'kill "$dbus_pid" 2>/dev/null || true' EXIT + export DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" + for _ in $(seq 1 50); do [ -S "$bus" ] && break; sleep 0.1; done +fi + +rm -rf "$dir" +mkdir -p "$dir" +tar -xJf "$archive" -C "$dir" +chrome="$(find "$dir" -type f -name chrome -perm -u+x | head -n 1)" +if [ -z "$chrome" ]; then + echo "::error::The archive has no chrome executable." + exit 1 +fi +echo "Wayfern executable: $chrome" + +in_sandbox "/lib64/ld-linux-x86-64.so.2 --list '$chrome'" >"$out/wayfern-libraries.txt" 2>&1 || true +if grep "not found" "$out/wayfern-libraries.txt" || ! grep -q "=>" "$out/wayfern-libraries.txt"; then + echo "::error::Wayfern's libraries do not all resolve inside the sandbox." + cat "$out/wayfern-libraries.txt" + exit 1 +fi +echo "Every Wayfern library resolves inside the sandbox." + +# Donut asks the user to accept the Wayfern terms before the first launch and +# then runs this same command. This runner is thrown away after the job. +in_sandbox "'$chrome' --accept-terms-and-conditions" >"$out/wayfern-terms.log" 2>&1 + +port=9333 +in_sandbox "exec '$chrome' --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage \ + --no-first-run --user-data-dir='$dir/profile' --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port=$port about:blank" >"$out/wayfern-output.log" 2>&1 & +wayfern_pid=$! + +answered=0 +for _ in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:$port/json/version" >"$out/wayfern-version.json" 2>/dev/null; then + answered=1 + break + fi + kill -0 "$wayfern_pid" 2>/dev/null || break + sleep 1 +done + +kill "$wayfern_pid" 2>/dev/null || true +pkill -f "$dir/" 2>/dev/null || true + +if [ "$answered" -ne 1 ]; then + echo "::error::Wayfern did not answer on its DevTools port." + tail -n 40 "$out/wayfern-output.log" || true + exit 1 +fi +cat "$out/wayfern-version.json" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 00000000..54bb273a --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,87 @@ +# Snap for Donut Browser. +# +# This repackages the .deb that `pnpm tauri build --bundles deb` produces +# (the app, the donut-proxy and xray sidecars, the desktop entry and the icons). +# WebKitGTK, GTK, libayatana-appindicator and NSS come from the gnome-46-2404 +# content snap through the gnome extension, not from the build host. +# +# Build it locally on Ubuntu 24.04 (x86_64) after `pnpm tauri build --bundles deb`: +# cp src-tauri/target/release/bundle/deb/*.deb packaging/donut.deb +# sudo snapcraft pack --destructive-mode +name: donutbrowser +title: Donut Browser +base: core24 +adopt-info: donutbrowser +license: AGPL-3.0-only +grade: stable +confinement: strict +compression: lzo +icon: src-tauri/icons/icon.png +contact: mailto:contact@donutbrowser.com + +platforms: + amd64: + +slots: + # tauri-plugin-single-instance owns this session bus name so that a second + # launch (a link clicked in another app) is handed to the running instance. + single-instance: + interface: dbus + bus: session + name: com.donutbrowser.SingleInstance + +apps: + donutbrowser: + command: usr/bin/donutbrowser + desktop: usr/share/applications/donutbrowser.desktop + extensions: [gnome] + environment: + # $SNAP_USER_DATA is copied on every refresh so that a revert can restore + # it. Downloaded Wayfern builds and profiles run to gigabytes, so they go + # to the unversioned $SNAP_USER_COMMON instead, as the Chromium snap does. + DONUTBROWSER_DATA_DIR: $SNAP_USER_COMMON/.local/share/DonutBrowser + DONUTBROWSER_CACHE_DIR: $SNAP_USER_COMMON/.cache/DonutBrowser + plugs: + # Proxies, VPNs, sync and Wayfern downloads. The local API, the MCP server + # and the per-profile proxy workers listen on loopback ports. + - network + - network-bind + # Wayfern is Chromium based. Donut starts it with --no-sandbox, so the + # plug does not need allow-sandbox. + - browser-support + - audio-playback + # Microphone and camera for the websites Wayfern opens. Not connected + # automatically: `snap connect donutbrowser:audio-record` / `:camera`. + - audio-record + - camera + # Imports and exports go through the file dialog. + - home + # Profile import reads the source browser's "Safe Storage" secret. Not + # connected automatically: `snap connect donutbrowser:password-manager-service`. + - password-manager-service + # Tracking and cleaning up the browser and proxy processes Donut starts. + - system-observe + # The tray icon (StatusNotifierItem / libayatana-appindicator). + - unity7 + slots: + - single-instance + +parts: + donutbrowser: + plugin: dump + source: packaging/donut.deb + source-type: deb + parse-info: + - usr/share/metainfo/com.donutbrowser.DonutBrowser.metainfo.xml + override-pull: | + craftctl default + version="$(sed -n 's/^ "version": "\([^"]*\)",$/\1/p' "$CRAFT_PROJECT_DIR/src-tauri/tauri.conf.json")" + craftctl set version="$version" + override-build: | + craftctl default + install -Dm644 "$CRAFT_PROJECT_DIR/packaging/linux/com.donutbrowser.DonutBrowser.metainfo.xml" \ + "$CRAFT_PART_INSTALL/usr/share/metainfo/com.donutbrowser.DonutBrowser.metainfo.xml" + desktop="$CRAFT_PART_INSTALL/usr/share/applications" + mv "$desktop/Donut.desktop" "$desktop/donutbrowser.desktop" + sed -i 's|^Icon=.*|Icon=${SNAP}/usr/share/icons/hicolor/512x512/apps/donutbrowser.png|' \ + "$desktop/donutbrowser.desktop" diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 05947583..eefaf3a5 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -523,6 +523,12 @@ impl AppAutoUpdater { /// Check if a system package manager repo is configured for this installation. #[cfg(target_os = "linux")] fn is_repo_configured(&self) -> bool { + // flatpak and snapd update these installs. Nothing inside the sandbox can + // replace the app, so report the release the way a repository install does + // instead of downloading a .deb that cannot be installed from in here. + if crate::app_dirs::linux_sandbox().is_some() { + return true; + } let installation_method = self.detect_linux_installation_method(); match installation_method { LinuxInstallationMethod::Deb => Self::is_deb_repo_configured(), diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index 15123169..ad6e2660 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -26,6 +26,50 @@ pub fn is_portable() -> bool { portable_dir().is_some() } +/// A Linux app sandbox that Donut can be installed through. In either one the +/// app cannot replace its own files, and it does not see the desktop's +/// default-application settings the way a .deb or .rpm install does. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinuxSandbox { + Flatpak, + Snap, +} + +/// The sandbox this process runs in, or `None` for a regular install. +#[cfg(target_os = "linux")] +pub fn linux_sandbox() -> Option { + static SANDBOX: OnceLock> = OnceLock::new(); + *SANDBOX.get_or_init(|| { + detect_linux_sandbox( + std::path::Path::new("/.flatpak-info").exists(), + std::env::var_os("SNAP").map(PathBuf::from).as_deref(), + std::env::current_exe().ok().as_deref(), + ) + }) +} + +/// Flatpak writes `/.flatpak-info` into every sandbox. A snap is recognised by +/// the executable living under `$SNAP`, not by the variable alone: a snapped +/// terminal or IDE leaks `SNAP` into every program started from it. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn detect_linux_sandbox( + flatpak_info_exists: bool, + snap_dir: Option<&std::path::Path>, + exe: Option<&std::path::Path>, +) -> Option { + if flatpak_info_exists { + return Some(LinuxSandbox::Flatpak); + } + match (snap_dir, exe) { + // An empty `SNAP` is a prefix of every path. + (Some(snap), Some(exe)) if !snap.as_os_str().is_empty() && exe.starts_with(snap) => { + Some(LinuxSandbox::Snap) + } + _ => None, + } +} + /// Optional single-root override for all on-disk state. Set /// `DONUTBROWSER_DATA_ROOT=/path` (e.g. a tmpfs mount) to relocate /// data/cache/logs under `/{data,cache,logs}` without touching the real @@ -460,6 +504,33 @@ mod tests { assert!(portable.join("cache").starts_with(&portable)); } + #[test] + fn linux_sandbox_detection() { + let snap = PathBuf::from("/snap/donutbrowser/x1"); + let snapped_exe = snap.join("usr/bin/donutbrowser"); + let deb_exe = PathBuf::from("/usr/bin/donutbrowser"); + + assert_eq!( + detect_linux_sandbox(true, None, Some(&deb_exe)), + Some(LinuxSandbox::Flatpak) + ); + assert_eq!( + detect_linux_sandbox(false, Some(&snap), Some(&snapped_exe)), + Some(LinuxSandbox::Snap) + ); + // A .deb install started from a snapped terminal inherits its `SNAP`. + assert_eq!( + detect_linux_sandbox(false, Some(&snap), Some(&deb_exe)), + None + ); + assert_eq!( + detect_linux_sandbox(false, Some(&PathBuf::new()), Some(&deb_exe)), + None + ); + assert_eq!(detect_linux_sandbox(false, None, Some(&deb_exe)), None); + assert_eq!(detect_linux_sandbox(false, Some(&snap), None), None); + } + #[test] fn test_subdirectory_helpers() { assert!(profiles_dir().ends_with("profiles")); diff --git a/src-tauri/src/default_browser.rs b/src-tauri/src/default_browser.rs index 6af7859d..9296c349 100644 --- a/src-tauri/src/default_browser.rs +++ b/src-tauri/src/default_browser.rs @@ -848,11 +848,20 @@ mod windows { #[cfg(target_os = "linux")] mod linux { + use crate::app_dirs::{linux_sandbox, LinuxSandbox}; use std::process::Command; const APP_DESKTOP_NAME: &str = "donutbrowser.desktop"; pub fn is_default_browser() -> Result { + match linux_sandbox() { + Some(LinuxSandbox::Snap) => { + return snap_default_browser("check").map(|answer| answer == "yes") + } + Some(LinuxSandbox::Flatpak) => return Err(flatpak_cannot_manage_default()), + None => {} + } + // Check if xdg-mime is available if !is_xdg_mime_available() { return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string()); @@ -886,6 +895,19 @@ mod linux { } pub fn set_as_default_browser() -> Result<(), String> { + match linux_sandbox() { + Some(LinuxSandbox::Snap) => { + snap_default_browser("set")?; + return if snap_default_browser("check")? == "yes" { + Ok(()) + } else { + Err("xdg-settings did not make Donut Browser the default browser".to_string()) + }; + } + Some(LinuxSandbox::Flatpak) => return Err(flatpak_cannot_manage_default()), + None => {} + } + // Check if xdg-mime is available if !is_xdg_mime_available() { return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string()); @@ -982,6 +1004,53 @@ mod linux { false } + + /// Inside a snap, `xdg-settings` is snapd's client for its + /// `io.snapcraft.Settings` service, not the xdg-utils script. It asks the + /// user before it changes anything. `xdg-mime` would only edit the + /// mimeapps.list in the snap's private home, which the desktop never reads. + fn snap_default_browser(action: &str) -> Result { + let output = Command::new("xdg-settings") + .args(snap_settings_args(action)) + .output() + .map_err(|e| format!("Failed to run xdg-settings: {e}"))?; + if !output.status.success() { + return Err(format!( + "xdg-settings {action} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// snapd installs the snap's desktop file as `_donutbrowser.desktop` + /// and adds that prefix to the name it is given, so the name goes in bare. + fn snap_settings_args(action: &str) -> [&str; 3] { + [action, "default-web-browser", APP_DESKTOP_NAME] + } + + /// No portal lets a Flatpak app read or change the default browser, and + /// `xdg-mime` in here would only edit the sandbox's own mimeapps.list. + fn flatpak_cannot_manage_default() -> String { + let app_id = + std::env::var("FLATPAK_ID").unwrap_or_else(|_| "com.donutbrowser.DonutBrowser".to_string()); + format!( + "A Flatpak app cannot change the default browser. Choose Donut Browser in your system settings (GNOME: Settings > Default Applications > Web; KDE: System Settings > Default Applications > Web Browser), or run this outside the sandbox: xdg-settings set default-web-browser {app_id}.desktop" + ) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn snapd_gets_the_desktop_name_without_its_snap_prefix() { + assert_eq!( + snap_settings_args("check"), + ["check", "default-web-browser", "donutbrowser.desktop"] + ); + } + } } // Global singleton instance