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.

Requirements (all desktops)
CMake 3.21+ (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 / defineDefaultEffect
OCTAVE_HAVE_FFMPEGauto-detectedSet 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_AAauto-detectedThe 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_TOOLSON 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_TESTSON for CMAKE_BUILD_TYPE=Debug desktop builds; OFF otherwiseAdds 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_FFMPEGOFFTurns 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_ROOTempty (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_FOUNDprobed (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_MOBILEdefined when ANDROIDActivates 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

Windows

Requirements

  • Visual Studio 2022 with the "Desktop development with C++" workload
  • Qt 6.7.3 (msvc2019_64 or msvc2022_64) via the Qt online installer; CI uses win64_msvc2019_64 with modules qtmultimedia qtserialport qtconnectivity qtquick3d qt5compat qtshadertools qtquicktimeline (qtquicktimeline is needed by Quick3D's AssetUtils library; windeployqt also fails on QtQuick3D without it)
  • vcpkg for taglib:x64-windows and ffmpeg[core,avcodec]:x64-windows
  • (CI only) Inno Setup 6 — pre-installed on windows-latest as ISCC.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 in build\)
  • CI stages dist\OCTAVE\ with the executable, TagLib and FFmpeg DLLs, adb, and Qt dependencies from windeployqt. It copies the Visual C++ runtime DLLs beside the executable, then packaging/windows/resolve_dlls.py checks 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 renamed OCTAVE.exe to match the installer. CI then launches it with --smoke and a restricted PATH, requiring exit code 0 within 90 seconds and the log line Smoke test: phone mirror native: 1 in octave-cpp.log (libavcodec compiled in and the bundled adb found), plus Smoke 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 0 and 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 as 0.9.5) in build_scripts/installer_windows.iss from the git tag. A manual dispatch runs on a branch, so it uses the fallback 0.9.5; before 0.9.4 the branch name ("main") leaked into the installer's version. This happens before running ISCC.exe. The installer has no VC++ runtime check: the old one showed a plain MsgBox, which /SUPPRESSMSGBOXES cannot silence, so every /VERYSILENT install hung on an invisible dialog. The installer runs in 64-bit mode (ArchitecturesInstallIn64BitMode=x64compatible), so OCTAVE goes to Program Files and its uninstall key to the 64-bit registry view. Earlier installers ran in 32-bit mode and used Program Files (x86). So at ssInstall, MigrateOld32BitInstall deletes an old (x86)\OCTAVE folder 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

macOS

Requirements

  • 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-config for 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 sets MACOSX_BUNDLE explicitly (bundle id com.robdegeorge.octave, name OCTAVE); without it qt_add_executable would emit a standalone executable that macdeployqt cannot bundle
  • dist/OCTAVE-<version>-macos.dmg — CI renames the bundle to OCTAVE.app, copies adb into Contents/Resources/platform-tools/, runs macdeployqt dist/OCTAVE.app -qmldir=frontend -dmg (macdeployqt creates the DMG itself) and renames the result (version fallback 0.0.0)
Code signing
The workflow does not configure Developer ID signing or notarization. macdeployqt uses ad-hoc signing by default; that does not provide a notarized distribution. If a trusted download is blocked, check the signature and Gatekeeper message before changing its quarantine attributes. iOS is not wired at all (no toolchain in CMake, no CI job — BUILD.md, iOS build).

Linux Build

Linux

Requirements

  • 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_arm64 for 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, add nasm, run bash packaging/ffmpeg/build-h264-decoder.sh ~/ffmpeg-h264 and pass OCTAVE_CMAKE_ARGS=-DOCTAVE_FFMPEG_ROOT=$HOME/ffmpeg-h264 (then libavcodec-dev / libavutil-dev are 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's frontend/
  • dist/OCTAVE-<version>-linux-x86_64.AppImage / -linux-aarch64.AppImage — portable single-file AppImages. The version comes from OCTAVE_VERSION (CI passes the tag), else GITHUB_REF_NAME, else the project() 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-check and phone-server (independent of each other)
  • Version tags v* or manual workflow_dispatch: the five cpp-build-* jobs, each gated on lint and cpp-compile-check (so a failing ctest blocks a release); the desktop ones smoke-test their packaged artifact with --headless --sim --smoke before uploading it
  • Version tags only: release, which needs all five build jobs

Jobs

JobRunnerWhat it does / artifactRetention
lintubuntu-22.04Python 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-checkubuntu-22.04apt 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-serverubuntu-22.04Temurin 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-server30 days
cpp-build-windowswindows-latestOCTAVE-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-macosmacos-14OCTAVE-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-linuxubuntu-22.04OCTAVE-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-arm64ubuntu-22.04-armOCTAVE-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-androidubuntu-22.04OCTAVE-Android-APK: OCTAVE-<v>-android-arm64-v8a.apk — see Android for the full step list30 days
releaseubuntu-latestsoftprops/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

Android

The 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

  1. 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 includes qtwebview for the offline wiki
  2. android-actions/setup-android@v3, then ndk;26.1.10909125, platforms;android-33, build-tools;33.0.2
  3. Restores a Gradle cache keyed on android/build.gradle + gradle.properties so repeat runs survive Maven Central's intermittent 429s
  4. Dereferences the android/assets/frontend and wiki symlinks into real copies — APKs cannot contain symlinks; the entries become 0-byte files and the app dies with "Failed to load Main.qml"
  5. 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_QT
  6. cmake --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)
  7. Signs the unsigned release APK with the permanent release key: the repository secrets ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD and ANDROID_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, runs zipalign -p 4 and apksigner sign (password passed via env:, never on the command line) with v1/v2/v3 requested, then apksigner verify --print-certs, deletes the keystore and stages dist/OCTAVE-<v>-android-arm64-v8a.apk (version fallback dev)
Keep the signing key consistent when updating
The workflow uses the persistent release key when repository signing secrets are available. Without them, it generates a temporary key. A differently signed APK cannot update an existing install (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.
Full local toolchain
See BUILD.md (Android build) for the complete local setup — Qt for Android 6.7.3 via 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: a QSerialPort / QSerialPortInfo whose open() always fails, obdadapterfinder_wasm.cpp (Web Bluetooth, below) and an inert networkmanager_wasm.cpp used instead of the real sources (HEADER_FILE_ONLY on the originals). QProcess users (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 /frontend in the page's in-memory filesystem: that is octave.data. The README screenshots, assets/-src and the full-size jeep_tj_2003.glb are 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 --sim runs): the real OBDManager on its CarSim + SimElm327 (port sim://elm327, realistic mode off) and the IMU fed by its ImuFeed, 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. A QIODevice for port webbt://<id>, added with OBDConnectionWorker::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 and OBDManager'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 with ATI and the first that answers (its whole reply, up to the > prompt) is used, the one that answered last time on that adapter first (remembered in localStorage); 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 wasm OBDAdapterFinder is always supported: 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 points OBDManager at it with setPortOverride() (nothing is saved; a reload starts with no car again); WebDemo then stops the autopilot and the simulated IMU, and picking the simulated car again disconnects the adapter and restarts them. Without Web Bluetooth (canScan false) the sheet hides that button and its status says what the browser needs, from browserKind() in the helper: on Linux, Chrome and Chromium keep Web Bluetooth behind chrome://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 QML FolderDialog would 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 calls mediaManager.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 the FolderDialog opens. 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 browser File, 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, MediaManager hands the player a blob: URL for that File (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's moov atom 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 no QAudioDecoder in the browser: the page decodes the track with Web Audio (decodeMono(), Audio Analyzer). For audio, Qt's browser player reports position 0 and StoppedState while it plays (only the page's timeupdate events, about 4 a second, carry the time), so MediaManager::_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 every setSource and leaves it at full volume until the volume next changes, so MediaManager::applyOutputVolume() also calls WebMusicLibrary::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 editing phone_server/src, rebuild with ANDROID_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 with javac -source 1.8 -target 1.8, generates the AIDL stubs, dexes with d8 and writes the jar to build_manual/ or $BUILD_DIR) then copy build_manual/octave-phone-server (or the jar under your chosen BUILD_DIR) to tools/phone-server/octave-phone-server and commit it together with the source. The version string SCRCPY_VERSION_NAME=3.3.4-octave in the script must equal kServerVersion in 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-tools 37.0.1, verifies the SHA-256 recorded in the script, and extracts only adb (+ lib64/libc++.so on Linux, the three DLLs on Windows), NOTICE.txt and source.properties into tools/platform-tools/<os>/ (gitignored). CI ships that next to the executable: platform-tools/ beside OCTAVE.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 compatible adb installation.

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/.