Skip to content

Release builds

This page mirrors implementation notes maintained in CLAUDE.md. Update both when changing this subsystem.

Cue ships as a signed, notarized .app / DMG (macOS) and a PyInstaller-built .exe + Inno Setup installer (Windows). The macOS path is the more involved of the two due to hardened runtime + Apple notary requirements.

macOS — Nuitka + Developer ID + notarization

Single command:

bash scripts/build_nuitka.sh

What that script does:

  1. Activates the conda cue env (uv-built env is for dev; the build env is conda for ABI consistency between Python, PyGObject, GStreamer, and the GStreamer plugins). See scripts/environment.yml.
  2. Compiles via Nuitka into a .app bundle.
  3. Bundles dist-info from the vendored owa packages so importlib.metadata.entry_points() discovers the plugin registrations.
  4. Resolves GStreamer.framework from the system path (not bundled — too large; users install GStreamer system-wide). A small libiconv compatibility shim is built so PyGObject finds the GNU iconv* symbol set.
  5. Bundles the llama-server binary under Cue.app/Contents/MacOS/bin/llama-b<tag>/ for on-device inference (downloaded automatically by the script if missing).
  6. Codesigns every Mach-O binary serially with the Developer ID Application certificate, hardened runtime, embedded entitlements (scripts/release/entitlements.plist), and a secure timestamp. Retries on transient timestamp-server hiccups.
  7. Notarizes the .app via xcrun notarytool submit --wait using a keychain profile (configured via xcrun notarytool store-credentials).
  8. Staples the notary ticket onto the .app.
  9. Builds a DMG, signs it with the same Developer ID identity, submits to the notary, staples.

Artifacts land in dist/Cue.app and dist/Cue-<version>.dmg.

Hardened runtime entitlements

scripts/release/entitlements.plist:

  • com.apple.security.cs.disable-library-validation — required because GStreamer.framework dylibs are not signed by our team-id.
  • com.apple.security.cs.allow-dyld-environment-variablesInfo.plist LSEnvironment (see below) injects DYLD_LIBRARY_PATH / GST_PLUGIN_PATH / GI_TYPELIB_PATH to point at the system GStreamer.framework. Hardened runtime strips those env vars unless this entitlement is granted.
  • com.apple.security.cs.allow-jit — required because GStreamer's orc SIMD JIT compiler calls pthread_jit_write_protect_np to toggle W^X memory.

LSEnvironment + sticky permissions

Info.plist carries an LSEnvironment dict so Launch Services injects DYLD_LIBRARY_PATH / GST_PLUGIN_PATH / GI_TYPELIB_PATH / GST_PLUGIN_SCANNER before the process starts. dyld sees them on first execve, so run.py does not re-exec the process.

Why this matters: re-execing the same Mach-O reliably drops the macOS Accessibility TCC grant on every relaunch, even when the codesign identity is unchanged. Screen Recording survives the re-exec; Accessibility doesn't. Without LSEnvironment the user has to re-grant Accessibility every time they launch Cue, which breaks the global hotkey.

