Build the Windows installer in a container, and run it under Wine

docs/windows.md specified it; this is §9 steps 1, 2 and 4 run, and the
report in §10. A Debian trixie image with rustup, the MinGW cross
compiler, NSIS and Wine; a build.sh in the shape of the Android one;
a package.sh that stages the executable and the seven models behind
the same LFS-pointer guard every other packager carries, then runs
makensis; and the .nsi itself — per-user, no elevation, an uninstaller
that leaves the library alone.

Measured: the executable links first time once the link flags were
right, imports only Windows system DLLs, prints its version under
Wine, and the installer installs and uninstalls silently under Wine
with the registry key and the models where §5.2 says. What Wine
cannot show is the Start Menu shortcut: CreateShortcut is IShellLink
and does nothing headless.

Four claims in the spec's first draft were wrong and are corrected in
place with the reasoning kept: the whole-archive winpthread flag
breaks the link and was never needed; build scripts need a host gcc;
bookworm's Wine lacks the bcryptprimitives.dll rustc's std imports,
so the image is trixie; and NSIS's default stub is 32-bit, so the
installer says amd64-unicode and needs no i386 Wine.
This commit is contained in:
2026-09-12 00:54:11 +02:00
parent fa4dca327f
commit 2836ec2881
6 changed files with 536 additions and 27 deletions
+107
View File
@@ -0,0 +1,107 @@
# DarkRoom — reproducible Windows cross-build environment
#
# Everything docs/windows.md §2 names: Rust with the GNU Windows target, the
# MinGW-w64 cross compiler it links with, NSIS to build the installer, and Wine
# to smoke-test the result. Both CI and local builds use this image, so "works
# on my machine" and "works in CI" are the same machine — the same argument
# docker/android makes, and the same shape.
#
# Build: docker build -t darkroom-windows:latest docker/windows
# Use: ./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
# trixie rather than the Android image's bookworm, for Wine: rustc's std
# imports bcryptprimitives.dll for its random source, and bookworm's Wine 8.0
# does not have it, so the smoke test dies at load with c0000135 before a
# single instruction of the application runs. Wine 10 does. trixie also ships
# Node 20 itself, so the NodeSource step the Android image needs is not here.
FROM docker.io/library/debian:trixie-slim
# ---------------------------------------------------------------------------
# Versions — pinned deliberately, like the Android image.
# ---------------------------------------------------------------------------
ARG RUST_VERSION=1.92.0
ENV DEBIAN_FRONTEND=noninteractive \
CARGO_HOME=/opt/cargo \
RUSTUP_HOME=/opt/rustup \
PATH=/opt/cargo/bin:$PATH
# ---------------------------------------------------------------------------
# System packages
# ---------------------------------------------------------------------------
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git git-lfs \
# A *host* C compiler as well as the cross one: build scripts and
# proc-macros are compiled for Linux and linked with `cc`, whatever
# the target. Without it the very first build script fails with
# "linker `cc` not found" before any Windows code is reached.
gcc libc6-dev \
# The cross compiler, binutils and the MinGW runtime headers/libs. This
# is the one C toolchain the target needs: bundled SQLite, ring's asm
# and anything else the cc crate builds for the target go through it.
gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 \
# The installer compiler. A native Linux binary; NSIS has always built
# its installers on POSIX hosts.
nsis \
# Runs the .exe and the installer for the smoke tests (windows.md §6).
# Not needed to build anything. Both packages: `wine64` is the
# loader under /usr/lib/wine, `wine` is the wrapper on PATH.
wine wine64 \
# `file` reports PE32+; `xz-utils` because the mingw packages are
# compressed with it.
file xz-utils \
# Gitea runs JavaScript actions (checkout, cache) from inside the job
# container, and current actions want Node 20 or newer.
nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version
# ---------------------------------------------------------------------------
# Rust + the Windows target
#
# The component list must be a superset of rust-toolchain.toml's, for the
# reason the Android Dockerfile gives: rustup reconciles that file on the
# first cargo invocation and downloads anything missing inside the job.
# ---------------------------------------------------------------------------
RUN curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain ${RUST_VERSION} \
&& rustup target add x86_64-pc-windows-gnu \
&& rustup component add rustfmt clippy rust-analyzer \
&& chmod -R a+rwX ${CARGO_HOME} ${RUSTUP_HOME}
# ---------------------------------------------------------------------------
# Linker configuration
#
# Debian ships the cross compiler in two thread models and the bare name is an
# alternatives symlink. `-posix` is stated: it is the one whose libstdc++ and
# libwinpthread the Rust target's own MinGW pieces were built against, and
# picking the other produces link errors that read as if std were missing.
#
# The runtime is linked statically (docs/windows.md §2) so the installer
# carries one file. `-static-libgcc` is all it takes: rustc's windows-gnu
# target links its own copy of winpthread in self-contained mode, so nothing
# imports libwinpthread-1.dll — the smoke test's objdump step is what checks
# that. The `--whole-archive -lwinpthread` incantation the spec first named is
# wrong here: it forces in unused winpthread objects whose kernel32 and
# msvcrt references come after those libraries on the link line, and the
# link fails on a hundred undefined `__imp_` symbols.
# ---------------------------------------------------------------------------
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix \
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-args=-static-libgcc -C link-args=-static-libstdc++" \
CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc-posix \
CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++-posix \
AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc-ar-posix \
WINDRES=x86_64-w64-mingw32-windres
# Wine writes its prefix under $HOME and refuses a directory it does not own.
# The caller passes --user, so nothing baked into the image can be owned by
# that user; build.sh bind-mounts a host directory here instead, which also
# keeps the prefix (and its slow first `wineboot`) across runs.
ENV HOME=/tmp/home \
WINEDEBUG=-all
VOLUME ["/opt/cargo/registry"]
WORKDIR /work
CMD ["/bin/bash"]
+56
View File
@@ -0,0 +1,56 @@
# Windows cross-build environment
Reproducible container for building the Windows executable and its installer from Linux. The
specification is [docs/windows.md](../../docs/windows.md); this directory is what it turned into,
and every departure from the spec's first draft is recorded in the Dockerfile's comments.
## Use
```bash
# Cross-compile the desktop application
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
# Lint the cfg(windows) branches, which the Linux job never sees
./docker/windows/build.sh cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
# Build the installer from that binary
./docker/windows/build.sh docker/windows/package.sh
# Smoke-test under Wine (docs/windows.md §6)
./docker/windows/build.sh wine target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe --version
./docker/windows/build.sh wine target-windows/installer/DarkRoom-0.12.0-x86_64-setup.exe /S
# Interactive shell
./docker/windows/build.sh
# After editing the Dockerfile
./docker/windows/build.sh --rebuild
```
Prefers `podman`, falls back to `docker`. The cargo registry, the target directory and the Wine
prefix persist under `~/.cache/darkroom-windows/`, so a warm rebuild is minutes and the first
`wineboot` happens once.
## What is verified here, and what is not
Measured on the first build, 2026-09-12:
| Check | Result |
|---|---|
| `cargo build --target x86_64-pc-windows-gnu -p darkroom-desktop` | Links. 115 MB, `PE32+ … (GUI)` |
| DLL imports | 26 Windows system DLLs; **no** `libwinpthread-1.dll`, `libgcc_s`, `libstdc++` |
| `wine darkroom-desktop.exe --version` | `darkroom-desktop 0.12.0`, exit 0, 0.1 s |
| `makensis` | 105 MB `DarkRoom-<version>-x86_64-setup.exe`, 64-bit stub |
| `wine setup.exe /S` | Installs exe + 7 models to `AppData\Local\Programs\DarkRoom`, writes the `HKCU` uninstall key; the installed exe runs |
| `wine uninstall.exe /S` | Removes the directory and the key |
| Start Menu shortcut | **Not verifiable here.** `CreateShortcut` is `IShellLink` and does nothing under a headless Wine; the directory beside it is created. Check on Windows. |
None of this proves a Vulkan device opens, a render completes, fonts are found or the secret store
round-trips. Those are a Windows machine, once per release — and today the secret store *cannot*
round-trip, because its Windows implementation is still the loud placeholder (docs/windows.md
§3.2).
## In CI
Not yet wired. The image and the leg are specified in docs/windows.md §7 in the shape of the
Android ones, and every step they would run has been exercised by hand above.
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Run a command inside the DarkRoom Windows cross-build container.
#
# ./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
# ./docker/windows/build.sh cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop
# ./docker/windows/build.sh # interactive shell
#
# Builds the image on first use. Rebuild after editing the Dockerfile with:
# ./docker/windows/build.sh --rebuild
set -euo pipefail
IMAGE="darkroom-windows:latest"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${HERE}/../.." && pwd)"
# Prefer podman (rootless by default); fall back to docker.
if command -v podman >/dev/null 2>&1; then
ENGINE=podman
elif command -v docker >/dev/null 2>&1; then
ENGINE=docker
else
echo "error: neither podman nor docker found" >&2
exit 1
fi
if [[ "${1:-}" == "--rebuild" ]]; then
shift
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
elif ! "${ENGINE}" image exists "${IMAGE}" 2>/dev/null && \
! "${ENGINE}" image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "==> building ${IMAGE} (first run; several minutes)"
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
fi
# Persist the cargo registry and target dir across runs, or every build
# re-downloads the crate index.
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-windows"
# `home` is the container's $HOME: Wine keeps its prefix there for the smoke
# tests, and refuses one it does not own — which rules out anything the image
# could have created, since the container runs as the host user.
mkdir -p "${CACHE}/registry" "${CACHE}/target" "${CACHE}/home"
ARGS=(
--rm
-v "${REPO}:/work:z"
-v "${CACHE}/registry:/opt/cargo/registry:z"
-v "${CACHE}/target:/work/target-windows:z"
-v "${CACHE}/home:/tmp/home:z"
-e CARGO_TARGET_DIR=/work/target-windows
-w /work
)
# Build parallelism. A full cross-compile will otherwise take every thread on
# the host and make the machine unusable for the length of the build, which is
# a poor trade when it is running in the background.
#
# Both halves are needed: CARGO_BUILD_JOBS caps how many rustc processes cargo
# starts, while --cpus caps what the container gets no matter what any nested
# build script decides to spawn (cc, cmake, and ring's asm build all parallelise
# on their own account and do not consult cargo).
JOBS="${DARKROOM_BUILD_JOBS:-8}"
if [[ "${JOBS}" != "0" ]]; then
ARGS+=(--cpus "${JOBS}" -e "CARGO_BUILD_JOBS=${JOBS}")
fi
# Rootless podman already maps the host user; docker needs it stated.
if [[ "${ENGINE}" == "docker" ]]; then
ARGS+=(--user "$(id -u):$(id -g)")
fi
if [[ $# -eq 0 ]]; then
ARGS+=(-it)
set -- /bin/bash
fi
exec "${ENGINE}" run "${ARGS[@]}" "${IMAGE}" "$@"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Assemble the Windows installer from a finished cross-build.
#
# ./docker/windows/build.sh docker/windows/package.sh
#
# Expects `cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop`
# to have run in the same target directory. Produces
# DarkRoom-<version>-x86_64-setup.exe in $OUT (default: target-windows/installer).
#
# Runs inside the container, where makensis is; docs/windows.md §5 is the
# specification this implements.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${HERE}/../.." && pwd)"
TARGET="${CARGO_TARGET_DIR:-${REPO}/target}/x86_64-pc-windows-gnu/release"
OUT="${OUT:-${CARGO_TARGET_DIR:-${REPO}/target}/installer}"
EXE="${TARGET}/darkroom-desktop.exe"
[[ -f "${EXE}" ]] || { echo "error: ${EXE} not built" >&2; exit 1; }
# The version, from the workspace rather than restated here — the same single
# source tools/set-version.sh writes and the APK packager reads.
VERSION="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)"
[[ -n "${VERSION}" ]] || { echo "error: no version in Cargo.toml" >&2; exit 1; }
echo "==> version ${VERSION}"
# Staging: exactly what the installer carries (windows.md §5.2), and nothing
# from a previous run — a model dropped from the tree must not linger here
# and go on shipping.
STAGE="${OUT}/stage"
rm -rf "${STAGE}"
mkdir -p "${STAGE}/models"
cp "${EXE}" "${STAGE}/darkroom.exe"
# The models, with the guard every other packager carries: an LFS pointer is
# ~130 bytes and looks exactly like a model to `cp`. Shipped, it fails inside
# tract on the user's machine with a message about a broken graph rather than
# a checkout that needed `git lfs pull`. Only the weights are checked; the
# scene model's vocabulary and category descriptor are legitimately small.
for dir in face scene; do
for f in "${REPO}/models/${dir}"/*; do
case "$(basename "${f}")" in
README.md) continue ;;
esac
if [[ "${f}" == *.onnx && "$(stat -c%s "${f}")" -lt 100000 ]]; then
echo "error: $(basename "${f}") is $(stat -c%s "${f}") bytes — an LFS pointer, not a model." >&2
echo " run: git lfs pull" >&2
exit 1
fi
cp "${f}" "${STAGE}/models/"
done
done
echo "==> staged $(ls "${STAGE}/models" | wc -l) model file(s)"
INSTALLER="${OUT}/DarkRoom-${VERSION}-x86_64-setup.exe"
# NSIS wants Windows-style paths in File directives even on a POSIX host, and
# the script takes its inputs by define so nothing about the layout here is
# written into it.
makensis -V2 \
-DVERSION="${VERSION}" \
-DSTAGE="${STAGE}" \
-DOUT="${INSTALLER}" \
"${REPO}/packaging/windows/darkroom.nsi"
ls -la "${INSTALLER}"
echo "==> ${INSTALLER}"
+97 -27
View File
@@ -10,9 +10,11 @@ tree has to change to compile for the target, what the installer does, how the C
and — because there is no Windows hardware on the runner — exactly how much of the result can be
verified before a person double-clicks it.
**It is a spec, not a report.** Nothing below has been built. Where a claim rests on reading rather
than running, it says so, in the same discipline [faces.md §2.3](faces.md) applies to licences: the
cheap way to find out is to read first, and the expensive way is at packaging time.
**Written as a spec; §10 is the report.** §9's steps 1, 2 and 4 have since been run —
[`docker/windows/`](../docker/windows/) is the container, [`packaging/windows/darkroom.nsi`](../packaging/windows/darkroom.nsi)
the installer, and §6's gate passes through row 4 under Wine. Four claims in the first draft were
wrong and are corrected in place with a note; §10 lists them. Where a claim still rests on reading
rather than running, it says so.
---
@@ -59,18 +61,29 @@ conventional binary — the same CRT every other Windows application links — a
1.5 GB fetch of Microsoft-licensed headers on every cold CI run. That is the wrong trade for a
channel whose users are, for now, the author.
Static-link the MinGW runtime so the installer carries one file rather than three:
Static-link the MinGW runtime so the installer carries one file rather than three. The
configuration lives in the container as environment variables rather than in a `.cargo/config.toml`
— that file is untracked here on purpose, and the Android image sets its linkers the same way:
```toml
# .cargo/config.toml
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-gcc-ar"
rustflags = ["-C", "link-args=-static-libgcc -static-libstdc++ -Wl,-Bstatic,--whole-archive -lwinpthread -Wl,--no-whole-archive"]
```sh
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-args=-static-libgcc -C link-args=-static-libstdc++"
```
The `winpthread` incantation is the well-known one and is stated rather than derived; it is the
first thing to check if the resulting `.exe` complains about a missing `libwinpthread-1.dll`.
**Corrected.** The first draft added `-Wl,--whole-archive -lwinpthread` "so nothing imports
`libwinpthread-1.dll`". That flag breaks the link: forcing the whole archive drags in unused
winpthread objects whose kernel32 and msvcrt references land after those libraries on the link
line, and the build dies on a hundred undefined `__imp_` symbols. It was also unnecessary —
rustc's windows-gnu target links its own winpthread in self-contained mode, and the built
executable imports no MinGW library at all (§10). `-posix` is stated because Debian's bare
`x86_64-w64-mingw32-gcc` is an alternatives symlink to either thread model.
Two more things the container needs that the draft did not name: a **host** `gcc`, because build
scripts and proc-macros compile for Linux whatever the target and the very first one fails with
"linker `cc` not found" without it; and **Wine 10**, because rustc's std imports
`bcryptprimitives.dll` for its random source and Debian bookworm's Wine 8.0 does not have it —
the smoke test dies at load with `c0000135` before the first instruction. So the image is
`debian:trixie-slim`, which also ships Node 20 natively.
### 2.1 What the binary reaches at runtime
@@ -96,13 +109,14 @@ there explains why).
```
docker/windows/
Dockerfile ubuntu + rustup (1.92.0, target x86_64-pc-windows-gnu) + gcc-mingw-w64-x86-64 + nsis + wine64 + osslsigncode
build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
package.sh the LFS guard, then makensis
Dockerfile debian:trixie + rustup (1.92.0, target x86_64-pc-windows-gnu) + gcc + gcc-mingw-w64-x86-64 + nsis + wine
build.sh run a command in the container; caches registry, target and the Wine prefix
package.sh the LFS guard, staging, then makensis
```
`makensis` is a Linux binary; NSIS has always been buildable and runnable on POSIX hosts, and
Debian ships it as `nsis`. Wine is in the image for §6, not for the build.
Debian ships it as `nsis`. Wine is in the image for §6, not for the build. `osslsigncode` is not
in it until there is a certificate to give it (§5.4).
---
@@ -172,19 +186,31 @@ Ordered by what blocks a first sign-in.
the first cross-compile will name any that are not, and the fix is a `cfg` rather than a
Windows ACL equivalent — the files in question are the user's own.
6. **The executable's identity.** Windows takes the icon and the version block from a resource
compiled into the `.exe`, not from a `.desktop` file. A `build.rs` in `darkroom-desktop` using
`winresource` (which invokes MinGW's `windres` when cross-compiling) embeds
[`ui/dr-ui/ui/app-icon.png`](../ui/dr-ui/ui/app-icon.png) converted to `.ico`, the version
from `CARGO_PKG_VERSION`, and the product name. This is the fourth place the identifier lives
(distribution.md §1's "one identifier, four places" becomes five), and
[`tools/set-version.sh`](../tools/set-version.sh) does not need to learn it: the resource reads
the version cargo already knows.
6. **The executable's identity.** *Done.* Windows takes the icon and the version block from a
resource compiled into the `.exe`, not from a `.desktop` file.
[`apps/darkroom-desktop/build.rs`](../apps/darkroom-desktop/build.rs) uses `winresource`
(which invokes MinGW's `windres` when cross-compiling) to embed
[`ui/dr-ui/ui/app-icon.png`](../ui/dr-ui/ui/app-icon.png) — wrapped into an `.ico` in
`OUT_DIR` at build time, since an ICO entry may be a PNG, so no generated binary is committed —
plus the version from `CARGO_PKG_VERSION` and the product name. The script returns before
touching the crate on every other target, and `winresource` is an unconditional
build-dependency because **a `cfg(windows)` on a build-dependency is evaluated against the
host**, which is Linux. This is the fifth place the identifier lives, and
[`tools/set-version.sh`](../tools/set-version.sh) does not need to learn it: the resource
reads the version cargo already knows. The same commit made the release binary a GUI-subsystem
executable (`windows_subsystem = "windows"`), or Windows keeps a console window open behind
the application.
Everything in this list is `cfg(windows)` code in `dr-plat` or a call-site switch in `dr-ui`, and
none of it touches the image core, the catalog schema, or the edit pipeline. That is the NFR-PORT-3
test, and it should be stated in the commit that closes the list whether it passed.
**What the first cross-compile actually found** (§9 step 2): nothing in this list blocked the
link. The whole graph compiled; the only warnings were two constants — `SERVICE` in `secrets.rs`
and `TIMEOUT` in `desktop_client.rs` — left unused by the `cfg`s that already shadow their users,
now guarded the same way. Items 1–4 are still open, and the binary starts without them; it just
cannot sign in.
### 3.3 Explicitly not in this pass
- **MIME/file-type registration** — FR-PLAT-LIN-1's `.desktop` MIME entries have a registry
@@ -252,7 +278,9 @@ $LOCALAPPDATA\Programs\DarkRoom\
Plus a Start Menu shortcut, and nothing on the desktop unless the user ticks it. The models are
the same seven files the APK bundles and the PKGBUILD installs; `models\` beside the executable is
where §3.2's lookup finds them. The face weights carry the research-only grant that
where §3.2's lookup finds them. **No `LICENSE` yet**: the repository has no licence file at its
root (the Arch package points at the system's shared GPL text), so the installer has no licence
page until one is added — a one-file change, and the `.nsi` says where the page then goes. The face weights carry the research-only grant that
[faces.md §2](faces.md) records, and this channel changes nothing about that: the installer is
for the author's own machines until §2.2a's caveat is resolved, exactly as the APK is.
@@ -287,6 +315,11 @@ real but thinly used. Inno Setup runs only under Wine. NSIS is scriptable in pla
natively on Linux, produces a single self-contained `.exe`, and the script for §5.2 is under a
hundred lines. It is the conventional answer for exactly this situation.
One choice inside NSIS: `Target amd64-unicode`, a 64-bit installer rather than the default 32-bit
stub. The application is x86_64 only so nothing is lost, and it is what lets §6's install test run
under a 64-bit-only Wine — the 32-bit stub needs an i386 multiarch Wine and dies loading the WoW64
`ntdll` without one.
---
## 6. Verifying without Windows
@@ -307,8 +340,14 @@ it needs `winevulkan` to find a host ICD, `xvfb`, and a Wine prefix warmed up in
and every one of those is a moving part that has nothing to do with whether the application works
on Windows.
`--version` does not exist yet — `main.rs` takes paths and nothing else — and it is added for this,
because a smoke test needs an exit that does not open a window. It is two lines.
`--version` exists for this — a smoke test needs an exit that opens no window and touches no
directory — and it is answered before the logger and the crash hook install, so it proves the CRT
and the resource block and nothing above them.
**One more row the table missed:** the Start Menu shortcut. `CreateShortcut` is `IShellLink`,
which does nothing under a headless Wine while the `CreateDirectory` beside it succeeds, so an
installer that installs and uninstalls cleanly here can still have a broken shortcut. That row is
on Windows only.
**What none of this proves:** that wgpu opens a Vulkan device on a real driver, that a 6000-px
render completes, that fonts are found, that the secret store round-trips. Those are a person with
@@ -396,3 +435,34 @@ its first real test, and the commit that closes §3.2 records the answer.
Steps 1–2 are cheap and either confirm this document or replace §3.2 with the true list. Nothing
past step 2 should be started on the strength of this document alone.
---
## 10. Report · 2026-09-12
Steps 1, 2 and 4 run, in the container rather than on the developer machine, because the
container was the cheaper way to get a pinned MinGW and a Wine that could be thrown away.
| §6 row | Result |
|---|---|
| It links | Yes, first attempt once the link flags were right. 115 MB, `PE32+ … (GUI)`. Two dead-code warnings, both `cfg`-shadowed constants, fixed. |
| It is a Windows executable | 26 imports, all Windows system DLLs. No MinGW runtime. `.rsrc` carries `PRODUCTVERSION 0,12,0,0`, `ProductName DarkRoom`, the icon. |
| It starts | `wine darkroom-desktop.exe --version` → `darkroom-desktop 0.12.0`, exit 0, 0.1 s. |
| The installer runs | `makensis` → 105 MB. `/S` installs the exe and seven models to `AppData\Local\Programs\DarkRoom`, writes the `HKCU` uninstall key; the installed exe runs; `uninstall.exe /S` removes directory and key. Shortcut unverifiable (§6). |
| It draws a window | Not attempted. |
**What the first draft got wrong**, kept in place above with a note rather than rewritten, because
the reasoning that produced each mistake is the thing a reader will otherwise repeat:
1. The `--whole-archive -lwinpthread` link flag (§2) — breaks the link and was never needed.
2. No host C compiler in the image (§2) — build scripts are host binaries.
3. Debian bookworm's Wine (§2) — lacks `bcryptprimitives.dll`, which rustc's std imports.
4. A `cfg(windows)` on the `winresource` build-dependency (§3.2 item 6) — evaluated against the
host, so the crate was silently absent from the cross-build.
And two things it did not know to say: NSIS's default stub is 32-bit (§5.5), and `CreateShortcut`
cannot be verified headless (§6).
**Still open**, in the order §3.2 gives them: the secret store, the known-folder paths, the
models lookup beside the executable, the sign-in URL. The binary that exists today starts, and
cannot sign in. Then §7's CI leg, whose every step has now been run by hand once.
+133
View File
@@ -0,0 +1,133 @@
; DarkRoom — Windows installer
;
; Built by docker/windows/package.sh with makensis on Linux; see docs/windows.md
; §5 for every decision below. Nothing here is Windows-specific to *write* —
; NSIS has always compiled on POSIX hosts.
;
; makensis -DVERSION=0.12.0 -DSTAGE=/path/to/staging -DOUT=/path/to/setup.exe darkroom.nsi
;
; STAGE holds exactly what §5.2 installs: darkroom.exe and models\.
; package.sh assembles it and applies the LFS-pointer guard before this runs.
;
; No licence page yet: the repository carries no LICENSE file (the Arch package
; points at the system's shared GPL text), and a page needs a file to show.
; Adding one at the root is the whole change; `!insertmacro MUI_PAGE_LICENSE`
; then goes before the directory page.
; A 64-bit installer, not NSIS's default 32-bit stub. The application is
; x86_64 only, so nothing is lost; and it means the smoke test runs under a
; 64-bit-only Wine rather than needing an i386 multiarch: the 32-bit stub
; cannot load the WoW64 ntdll there and dies before showing a page.
Target amd64-unicode
Unicode true
SetCompressor /SOLID lzma
!ifndef VERSION
!error "pass -DVERSION=x.y.z"
!endif
!ifndef STAGE
!error "pass -DSTAGE=<staging directory>"
!endif
!ifndef OUT
!error "pass -DOUT=<installer path>"
!endif
!define NAME "DarkRoom"
!define PUBLISHER "Duncan Tourolle"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${NAME}"
Name "${NAME} ${VERSION}"
OutFile "${OUT}"
; Per-user, no elevation (docs/windows.md §5.1). An unsigned installer that
; also asks for administrator rights is the most alarming thing Windows can
; show, and a per-user install keeps the binaries under the same account as
; the library the application builds.
RequestExecutionLevel user
InstallDir "$LOCALAPPDATA\Programs\${NAME}"
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation"
; The resource block the executable itself carries (`winresource` in
; darkroom-desktop's build.rs) is what Explorer shows for the application;
; this is what it shows for the installer.
VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "${NAME}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "CompanyName" "${PUBLISHER}"
VIAddVersionKey "LegalCopyright" "GPL-3.0-or-later"
VIAddVersionKey "FileDescription" "${NAME} installer"
!include "MUI2.nsh"
!define MUI_ABORTWARNING
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Section "DarkRoom" SecMain
SectionIn RO
SetOutPath "$INSTDIR"
File "${STAGE}\darkroom.exe"
; The seven model files (four face, three scene), beside the executable,
; which is where `library::system_face_models_dirs` looks on Windows —
; last, after the user's own directories, exactly as /usr/share is on Linux.
SetOutPath "$INSTDIR\models"
File /r "${STAGE}\models\*"
WriteUninstaller "$INSTDIR\uninstall.exe"
; Add/Remove Programs. HKCU, to match the per-user install.
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${NAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${PUBLISHER}"
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\darkroom.exe"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
WriteRegDWORD HKCU "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKCU "${UNINST_KEY}" "NoRepair" 1
CreateDirectory "$SMPROGRAMS\${NAME}"
CreateShortcut "$SMPROGRAMS\${NAME}\${NAME}.lnk" "$INSTDIR\darkroom.exe"
CreateShortcut "$SMPROGRAMS\${NAME}\Uninstall ${NAME}.lnk" "$INSTDIR\uninstall.exe"
SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${NAME}.lnk" "$INSTDIR\darkroom.exe"
SectionEnd
; Off by default: a desktop icon is the user's to ask for.
Function .onInit
SectionSetFlags ${SecDesktop} 0
FunctionEnd
Section "Uninstall"
; What the installer wrote, and nothing the application wrote
; (docs/windows.md §5.3): %LOCALAPPDATA%\darkroom and %APPDATA%\darkroom —
; the catalog, the thumbnails, the face index, the settings — stay. An
; uninstaller that deletes a library index the user spent two hours
; building is the destructive default this project argues against
; everywhere else, so the confirm page says where those directories are.
Delete "$INSTDIR\darkroom.exe"
Delete "$INSTDIR\uninstall.exe"
RMDir /r "$INSTDIR\models"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\${NAME}\${NAME}.lnk"
Delete "$SMPROGRAMS\${NAME}\Uninstall ${NAME}.lnk"
RMDir "$SMPROGRAMS\${NAME}"
Delete "$DESKTOP\${NAME}.lnk"
DeleteRegKey HKCU "${UNINST_KEY}"
SectionEnd
Function un.onInit
MessageBox MB_OKCANCEL|MB_ICONINFORMATION \
"This removes the ${NAME} program.$\r$\n$\r$\nYour library index and settings are kept, in:$\r$\n $LOCALAPPDATA\darkroom$\r$\n $APPDATA\darkroom" \
/SD IDOK IDOK done
Abort
done:
FunctionEnd