Skip to content

Windows build

The contract iiiris maintains for first-class Windows support: which artifacts ship, what the installer guarantees, how the service-mode runtime behaves, and the build pipeline that produces all of it. This spec replaces docs/briefs/WINDOWS_BUILD.md now that Phases 3a + 3b + 3c have shipped end-to-end.

The operator-facing reference lives in ../deployment.md — install walkthroughs, upgrade procedures, service-mode operations. This spec captures what the implementation guarantees; that file captures how to use it.

Audiences and artifacts

Three audiences, three artifacts. Each tagged release publishes all three.

Artifact Audience Form
Source build Contributors, self-builders make build from an MSYS2 UCRT64 shell (see ../deployment.md).
iiirisd-vX.Y.Z-windows-amd64.zip Operators comfortable extracting a zip iiirisd.exe + ~90 MSYS2 UCRT64 DLLs + LICENSE/NOTICE/THIRD_PARTY_LICENSES.md. Extract and run.
iiirisd-vX.Y.Z-windows-amd64.msi Production deployers WiX v5 MSI. Per-user or all-users layout. Optional service-install mode with Event Log + rolling file logging. Self-signed.

Build pipeline contract

  • Toolchain: MSYS2 UCRT64. mingw-w64-ucrt-x86_64-libvips, mingw-w64-ucrt-x86_64-gcc, mingw-w64-ucrt-x86_64-pkg-config, mingw-w64-ucrt-x86_64-openjpeg2. govips (CGO) is the only reason CGO is needed; the build is otherwise pure Go. Pinned by the Phase 1.1 spike at Go 1.25.0 / gcc 16.1.0 / libvips 8.18.2.
  • Runner: GitLab SaaS Windows pool (saas-windows-medium-amd64). Cross-compiling CGO + libvips from Linux is impractical; Windows builds happen on Windows.
  • Linkage: dynamic. iiirisd.exe depends on ~90 MSYS2 UCRT64 DLLs at runtime; the zip and MSI both bundle them alongside the binary. LGPL §6(d) is satisfied trivially by drop-in replacement of the libvips DLL.
  • DLL inventory: deploy/installer/dlls.wxs is generated from the staged binary's ldd closure by scripts/wix-generate-dlls.ps1. The committed file is checked for staleness in CI (-Mode Check); MSYS2 dep drift fails the build with an actionable diff. Reviewer regenerates +commits before the build can pass.
  • CI cadence: build-windows-amd64 (zip) runs on every push; build-windows-msi (MSI) runs tag-only via the <<: *release-rules anchor — .wxs changes infrequently and SaaS Windows minutes are budgeted accordingly. Both jobs consume the same staging directory artifact, so the MSI bundles exactly the same binary + DLL set the zip ships.

MSI installer contract

Layout

  • Single MSI, two install scopes (WiX single-package authoring via ALLUSERS=2 MSIINSTALLPERUSER=…):
  • All-users — binary at C:\Program Files\iiiris\, data at C:\ProgramData\iiiris\{config,cache,logs}\. Per-machine, requires Administrator. Default.
  • Per-user — binary at %LocalAppData%\iiiris\, data still at %ProgramData%\iiiris\. No admin needed. Service-install mode is unavailable (HKLM writes required).
  • Layout chosen via WixUI_Advanced's WhichUsersDlg (per-machine default; per-user requires explicit user selection).

Default config

  • config.yaml.example (commented Windows-aware template) ships in installer/ and lands at %ProgramData%\iiiris\config\config.yaml on first install only. NeverOverwrite="yes" + Permanent="yes" together mean: the example never replaces operator edits, and uninstall preserves the (possibly-edited) config.

Data dirs

%ProgramData%\iiiris\{config,cache,logs}\ are created on install with Permanent="yes" on each Component. Uninstall preserves all three. Operators wanting a clean wipe remove %ProgramData%\iiiris\ manually after uninstall.

