# Windows architecture ## Installation and command line Public releases are published at [reville/lighttable-digital-darkroom](https://github.com/reville/lighttable-digital-darkroom/releases). The Windows x64 artifacts are `LightTable-VERSION-windows-x64-setup.exe` and `LightTable-VERSION-windows-x64.zip`. See the [Windows installation guide](https://lighttable.app/windows.html) and [exact-binary acceptance record](../windows-client-acceptance.md) for the current release. The installer runs without administrator rights and defaults to `%LOCALAPPDATA%\Programs\LightTable`. It adds a dedicated `bin` directory to the current user's PATH. Open a new terminal after installation, then run `lighttable --help`. The CLI uses bundled Python and does not require a source checkout, Python installation, or Node.js. The dedicated directory prevents Windows from resolving the desktop `LightTable.exe` before the CLI. Before copying or replacing LightTable, setup checks for the Microsoft Edge WebView2 Runtime in both the per-machine and per-user registry locations. The signed 0.6.1 installer bundles Microsoft's verified standalone runtime and can install it without network access when missing. Windows 10 acceptance covers that missing-runtime case; Windows 11 acceptance preserves its preinstalled runtime. Setup runs without elevation and leaves this shared Microsoft runtime installed when LightTable is removed. Developer installers built without the offline prerequisite use Microsoft's Evergreen bootstrapper when the runtime is missing. That fallback requires internet access and a valid Microsoft Authenticode signature; a bounded download or installation failure stops setup before changing the existing app. For unattended installation: ```powershell Start-Process -Wait .\LightTable-VERSION-windows-x64-setup.exe -ArgumentList '/S' ``` An optional `/D=C:\path with spaces\LightTable` must be the last installer argument and its value must not be quoted separately. The per-user uninstall registry key is `LightTable`, publisher metadata is `Chonkers LLC`, and the `QuietUninstallString` supports `/S` for WinGet and other package managers. Uninstall removes shipped files, shortcuts, and the CLI's PATH entry. Catalogs, preferences, caches, photos, and unrelated files in the install directory remain. Nothing LightTable writes at runtime lands in the install directory: the catalog, preferences, desktop settings, server log, render caches, compiled Python bytecode, compiled numba kernels, and the WebView2 profile all live under `%LOCALAPPDATA%\LightTable`. For the portable ZIP, extract its `LightTable` folder, open `LightTable.exe`, or run `LightTable\lighttable.cmd --help`. A package manager can shim `LightTable\lighttable.cmd` explicitly. Merely adding the ZIP root to PATH would choose the GUI executable instead of the command. Portable users must install the WebView2 Runtime separately if it is missing. Both package formats include Microsoft-signed Visual C++ x64 runtime DLLs beside the executables. The build requires runtime 14.44.35211.0 or newer, records its version, and verifies that Python loads the packaged copies. Users do not need administrator rights to update the machine's C++ runtime. ## Updates Signed direct installations use WinSparkle for signed update checks, release notes, downloads, and installation. **Settings → General → Check for Updates…** opens the native updater. Automatic checks can be disabled in General; when enabled, they run at most daily after the editor opens. Downloads do not close the app. Installing an update saves pending edits, verifies a catalog backup, and requires imports, exports, and other active work to finish first. The update helper waits for both the window and server to exit before running the installer, then reopens LightTable with the same catalog. A failed preparation leaves the app open. The installer writes `install-channel.txt`. Direct installations use `direct`; Scoop, WinGet, and Chocolatey packages record their manager and disable in-app installation. Update those copies through the same package manager. The portable ZIP uses `portable` and requires downloading and extracting a newer ZIP. Unsigned development builds cannot install updates automatically. The Windows feed is `appcast-windows-x64.xml` on the dedicated `desktop-updates` GitHub release. Its signed enclosures point to immutable versioned installers. WinSparkle verifies the Ed25519 signature before handing off the download. The release build signs and verifies the installer's Authenticode signature before publication. The helper waits for shutdown and runs that installer. See [release setup](../../release/README.md) for signing and feed publication. This source integration still requires a signed upgrade on an actual Windows desktop before release. ## Build and validation On Windows x64 with Rust 1.88.0, Git, uv 0.11.28, and NSIS installed: ```powershell .\scripts\windows\build-release.ps1 # version defaults to app_version.py ``` The build downloads a hash-verified embedded Python runtime and pinned render sources, runs Rust tests and a packaged runtime smoke test, creates the ZIP and installer, then tests silent installation, reinstallation, CLI discovery from another directory, exit-code forwarding, and uninstall with user-data sentinels. The installer smoke uses a disposable directory and refuses to run when that Windows account already has a registered LightTable installation. A `build-manifest.json` records the exact source commit and version. Use `-PortableOnly` explicitly to build a ZIP without NSIS or installer testing. `scripts/windows/installer-fixture-smoke.ps1` runs the same install, repair, package-ownership, CLI-registration, and data-preserving uninstall checks with a tiny fixture payload. It compiles the real NSIS source and uses the real PATH and uninstall helpers; its temporary WebView2 prerequisite and runtime files are stubs. CI requires this fast installer check before a full package build. It does not establish application or prerequisite-runtime behavior. `-RuntimeSmokeOnly` stages the same embedded Python runtime and application files, then checks imports, high-precision processed-image conversion, and a real server startup with HTTP health, editor, and options requests. It does not compile Rust or create an installer. Pull requests run this check in addition to the Windows Rust compile checks. Root Python modules are staged together, so adding an indirect or optional feature import cannot silently leave its local dependency out of the Windows package. `.github/workflows/windows-build.yml` checks pull requests and produces packages on `main`, manual dispatch, or reusable-workflow calls. The reusable workflow accepts a required `version` string and uploads both files in the `LightTable-windows-x64` artifact for the unified release workflow. `source_ref` selects the exact release commit. `require_signing` defaults to `false` for CI; the public release workflow sets it to `true` and passes signing secrets to the reusable workflow. Runtime and installer smoke tests do not establish Windows GUI or RAW-rendering proof. Full package workflows additionally run `scripts/windows/desktop-smoke.py` against the extracted portable ZIP. It opens the real native shell in an isolated catalog, requires a rendered precision TIFF, changes exposure and rating through the interface, closes and reopens the app, and verifies retained edits and an RGB16 TIFF export with an embedded ICC profile. A private Windows Job Object owns and cleans up only the test's processes. The runner checks `--runtime-paths` before opening a window; the Windows-only absolute `LIGHTTABLE_SUPPORT_DIR` override isolates native settings and WebView2 data. The JSON evidence records the source revision and renderer. Use `--photo` with a real RAW file for an additional hardware acceptance run. This gate provides native runtime evidence, not screenshot or monitor-color proof. To recheck an existing CI package after changing the acceptance script, dispatch `windows-build.yml` with `build_run_id` set to that package's Windows build run. The native-only job verifies the successful package-build step and the archive's source identity, then records both the package commit and the tester commit. The server keeps the launcher's shutdown pipe private and gives helper processes null standard input. Otherwise a worker can block during Python initialization while the server waits for launcher EOF. The Windows process regression checks helper startup with the launcher pipe open, then verifies shutdown on EOF. On failure, this recheck uses a checksum-pinned external profiler to capture stacks from the test's packaged Python processes, without local variables or changes to the archive. The standalone script accepts the same optional tool through `--stack-dumper`; diagnostics never turn a failed journey into a pass. Completed packages are retained even when native acceptance fails; that failure still blocks the full build job and release publication. For Azure Artifact Signing, configure the GitHub environment `windows-release` with deployment rules allowing branch `main` and tags `v*`. Give a dedicated Microsoft Entra application **Artifact Signing Certificate Profile Signer** at the certificate-profile scope only. Its federated credential must use: - Issuer: `https://token.actions.githubusercontent.com` - Subject: `repo:reville@279601/lighttable-digital-darkroom@1358417583:environment:windows-release` - Audience: `api://AzureADTokenExchange` Set these environment variables in GitHub's **Settings > Environments > windows-release > Environment variables**: | Variable | Value | | --- | --- | | `AZURE_SIGNING_ENDPOINT` | `https://eus.codesigning.azure.net/` | | `AZURE_SIGNING_ACCOUNT` | `lighttable-signing` | | `AZURE_SIGNING_PROFILE` | `lighttable-windows` | | `AZURE_TENANT_ID` | The signing account's Microsoft Entra tenant ID | | `AZURE_CLIENT_ID` | The dedicated application's client ID | The subject includes immutable owner and repository IDs. Match the subject GitHub actually issues; do not assume a name-only subject from the API's `use_immutable_subject` flag. Signed jobs request `id-token: write`; reusable callers must also grant that permission. The signing helper fetches a fresh GitHub OIDC assertion at each signing stage and uses Azure's `WorkloadIdentityCredential`. Temporary assertions are deleted after signing. No Azure client secret or private signing key is stored in GitHub. The Microsoft signing client and SDK packages are version- and checksum-pinned. The Windows runner needs .NET 8 or later. Ordinary CI uses the separate `windows-ci` environment, which has no Azure federated trust. Alternatively, for PFX-based Authenticode signing, configure repository secrets `WINDOWS_CERTIFICATE_BASE64` (a base64-encoded PFX containing a valid code-signing certificate and private key) and `WINDOWS_CERTIFICATE_PASSWORD`. The installed Windows SDK must provide `signtool.exe`. A reusable-workflow caller must pass these secrets explicitly or use `secrets: inherit`. Configure only one signing backend; mixed or partial settings fail the build. `build-release.ps1 -RequireSigning` checks the signing configuration before any downloads or compilation and refuses missing or partial credentials. Without credentials, ordinary CI builds remain unsigned. With credentials, the build signs the desktop executable, WinSparkle DLL, both render-engine executables, and the final NSIS installer; verification precedes smoke testing and final archiving. The temporary PFX is deleted in a `finally` block and no certificate is installed in the Windows certificate store. `build-manifest.json` records whether the package was signed. Before compiling the application, a required signing run signs and verifies a temporary executable. This exercises federation, signer permissions, and the timestamp service early. The fixture is deleted and is never run or shipped. The signing helper uses SHA-256 file and RFC 3161 timestamp digests with the Microsoft timestamp service for Azure or DigiCert for PFX, then requires `signtool verify /pa /all /tw` to pass. The flags follow [Microsoft's SignTool documentation](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). Signed CI builds also extract the completed ZIP and independently verify every signed application binary and the installer, including timestamp presence. `windows-signatures.json` records the source revision, file hashes, publishers, and timestamp authorities alongside the build artifacts. Configuring this workflow does not itself obtain a certificate or prove a successful signed release. To produce a signed candidate without publishing a release, dispatch `windows-build.yml` on a pinned version tag with the matching new numeric `version` and `require_signing=true`. Inspect the signature report and native acceptance evidence before publishing. Windows support is an additional host around the shared render core, not a replacement for the macOS implementation. ## Boundaries - `app/main.swift` remains the macOS host. It still launches the same server, uses the macOS image and colour-management tools, and reaches WGPU through Metal. - `windows-shell/` owns Windows windowing, native file dialogs, source-folder persistence, and the system webview. It launches the same local server and reaches WGPU through DirectX 12. - `platform_image.py` selects native macOS image services on macOS and the bundled portable decoder, metadata, and ICC path on Windows. - The web UI talks to either host through the small `postMessage` bridge. Film, grade, cache identity, and export math remain shared. This separation is intentional: platform work should not add a conditional to the measured render loop unless the operating system genuinely requires one. Learned denoise (Detail panel and Photo → Enhance Photo…) has no Core ML on Windows. `scripts/convert-models.py --format onnx` exports the same traced, bit-identity-gated SCUNet network to ONNX at the fixed 512×512 input Core ML uses, and `enhance_workflow.onnx_runner`/`onnx_batch_runner` run it in-process through onnxruntime (`onnxruntime-directml`, pinned in `packaging/runtime-windows.lock`) instead of the Swift helper subprocess. The DirectML execution provider accelerates on whatever GPU is present and falls back to CPU on its own; strength blends against the original with the same formula the Swift helper uses, so a strength value means the same thing on every platform. `scripts/smoke-denoise.py` exercises the packaged ONNX model after relocation, the same way `scripts/smoke-hair-mask.py` exercises the LiteRT hair model. The build bundles the converted model only when `scripts/models/onnx/denoise.onnx` is present (set `LIGHTTABLE_REQUIRE_DENOISE_MODEL=1` to make its absence a build failure); the release workflow's `denoise-onnx` job exports it with the pinned `scripts/models/requirements-convert-onnx.txt` environment, verifies it with `scripts/ci/check-converted-model.py --onnx`, and hands it to the Windows and Linux package jobs, which then require it. Pull-request builds never convert it, so a runtime built there reports learned denoise as unavailable. Full-resolution portable processed-image conversion decodes through OpenImageIO and applies ICC transforms through LittleCMS via the pinned `imagecodecs` runtime. 16-bit and floating-point intermediates preserve source detail instead of passing it through Pillow's 8-bit RGB conversion. These dependencies are loaded only for portable conversion; macOS retains its native image/color path, and the bounded preview path is unchanged. The portable processed-input cache has its own version so an existing Windows installation rebuilds its old 8-bit intermediates. Mac and RAW input cache identities remain unchanged. ## Runtime layout The embedded Python runtime ships a `python313._pth` file, and CPython treats that file as a request for isolated mode: `PYTHONPATH`, `PYTHONUNBUFFERED`, `PYTHONPYCACHEPREFIX`, and every other `PYTHON*` variable are ignored. The desktop shell and `lighttable.cmd` therefore pass what matters as interpreter options: `-u` keeps `server.log` current, and `-X pycache_prefix` caches bytecode under `%LOCALAPPDATA%\LightTable\python-bytecode` so the second launch skips recompiling the application and its scientific dependencies. `NUMBA_CACHE_DIR` (honoured, because it is not a `PYTHON*` variable) keeps compiled kernels under `%LOCALAPPDATA%\LightTable\compiled-runtime`, and the WebView2 profile lives under `%LOCALAPPDATA%\LightTable\WebView2` rather than beside the executable. The install directory stays read-only in practice. The shell opens its window immediately with a dark loading page and starts the render server on a background thread, so the first frame no longer waits for Python imports and catalog opening. Choosing another folder stops the running server before starting its replacement, because one catalog holds one process lease; a choice made while a start is in flight waits its turn, and a folder whose server cannot start returns to the previous one with a toast. The window remembers its size and maximized state, fits the current display, and uses the dark title bar and WebView2 colour scheme. Every helper the server starts (the resident engine, the one-shot exporter, the export worker, git) runs with `CREATE_NO_WINDOW`; under a console-less parent a console-subsystem child would otherwise open a visible command window. The OpenMP, numba, and BLAS pools use half the logical processors, between four and eight, instead of the fixed four the macOS host uses. ## Performance path On Windows, checking whether originals have changed reads each local file in full. Rescanning, opening photos, and export checks can take longer with large originals. These checks do not download files stored only in the cloud. The resident renderer remains a separate long-lived process on both platforms, so GPU device creation, pipeline compilation, profile loading, and decoded inputs stay warm. Adding Windows therefore does not force the Mac through a portable renderer or a cross-platform desktop framework. Decoded RGB16 pixels reach the resident engine through memory on Windows as they do on macOS. `multiprocessing.shared_memory` creates a named file mapping, the engine opens it with `OpenFileMappingW`, maps exactly the protocol length, and copies the pixels into its resident input cache before replying; the Python side releases the mapping afterwards. A full-resolution RAW export therefore no longer writes and re-reads a six-byte-per-pixel TIFF, and first previews at a new size skip the disk as well. The TIFF route remains the fallback, and an engine that reports the exchange unavailable is remembered so later renders go straight to TIFF. The interactive preview frame itself used to reach the webview as a JPEG: the resident engine's packed RGBA8 surface, encoded to JPEG on the server, decoded again by an `` element, then uploaded to a WebGL texture. Since the resident engine already produces that RGBA8 surface for the Mac's Metal path, a non-Metal client can now ask for it directly (`raw: true` on `/api/render`) and upload it with `texImage2D` from a `fetch()` + `ArrayBuffer`, skipping the JPEG encode and decode entirely; JPEG remains the transport for thumbnails and edited/baked renditions. Measured end-to-end improvement and remaining gaps (viewport-tile compositing for this path, a non-film source-image passthrough that stays on JPEG) are in [`../performance.md`](../performance.md#windowslinux-interactive-preview-transport-2026-09-13). If future measurement shows that webview texture upload and paint still dominate at large preview sizes even with that JPEG round trip removed, each native host can add a child WGPU viewport while retaining the webview for controls. The resident render protocol and shared editing/export math are already outside the host, so that experiment does not require another application rewrite or a forked Windows pipeline; a concrete crate/API plan, risks (WebView2 airspace foremost), and an estimate are in [`gpu-preview-design.md`](gpu-preview-design.md). ## Required proof before release 1. Run the full shared Python and Rust suites. 2. Compare `bench/benchmark.py` before and after on the Mac benchmark machine, including browser decode/upload/paint timing at 1100, 2200, and 5000 px. 3. Build on Windows x64 and pass `scripts/windows/runtime-smoke.py` from the packaged runtime. 4. Open the installed app on Windows and verify a RAW preview, a processed-file preview, folder operations, preset save, and an RGB16 TIFF export. 5. Confirm a RAW export reports `input_transport: shared-memory-rgb16`, that a second launch starts faster than the first, that no command window appears while rendering, and that switching folders shows the loading page rather than a frozen window. 6. Check an existing WebView2 runtime and a clean machine without one. Confirm setup handles the missing prerequisite and can be retried after an offline failure without damaging an existing install. 7. Compare 16-bit TIFF ramps and real RAW/JPEG/TIFF exports against the Mac reference. Check ICC profiles, orientation, and smooth gradients using the exported files, not only the remote desktop stream. The [Windows client acceptance record](../windows-client-acceptance.md) records the current Windows 10/11 and Windows 11 ARM emulation checks, exact release identity, and physical hardware coverage limits. ## First Windows GPU session Use a Windows x64 desktop with a graphics-capable GPU driver. Record the OS, driver, GPU, package source revision, and reported WGPU adapter/backend before benchmarking. A Windows Server cloud desktop can establish installation, rendering, and GPU behavior; a Windows 11 client still needs a separate pass. Start with a small reproducible set: two JPEGs, one 16-bit TIFF gradient, and RAWs from the cameras used for the existing Mac benchmarks. Test install, first and second launch, import, film changes, continuous slider movement, Compare, Fit/1:1 zoom, export, folder changes, quit/reopen, and edit recovery. Repeat at 100%, 150%, and 200% display scaling. Use local render timings to separate application delays from remote desktop latency, and download exported files for pixel/color inspection. Cloud streaming is not proof of calibrated monitor color or local display latency. Windows currently presents the photo through the webview; the Mac's native preview surface is not enabled in the Windows shell. Measure decode, GPU work, webview upload, and presentation separately before choosing whether a native DirectX viewport is needed. Apple-only AI providers and HEIF export also remain separate feature-porting work. ## Microsoft Store EXE candidate Store preparation uses the existing per-user NSIS installer. It is an opt-in build mode; direct downloads keep their current defaults. It does not reserve a Store name, enroll a publisher, upload anything, or prove certification. On a Windows build runner with the existing release-signing prerequisites, provide the **x64 Evergreen Standalone Installer** from [Microsoft WebView2 downloads](https://developer.microsoft.com/microsoft-edge/webview2/) and record its SHA256 when acquiring it. Do not use the small online bootstrapper. The build checks that pinned hash and a trusted Microsoft signature before any compilation. Offline setup never falls back to downloading a prerequisite. ```powershell ./scripts/windows/build-release.ps1 -Version 0.5.0 -StoreCandidate ` -OfflineWebView2Installer C:\release-inputs\MicrosoftEdgeWebView2RuntimeInstallerX64.exe ` -OfflineWebView2Sha256 '' ``` Use a selected stable release number in place of the example. Outputs are under `dist/store-candidate/`; do not overwrite an already submitted version. The existing Azure signing configuration runs only on approved main/version-tag GitHub Actions contexts. The command also requires the existing update-signing key. This mode does not change credentials, certificates, or Azure identity. Candidate builds: - Embed the standalone WebView2 prerequisite and invoke it silently when needed. - Scan the native payload by PE header, including Python `.pyd` modules. Sign unsigned EXE/DLL/PYD files with the configured release signer and preserve valid vendor signatures. Reject invalid signatures or unsupported unsigned PE suffixes rather than silently ignoring them. - Sign NSIS's generated uninstaller before embedding it, then audit every PE in the installed payload. `windows-store-pe-signatures.json` records each path, SHA256, signature status, and publisher. A failure blocks the candidate. - Set candidate installer CompanyName and installed-app publisher metadata to `Chonkers LLC`. Direct-release metadata remains unchanged. Metadata does not change the certificate's legal identity: review the actual signing identity with the Chonkers LLC Partner Center account before submission. Partner Center installation arguments: `/S` (case-sensitive). Silent uninstall uses `/S`. The installer is per-user and currently uses the direct WinSparkle updater; it is not an MSIX package. ### Evidence still required before initial submission The [current acceptance record](../windows-client-acceptance.md) binds the selected installer to its source, hash, signatures and native edit/export/restart checks. Windows 10 covers offline installation with WebView2 absent; Windows 11 covers offline installation with its preinstalled runtime. Keep those cases explicit when selecting future candidates. Recheck Store-specific requirements, listing screenshots, update ownership, and declarations against the final submitted installer; direct release validation is not Store certification. Host the final signed EXE at a permanent **versioned HTTPS URL** only after release approval. Record its SHA256 and never replace bytes at that URL. Complete publisher verification, name reservation, Store listing assets, privacy/support links, age rating, and certification notes in Partner Center. No Store release is prepared merely because the four original application signatures or the standard Windows workflow passed. Sources: [Store EXE package requirements](https://learn.microsoft.com/windows/apps/publish/publish-your-app/msi/app-package-requirements) and [Microsoft's offline WebView2 deployment](https://learn.microsoft.com/microsoft-edge/webview2/concepts/distribution#offline-deployment). ### Build a future candidate The checked-in `packaging/webview2-store-input.json` pins the acquired Microsoft x64 standalone download (258,614,480 bytes; SHA256 `e7fa35755196ad9223596ef021a1ce6799509142eaa40ba35f634026be50b831`). The workflow downloads only that Microsoft CDN URL without redirects and checks its byte length, SHA256, and trusted Microsoft signature on Windows. If the CDN no longer serves those exact bytes, acquire and review a new input explicitly; never replace the checksum automatically. ```sh gh workflow run windows-build.yml --repo reville/lighttable-digital-darkroom \ --ref VERSION_TAG -f version=VERSION -F store_candidate=true -F require_signing=true ``` Replace `VERSION_TAG` and `VERSION` with a new immutable source tag and its numeric version. Do not rebuild or overwrite an already published version. Store mode implies required signing even when the separate signing input is false, uses the existing `windows-release` environment, accepts only main/version-tag dispatches, and cannot be combined with native-recheck mode. The direct-download workflow defaults and artifact name remain unchanged. The `LightTable-windows-x64` workflow artifact contains the candidate ZIP and EXE, signature reports, signed appcast, and `store-candidate-receipt.json`. The receipt cross-checks the source commit, final installer hash, archive hash, prerequisite hash, and installed PE report including the uninstaller. Native edit/export/restart still gates the job and uploads separate `Windows-native-acceptance` evidence. The receipt explicitly leaves clean-machine offline acceptance unconfirmed until that independent Windows test is done. Select the current permanent installer URL and checksum from the [canonical release manifest](../../release/manifest.json), after verifying that the Windows entry is published. Store account approval, listing preparation and certification remain separate gates. Never replace bytes at a submitted URL.