CI and packaging
GitLab CI/CD overview
.gitlab-ci.yml defines these stages:
- build-image — builds and pushes the custom builder image
(
ci/Dockerfile, tagged by Flutter version) when the Dockerfile, this file, or a tag changes; manual otherwise. - test —
sasttemplate, plus theflutterjob: format check →flutter gen-l10n→build_runner(root and each workspace package that needs it) → format → analyze →tool/build_web_client.sh(the browser client's compile doubles as thedart:io-leak guard — see Bundled browser client below) →flutter test --coverage→ per-package tests. This mirrorstool/verify.shplus the web build. - secret-detection — template job.
- package —
linux-package,android-package, andsnap-package. They run on tags automatically; on branches they are manual. Artifacts land indist/<version>/. - release —
extract-release-notespulls the tag'sCHANGELOG.mdsection;releaseuploadsdist/artifacts to the GitLab package registry and creates a release with those notes. - deploy —
pagesrendersdocs/into the project website withmkdocs build --strictand publishes it on GitLab Pages (see The website below).
Dependency scanning excludes the gemnasium-maven analyzer
(DS_EXCLUDED_ANALYZERS in .gitlab-ci.yml): it detects
android/*.gradle.kts and tries to resolve the Gradle project, which
needs the Flutter and Android SDKs the analyzer image does not carry, so
it can only fail. The app's real dependency surface is pubspec.lock,
which the generic gemnasium analyzer still scans.
The builder image also makes ALSA's default PCM the built-in null
plugin (/etc/asound.conf, plus libasound2t64 for the shared library
miniaudio dlopens). Runners normally have no sound hardware and no audio
daemon, and the real-engine tests under test/data/services/ need
SoLoud.init() to find an openable playback device or the backend
aborts. The null PCM is pure userspace — no card, /dev/snd node, or
PipeWire — and the tests assert on voice counts, not audible output.
The workspace shape means codegen/tests iterate packages/*/ — keep that
loop in sync with tool/verify.sh when changing either side.
Local builds
tool/build.sh is the entrypoint for builds that match CI:
tool/build.sh linux # deb, AppImage, pacman, zip + rpm + flatpak + snap
tool/build.sh linux-deb # one format only
tool/build.sh android # arm64 APK into dist/<version>/
tool/build.sh --deploy android # build + adb install to configured devices
tool/build.sh --deployonly android # deploy existing artifacts, no build
tool/build.sh --dev android # side-by-side dev variant (.dev app id,
# inverted icon, "LibreBash Dev" label)
tool/build.sh --debuggable android # release APK inspectable via run-as
Deploy targets (ADB serials, AppImage dir, per-format skips) live in
tool/build_config.env — copy tool/build_config.env.example and fill it
in. The file is gitignored so device IDs stay local.
The optional on_complete_command there is a shell command run when
tool/build.sh exits after build or deploy work has begun — exits before
that point (--help, bad arguments) never fire it. The command receives
the outcome (success or failure) as $1 and the platform as $2, so
a notification can say what finished; a failing command is reported but
does not change the script's exit status.
Every build writes tool/build-timestamp.json (UTC ISO-8601) and passes it
via --dart-define-from-file; lib/core/build_info.dart reads it and the
about tile shows it. Builds that bypass build.sh simply hide the line.
fastforge is installed on demand (dart pub global activate fastforge).
Linux packaging copies the tree to a temp dir automatically when the
project path contains spaces, because the AppImage maker's ldd parsing
breaks on space-separated paths.
Bundled browser client
Every packaged build carries assets/web_client/ — the Flutter web
build of lib/web_client/main.dart that a room serves to browsers and
embedded WebViews (ADR 0006). tool/build_web_client.sh is the shared
staging step: flutter build web (--pwa-strategy=none, so no service
worker can serve a stale bundle to a later room) then stages
build/web/ into assets/web_client/ flattened — Flutter's asset
bundler packs only the top-level files of a declared directory, so the
nested canvaskit//icons//assets/ tree would silently 404 in the
packaged app (the page boots white: main.dart.js loads but its
renderer wasm can't). The script joins each path with __ and
WebClientBundle.assetKeyFor maps requests back. It also drops
build/web/assets/assets/web_client/ first — the web build bundles the
app's declared assets, which includes the previously staged copy of
itself. Like the piper payload it must
land before the native build — asset packing happens inside
flutter build/fastforge, so build.sh calls it at the top of each
platform path and the CI package jobs call it explicitly before their
native build steps. It picks up tool/build-timestamp.json when present
so the bundle's build metadata matches its enclosing package; run it
standalone after flutter clean or any web-client change.
A build without it still runs — the asset dir holds only a .gitkeep
marker — but its rooms would 404 every browser join and embedded
fallback, so never skip the step for anything someone might install.
Building for Linux
Formats: .deb, .rpm, .pacman, .AppImage, .zip, .flatpak, .snap.
- fastforge builds deb/appimage/pacman/zip from
distribute_options.yamland the per-formatlinux/packaging/*/make_config.yaml. .rpmuseslinux/packaging/rpm/build-rpm.sh— fastforge's RPM maker is incompatible with rpm 4.20+..flatpakusesflatpak/build-flatpak.sh+flatpak/dev.johnkneev.librebash.ymlagainst the release bundle. If the host's AppArmorfusermount3profile restricts where unprivileged FUSE mounts may live (openSUSE, recent Ubuntu — allowed targets are/home,/tmp,/mnt,/media,/run/user), the build fails withfusermount3: mount failed: Permission denied; setflatpak_work_directoryintool/build_config.env(see the.example) to relocate the build dir, ostree repo, and.flatpak-buildercache — e.g./tmp/librebash-flatpak. The script alsoldd-resolves each bundled.soand stages the librariesorg.freedesktop.Platformlacks (e.g.libsecret) intoflatpak-libs/, which the manifest installs to/app/lib; it warns if a staged library needs a newer glibc than the runtime's and re-checks linkage inside the finished sandbox..snapusessnap/snapcraft.yaml(needssnapcraft).
The shared metadata lives in linux/packaging/librebash.desktop,
dev.johnkneev.librebash.metainfo.xml, and
linux/runner/resources/librebash.png (generated by
dart tool/generate.dart app).
Speech engine per format
Linux read-aloud (ProcessSpeaker) prefers piper — the neural
engine — falling back to spd-say, then espeak-ng/espeak on hosts
that have them. Piper synthesizes but doesn't play, so a wav player
(paplay, pw-play, aplay — probed in that order) completes the
pipeline.
linux/packaging/bundle-piper.sh is the shared bundling step: it
fetches the pinned piper release tarball plus the two multi-speaker
English models (en_US-l2arctic-medium, en_US-libritts_r-medium),
verifies sha256s, and primes linux/packaging/piper-assets/ with
piper/ + piper-voices/. linux/CMakeLists.txt then installs that
payload into the flutter bundle — beside the executable, exactly where
ProcessSpeaker probes — on every build. That ordering is the only
reliable one: every flutter build linux wipes the bundle dir
(file(REMOVE_RECURSE …) in the generated install rules), and fastforge
runs an internal rebuild while packaging, so injecting after our build
always loses the payload. Priming before the build is what makes it
stick for tool/build.sh, fastforge's rebuilds, rpm/flatpak/snap
(reuse the bundle dir), and CI alike. Fetches are cached under
~/.cache/librebash/piper-bundle so repeat builds don't redownload
~185 MB. Missing downloads are fatal under CI=true (or --required)
— a release never ships voiceless — while a local build without
network warns and ships unbundled (piper on the host's PATH or models in
the scanned dirs still get used).
Plain flutter build linux/flutter run installs the payload too —
as long as piper-assets/ has been primed:
./linux/packaging/bundle-piper.sh # once, or after flutter clean
flutter run # piper lands in the debug bundle
Passing a bundle dir (bundle-piper.sh <dir>) still does a direct
copy for one-off injection into an already-built bundle.
Per format:
.deb— piper + voices ship inside the package (bundle dirs land under the app's install dir);Recommends: alsa-utils, pulseaudio-utilscovers the player..rpm— same bundling;Recommends: alsa-utils, pulseaudio-utils..pacman— same bundling;optdepends: alsa-utils, pulseaudio-utils..snap— the bundle'spiper/+piper-voices/are copied into$SNAP/bin(the app finds them beside the executable);pulseaudio-utilsis staged forpaplay, which reaches the sound server over theaudio-playbackplug..flatpak— the bundle dirs land in/app/bin; playback uses the runtime'spw-play(--filesystem=xdg-run/pipewire-0) withpaplayover--socket=pulseaudioas the fallback..AppImage— the bundle dirs land inusr/bin/inside the image — no repack step needed anymore. The player is the host'spaplay/aplay..zip— the bundle dirs ride along; the player is the host's.
A system-installed piper on PATH (or $LIBREBASH_PIPER) and models
in ~/.local/share/piper-voices, /usr/local/share/piper-voices,
/usr/share/piper-voices, or $LIBREBASH_PIPER_VOICES are picked up
too — user's own files shadow bundled ones.
mDNS per format
Room discovery (bonsoir) is native on Android/iOS/macOS/Windows, but
on Linux it drives avahi-daemon over the system D-Bus
(org.freedesktop.Avahi). If the daemon isn't running — or the package
sandbox can't reach its bus name — both directions silently degrade to
QR/manual joins (LobbyBeacon/LobbyBrowser return null). Per format:
.deb/.rpm/.pacman/.zip/.AppImage— unsandboxed; the host just needsavahi-daemonrunning (systemctl enable --now avahi-daemon) and UDP 5353 permitted inbound (firewalld'smdnsservice on distros that ship it). Advertising additionally needs publishing enabled — some distros ship/etc/avahi/avahi-daemon.confwithdisable-publishing=yes, which produces exactly "browsing works, nobody sees my rooms". Set it tonoand restart the daemon;avahi-publish-service "Probe" _librebash._tcp 1is the quick probe —Not permittedmeans the config still rejects it. The otherpublish-*=nokeys only gate automatic host records and static service files, so they're fine as shipped..flatpak—--system-talk-name=org.freedesktop.Avahiin finish-args reaches the host daemon..snap—avahi-control(advertising) andavahi-observe(browsing) plugs; on a--dangerousinstall they need a manualsnap connect librebash:avahi-control/:avahi-observe.
Gamepad access per format
The gamepads plugin's Linux side reads /dev/input/event* — needed
for hosting from a Linux PC on a TV (see
ARCHITECTURE.md). No build
deps; sandbox permissions only:
.flatpak—--device=inputin finish-args. That subdevice needs Flatpak >= 1.15.6; older runtimes only offer the far broader--device=all, which we refuse. The builder image clears the floor by installing Flatpak from theppa:flatpak/stablePPA — the Ubuntu 24.04 base only carries 1.14.x, which rejected the arg atflatpak-builder's finishing step..snap— thejoystickplug.- Unsandboxed formats (deb/rpm/AppImage/zip) see
/dev/inputdirectly.
Launching through Steam
Adding the .AppImage to Steam as a non-Steam game fails silently —
Play flicks to Cancel and back, no window. The captured environment
shows why: Steam wraps the shortcut in the Steam Linux Runtime
(container=pressure-vessel, STEAM_COMPAT_TOOL_PATHS=SteamLinuxRuntime_4,
PATH=/usr/bin:/bin, LD_PRELOAD=gameoverlayrenderer.so), and inside
pressure-vessel there is no usable fusermount — the AppImage runtime
prints Cannot mount AppImage and exits before the app starts. Two
escapes:
- Uncheck Force the use of a specific Steam Play compatibility tool
on the shortcut so it runs on the host env (host
fusermount3mounts normally); or - Keep the container and run the AppImage extract-and-run via a wrapper
—
APPIMAGE_EXTRACT_AND_RUN=1 exec ./librebash-linux.AppImage. Slower start (~200 MB extract) but it survives the sandbox.
A second failure mode is possible after that: the overlay preload can
crash GL init on some drivers — unset LD_PRELOAD in the wrapper drops
the overlay popup only (Steam Input is evdev-level, unaffected). The
.zip bundle doesn't depend on FUSE at all, so it survives the same
container without a wrapper. The user-facing version lives in
USER_GUIDE.md.
Building for Android
tool/build.sh android produces
dist/<version>/librebash-phone-<version>-android.apk. Release builds are
signed with android/key.properties + android/app/release.keystore when
present, falling back to debug signing (which keeps flutter run --release
working). To set up signing:
tool/generate_android_keystore.sh <output.keystore> <password>- Locally: write
android/key.properties(see what the script prints) and point it at the keystore. - CI: set
ANDROID_KEYSTORE_FILE(base64),ANDROID_KEYSTORE_PASSWORD,ANDROID_KEY_PASSWORD,ANDROID_KEY_ALIASas masked CI variables —tool/prepare_android_signing.shmaterializes them per job.
Keystore and key.properties are gitignored; never commit them.
Android TV
The same APK runs on Android TV / Google TV — the manifest declares it TV-aware rather than shipping a leanback build:
android.intent.category.LEANBACK_LAUNCHERon the launcher activity puts the app on the TV home screen next to its phone entry.android:banner(the${appBanner}placeholder →drawable-xhdpi/tv_banner.png, 320x180) is the artwork the TV home screen shows;tool/generate.dart appregenerates it fromassets/brand/app_icon_source.pngon the brand violet — no text — alongside atv_banner_dev.pngtwin for--devinstalls (wired through theappBannermanifest placeholder, same mechanism asappIcon).- Phone hardware features are declared
required="false"—android.hardware.touchscreen,camera/camera.autofocus,wifi,location/location.gps, andandroid.software.leanbackitself — so Play's feature filtering doesn't hide the listing from television devices. The companion code treats them as best-effort already:TvDetectionServiceanswersisTelevisionover thedev.johnkneev.librebash/tv_detectionchannel (UiModeManager/FEATURE_LEANBACK), and the QR join scanner simply never gets offered on a camera-less box.
Verify a build with aapt2 dump badging (or aapt) on the APK: expect
leanback-launchable-activity and no uses-feature line with
required='true' for touchscreen, camera, wifi, or location.
Permissions
The permission list is deliberately short — store listings and F-Droid review both audit it, so every entry carries its reason in a manifest comment, and anything the merger pulls in unused is stripped. Runtime prompts only ever appear for the camera (QR join scanner), nearby devices/location (starting or joining the host's hotspot), and notifications (the room-presence service, API 33+).
What the app declares, and why:
INTERNET— the host runs an embedded WebSocket server on the LAN/hotspot and clients connect to it (also contributed by bonsoir).CAMERA— scanning the host's QR join ticket.CHANGE_WIFI_MULTICAST_STATE— receiving mDNS broadcasts while browsing for lobbies (bonsoir acquires the multicast lock).ACCESS_NETWORK_STATE,ACCESS_WIFI_STATE,CHANGE_WIFI_STATE,CHANGE_NETWORK_STATE— the host-provides-the-network tier:startLocalOnlyHotspotplus therequestNetworkapp-scoped join (getLinkPropertiesin the join callback is a fatalSecurityExceptionwithoutACCESS_NETWORK_STATE).NEARBY_WIFI_DEVICES(neverForLocation) — the API 33+ runtime grant forstartLocalOnlyHotspot.ACCESS_FINE_LOCATION(≤ API 32) — the same call's legacy runtime grant on older builds; nothing reads GPS.FOREGROUND_SERVICE,FOREGROUND_SERVICE_DATA_SYNC,POST_NOTIFICATIONS— the room-presence service below.VIBRATE— the turn ping plays a realVibrationEffectpattern.
What the manifest explicitly strips, and why: the merger ORs every
plugin's manifest into the APK, and camera_android_camerax (the
camera implementation flutter_zxing pulls in) declares permissions
for camera features the app never calls. Each gets a
tools:node="remove" in android/app/src/main/AndroidManifest.xml:
RECORD_AUDIO— camera's video-recording path; the scanner runsenableAudio: falseand it was never requested at runtime.WRITE_EXTERNAL_STORAGE(≤ API 28) — camera's photo-capture save path; the scanner only streams frames,takePicturenever runs.READ_EXTERNAL_STORAGE— implied by WRITE in the merged result; removed explicitly so no library can reintroduce it. The scanner's gallery button goes throughimage_picker's system photo picker, which grants per-URI access and needs no storage permission.
(dev.johnkneev.librebash.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
in the merged output is androidx boilerplate — signature-level, guards
unexported receivers, declared by the toolchain rather than us.)
To audit: aapt2 dump permissions on the APK lists the final grants,
and build/app/outputs/logs/manifest-merger-*-report.txt records
which library contributed every merged element.
Room presence foreground service
The app holds a dataSync foreground service while a room's
SessionLink lives (see Room presence in ARCHITECTURE.md), so the
manifest declares FOREGROUND_SERVICE,
FOREGROUND_SERVICE_DATA_SYNC, and POST_NOTIFICATIONS — the last is
a runtime prompt the acquire path raises on API 33+. Two limits worth
knowing: Android 15+ budgets dataSync services to roughly six hours
per 24-hour window (a party session won't reach it, but a marathon
room would silently lose its keep-alive), and Play Console may ask for
a foreground-service-type declaration when this ships — dataSync
covers "maintain an active LAN socket", which is the honest answer.
--dev writes android/dev-build.properties, which Gradle reads to swap
the applicationId (.dev suffix), launcher label, icon
(ic_launcher_dev, the inverted icon), and TV banner
(tv_banner_dev, the inverted banner) so dev builds install
side-by-side with release. It also implies --debuggable.
Releases and versioning
pubspec.yamlversion:is the source of truth:x.y.z+build. Tags are the same value (1.0.0+2), and release notes come from the## <tag>section ofCHANGELOG.md.- Android
versionCodeis the git commit count, not the pubspec build number.resolveVersionCode()inandroid/app/build.gradle.ktsprefersLIBREBASH_VERSION_CODE(CI exportsgit rev-list --count HEAD), then the localgit rev-list --count HEAD, thenflutter.versionCode. The commit count is monotonic, which is the invariant Play andINSTALL_FAILED_VERSION_DOWNGRADEcare about. - Linux bundles carry the same commit count — but not through
version.json.flutter_build_linuxpasses--build-number, yet fastforge's internalflutter buildreruns without it and restampsversion.jsonwith pubspec's+0, sopackage_info_plusreports nothing useful inside packaged builds. Instead,build-timestamp.jsoncarries the count as aBUILD_NUMBERdart-define (written bybuild.shand the CI jobs), andbuildNumberProviderprefers it over package_info_plus — the About dialog shows "Build N" exactly like Android, and multiplayer announcements report the same number on every platform. Comparing build numbers across devices is the quick check for a mixed-version room. - F-Droid readiness: there is one Android variant and it is already
FOSS-clean — no Play Services or non-free deps. If a future feature needs
a proprietary dependency, it must land behind a dedicated flavor like the
sibling project's
phone/fdroidsplit so a GMS-free build keeps shipping.
The website (GitLab Pages)
The project publishes a static website built from docs/ itself.
mkdocs.yml configures mkdocs-material: docs/README.md is the homepage
(it still reads as a normal readme when browsing the repo), each section's
README.md is its index page, and every docs file is listed under nav: —
mkdocs build --strict fails on a missing nav entry or a broken link, so
add new docs files to nav when you create them.
Two parts of docs/ are deliberately not on the site: PROJECT_PLAN.md
and adr/ are historical documents, excluded via exclude_docs in
mkdocs.yml and browsed in the repo instead. Because excluded pages 404
on the site, links to them from published pages must use absolute GitLab
URLs (https://gitlab.com/johnkneevsource/librebash/-/blob/main/docs/…),
never relative .md paths.
The pages job runs automatically on release tags — so the published
player guide describes the released app — and appears as a manual button
on main pipelines for shipping doc fixes between releases. It builds
public/ with mkdocs build --strict and uploads it as an artifact;
gitlab.com deploys any public artifact produced by a job named pages.
The job clears the squidfunk/mkdocs-material image's mkdocs entrypoint
(entrypoint: [""]) — without that, the runner's sh -c wrapper is
passed to mkdocs as arguments and the job fails with "No such command
'sh'" — and declares dependencies: [] since it builds purely from the
checked-out docs tree and has no use for earlier stages' artifacts. The
site logo (docs/assets/app_icon.png, shared as the favicon) is a copy of
the generated app icon committed to the repo, so the job needs no
generation step — which is just as well, since the image has no Dart SDK.
The site is served at https://librebash.johnkneev.dev, a custom domain
mapped under Deploy > Pages (CNAME librebash ->
johnkneevsource.gitlab.io plus GitLab's verification TXT record; the
Let's Encrypt certificate is automatic). The default *.gitlab.io Pages
domain keeps serving the same content alongside it.
To preview or check the site locally:
python3 -m venv .venv-mkdocs
.venv-mkdocs/bin/pip install mkdocs-material==9.7.7
.venv-mkdocs/bin/mkdocs build --strict # or: .venv-mkdocs/bin/mkdocs serve
Keep the pip pin and the squidfunk/mkdocs-material image tag in the
pages job on the same version.
Docs may use the enabled markdown extensions sparingly, but keep GitLab's own markdown rendering readable too — the same files are browsed in the repo.
Optional git hooks
./tool/install_git_hooks.sh sets core.hooksPath to .githooks/:
pre-commitformats staged Dart files (refuses partial-stage files it can't safely re-stage).pre-pushrunsflutter testfor the app and everypackages/*/test/.
Both are local conveniences; CI is the real gate. Bypass once with
--no-verify.
macOS and Windows
Platform folders exist from flutter create, and tool/generate.dart app
fills their icon slots, but neither is a build target yet — packaging for
them lands when there's a reason to ship there (the plan's initial targets
are Android and Linux). iOS needs a macOS runner; Windows needs a Windows
runner — both are sketched as commented jobs in the sibling project's CI if
we ever want the pattern.