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:
- Activates the conda
cueenv (uv-built env is for dev; the build env is conda for ABI consistency between Python, PyGObject, GStreamer, and the GStreamer plugins). Seescripts/environment.yml. - Compiles via Nuitka into a
.appbundle. - Bundles dist-info from the vendored owa packages so
importlib.metadata.entry_points()discovers the plugin registrations. - Resolves GStreamer.framework from the system path (not bundled
— too large; users install GStreamer system-wide). A small
libiconvcompatibility shim is built so PyGObject finds the GNUiconv*symbol set. - Bundles the
llama-serverbinary underCue.app/Contents/MacOS/bin/llama-b<tag>/for on-device inference (downloaded automatically by the script if missing). - 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. - Notarizes the
.appviaxcrun notarytool submit --waitusing a keychain profile (configured viaxcrun notarytool store-credentials). - Staples the notary ticket onto the
.app. - 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-variables—Info.plistLSEnvironment(see below) injectsDYLD_LIBRARY_PATH/GST_PLUGIN_PATH/GI_TYPELIB_PATHto 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'sorcSIMD JIT compiler callspthread_jit_write_protect_npto 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-notarizerun 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:
- Hard-cleans
dist/andbuild/(avoid stale PyInstaller output). - Installs build-time deps (
nuitka,ordered-set,Pillow) viauv pip install— uv venvs don't ship pip. - Converts
assets/icon.png→assets/Cue.ico(multi-res 16-256). - Ensures spaCy
en_core_web_smis in the venv. - Downloads
llama-<tag>-bin-win-cpu-x64.zipfromggml-org/llama.cppand extracts it intovendor/llama-bin/llama-b<tag>/. - Compiles via Nuitka standalone (~5–15 min first run; subsequent
runs are faster thanks to ccache). Uses
--enable-plugin=tk-interso the customtkinter / Settings window / API-key prompt work. - Renames
build/run.dist→dist/Cueandrun.exe→Cue.exe. - 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. - Recovers GStreamer native assets that Nuitka's
--include-data-dirskips: walks eachgstreamer_*package in site-packages and copies every*.exe,*.dll,*.typelibinto the dist tree (plus the customgst-plugins/*.pyfromvendor/ocap-windows/). Mirrors what ocap-windows does. - Copies
vendor/llama-bin/llama-b<tag>/→dist/Cue/bin/llama-b<tag>/. - Invokes Inno Setup (
scripts/release/cue_setup.iss) to wrap thedist/Cue/tree intodist/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: gdiscreencapsrc → videoconvert →
videoscale name=d3d11scale0 → tee → fpsdisplaysink /
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 / x64 —
llama-<tag>-bin-macos-{arm64,x64}.tar.gzrelease artifact fromggml-org/llama.cpp. - Windows x64 —
llama-<tag>-bin-win-cpu-x64.ziprelease 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]"anduv run --no-sync mkdocs build --strict. Catches broken nav, missing anchors, andmkdocstringsimport regressions before the deploy. - On main pushes, additionally invokes
cloudflare/wrangler-action@v3with theCLOUDFLARE_API_TOKEN+CLOUDFLARE_ACCOUNT_IDrepo secrets to push the builtsite/to thecueCloudflare 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.toml → version. Also update
_VERSION in main.py and the README footer.
See also¶
- Dev environment — local dev workflow.
- Submodules & vendoring.
- Cross-platform rule.
- On-device vision.