Building & Distribution
CMake / Qt 6 build of the shipped C++ binary, per-platform packaging, the GitHub Actions matrix, and why the pipeline is shaped the way it is.
Quick Start
Every shipped binary (Windows .exe, macOS .dmg, Linux x86_64 and aarch64 AppImages, Android .apk, and the browser build at robdegeorge.com/OCTAVE/) is built from the C++ / Qt 6 tree in src/ with CMake. There is no .deb and no Flatpak (see Design Decisions).
# Configure + build (Debug, current platform)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
# Run
./build/octave # Linux (QML loads from the checkout's frontend/)
./build/octave --debug # DEBUG-level logging + octave-cpp-debug.log; --smoke exits after 5 s
# macOS: stage the frontend, then run
cp -R frontend build/octave.app/Contents/MacOS/
./build/octave.app/Contents/MacOS/octave # macOS bundle executable
# Windows PowerShell: stage the frontend, then run
Copy-Item -Recurse -Force frontend build/Debug/
./build/Debug/octave.exe # Windows MSVC multi-config
For the Windows multi-config and macOS bundle layouts, stage the runtime frontend before launching. In PowerShell, run Copy-Item -Recurse -Force frontend build/Debug/ for the Debug executable (use build/Release/ for Release). On macOS, run cp -R frontend build/octave.app/Contents/MacOS/. Refresh these copies after QML or asset changes. Linux's build/octave finds the checkout's frontend one level up.
cmake_minimum_required in CMakeLists.txt), Qt 6.7.3 (the CI-pinned version), a C++17 compiler (GCC 11+ / Clang 14+ / MSVC 2022), TagLib (required on every desktop platform — find_package(taglib CONFIG) from vcpkg on Windows, pkg-config taglib elsewhere; Android skips it and uses Qt's metadata fallback), and Ninja recommended on Linux/macOS (cmake -G Ninja). FFmpeg (libavcodec/libavutil), Qt Quick 3D, and desktop Qt WebEngineQuick (offline wiki reader) are optional — see CMake Options. Full per-platform package lists live in BUILD.md.
Design Decisions
The build and release pipeline has been rewritten several times. The reasons behind its current shape are recorded here so they are not re-litigated by accident. Sources: CLAUDE.md, comments in CMakeLists.txt and .github/workflows/build.yml, and the commit messages named below.
Why AppImage rather than .deb on Linux
v0.9 briefly shipped a .deb (commit fdb7d94). It "installed cleanly but launched a window-less process": the package staged the QML tree at usr/share/octave/frontend/, which the binary's applicationDirPath()/../frontend lookup in src/main.cpp never finds. Commit 14811c5 replaced the .deb job with packaging/linux/build-appimage.sh, whose AppDir layout mirrors the runtime lookup exactly (usr/bin/octave + usr/frontend/), and b9ca3be switched the docs. An AppImage also carries the pinned Qt 6.7.3 with it — distro Qt 6.2 on Ubuntu 22.04 cannot run the frontend at all (see CMake Options) — and the same script produces the aarch64 image for the Orange Pi with no packaging-format work per distro. Flatpak and app-store variants are not built.
AppStream metadata and AppImageHub
OCTAVE has an AppImageHub entry. The catalogue reads the name, licence, description and screenshots from the AppImage's AppStream file, packaging/linux/com.robdegeorge.octave.metainfo.xml, which the script copies to usr/share/metainfo/com.robdegeorge.octave.appdata.xml. The desktop file is named com.robdegeorge.octave.desktop so the metainfo's <launchable> resolves. The id matches the macOS bundle id. Screenshots point at the README images on main, so renaming or deleting one of those breaks the listing. Check edits with appstreamcli validate packaging/linux/com.robdegeorge.octave.metainfo.xml.
Why the Linux artifact matters more than the others
The vehicle head unit is an Orange Pi 5 Plus (aarch64). The cpp-build-linux-arm64 job exists specifically so that board gets a first-class binary built against the same Qt 6.7.3 as every other target (workflow comment: "the actual vehicle hardware"). It needed two extra decisions: Google publishes no aarch64 adb, so that build relies on the distro's adb on PATH, and the arm64 Qt package's GStreamer media plugin depends on libgstphotography, which jammy does not ship — OCTAVE uses Qt's FFmpeg media backend anyway, so the plugin is deleted after the CMake configure and before linuxdeploy-plugin-qt walks plugins/ (OCTAVE_DROP_QT_PLUGINS, commits 39dd25b and 10a6afa; it must be after configure because Qt's CMake config refuses to configure when a listed plugin file is missing).
Why the phone-server jar is committed
phone_server/ is OCTAVE's fork of the scrcpy server (Apache-2.0, forked at v3.3.4, package org.octave.phoneserver). Building it needs a JDK plus Android platforms;android-36 and build-tools;36.0.0 — a toolchain no desktop CI runner or Windows/macOS developer otherwise has. Committing the prebuilt tools/phone-server/octave-phone-server lets CMakeLists.txt embed it as a Qt resource (qrc:/phone-server/, "written to a temp file at runtime, so no packaging target has to carry it as a loose file"). The phone-server CI job rebuilds from source on pushes and pull requests as a compatibility check: it fails if either jar lacks the org.octave.phoneserver.Server class or the 3.3.4-octave version string, and only warns on a hash mismatch, because javac/d8 output differs between toolchain versions (commit 2a971ff: the first strict byte comparison failed when CI built with JDK 17 against a JDK 21 jar — hence the job pins JDK 21).
Why adb is bundled but not scrcpy
Commit 7a8c8b8 removed every external phone-mirror path (scrcpy binary, v4l2loopback, window grab, about 3,000 lines). OCTAVE speaks the scrcpy protocol itself, so the only third-party pieces are the phone server above and adb. scripts/fetch_platform_tools.py downloads Google's pinned platform-tools (37.0.1, SHA-256 verified) and keeps only the files adb needs plus Google's NOTICE.txt; CI ships that directory next to the executable so users install nothing.
Why one artifact naming scheme
Commit d7812ff unified four conventions into OCTAVE-<version>-<platform>[-<arch>].<ext> with the leading v stripped from the tag. The Inno Setup OutputBaseFilename, the macdeployqt rename step, the AppImage script and the APK staging copy all follow it, and the workflow's artifact globs and release file list depend on it.
Why macOS builds on macos-14
The macOS 15 SDK on macos-latest drops the AGL framework that Qt 6.7.3's OpenGL support still links (ld: framework 'AGL' not found). The job is pinned to macos-14 / Xcode 15 until Qt is bumped to 6.8+ (commit f559afc).
Why Qt 6.7.3 specifically
Every CI job pins version: '6.7.3', and the local build instructions use that version to match. This is a project toolchain choice, not a limit on public Qt availability: Qt also publishes Qt 6.8.3 packages. Upgrading requires checking the platform kits, QML modules, deploy tools, and Android Gradle configuration together.
CMake Options & Layout
CMakeLists.txt declares project(OCTAVE VERSION 0.9.5); the version is compiled in as OCTAVE_VERSION. The APK takes it from there too: QT_ANDROID_VERSION_NAME is the version string and QT_ANDROID_VERSION_CODE is major×10000 + minor×100 + patch (0.9.5 → 905; Android only installs a higher code over an installed APK), filled into the manifest's %%INSERT_VERSION_*%% placeholders by androiddeployqt. Only build_scripts/installer_windows.iss (MyAppVersion) carries its own copy, which CI patches from the tag; bump it together with project(). Every source file is listed explicitly in the qt_add_executable(octave ...) call — a new manager means two new lines there.
| Option / define | Default | Effect |
|---|---|---|
OCTAVE_HAVE_FFMPEG | auto-detected | Set when libavcodec / libavutil are found (on Windows find_path(libavcodec/avcodec.h) + find_library(avcodec / avutil) through the vcpkg toolchain, because vcpkg's FindFFMPEG wrapper leaves FFMPEG_FOUND false and v0.9.4 shipped without the decoder; pkg-config libavcodec libavutil elsewhere; never on Android). Provides the H.264 decoder (src/util/h264decoder.{h,cpp}) for the built-in phone-mirror client and, with OpenSSL, the built-in Android Auto receiver; without it OCTAVE still builds but phoneMirrorManager.nativeAvailable is false. CMake prints Built-in phone mirror decoder (libavcodec): ON/OFF at configure time. |
OCTAVE_HAVE_NATIVE_AA | auto-detected | The built-in Android Auto receiver (src/android_auto/, Built-in Android Auto). Set when OCTAVE_HAVE_FFMPEG is on and find_package(OpenSSL COMPONENTS SSL Crypto) succeeds, on desktop only (never on Android, iOS or WebAssembly); OpenSSL is optional, there is no option to require it. Compiles src/android_auto/, links OpenSSL::SSL / OpenSSL::Crypto and defines OCTAVE_HAVE_NATIVE_AA; without it androidAutoManager.nativeAvailable is false and Android Auto offers only the Google DHU backend. Also gates the tst_androidauto test. CMake prints Native Android Auto receiver (OpenSSL + libavcodec): ON/OFF at configure time. Packages: Debian / Ubuntu libssl-dev (already in the CI list below), Arch openssl, Homebrew openssl@3 (keg-only: pass -DOPENSSL_ROOT_DIR=$(brew --prefix openssl@3) if CMake doesn't find it), vcpkg openssl. The head-unit certificate it needs at run time is never part of the build. |
OCTAVE_DEV_TOOLS | ON for CMAKE_BUILD_TYPE=Debug desktop builds; OFF otherwise (any other or no build type, Android, iOS) | Compiles src/dev/ into the binary and defines OCTAVE_DEV_TOOLS: --dev (the --sim simulator of src/sim/, which every build has, plus the dev overlay) and --profile (perf monitor + command server on 127.0.0.1:9876, which the MCP / screenshot / perf tooling drives). Links Qt Test for synthetic mouse input. Off, the flags are ignored with a warning and OCTAVE_PERF_SCOPE timings compile to nothing, so release artifacts carry none of it. A build dir configured without a build type (the head unit's, multi-config generators such as Visual Studio) gets it off; pass -DOCTAVE_DEV_TOOLS=ON to force it. See Development. |
OCTAVE_BUILD_TESTS | ON for CMAKE_BUILD_TYPE=Debug desktop builds; OFF otherwise | Adds tests/cpp/ (QtTest executables, enable_testing()) so ctest --test-dir build runs them; tst_obdsim also needs OCTAVE_DEV_TOOLS, and sdk_python is registered only when CMake finds a Python 3 interpreter. CI's cpp-compile-check runs the suite. See Development. |
OCTAVE_REQUIRE_FFMPEG | OFF | Turns a missing libavcodec / libavutil into a configure error instead of a build without phone mirroring. CI passes -DOCTAVE_REQUIRE_FFMPEG=ON in cpp-compile-check and every desktop release job (Windows, macOS, both AppImages; the AppImage script forwards it through OCTAVE_CMAKE_ARGS), never on Android. |
OCTAVE_FFMPEG_ROOT | empty (use the FFmpeg pkg-config finds) | Linux / macOS: link the static, LGPL FFmpeg (H.264, AAC and ALAC decoders only) that packaging/ffmpeg/build-h264-decoder.sh installed into this prefix instead of the system one. The Linux and macOS release jobs set it, so no distro / Homebrew FFmpeg (GPL, with x264 / x265 / xvid) ends up in a package. On Linux the static symbols are kept out of the dynamic symbol table (--exclude-libs) so Qt's media plugin can't bind to them. |
Qt6Quick3D_FOUND | probed (find_package(Qt6 QUIET COMPONENTS Quick3D)) | Backs the 3D vehicle view in CarMenu.qml. Optional: the QML is loaded lazily and SensorHome greys out the "3D View" card when the module is absent. Linking it when present is what makes windeployqt / macdeployqt / androiddeployqt / linuxdeploy bundle the QML module. When found, CMake writes build/qt_plugins_path.txt so the AppImage script can copy the assetimporters plugin dir (linuxdeploy-plugin-qt does not know that category). |
Q_OS_MOBILE | defined when ANDROID | Activates the existing #ifndef Q_OS_MOBILE guards so desktop-only code is not compiled on mobile (iOS would reuse it). |
SQLite for the black box is compiled in from the public-domain amalgamation in third_party/sqlite/ (the octave_sqlite static library; third_party/sqlite/README.md has the version and hash). It deliberately does not go through Qt Sql: that module loads its SQLite driver as a separate runtime plugin, which Debian / Ubuntu ship as a separate package (libqt6sql6-sqlite) that the first in-car build did not have, so two drives went unrecorded with "Driver not loaded" in the log. Nothing to install or deploy now, on any platform.
Qt components linked: Core, Gui, Qml, Quick, QuickControls2, Multimedia, Network, Concurrent, SerialPort, Bluetooth; on Android additionally Sensors and Qt6::CorePrivate (for QtAndroidPrivate::requestPermission). The QML frontend additionally needs QtQuick.Effects (Qt ≥ 6.5) and Qt5Compat.GraphicalEffects at runtime (both imported by frontend/MainMenu.qml) — distro Qt 6.2 (Ubuntu 22.04) links fine and then fails at Main.qml load, which is why every CI job installs the qt5compat qtquick3d qtshadertools qtquicktimeline modules. qtquicktimeline is required by Quick3D's AssetUtils library (QtQuick3D.AssetUtils, the RuntimeLoader behind the music player objects in MediaRoom): when Quick3D is found, CMake also looks for Quick3DAssetUtils and QuickTimeline and links both, so every deploy tool bundles them, and warns at configure time if they are missing. The v0.9.1 APK, built from a kit without qtquicktimeline, died at launch with libQt6QuickTimeline_arm64-v8a.so not found, and the x86_64 AppImage shipped the AssetUtils QML plugin without its library; the Android job and packaging/linux/build-appimage.sh now fail the build if that combination recurs.
Resources vs. filesystem
The compiled Qt resources include the .qsb shader frontend/shaders/dissolve_squares.frag.qsb (qrc:/shaders/ — Qt RHI's ShaderEffect rejects assets:/ URIs on Android, so QML branches on Qt.platform.os) and the phone-server jar with its LICENSE / NOTICE (qrc:/phone-server/). Desktop builds with Qt WebEngineQuick also embed the wiki at qrc:/wiki/; the reader prefers a local wiki beside the frontend when available. Everything else in frontend/ is loaded from the filesystem at runtime, so QML edits need no rebuild. src/main.cpp resolves it as applicationDirPath()/frontend, then applicationDirPath()/../frontend (the build/ case), and on Android as assets:/frontend; every packaging layout must preserve that relationship.
Windows Build
WindowsRequirements
- Visual Studio 2022 with the "Desktop development with C++" workload
- Qt 6.7.3 (
msvc2019_64ormsvc2022_64) via the Qt online installer; CI useswin64_msvc2019_64with modulesqtmultimedia qtserialport qtconnectivity qtquick3d qt5compat qtshadertools qtquicktimeline(qtquicktimelineis needed by Quick3D's AssetUtils library; windeployqt also fails on QtQuick3D without it) - vcpkg for
taglib:x64-windowsandffmpeg[core,avcodec]:x64-windows - (CI only) Inno Setup 6 — pre-installed on
windows-latestasISCC.exe
cmake -S . -B build `
-DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" `
-DVCPKG_TARGET_TRIPLET=x64-windows `
-DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release -j
Output and CI packaging
build\Release\octave.exe— the built executable (multi-config MSVC; single-config generators drop it inbuild\)- CI stages
dist\OCTAVE\with the executable, TagLib and FFmpeg DLLs, adb, and Qt dependencies fromwindeployqt. It copies the Visual C++ runtime DLLs beside the executable, thenpackaging/windows/resolve_dlls.pychecks import tables and adds missing dependencies from vcpkg or Qt. Unresolved DLLs fail the build. - The frontend is copied to
dist\OCTAVE\frontend, and the executable is renamedOCTAVE.exeto match the installer. CI then launches it with--smokeand a restrictedPATH, requiring exit code 0 within 90 seconds and the log lineSmoke test: phone mirror native: 1inoctave-cpp.log(libavcodec compiled in and the bundled adb found), plusSmoke test: libavcodec <version> license: LGPL…(vcpkg's FFmpeg must stay an LGPL build). This verifies startup and the phone-mirror prerequisites, not every application feature. The avcodec / avutil DLL copy fails the build if they are missing; only swresample is optional. A second smoke step runs the same files with--headless --sim --smoke --service 0and requires the log lines a working simulated car and service produce (Simulated car on sim://elm327,ELM327 initialised, vehicle answered,objects served on port,Smoke test: headless core up), so a release cannot ship without the simulator a frontend author starts from; the AppImages (x86_64, and aarch64 on its native arm runner, extracted so FUSE is not needed) and the macOS app get the same from packaging/smoke-sim.sh before upload. dist\OCTAVE-<version>-windows-x86_64.exe— Inno Setup installer. CI patches#define MyAppVersion(checked in as0.9.5) in build_scripts/installer_windows.iss from the git tag. A manual dispatch runs on a branch, so it uses the fallback0.9.5; before 0.9.4 the branch name ("main") leaked into the installer's version. This happens before runningISCC.exe. The installer has no VC++ runtime check: the old one showed a plainMsgBox, which/SUPPRESSMSGBOXEScannot silence, so every/VERYSILENTinstall hung on an invisible dialog. The installer runs in 64-bit mode (ArchitecturesInstallIn64BitMode=x64compatible), so OCTAVE goes toProgram Filesand its uninstall key to the 64-bit registry view. Earlier installers ran in 32-bit mode and usedProgram Files (x86). So atssInstall,MigrateOld32BitInstalldeletes an old(x86)\OCTAVEfolder and its 32-bit-view uninstall key; otherwise an upgrade would leave a second copy and a second Add/Remove entry. It never runs the old uninstaller, whose[UninstallDelete]would wipe the user's settings in%APPDATA%\OCTAVE. CMake builds the Windows target as a GUI program (WIN32_EXECUTABLE), so no console window opens behind the app.
build_scripts/installer_windows.iss is the only file left in build_scripts/. It installs to Program Files (admin, Windows 10+), creates Start Menu / optional desktop shortcuts, uses the app-local VC++ runtime DLLs staged by CI, adds a Windows Firewall rule "OCTAVE adb" allowing the bundled platform-tools\adb.exe inbound on private networks (admin installs only; deleted and re-added on upgrade, removed on uninstall) so the first QR pairing shows no firewall prompt, and — note — its [UninstallDelete] section removes %APPDATA%\OCTAVE (settings, dashboards, logs) on uninstall.
macOS Build
macOSRequirements
- Xcode Command Line Tools (
xcode-select --install) - Qt 6.7.3 (Apple Silicon and Intel), e.g.
aqt install-qt mac desktop 6.7.3 -O ~/Qt -m qtmultimedia qtserialport qtconnectivity qtsensors qtquick3d qt5compat qtshadertools qtquicktimeline brew install taglib ffmpeg pkg-configfor tag reading and the phone-mirror decoder (the release job builds its own decoder instead of Homebrew's ffmpeg:bash packaging/ffmpeg/build-h264-decoder.sh ~/ffmpeg-h264, then-DOCTAVE_FFMPEG_ROOT=~/ffmpeg-h264)
export PATH="$HOME/Qt/6.7.3/macos/bin:$PATH"
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
Output
build/octave.app— CMakeLists.txt setsMACOSX_BUNDLEexplicitly (bundle idcom.robdegeorge.octave, nameOCTAVE); without itqt_add_executablewould emit a standalone executable that macdeployqt cannot bundledist/OCTAVE-<version>-macos.dmg— CI renames the bundle toOCTAVE.app, copiesadbintoContents/Resources/platform-tools/, runsmacdeployqt dist/OCTAVE.app -qmldir=frontend -dmg(macdeployqt creates the DMG itself) and renames the result (version fallback0.0.0)
Linux Build
LinuxRequirements
- Build essentials, CMake, Ninja, pkg-config, and the X11/XCB dev libraries listed in BUILD.md
- Qt 6.7.3 via the Qt online installer or
aqtinstall(aqt install-qt linux desktop 6.7.3 linux_gcc_64 ...;linux_arm64for ARM boards). Distro Qt 6.2 cannot run OCTAVE — see CMake Options - For a local AppImage on Ubuntu 22.04:
libtag1-dev libavcodec-dev libavutil-dev libfuse2 wget file; to match the release build, addnasm, runbash packaging/ffmpeg/build-h264-decoder.sh ~/ffmpeg-h264and passOCTAVE_CMAKE_ARGS=-DOCTAVE_FFMPEG_ROOT=$HOME/ffmpeg-h264(thenlibavcodec-dev/libavutil-devare not needed)
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
# Optional: portable AppImage (same layout CI ships, linked against your distro's glibc)
bash packaging/linux/build-appimage.sh
# -> dist/OCTAVE-<version>-linux-x86_64.AppImage (or -linux-aarch64 on an ARM host)
Output
build/octave— the built executable; QML and assets load from the checkout'sfrontend/dist/OCTAVE-<version>-linux-x86_64.AppImage/-linux-aarch64.AppImage— portable single-file AppImages. The version comes fromOCTAVE_VERSION(CI passes the tag), elseGITHUB_REF_NAME, else theproject()version in CMakeLists.txt
AppImage structure
packaging/linux/build-appimage.sh does a Release Ninja build into build-appimage/, stages AppDir/, then runs linuxdeploy + linuxdeploy-plugin-qt. Both tools are downloaded on first use from their continuous release tag into packaging/linux/tools/ (gitignored, as are build-appimage/, AppDir/ and dist/). The layout must match the runtime asset lookup in src/main.cpp:
AppDir/
AppRun # custom: exports LD_LIBRARY_PATH, QT_PLUGIN_PATH and
# QML2_IMPORT_PATH=usr/qml:usr/frontend (linuxdeploy's own AppRun only knows usr/qml)
usr/bin/octave # applicationDirPath()
usr/bin/platform-tools/ # adb, if scripts/fetch_platform_tools.py ran first
usr/frontend/ # ../frontend relative to the binary -- the QML tree
# (assets/-src and assets/readme are stripped: not runtime assets)
usr/lib/, usr/plugins/, usr/qml/ # Qt runtime deployed by linuxdeploy-plugin-qt
usr/plugins/assetimporters/ # copied by the script from the path CMake recorded (Quick3D's assimp loader)
Other details of the script: it bundles libtag.so explicitly via --library because linuxdeploy occasionally misses it (avoiding an awk ... exit SIGPIPE that set -o pipefail would turn into an abort — commit 0ab7f14), reuses a project icon if one exists or writes a placeholder SVG, sets QML_SOURCES_PATHS so the Qt plugin scans usr/frontend for imports, and honours QMAKE (CI points it at install-qt-action's qmake6 so the plugin does not fall back to a system Qt 5 stub) and OCTAVE_DROP_QT_PLUGINS (space-separated paths under Qt's plugins/ to delete after the CMake build and before deployment). Putting the QML tree anywhere else breaks the lookup and the app exits silently with no window. On Arch the bundled strip rejects modern .relr.dyn sections — pass NO_STRIP=1. AppImages built on Arch will not run on Ubuntu/Debian (newer glibc); let CI on ubuntu-22.04 produce distributable ones.
Desktop launcher (development machines)
scripts/octave-desktop.sh builds and launches the native app straight from a Dropbox-synced checkout (a ~/.local/share/applications/octave-dropbox.desktop menu entry points at it; BUILD.md describes the Mod+D binding). It configures once into build-desktop-<hostname>/ (Release) so two machines syncing the same tree never share a CMake cache, then runs an incremental cmake --build --target octave --parallel 4 (bounded so a desktop launch cannot exhaust memory) and execs the binary. Since commit 037f753 the configure step passes -DQt6_DIR="$(/usr/bin/qmake6 -query QT_INSTALL_LIBS)/cmake/Qt6" because the CMake package registry could otherwise select an installed Android kit for the desktop build. A flock on ~/.local/state/octave/desktop-build.lock stops two menu clicks from building concurrently; failures raise a desktop notification and the log is ~/.local/state/octave/desktop-launch.log ($XDG_STATE_HOME honoured). With OCTAVE_FRESH=1 (the OCTAVE (Fresh) menu entry, octave-fresh.desktop) the app runs with HOME and the XDG dirs in a new empty /tmp/octave-fresh.* folder, deleted on exit, so every launch is a first run with no settings, library or history. .gitignore covers build/, build-desktop-*/, build-rhea/, build-android/ and build-appimage/.
The Orange Pi head unit's launcher and the rest of its system config live in deployment/orange-pi-5-plus/ (see Reference Install). Its home/.local/bin/octave-start.sh is started by Hyprland's exec-once: it runs the native C++ build from ~/octave-cpp/build, routes audio to the 3.5 mm jack, appends stdout/stderr to ~/.local/state/octave/console.log, and restarts OCTAVE after a crash (a clean exit ends the loop; it gives up after 5 crashes in 3 minutes). octave-rebuild-start.sh next to it rebuilds the C++ clone and restarts on the new binary. The old scripts/octave-start.sh and scripts/octave-cpp-start.sh are now stubs that point there. scripts/octave-update.sh is legacy: it manages a separate checkout under /home/rob/Roxy, not the C++ clone the Hyprland launcher runs, and was written for the earlier i3 session. octave-update.sh keeps updates from ever delaying boot or changing files under a running app. --apply runs at boot, before the start script: offline and instant, it resets to an origin/main fetched earlier (not if tracked files have local edits). --fetch runs in the background: it waits up to 15 minutes for the network (Wi-Fi is rarely up when i3 starts, which is why the old one-shot ping check logged "No internet connection" on every boot), then downloads origin/main for the next boot. It only downloads, because OCTAVE loads some QML on demand and swapping files mid-session could mix versions. With no argument it fetches and resets immediately, for manual use. Both are started from the i3 config (exec ... octave-update.sh --apply && ... octave-start.sh; &&, not ;, which i3's command parser treats as a command separator and exec ... octave-update.sh --fetch), log to /home/rob/Roxy/update.log, and always exit 0 so OCTAVE starts regardless. Paths default to /home/rob/Roxy and can be overridden with OCTAVE_DIR / LOG_FILE.
GitHub Actions CI/CD
.github/workflows/build.yml (single workflow, "Build OCTAVE")
Triggers
- Every push / PR to
main:lint,cpp-compile-checkandphone-server(independent of each other) - Version tags
v*or manualworkflow_dispatch: the fivecpp-build-*jobs, each gated onlintandcpp-compile-check(so a failing ctest blocks a release); the desktop ones smoke-test their packaged artifact with--headless --sim --smokebefore uploading it - Version tags only:
release, which needs all five build jobs
Jobs
| Job | Runner | What it does / artifact | Retention |
|---|---|---|---|
| lint | ubuntu-22.04 | Python 3.11 + ruff==0.15.10; ruff check . over the repo's Python tools (scripts/, packaging/; settings in pyproject.toml), then scripts/gen_gauge_param_docs.py and git diff --exit-code docs/GAUGE_AUTHORING.md (the generated parameter table must match the catalog). Blocking. | — |
| cpp-compile-check | ubuntu-22.04 | apt build deps + Qt 6.7.3 (jurplel/install-qt-action@v4, cached, modules qtmultimedia qtserialport qtconnectivity qt5compat qtquick3d qtshadertools qtquicktimeline), cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug + build, ctest --output-on-failure (offscreen; it includes tst_apidocs, which fails when the committed docs/api/ no longer matches the build, sdk_python, the Python SDK against --headless with the runner's Python 3, and, with Node 22 and Go 1.22 set up first, sdk_go, template_js, template_python and template_go), then qmllint -I frontend --json over every frontend/**/*.qml. qmllint exits non-zero on plain warnings, so the step runs it with find -exec ... + (not xargs, which would abort on exit 255), parses the JSON and fails only on "type": "critical" entries (syntax errors, unresolvable imports) | — |
| phone-server | ubuntu-22.04 | Temurin JDK 21, android-actions/setup-android@v3, platforms;android-36 + build-tools;36.0.0; runs phone_server/build_without_gradle.sh, then checks both the fresh jar and tools/phone-server/octave-phone-server contain org/octave/phoneserver/Server and 3.3.4-octave (hard failure), prints both SHA-256s and emits a ::warning:: if they are not byte-identical; uploads the fresh build as octave-phone-server | 30 days |
| cpp-build-windows | windows-latest | OCTAVE-Windows-cpp: OCTAVE-<v>-windows-x86_64.exe (Qt win64_msvc2019_64, vcpkg taglib + ffmpeg, adb fetch, windeployqt, Inno Setup; if-no-files-found: error) | 30 days |
| cpp-build-macos | macos-14 | OCTAVE-macOS-cpp-App (the .app directory) and OCTAVE-macOS-cpp-DMG: OCTAVE-<v>-macos.dmg (Homebrew taglib/ffmpeg, adb fetch, macdeployqt -dmg) | 30 days |
| cpp-build-linux | ubuntu-22.04 | OCTAVE-Linux-cpp-AppImage: OCTAVE-<v>-linux-x86_64.AppImage (adb fetch, build-appimage.sh with QMAKE and OCTAVE_VERSION set) | 30 days |
| cpp-build-linux-arm64 | ubuntu-22.04-arm | OCTAVE-Linux-arm64-cpp-AppImage: OCTAVE-<v>-linux-aarch64.AppImage (Qt host linux_arm64 + qtquicktimeline, extra libavformat/libswresample/libswscale and GStreamer runtime libs, OCTAVE_DROP_QT_PLUGINS=multimedia/libgstreamermediaplugin.so, no adb fetch) | 30 days |
| cpp-build-android | ubuntu-22.04 | OCTAVE-Android-APK: OCTAVE-<v>-android-arm64-v8a.apk — see Android for the full step list | 30 days |
| release | ubuntu-latest | softprops/action-gh-release@v2 with generate_release_notes: true, prerelease: true and contents: write; attaches the five installers (the macOS .app artifact stays on the run page to keep the Releases page tidy). The release is published as a pre-release: public, so the clean-install test VMs can fetch the exact published files without auth, but not "Latest". Once those files are verified, promote it with gh release edit vX.Y.Z --prerelease=false --latest and replace the generated notes with hand-written ones (--notes-file). | Permanent |
Linux system dependencies
The compile-check job installs the list below. The x86_64 AppImage job installs the same minus libavcodec-dev libavutil-dev, plus nasm libfuse2 wget file: it builds the phone-mirror decoder with packaging/ffmpeg/build-h264-decoder.sh (static LGPL FFmpeg 9.0.2, H.264, AAC and ALAC decoders only; the macOS and aarch64 jobs do the same) and fails if the AppDir holds any shared FFmpeg or x264 / x265 / xvid library.
sudo apt-get install -y \
build-essential cmake ninja-build pkg-config \
libtag1-dev libavcodec-dev libavutil-dev libgl1-mesa-dev libegl1-mesa-dev \
libxcb-xinerama0 libxcb-cursor0 libxkbcommon-dev \
libpulse-dev libssl-dev libplist-dev
libplist-dev (with OpenSSL and libavcodec) turns on the AirPlay receiver (OCTAVE_HAVE_AIRPLAY, Linux only): without it the build still succeeds and airPlayManager.available is false.
The aarch64 job additionally installs libavformat-dev libswresample-dev libswscale-dev libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer-plugins-bad1.0-0, because the arm64 Qt package does not bundle ffmpeg libraries and linuxdeploy-plugin-qt resolves the multimedia plugin's and QtQuick3D's dependencies at deploy time. That plugin links Ubuntu's GPL FFmpeg 4.4 (libavcodec58, with x264 / x265 / xvid), so the aarch64 AppImage still bundles it for music playback; NOTICE says so and where its source is. OCTAVE's own decoder there is the static LGPL build.
Release job
Runs only on version tags, after all five build jobs. It downloads their artifacts into artifacts/ and creates a GitHub Release with auto-generated notes, attaching OCTAVE-*-windows-x86_64.exe, OCTAVE-*-macos.dmg, both AppImages and the APK. There is no nightly pipeline; releases are tested by hand as release candidates before tagging.
Android APK Build
AndroidThe Android .apk is built from the C++ / Qt 6 tree using Qt's qt-cmake cross-compile wrapper (or a plain cmake with Qt's qt.toolchain.cmake, as CI does). Platform-specific behaviour is gated behind #ifdef Q_OS_ANDROID in the managers, with the Java side in android/src/org/octave/app/ (OctaveOBDBridge.java for Bluetooth/BLE OBD, OctaveMediaBridge.java for track metadata, embedded album art and the app's own Music dir).
qt-cmake -S . -B build-android \
-DCMAKE_BUILD_TYPE=Debug \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-30 \
-DQT_ANDROID_SIGN_APK=OFF
cmake --build build-android --target apk -j
adb install build-android/android-build/build/outputs/apk/debug/android-build-debug.apk
Packaging facts from CMakeLists.txt and android/: package org.octave.app; single ABI arm64-v8a; minSdk 30, targetSdk 33 (capped by AGP 7.4.1, Qt 6.7.3's target Gradle plugin — bump with Qt 6.8+); QT_ANDROID_PACKAGE_SOURCE_DIR = android/; QT_QML_ROOT_PATH = frontend/ so the runtime-loaded QML is findable at assets:/frontend/; android/assets/frontend is a symlink into the repo for development. Two things androiddeployqt would not bundle on its own are added explicitly: OpenSSL 3 (android/openssl/<abi>/libcrypto_3.so, libssl_3.so, prebuilt from KDAB's android_openssl, via QT_ANDROID_EXTRA_LIBS — Qt ships only the TLS plugin, and without these every QSslSocket failed with "No functional TLS backend was found") and Quick3D's assetimporters plugin directory (the Assimp glTF importer behind RuntimeLoader, via QT_ANDROID_EXTRA_PLUGINS — without it every .glb failed with "Unsupported: Unknown error" in v0.9.2). main.cpp logs "Android TLS: …" at startup, and the Android CI job fails if the APK lacks either. The offline wiki reader needs the qtwebview module in the Android kit (linked as Qt6::WebView, OCTAVE_HAVE_WEBVIEW) and the wiki itself in the APK: android/assets/wiki is a symlink to wiki/, dereferenced by the CI job like the frontend link; the APK check also fails without libQt6WebView or assets/wiki/index.html. The compile-check kit installs qtwebview too so qmllint can resolve import QtWebView in WikiNativeView.qml. LGPL Qt is dynamically linked.
What the CI job does
- Temurin JDK 17 (OCTAVE's CI choice; AGP 7.4 lists JDK 11 as its minimum), host Qt 6.7.3 (
linux/desktop) and target Qt 6.7.3 (android_arm64_v8a), with the modules listed in the workflow; the Android kit includesqtwebviewfor the offline wiki android-actions/setup-android@v3, thenndk;26.1.10909125,platforms;android-33,build-tools;33.0.2- Restores a Gradle cache keyed on
android/build.gradle+gradle.propertiesso repeat runs survive Maven Central's intermittent 429s - Dereferences the
android/assets/frontendandwikisymlinks into real copies — APKs cannot contain symlinks; the entries become 0-byte files and the app dies with "Failed to load Main.qml" cmake -S . -B build-android -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE=$QT_ROOT_DIR/lib/cmake/Qt6/qt.toolchain.cmake -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-30 -DQT_HOST_PATH=$HOST_QTcmake --build build-android --target apk -j, retried up to four times with 20 s doubling backoff (Maven Central rate-limits runner IPs on dependency fetches)- Signs the unsigned release APK with the permanent release key: the repository secrets
ANDROID_KEYSTORE_BASE64,ANDROID_KEYSTORE_PASSWORDandANDROID_KEY_ALIAS. Updates can install over an earlier APK signed with the same key. Runs without the secrets (forks) generate a throwaway key and log a warning. The step decodes the keystore, runszipalign -p 4andapksigner sign(password passed viaenv:, never on the command line) with v1/v2/v3 requested, thenapksigner verify --print-certs, deletes the keystore and stagesdist/OCTAVE-<v>-android-arm64-v8a.apk(version fallbackdev)
INSTALL_FAILED_UPDATE_INCOMPATIBLE); use the same signing key or back up the app data before uninstalling. The older signing and OpenSSL TODO files describe work now implemented in the workflow and CMake configuration.
aqtinstall, OpenJDK 17, Android SDK + NDK 26.1.10909125, the environment variables (QT_HOST_PATH is mandatory for the cross-compile), device USB debugging and a troubleshooting table. A condensed walkthrough is on the Getting Started page; the port's remaining work is in TODO/android-cpp-port.md.
WebAssembly (Browser) Build
OCTAVE runs in the browser at robdegeorge.com/OCTAVE/ as a demo: the simulated car, every page, no hardware. It is a Qt for WebAssembly build of the same C++ tree, published by .github/workflows/wiki-pages.yml together with this wiki (Development) on every push to main that touches src/, frontend/, CMakeLists.txt, packaging/wasm/ or wiki/. It is not part of the release matrix.
Toolchain
Qt 6.12.0 wasm_multithread plus a matching desktop kit for the host tools (moc, qsb, balsam, qmlimportscanner), both with the modules qt5compat qtmultimedia qtquick3d qtquicktimeline qtwebsockets qtshadertools, and emsdk 5.0.5 (QT_EMCC_RECOMMENDED_VERSION for Qt 6.12; the build warns on any other version).
aqt install-qt all_os wasm 6.12.0 wasm_multithread -O ~/Qt -m qt5compat qtmultimedia qtquick3d qtquicktimeline qtwebsockets qtshadertools
aqt install-qt linux desktop 6.12.0 linux_gcc_64 -O ~/Qt -m qt5compat qtmultimedia qtquick3d qtquicktimeline qtwebsockets qtshadertools
git clone https://github.com/emscripten-core/emsdk ~/emsdk && ~/emsdk/emsdk install 5.0.5 && ~/emsdk/emsdk activate 5.0.5
Build
packaging/wasm/build.sh is what CI runs and what to run locally. It sources emsdk_env.sh if emcc is not on PATH, configures with qt-cmake (Release, Ninja, OCTAVE_BUILD_TESTS / OCTAVE_DEV_TOOLS off), builds, and with OUT_DIR stages the site: octave.js, octave.wasm, octave.data, qtloader.js and the page files from packaging/wasm/.
QT_WASM=~/Qt/6.12.0/wasm_multithread QT_HOST=~/Qt/6.12.0/gcc_64 \
OUT_DIR=/tmp/octave-site packaging/wasm/build.sh
# variables: QT_WASM, QT_HOST (or QT_HOST_PATH), BUILD_DIR (build-wasm), OUT_DIR, EMSDK (~/emsdk), JOBS
What the target changes
Everything is gated on CMake's EMSCRIPTEN, which defines OCTAVE_WASM and Q_OS_MOBILE:
- Qt SerialPort and Bluetooth and FFmpeg are dropped; TagLib 2.3.2 is built from its release tarball (
FetchContent, pinned by SHA-256) and its headers staged flat in build-wasm/taglib_include/taglib/. src/platform/wasm/ holds the stand-ins: aQSerialPort/QSerialPortInfowhoseopen()always fails, obdadapterfinder_wasm.cpp (Web Bluetooth, below) and an inert networkmanager_wasm.cpp used instead of the real sources (HEADER_FILE_ONLYon the originals).QProcessusers (the settings manager's git/curl, the OBD manager destructor) sit behind#if QT_CONFIG(process); the IMU manager and the simulator's IMU feed take their desktop (no I²C) branch. -sDISABLE_EXCEPTION_CATCHING=0: Assimp's glTF1 probe throws on.glb, so C++ exceptions stay enabled.- frontend/ is staged by packaging/wasm/stage_frontend.cmake into build-wasm/wasm_fs (a build step whose stamp is in
LINK_DEPENDS, so a QML edit relinks) and preloaded at/frontendin the page's in-memory filesystem: that isoctave.data. The README screenshots, assets/-src and the full-sizejeep_tj_2003.glbare excluded. - src/platform/wasm/webdemo.{h,cpp} (
WebDemo, built by main.cpp under#ifdef OCTAVE_WASM): the page opens with no car connected (OBD Home shows the connect prompt), and the adapter sheet lists "Simulated car" beside Web Bluetooth. Picking it starts the simulator of src/sim/ (the same one--simruns): the realOBDManageron itsCarSim+SimElm327(portsim://elm327, realistic mode off) and the IMU fed by itsImuFeed, then runs an autopilot: idle with throttle blips, pull away to a 35–70 mph target, cruise, coast, brake to a stop. - src/platform/wasm/webbluetooth.{h,cpp}: a real Bluetooth LE ELM327 over Web Bluetooth (Chrome and Edge, desktop and Android, Chrome / Chromium on Linux with the experimental web platform features flag; not Safari, iOS or Firefox).
WebBluetoothLink(main thread) drives a small JS helper (Module.octaveWebBt: the browser's device chooser, GATT connect, notifications, writes in 20-byte chunks, one GATT operation at a time: with response where the characteristic allows it, since a write without response settles before Android has sent it and the next one was refused with "GATT operation already in progress"; that error is retried) over the ISSC, FFF0, FFE0, 18F0 and vLinker services. AQIODevicefor portwebbt://<id>, added withOBDConnectionWorker::addCustomTransport(), is what the OBD worker thread reads; opening it while the link is down starts a reconnect to the last adapter (no new pick needed) and fails at once, so the worker never waits on the main thread andOBDManager's next retry finds the link up; its retries survive an ignition cycle. An adapter with more than one of those services (the NEXAS NexLINK has ISSC and FFF0, and its ISSC side never answered) gets each probed withATIand the first that answers (its whole reply, up to the>prompt) is used, the one that answered last time on that adapter first (remembered inlocalStorage); the probes and the characteristics in use appear in the log. A connect that Android's GATT stack refuses as busy ("GATT operation failed for unknown reason", "already in progress") is dropped and retried up to four times. The wasmOBDAdapterFinderis alwayssupported: its sheet (OBD Home's Connect button, or Settings > OBD > Adapters) lists the simulated car and, in a browser with Web Bluetooth, opens the chooser from "Pick Bluetooth Adapter" (not on open, so the chooser doesn't cover the list). It connects the pick and pointsOBDManagerat it withsetPortOverride()(nothing is saved; a reload starts with no car again);WebDemothen stops the autopilot and the simulated IMU, and picking the simulated car again disconnects the adapter and restarts them. Without Web Bluetooth (canScanfalse) the sheet hides that button and its status says what the browser needs, frombrowserKind()in the helper: on Linux, Chrome and Chromium keep Web Bluetooth behindchrome://flags/#enable-experimental-web-platform-features; Firefox and Safari have none. The manual MAC entry is hidden in the browser. Classic SPP, USB and Wi-Fi adapters can't be reached from a page this way. - src/platform/wasm/weblibrary.{h,cpp} (
WebMusicLibrary): the music library. A QMLFolderDialogwould only browse the page's in-memory filesystem, so every library "Browse" (Home's "Browse Music", Media Room's empty playlist, Settings > Media > Library Folder) first callsmediaManager.pick_browser_library(), which in the browser opens the browser's own folder picker (a hidden<input type=file webkitdirectory>: Chrome, Edge, Firefox and Safari) and returns true; elsewhere it returns false and theFolderDialogopens. The songs are never copied into wasm memory: each audio file of the picked folder gets a placeholder at the same relative path under /weblibrary/<n>/ (a new<n>per pick, so the same folder picked twice still rescans; the library in use keeps its placeholders and files until the new pick is read and scanned, and for good if the new pick holds no audio, so picking again while a song plays never strands the playlist on deleted placeholders) holding the index of its browserFile, the library root is set to the picked folder there, and the ordinary scan, playlists, rename and move code run on the placeholders. When a track plays,MediaManagerhands the player ablob:URL for thatFile(URL.createObjectURL, no copy) instead of a file URL; Qt's player plays a non-local URL straight from a media element. The library lasts for the visit: a reload starts empty again, as with every setting. Tags and covers are read by TagLib as on the desktop, from the parts of each file that hold them, read once the folder is picked and before the library is handed over (tagStream(): the ID3v2 tag and 64 KB of audio after it, FLAC's metadata blocks, an MP4'smoovatom wherever it is, the first 1 MB of Ogg / Opus, and the last 64 KB of every file, zeros between; about 0.1–0.5 MB a song, 512 MB at most, 145 songs in under a second). The visualizer has noQAudioDecoderin the browser: the page decodes the track with Web Audio (decodeMono(), Audio Analyzer). For audio, Qt's browser player reports position 0 andStoppedStatewhile it plays (only the page'stimeupdateevents, about 4 a second, carry the time), soMediaManager::_player_position()/_player_playing()read the<audio>element instead (playerPositionMs()/playerPlaying()): the 100 ms tick feeds the visualizer as on the device, and a resume is not mistaken for a failed start. Qt's browser player also makes a new<audio>element on everysetSourceand leaves it at full volume until the volume next changes, soMediaManager::applyOutputVolume()also callsWebMusicLibrary::setPlayerVolume(), which sets every<audio>element's volume now and as each new one loads.
What works, what does not
In the browser: home, the OBD pages, every dashboard, the 3D Jeep, sensors, settings, trip recording (SQLite in memory) and a music library from a folder the visitor picks, with tags, covers and the visualizer. Not there: persistence across reloads (MEMFS), hardware other than a Bluetooth LE OBD adapter, and the service / command server.
Sizes: octave.wasm 48 MB and octave.data 29 MB raw; GitHub Pages serves both gzipped, about 25 MB over the wire.
The page and the headers
packaging/wasm/index.html replaces Qt's generated page: it downloads the two big files with a progress bar, compiles the module and hands both to Qt's qtLoad(). Threaded WebAssembly needs cross-origin isolation (COOP/COEP headers), which GitHub Pages cannot send, so the vendored coi-serviceworker.js (MIT, 0.1.7) adds them and reloads once on first visit. The page sets window.coi = { coepCredentialless: () => false } before it, so the worker always sends require-corp: by default it sends credentialless to every browser without window.chrome / window.netscape, which is every WebKit browser (Safari, and Chrome / Firefox / Edge on iPhone and iPad), and WebKit doesn't support credentialless, so there only the first load after the worker installed was isolated and every later visit failed. If a load is controlled by the worker but still not isolated (a worker from an older page served it), the page reloads once per session. Locally, any static server that sends the headers works; packaging/wasm/README.md has a one-liner.
Phone Server Jar & adb
No scrcpy binary is bundled or required. The phone server and adb are distributed with OCTAVE; their bundled LICENSE / NOTICE files describe the applicable terms:
- The phone server — phone_server/ (Java,
org.octave.phoneserver) built into tools/phone-server/octave-phone-server, embedded in the binary as a resource. After editingphone_server/src, rebuild withANDROID_HOME=~/Android/Sdk ANDROID_PLATFORM=36 ANDROID_BUILD_TOOLS=36.0.0 bash phone_server/build_without_gradle.sh(JDK 21 to match CI; the script compiles withjavac -source 1.8 -target 1.8, generates the AIDL stubs, dexes withd8and writes the jar tobuild_manual/or$BUILD_DIR) then copybuild_manual/octave-phone-server(or the jar under your chosenBUILD_DIR) totools/phone-server/octave-phone-serverand commit it together with the source. The version stringSCRCPY_VERSION_NAME=3.3.4-octavein the script must equalkServerVersionin src/phone_mirror/scrcpyclient.h (3.3.4-octave); the server refuses a mismatch. Protocol byte layouts live in docs/PHONE_MIRROR_NATIVE_PLAN.md. - adb —
python scripts/fetch_platform_tools.py [--os linux|windows|darwin | --all] [--dest DIR]downloads Google's platform-tools37.0.1, verifies the SHA-256 recorded in the script, and extracts onlyadb(+lib64/libc++.soon Linux, the three DLLs on Windows),NOTICE.txtandsource.propertiesinto tools/platform-tools/<os>/ (gitignored). CI ships that next to the executable:platform-tools/besideOCTAVE.exe,usr/bin/platform-tools/in the AppImage,Contents/Resources/platform-tools/in the bundle. The Linux aarch64 CI job skips this download; ARM boards need a compatibleadbinstallation.
libavcodec is an optional build dependency for the H.264 decoder; without it phoneMirrorManager.nativeAvailable is false and there is no fallback path any more.
Build Troubleshooting
CMake configures against the Android kit on a desktop build
With Qt for Android installed, the CMake package registry can resolve find_package(Qt6) to the Android kit and the desktop build fails at configure. Pass the desktop kit explicitly: -DQt6_DIR="$(qmake6 -query QT_INSTALL_LIBS)/cmake/Qt6" (what scripts/octave-desktop.sh does). The AppImage script has the mirror-image problem for its assetimporters copy, which is why CMake records the real plugin path in build/qt_plugins_path.txt.
"DLL not found" on Windows
Installers from 0.9.4 on ship the Visual C++ runtime DLLs next to OCTAVE.exe. If an older install or a hand-built copy reports a missing MSVCP140.dll / VCRUNTIME140.dll, install the Visual C++ Redistributable: Download link.
"App is damaged" on macOS
The workflow does not notarize the bundle. Inspect the downloaded package and its signature; the macOS Gatekeeper guide distinguishes an unidentified developer from a damaged or modified application.
App links but exits at Main.qml load
The Qt you built against lacks QtQuick.Effects or Qt5Compat.GraphicalEffects (both imported by frontend/MainMenu.qml), or the packaged QML tree is not at <binary dir>/frontend or <binary dir>/../frontend. Use the pinned Qt 6.7.3 with the qt5compat module rather than distro Qt 6.2, and keep the AppDir layout above.
3D vehicle model does not load
Failed to load asset import plugin with key: "assimp" means Qt Quick 3D is present but its assimp import plugin is not — on Arch, qt6-quick3d additionally needs the assimp package; in an AppImage it means usr/plugins/assetimporters/ was not copied (CMake did not find Quick3D at configure time).
AppImage won't run
# Make executable
chmod +x OCTAVE-<version>-linux-x86_64.AppImage
# If FUSE unavailable, extract and run
./OCTAVE-<version>-linux-x86_64.AppImage --appimage-extract
./squashfs-root/AppRun
arm64 AppImage build aborts in linuxdeploy-plugin-qt
An unresolved libgstphotography dependency from Qt's GStreamer plugin. Set OCTAVE_DROP_QT_PLUGINS=multimedia/libgstreamermediaplugin.so as the CI job does; OCTAVE uses the FFmpeg media backend and does not need the plugin.
Android: INSTALL_FAILED_UPDATE_INCOMPATIBLE / "package appears to be invalid"
The first is a signing-key change: releases up to 0.9.3 each used a throwaway key, and a local build is signed with your own key. adb uninstall org.octave.app once, then install; releases from 0.9.4 share one key and update in place. The second is a signature-scheme complaint from some sideload installers. The workflow requests v1, v2, and v3 signing and runs apksigner verify --verbose --print-certs. Inspect that output for the APK in question; the workflow does not establish which signatures a previously downloaded APK contains.
Android APK build fails fetching a Maven dependency (HTTP 429)
Maven Central rate-limiting. CI retries four times with backoff and caches ~/.gradle; locally, just re-run cmake --build build-android --target apk.
qmllint fails locally on warnings
qmllint exits 255 on plain warnings. CI only fails on critical entries — reproduce with find frontend -name '*.qml' -exec qmllint -I frontend --json qmllint.json {} + and inspect the JSON rather than the exit code.
Dropbox sync interference
CMake caches are machine-specific. If the checkout is Dropbox-synced between machines, build into a per-host directory (as scripts/octave-desktop.sh does with build-desktop-<hostname>/) rather than sharing build/.