Direct shell exec of Cue.app/Contents/MacOS/run from a terminal falls back to the legacy os.execv re-exec path (LSEnvironment isn't applied outside Launch Services). That's the dev/diagnostic case.

Apple Developer prerequisites

  • Apple Developer Program membership.
  • Developer ID Application certificate installed in the login keychain (verify with security find-identity -v -p codesigning).
  • xcrun notarytool store-credentials cue-notarize run once with Apple ID + app-specific password + Team ID.
  • security set-key-partition-list -S apple-tool:,apple: run once on the cert so codesign doesn't prompt for keychain access on every binary.

The build script auto-detects the Developer ID identity via security find-identity and falls back to ad-hoc signing (--sign -) when none is present — useful for local-only smoke testing without distribution.

Selftests

The frozen build supports selftests for catching missing native libraries before users hit them:

Cue.app/Contents/MacOS/run --selftest=llama_server_health
Cue.app/Contents/MacOS/run --selftest=llama_server_import

The CI step runs these post-build.

Windows — Nuitka + Inno Setup

scripts\build_nuitka.bat

What that script does:

  1. Hard-cleans dist/ and build/ (avoid stale PyInstaller output).
  2. Installs build-time deps (nuitka, ordered-set, Pillow) via uv pip install — uv venvs don't ship pip.
  3. Converts assets/icon.pngassets/Cue.ico (multi-res 16-256).
  4. Ensures spaCy en_core_web_sm is in the venv.
  5. Downloads llama-<tag>-bin-win-cpu-x64.zip from ggml-org/llama.cpp and extracts it into vendor/llama-bin/llama-b<tag>/.
  6. Compiles via Nuitka standalone (~5–15 min first run; subsequent runs are faster thanks to ccache). Uses --enable-plugin=tk-inter so the customtkinter / Settings window / API-key prompt work.
  7. Renames build/run.distdist/Cue and run.exeCue.exe.
  8. Manually copies dist-info for the owa.* namespace packages (owa-core, owa-env-desktop, owa-msgs, ocap-windows, mcap-owa-support). Nuitka's --include-distribution-metadata= flag rejects PEP 420 namespace dists whose name doesn't match a top-level importable, so the entry-point loader needs the dist-info dirs present at runtime.
  9. Recovers GStreamer native assets that Nuitka's --include-data-dir skips: walks each gstreamer_* package in site-packages and copies every *.exe, *.dll, *.typelib into the dist tree (plus the custom gst-plugins/*.py from vendor/ocap-windows/). Mirrors what ocap-windows does.
  10. Copies vendor/llama-bin/llama-b<tag>/dist/Cue/bin/llama-b<tag>/.
  11. Invokes Inno Setup (scripts/release/cue_setup.iss) to wrap the dist/Cue/ tree into dist/Cue-Setup-<version>.exe (~300–500 MB after LZMA2 compression).

Outputs: dist/Cue/Cue.exe (~700 MB–1.2 GB tree) and dist/Cue-Setup-<version>.exe.

Runtime GStreamer env setup

run.py calls _setup_windows_gstreamer_env() before any gi / owa.gstreamer / owa.ocap_windows import. It detects bundled mode (presence of gstreamer_libs/ next to the exe), prepends every gstreamer_*/bin/ to PATH, calls os.add_dll_directory() for each (belt-and-suspenders for Win10+ secure DLL search), and sets GI_TYPELIB_PATH, GST_PLUGIN_PATH{,_1_0}, GST_PLUGIN_SYSTEM_PATH_1_0, GST_PLUGIN_SCANNER_1_0, PYGI_DLL_DIRS. There's an assert "gi" not in sys.modules tripwire at the top of the function — if Nuitka ever hoists an early GStreamer import past the env setup, you find out at first launch instead of from a mysterious DLL load failure in the field.

GStreamer source

gstreamer-bundle>=1.28.0,<1.29 (the same PyPI distribution ocap-windows uses) ships GStreamer 1.0 + PyGObject + every plugin as a set of importable Python packages. No system GStreamer install required. The 1.29 line is alpha — pin holds at 1.28.

PyInstaller fallback

The legacy PyInstaller path is kept for parity testing:

scripts\build_windows.bat               # PyInstaller .exe
python scripts/build_installer.py       # Wraps with Inno Setup

Useful when diagnosing whether a regression is Nuitka-specific.

Code signing

Skipped. The installer is unsigned — Windows SmartScreen warns on first run; users click "More info → Run anyway." Adding SignTool with an Authenticode cert is a follow-up.

Windows GStreamer pipeline — GDI fallback under Nuitka

vendor/ocap-windows's default pipeline hardcodes d3d11screencapturesrc + a chain of d3d11scale / d3d11download / d3d11convert elements. Under Nuitka standalone, instantiating any d3d11* element inside Gst.parse_launch access-violates immediately on element creation — verified via Cue.exe --selftest=gst_init:

videotestsrc          - parses
gdiscreencapsrc       - parses
dx9screencapsrc       - parses
d3d11screencapturesrc - SIGSEGV

The same pipeline parses fine in dev-mode CPython, so this is specifically a Nuitka-vs-D3D11 interaction. To work around it, cue.ocap_launcher.patched_on_configure builds its own GDI-based pipeline on Windows: gdiscreencapsrcvideoconvertvideoscale name=d3d11scale0teefpsdisplaysink / x265enc (CPU H.265) / pruner appsink. macOS unchanged.

Trade-off: pure CPU capture + encoding instead of GPU, costs ~1 core at 5 fps. ocap-windows's own gui binary (which builds with Nuitka via their scripts/build_exe.py) avoids this by wrapping each recording in a fresh multiprocessing.Process — Cue's launcher sidesteps that by routing through ocap's recorder.main() directly, which is why we hit the bug their gui doesn't.

First-build troubleshooting

Symptom Cause Fix
Nuitka: Could not find C compiler MSVC Build Tools missing --assume-yes-for-downloads should auto-pull; if blocked, install Build Tools 2022 manually.
assert "gi" not in sys.modules fires Import-order guard tripped Find the early gi/owa import; move it past _setup_windows_gstreamer_env().
ImportError: gi at startup gstreamer-bundle missing uv pip install -e ".[windows,on-device]"; verify with python -c "import gi".
Tray icon missing pystray Win32 backend not bundled The flag list already covers --include-package=pystray; if still missing add --include-module=pystray._win32.
Hotkey doesn't fire pynput Win32 backend missing --include-module=pynput.keyboard._win32 and pynput.mouse._win32 (already in flag list).
API-key prompt / Settings window blank Tkinter not bundled --enable-plugin=tk-inter (already in flag list — if absent, this is the culprit).
MissingDependencyException: en_core_web_sm spaCy model missing Confirm both --spacy-language-model=en_core_web_sm and --include-package=en_core_web_sm are present.
llama-server.exe not found Bundle layout drift Verify dist\Cue\bin\llama-b<tag>\llama-server.exe; run Cue.exe --selftest=llama_server_health.
Inno Setup "missing source" Phase 7 rename didn't run Verify dist\Cue\Cue.exe exists before ISCC invocation.

Bundled llama-server

The on-device digest backend uses an external multimodal inference binary, not llama-cpp-python. See On-device vision for the rationale.

The build matrix:

  • macOS arm64 / x64llama-<tag>-bin-macos-{arm64,x64}.tar.gz release artifact from ggml-org/llama.cpp.
  • Windows x64llama-<tag>-bin-win-cpu-x64.zip release artifact.

scripts/build_nuitka.sh (macOS), scripts/build_nuitka.py (Windows), and scripts/build_installer.py (legacy PyInstaller fallback) download + verify + extract these binaries into the bundle. The pinned release tag is updated by editing the manifest in scripts/.

CI integration

.github/workflows/ci.yml runs ruff lint + format on every push. Application release builds are not run in CI (they need keychain access and an Apple Developer cert that isn't in CI).

.github/workflows/docs.yml is a separate workflow:

  • On every PR + main push, runs uv pip install -e ".[docs]" and uv run --no-sync mkdocs build --strict. Catches broken nav, missing anchors, and mkdocstrings import regressions before the deploy.
  • On main pushes, additionally invokes cloudflare/wrangler-action@v3 with the CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID repo secrets to push the built site/ to the cue Cloudflare Pages project (pages deploy site --project-name=cue --branch=main).
  • Submodule fetch is intentionally off in this workflow — docs build doesn't need ocap or owa, and Cloudflare's git-connect flow couldn't read our private vendor submodules anyway. That's the whole reason deploys go through GitHub Actions instead of Cloudflare's Git integration.

Public docs URL: https://cue-aif.pages.dev/.

Version

Single source of truth: pyproject.tomlversion. Also update _VERSION in main.py and the README footer.

See also