Ship AppImage with bundled GTK runtime and automated releases
AppImage / build (push) Has been cancelled
AppImage / release (push) Has been cancelled
Tests / unit (de, 3.10) (push) Has been cancelled
Tests / unit (de, 3.14) (push) Has been cancelled
Tests / unit (en, 3.10) (push) Has been cancelled
Tests / unit (en, 3.14) (push) Has been cancelled

This commit is contained in:
Justin
2026-09-05 21:32:30 +02:00
parent dd0809e877
commit 3de4ca48e0
25 changed files with 690 additions and 32 deletions
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
APPDIR=${APPDIR:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)}
export APPDIR
. "$APPDIR/usr/share/btd700-control/appimage-environment.sh"
case "${1:-}" in
--install-desktop) exec "$APPDIR/usr/bin/python3.12" "$APPDIR/usr/share/btd700-control/install.py" ;;
--remove-desktop) exec "$APPDIR/usr/bin/python3.12" "$APPDIR/usr/share/btd700-control/install.py" --uninstall ;;
esac
exec "$APPDIR/usr/bin/python3.12" -m btd700 "$@"
+14
View File
@@ -0,0 +1,14 @@
FROM docker.io/library/ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN sed -i 's/Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/ubuntu.sources \
&& apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
python3 python3-gi python3-gi-cairo gir1.2-gtk-4.0 gir1.2-adw-1 \
gir1.2-dbusmenu-glib-0.4 adwaita-icon-theme fonts-dejavu-core \
ca-certificates curl squashfs-tools desktop-file-utils binutils libglib2.0-bin \
xvfb xauth dbus-x11 \
&& rm -rf /var/cache/apt/archives/*.deb
RUN apt-get install -y --no-install-recommends librsvg2-common xz-utils file
WORKDIR /src
CMD ["bash", "packaging/appimage/build.sh"]
+23
View File
@@ -0,0 +1,23 @@
Download **BTD_700_Control-0.3.0-x86_64.AppImage**, make it executable and open it.
Python, GTK, libadwaita and the tray library are included. Requires **x86-64 Linux
with glibc 2.39+** (such as Ubuntu 24.04 or newer). English and German are supported.
```bash
chmod +x BTD_700_Control-0.3.0-x86_64.AppImage
./BTD_700_Control-0.3.0-x86_64.AppImage
```
Optional `--install-desktop` adds an application-menu entry. Keep the file at that
location; enable Start at login in the app for tray autostart. `--remove-desktop`
removes both entries. If FUSE is unavailable, prefix the launch command with
`APPIMAGE_EXTRACT_AND_RUN=1`.
GNOME still needs a working AppIndicator/StatusNotifier extension. If USB access
is denied, install the attached udev rule following the README instructions.
SHA256SUMS covers the binary, package manifest, USB rule and dependency source archive.
The large `dependency-sources.tar.gz` is for reviewing/rebuilding libraries and is
not needed to run the app. GitHub's Source code archives contain the app itself.
**Independent, AI-developed (OpenAI Codex), experimental software. No warranty or
guarantee of compatibility.** Project code is MIT-licensed; bundled dependencies
retain their own licenses. Controls only: no firmware updates, downloads or update APIs.
+7
View File
@@ -0,0 +1,7 @@
# Deliberately no Python, GTK, libadwaita or libdbusmenu installed on this host.
FROM docker.io/library/ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb xauth dbus-x11 desktop-file-utils
WORKDIR /src
CMD ["dbus-run-session", "--", "xvfb-run", "-a", "bash", "packaging/appimage/check.sh"]
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Bundle Ubuntu runtime files and record every contributing binary/source package."""
import json
from pathlib import Path
import re
import shutil
import subprocess
ROOT = Path(__file__).resolve().parents[2]
APPDIR = ROOT / 'build/appimage/BTD_700_Control.AppDir'
if APPDIR.exists():
shutil.rmtree(APPDIR)
APPDIR.mkdir(parents=True)
def output(*args):
return subprocess.check_output(args, text=True)
packages = {}
owners = {}
for line in output('dpkg-query', '-W', '-f=${binary:Package}\t${Version}\t${source:Package}\t${source:Version}\n').splitlines():
name, version, source, source_version = line.split('\t')
packages[name] = dict(package=name, version=version, source=source, source_version=source_version)
for path in output('dpkg-query', '-L', name).splitlines():
owners[path] = name
if Path(path).is_file():
owners[str(Path(path).resolve())] = name
included = set()
def copy(path, destination=None):
path = Path(path)
if path.name in {'__pycache__', 'icon-theme.cache', 'gschemas.compiled'} or path.suffix == '.pyc':
return
if path.is_dir():
for child in sorted(path.iterdir()):
copy(child, Path(destination) / child.name if destination else None)
elif path.is_file():
target = APPDIR / (str(destination) if destination else str(path).lstrip('/'))
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
owner = owners.get(str(path)) or owners.get(str(path.resolve()))
if not owner and path.is_relative_to('/usr/share/mime'):
owner = owners['/usr/share/mime/packages/freedesktop.org.xml']
if owner:
included.add(owner)
elif not path.is_relative_to(ROOT):
raise RuntimeError(f'No package provenance for {path}')
for path in ['/usr/bin/python3.12', '/usr/lib/python3.12',
'/usr/lib/python3/dist-packages/gi', '/usr/lib/python3/dist-packages/cairo',
'/usr/lib/x86_64-linux-gnu/girepository-1.0',
'/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders',
'/usr/share/glib-2.0/schemas', '/usr/share/icons/Adwaita',
'/usr/share/icons/hicolor', '/usr/share/fonts/truetype/dejavu',
'/usr/share/mime',
'/etc/fonts', '/usr/share/fontconfig']:
copy(path)
# Include GTK/libadwaita's German translations as well as the app's own catalog.
for path in Path('/usr/share/locale').glob('de*/LC_MESSAGES/*.mo'):
if path.name.startswith(('gtk40', 'libadwaita', 'glib20', 'gdk-pixbuf')):
copy(path)
roots = list(APPDIR.rglob('*.so')) + [APPDIR / 'usr/bin/python3.12']
roots += [Path('/usr/lib/x86_64-linux-gnu') / name for name in
['libgtk-4.so.1', 'libadwaita-1.so.0', 'libdbusmenu-glib.so.4']]
# ldd includes the transitive ELF dependencies. glibc and the ELF loader belong
# to the host; Ubuntu 24.04 (glibc 2.39) is the documented compatibility floor.
host_libraries = re.compile(r'^(ld-linux.*|lib(c|m|dl|rt|pthread|resolv|util|nss_.*)\.so\..*)$')
libraries = set(roots[-3:])
for path in roots:
for line in output('ldd', str(path)).splitlines():
if 'not found' in line:
raise RuntimeError(f'Missing dependency of {path}: {line}')
match = re.search(r'=> (/\S+)', line)
if match and not host_libraries.match(Path(match[1]).name):
libraries.add(Path(match[1]))
for path in sorted(libraries):
copy(path, 'usr/lib/x86_64-linux-gnu/' + path.name)
copy(ROOT / 'btd700', 'usr/share/btd700-control/btd700')
for name in ['install.py', 'LICENSE', 'THIRD_PARTY_NOTICES.md']:
copy(ROOT / name, 'usr/share/btd700-control/' + name)
copy(ROOT / 'packaging/btd700-control.svg', 'btd700-control.svg')
copy(ROOT / 'packaging/btd700-control.svg', 'usr/share/icons/hicolor/scalable/apps/btd700-control.svg')
copy(ROOT / 'packaging/70-btd700-control.rules', 'usr/share/btd700-control/packaging/70-btd700-control.rules')
copy(ROOT / 'packaging/appimage/AppRun', 'AppRun')
copy(ROOT / 'packaging/appimage/environment.sh', 'usr/share/btd700-control/appimage-environment.sh')
(APPDIR / 'AppRun').chmod(0o755)
copy(ROOT / 'packaging/appimage/btd700-control.desktop', 'btd700-control.desktop')
(APPDIR / '.DirIcon').symlink_to('btd700-control.svg')
subprocess.run(['desktop-file-validate', str(APPDIR / 'btd700-control.desktop')], check=True)
subprocess.run(['glib-compile-schemas', str(APPDIR / 'usr/share/glib-2.0/schemas')], check=True)
cache = output('/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders')
# A bare module name is resolved by dlopen through our LD_LIBRARY_PATH. This
# avoids storing a container or temporary AppImage mount path in the cache.
cache = re.sub(r'^"[^"\n]*/([^/"\n]+\.so)"$', r'"\1"', cache, flags=re.MULTILINE)
(APPDIR / 'usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders.cache').write_text(cache)
# Make the bundled fonts available without relying on a host font installation.
font_config = APPDIR / 'etc/fonts/fonts.conf'
font_config.write_text(font_config.read_text().replace('<fontconfig>',
'<fontconfig>\n <dir prefix="relative">../../usr/share/fonts</dir>'))
# License texts accompany the binary; complete corresponding Ubuntu source
# archives are downloaded separately by build.sh and attached to the release.
copy('/usr/share/common-licenses')
for package in sorted(included):
doc = Path('/usr/share/doc') / package.split(':')[0] / 'copyright'
if not doc.is_file():
raise RuntimeError(f'No copyright file for {package}')
copy(doc, 'usr/share/doc/btd700-bundled/' + package.replace(':', '_') + '.copyright')
manifest = [packages[name] for name in sorted(included)]
(APPDIR / 'usr/share/doc/btd700-bundled/packages.json').write_text(json.dumps(manifest, indent=2) + '\n')
(ROOT / 'build/appimage/packages.json').write_text(json.dumps(manifest, indent=2) + '\n')
sources = sorted({f"{p['source']}={p['source_version']}" for p in manifest})
(ROOT / 'build/appimage/sources.txt').write_text('\n'.join(sources) + '\n')
# Do not ship bytecode, Python tests, or compilation-only metadata.
for path in list(APPDIR.rglob('__pycache__')):
shutil.rmtree(path)
for path in [APPDIR / 'usr/lib/python3.12/test', APPDIR / 'usr/lib/python3.12/config-3.12-x86_64-linux-gnu']:
if path.exists():
shutil.rmtree(path)
print(f'Assembled {APPDIR}: {len(manifest)} binary packages, {len(sources)} source packages')
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=BTD 700 Control
Comment=Linux controls for the Sennheiser BTD 700
Comment[de]=Linux-Steuerung für den Sennheiser BTD 700
Exec=btd700-control
Icon=btd700-control
Terminal=false
Categories=AudioVideo;Audio;
Keywords=Sennheiser;Bluetooth;aptX;Auracast;Dongle;
StartupWMClass=io.github.btd700linux.Control
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Run inside the Ubuntu 24.04 builder (see docs/APPIMAGE.md).
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.."
repo_dir=$PWD
version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
build_dir="$repo_dir/build/appimage"
sources_dir="$build_dir/dependency-sources"
mkdir -p "$build_dir/downloads" "$sources_dir" dist
download() {
local url=$1 name=$2 checksum=$3
if [[ ! -f "$build_dir/downloads/$name" ]]; then
curl --fail --location --retry 3 "$url" -o "$build_dir/downloads/$name"
fi
echo "$checksum $build_dir/downloads/$name" | sha256sum --check
}
download https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage appimagetool \
ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
download https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64 runtime-x86_64 \
2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d
chmod +x "$build_dir/downloads/appimagetool"
python3 packaging/appimage/assemble.py
# Keep the exact corresponding Ubuntu sources alongside the downloadable binary.
# apt checks the archives against the signed repository's source index.
cd "$sources_dir"
while IFS= read -r package; do
apt-get source --download-only "$package"
done < "$build_dir/sources.txt"
curl --fail --location --retry 3 https://github.com/AppImage/type2-runtime/archive/refs/tags/20251108.tar.gz -o type2-runtime-20251108.tar.gz
download https://github.com/libfuse/libfuse/releases/download/fuse-3.15.0/fuse-3.15.0.tar.xz fuse-3.15.0.tar.xz \
70589cfd5e1cff7ccd6ac91c86c01be340b227285c5e200baa284e401eea2ca0
download https://github.com/vasi/squashfuse/archive/0.5.2.tar.gz squashfuse-0.5.2.tar.gz \
db0238c5981dabbd80ee09ae15387f390091668ca060a7bc38047912491443d3
cp "$build_dir/downloads/fuse-3.15.0.tar.xz" "$build_dir/downloads/squashfuse-0.5.2.tar.gz" .
cp "$build_dir/packages.json" "$build_dir/sources.txt" .
rm -rf -- "$sources_dir/build-instructions"
cp -r "$repo_dir/packaging/appimage" build-instructions
cp "$repo_dir/docs/APPIMAGE.md" README.md
tar -xOf type2-runtime-20251108.tar.gz type2-runtime-20251108/LICENSE > "$build_dir/BTD_700_Control.AppDir/usr/share/doc/btd700-bundled/AppImage-runtime.LICENSE"
tar -xOf fuse-3.15.0.tar.xz fuse-3.15.0/LGPL2.txt > "$build_dir/BTD_700_Control.AppDir/usr/share/doc/btd700-bundled/libfuse.LICENSE"
tar -xOf squashfuse-0.5.2.tar.gz squashfuse-0.5.2/LICENSE > "$build_dir/BTD_700_Control.AppDir/usr/share/doc/btd700-bundled/squashfuse.LICENSE"
# Permissive runtime dependency notices (the upstream runtime LICENSE links these).
curl -fsSL --retry 3 https://git.musl-libc.org/cgit/musl/plain/COPYRIGHT?h=v1.2.5 -o "$build_dir/BTD_700_Control.AppDir/usr/share/doc/btd700-bundled/musl.COPYRIGHT"
curl -fsSL --retry 3 https://raw.githubusercontent.com/facebook/zstd/v1.5.6/LICENSE -o "$build_dir/BTD_700_Control.AppDir/usr/share/doc/btd700-bundled/zstd.LICENSE"
curl -fsSL --retry 3 https://raw.githubusercontent.com/madler/zlib/v1.3.1/LICENSE -o "$build_dir/BTD_700_Control.AppDir/usr/share/doc/btd700-bundled/zlib.LICENSE"
cd "$repo_dir"
archive="BTD_700_Control-$version-dependency-sources.tar.gz"
tar --exclude='__pycache__' -czf "dist/$archive" -C "$build_dir" dependency-sources
ARCH=x86_64 VERSION="$version" APPIMAGE_EXTRACT_AND_RUN=1 "$build_dir/downloads/appimagetool" \
--no-appstream --mksquashfs-opt -processors --mksquashfs-opt 2 \
--runtime-file "$build_dir/downloads/runtime-x86_64" \
"$build_dir/BTD_700_Control.AppDir" "dist/BTD_700_Control-$version-x86_64.AppImage"
cp "$build_dir/packages.json" "dist/BTD_700_Control-$version-packages.json"
cp packaging/70-btd700-control.rules dist/
cd dist
sha256sum "BTD_700_Control-$version-x86_64.AppImage" "$archive" \
"BTD_700_Control-$version-packages.json" 70-btd700-control.rules > SHA256SUMS
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Run in the builder with: dbus-run-session -- xvfb-run -a bash packaging/appimage/check.sh
set -euo pipefail
repo_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
appimages=("$repo_dir"/dist/*-x86_64.AppImage)
[[ ${#appimages[@]} == 1 ]] || { echo 'Expected exactly one AppImage in dist/' >&2; exit 1; }
test_dir=$(mktemp -d)
trap 'rm -rf -- "$test_dir"' EXIT
export XDG_CONFIG_HOME="$test_dir/config" XDG_DATA_HOME="$test_dir/data"
appimage=${appimages[0]}
# No accessibility service runs inside this private headless test session.
export GTK_A11Y=none
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --language en --demo status
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --install-desktop
desktop-file-validate "$XDG_DATA_HOME/applications/io.github.btd700linux.Control.desktop"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --remove-desktop
test ! -e "$XDG_DATA_HOME/applications/io.github.btd700linux.Control.desktop"
cd "$test_dir"
"$appimage" --appimage-extract > /dev/null
export APPDIR="$test_dir/squashfs-root"
. "$APPDIR/usr/share/btd700-control/appimage-environment.sh"
"$APPDIR/usr/bin/python3.12" - <<'PY'
import os, sys, gi
from pathlib import Path
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import GdkPixbuf
root = Path(os.environ['APPDIR'])
assert Path(sys.prefix).is_relative_to(root)
assert Path(gi.__file__).is_relative_to(root)
icon = GdkPixbuf.Pixbuf.new_from_file(str(root / 'btd700-control.svg'))
assert icon.get_width() == 128
print('PASS: bundled Python, PyGObject, SVG loader and MIME recognition')
PY
export BTD700_TEST_INSTALLED=1 BTD700_TEST_TRAY_HOST=1
for language in en de; do
BTD700_LANGUAGE=$language "$APPDIR/usr/bin/python3.12" "$repo_dir/tools/check_gui.py"
done
# Also exercise the actual runtime/extraction entry point into the GUI.
"$APPDIR/usr/bin/python3.12" "$repo_dir/tools/tray_test_host.py" > "$test_dir/tray.log" &
tray_pid=$!
trap 'kill "$tray_pid" 2>/dev/null || true; rm -rf -- "$test_dir"' EXIT
for attempt in {1..50}; do
if [[ $(cat "$test_dir/tray.log") == TEST_TRAY_READY ]]; then break; fi
sleep 0.1
done
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --demo --smoke-seconds 3
+14
View File
@@ -0,0 +1,14 @@
# Shared by the launcher and packaged-runtime verification. APPDIR must be set.
export PYTHONHOME="$APPDIR/usr"
export PYTHONPATH="$APPDIR/usr/lib/python3/dist-packages:$APPDIR/usr/share/btd700-control"
export PYTHONNOUSERSITE=1
export PYTHONDONTWRITEBYTECODE=1
export LD_LIBRARY_PATH="$APPDIR/usr/lib/x86_64-linux-gnu:$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export GDK_PIXBUF_MODULE_FILE="$APPDIR/usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders.cache"
export GI_TYPELIB_PATH="$APPDIR/usr/lib/x86_64-linux-gnu/girepository-1.0"
export GSETTINGS_SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas"
export XDG_DATA_DIRS="$APPDIR/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
export FONTCONFIG_FILE="$APPDIR/etc/fonts/fonts.conf"
export FONTCONFIG_PATH="$APPDIR/etc/fonts"
# A control panel does not need GPU rendering or host-specific Mesa drivers.
export GSK_RENDERER=${GSK_RENDERER:-cairo}