Service mode (optional)

  • Enabled via msiexec /i ... INSTALLSERVICE=1 (or the future GUI checkbox). Default is binary-install mode.
  • Service name: iiiris (matches the serviceName const in cmd/iiirisd/service_windows.go).
  • Display name: "iiiris IIIF Image Server".
  • Account: NT AUTHORITY\LocalSystem by default. Operators can swap via services.msc → Log On tab (NetworkService for least-privilege, or a dedicated domain account).
  • Start: auto on boot.
  • Service arguments: -config "%ProgramData%\iiiris\config\config.yaml" (resolved at install time via WiX's [CommonAppDataFolder] token).
  • ServiceControl: Start="install" Stop="both" Remove="uninstall" Wait="yes". Stop-on-both handles the upgrade dance: stop old → replace binary → start new.

Event Log source

The installer writes two REG values under HKLM\SYSTEM\CurrentControlSet\Services\EventLog\Application\iiiris when service-install is enabled:

  • EventMessageFile (REG_EXPAND_SZ) = path of the installed iiirisd.exe. No embedded message table; Windows logs records verbatim.
  • TypesSupported (REG_DWORD) = 7 (Info | Warning | Error).

internal/observability.EventLogHandler opens this source at runtime. If the source is absent (pre-3c installs, manual sc.exe registrations, per-user installs that can't write HKLM), the handler returns nil and falls back to file + stderr — graceful degradation by design.

Upgrade

MajorUpgrade Schedule="afterInstallExecute":

  1. New install begins.
  2. ServiceControl Stop="both" stops the running service.
  3. InstallFiles replaces iiirisd.exe + bundled DLLs (no file-lock contention because the service is stopped).
  4. ServiceControl Start="install" starts the new binary.
  5. RemoveExistingProducts removes the old MSI's product entry; same-Guid components are no-ops.

Brief downtime window (seconds). Single-host install assumption; no rolling-upgrade pattern.

Uninstall

Removes the binary, bundled DLLs, Start Menu shortcuts, service registration (when present), Event Log source registry keys. Preserves all Permanent="yes" components: %ProgramData%\iiiris\{config,cache,logs}\ plus the operator's config.yaml. Manual Remove-Item -Recurse "%ProgramData%\iiiris" is the clean-wipe escape hatch.

Start Menu shortcuts

Three entries under Start Menu → iiiris\:

  • "iiiris IIIF Server" — console-mode launch. Binary-install only (hidden in service mode via Component Condition; the service already binds :8080).
  • "Open iiiris admin" — Internet shortcut to http://localhost:8080/admin/. Always present; useful once IIIRIS_ADMIN_USER / IIIRIS_ADMIN_PASS are set.
  • "Uninstall iiiris" — msiexec /x [ProductCode]. Always present.

Code signing

  • Self-signed for the first cut. A future EV/OV cert procurement is a drop-in replacement (same CI secrets, the cert just changes; SIGN_MSI=1 stays).
  • SmartScreen shows "Unknown publisher" on first run; users click-through via "More info → Run anyway" or right-click → Properties → Unblock. The self-signature proves the artifact wasn't tampered after CI built it.
  • CI gate: SIGN_MSI variable, default "0". When "1", scripts/sign-msi.ps1 runs signtool sign /f <pfx> /p <pass> /tr http://timestamp.digicert.com /td sha256 /fd sha256 and verifies via signtool verify /pa.

Runtime contract (Windows service mode)

Phase 3a's Go-side surface in cmd/iiirisd/:

  • SCM context detection: isWindowsService() (via golang.org/x/sys/windows/svc.IsWindowsService) returns true when launched by SCM. Errors from the underlying API are treated as "not a service" so the binary falls back to console mode rather than wedging.
  • Service handler: runService(configPath) reuses setup(cfg, configPath, extras...) and serve(bundle, stop) from the console path. Extras = Event Log handler + rolling-file handler. The handler translates SCM Stop / Shutdown control events into a stop-channel close that serve() blocks on. Exit code flows back to SCM.
  • Build-tagged files: service_{windows,unix}.go, is_service_{windows,unix}.go, signals_{windows,unix}.go. Non-Windows stubs keep the package buildable; runtime dispatch never reaches them.

Portability fixes (Phase 1)

Universal cross-platform corrections that landed during Phase 1 and remain part of the contract:

  • signals_unix.go / signals_windows.goSIGTERM is Unix-only; the Windows path uses os.Interrupt. Build-tagged helper shutdownSignals() returns the right set per OS.
  • source.safePath rejects on every platform:
  • NUL bytes (defense-in-depth).
  • Colon : (Windows drive-letter injection + NTFS alternate data streams).
  • Reserved DOS device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9) in any path component, stem-matched with trailing dot/space stripped. Applies universally so Linux-authored collections stay portable.
  • cache.Filesystem.Put notes the Windows rename sharing-violation edge (open file during rename); the actual retry wrapper lands if realistic-load smoke surfaces the failure mode.

Smoke test contract

scripts/smoke-msi.ps1 runs after every build-windows-msi build. Two subtests:

  • Binary-mode — install (msiexec /qn ALLUSERS=2), run installed binary, /health, one IIIF render, uninstall, assert data preserved.
  • Service-mode — install (/qn ALLUSERS=2 INSTALLSERVICE=1), assert Get-Service iiiris returns a registered service + Event Log source registry keys exist with the right TypesSupported/EventMessageFile, poll /health on :8080, net stop iiiris, uninstall, assert service deregistered + Event Log keys gone + data still preserved.

The job's CI artifacts include both install/uninstall logs, the installed binary's stdio, the service log file, and the rendered JPEG — enough to diagnose any failure without reproducing locally.

A separate scripts/smoke-windows.ps1 (per-format + console + service-via-sc.exe lifecycle) runs after build-windows-amd64 and covers the staged-binary surface independent of the MSI.

Out of scope

  • Cross-compiling Windows from Linux. CGO + libvips on mingw-on-Linux is a tarpit; the project explicitly opted out. Windows builds happen on Windows.
  • macOS installer. Source build only; no published artifact.
  • Multi-architecture Windows. windows/amd64 only. windows/arm64 lands if demand materialises.
  • Auto-update / update channels. Each MSI is a manual download. MajorUpgrade handles the install-over-install case; nothing phones home.
  • In-installer GUI checkbox for service mode. Currently command-line-driven (INSTALLSERVICE=1). Custom dialog page is a polish follow-up.
  • PerfMon / ETW tracing. No Windows-specific telemetry beyond what slog + Event Log already provide.
  • HKEY_LOCAL_MACHINE writes outside the service path. The installer writes only what's needed for service registration + Event Log source. No per-app HKLM\SOFTWARE\iiiris keys outside the synthetic component KeyPath.