# Build Windows packaging for the studio desktop app: **one provisioning step per machine, then repeatable builds.** > **AutoCal is not built here.** `apps/autocal` (the internal 5-axis platform > control app) runs from source and has no PyInstaller spec, deliberately: this > spec would otherwise have to learn about the ZMotion runtime DLLs, and AutoCal > must stay out of the customer installer. See `apps/autocal/README.md`. ## Layout ```text build/ studio/ setup.bat / setup.ps1 # provision the build venv (run once per machine) constraints-build.txt # pinned build-time versions build.bat / build.ps1 # build the EXE and verify it entry.py # frozen entry (no source chdir) orisys-studio.spec # PyInstaller onedir spec _pyinstaller/ # PyInstaller workpath (gitignored) _smoke/ # log of the last build's start-up smoke test dist/ OrisysStudio/ OrisysStudio.exe config/ # lean locales + sensors (no finger visualization/) _internal/ # Python, PySide6, opencv-headless, orisys, OpenGL, … ``` ## Why there are two steps A PyInstaller bundle depends on the *machine* as much as on the sources: which interpreter, whether it is Anaconda (whose `libexpat`/`libffi`/OpenSSL DLLs live outside the venv and are therefore **not** bundled), and where the `orisys` SDK checkout is. Those facts are decided once, by `setup.ps1`, and recorded in `.venv-build\orisys-build-env.json`. `build.ps1` then installs nothing and guesses nothing: it verifies the environment, builds, and verifies the artefact. That split is what keeps a build reproducible instead of "works on my machine". ## First time on a machine ```bat cd build\studio setup.bat setup.bat -Python C:\Python310\python.exe setup.bat -AllowConda ``` `setup.bat` finds an interpreter, creates or repairs `.venv-build`, installs the pinned toolchain plus the app's extras (including the studio's `three-d` extra — without it the 3D pane silently reports "PyOpenGL is not installed" on the customer's machine), writes the stamp, and then verifies the result by importing what the frozen app imports. Interpreter resolution, in order: `-Python `; `py -3.10`; `python`; the interpreter already behind `.venv-build`. The Microsoft Store `python.exe` alias is refused (it is not an interpreter), and an Anaconda interpreter is refused unless `-AllowConda` is passed — in that case its `Library\bin` is recorded and the spec adds the runtime DLLs, because PyInstaller does not search there and the EXE would otherwise die at start-up with `ImportError: DLL load failed while importing pyexpat`. Building with python.org CPython is still the preferred answer. The SDK is installed from `..\..\..\orisys-sdk` when that checkout exists (`-SdkPath` to point elsewhere, `-FromPypi` to install the published wheel instead). Installing the published SDK is how a build once shipped with no `orisys.finger` at all. Re-running `setup.bat` is safe: with a valid stamp for this checkout it reports and exits; without one it repairs the existing venv rather than deleting it. `-Force` recreates it. ## Build ```bat build.bat build.bat -Clean # after dependency/spec changes build.bat -RebuildVenv # setup.bat -Force, then build build.bat -SkipInstall # never provision: fail instead build.bat -SkipSmokeTest # do not launch the produced EXE build.bat -StrictSmokeTest # treat a 3D-context warning as a failure ``` Or from the repo root: ```powershell powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1 ``` `build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags. ## What the build verifies, and why Every silent failure this packaging has produced looked like a *successful* build, so the script checks the things that only show up when the EXE runs: | check | failure it prevents | |---|---| | the venv imports `orisys_app_shared`, `orisys_studio`, `orisys`, `OpenGL`, `cv2`, `PyInstaller` | a stale editable install still builds (the spec's `pathex` finds the modules) and produces an EXE that cannot start | | `collect_data_files('orisys_app_shared', includes=['theme/*.json'])` is non-empty | the theme colours are package data; without them the app dies in `build_qss()` before logging anything | | every `config\sensors\**\*.oryscene` is present, and the finger manifest/`sensor.json`/unroll map survive the config strip | the 3D pane refuses the scene when the device description cannot be derived | | no `open3d*` under `dist` | the offline tool leaking into the shipped bundle | | **smoke test**: start the produced EXE, wait for its own log to say `Studio shell started` (and `3D scene loaded` when a finger scene shipped), then stop it | a bundle that cannot start at all — missing runtime DLLs, an excluded module, missing data. The log is kept under `build\_smoke\` | The smoke test seeds a `preference.json` beside the EXE to select the finger sensor with the 3D pane on, and restores (or removes) it afterwards. A *GL context* failure is reported as a warning, because it is environment-dependent; `-StrictSmokeTest` turns it into a failure. ## Where the app's own code lives in the bundle `_internal\` contains **native code and data files only**. Every pure-Python module — `orisys_studio.*`, `orisys_app_shared.*`, `orisys.*` — is compiled into the `PYZ` archive that sits *inside* `OrisysStudio.exe` (that is most of the EXE's ~13 MB). So there is deliberately no `_internal\orisys_studio\` folder, and no `.py` files anywhere: PyInstaller ships bytecode, never sources. `_internal` therefore shows only these orisys-related paths: ```text _internal\orisys_app_shared\theme\colors_dark.json <- package data, bundled (qss.py reads it) _internal\orisys_app_shared\theme\colors_light.json _internal\orisys\.gitignore <- stray non-.py file of the SDK package _internal\branding\... <- logos ``` (`collect_data_files("orisys")` exists for the case where the SDK is installed from a wheel that bundles `sensors/` assets; with a local editable checkout the only non-`.py` file is that `.gitignore`. It is harmless — the app prefers `config\sensors` — but it does look odd.) To list what is really in the archive: ```bat .venv-build\Scripts\python.exe -m PyInstaller.utils.cliutils.archive_viewer ^ -l -r -b dist\OrisysStudio\OrisysStudio.exe ``` (The `PYZ-00.toc` under `build\_pyinstaller\orisys-studio\` is the same information at build time, and `build\_smoke\*.log` is the runtime proof: "Studio shell started" and "3D scene loaded" can only be printed if those modules imported.) ## Dependency notes - **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it (`apps/studio/tests/test_binding_purity.py`, `apps/autocal/tests/test_binding_purity.py`). `setup.ps1` **uninstalls** PyQt5/PyQt6/PySide2 from the build venv and verifies only one binding is present: `pyqtgraph` binds to whichever toolkit it imports first, so a leftover PyQt5 yields a bundle whose plots were built from the wrong binding. The spec also excludes `PyQt5`/`PyQt6`/`PySide2` outright, and must never exclude `PySide6` itself. - App shared dep uses **`opencv-python-headless`** (PySide6 owns the GUI). `setup.ps1` uninstalls any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped wheels without a `cv2` module) and verifies `import cv2`. - **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra. Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped. - **`QOpenGLWidget` lives in `PySide6.QtOpenGLWidgets`**, not `QtWidgets` (Qt6 moved it): the spec names it in `hiddenimports` so the module and `Qt6OpenGLWidgets.dll` are bundled. - **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is Qt `QOpenGLWidget` + PyOpenGL. - `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force` and a passing `build.bat` — the smoke test is the acceptance evidence, not the file. ## GL note The pane asks for the profile in `gl_profile` (`desktop33` by default, `gles30` otherwise). A `desktop33` request needs a desktop GL 3.3 core context; on some sessions Qt falls back to its ANGLE (GLES) backend, which cannot satisfy it, and the widget then reports no context on a machine whose GPU is fine. `QT_OPENGL=desktop` is the workaround; the durable fix is to pin the backend in `orisys_studio.main` before `QApplication` is constructed (`Qt.AA_UseDesktopOpenGL` / `Qt.AA_UseOpenGLES`). `build.bat -StrictSmokeTest` makes this a build failure. ## Size / startup - **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder. - Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). Nothing in the app reads it (`manifest.json`'s `viz_pose` is used only by the SDK examples); the 3D pane needs `scene/*.oryscene` plus `assets/30fps-0703`, and the build checks both. - Studio shell defers OpenCV / acquisition imports until Preview or Run. Expect ~450 MB total (Qt + numpy/scipy/numba + OpenCV + PyOpenGL). ## Verification on a new machine 1. `setup.bat` (see its report: interpreter, conda or not, SDK source). 2. `build.bat` — its smoke test must print "the application started" and "the packaged 3D scene loaded". 3. Launch `OrisysStudio.exe` and check theme/locale/prefs; `preference.json` and `outputs\` appear next to the EXE. 4. Camera preview + box/mini/finger Run (video) with image modes and force plots. 5. Open the 3D pane on a finger source: the surface draws, the legend shows the field. 6. Confirm no `open3d*` files under `dist\OrisysStudio`. ## Editable run (unchanged) ```powershell $env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\studio\src" python D:\repo\orisys-app\apps\studio\run.py ```