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
+2
View File
@@ -0,0 +1,2 @@
# The Containerfile only installs Ubuntu packages; source is mounted at runtime.
**
+76
View File
@@ -0,0 +1,76 @@
name: AppImage
on:
push:
tags: ['v*']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 35
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Check release version
if: startsWith(github.ref, 'refs/tags/')
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
python3 - <<'PY'
import os, tomllib
version = tomllib.load(open('pyproject.toml', 'rb'))['project']['version']
assert os.environ['RELEASE_TAG'] == 'v' + version, 'Tag must match pyproject.toml'
PY
- name: Build isolated Ubuntu runtime and source archives
run: |
docker build -t btd700-appimage-builder -f packaging/appimage/Containerfile .
docker run --rm -v "$PWD:/src" btd700-appimage-builder
- name: Verify unit tests
run: |
docker run --rm -v "$PWD:/src" btd700-appimage-builder bash -c '
BTD700_LANGUAGE=en python3 -m unittest discover -s tests -v &&
BTD700_LANGUAGE=de python3 -m unittest discover -s tests -v'
- name: Verify packaged English/German GUI and tray without host Python or GTK
run: |
docker build -t btd700-appimage-test -f packaging/appimage/TestContainerfile .
docker run --rm -v "$PWD:/src" btd700-appimage-test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
with:
name: appimage-x86_64
path: dist/*
compression-level: 0
if-no-files-found: error
retention-days: 1
release:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: appimage-x86_64
path: dist
- name: Publish verified release assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
(cd dist && sha256sum --check SHA256SUMS)
gh release create "$RELEASE_TAG" dist/* --draft --verify-tag \
--title "BTD 700 Control $RELEASE_TAG" \
--notes-file packaging/appimage/RELEASE_NOTES.md
gh release edit "$RELEASE_TAG" --draft=false --latest
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## 0.3.0 — 2026-09-05
- Downloadable x86-64 AppImage for glibc 2.39+ desktops, including Python, GTK,
libadwaita and the tray library; no Python package installation needed.
- AppImage-aware application-menu installation and autostart using its permanent
file path, including a persistent icon and support for extracted AppDirs.
- Container build and GitHub release workflow, checksums, bundled license notices
and corresponding dependency source archives.
- English/German packaged GUI and D-Bus tray verification with simulated hardware.
## 0.2.0 — 2026-09-05 ## 0.2.0 — 2026-09-05
- English and German app, tray, CLI help and error messages, selected by system - English and German app, tray, CLI help and error messages, selected by system
+1
View File
@@ -1,6 +1,7 @@
include README.md README.de.md LICENSE THIRD_PARTY_NOTICES.md CONTRIBUTING.md CHANGELOG.md include README.md README.de.md LICENSE THIRD_PARTY_NOTICES.md CONTRIBUTING.md CHANGELOG.md
include run.sh install.py include run.sh install.py
recursive-include packaging *.svg *.rules recursive-include packaging *.svg *.rules
recursive-include packaging/appimage *.sh *.py *.desktop *.md Containerfile TestContainerfile AppRun
recursive-include docs *.md *.png recursive-include docs *.md *.png
recursive-include tools *.py recursive-include tools *.py
recursive-include tests *.py recursive-include tests *.py
+27 -6
View File
@@ -1,6 +1,6 @@
# BTD 700 Control für Linux # BTD 700 Control für Linux
[English](README.md) · [Quellcode v0.2.0 herunterladen](https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.2.0.zip) · [Fehler melden](https://github.com/justin-eckenweber/btd700linux/issues) [English](README.md) · [AppImage herunterladen](https://github.com/justin-eckenweber/btd700linux/releases/latest) · [Fehler melden](https://github.com/justin-eckenweber/btd700linux/issues)
Native GTK-4-App zur Steuerung des **Sennheiser BTD 700**, mit einem Menü im Native GTK-4-App zur Steuerung des **Sennheiser BTD 700**, mit einem Menü im
Infobereich von GNOME/KDE. Eigenständige Implementierung des HID-Steuerprotokolls, Infobereich von GNOME/KDE. Eigenständige Implementierung des HID-Steuerprotokolls,
@@ -29,16 +29,37 @@ Die Bilder zeigen die englische Oberfläche; Deutsch ist ebenfalls enthalten.
<img src="docs/screenshots/auracast-settings.png" width="46%" alt="Auracast-Einstellungen und Infobereich-Optionen"> <img src="docs/screenshots/auracast-settings.png" width="46%" alt="Auracast-Einstellungen und Infobereich-Optionen">
</p> </p>
## Herunterladen ## AppImage herunterladen
[Das AppImage findest du unter GitHub Releases](https://github.com/justin-eckenweber/btd700linux/releases/latest).
Python, GTK, libadwaita und die Tray-Bibliothek sind enthalten. Das Paket ist für
**x86-64 mit glibc 2.39 oder neuer**, etwa Ubuntu 24.04+ oder das getestete Bazzite 44.
```bash
chmod +x BTD_700_Control-0.3.0-x86_64.AppImage
./BTD_700_Control-0.3.0-x86_64.AppImage
```
Datei zuerst an ihren dauerhaften Platz legen. Mit `--install-desktop` legst du
einen Anwendungsmenü-Eintrag an; „Beim Anmelden starten“ in der App aktiviert den
Autostart im Tray. `--remove-desktop` entfernt beide Einträge. Nach Verschieben
oder Umbenennen der Datei den Menüeintrag neu anlegen und Autostart aus-/einschalten.
Ohne FUSE funktioniert der Start mit
`APPIMAGE_EXTRACT_AND_RUN=1 ./BTD_700_Control-0.3.0-x86_64.AppImage`.
Die [AppImage-Anleitung](docs/APPIMAGE.md) erklärt dauerhaftes Entpacken und eigene
Builds. Prüfsummen und Bibliotheksquellen liegen beim Release. Die USB-Berechtigung
und ein Tray-Host bleiben nötig; GNOME benötigt eine StatusNotifier/AppIndicator-Erweiterung.
## Alternativ: Quellcode herunterladen
```bash ```bash
git clone https://github.com/justin-eckenweber/btd700linux.git git clone https://github.com/justin-eckenweber/btd700linux.git
cd btd700linux cd btd700linux
``` ```
Alternativ die oben verlinkte ZIP-Datei entpacken und `bash run.sh` im Projektordner Alternativ [Quellcode v0.3.0 als ZIP](https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.3.0.zip)
ausführen. Die aktuelle Version wird als Quellcode verteilt; es gibt noch kein entpacken und `bash run.sh` im Projektordner ausführen.
AppImage, Flatpak oder eigenständiges Binärpaket.
## Starten ## Starten
@@ -101,7 +122,7 @@ Die angezeigten Bit-/kHz-Werte stammen vom Dongle. Die USB-Abtastrate wird vom
Audiosystem (z. B. PipeWire) bestimmt und ist kein hier nachgewiesener Stellbefehl. Audiosystem (z. B. PipeWire) bestimmt und ist kein hier nachgewiesener Stellbefehl.
Koppeln erfolgt über die Taste am Dongle; „Verbinden“ versucht die bestehende Kopplung. Koppeln erfolgt über die Taste am Dongle; „Verbinden“ versucht die bestehende Kopplung.
## Voraussetzungen ## Voraussetzungen für den Start aus dem Quellcode
Python ≥ 3.10, PyGObject, GTK ≥ 4.10, libadwaita ≥ 1.5 und libdbusmenu mit Python ≥ 3.10, PyGObject, GTK ≥ 4.10, libadwaita ≥ 1.5 und libdbusmenu mit
GObject-Introspection. Auf dem hier verwendeten Bazzite sind die benötigten GObject-Introspection. Auf dem hier verwendeten Bazzite sind die benötigten
+32 -9
View File
@@ -15,7 +15,7 @@
<p align="center"> <p align="center">
<a href="#install">Install</a> · <a href="#install">Install</a> ·
<a href="https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.2.0.zip">Download source v0.2.0</a> · <a href="https://github.com/justin-eckenweber/btd700linux/releases/latest">Download AppImage</a> ·
<a href="README.de.md">Deutsche Anleitung</a> · <a href="README.de.md">Deutsche Anleitung</a> ·
<a href="https://github.com/justin-eckenweber/btd700linux/issues">Report an issue</a> <a href="https://github.com/justin-eckenweber/btd700linux/issues">Report an issue</a>
</p> </p>
@@ -65,11 +65,33 @@ window quits the app instead.
## Install ## Install
Version **0.2.0** is distributed as source. You do not need to compile the app or ### AppImage (x86-64)
install Python packages with pip. There is currently no AppImage, Flatpak or
self-contained binary download.
### 1. Install the system libraries [Download the AppImage from GitHub Releases](https://github.com/justin-eckenweber/btd700linux/releases/latest).
It includes Python, GTK, libadwaita and the tray library. Requires **glibc 2.39+**
(for example Ubuntu 24.04 or newer, or the tested Bazzite 44 desktop).
```bash
chmod +x BTD_700_Control-0.3.0-x86_64.AppImage
./BTD_700_Control-0.3.0-x86_64.AppImage
```
Put it in a permanent folder, then optionally run it with `--install-desktop` to
add an application-menu entry. Enable **Start at login** inside the app for tray
autostart. Use `--remove-desktop` to remove both entries. Keep the AppImage at the
same path; after moving it, recreate the menu entry and toggle autostart off/on.
If FUSE is unavailable, start with
`APPIMAGE_EXTRACT_AND_RUN=1 ./BTD_700_Control-0.3.0-x86_64.AppImage`.
See the [AppImage guide](docs/APPIMAGE.md) for permanent extraction, build instructions,
checksums and dependency sources. USB permissions and the desktop's tray host are
still required; GNOME needs a StatusNotifier/AppIndicator extension.
### Run from source
You can also run the Python source directly, without compiling or using pip.
#### 1. Install the system libraries
Requirements: **Python 3.10+**, PyGObject, **GTK 4.10+**, **libadwaita 1.5+** and Requirements: **Python 3.10+**, PyGObject, **GTK 4.10+**, **libadwaita 1.5+** and
libdbusmenu with GObject introspection. A graphical desktop session is needed for libdbusmenu with GObject introspection. A graphical desktop session is needed for
@@ -99,7 +121,7 @@ KDE Plasma provides a tray host. Other desktops may work but have not been teste
Package references: [Fedora libdbusmenu](https://packages.fedoraproject.org/pkgs/libdbusmenu/libdbusmenu/), Package references: [Fedora libdbusmenu](https://packages.fedoraproject.org/pkgs/libdbusmenu/libdbusmenu/),
[Ubuntu introspection package](https://packages.ubuntu.com/noble/gir1.2-dbusmenu-glib-0.4). [Ubuntu introspection package](https://packages.ubuntu.com/noble/gir1.2-dbusmenu-glib-0.4).
### 2. Download and run #### 2. Download and run
```bash ```bash
git clone https://github.com/justin-eckenweber/btd700linux.git git clone https://github.com/justin-eckenweber/btd700linux.git
@@ -107,7 +129,7 @@ cd btd700linux
./run.sh ./run.sh
``` ```
Or [download the v0.2.0 source ZIP](https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.2.0.zip), Or [download the v0.3.0 source ZIP](https://github.com/justin-eckenweber/btd700linux/archive/refs/tags/v0.3.0.zip),
extract it, and run `bash run.sh` inside the extracted folder. extract it, and run `bash run.sh` inside the extracted folder.
Want to explore without touching any hardware? Want to explore without touching any hardware?
@@ -116,7 +138,7 @@ Want to explore without touching any hardware?
./run.sh --demo ./run.sh --demo
``` ```
### 3. Add it to your application menu #### 3. Add it to your application menu
```bash ```bash
python3 install.py python3 install.py
@@ -133,7 +155,8 @@ python3 install.py --uninstall # Remove launcher and autostart entry
### USB permissions ### USB permissions
If the app reports that USB access is denied: If the app reports that USB access is denied, use the rule from the source checkout
or download `70-btd700-control.rules` from the same AppImage release (adjust its path below):
```bash ```bash
sudo install -m 0644 packaging/70-btd700-control.rules /etc/udev/rules.d/70-btd700-control.rules sudo install -m 0644 packaging/70-btd700-control.rules /etc/udev/rules.d/70-btd700-control.rules
+15 -2
View File
@@ -3,8 +3,21 @@
The MIT license in this repository covers this project's own implementation, The MIT license in this repository covers this project's own implementation,
documentation and artwork. It does not relicense third-party software or trademarks. documentation and artwork. It does not relicense third-party software or trademarks.
- Python, PyGObject, GTK, libadwaita and libdbusmenu are provided by your system, - When running from source, Python, PyGObject, GTK, libadwaita and libdbusmenu are
not bundled here. Their respective licenses continue to apply. provided by your system. The AppImage bundles these libraries and their runtime
dependencies from Ubuntu 24.04. Their respective licenses continue to apply.
- AppImage license/copyright texts and the package manifest are included under
`usr/share/doc/btd700-bundled/`; shared license texts are under
`usr/share/common-licenses/`. Extract with `--appimage-extract` to inspect them.
Exact corresponding Ubuntu source archives, package versions and build
instructions are downloadable beside each AppImage as
`*-dependency-sources.tar.gz`. These dependencies are not relicensed under MIT.
- The AppImage type2 runtime is MIT-licensed and statically includes musl (MIT),
libfuse (LGPL 2.1), squashfuse (BSD), zstd (BSD) and zlib (zlib license).
Runtime notices are included in the image; runtime sources, build scripts,
libfuse sources/patches and squashfuse sources accompany the dependency archive.
See [AppImage build documentation](docs/APPIMAGE.md) for replacing libraries and
rebuilding the runtime or app.
- The control protocol was reconstructed by examining the official Windows - The control protocol was reconstructed by examining the official Windows
Sennheiser Dongle Control 1.0.5 application. The vendor application, decompiled Sennheiser Dongle Control 1.0.5 application. The vendor application, decompiled
sources, firmware and vendor graphics are not included. Provenance and references sources, firmware and vendor graphics are not included. Provenance and references
+4
View File
@@ -32,6 +32,10 @@ def tr(message):
# German source messages are retained for compatibility with the first release. # German source messages are retained for compatibility with the first release.
# Keep placeholders identical in both languages; never translate protocol values. # Keep placeholders identical in both languages; never translate protocol values.
ENGLISH = { ENGLISH = {
'Menüeintrag und Autostart entfernt. Die Programmdateien bleiben erhalten.':
'Removed the launcher and start-at-login entry. The application files are unchanged.',
'Im Anwendungsmenü „BTD 700 Control“ öffnen. Die AppImage-Datei muss an diesem Ort bleiben.':
'Open BTD 700 Control from the application menu. Keep the AppImage file at this location.',
'Menüeintrag und Autostart entfernt. Der Projektordner bleibt erhalten.': 'Removed the launcher and start-at-login entry. The project folder is unchanged.', 'Menüeintrag und Autostart entfernt. Der Projektordner bleibt erhalten.': 'Removed the launcher and start-at-login entry. The project folder is unchanged.',
'Status aktualisieren': 'Refresh status', 'Status aktualisieren': 'Refresh status',
'App vollständig beenden': 'Quit application', 'App vollständig beenden': 'Quit application',
+24 -3
View File
@@ -1,6 +1,7 @@
"""User-scoped desktop integration.""" """User-scoped desktop integration."""
from pathlib import Path from pathlib import Path
import os import os
import shutil
import sys import sys
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -8,10 +9,17 @@ APP_ID = 'io.github.btd700linux.Control'
def desktop_entry(*, background=False): def desktop_entry(*, background=False):
appdir = os.environ.get('APPDIR')
appimage = os.environ.get('APPIMAGE') if appdir else None
checkout = (ROOT / 'run.sh').is_file() checkout = (ROOT / 'run.sh').is_file()
path = str(ROOT / 'run.sh') if checkout else sys.executable path = appimage or (str(Path(appdir) / 'AppRun') if appdir else None) or (str(ROOT / 'run.sh') if checkout else sys.executable)
module_args = '' if checkout else ' -m btd700' module_args = '' if appdir or checkout else ' -m btd700'
icon = str(ROOT / 'packaging/btd700-control.svg') if checkout else 'audio-headphones' # The pinned type2 runtime removes its CLI flag before launching AppRun;
# its extraction directory also identifies subsequent launches in this mode.
if appimage and ('APPIMAGE_EXTRACT_AND_RUN' in os.environ or Path(appdir).name.startswith('appimage_extracted_')):
module_args = ' --appimage-extract-and-run'
icon = (str(installed_icon()) if appimage else str(Path(appdir) / 'btd700-control.svg') if appdir
else str(ROOT / 'packaging/btd700-control.svg') if checkout else 'audio-headphones')
# Desktop Entry Exec quoting is not shell quoting. # Desktop Entry Exec quoting is not shell quoting.
escaped = path.replace('\\', '\\\\').replace('"', '\\"').replace('`', '\\`').replace('$', '\\$').replace('%', '%%') escaped = path.replace('\\', '\\\\').replace('"', '\\"').replace('`', '\\`').replace('$', '\\$').replace('%', '%%')
return ('[Desktop Entry]\nType=Application\nName=BTD 700 Control\n' return ('[Desktop Entry]\nType=Application\nName=BTD 700 Control\n'
@@ -29,9 +37,22 @@ def autostart_path():
return base / 'autostart' / f'{APP_ID}.desktop' return base / 'autostart' / f'{APP_ID}.desktop'
def installed_icon():
base = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local/share'))
return base / 'icons/hicolor/scalable/apps' / f'{APP_ID}.svg'
def install_appimage_icon():
if os.environ.get('APPIMAGE') and os.environ.get('APPDIR'):
path = installed_icon()
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(Path(os.environ['APPDIR']) / 'btd700-control.svg', path)
def set_autostart(enabled): def set_autostart(enabled):
path = autostart_path() path = autostart_path()
if enabled: if enabled:
install_appimage_icon()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(desktop_entry(background=True)) path.write_text(desktop_entry(background=True))
else: else:
+90
View File
@@ -0,0 +1,90 @@
# AppImage packaging
The x86-64 AppImage bundles Python 3.12, PyGObject, GTK 4, libadwaita,
libdbusmenu, icons and fallback fonts from Ubuntu 24.04. It requires a Linux
host with **glibc 2.39 or newer** and a graphical Wayland or X11 session.
It does not bundle glibc, audio drivers or firmware. ARM and older glibc
distributions are not supported by this binary; running from source is another option.
## Download and use
Download the `.AppImage` from [GitHub Releases](https://github.com/justin-eckenweber/btd700linux/releases/latest),
make it executable, and open it. All regular CLI arguments also work.
```bash
chmod +x BTD_700_Control-*-x86_64.AppImage
./BTD_700_Control-0.3.0-x86_64.AppImage
./BTD_700_Control-0.3.0-x86_64.AppImage --demo
```
Put the file at its permanent location before adding a menu entry:
```bash
./BTD_700_Control-0.3.0-x86_64.AppImage --install-desktop
```
The menu entry and **Start at login** use the original AppImage path, not its
temporary mount. Moving or renaming the file requires installing the menu entry
again and toggling Start at login off/on. `--remove-desktop` removes this app's
menu entry, autostart entry and installed icon. It leaves the AppImage in place.
If FUSE is unavailable, use the runtime's extraction mode:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./BTD_700_Control-0.3.0-x86_64.AppImage
```
When installing a menu entry or enabling autostart from this mode, the launcher
preserves it using the runtime's `--appimage-extract-and-run` argument.
For a permanent launcher on such a system, extract once with `--appimage-extract`,
keep the resulting `squashfs-root` directory, and run its `AppRun`. The launcher
created from that extracted copy uses its `AppRun` wrapper. Remove its
desktop/autostart entries before deleting the directory.
GNOME still needs a working StatusNotifier/AppIndicator extension for a tray.
USB access uses the host's `/dev/hidraw` permissions. If access is denied, download
`70-btd700-control.rules` from the same release and install it as described in the
[README](../README.md#usb-permissions). The app never changes system permissions itself.
## Build
Run from the repository root using rootless Podman (Docker can also build the
same Containerfile):
```bash
podman build -t btd700-appimage-builder -f packaging/appimage/Containerfile .
podman run --rm -v "$PWD:/src:z" btd700-appimage-builder
```
Verify the resulting file on a minimal test host without Python, GTK, libadwaita
or libdbusmenu installed (CLI, menu installation/removal, both GUI languages,
real D-Bus tray events against a simulated device, and extraction-mode startup):
```bash
podman build -t btd700-appimage-test -f packaging/appimage/TestContainerfile .
podman run --rm -v "$PWD:/src:z" btd700-appimage-test
```
The output goes to `dist/`. Ubuntu source repositories are enabled in the builder.
`assemble.py` copies runtime files and follows their ELF dependencies, retaining
package versions and license texts. The build downloads the exact corresponding
Ubuntu source archives and publishes them in `*-dependency-sources.tar.gz`.
`*-packages.json` lists the binary and source versions. The container receives
current Ubuntu 24.04 security updates, so two builds at different times need not
be byte-identical. No host packages are installed by this process.
Appimagetool 1.9.1 and type2-runtime 20251108 are checksum-pinned. The dependency
source archive also includes the runtime source, its build scripts and patches,
and the libfuse 3.15.0 and squashfuse 0.5.2 sources used by that runtime. To rebuild
the runtime, follow `BUILD.md` and `scripts/docker/` in that source archive;
the Dockerfile uses Alpine 3.21 and installs its build dependencies. The GNU LGPL
libraries in the AppDir remain dynamically linked and replaceable after extracting
the image. Repack a modified AppDir using appimagetool with `--runtime-file`.
For Ubuntu libraries, unpack their `.dsc` files with `dpkg-source -x`, install
the package's build dependencies and build with `dpkg-buildpackage` in Ubuntu 24.04.
For app modifications, rebuild from this project's tagged source and Containerfile.
The release contains `SHA256SUMS` for the binary, dependency sources, package manifest
and USB rule. The project itself remains MIT-licensed; dependencies retain their
own licenses. See [third-party notices](../THIRD_PARTY_NOTICES.md).
+18 -2
View File
@@ -38,14 +38,30 @@ Connected BTD 700: USB `3542:3001`, firmware 3.11.0.
- Extraction helper output matched the SHA256 of the original assembly used for - Extraction helper output matched the SHA256 of the original assembly used for
protocol research. No vendor binaries were added to the distribution. protocol research. No vendor binaries were added to the distribution.
The CI workflow runs the unit tests on Python 3.10 and 3.14, in both languages. ## AppImage 0.3.0
- Built an x86-64 image from Ubuntu 24.04 runtime packages, with bundled Python
3.12, GTK, libadwaita, libdbusmenu, SVG loading, MIME data, icons and fonts.
- Normal FUSE startup and `APPIMAGE_EXTRACT_AND_RUN=1` startup both displayed a
working demo window and registered the tray on Bazzite/GNOME.
- The bundled runtime passed the English and German form and D-Bus menu checks.
- The AppImage read the real BTD 700 status successfully: firmware 3.11.0,
Gaming, aptX Adaptive, 24-bit / 48 kHz, audio playing. No device settings changed.
- Menu installation/removal uses isolated temporary XDG directories. Autostart
tests cover persistent AppImage paths/icons, extracted AppDirs and no-FUSE mode.
- A minimal Ubuntu test container without Python, GTK, libadwaita or libdbusmenu
exercises the packaged CLI, SVG decoding, both GUI languages and D-Bus tray
controls with simulated hardware. The release workflow repeats these checks.
The unit-test CI workflow runs on Python 3.10 and 3.14, in both languages.
It does not run real hardware tests or claim that a particular receiver works. It does not run real hardware tests or claim that a particular receiver works.
## Not yet systematically verified ## Not yet systematically verified
- Hardware writes across different firmware revisions and all individual commands. - Hardware writes across different firmware revisions and all individual commands.
- Real Auracast receiver compatibility, audio quality and reconnect behavior. - Real Auracast receiver compatibility, audio quality and reconnect behavior.
- Other distributions/desktops beyond the tested Bazzite/GNOME setup. - Interactive desktops other than Bazzite/GNOME; Ubuntu GUI checks use Xvfb and
a private test tray watcher, not a full GNOME or KDE desktop.
`tools/check_hardware.py --run` has not been executed. It deliberately interrupts `tools/check_hardware.py --run` has not been executed. It deliberately interrupts
audio, changes settings including the broadcast password, and attempts to restore audio, changes settings including the broadcast password, and attempts to restore
+7 -2
View File
@@ -4,7 +4,7 @@ import argparse
import os import os
from pathlib import Path from pathlib import Path
from btd700.i18n import tr from btd700.i18n import tr
from btd700.integration import APP_ID, autostart_path, desktop_entry from btd700.integration import APP_ID, autostart_path, desktop_entry, install_appimage_icon, installed_icon
def main(): def main():
@@ -16,11 +16,16 @@ def main():
if args.uninstall: if args.uninstall:
destination.unlink(missing_ok=True) destination.unlink(missing_ok=True)
autostart_path().unlink(missing_ok=True) autostart_path().unlink(missing_ok=True)
print(tr('Menüeintrag und Autostart entfernt. Der Projektordner bleibt erhalten.')) installed_icon().unlink(missing_ok=True)
print(tr('Menüeintrag und Autostart entfernt. Die Programmdateien bleiben erhalten.'))
return return
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
install_appimage_icon()
destination.write_text(desktop_entry()) destination.write_text(desktop_entry())
print(tr('Installiert: {path}').format(path=destination)) print(tr('Installiert: {path}').format(path=destination))
if os.environ.get('APPIMAGE') and os.environ.get('APPDIR'):
print(tr('Im Anwendungsmenü „BTD 700 Control“ öffnen. Die AppImage-Datei muss an diesem Ort bleiben.'))
else:
print(tr('Im Anwendungsmenü „BTD 700 Control“ öffnen. Der Projektordner muss erhalten bleiben.')) print(tr('Im Anwendungsmenü „BTD 700 Control“ öffnen. Der Projektordner muss erhalten bleiben.'))
+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}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "btd700-control" name = "btd700-control"
version = "0.2.0" version = "0.3.0"
description = "Independent native Linux controls and tray menu for the Sennheiser BTD 700" description = "Independent native Linux controls and tray menu for the Sennheiser BTD 700"
requires-python = ">=3.10" requires-python = ">=3.10"
readme = "README.md" readme = "README.md"
+35 -1
View File
@@ -6,7 +6,7 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from btd700.integration import APP_ID, autostart_path, desktop_entry, set_autostart from btd700.integration import APP_ID, autostart_path, desktop_entry, set_autostart, installed_icon
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -44,3 +44,37 @@ class IntegrationTests(unittest.TestCase):
text = desktop_entry(background=True) text = desktop_entry(background=True)
self.assertIn(' -m btd700 --background', text) self.assertIn(' -m btd700 --background', text)
self.assertIn('Icon=audio-headphones', text) self.assertIn('Icon=audio-headphones', text)
def test_appimage_autostart_survives_temporary_mount_removal(self):
with tempfile.TemporaryDirectory() as tmp:
mount = Path(tmp) / 'temporary-mount'
mount.mkdir()
(mount / 'btd700-control.svg').write_text('<svg/>')
env = dict(APPIMAGE='/home/example/Apps/BTD "700" 100%.AppImage',
APPDIR=str(mount), XDG_CONFIG_HOME=tmp+'/config', XDG_DATA_HOME=tmp+'/data')
with patch.dict(os.environ, env):
set_autostart(True)
text = autostart_path().read_text()
self.assertIn('Exec="/home/example/Apps/BTD \\"700\\" 100%%.AppImage" --background', text)
self.assertNotIn(str(mount), text)
self.assertNotIn(' -m btd700', text)
(mount / 'btd700-control.svg').unlink()
mount.rmdir()
self.assertEqual(installed_icon().read_text(), '<svg/>')
def test_extracted_appimage_launcher_uses_environment_wrapper(self):
with patch.dict(os.environ, {'APPDIR': '/tmp/Extracted app', 'APPIMAGE': ''}):
text = desktop_entry()
self.assertIn('Exec="/tmp/Extracted app/AppRun"', text)
self.assertIn('Icon=/tmp/Extracted app/btd700-control.svg', text)
self.assertNotIn(' -m btd700', text)
def test_appimage_launcher_preserves_fuse_free_mode(self):
with patch.dict(os.environ, {'APPDIR': '/tmp/mount', 'APPIMAGE': '/tmp/BTD.AppImage', 'APPIMAGE_EXTRACT_AND_RUN': '1'}):
text = desktop_entry(background=True)
self.assertIn('"/tmp/BTD.AppImage" --appimage-extract-and-run --background', text)
def test_runtime_extraction_directory_preserves_fuse_free_mode(self):
with patch.dict(os.environ, {'APPDIR': '/tmp/appimage_extracted_abc', 'APPIMAGE': '/tmp/BTD.AppImage'}):
text = desktop_entry(background=True)
self.assertIn(' --appimage-extract-and-run --background', text)
+15 -4
View File
@@ -1,12 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Exercise the GTK UI and real D-Bus menu against an isolated demo device.""" """Exercise the GTK UI and real D-Bus menu against an isolated demo device."""
import sys import sys
import os
from pathlib import Path from pathlib import Path
if not os.environ.get('BTD700_TEST_INSTALLED'):
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from btd700.app import Application from btd700.app import Application
from btd700.i18n import tr from btd700.i18n import tr
from gi.repository import Gio, GLib, Gtk from gi.repository import Gio, GLib, Gtk
# CI uses a private dbus-run-session; never replace the real desktop's watcher.
if os.environ.get('BTD700_TEST_TRAY_HOST') == '1':
from tray_test_host import start_host
test_host = start_host()
app = Application(demo=True) app = Application(demo=True)
step = 0 step = 0
wait_ticks = 0 wait_ticks = 0
@@ -46,9 +53,10 @@ def screenshot(path):
paintable.snapshot(snapshot, window.get_width(), window.get_height()) paintable.snapshot(snapshot, window.get_width(), window.get_height())
node = snapshot.to_node() node = snapshot.to_node()
if node is None: if node is None:
raise RuntimeError('Window snapshot is not ready') return False # Wayland may still be committing a resize; retry next tick.
texture = window.get_renderer().render_texture(node, None) texture = window.get_renderer().render_texture(node, None)
texture.save_to_png(path) texture.save_to_png(path)
return True
def tick(): def tick():
@@ -118,14 +126,17 @@ def tick():
app.mode_buttons[0].emit('clicked') if app.mode_buttons[0].get_active() else app.mode_buttons[0].set_active(True) app.mode_buttons[0].emit('clicked') if app.mode_buttons[0].get_active() else app.mode_buttons[0].set_active(True)
app.perform('set_mode', 0) app.perform('set_mode', 0)
elif step == 14: elif step == 14:
screenshot('/tmp/btd700-gui-test.png') if not screenshot('/tmp/btd700-gui-test.png'):
return True
adjustment = app.scroll.get_vadjustment() adjustment = app.scroll.get_vadjustment()
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size()) adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
elif step == 15: elif step == 15:
screenshot('/tmp/btd700-gui-test-bottom.png') if not screenshot('/tmp/btd700-gui-test-bottom.png'):
return True
app.window.set_default_size(420, 600) app.window.set_default_size(420, 600)
elif step == 16: elif step == 16:
screenshot('/tmp/btd700-gui-test-narrow.png') if not screenshot('/tmp/btd700-gui-test-narrow.png'):
return True
print('PASS: GTK form, all D-Bus tray control groups, encryption, close/reopen, dirty edits, screenshots', flush=True) print('PASS: GTK form, all D-Bus tray control groups, encryption, close/reopen, dirty edits, screenshots', flush=True)
passed = True passed = True
app.quit() app.quit()
+23
View File
@@ -0,0 +1,23 @@
"""Minimal StatusNotifier watcher for GUI tests on a private CI session bus."""
from gi.repository import Gio, GLib
def start_host():
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
result = bus.call_sync('org.freedesktop.DBus', '/org/freedesktop/DBus',
'org.freedesktop.DBus', 'RequestName',
GLib.Variant('(su)', ('org.kde.StatusNotifierWatcher', 4)),
GLib.VariantType.new('(u)'), Gio.DBusCallFlags.NONE, 2000, None)
if result.unpack()[0] != 1:
raise RuntimeError('Test watcher refuses to replace an existing tray host')
xml = '<node><interface name="org.kde.StatusNotifierWatcher"><method name="RegisterStatusNotifierItem"><arg type="s" direction="in"/></method></interface></node>'
def register(_bus, _sender, _path, _interface, _method, _params, invocation):
invocation.return_value(None)
registration = bus.register_object('/StatusNotifierWatcher', Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], register, None, None)
return bus, registration
if __name__ == '__main__':
host = start_host()
print('TEST_TRAY_READY', flush=True)
GLib.MainLoop().run()