feat: flatpak and snap support

This commit is contained in:
zhom committed 2026-09-26 10:39:07 +04:00
1 parent feef010f55
commit 276d5b56f7
17 files changed
+1143

No files matched your search

+328
View File
@@ -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
+2
View File
@@ -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
```
+11
View File
@@ -86,6 +86,17 @@ If that gives an EGL display error, add `WEBKIT_DISABLE_DMABUF_RENDERER=1` or `G
</details>
### 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
+2
View File
@@ -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]
+2
View File
@@ -0,0 +1,2 @@
# The .deb that the Flatpak manifest and snap/snapcraft.yaml repackage.
/donut.deb
@@ -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 <prefix>/lib/<productName>.
- 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
@@ -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"
}
}
@@ -0,0 +1,44 @@
From 8a09e6ad33c58c017c0c8fd756da036fc39428ea Mon Sep 17 00:00:00 2001
From: Alexander Koskovich <akoskovich@pm.me>
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
@@ -0,0 +1,59 @@
Description: Escape "{", to prevent complaints from perl 5.22 and 5.26
Author: Roderich Schupp <roderich.schupp@gmail.com>
Author: gregor herrmann <gregoa@debian.org>
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?
@@ -0,0 +1,51 @@
From 729546c51806a1b3ea6cb6efb7a115b1baa811f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stefan=20Br=C3=BCns?= <stefan.bruens@rwth-aachen.de>
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
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright 2025 zhom@github -->
<component type="desktop-application">
<id>com.donutbrowser.DonutBrowser</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>AGPL-3.0-only</project_license>
<name>Donut Browser</name>
<summary>Open source anti-detect browser</summary>
<developer id="com.donutbrowser">
<name>Donut Browser</name>
</developer>
<description>
<p>
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.
</p>
<ul>
<li>Per-profile HTTP, HTTPS, SOCKS4 and SOCKS5 proxies, and WireGuard configs</li>
<li>Per-profile DNS blocklists for ads and trackers</li>
<li>Profile import from Chrome, Edge, Brave and other Chromium browsers</li>
<li>Cookie and extension management</li>
<li>A local REST API and a Model Context Protocol server for automation</li>
<li>Optional end-to-end encrypted sync to Donut cloud or a self-hosted server</li>
</ul>
</description>
<launchable type="desktop-id">com.donutbrowser.DonutBrowser.desktop</launchable>
<provides>
<binary>donutbrowser</binary>
</provides>
<url type="homepage">https://donutbrowser.com</url>
<url type="bugtracker">https://github.com/zhom/donutbrowser/issues</url>
<url type="vcs-browser">https://github.com/zhom/donutbrowser</url>
<url type="contribute">https://github.com/zhom/donutbrowser/blob/main/CONTRIBUTING.md</url>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/zhom/donutbrowser/main/assets/donut-preview.png</image>
<caption>The profile list</caption>
</screenshot>
</screenshots>
<categories>
<category>Network</category>
<category>WebBrowser</category>
</categories>
<supports>
<control>keyboard</control>
<control>pointing</control>
</supports>
<requires>
<display_length compare="ge">768</display_length>
</requires>
<content_rating type="oars-1.1" />
<releases>
<release version="0.31.2" date="2026-09-23">
<url type="details">https://github.com/zhom/donutbrowser/releases/tag/v0.31.2</url>
</release>
</releases>
</component>
+80
View File
@@ -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 <output-dir> <seconds> <command> [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 <seconds> have passed or prints a Rust panic. The app's output and a
# screenshot are left in <output-dir>. 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"
+91
View File
@@ -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 <archive> <output-dir>
#
# <archive> 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 <archive> <output-dir>" >&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"
+87
View File
@@ -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"
+6
View File
@@ -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(),
+71
View File
@@ -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<LinuxSandbox> {
static SANDBOX: OnceLock<Option<LinuxSandbox>> = 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<LinuxSandbox> {
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 `<root>/{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"));
+69
View File
@@ -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<bool, String> {
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<String, String> {
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 `<snap>_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