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