diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3ebee8..fcf9f0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,27 @@ on: env: # The mcpp release the consumers build with. Raising it is what admits a # member that relies on a newer engine; the README states each member's floor. - MCPP_VERSION: 2026.9.8.1 + # 2026.9.11.1 is what `dist-*` needs at the API level: + # `mcpp::provides_pack_format`, `mcpp::pack_format`, `${mcpp.stage_dir}` and + # `mcpp::package_version` all arrived in it. Raising this is what admits a + # member relying on a newer engine; the README states each member's floor, + # and the index descriptor's own `min_mcpp` is a different question -- it + # bounds the DESCRIPTOR's syntax, and raising it would brick every older + # client's whole index. + # + # 2026.9.11.2 IS WHAT `dist-apple` NEEDS TO BE REACHABLE AT ALL, and the red + # lane was the dependency rather than a convention. Under 2026.9.11.1 the + # macOS job failed with + # + # error: cannot package the Mach-O program 'app-consumer' yet. + # + # because `mcpp pack` staged unconditionally before dispatching and let a + # staging failure fail the command -- and its built-in closure walk uses + # `LD_TRACE_LOADED_OBJECTS`, which dyld answers by RUNNING the program. So + # every dispatched format was unreachable on macOS, including one that never + # reads the staged tree. Staging is a service to the provider in 2026.9.11.2, + # and `dist-apple` has been unreachable, not broken, since it was written. + MCPP_VERSION: 2026.9.11.2 # PINNED, AND WITHOUT IT THE CACHE BELOW CACHED NOTHING. # # A released mcpp is self-contained: with no `MCPP_HOME`, `mcpp self env` @@ -925,6 +945,118 @@ jobs: fi echo "ok: two payloads, one import, sizes exact" + # N INPUTS, ONE HEADER, ONE TABLE -- the shape `file()` and `files()` + # cannot express, taken from a shader set whose consumer iterates rather + # than naming each entry. + # + # The fixture's own build program runs three more `table()` calls than + # the consumer needs, and the build only reaches "Finished" because all + # of them behave: a duplicate key is refused naming BOTH inputs, the + # same collision is resolved by `key_kind::relative_path`, and a + # non-multiple-of-4 input under `element::word32` is refused naming the + # file. Each was checked by fault injection while it was written -- the + # refusal was disabled and the probe caught it -- so these are not + # assertions that pass by never running. + - name: tools-embed table() through a consumer + working-directory: tests/embed-table-consumer + run: | + "$MCPP" build + "$MCPP" run | tee run.log + grep -q '^row 0: key=Standard.vert size=20' run.log + grep -q '^row 1: key=Standard.frag size=22' run.log + grep -q '^all ok' run.log + # EVERY GENERATED HEADER, NOT WHICHEVER `find` LISTED FIRST. This + # fixture writes three -- the real table plus probes that exercise + # `key_kind::relative_path` and `element::word32` -- and `head -1` + # picked one by directory order, so the check was asserting about a + # file nothing chose. + mapfile -t headers < <(find target/.build-mcpp/out -name '*.h' | sort) + [ "${#headers[@]}" -gt 0 ] || { echo "FAIL: no generated header"; exit 1; } + echo "checking ${#headers[@]} generated header(s)" + for h in "${headers[@]}"; do + # The bytes are a numeric array and never a raw string literal: a + # raw literal cannot carry arbitrary binary, and its delimiter is + # terminable by the input. The CMake code this shape replaces used + # string concatenation and has exactly that defect. + if grep -q 'R"' "$h"; then + echo "FAIL: $h emitted a raw string literal"; exit 1 + fi + # SELF-CONTAINED, ASSERTED AS A PROPERTY AND NOT AS A NAME. The + # row type is declared beside the array, so the header compiles on + # its own. The first version grepped for `struct embedded_file`, + # which is the DEFAULT `row_type` -- and this fixture sets + # `row_type = "shader_entry"` precisely because that option + # exists. So the assertion failed with the code correct, which is + # what a check tied to a spelling the fixture chooses will + # eventually always do. + # + # What is wanted: the header declares a struct, and the table's + # element type IS that struct. Both read out of the file. + row=$(sed -n 's/^struct \([A-Za-z_][A-Za-z0-9_]*\) {.*/\1/p' "$h" | head -1) + test -n "$row" \ + || { echo "FAIL: $h declares no row struct"; head -30 "$h"; exit 1; } + grep -q "constexpr $row .*\[\] = {" "$h" \ + || { echo "FAIL: $h's element type is not the struct it declares ($row)" + head -40 "$h"; exit 1; } + echo " ok: $(basename $h) declares '$row' and its table is an array of it" + done + echo "ok: N inputs, one header, one table" + + # ── dist: what comes out of the LINK ──────────────────────────────── + # + # Every step above asserts what reaches a COMPILE. These assert what + # comes out of the link, which is a different pass of the engine: the + # build program runs a SECOND time, after `mcpp pack` has staged the + # closure, and submits an artifact action whose input is a directory that + # did not exist during the build. + - name: dist-appimage produces an AppImage, and it runs + working-directory: tests/appimage-consumer + run: | + # A PLAIN BUILD HAS NO DISTRIBUTION EDGE. The member declares the + # format unconditionally and submits only when asked, so this build + # must produce no AppImage at all -- the half of the contract a + # member author is most likely to get wrong, and the half that still + # works for whoever wrote it. + "$MCPP" build + if find target -name '*.AppImage' | grep -q .; then + echo "FAIL: a plain build produced a distributable"; exit 1 + fi + # AND YET THE FORMAT IS KNOWN, which is what the unconditional + # declaration buys: the engine can name what is available on a build + # that asked for nothing. + set +e + so=$("$MCPP" pack --format bogus 2>/dev/null); rc=$? + se=$("$MCPP" pack --format bogus 2>&1 >/dev/null) + set -e + [ "$rc" = 2 ] || { echo "FAIL: --format bogus exited $rc, expected 2"; exit 1; } + [ -z "$so" ] || { echo "FAIL: the refusal wrote to stdout"; exit 1; } + grep -q 'available in this build: tar, dir, appimage' <<<"$se" \ + || { echo "FAIL: the refusal does not name appimage"; echo "$se"; exit 1; } + + "$MCPP" pack --format appimage | tee pack.log + img=$(find target -name '*.AppImage' | head -1) + test -n "$img" || { cat pack.log; echo "FAIL: no AppImage"; exit 1; } + # THE CRITERION IS THAT IT RUNS AND PRINTS, not that a file exists. + # A packaging step that succeeds while carrying nothing is the + # measured failure this whole category exists to prevent, and a valid + # empty image satisfies every check that stops at the file. + # + # `--appimage-extract-and-run` because a runner has no FUSE: measured + # with `fusermount` off the PATH, the plain invocation exits 127 with + # "No suitable fusermount binary found on the $PATH". + chmod +x "$img" + APPIMAGE_EXTRACT_AND_RUN=1 "$img" | tee run.log + grep -q '^appimage-consumer ok' run.log \ + || { echo "FAIL: the AppImage did not run the program"; exit 1; } + # No network was reached to build it. appimagetool downloads its + # type-2 runtime stub on every invocation unless `--runtime-file` + # names a local copy, and `xim:appimagetool` carves one out of + # itself for exactly this reason. + if grep -qi 'downloading runtime' pack.log; then + echo "FAIL: the build fetched a runtime stub over the network"; exit 1 + fi + echo "ok: one AppImage, and it printed the program's output" + # Compiles the device unit on a machine with no GPU: the clang route # produces sm_89 code from the payload toolkit. Running it needs a # device, so the run is of the CPU variant, which the same seam serves. @@ -1380,6 +1512,146 @@ jobs: fi echo "ok: the boundary is generated and namespaced on this host" + # ── dist, on the platform each member serves ──────────────────────── + # + # A PLAN-LEVEL ASSERTION IS NOT A MEASUREMENT. `plan_for()` returning + # `applies == false` on the wrong OS says the gate works; it says nothing + # about whether the tool accepts what the member renders. These two steps + # exist because that question is only answerable here, and because the + # members were written on a Linux host where neither tool can run. + # + # THE FIRST THING EITHER ONE SETTLES IS A SPELLING. `dist-wix` writes + # `$(Executable)` in its generated `.wxs`, taken from a working + # implementation rather than from WiX's documentation, which spells a + # `-d` define as `$(var.Executable)`. Only a real `wix build` can say + # which one this schema accepts. + - name: dist-wix produces an MSI that carries the program + if: runner.os == 'Windows' + working-directory: tests/msi-consumer + shell: bash + run: | + set -e + # WiX ships through NuGet and is not redistributable through this + # ecosystem, which is why the member LOCATES it rather than declaring + # a payload. Installing it here is the CI runner supplying what a + # developer's machine would. + # WiX 6, PINNED, AND THE PIN IS THE WHOLE POINT. + # + # An unpinned `dotnet tool install wix` gets v7, which REFUSES TO RUN + # AT ALL until a licence is accepted out of band. Measured on + # windows-2022: + # + # wix.exe : error WIX7015: You must accept the Open Source + # Maintenance Fee (OSMF) EULA to use WiX Toolset v7. + # + # That is a runtime gate, not a licence term about redistribution: 6's + # own EULA states that binaries may be redistributed and that the + # source remains freely distributable under the Microsoft Reciprocal + # License. So 6 is the version this ecosystem can actually drive, and + # it is also the version `xim:wix` should carry when that package + # lands -- a package pinning 7 would install a tool that cannot run. + # + # THE DISPATCH WAS ALREADY CORRECT WHEN THIS FAILED, which is worth + # recording because it is why the fix is a version pin and not a code + # change. The same run reported + # + # Distributing msi-consumer v0.3.0 (--format msi) + # wix.exe build -arch x64 -d Executable=bin/msi-consumer.exe -o ... MsiConsumer.wxs + # + # so the `.wxs` was rendered, the argv was assembled and the engine + # handed the request to the member. Only the tool declined. + dotnet tool install --global wix --version 6.0.2 >/dev/null 2>&1 \ + || dotnet tool update --global wix --version 6.0.2 >/dev/null 2>&1 + export PATH="$PATH:$HOME/.dotnet/tools" + wix --version + + # A plain build must have no distribution edge. + "$MCPP" build + if find target -name '*.msi' | grep -q .; then + echo "FAIL: a plain build produced an installer"; exit 1 + fi + "$MCPP" pack --format msi | tee pack.log + msi=$(find target -name '*.msi' | head -1) + test -n "$msi" || { cat pack.log; echo "FAIL: no MSI"; exit 1; } + + # THE CRITERION IS THE `File` TABLE, NOT THE FILE'S EXISTENCE. The + # measured failure this member is shaped around is a VALID, EMPTY, + # 52 KB installer with no diagnostic, produced when a harvested bind + # path resolved to nothing. An assertion that stops at "an .msi + # exists" passes on exactly that artifact. + size=$(stat -c %s "$msi" 2>/dev/null || stat -f %z "$msi") + echo "msi: $msi ($size bytes)" + exe=$(find target -name 'msi-consumer.exe' | head -1) + test -n "$exe" || { echo "FAIL: no linked program to compare against"; exit 1; } + exesize=$(stat -c %s "$exe" 2>/dev/null || stat -f %z "$exe") + + # ASK THE INSTALLER WHAT IT CONTAINS. A SIZE RATIO IS A GUESS. + # + # This was `size > exesize / 2`, with the reasoning "a cabinet + # compresses, so this is a floor and not an equality". The ratio was + # invented, and the first real WiX 6 run refused a correct MSI: 32768 + # bytes for a 114688-byte program, which is what a stripped + # hello-world looks like once a cab has had it -- mostly padding and + # highly compressible. The same mistake was already made once in this + # ecosystem, on the AppImage floor, where a 16 KB bound refused a + # 14999-byte correct bundle. + # + # An administrative install writes the payload out uncompressed, so + # the question "is the program in there" has an exact answer and this + # asserts EQUALITY against the linked binary. It also proves the + # package installs, which a size never did. + admin="$PWD/msi-admin-image" + rm -rf "$admin"; mkdir -p "$admin" + msiexec //a "$(cygpath -w "$msi")" //qn TARGETDIR="$(cygpath -w "$admin")" \ + || { echo "FAIL: the MSI would not produce an administrative image"; exit 1; } + inside=$(find "$admin" -name 'msi-consumer.exe' | head -1) + test -n "$inside" || { + echo "FAIL: the MSI installs no msi-consumer.exe -- it is the empty installer" + find "$admin" -type f | head -20; exit 1; } + insidesize=$(stat -c %s "$inside" 2>/dev/null || stat -f %z "$inside") + [ "$insidesize" -eq "$exesize" ] || { + echo "FAIL: the installed program is $insidesize bytes, the linked one $exesize" + exit 1; } + echo "ok: the MSI installs msi-consumer.exe, $insidesize bytes, byte-for-byte the linked program" + + - name: dist-apple produces a bundle that launches + if: runner.os == 'macOS' + working-directory: tests/app-consumer + shell: bash + run: | + set -e + "$MCPP" build + if find target -name '*.app' | grep -q .; then + echo "FAIL: a plain build produced a bundle"; exit 1 + fi + "$MCPP" pack --format app | tee pack.log + app=$(find target -name '*.app' -type d | head -1) + test -n "$app" || { cat pack.log; echo "FAIL: no .app bundle"; exit 1; } + + # The three files a bundle is, and the one that decides whether it + # launches at all. + test -f "$app/Contents/Info.plist" || { echo "FAIL: no Info.plist"; exit 1; } + exe=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist") + test -n "$exe" || { echo "FAIL: Info.plist names no executable"; exit 1; } + # `CFBundleExecutable` must be a BARE NAME, never a path: Apple's + # loader resolves it against Contents/MacOS/ and a path there is not + # a launchable bundle. This is the assumption the member documents. + case "$exe" in */*) echo "FAIL: CFBundleExecutable is a path: $exe"; exit 1 ;; esac + test -x "$app/Contents/MacOS/$exe" \ + || { echo "FAIL: $app/Contents/MacOS/$exe is missing or not executable" + ls -la "$app/Contents/MacOS"; exit 1; } + # THE CRITERION IS THAT IT RUNS THROUGH THE BUNDLE, not that the + # binary runs: a bundle whose Info.plist names the wrong executable + # still contains a working program. + "$app/Contents/MacOS/$exe" | tee run.log + grep -q '^app-consumer ok' run.log \ + || { echo "FAIL: the bundled program did not run"; exit 1; } + # `plutil -lint` is Apple's own parser: a plist this member wrote by + # hand and got subtly wrong would otherwise fail at launch time on a + # user's machine and nowhere here. + plutil -lint "$app/Contents/Info.plist" + echo "ok: one bundle, a valid plist, and it launches" + - name: the rule declared its own compiler working-directory: tests/spirv-consumer run: | diff --git a/.gitignore b/.gitignore index 980640e..ed94241 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ compile_commands.json mcpp.lock .mcpp/ *.log +.worktrees/ diff --git a/README.md b/README.md index 7d99042..8e26cf7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ imports each one from `build.mcpp` under the module name the member declares. ```toml [build-dependencies.mcpp] -plugins = { version = "0.5.2", features = ["rules-spirv"], host-module = true } +plugins = { version = "0.6.0", features = ["rules-spirv"], host-module = true } ``` `[build-dependencies]`, not `[dependencies]`. The two keys answer separate @@ -36,8 +36,25 @@ int main() { |---|---|---| | rules | `mcpp.rules.` | how one kind of translation unit is compiled by a compiler mcpp does not drive: the spelling of its flags, the probe of its toolkit, the actions it submits | | tools | `mcpp.tools.` | a build-time utility independent of any compiler; see `tools/README.md` | +| dist | `mcpp.dist.` | what comes out of the link, and in what form a user installs it: an `.msi`, an AppImage, a signed `.app` | | identity | `mcpp.plugins` | the lib root, compiled before every member; it states the collection's version | +The three families answer three different questions, and the prefix is which +one a member answers: + +``` +rules-* how is this translation unit compiled +tools-* what does the build program need to do itself +dist-* what comes out of the link, and in what form a user installs it +``` + +A `dist-*` member fits neither of the first two definitions: it does not +compile a translation unit and it does not do its work while the build program +runs. It consumes **link outputs** through a `role = "artifact"` action, reached +with `mcpp pack --format ` (mcpp 2026.9.11.1+). The prefix matters because +the taxonomy is load-bearing -- a consumer reading `rules-wix` would expect a +compiler it does not drive and a translation unit, and there is neither. + The `mcpp.` prefix is reserved for this package: mcpp warns when a module under it is declared by a package outside the `mcpp` namespace. `mcpp.build.*` is the engine's own module family and is not used here. @@ -54,6 +71,9 @@ engine's own module family and is not used here. | `rules-sycl` | `mcpp.rules.sycl` | 2026.9.6.6 | `[build] accel = "sycl"` or `"sycl, cuda12.9+{sm_89}"`, a constrained glob for `*.sycl`, and `compat:sycl-runtime` so the artifact can reach `libsycl.so.9` at run time. Its own engine need is `.sycl` in the device-source table, 2026.9.6.1 | | `tools-embed` | `mcpp.tools.embed` | 2026.9.5.4 | nothing beyond mcpp: it reads a file and writes a header while the build program runs. The floor is the release whose fast path compares a declared file input, without which an edit to the data does not reach the binary | | `tools-island` | `mcpp.tools.island` | 2026.9.7.1 | nothing beyond mcpp: it reads marked entry points out of an island's own source and writes the `extern "C"` boundary header its compiler reads and the module the C++ side imports. Not a device rule -- it claims no extension, and a project calls it from its own `build.mcpp` | +| `dist-appimage` | `mcpp.dist.appimage` | 2026.9.11.1 | `xim:appimagetool`, which this feature declares on the `cfg(linux)` axis. Linux only. Turns the tree `mcpp pack` staged into one AppImage: the staged bundle is already an AppDir bar three files, so the member writes an `AppRun`, a `.desktop` entry and an icon into it and invokes one tool -- it never copies or re-lays-out a tree that can be hundreds of megabytes | +| `dist-wix` | `mcpp.dist.wix` | 2026.9.11.1 | the WiX 6 CLI on `PATH` or in `MCPP_WIX`, which is not redistributable through this ecosystem and is therefore located rather than installed -- the `msvc@system` shape. Windows only. Renders a `.wxs` and passes the program in as a preprocessor variable, because a bind path that resolves to nothing is silent | +| `dist-apple` | `mcpp.dist.apple` | 2026.9.11.2 | the base macOS install (`ditto`, and `codesign` only when an identity is given). macOS now; iOS when the target row is wired, which is a payload rather than a redesign. **The floor is one release higher than its siblings** and the reason is not this member: under 2026.9.11.1 `mcpp pack` staged before dispatching and let a staging failure fail the command, so on a Mach-O program -- which the built-in closure walk refuses, because it uses `LD_TRACE_LOADED_OBJECTS` and dyld answers that by running the program -- every dispatched format was unreachable, including one that reads no staged tree. 2026.9.11.2 makes staging a service to the provider | ### Each rule brings its own environment @@ -61,7 +81,7 @@ A project names the rule and nothing else: ```toml [build-dependencies.mcpp] -plugins = { version = "0.5.2", features = ["rules-cuda"], host-module = true } +plugins = { version = "0.6.0", features = ["rules-cuda"], host-module = true } ``` The payloads each rule drives are declared **here**, under the feature that @@ -329,6 +349,74 @@ before this every translation unit that included one carried its own copy. Exactly one translation unit -- the generated implementation -- includes them now, and every consumer reaches the same array through the accessor. +## `tools-embed`'s four entry points + +| entry point | inputs | outputs | shape | +|---|---|---|---| +| `file()` | one | one header | one array, one `_size`, included by name | +| `files()` | N | N headers | one `file()` call per input; `options::identifier` is refused, because it names one symbol and there are several | +| `group()` | N | N headers + one generated interface | the same N headers, handed to `mcpp::plugins::surface` so a consumer writes one `import` and names no generated file -- see "What a consumer names" above | +| `table()` | N | one header | one array of rows, each carrying its input's key beside its bytes; the consumer iterates or looks a row up by key | + +`table()` is for a set the consumer wants to walk rather than name member by +member, where `files()`'s one accessor per input and `group()`'s one function +per input are both the wrong shape. The motivating case is a shader set: + +```cpp +mcpp::tools::embed::table_options opt; +opt.name_space = "myapp"; +opt.identifier = "shaders"; +opt.row_type = "shader_entry"; +mcpp::tools::embed::table({ "shaders/Standard.vert", "shaders/Standard.frag" }, opt); +``` + +which produces, in one header, a struct and an array of it: + +```cpp +struct shader_entry { const char* key; const unsigned char* data; std::size_t size; }; +inline constexpr shader_entry shaders[] = { + { "Standard.vert", /* ... */, /* ... */ }, + { "Standard.frag", /* ... */, /* ... */ }, +}; +``` + +**The row struct is generated beside the array it describes, for the reason +0.2.6 fixed for `mcpp.rules.spirv`'s header:** a generated header has to be +includable on its own with nothing else. A consumer never declares the row +type by hand, so it cannot declare one that has drifted from what the array +actually holds. + +**Each row's bytes are a numeric array, never a raw string literal.** A raw +string literal delimits on a fixed marker (`)"` closes `R"(...)"`), and no byte +sequence in an arbitrary payload is excluded strongly enough to promise it +never contains that marker: shader source can carry it by accident, and a +binary payload can carry it by construction. The motivating case for this +entry point built its shader table by concatenating file contents into one +string in a build script -- exactly this bug: a shader containing that +four-character sequence truncates the string at that point, and every shader +concatenated after it goes missing, with nothing but an unrelated compiler +error to show for it. + +**A row's key is a choice, not a convention.** `table_options::key` selects +between the file name with its extension (`Standard.vert`, the default, +because a bare stem would collide with `Standard.frag`), the bare stem, and the +path relative to the manifest directory -- for a nested input set where two +directories hold a file of the same name, which the file name alone cannot +tell apart. Two inputs that derive one key are refused, naming both: the same +shape `mcpp.rules.spirv` refuses two shaders sharing one output name, because +the alternative is a table that silently keeps the last row sharing a key and +drops the rest, and a build that did that would still link and run. + +**`table_options` is its own type rather than a second meaning for `options`'s +fields.** `options::identifier` names one symbol, so `files()` refuses a caller +who sets it for several inputs rather than silently applying it to the first. +A table writes exactly one array regardless of how many inputs feed it, so +there is always exactly one name to give: `table_options::identifier` (default +`embedded_table`) and `table_options::row_type` (default `embedded_file`) name +the array and the struct. `out_dir`, `name_space`, `elem` and `width` mean what +they mean in `options`, `element::word32`'s "not a multiple of 4" refusal +included -- checked per input, since a table has several. + ## `tools-island`: an island's boundary `mcpp::plugins::surface` generates the whole interface for a **data** payload, @@ -576,6 +664,7 @@ src/plugins.cppm export module mcpp.plugins; the lib root: the version, and payload uses to write the declarations a consumer names rules/.cppm export module mcpp.rules.; tools/.cppm export module mcpp.tools.; +dist/.cppm export module mcpp.dist.; tests// one project per member, built by CI with the pinned mcpp ``` @@ -595,7 +684,8 @@ own include search list, and only on a machine that has those directories. ## Adding a member -1. One file, `rules/.cppm` or `tools/.cppm`, declaring its module name. +1. One file, `rules/.cppm`, `tools/.cppm` or `dist/.cppm`, declaring + its module name. The engine owns the graph — the accelerator axis, the constrained globs, the action edges, the fingerprint — and the member owns the spelling: which tool, which flags, what is generated. A member does not read `/usr`; a tool diff --git a/dist/appimage.cppm b/dist/appimage.cppm new file mode 100644 index 0000000..bb3fd5f --- /dev/null +++ b/dist/appimage.cppm @@ -0,0 +1,516 @@ +// mcpp.dist.appimage -- a linked program and its closure become one AppImage. +// +// WHY THIS IS NEITHER A RULE NOR A TOOL. A rule states how a translation unit +// is compiled by a compiler mcpp does not drive. A tool states something the +// build program needs that no compiler performs, and does it while the program +// runs. This member does neither: it consumes LINK OUTPUTS and produces +// something a user installs. That is a third category, and the `dist-` prefix +// says which of the three questions a member answers -- a consumer reading +// `rules-appimage` would expect a compiler and a translation unit, and there is +// neither. +// +// THE ENGINE HOLDS THE DISPATCH AND NOT THE FORMAT. `mcpp pack --format +// appimage` finds the package that declared the name and hands it the staged +// tree; nothing about squashfs, the type-2 runtime or the desktop-entry +// specification is in mcpp. Binding any of it there would couple an mcpp +// release to a release mcpp does not control -- the same argument the project +// already made for languages, where Slang is supported without being named in +// the engine. +// +// THE STAGED TREE IS ALREADY AN AppDir, BAR THREE FILES, and that is the whole +// reason this member is small. `mcpp pack --mode vendored` stages `bin/`, +// `lib/` and a top-level launcher whose `$ORIGIN` rewriting makes the tree +// relocatable -- which is what an AppImage is. AppImage additionally requires +// an `AppRun`, a top-level `.desktop` entry and an icon; the payload's own +// layout inside the directory is free, because `AppRun` is the entry point. +// So this member writes three small files and invokes one tool, and never +// copies or re-lays-out a tree that can be hundreds of megabytes. +// +// WHY THE THREE FILES ARE WRITTEN AT PLAN TIME AND DECLARED AS ACTION INPUTS. +// Writing them is configuration, not construction, and the second pass of +// `mcpp pack --format appimage` runs the build program after the tree has been +// staged, so the directory exists. Declaring them as inputs is what makes a +// change to `[package] description` reach the graph: the build program re-runs +// on any change to the package metadata it was told, rewrites the entry, and +// the edge is dirty because a declared input changed. Without the declaration +// the edge depends only on the program binary, and a metadata-only change would +// leave the previous AppImage in place, reported as up to date. + +module; +#include + +export module mcpp.dist.appimage; + +import std; +import mcpp; +import mcpp.plugins; + +// Nothing here uses `std::println`, and that is not a style choice: both of its +// overloads reach into the libc++ dylib for symbols macOS 14 does not ship, so +// a member that printed with it compiled and then failed to link. `std::format` +// is header-only. The full measurement is in `rules/spirv.cppm`. + +export namespace mcpp::dist::appimage { + +// ─── Options ─────────────────────────────────────────────────────────────── + +struct options { + // The program target this AppImage wraps. Empty means the package name, + // which is the target `mcpp pack` itself selects by convention. + // + // NAMED, NEVER DISCOVERED. `${mcpp.target_file:}` refuses an unknown + // target rather than expanding to an empty path, and a path that resolves + // to nothing is the failure this category is most exposed to: a WiX action + // that bound a directory and harvested it produced a valid, empty, 52 KB + // installer with no diagnostic when the bind path resolved to nothing. + std::string target; + + // What the desktop entry says. Each empty field is taken from `[package]`, + // which mcpp reports to the build program from 2026.9.11.1: a project that + // states its version once does not state it again here, and a second copy + // is one that drifts with nothing able to detect it. + std::string app_name; // `Name=`; default: the target + std::string comment; // `Comment=`; default: `[package] description` + std::string version; // `X-AppImage-Version=`; default: `[package] version` + // Freedesktop main categories, semicolon-terminated by the writer. The + // specification requires at least one, and `Utility` is the honest answer + // for a program whose category nothing has stated. + std::vector categories; + // `Terminal=true` for a program that expects a terminal. A wrong answer + // here is visible only to a desktop launcher, never to a build. + bool terminal = true; + + // A PNG a project supplies. Empty uses the built-in placeholder, which + // exists so that an AppImage can be produced with nothing declared: + // appimagetool requires an icon and refuses without one, and a member whose + // first use needs a graphic asset is a member nobody tries. + std::string icon; + + // An explicit `appimagetool` wins over discovery. Set it to pin a build + // other than the one the workspace installed. + std::string tool; + // The type-2 runtime stub the produced AppImage starts with. + // + // IT IS DECLARED BECAUSE THE TOOL WOULD OTHERWISE FETCH IT. Measured on + // appimagetool 1.9.1: with no `--runtime-file`, every invocation downloads + // the stub from a GitHub release, and with that address unreachable the + // build fails inside the tool. A build must not reach the network -- one + // that does is neither reproducible nor usable offline -- so the stub comes + // from the declared payload and is passed explicitly. + std::string runtime; + + // Where the produced file lands. Empty means + // `/-.AppImage`. + std::string output; + std::string out_dir = std::string(mcpp::out_dir()); +}; + +// ─── The plan ────────────────────────────────────────────────────────────── + +// What this member would submit, without submitting it. +// +// A PLAN AND A SUBMIT, BECAUSE A DISTRIBUTABLE IS THE LAST THING BEFORE A +// USER'S HANDS. It is therefore the part of a build most likely to need a +// project-specific edit -- one extra file, a different compression level, a +// second signature -- and `generate()` being exactly `submit(plan_for())` is +// what keeps such an edit from becoming a reimplementation of this member. +struct plan { + // False when this build is not `mcpp pack --format appimage`, which is + // every ordinary build. `reason` then says which of the several ways. + bool applies = false; + std::string reason; + std::string output; + std::string appdir; + std::vector argv; + std::vector inputs; + explicit operator bool() const { return applies; } +}; + +// ─── Internals ───────────────────────────────────────────────────────────── + +inline bool is_file(const std::string& p) { + std::error_code ec; + return !p.empty() && std::filesystem::is_regular_file(p, ec); +} + +// The 70 bytes of a 1x1 opaque PNG. +// +// A CONSTANT RATHER THAN A GENERATED FILE, because generating any PNG needs a +// deflate implementation and a member of this collection does not earn one to +// draw a placeholder. Verified as `PNG image data, 1 x 1, 8-bit/color RGBA, +// non-interlaced`. +inline constexpr unsigned char kPlaceholderIcon[]{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0xab, 0xce, 0x36, 0x89, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +}; + +// Written only when the bytes differ, for the reason `mcpp.tools.embed` gives: +// rewriting identical bytes moves the mtime, and a moved mtime on a declared +// input is indistinguishable from a changed input -- so a second pack of an +// unchanged project would rebuild the AppImage. +inline bool write_if_different(const std::filesystem::path& path, + std::string_view bytes) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (std::ifstream in(path, std::ios::binary); in) { + std::string old((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + if (old == bytes) return true; + } + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) return false; + out.write(bytes.data(), static_cast(bytes.size())); + return static_cast(out); +} + +// `Name=` and the file stem. Not sanitised beyond what a desktop entry cannot +// carry: the value a user sees is the project's to choose. +inline std::string app_name_for(const options& opt) { + if (!opt.app_name.empty()) return opt.app_name; + if (!opt.target.empty()) return opt.target; + const char* n = mcpp::package_name(); + return (n && *n) ? std::string(n) : std::string("app"); +} + +inline std::string target_for(const options& opt) { + if (!opt.target.empty()) return opt.target; + const char* n = mcpp::package_name(); + return (n && *n) ? std::string(n) : std::string(); +} + +// The staged tree's top-level launcher, which is what `AppRun` execs. +// +// `mcpp pack` writes one per mode and names it after the binary; under +// `--mode static` it is the program itself. Both are answered by looking, so +// this member states no convention mcpp has not already put on disk. +inline std::string launcher_in(const std::string& stage, const std::string& target) { + for (auto candidate : {stage + "/" + target, + stage + "/bin/" + target, + stage + "/run.sh"}) + if (is_file(candidate)) return candidate; + return {}; +} + +// appimagetool's own architecture vocabulary, from the target mcpp resolved. +// It is passed as `ARCH` in the tool's environment and it also names the +// runtime stub, so a wrong answer produces an AppImage that cannot start on +// the machine it was built for. +inline std::string arch_for() { + const std::string a = mcpp::target_arch(); + return a.empty() ? std::string("x86_64") : a; +} + +// Where the payload lives. Both are legitimate and only one exists. +inline std::string payload_dir_of(const std::string& tool) { + return std::filesystem::path(tool).parent_path().string(); +} + +inline std::string discover_tool(const options& opt) { + if (!opt.tool.empty()) return opt.tool; + // THE PAYLOAD THIS MEMBER DECLARED, and nothing else. + // + // `xpkg_dir` answers from `MCPP_XPKG_*_DIR`, which mcpp sets for the + // package being built -- so a member that runs a payload tool declares the + // payload itself rather than relying on a consumer's manifest. There is + // deliberately no PATH fallback: a host `appimagetool` would make the + // produced AppImage depend on a machine rather than on a declaration, and + // an AppImage is the artifact for which that matters most. + const std::string dir = mcpp::xpkg_dir("xim", "appimagetool"); + if (dir.empty()) return {}; + const std::string exe = (std::filesystem::path(dir) / "appimagetool").string(); + return is_file(exe) ? exe : std::string(); +} + +inline std::string discover_runtime(const options& opt, const std::string& tool) { + if (!opt.runtime.empty()) return opt.runtime; + if (tool.empty()) return {}; + const auto dir = payload_dir_of(tool); + for (auto name : {"runtime-" + arch_for(), std::string("runtime")}) { + const auto p = (std::filesystem::path(dir) / name).string(); + if (is_file(p)) return p; + } + return {}; +} + +inline std::string desktop_entry(const options& opt, const std::string& name, + const std::string& icon_stem) { + std::string categories; + if (opt.categories.empty()) categories = "Utility;"; + else for (auto const& c : opt.categories) categories += c + ";"; + + const char* d = mcpp::package_description(); + const char* v = mcpp::package_version(); + const std::string comment = !opt.comment.empty() ? opt.comment + : (d && *d ? std::string(d) : std::string()); + const std::string version = !opt.version.empty() ? opt.version + : (v && *v ? std::string(v) : std::string()); + + std::string text = "[Desktop Entry]\nType=Application\n"; + text += std::format("Name={}\n", name); + if (!comment.empty()) text += std::format("Comment={}\n", comment); + text += std::format("Exec={}\n", name); + text += std::format("Icon={}\n", icon_stem); + text += std::format("Categories={}\n", categories); + text += std::format("Terminal={}\n", opt.terminal ? "true" : "false"); + if (!version.empty()) text += std::format("X-AppImage-Version={}\n", version); + return text; +} + +// `AppRun` hands control to the tree's own launcher, with `APPDIR` exported +// because that launcher may be the `$ORIGIN`-relative one `mcpp pack` wrote +// and a caller may have set neither. +// +// `exec` rather than a call, so the AppImage's process IS the program: a +// wrapper that waits would break signal delivery and a shell's job control, +// and `"$@"` unquoted would split an argument containing a space. +inline std::string apprun(const std::string& launcher_rel) { + return std::format( + "#!/bin/sh\n" + "# Generated by mcpp.dist.appimage. Do not edit.\n" + "APPDIR=\"$(dirname \"$(readlink -f \"$0\")\")\"\n" + "export APPDIR\n" + "exec \"$APPDIR/{}\" \"$@\"\n", launcher_rel); +} + +// ─── Plan ────────────────────────────────────────────────────────────────── + +inline plan plan_for(options opt = {}) { + plan p; + + // NOT THIS PASS. Every ordinary build lands here, and the empty + // `pack_format()` is what says so -- see `generate` for why the DECLARATION + // must not be gated the same way. + const std::string requested = mcpp::pack_format(); + if (requested != "appimage") { + p.reason = requested.empty() + ? "this build is not packaging" + : std::format("--format {} was requested, not appimage", requested); + return p; + } + + // Linux only, and this is a refusal rather than a silent skip: a user who + // typed `--format appimage` on a Mac asked for something that does not + // exist there, and the engine has already accepted the value because the + // graph declared it. + if (const std::string os = mcpp::target_os(); os != "linux") { + std::cerr << std::format( + "mcpp.dist.appimage: an AppImage is a Linux format, and this build " + "targets '{}'.\n" + " use: --format tar, or build for a Linux target", + os.empty() ? "unknown" : os) << '\n'; + p.reason = "not a Linux target"; + return p; + } + + const std::string stage = mcpp::pack_stage_dir(); + if (stage.empty()) { + std::cerr << "mcpp.dist.appimage: mcpp reported no staged tree. This " + "member needs mcpp 2026.9.11.1 or newer.\n"; + p.reason = "no staged tree"; + return p; + } + + const std::string target = target_for(opt); + if (target.empty()) { + std::cerr << "mcpp.dist.appimage: no target to wrap. Set " + "`options::target` to the program target's name.\n"; + p.reason = "no target"; + return p; + } + + const std::string tool = discover_tool(opt); + if (tool.empty()) { + // NAMES WHERE IT LOOKED, not a manifest the reader does not own. The + // payload is declared by this package's own feature, so a consumer who + // sees this has an installation problem rather than a declaration to + // add. + std::cerr << std::format( + "mcpp.dist.appimage: appimagetool was not found.\n" + " looked for: {}/appimagetool (the `xim:appimagetool` payload " + "this feature declares)\n" + " set `options::tool` to name one explicitly.", + mcpp::xpkg_dir("xim", "appimagetool")) << '\n'; + p.reason = "appimagetool not found"; + return p; + } + const std::string runtime = discover_runtime(opt, tool); + if (runtime.empty()) { + std::cerr << std::format( + "mcpp.dist.appimage: the type-2 runtime stub was not found beside " + "{}.\n" + " Without it appimagetool downloads the stub on every invocation, " + "which a build must not do.\n" + " set `options::runtime`, or install a `xim:appimagetool` that " + "carries `runtime-{}`.", + payload_dir_of(tool), arch_for()) << '\n'; + p.reason = "runtime stub not found"; + return p; + } + + const std::string launcher = launcher_in(stage, target); + if (launcher.empty()) { + std::cerr << std::format( + "mcpp.dist.appimage: the staged tree at {0} carries no launcher for " + "target '{1}'.\n" + " expected one of: {0}/{1}, {0}/bin/{1}, {0}/run.sh", + stage, target) << '\n'; + p.reason = "no launcher in the staged tree"; + return p; + } + const auto launcher_rel = + std::filesystem::path(launcher).lexically_relative(stage).generic_string(); + + // ── The three files AppImage requires, written into the staged tree ──── + const std::string name = app_name_for(opt); + const auto stagePath = std::filesystem::path(stage); + const auto desktop = stagePath / (name + ".desktop"); + const auto icon = stagePath / (name + ".png"); + const auto dirIcon = stagePath / ".DirIcon"; + const auto runFile = stagePath / "AppRun"; + + std::string iconBytes; + if (!opt.icon.empty()) { + std::ifstream in(opt.icon, std::ios::binary); + if (!in) { + std::cerr << std::format( + "mcpp.dist.appimage: cannot read the icon {}", opt.icon) << '\n'; + p.reason = "icon unreadable"; + return p; + } + iconBytes.assign((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + } else { + iconBytes.assign(reinterpret_cast(kPlaceholderIcon), + sizeof kPlaceholderIcon); + } + + const bool wrote = + write_if_different(runFile, apprun(launcher_rel)) + && write_if_different(desktop, desktop_entry(opt, name, name)) + && write_if_different(icon, iconBytes) + // `.DirIcon` is what the AppImage's own thumbnailer reads. A copy + // rather than a symlink: mksquashfs stores a symlink as a symlink, and + // one pointing at a sibling inside the image is resolved by no reader + // that has not mounted it. + && write_if_different(dirIcon, iconBytes); + if (!wrote) { + std::cerr << std::format( + "mcpp.dist.appimage: cannot write the AppDir metadata into {}", + stage) << '\n'; + p.reason = "cannot write AppDir metadata"; + return p; + } + std::error_code ec; + std::filesystem::permissions(runFile, + std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec + | std::filesystem::perms::others_exec, + std::filesystem::perm_options::add, ec); + + p.output = !opt.output.empty() ? opt.output + : (std::filesystem::path(opt.out_dir) + / std::format("{}-{}.AppImage", name, arch_for())).string(); + p.appdir = stage; + + p.argv = { + tool, + // Without this the tool self-mounts through libfuse, which a container + // and most CI runners do not have. Measured with `fusermount` off the + // PATH: `No suitable fusermount binary found on the $PATH` and exit + // 127; with this flag the same invocation succeeds. + "--appimage-extract-and-run", + "--runtime-file", runtime, + // The staged tree, named through the placeholder rather than the + // absolute path this program read, so the path in the graph and the + // path here cannot disagree. + "${mcpp.stage_dir}", + p.output, + }; + p.inputs = { + std::format("${{mcpp.target_file:{}}}", target), + runFile.string(), + desktop.string(), + icon.string(), + }; + p.applies = true; + return p; +} + +// ─── Submit ──────────────────────────────────────────────────────────────── + +inline bool submit(const plan& p) { + if (!p.applies) return true; + mcpp::action a; + a.id = "mcpp.dist.appimage"; + a.role = "artifact"; + a.description = "APPIMAGE"; + for (auto const& tok : p.argv) a.arg(tok.c_str()); + for (auto const& in : p.inputs) a.input(in.c_str()); + a.output(p.output.c_str()); + a.submit(); + + // A FLOOR ON THIS MEMBER'S OWN OUTPUT, ON THE SUCCESS PATH. + // + // A packaging step that succeeds while carrying nothing is the failure this + // whole category is most exposed to, and stderr on a successful build is + // discarded -- so the only channel that survives is `mcpp::warning`, which + // is also replayed on a cache hit. The tree is measured here, before the + // tool runs, because that is where this program is: an AppDir with no + // executable payload produces a valid AppImage that does nothing. + // IT COUNTS FILES THE MEMBER DID NOT WRITE, NOT BYTES. + // + // A size floor was the first version of this check and it was wrong on its + // first real fixture: a stripped hello-world stages at 14999 bytes, under + // the 16 KB bound, so a correct AppImage was reported as carrying no + // program. A size is a PROXY for the question, and the question is + // answerable directly -- does the tree hold anything besides the four + // files this member just put there. + // + // The floor belongs on the success path and in `mcpp::warning` because + // stderr on a successful build is discarded, and because a warning is + // replayed on a cache hit: an advisory that appeared once and then vanished + // would read as "resolved". + std::error_code ec; + std::size_t carried = 0; + for (auto const& e : std::filesystem::recursive_directory_iterator(p.appdir, ec)) { + if (ec) break; + if (!e.is_regular_file(ec)) continue; + const auto name = e.path().filename().string(); + if (e.path().parent_path() == std::filesystem::path(p.appdir) + && (name == "AppRun" || name == ".DirIcon" + || name.ends_with(".desktop") || name.ends_with(".png"))) + continue; + ++carried; + } + if (carried == 0) { + static char msg[512]; + std::snprintf(msg, sizeof msg, + "mcpp.dist.appimage: the staged tree at %s holds nothing but the " + "AppDir metadata this member wrote; the AppImage will start and do " + "nothing", + p.appdir.c_str()); + mcpp::warning(msg); + } + return true; +} + +// ─── The one call a consumer makes ───────────────────────────────────────── + +// DECLARE UNCONDITIONALLY, SUBMIT CONDITIONALLY -- and both halves are here so +// a consumer cannot do one without the other. +// +// The declaration is what lets the engine answer a question the requesting +// build cannot: `mcpp pack --format bogus` names what IS available, and +// `--help` says "any format the resolved graph provides". Both read the set +// collected from a pass that asked for nothing. A member that declared only +// when asked still works for its author -- they always pass their own format -- +// and makes the set unknowable for everyone else. +inline bool generate(options opt = {}) { + mcpp::provides_pack_format("appimage"); + return submit(plan_for(std::move(opt))); +} + +} // namespace mcpp::dist::appimage diff --git a/dist/apple.cppm b/dist/apple.cppm new file mode 100644 index 0000000..43dce5d --- /dev/null +++ b/dist/apple.cppm @@ -0,0 +1,621 @@ +// mcpp.dist.apple -- a staged tree becomes a `.app` bundle, and is optionally +// signed. +// +// WHY THIS IS NEITHER A RULE NOR A TOOL. A rule states how a translation unit +// is compiled by a compiler mcpp does not drive. A tool states something the +// build program needs that no compiler performs, and does it while the +// program runs. This member does neither: it consumes a STAGED TREE -- itself +// built from link outputs -- and produces something a user installs. That is +// the third category `dist/appimage.cppm` establishes, and the prefix says +// which of the three questions a member answers. +// +// THE ENGINE HOLDS THE DISPATCH AND NOT THE FORMAT. `mcpp pack --format app` +// finds the package that declared the name; nothing about `Info.plist`'s +// keys, the bundle layout, or `codesign`'s flags is in mcpp. Apple's own +// notarisation service is a further release mcpp does not control even less +// than the bundle format is, which is one reason it is out of scope below. +// +// UNLIKE AN AppImage, THIS IS A RE-LAYOUT, AND THAT IS WHY IT COSTS MORE THAN +// THREE FILES. `mcpp pack --mode vendored` stages `bin/`, `lib/` and a +// top-level launcher -- a tree that already satisfies what an AppImage wants, +// because an AppImage has no opinion about its own internal layout beyond +// `AppRun`. A `.app` bundle does have an opinion: it wants the executable and +// its closure under `Contents/MacOS/`, configuration under `Contents/`, and +// resources under `Contents/Resources/`. Reaching that from the staged tree +// is therefore a MOVE, not an ADDITION, and a build program is a bad place to +// perform it: the staged tree can be hundreds of megabytes, and a build +// program is a poor substitute for a shell utility written and optimised for +// exactly this copy. So the assembly is done as the declared COMMAND of one +// or more actions, which is a graph edge ninja schedules and can skip on a +// cache hit, rather than as file I/O this program performs every time it +// runs. +// +// `ditto` IS THE TOOL FOR EVERY COPY BELOW, AND FOR ONE STATED REASON: `cp -R +// SRC DST`'s meaning depends on whether `DST` already exists -- copying +// SRC's CONTENTS into an existing `DST`, but creating a new `DST` as a peer +// of `SRC` when it does not -- which is exactly the kind of environment- +// dependent behaviour that turns "it worked when I tested it" into "it +// nested one directory deeper on a clean machine". `ditto SRC DST` does not +// have that branch: `DST`'s contents become a copy of `SRC`'s contents either +// way, it creates every intermediate directory `DST` needs, and it preserves +// resource forks and permissions, which a plain `cp -R` is not guaranteed to. +// It is part of the base macOS install, not Xcode, so it needs no discovery +// and no `options::tool` the way `wix` does in `dist/wix.cppm` -- there is +// exactly one `ditto`, at a fixed path, on every Mac this can run on. +// +// HOW MANY ACTIONS, AND WHY EACH IS SEPARATE. Up to four: +// +// 1. install `Info.plist` (always) +// 2. lay out the staged tree (always) +// 3. install the icon (only when `options::icon` is set) +// 4. codesign the bundle (only when `options::identity` is set) +// +// 1 and 3 are separate from 2 because they have different INPUTS: `Info.plist` +// is regenerated whenever package metadata changes, the icon only when the +// project's icon file changes, and the staged tree only when the program or +// its closure changes. One action for all three would make every one of those +// changes re-run the multi-hundred-megabyte copy. 4 is last and depends on +// the OUTPUTS of whichever of 1 to 3 actually ran, because a code signature +// covers the bundle's content at signing time -- signing before the content +// is in place is either a failure (an incomplete bundle) or a signature that +// the next file added invalidates. +// +// `Info.plist` IS WRITTEN AT PLAN TIME, BUT NOT DIRECTLY TO ITS FINAL PATH, +// AND THE DIFFERENCE MATTERS. It is configuration, so `write_if_different` +// is the right way to produce its bytes -- the same reasoning +// `dist/appimage.cppm` gives for its own desktop entry. But this member +// writes it to a location of its own choosing and declares that file as the +// INPUT of the action that copies it into `Contents/Info.plist`, rather than +// writing directly to the bundle path. A stray write to a path no action +// declares is invisible to the graph: nothing would notice `Info.plist` +// changing, and `dist/appimage.cppm`'s own header comment already measured +// what that costs -- "a metadata-only change would leave the previous +// [artifact] in place, reported as up to date." Declaring the plan-time file +// as this action's input is what makes a version bump reach the bundle. +// +// CODESIGN IS OFF BY DEFAULT. An unsigned `.app` builds and runs locally on +// the machine that built it; a member that signed by default would fail +// every build on a machine with no identity in its keychain, which is most +// of them. Notarisation is out of scope entirely, and not merely deferred: +// it requires uploading the bundle to Apple over the network and waiting on +// a ticket, and a build must not reach the network -- the same rule +// `dist/appimage.cppm` states for appimagetool's runtime-stub download, +// here applying to a step this member does not attempt at all rather than +// one it works around. +// +// iOS IS THE SAME SHAPE PLUS A TARGET ROW THE ENGINE DOES NOT YET HAVE, NOT A +// REDESIGN. An iOS app is the same `Contents`-free flat bundle format's +// sibling with its own signing and provisioning-profile rules; what is +// missing is not logic in this file but a target triple and an SDK mcpp does +// not resolve today. This member gains iOS when that row lands. It is not +// implemented here. + +module; +#include + +export module mcpp.dist.apple; + +import std; +import mcpp; +import mcpp.plugins; + +// Nothing here uses `std::println`, and that is not a style choice: both of +// its overloads reach into the libc++ dylib for symbols macOS 14 does not +// ship, so a member that printed with it compiled and then failed to link. +// `std::format` is header-only. The full measurement is in `rules/spirv.cppm`. + +export namespace mcpp::dist::apple { + +// ─── Options ─────────────────────────────────────────────────────────────── + +struct options { + // The program target this bundle wraps. Empty means the package name, + // which is the target `mcpp pack` itself selects by convention. + std::string target; + + // The bundle's own name -- the `` in `.app`, and + // `CFBundleName`. Empty means the target name, then the package name, + // then "app". + std::string app_name; + + // `CFBundleIdentifier`. Empty derives a reversed-DNS-shaped identifier + // from `package_namespace()` and `package_name()`; see `bundle_id_for`. + std::string bundle_id; + + // `CFBundleShortVersionString` and `CFBundleVersion`, used as given, with + // no conversion: unlike `dist/wix.cppm`'s MSI version, Apple's bundle + // version keys have no field-width ceiling this member has measured, so + // mcpp's own four-segment date version needs nothing done to it here. + // Empty means `package_version()`. + std::string version; + + // A project-supplied icon file, copied into `Contents/Resources/` and + // named by `CFBundleIconFile`. Finder specifically expects `.icns` (or + // the newer `.icon` bundle) to render an application icon; this member + // does not validate or convert the format, only wires up whatever file + // is named. Empty omits `Contents/Resources/` and `CFBundleIconFile` + // entirely -- macOS runs a bundle with no custom icon without complaint. + std::string icon; + + // `LSMinimumSystemVersion`. mcpp does not expose the compiled deployment + // target to a build program, so this member does not guess one; empty + // omits the key, and a project that needs the floor enforced states it. + std::string minimum_system_version; + + // A `codesign` identity -- a name or hash `security find-identity` would + // list. Empty means unsigned, which is the default; see the header + // comment for why signing is opt-in rather than automatic. + std::string identity; + + // `--options runtime`, the hardened runtime, only meaningful together + // with `identity`: without signing there is no runtime flag to attach it + // to. + bool hardened_runtime = false; + + // `--entitlements `, likewise only applied when `identity` is also + // set. Validated to exist when named, the same as `icon`. + std::string entitlements; + + // Where the produced bundle lands. Empty means `/.app`. + std::string output; + std::string out_dir = std::string(mcpp::out_dir()); +}; + +// ─── The plan ────────────────────────────────────────────────────────────── + +// One chained action per row of the header comment's table, and a submit +// that walks them in dependency order. `generate()` being exactly +// `submit(plan_for())` is what keeps a project's edit -- an extra resource, +// a different signing flag -- from becoming a reimplementation of this +// member, the same trade `dist/appimage.cppm` makes. +struct step { + const char* id; + const char* role; + const char* description; + std::vector argv; + std::vector inputs; + std::string output; +}; + +struct plan { + // False when this build is not `mcpp pack --format app`, which is every + // ordinary build. `reason` then says which of the several ways. + bool applies = false; + std::string reason; + std::string bundle_path; // the .app directory + std::string appdir; // pack_stage_dir(), kept for the floor check + std::vector steps; + explicit operator bool() const { return applies; } +}; + +// ─── Internals ───────────────────────────────────────────────────────────── + +inline bool is_file(const std::string& p) { + std::error_code ec; + return !p.empty() && std::filesystem::is_regular_file(p, ec); +} + +// Written only when the bytes differ, for the reason `dist/appimage.cppm` +// gives: rewriting identical bytes moves the mtime, and a moved mtime on a +// declared input is indistinguishable from a changed one -- so a second pack +// of an unchanged project would re-copy the bundle and re-sign it. +inline bool write_if_different(const std::filesystem::path& path, + std::string_view bytes) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (std::ifstream in(path, std::ios::binary); in) { + std::string old((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + if (old == bytes) return true; + } + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) return false; + out.write(bytes.data(), static_cast(bytes.size())); + return static_cast(out); +} + +inline std::string target_for(const options& opt) { + if (!opt.target.empty()) return opt.target; + const char* n = mcpp::package_name(); + return (n && *n) ? std::string(n) : std::string(); +} + +inline std::string app_name_for(const options& opt) { + if (!opt.app_name.empty()) return opt.app_name; + if (!opt.target.empty()) return opt.target; + const char* n = mcpp::package_name(); + return (n && *n) ? std::string(n) : std::string("app"); +} + +// The staged tree's top-level launcher -- the same search +// `dist/appimage.cppm` performs, and for the same reason: `mcpp pack` writes +// one per mode and names it after the binary, so this member states no +// convention mcpp has not already put on disk. +inline std::string launcher_in(const std::string& stage, const std::string& target) { + for (auto candidate : {stage + "/" + target, + stage + "/bin/" + target, + stage + "/run.sh"}) + if (is_file(candidate)) return candidate; + return {}; +} + +// CFBundleExecutable MUST BE A BARE FILENAME, NOT A PATH. +// +// Apple's own bundle documentation describes it as the executable's name +// within `Contents/MacOS/`, and real-world bundle tooling has hit this +// directly enough to be a filed CMake defect: "CFBundleExecutable path in a +// bundle should not be a relative path into bundle." So this member takes +// only the basename of whatever `launcher_in` finds. That is exactly correct +// when the staged tree's launcher sits at the tree's ROOT, which is the +// default `--mode vendored` shape `dist/appimage.cppm`'s own header comment +// describes ("a top-level launcher"). A staged tree whose launcher were +// nested under `bin/` instead would still copy correctly by the layout step +// below, but the resulting bundle would name an executable that is not at +// the top of `Contents/MacOS/`, and this member does not flatten that case -- +// it is not exercised by the default staging mode, and nothing here can +// exercise it on Linux to find out how macOS actually responds. +inline std::string bundle_executable_name(const std::string& launcher_path) { + return std::filesystem::path(launcher_path).filename().string(); +} + +inline std::string sanitize_bundle_id_component(std::string_view s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '-' || c == '.'; + out += ok ? c : '-'; + } + return out; +} + +// A reversed-DNS-SHAPED identifier, not a literal reversal of a domain -- +// mcpp packages carry `(namespace, name)`, not a domain to reverse. Sanitised +// to the character set Apple documents for `CFBundleIdentifier` +// (alphanumeric, `-`, `.`), because a package name or namespace is free text +// as far as this member is concerned. +inline std::string bundle_id_for(const options& opt) { + if (!opt.bundle_id.empty()) return opt.bundle_id; + const char* nsC = mcpp::package_namespace(); + const char* nmC = mcpp::package_name(); + const std::string ns = (nsC && *nsC) ? sanitize_bundle_id_component(nsC) : std::string(); + const std::string nm = (nmC && *nmC) ? sanitize_bundle_id_component(nmC) : std::string(); + if (!ns.empty() && !nm.empty()) return ns + "." + nm; + if (!nm.empty()) return nm; + if (!ns.empty()) return ns; + return "app"; +} + +inline std::string plist_escape(std::string_view s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + switch (c) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + default: out += c; + } + } + return out; +} + +// Written as XML text directly rather than by shelling out to `plutil`: the +// content is a handful of string keys this member already holds, and adding +// a second host tool to discover and invoke would buy nothing over +// formatting the eleven lines by hand. `LSMinimumSystemVersion` and +// `CFBundleIconFile` are omitted rather than emitted empty when their inputs +// are empty -- an empty string in either key is a claim as unsupported as +// omitting the key, so omission is the honest one. +inline std::string plist_document(const std::string& executable, const std::string& bundle_id, + const std::string& name, const std::string& version, + const std::string& min_system_version, + const std::string& icon_name) { + std::string doc; + doc += "\n"; + doc += "\n"; + doc += "\n\n"; + doc += std::format(" CFBundleExecutable\n {}\n", + plist_escape(executable)); + doc += std::format(" CFBundleIdentifier\n {}\n", + plist_escape(bundle_id)); + doc += std::format(" CFBundleName\n {}\n", + plist_escape(name)); + doc += std::format(" CFBundleShortVersionString\n {}\n", + plist_escape(version)); + doc += std::format(" CFBundleVersion\n {}\n", + plist_escape(version)); + doc += " CFBundlePackageType\n APPL\n"; + if (!min_system_version.empty()) + doc += std::format(" LSMinimumSystemVersion\n {}\n", + plist_escape(min_system_version)); + doc += " NSHighResolutionCapable\n \n"; + // Not in the letter of this member's key list, but added deliberately: + // without it, an icon file this member went to the trouble of copying + // into `Contents/Resources/` is inert -- nothing in the bundle would ever + // reference it, and Finder would show the generic application icon + // regardless of `options::icon`. Wiring the file up once it exists is + // what makes the option do what its name says. + if (!icon_name.empty()) + doc += std::format(" CFBundleIconFile\n {}\n", + plist_escape(icon_name)); + doc += "\n\n"; + return doc; +} + +// ─── Plan ────────────────────────────────────────────────────────────────── + +inline plan plan_for(options opt = {}) { + plan p; + + // NOT THIS PASS. Every ordinary build lands here, and the empty + // `pack_format()` is what says so -- see `generate` for why the + // DECLARATION must not be gated the same way. + const std::string requested = mcpp::pack_format(); + if (requested != "app") { + p.reason = requested.empty() + ? "this build is not packaging" + : std::format("--format {} was requested, not app", requested); + return p; + } + + // macOS only, and this is a refusal rather than a silent skip: a user who + // typed `--format app` on Linux asked for something that does not exist + // there, and the engine has already accepted the value because the graph + // declared it. + if (const std::string os = mcpp::target_os(); os != "macos") { + std::cerr << std::format( + "mcpp.dist.apple: a .app bundle is a macOS format, and this " + "build targets '{}'.\n" + " use: --format tar, or build for a macOS target", + os.empty() ? "unknown" : os) << '\n'; + p.reason = "not a macOS target"; + return p; + } + + // THE STAGED TREE IS OPTIONAL, AND THAT IS THE WHOLE FINDING. + // + // This required it, and on macOS it cannot exist: `mcpp pack`'s built-in + // closure walk refuses a Mach-O program, because it uses + // `LD_TRACE_LOADED_OBJECTS` and dyld answers that by RUNNING the program. + // mcpp 2026.9.11.2 made staging a service rather than a precondition, so + // the dispatch now reaches this member -- and the member then refused for + // the same underlying reason, one layer up, with + // + // error: no action claimed --format 'app' + // + // from the engine, because a member's stderr on a successful build is + // discarded. A refusal nobody can read. + // + // A `.app` needs ONE program, not a tree. `${mcpp.target_file:}` is + // what names it -- the same placeholder `dist/wix.cppm` uses for exactly + // this reason, and what section 6 of the design record recommends for a + // member that packages a named target. The staged tree is still preferred + // when it exists, because a `--mode vendored` tree carries the program's + // dependencies beside it and a bundle should keep them; without one the + // bundle carries the program alone, which is correct for a self-contained + // Mach-O and is what the platform's own default produces. + const std::string stage = mcpp::pack_stage_dir(); + + const std::string target = target_for(opt); + if (target.empty()) { + std::cerr << "mcpp.dist.apple: no target to bundle. Set " + "`options::target` to the program target's name.\n"; + p.reason = "no target"; + return p; + } + + const std::string launcher = stage.empty() + ? std::format("${{mcpp.target_file:{}}}", target) + : launcher_in(stage, target); + if (launcher.empty()) { + std::cerr << std::format( + "mcpp.dist.apple: the staged tree at {0} carries no launcher for " + "target '{1}'.\n" + " expected one of: {0}/{1}, {0}/bin/{1}, {0}/run.sh", + stage, target) << '\n'; + p.reason = "no launcher in the staged tree"; + return p; + } + // CFBundleExecutable is a bare filename (see the note above). With no + // staged tree the launcher is a PLACEHOLDER the engine expands later, so + // its basename cannot be taken from the string -- the target's own name is + // what the expansion will produce. + const std::string executableName = stage.empty() + ? target : bundle_executable_name(launcher); + + if (!opt.icon.empty() && !is_file(opt.icon)) { + std::cerr << std::format("mcpp.dist.apple: the icon {} was not found", opt.icon) << '\n'; + p.reason = "icon not found"; + return p; + } + if (!opt.entitlements.empty() && !is_file(opt.entitlements)) { + std::cerr << std::format( + "mcpp.dist.apple: the entitlements file {} was not found", opt.entitlements) << '\n'; + p.reason = "entitlements not found"; + return p; + } + + const char* pv = mcpp::package_version(); + const std::string version = !opt.version.empty() ? opt.version + : (pv && *pv ? std::string(pv) : std::string()); + if (version.empty()) { + std::cerr << "mcpp.dist.apple: no version to state. Set " + "`[package] version` or `options::version`.\n"; + p.reason = "no version"; + return p; + } + + const std::string name = app_name_for(opt); + const std::string bundleId = bundle_id_for(opt); + const std::string bundlePath = !opt.output.empty() ? opt.output + : (std::filesystem::path(opt.out_dir) / (name + ".app")).string(); + const std::string contents = bundlePath + "/Contents"; + + const std::string iconName = opt.icon.empty() ? std::string() + : std::filesystem::path(opt.icon).filename().string(); + const std::string plistBytes = plist_document(executableName, bundleId, name, version, + opt.minimum_system_version, iconName); + const std::string plistSrc = (std::filesystem::path(opt.out_dir) / (name + "-Info.plist")).string(); + if (!write_if_different(plistSrc, plistBytes)) { + std::cerr << std::format("mcpp.dist.apple: cannot write {}", plistSrc) << '\n'; + p.reason = "cannot write Info.plist"; + return p; + } + + p.bundle_path = bundlePath; + p.appdir = stage; + + // Every action's output that later steps may need to depend on, gathered + // as they are declared so the final, conditional codesign step can name + // exactly the ones that ran. + std::vector assembled; + + step info; + info.id = "mcpp.dist.apple.info-plist"; + info.role = "artifact"; + info.description = "INFO.PLIST"; + info.argv = { "ditto", plistSrc, contents + "/Info.plist" }; + info.inputs = { plistSrc }; + info.output = contents + "/Info.plist"; + p.steps.push_back(info); + assembled.push_back(info.output); + + step layout; + layout.id = "mcpp.dist.apple.layout"; + layout.role = "artifact"; + layout.description = "APP LAYOUT"; + // The whole staged tree, named through the placeholder rather than the + // absolute path this program read, so the path in the graph and the path + // here cannot disagree -- the same reasoning `dist/appimage.cppm` gives + // for its own `${mcpp.stage_dir}` argument. Naming it here also earns + // this action the engine's automatic dependency on the staged tree's + // manifest (`docs/30-build-mcpp.md`, "An action that names + // `${mcpp.stage_dir}` gains a dependency on the tree's manifest"). + // + // WITH NO STAGED TREE IT COPIES THE ONE PROGRAM. `${mcpp.stage_dir}` + // REFUSES when there is no tree -- that is the engine's contract, and a + // member that names it unconditionally cannot run on a target whose + // built-in staging is refused. So the source is the tree when there is + // one and the program when there is not, and the `inputs` entry is the + // same either way: the program is what this bundle is FOR, and naming it + // is what orders this action after the link. + layout.argv = stage.empty() + ? std::vector{ "ditto", + std::format("${{mcpp.target_file:{}}}", target), + contents + "/MacOS/" + executableName } + : std::vector{ "ditto", "${mcpp.stage_dir}", + contents + "/MacOS" }; + layout.inputs = { std::format("${{mcpp.target_file:{}}}", target) }; + layout.output = contents + "/MacOS/" + executableName; + p.steps.push_back(layout); + assembled.push_back(layout.output); + + if (!opt.icon.empty()) { + step icon; + icon.id = "mcpp.dist.apple.icon"; + icon.role = "artifact"; + icon.description = "APP ICON"; + icon.argv = { "ditto", opt.icon, contents + "/Resources/" + iconName }; + icon.inputs = { opt.icon }; + icon.output = contents + "/Resources/" + iconName; + p.steps.push_back(icon); + assembled.push_back(icon.output); + } + + if (!opt.identity.empty()) { + step sign; + sign.id = "mcpp.dist.apple.codesign"; + sign.role = "artifact"; + sign.description = "CODESIGN"; + sign.argv = { "codesign", "--force", "--sign", opt.identity, "--timestamp" }; + if (opt.hardened_runtime) { sign.argv.push_back("--options"); sign.argv.push_back("runtime"); } + if (!opt.entitlements.empty()) { + sign.argv.push_back("--entitlements"); + sign.argv.push_back(opt.entitlements); + } + sign.argv.push_back(bundlePath); + // Depends on every other step's output, because codesign covers the + // bundle's content at signing time -- see the header comment. + sign.inputs = assembled; + if (!opt.entitlements.empty()) sign.inputs.push_back(opt.entitlements); + // codesign has no flag to write a receipt to an arbitrary path, so + // this names the one file signing a BUNDLE (rather than a flat + // Mach-O) is documented to write as part of embedding the signature: + // `Contents/_CodeSignature/CodeResources`, a manifest of the signed + // resources' hashes. This is documented Apple codesign behaviour, + // not something measured here -- codesign does not run on Linux. + sign.output = contents + "/_CodeSignature/CodeResources"; + p.steps.push_back(sign); + } + + p.applies = true; + return p; +} + +// ─── Submit ──────────────────────────────────────────────────────────────── + +inline bool submit(const plan& p) { + if (!p.applies) return true; + for (auto const& s : p.steps) { + mcpp::action a; + a.id = s.id; + a.role = s.role; + a.description = s.description; + for (auto const& tok : s.argv) a.arg(tok.c_str()); + for (auto const& in : s.inputs) a.input(in.c_str()); + a.output(s.output.c_str()); + a.submit(); + } + + // A FLOOR ON THIS MEMBER'S OWN OUTPUT, ON THE SUCCESS PATH. + // + // The assembled bundle does not exist when this program runs -- ditto and + // codesign have not been invoked yet, only declared -- so what this + // member measures instead is the staged tree the layout step is about to + // copy, exactly as `dist/appimage.cppm` measures its own AppDir before + // appimagetool runs. This catches "nothing was linked or staged"; it + // cannot catch a `ditto` that fails partway, a codesign that fails to + // verify, or the bare-filename assumption `bundle_executable_name` + // documents not holding for a non-default staging layout -- all of those + // happen after this program has already exited. + std::error_code ec; + std::uintmax_t bytes = 0; + for (auto const& e : std::filesystem::recursive_directory_iterator(p.appdir, ec)) { + if (ec) break; + if (e.is_regular_file(ec)) bytes += std::filesystem::file_size(e.path(), ec); + } + // Loose on purpose, matching `dist/appimage.cppm`'s own bound: this + // exists to catch "nothing was staged", not to police a size budget. + // Unlike that member, nothing is written INTO the staged tree here -- + // `Info.plist` and the icon live outside it until the layout and install + // steps run -- so even a low bound is already suspicious. + if (bytes < 4u * 1024u) { + static char msg[512]; + std::snprintf(msg, sizeof msg, + "mcpp.dist.apple: the staged tree at %s holds only %llu bytes, " + "which is not a program; the .app will not launch anything", + p.appdir.c_str(), static_cast(bytes)); + mcpp::warning(msg); + } + return true; +} + +// ─── The one call a consumer makes ───────────────────────────────────────── + +// DECLARE UNCONDITIONALLY, SUBMIT CONDITIONALLY -- and both halves are here so +// a consumer cannot do one without the other. +// +// The declaration is what lets the engine answer a question the requesting +// build cannot: `mcpp pack --format bogus` names what is available, and +// `--help` says "any format the resolved graph provides". Both read the set +// collected from a pass that asked for nothing. A member that declared only +// when asked still works for its author -- they always pass their own +// format -- and makes the set unknowable for everyone else. +inline bool generate(options opt = {}) { + mcpp::provides_pack_format("app"); + return submit(plan_for(std::move(opt))); +} + +} // namespace mcpp::dist::apple diff --git a/dist/wix.cppm b/dist/wix.cppm new file mode 100644 index 0000000..5c51f3a --- /dev/null +++ b/dist/wix.cppm @@ -0,0 +1,714 @@ +// mcpp.dist.wix -- an MSI from a linked program, and a definition this member +// renders around it. +// +// WHY THIS IS NEITHER A RULE NOR A TOOL. A rule states how a translation unit +// is compiled by a compiler mcpp does not drive. A tool states something the +// build program needs that no compiler performs, and does it while the +// program runs. This member does neither: it consumes a LINK OUTPUT and +// produces something a user installs. That is the third category `dist/ +// appimage.cppm` establishes, and the prefix says which of the three +// questions a member answers -- a consumer reading `rules-wix` would expect a +// compiler and a translation unit, and there is neither. +// +// THE ENGINE HOLDS THE DISPATCH AND NOT THE FORMAT. `mcpp pack --format msi` +// finds the package that declared the name and hands it the same graph +// mechanism every other format uses; nothing about WiX's schema, its +// preprocessor, or its table model is in mcpp. Binding any of it there would +// couple an mcpp release to a release mcpp does not control -- WiX 4, 5 and 6 +// are three such releases sharing one schema, and this member already has to +// track which one it is talking to without the engine's help. +// +// NAME THE INPUT; DO NOT HARVEST A DIRECTORY. This is the load-bearing +// decision in this file, and it is measured rather than argued. An earlier +// working implementation of this exact step bound a directory and harvested +// it -- `-bindpath Application=bin` -- and let WiX's own harvester decide what +// went in the `File` table. When that bind path resolved to nothing on +// Windows, wix produced a **valid, empty, 52 KB installer with no diagnostic +// at all**: no missing-file error, no empty-harvest warning, an exit code of +// zero. The rule this produces: a path that RESOLVES TO NOTHING is silent, +// and a NAMED INPUT that is missing is an error. `${mcpp.target_file:}` +// is what gives this member the second shape instead of the first -- a build +// program is told neither the triple nor the fingerprint that produced the +// path, and the engine refuses an unknown target name rather than expanding +// it to an empty string -- so the program arrives as that placeholder and +// never as a directory this member goes looking through. +// +// THIS MEMBER NEVER READS THE STAGED TREE, AND THAT IS THE POINT OF ITS +// SHAPE. `mcpp pack --format msi` reports as the distributable whatever +// artifact action the REQUEST introduced, so a member that names one program +// and no directory needs nothing extra to be recognised. An earlier engine +// revision asked the narrower question -- which action named +// `${mcpp.stage_dir}` -- and refused this member for following section 6's +// guidance, which is why the engine's criterion is presence in the dispatch +// pass rather than a property of the member. +// +// THE PROGRAM'S PATH CROSSES TWO SUBSTITUTION PASSES, AND THAT IS WHY IT IS A +// WiX VARIABLE AND NOT LITERAL TEXT. `${mcpp.stage_dir}` and +// `${mcpp.target_file:}` are interpolated by the ENGINE, and only +// inside an action's own argv -- not inside a file this build program happens +// to write to disk, which is why the `.wxs` below cannot simply spell the +// program's path out. So the path is carried in the argv, where the engine's +// substitution applies (`-d Executable=${mcpp.target_file:}`), as a +// WiX preprocessor variable; the `.wxs` then references `$(Executable)`, +// which is WiX's OWN substitution, run later, when `wix build` itself +// executes. Two passes, chained: the engine resolves the argv token before +// the command runs, and wix resolves its own token while it runs. +// +// WHY PATH DISCOVERY IS ACCEPTABLE HERE AND IS NOT IN `dist/appimage.cppm`. +// appimagetool EMBEDS an asset it owns -- the type-2 runtime stub -- into the +// image it produces, so which build of the tool ran is part of the AppImage's +// own content, and `dist/appimage.cppm` declares that payload rather than +// trust whatever is on PATH. wix has no equivalent: an MSI's content is +// entirely decided by the `.wxs` and the files it names, and a newer or older +// wix compiling the same definition produces the same table rows. What varies +// between wix releases is the SCHEMA it accepts (v4/v5/v6 share the one this +// member targets) and its own diagnostics, neither of which the produced MSI +// carries away. So a host `wix` is a fine default here in a way a host +// `appimagetool` is not. +// +// WHY THERE IS NO PAYLOAD YET, AND WHY THAT IS A GAP RATHER THAN A DECISION. +// +// This member locates `wix` and refuses clearly when nothing is there, naming +// where it looked -- the `msvc@system` shape mcpp already has for the +// platform's own compiler. An earlier revision of this comment justified that +// by saying WiX is "not a redistributable archive this ecosystem can vendor", +// and THAT IS FALSE. Measured 2026-09-11: +// +// https://api.nuget.org/v3-flatcontainer/wix/6.0.2/wix.6.0.2.nupkg +// HTTP/2 200, application/octet-stream, 5851349 bytes +// +// anonymously fetchable and immutable, as every NuGet package is. And its own +// licence file, `OSMFEULA.txt` inside that package, says in as many words that +// redistribution is permitted: +// +// 2. "...this does not restrict the User from obtaining or redistributing +// binaries from other sources or self-compiling them." +// 3. "The Fee is not a license fee. The Software's source code is licensed +// to User under the OSI License and remains freely distributable..." +// +// The software is under the Microsoft Reciprocal License; the fee is a +// MAINTENANCE fee that applies to revenue-generating use, and section 4 +// resolves any conflict in favour of the OSI licence. So `xim:wix` is +// legitimate, and this member should declare it the way `dist-appimage` +// declares `xim:appimagetool` -- with no PATH fallback at all, so the produced +// installer depends on a declaration rather than on a machine. +// +// TWO THINGS ARE NEEDED FOR THAT AND NEITHER IS WRITTEN YET: the package, and +// a `xim:dotnet` dependency, because the payload is `tools/net6.0/any/wix.dll` +// and not a self-contained executable -- it is invoked as `dotnet wix.dll`. +// `pkgs/d/dotnet.lua` already exists in the index, so the dependency edge is +// available. Until the package lands, the lookup below is what there is, and +// the paragraph above records that it is a gap and not the answer. + +module; +#include + +export module mcpp.dist.wix; + +import std; +import mcpp; +import mcpp.plugins; + +// Nothing here uses `std::println`, and that is not a style choice: both of +// its overloads reach into the libc++ dylib for symbols macOS 14 does not +// ship, so a member that printed with it compiled and then failed to link. +// `std::format` is header-only. The full measurement is in `rules/spirv.cppm`. +// This member never runs on macOS, but it is compiled as a host module +// everywhere `mcpp-plugins` is built, `tests/all-rules-compile` included, so +// the same rule applies to it. + +export namespace mcpp::dist::wix { + +// ─── Options ─────────────────────────────────────────────────────────────── + +struct options { + // The program target this MSI wraps. Empty means the package name, which + // is the target `mcpp pack` itself selects by convention. + std::string target; + + // `Package/@Name`. Empty means the target name, then the package name, + // then "app" -- the same fallback chain `dist/appimage.cppm` uses for its + // own app name, so a project that names nothing still gets one answer + // rather than an MSI with a blank product name. + std::string product_name; + + // `Package/@Manufacturer`. Empty means the first of `package_authors()` + // (';'-separated), then `package_namespace()`, then a literal fallback -- + // see `manufacturer_for` for why a final fallback exists at all: WiX + // requires this attribute to be non-empty. + std::string manufacturer; + + // `Package/@Version`, in the project's OWN numbering, used as given with + // no conversion. Set this when `[package] version` is not already an MSI + // version WiX will accept (see `msi_version_from` for what mcpp's own + // four-segment date version needs done to it, which this member does + // automatically when this option is left empty). + std::string version; + + // `Package/@UpgradeCode`, a literal `{GUID}`. Empty derives one + // deterministically from `package_namespace()` and `package_name()`; see + // `upgrade_code_for` for why determinism is the requirement, not merely a + // convenience. + std::string upgrade_code; + + // A project-supplied `.wxs` that wins over the one this member generates. + // It must reference `$(Executable)` the same way the generated + // definition does if it wants the built program named at all -- this + // member always passes that one variable through `-d`, generated + // definition or not, because that is the one value this build program + // cannot bake in as literal text (see the header comment). + // + // `$(Executable)`, NOT `$(var.Executable)`, AND THE DIFFERENCE IS A + // MEASUREMENT RATHER THAN A READING. WiX's own documentation spells a + // `-d` define as `$(var.Name)`, which is v3's form, and that is what this + // member first generated. A working implementation that is green on + // Windows CI uses the bare form -- HuxerUI's + // `mcpp/huxerui-build-rules/wix/Package.wxs.in`, ``, against the same `-d Executable=` argv + // this member builds. Neither spelling could be run here, so the one with + // a measurement behind it is the one shipped. + std::string wxs; + + // An explicit `wix` wins over discovery. Set it to pin a build other than + // the one on PATH. + std::string tool; + + // Where the produced file lands. Empty means + // `/-.msi`. + std::string output; + std::string out_dir = std::string(mcpp::out_dir()); +}; + +// ─── The plan ────────────────────────────────────────────────────────────── + +// What this member would submit, without submitting it. +// +// A PLAN AND A SUBMIT, BECAUSE A DISTRIBUTABLE IS THE LAST THING BEFORE A +// USER'S HANDS. It is therefore the part of a build most likely to need a +// project-specific edit -- a second file in the `.wxs`, a UI sequence, a +// launch condition -- and `generate()` being exactly `submit(plan_for())` is +// what keeps such an edit from becoming a reimplementation of this member. +struct plan { + // False when this build is not `mcpp pack --format msi`, which is every + // ordinary build. `reason` then says which of the several ways. + bool applies = false; + std::string reason; + std::string output; // the .msi path + std::string wxs_path; // generated or project-supplied + std::string target_name; // for the opportunistic size probe below + std::vector argv; + std::vector inputs; + explicit operator bool() const { return applies; } +}; + +// ─── Internals ───────────────────────────────────────────────────────────── + +inline bool is_file(const std::string& p) { + std::error_code ec; + return !p.empty() && std::filesystem::is_regular_file(p, ec); +} + +// Written only when the bytes differ, for the reason `dist/appimage.cppm` +// gives: rewriting identical bytes moves the mtime, and a moved mtime on a +// declared input is indistinguishable from a changed one -- so a second pack +// of an unchanged project would rebuild the MSI. +inline bool write_if_different(const std::filesystem::path& path, + std::string_view bytes) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (std::ifstream in(path, std::ios::binary); in) { + std::string old((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + if (old == bytes) return true; + } + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) return false; + out.write(bytes.data(), static_cast(bytes.size())); + return static_cast(out); +} + +inline std::string target_for(const options& opt) { + if (!opt.target.empty()) return opt.target; + const char* n = mcpp::package_name(); + return (n && *n) ? std::string(n) : std::string(); +} + +inline std::string product_name_for(const options& opt) { + if (!opt.product_name.empty()) return opt.product_name; + if (!opt.target.empty()) return opt.target; + const char* n = mcpp::package_name(); + return (n && *n) ? std::string(n) : std::string("app"); +} + +inline std::string first_before(std::string_view s, char sep) { + auto pos = s.find(sep); + return std::string(pos == std::string_view::npos ? s : s.substr(0, pos)); +} + +// A FINAL FALLBACK THAT DOES NOT EXIST FOR appimage's DESKTOP `Comment=`. +// +// An AppImage's desktop entry tolerates an empty `Comment=`; WiX does not +// tolerate an empty `Manufacturer`, so this chain ends in a literal rather +// than in an empty string the way `dist/appimage.cppm`'s optional fields do. +inline std::string manufacturer_for(const options& opt) { + if (!opt.manufacturer.empty()) return opt.manufacturer; + if (const char* a = mcpp::package_authors(); a && *a) { + if (auto first = first_before(a, ';'); !first.empty()) return first; + } + if (const char* ns = mcpp::package_namespace(); ns && *ns) return ns; + return "Unknown"; +} + +// WiX's own architecture vocabulary, from the target mcpp resolved. Refused +// for anything else rather than guessed: an MSI built for the wrong +// architecture installs and then fails to run, with nothing in this build's +// own log pointing back at the guess that produced it. +inline std::string wix_arch_for(const std::string& arch) { + if (arch == "x86_64") return "x64"; + if (arch == "aarch64") return "arm64"; + return {}; +} + +// Splits "a.b.c.d" into unsigned fields, or an empty vector for anything that +// is not purely digits separated by single dots -- including a leading, +// trailing or doubled dot. A caller that gets an empty vector refuses by +// NAMING the string, rather than this function throwing through a build +// program the engine would then have to diagnose generically. +inline std::vector numeric_fields(std::string_view s) { + std::vector out; + if (s.empty()) return out; + std::size_t i = 0; + while (i <= s.size()) { + auto dot = s.find('.', i); + auto part = s.substr(i, dot == std::string_view::npos ? s.size() - i : dot - i); + if (part.empty()) return {}; + unsigned long v = 0; + for (char c : part) { + if (c < '0' || c > '9') return {}; + v = v * 10 + static_cast(c - '0'); + } + out.push_back(v); + if (dot == std::string_view::npos) break; + i = dot + 1; + } + return out; +} + +struct msi_version { std::string text; bool lossy = false; bool ok = false; }; + +// MSI PACKS ProductVersion INTO Major.Minor.Build WITH HARD PER-FIELD +// CEILINGS -- 255, 255, 65535 -- because Windows Installer stores the three +// fields in a single 32-bit integer (8+8+16 bits). WiX enforces the same +// ceiling at compile time, error CNDL0242 ("Invalid product version"). That +// is documented Windows Installer and WiX behaviour, not something measured +// on this host: wix does not run on Linux, so nothing here has produced or +// rejected a real MSI. A raw calendar year overflows the first field by a +// factor of eight, so mcpp's own `YYYY.M.D.N` convention cannot be copied +// into Major.Minor.Build positionally -- and Windows Installer's comparison +// ignores whatever the fourth field holds regardless, so the release counter +// cannot simply ride along as a fourth field either. +// +// The fields are therefore RE-PACKED rather than truncated: +// +// Major = year modulo 100 -- wraps every century; always under 255 +// Minor = month -- 1..12 for mcpp's own scheme, clamped +// Build = day * 1000 + counter -- keeps SAME-DAY releases ordered +// +// This repository has itself shipped 2026.9.10.1 and 2026.9.10.2 -- two +// releases on one calendar day -- so folding the day and the release counter +// into the one field Windows Installer actually compares is not a +// hypothetical: a naive Major.Minor.Build = Year.Month.Day mapping would have +// made those two collide as "the same version", and an upgrade would need +// `AllowSameVersionUpgrades` just to be recognised as one. What this loses: +// the day and the release counter are no longer independently readable from +// the MSI version the way they are from `package_version()`, and a project +// shipping past the year 2125 loses century information this scheme wraps at +// -- 2026 and 2126 both become Major 26. +// +// A version that is not already four numeric dotted fields is assumed to +// already be MSI-shaped -- an ordinary project version such as "1.2.3" -- and +// is passed through with only the same per-field ceiling applied, because +// nothing here knows it is a date, and re-packing it would invent structure +// that is not there. +inline msi_version msi_version_from(std::string_view v) { + msi_version out; + auto f = numeric_fields(v); + if (f.empty()) return out; + out.ok = true; + if (f.size() >= 4) { + unsigned long major = f[0] % 100; + unsigned long minor = f[1] > 255 ? 255 : f[1]; + unsigned long build = f[2] * 1000 + f[3]; + if (build > 65535) build = 65535; + out.text = std::format("{}.{}.{}", major, minor, build); + out.lossy = true; + } else { + static constexpr unsigned long kCaps[3] = {255, 255, 65535}; + bool clamped = false; + std::string joined; + for (std::size_t i = 0; i < f.size(); ++i) { + unsigned long v2 = f[i]; + if (v2 > kCaps[i]) { v2 = kCaps[i]; clamped = true; } + if (i) joined += '.'; + joined += std::to_string(v2); + } + out.text = joined; + out.lossy = clamped; + } + return out; +} + +// FNV-1a, run twice with different seeds to produce 128 bits. Chosen over +// `std::hash` because `std::hash` is not specified to produce +// the same digest across standard library implementations, versions, or even +// process runs -- and the one property an UpgradeCode needs for the whole +// life of a product is to be the SAME value every time this identity string +// is hashed, on whatever machine and whatever toolchain builds it. A GUID +// that silently changed because a CI runner's C++ runtime was upgraded would +// present Windows Installer with what looks like an unrelated product, with +// nothing in this build's log pointing at the cause -- exactly the failure +// category the rest of this file exists to refuse. +inline std::uint64_t fnv1a64(std::string_view s, std::uint64_t seed) { + std::uint64_t h = seed; + for (unsigned char c : s) { h ^= c; h *= 0x100000001b3ULL; } + return h; +} + +// A hash formatted as a GUID, not an RFC 4122 version-5 UUID -- that +// specifies SHA-1 over a namespace and a name, and nothing here claims that +// stronger guarantee. What is claimed is narrower and is all this needs: the +// same identity string always produces the same 16 bytes. The version and +// variant nibbles below are set anyway, for the benefit of any tool that +// validates a GUID's structure rather than only its punctuation; wix itself +// only parses the punctuation. +inline std::string upgrade_code_for(const std::string& identity) { + const std::uint64_t hi = fnv1a64(identity, 0x9e3779b97f4a7c15ULL); + const std::uint64_t lo = fnv1a64(identity, 0xcbf29ce484222325ULL); + unsigned char b[16]; + for (int i = 0; i < 8; ++i) b[i] = static_cast(hi >> (56 - 8 * i)); + for (int i = 0; i < 8; ++i) b[8 + i] = static_cast(lo >> (56 - 8 * i)); + b[6] = static_cast((b[6] & 0x0F) | 0x50); // version nibble + b[8] = static_cast((b[8] & 0x3F) | 0x80); // RFC 4122 variant + static const char* kHex = "0123456789ABCDEF"; + std::string out = "{"; + for (int i = 0; i < 16; ++i) { + if (i == 4 || i == 6 || i == 8 || i == 10) out += '-'; + out += kHex[b[i] >> 4]; + out += kHex[b[i] & 0x0F]; + } + out += "}"; + return out; +} + +inline std::string xml_escape(std::string_view s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + switch (c) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '"': out += """; break; + default: out += c; + } + } + return out; +} + +// Discovery, in the order `rules/spirv.cppm` establishes: what the project +// named, what the environment named, then PATH. There is no payload tier +// between them -- see the header comment for why a host `wix` is an +// acceptable default here in a way a host shader compiler or a host +// `appimagetool` is not. +inline std::string discover_tool(const options& opt) { + if (!opt.tool.empty()) return opt.tool; + if (const char* e = std::getenv("MCPP_WIX"); e && *e) return e; +#if defined(_WIN32) + const char* exe = "wix.exe"; +#else + // wix is a .NET tool and therefore Windows-only in practice; this branch + // exists only so the search compiles and returns nothing on every other + // host, which is what `tests/all-rules-compile` exercises. + const char* exe = "wix"; +#endif + const char* path = std::getenv("PATH"); + if (!path || !*path) return {}; +#if defined(_WIN32) + constexpr char kSep = ';'; +#else + constexpr char kSep = ':'; +#endif + std::string_view sv(path); + for (std::size_t i = 0; i <= sv.size();) { + auto sep = sv.find(kSep, i); + auto dir = sv.substr(i, sep == std::string_view::npos ? sv.size() - i : sep - i); + i = sep == std::string_view::npos ? sv.size() + 1 : sep + 1; + if (dir.empty()) continue; + auto candidate = (std::filesystem::path(dir) / exe).string(); + if (is_file(candidate)) return candidate; + } + return {}; +} + +// A minimal WiX v4/v5/v6 definition: one `Package`, one `Component` carrying +// the single file this member wraps, one `Feature` referencing it. WiX can +// derive a stable Component GUID from the component's own target path by +// itself when `Component/@Guid` is omitted -- that is why only one GUID is +// minted here rather than two: `UpgradeCode` expresses identity across the +// WHOLE product, across every rebuild, which is ecosystem knowledge +// (`package_namespace()` plus `package_name()`) the compiler has no way to +// see on its own. +// +// `Codepage="65001"` (UTF-8) is stated explicitly because `product_name` and +// `manufacturer` come from project metadata that may not be ASCII, and this +// member has no reason to assume WiX's default matches. +inline std::string wxs_document(const std::string& name, const std::string& manufacturer, + const std::string& version, const std::string& upgrade_code) { + return std::format( + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n", + xml_escape(name), xml_escape(manufacturer), xml_escape(version), upgrade_code); +} + +// ─── Plan ────────────────────────────────────────────────────────────────── + +inline plan plan_for(options opt = {}) { + plan p; + + // NOT THIS PASS. Every ordinary build lands here, and the empty + // `pack_format()` is what says so -- see `generate` for why the + // DECLARATION must not be gated the same way. + const std::string requested = mcpp::pack_format(); + if (requested != "msi") { + p.reason = requested.empty() + ? "this build is not packaging" + : std::format("--format {} was requested, not msi", requested); + return p; + } + + // Windows only, and this is a refusal rather than a silent skip: a user + // who typed `--format msi` on Linux asked for something that does not + // exist there, and the engine has already accepted the value because the + // graph declared it. + if (const std::string os = mcpp::target_os(); os != "windows") { + std::cerr << std::format( + "mcpp.dist.wix: an MSI is a Windows format, and this build targets " + "'{}'.\n" + " use: --format tar, or build for a Windows target", + os.empty() ? "unknown" : os) << '\n'; + p.reason = "not a Windows target"; + return p; + } + + const std::string target = target_for(opt); + if (target.empty()) { + std::cerr << "mcpp.dist.wix: no target to package. Set " + "`options::target` to the program target's name.\n"; + p.reason = "no target"; + return p; + } + + const std::string tool = discover_tool(opt); + if (tool.empty()) { + std::cerr << std::format( + "mcpp.dist.wix: the wix CLI was not found.\n" + " looked for: options::tool, then $MCPP_WIX, then `wix` on PATH.\n" + " WiX is a .NET tool this ecosystem does not redistribute; " + "install it with:\n" + " dotnet tool install --global wix\n" + " or set `options::tool` to name one explicitly.") << '\n'; + p.reason = "wix not found"; + return p; + } + + const std::string hostArch = mcpp::target_arch(); + const std::string arch = wix_arch_for(hostArch); + if (arch.empty()) { + std::cerr << std::format( + "mcpp.dist.wix: WiX has no architecture spelling this member " + "knows for '{}'. Known: x86_64 -> x64, aarch64 -> arm64.", + hostArch.empty() ? "unknown" : hostArch) << '\n'; + p.reason = "unknown architecture"; + return p; + } + + const std::string name = product_name_for(opt); + std::string wxsPath; + if (!opt.wxs.empty()) { + if (!is_file(opt.wxs)) { + std::cerr << std::format( + "mcpp.dist.wix: the definition file {} was not found", opt.wxs) << '\n'; + p.reason = "definition file not found"; + return p; + } + wxsPath = opt.wxs; + } else { + const char* pv = mcpp::package_version(); + const std::string rawVersion = !opt.version.empty() ? opt.version + : (pv && *pv ? std::string(pv) : std::string()); + if (rawVersion.empty()) { + std::cerr << "mcpp.dist.wix: no version to state. Set " + "`[package] version` or `options::version`.\n"; + p.reason = "no version"; + return p; + } + const auto mv = msi_version_from(rawVersion); + if (!mv.ok) { + std::cerr << std::format( + "mcpp.dist.wix: '{}' is not a purely numeric, dot-separated " + "version, which is what an MSI's Version attribute requires.", + rawVersion) << '\n'; + p.reason = "version not numeric"; + return p; + } + const std::string manufacturer = manufacturer_for(opt); + const char* nsC = mcpp::package_namespace(); + const char* nmC = mcpp::package_name(); + const std::string identity = (nsC && *nsC ? std::string(nsC) : std::string()) + + "/" + (nmC && *nmC ? std::string(nmC) : std::string()); + const std::string upgradeCode = !opt.upgrade_code.empty() ? opt.upgrade_code + : upgrade_code_for(identity); + wxsPath = (std::filesystem::path(opt.out_dir) / (name + ".wxs")).string(); + if (!write_if_different(wxsPath, wxs_document(name, manufacturer, mv.text, upgradeCode))) { + std::cerr << std::format("mcpp.dist.wix: cannot write {}", wxsPath) << '\n'; + p.reason = "cannot write definition"; + return p; + } + } + + p.output = !opt.output.empty() ? opt.output + : (std::filesystem::path(opt.out_dir) + / std::format("{}-{}.msi", name, arch)).string(); + p.wxs_path = wxsPath; + p.target_name = target; + + const std::string targetFile = std::format("${{mcpp.target_file:{}}}", target); + p.argv = { + tool, "build", + "-arch", arch, + // The one value this build program cannot bake in as literal text -- + // see the header comment for why it crosses two substitution passes + // instead. + "-d", "Executable=" + targetFile, + "-o", p.output, + wxsPath, + }; + // TWO INPUTS, AND NEITHER IS THE STAGED TREE. The program the MSI carries + // and the definition that describes it are the whole of what this action + // reads, so editing the `.wxs` rebuilds the MSI and a dependency's shared + // library -- which the MSI's one `File` row never names -- does not. + p.inputs = { targetFile, wxsPath }; + p.applies = true; + return p; +} + +// ─── Submit ──────────────────────────────────────────────────────────────── + +inline bool submit(const plan& p) { + if (!p.applies) return true; + mcpp::action a; + a.id = "mcpp.dist.wix"; + a.role = "artifact"; + a.description = "MSI"; + for (auto const& tok : p.argv) a.arg(tok.c_str()); + for (auto const& in : p.inputs) a.input(in.c_str()); + a.output(p.output.c_str()); + a.submit(); + + // A FLOOR ON THIS MEMBER'S OWN OUTPUT, ON THE SUCCESS PATH -- IN TWO + // HALVES, BECAUSE ONE THING IS ALWAYS MEASURABLE AND THE OTHER IS NOT. + // + // The MSI itself does not exist when this program runs -- wix has not + // been invoked yet, only declared -- so nothing here can open it. What + // this member CAN see: the definition it just wrote or was handed, and, + // opportunistically, a copy of the program inside whatever `mcpp pack` + // staged. Both are best-effort in different ways: the first is exact but + // narrow (it catches an empty definition, not a broken one); the second + // depends on a staging convention this member does not otherwise rely on + // -- see `plan_for`, which never reads the staged tree -- so it is + // skipped rather than refused when nothing recognisable turns up there. + // Neither half can catch a wix invocation that fails, a signature that + // does not verify, or an identity the machine does not have -- those run + // after this program has already exited. + std::string text; + if (std::ifstream in(p.wxs_path, std::ios::binary); in) + text.assign((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (text.find(" element; " + "the MSI wix builds from it will install nothing", + p.wxs_path.c_str()); + mcpp::warning(msg); + } + + if (const std::string stage = mcpp::pack_stage_dir(); !stage.empty()) { + std::string found; + for (auto candidate : { stage + "/" + p.target_name, + stage + "/bin/" + p.target_name, + stage + "/" + p.target_name + ".exe", + stage + "/bin/" + p.target_name + ".exe" }) + if (is_file(candidate)) { found = candidate; break; } + if (!found.empty()) { + std::error_code ec; + std::uintmax_t bytes = std::filesystem::file_size(found, ec); + // A SIZE BOUND IS DEFENSIBLE HERE AND WAS NOT IN + // `dist/appimage.cppm`, which is worth stating because the two + // look alike. That member measured a whole staged TREE and 16 KB + // was wrong on its first fixture -- a stripped hello-world stages + // at 14999 bytes -- so it counts files instead. This measures ONE + // LINKED PROGRAM, and no linked program is under a kilobyte on any + // platform this member serves. It exists to catch "nothing was + // linked", not to police a size budget. + if (!ec && bytes < 1024u) { + static char msg2[512]; + std::snprintf(msg2, sizeof msg2, + "mcpp.dist.wix: %s is %llu bytes, which is implausibly " + "small for a linked program; the MSI may be carrying a " + "stub", + found.c_str(), static_cast(bytes)); + mcpp::warning(msg2); + } + } + } + return true; +} + +// ─── The one call a consumer makes ───────────────────────────────────────── + +// DECLARE UNCONDITIONALLY, SUBMIT CONDITIONALLY -- and both halves are here so +// a consumer cannot do one without the other. +// +// The declaration is what lets the engine answer a question the requesting +// build cannot: `mcpp pack --format bogus` names what is available, and +// `--help` says "any format the resolved graph provides". Both read the set +// collected from a pass that asked for nothing. A member that declared only +// when asked still works for its author -- they always pass their own +// format -- and makes the set unknowable for everyone else. +inline bool generate(options opt = {}) { + mcpp::provides_pack_format("msi"); + return submit(plan_for(std::move(opt))); +} + +} // namespace mcpp::dist::wix diff --git a/mcpp.toml b/mcpp.toml index 7831a0a..4f12ba8 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] name = "plugins" namespace = "mcpp" -version = "0.5.2" +version = "0.6.0" description = "Official mcpp build plugins: rule packages under mcpp.rules.*, build-time utilities under mcpp.tools.*, each member selected by a feature" license = "Apache-2.0" authors = ["mcpp-community"] @@ -116,6 +116,52 @@ device_extensions = [".sycl"] # NOT device rules: these embed or declare things a project already has, so they # claim no extension and name no rule module. A consumer calls them from its own # `build.mcpp`. +# ── dist: what comes out of the link, and in what form a user installs it ── +# +# A THIRD CATEGORY, AND THE PREFIX IS THE POINT. `rules-*` says how a +# translation unit is compiled; `tools-*` says what the build program does +# itself; `dist-*` says what comes out of the link. A `dist-` member compiles no +# translation unit and does no work while the build program runs: it consumes +# link outputs through a `role = "artifact"` action and produces something a +# user installs, reached with `mcpp pack --format ` (mcpp 2026.9.11.1+). +# +# THE ENGINE HOLDS THE DISPATCH AND NOT THE FORMAT. dpkg's control fields, +# AppImage's runtime, WiX's schema and Apple's notarisation each couple a +# release to a release mcpp does not control, so none of them is in mcpp. The +# argument is the one this project already made for languages: Slang is +# supported without being named in the engine, and a distribution format has +# less claim to a name there than a language does. +# +# EACH MEMBER IS ONE PLATFORM'S ANSWER and says so in its own module. A member +# whose target OS does not match refuses by name rather than skipping silently: +# the engine has already accepted the `--format` value because this graph +# declared it, so a silent skip would report a successful pack that produced +# nothing. + +# NO `rule_module`, AND THE ENGINE IS RIGHT TO REQUIRE THAT. +# +# `rule_module` names the module a consumer's build program imports TO REACH A +# RULE, and the engine refuses it without `device_extensions` -- "a build rule +# states both: `device_extensions` is what it compiles, `rule_module` is how a +# consumer reaches it". A dist member compiles nothing, so it claims no +# extension and has no business with that key. +# +# It therefore takes the `tools-*` shape: the feature adds the unit, and the +# consumer writes `host-module = true` on the dependency edge and imports the +# module by name. One line more for a consumer than a rule needs, and it says +# something true -- this member is not a rule. +[features.dist-appimage] +sources = ["dist/appimage.cppm"] +implies = ["surface"] + +[features.dist-wix] +sources = ["dist/wix.cppm"] +implies = ["surface"] + +[features.dist-apple] +sources = ["dist/apple.cppm"] +implies = ["surface"] + [features.tools-embed] sources = ["tools/embed.cppm"] implies = ["surface"] @@ -289,6 +335,36 @@ implies = ["surface"] [target.'cfg(accelerator = "ascend")'.feature-xlings.rules-ascendc] "xim:cann-toolkit" = ">=8.5.0" +# ── The environment `dist-appimage` needs ────────────────────────────────── +# +# ONE PAYLOAD, AND IT CARRIES TWO FILES. `appimagetool` builds the image, and +# the type-2 runtime stub is what the image starts with. Measured on +# appimagetool 1.9.1: without `--runtime-file` the tool DOWNLOADS that stub from +# a GitHub release on every invocation, and with the address unreachable the +# build fails inside the tool. A build must not reach the network -- one that +# does is neither reproducible nor usable offline -- so the recipe installs both +# and the rule passes the stub explicitly. +# +# THE GATE IS THE FEATURE AND THE OS, AND THERE IS DELIBERATELY NO THIRD ONE. +# The rules gate their multi-gigabyte vendor toolkits on `cfg(accelerator = +# ...)` as well, because a CPU-only build of a project that depends on them must +# download nothing. No equivalent exists here and none should: provisioning runs +# BEFORE the build program, so `--format` -- which only the build program +# learns -- cannot be a layer key. The cost of that is bounded and small: a +# Linux consumer that names this feature installs 15 MB whether or not it packs. +# +# A FLOOR RATHER THAN AN EXACT VERSION. appimagetool's version is coupled to +# nothing this rule cannot see; 1.9.1 is where the runtime stub ships beside the +# tool, which is what makes an offline build expressible. +[target.'cfg(linux)'.feature-xlings.dist-appimage] +"xim:appimagetool" = ">=1.9.1" + +# `dist-wix` and `dist-apple` declare no payload, and that is a licence fact +# rather than an omission. WiX is a .NET tool distributed through NuGet and +# `codesign` is part of Xcode; neither is redistributable, so each member +# locates what the machine has and says where it looked when the lookup returns +# empty -- the `msvc@system` shape, which mcpp already has. + [targets.plugins] kind = "lib" diff --git a/src/plugins.cppm b/src/plugins.cppm index af1d374..68d1a03 100644 --- a/src/plugins.cppm +++ b/src/plugins.cppm @@ -49,7 +49,7 @@ export namespace mcpp::plugins { // // One package, one version: the number lives in mcpp.toml, and the CI step // `the collection states its own version` compares the two. -inline constexpr std::string_view version = "0.5.2"; +inline constexpr std::string_view version = "0.6.0"; } // namespace mcpp::plugins diff --git a/tests/all-rules-compile/build.mcpp b/tests/all-rules-compile/build.mcpp index 120dbd1..d124c09 100644 --- a/tests/all-rules-compile/build.mcpp +++ b/tests/all-rules-compile/build.mcpp @@ -13,6 +13,9 @@ import mcpp.rules.spirv; import mcpp.rules.sycl; import mcpp.tools.embed; import mcpp.tools.island; +import mcpp.dist.appimage; +import mcpp.dist.wix; +import mcpp.dist.apple; int main() { // No accelerator is named, so each of these returns true without looking @@ -29,9 +32,30 @@ int main() { // called, because a fixture whose point is a compilation should not also // produce output. ok = ok && !mcpp::tools::embed::header_path("probe.bin").empty(); + // Same question of `table()`, added beside `file()`'s: it is a second + // entry point in one module, not a second module, and this fixture's + // whole point is that everything this package exports compiles for the + // host running the build. + ok = ok && !mcpp::tools::embed::table_header_path().empty(); // Same question of the island generator: the one call that writes // nothing. `entry_name` is where its only parsing lives. ok = ok && mcpp::tools::island::entry_name("int f(int a)") == "f"; - std::println("all-rules-compile: every rule module compiled for this host"); + + // `dist-wix` and `dist-apple` are neither rules nor tools, but the same + // property is worth asserting on every platform for the same reason: a + // plan on a build that is not `mcpp pack --format ` must return + // `applies == false` and print nothing, on every host, including the two + // whose format this is not. `pack_format()` is empty here exactly as + // `accel()` is empty above, so both refusals are silent -- neither + // module's Windows-only or macOS-only diagnostic fires, because neither + // reaches the check that would print it. + auto appimagePlan = mcpp::dist::appimage::plan_for(); + ok = ok && !appimagePlan.applies; + auto wixPlan = mcpp::dist::wix::plan_for(); + ok = ok && !wixPlan.applies; + auto applePlan = mcpp::dist::apple::plan_for(); + ok = ok && !applePlan.applies; + + std::println("all-rules-compile: every rule and dist module compiled for this host"); return ok ? 0 : 1; } diff --git a/tests/all-rules-compile/mcpp.toml b/tests/all-rules-compile/mcpp.toml index 78e8c6b..0f3bfc8 100644 --- a/tests/all-rules-compile/mcpp.toml +++ b/tests/all-rules-compile/mcpp.toml @@ -1,5 +1,5 @@ -# The fixture that compiles every rule in this collection, on whatever host is -# running the build. +# The fixture that compiles every rule and dist member in this collection, on +# whatever host is running the build. # # WHY A FIXTURE WHOSE ONLY PRODUCT IS A COMPILATION. # @@ -14,35 +14,53 @@ # directory named `lib/x64`, a listing command. None of it is compiled by a # Linux build, so a Windows branch can be syntactically wrong for as long as # nobody builds on Windows -- and the first person to do so gets a compile -# error out of a package they only wanted to use. +# error out of a package they only wanted to use. A dist member's host- +# dependent code is the same shape -- `dist-wix`'s tool discovery carries its +# own `#if defined(_WIN32)` -- so it belongs in this fixture for the same +# reason a rule does. # -# This fixture names no accelerator. Every rule's `compile()` returns -# immediately in that state, so no payload is installed and no second compiler -# is needed; what happens is that all six modules are compiled as host modules -# for this host, which is the assertion. It is cheap enough to run on every -# platform in the matrix. +# This fixture names no accelerator and packages nothing (`mcpp build`, never +# `mcpp pack`). Every rule's `compile()` and every dist member's `plan_for()` +# returns immediately in that state, so no payload is installed and no second +# compiler is needed; what happens is that all eight modules are compiled as +# host modules for this host, which is the assertion. It is cheap enough to +# run on every platform in the matrix. [package] name = "all-rules-compile" namespace = "example" version = "0.1.0" -description = "Compiles every rule module in this collection for the host running the build" +description = "Compiles every rule and dist module in this collection for the host running the build" [language] standard = "c++23" modules = true import_std = true -# Every feature this package publishes. A rule left out of this list is a rule -# whose host-dependent code is compiled on one platform only. +# Every feature this package publishes. A rule or dist member left out of this +# list is one whose host-dependent code is compiled on one platform only. [build-dependencies.mcpp] plugins = { path = "../..", features = [ "rules-ascendc", "rules-cuda", "rules-hip", "rules-slang", "rules-spirv", "rules-sycl", "tools-embed", "tools-island", + "dist-appimage", "dist-wix", "dist-apple", ], host-module = true } # NO `accel`, and that is the whole design: with none, every rule returns -# before it looks for a payload, so this fixture downloads nothing on any host. +# before it looks for a payload, so no RULE downloads anything on any host. +# +# `dist-appimage` IS THE ONE MEMBER THAT CAN DOWNLOAD HERE, and it is listed +# anyway. Its payload is gated on `cfg(linux)` and its feature rather than on an +# accelerator -- provisioning runs before the build program, so `--format`, +# which only the build program learns, cannot be a layer key. On macOS and +# Windows that predicate is closed and this fixture still downloads nothing; on +# Linux it installs 15 MB, which the `appimage-consumer` step in the same job +# installs regardless. +# +# Omitting it was the alternative and is worse: this fixture exists so that +# every member's module is COMPILED on every host, and a member left out is a +# member whose macOS and Windows compile is never attempted -- which is exactly +# the failure the two platform-specific members are here to catch. [build] sources = ["src/*.cpp"] diff --git a/tests/app-consumer/build.mcpp b/tests/app-consumer/build.mcpp new file mode 100644 index 0000000..a3b6c2c --- /dev/null +++ b/tests/app-consumer/build.mcpp @@ -0,0 +1,13 @@ +import std; +import mcpp; +import mcpp.dist.apple; + +int main() { + mcpp::dist::apple::options opt; + opt.target = "app-consumer"; + opt.app_name = "AppConsumer"; + // No `identity`: signing is off by default, because a member that signed + // unconditionally would fail every build on a machine with no identity in + // its keychain. The signing path is exercised separately in CI. + return mcpp::dist::apple::generate(opt) ? 0 : 1; +} diff --git a/tests/app-consumer/mcpp.toml b/tests/app-consumer/mcpp.toml new file mode 100644 index 0000000..86c0167 --- /dev/null +++ b/tests/app-consumer/mcpp.toml @@ -0,0 +1,25 @@ +# Fixture: a program that ships as a app on macOS. +# +# It exists because a plan-level assertion is not a measurement. `plan_for()` +# returning `applies == false` on Linux says the gate works; it says nothing +# about whether the tool accepts what this member renders. That question is +# only answerable on the platform the member serves, which is why this fixture +# is built by the macOS runner and by no other. +[package] +name = "app-consumer" +version = "0.3.0" +description = "Fixture: packages a program as a app" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = true + +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["dist-apple"], host-module = true } + +[targets.app-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/app-consumer/src/main.cpp b/tests/app-consumer/src/main.cpp new file mode 100644 index 0000000..ff5199b --- /dev/null +++ b/tests/app-consumer/src/main.cpp @@ -0,0 +1,2 @@ +#include +int main() { std::puts("app-consumer ok"); return 0; } diff --git a/tests/appimage-consumer/build.mcpp b/tests/appimage-consumer/build.mcpp new file mode 100644 index 0000000..1ac2023 --- /dev/null +++ b/tests/appimage-consumer/build.mcpp @@ -0,0 +1,17 @@ +import std; +import mcpp; +import mcpp.dist.appimage; + +int main() { + // ONE CALL DOES BOTH HALVES, and that is why a member exists rather than + // each project writing this. `generate()` declares the format + // unconditionally -- so `mcpp pack --format bogus` can name what is + // available -- and submits only when this pass asked for `appimage`. + mcpp::dist::appimage::options opt; + opt.target = "appimage-consumer"; + opt.app_name = "AppimageConsumer"; + // Everything else comes from `[package]`: the version, the description and + // the licence are values mcpp has already parsed, and a copy here would + // drift from the manifest with nothing able to detect it. + return mcpp::dist::appimage::generate(opt) ? 0 : 1; +} diff --git a/tests/appimage-consumer/mcpp.toml b/tests/appimage-consumer/mcpp.toml new file mode 100644 index 0000000..8da3301 --- /dev/null +++ b/tests/appimage-consumer/mcpp.toml @@ -0,0 +1,29 @@ +# Fixture: a program that ships as an AppImage. +# +# It proves the third family of the collection end to end. `rules-*` and +# `tools-*` fixtures assert what reaches a COMPILE; this one asserts what comes +# out of the LINK, which is a different pass of the engine: the build program +# runs a second time, after `mcpp pack` has staged the closure, and submits an +# artifact action whose input is a directory that did not exist during the +# build. +[package] +name = "appimage-consumer" +version = "0.3.0" +description = "Fixture: packages a program as an AppImage" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = true + +# `[build-dependencies]`, for the reason every other fixture here gives: a +# member's library must never reach the target while its member is wanted at +# build time. +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["dist-appimage"], host-module = true } + +[targets.appimage-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/appimage-consumer/src/main.cpp b/tests/appimage-consumer/src/main.cpp new file mode 100644 index 0000000..61ef6ee --- /dev/null +++ b/tests/appimage-consumer/src/main.cpp @@ -0,0 +1,7 @@ +#include + +// Printed by the AppImage when it runs, which is what the end-to-end check +// greps for. An AppImage that starts and produces nothing is the failure this +// whole category is most exposed to, so the assertion is on OUTPUT rather than +// on the file existing. +int main() { std::puts("appimage-consumer ok"); return 0; } diff --git a/tests/embed-table-consumer/build.mcpp b/tests/embed-table-consumer/build.mcpp new file mode 100644 index 0000000..bcf1ce4 --- /dev/null +++ b/tests/embed-table-consumer/build.mcpp @@ -0,0 +1,69 @@ +import std; +import mcpp; +import mcpp.tools.embed; + +// `table()`: N inputs, ONE header, ONE array of rows, each carrying its +// input's key beside its bytes. See `tools/embed.cppm` for the shape and why +// a raw string literal is not how a row's bytes are written. +int main() { + mcpp::rerun_if_changed_glob("data/*.vert"); + mcpp::rerun_if_changed_glob("data/*.frag"); + + mcpp::tools::embed::table_options opt; + opt.name_space = "fixture"; + opt.identifier = "shader_table"; + opt.row_type = "shader_entry"; + + const std::vector inputs{ "data/Standard.vert", "data/Standard.frag" }; + if (!mcpp::tools::embed::table(inputs, opt)) return 1; + + // THE DUPLICATE-KEY REFUSAL, EXERCISED HERE SO A REGRESSION IS A BUILD + // FAILURE RATHER THAN A SILENT LAST-ROW-WINS. + // + // Two inputs share a file name in two directories -- the ordinary shape of + // a nested asset tree -- and `key_kind::file_name` is the default, so this + // is the collision `table()` exists to refuse: the failure this shape is + // most exposed to, because a table that silently kept the second row and + // dropped the first still links and runs with nothing to show for it. + // `identifier` and `out_dir` differ from the real table's above so a + // refused write is never confused with the one the program includes. + mcpp::tools::embed::table_options probeOpt; + probeOpt.identifier = "duplicate_probe"; + probeOpt.out_dir = std::string(mcpp::out_dir()) + "/embed-table-consumer-probe"; + const std::vector dupInputs{ "data/dup/a/clash.txt", "data/dup/b/clash.txt" }; + if (mcpp::tools::embed::table(dupInputs, probeOpt)) { + std::cerr << "FAIL: table() accepted two inputs that produce one key\n"; + return 1; + } + + // THE SAME TWO INPUTS, RESOLVED BY NAMING THE DIRECTORY -- the case + // `key_kind::relative_path` exists for: a nested input set where two + // directories hold a file of the same name. + probeOpt.identifier = "relative_probe"; + probeOpt.key = mcpp::tools::embed::key_kind::relative_path; + if (!mcpp::tools::embed::table(dupInputs, probeOpt)) { + std::cerr << "FAIL: key_kind::relative_path did not resolve the same-name collision\n"; + return 1; + } + + // element::word32, AND ITS ALIGNMENT REFUSAL, BOTH HAVE TO STILL WORK: + // `table()` shares the element choice with `file()` and must not regress + // either half of it. + mcpp::tools::embed::table_options wordOpt; + wordOpt.identifier = "word_table"; + wordOpt.out_dir = std::string(mcpp::out_dir()) + "/embed-table-consumer-probe"; + wordOpt.elem = mcpp::tools::embed::element::word32; + const std::vector alignedInputs{ "data/four.bin", "data/eight.bin" }; + if (!mcpp::tools::embed::table(alignedInputs, wordOpt)) { + std::cerr << "FAIL: table() refused two four-byte-aligned inputs under element::word32\n"; + return 1; + } + wordOpt.identifier = "misaligned_probe"; + const std::vector misalignedInputs{ "data/four.bin", "data/three.bin" }; + if (mcpp::tools::embed::table(misalignedInputs, wordOpt)) { + std::cerr << "FAIL: table() accepted a non-multiple-of-4 input under element::word32\n"; + return 1; + } + + return 0; +} diff --git a/tests/embed-table-consumer/data/Standard.frag b/tests/embed-table-consumer/data/Standard.frag new file mode 100644 index 0000000..e80f00b --- /dev/null +++ b/tests/embed-table-consumer/data/Standard.frag @@ -0,0 +1 @@ +fragment stage source diff --git a/tests/embed-table-consumer/data/Standard.vert b/tests/embed-table-consumer/data/Standard.vert new file mode 100644 index 0000000..8f891a6 --- /dev/null +++ b/tests/embed-table-consumer/data/Standard.vert @@ -0,0 +1 @@ +vertex stage source diff --git a/tests/embed-table-consumer/data/dup/a/clash.txt b/tests/embed-table-consumer/data/dup/a/clash.txt new file mode 100644 index 0000000..9c59e24 --- /dev/null +++ b/tests/embed-table-consumer/data/dup/a/clash.txt @@ -0,0 +1 @@ +first diff --git a/tests/embed-table-consumer/data/dup/b/clash.txt b/tests/embed-table-consumer/data/dup/b/clash.txt new file mode 100644 index 0000000..e019be0 --- /dev/null +++ b/tests/embed-table-consumer/data/dup/b/clash.txt @@ -0,0 +1 @@ +second diff --git a/tests/embed-table-consumer/data/eight.bin b/tests/embed-table-consumer/data/eight.bin new file mode 100644 index 0000000..bf5af90 --- /dev/null +++ b/tests/embed-table-consumer/data/eight.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/embed-table-consumer/data/four.bin b/tests/embed-table-consumer/data/four.bin new file mode 100644 index 0000000..82090ee --- /dev/null +++ b/tests/embed-table-consumer/data/four.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/embed-table-consumer/data/three.bin b/tests/embed-table-consumer/data/three.bin new file mode 100644 index 0000000..aed2973 --- /dev/null +++ b/tests/embed-table-consumer/data/three.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/embed-table-consumer/mcpp.toml b/tests/embed-table-consumer/mcpp.toml new file mode 100644 index 0000000..03f0bfe --- /dev/null +++ b/tests/embed-table-consumer/mcpp.toml @@ -0,0 +1,29 @@ +# The consumer that tests `mcpp.tools.embed::table()`. +# +# `embed-consumer` covers `file()` (one input, one header, one array) and +# `embed-module-consumer` covers `group()` (N inputs, one `import`, one +# accessor per input). This covers the third shape: N inputs, ONE header, ONE +# array of rows the consumer iterates or looks up by key. +# +# It exists as a separate fixture rather than as more assertions on either of +# those, for the reason `embed-module-consumer`'s own manifest gives: the +# three entry points have different contracts, and a fixture that exercised +# more than one would not say which one broke. +[package] +name = "embed-table-consumer" +version = "0.1.0" +description = "A shader-name table embedded through mcpp.tools.embed::table()" + +[language] +standard = "c++23" +modules = true +import_std = true + +# `[build-dependencies]`, not `[dependencies]`: see the other embed fixtures +# and docs/05 section 2.6.1. +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["tools-embed"], host-module = true } + +[targets.embed-table-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/embed-table-consumer/src/main.cpp b/tests/embed-table-consumer/src/main.cpp new file mode 100644 index 0000000..aee70f5 --- /dev/null +++ b/tests/embed-table-consumer/src/main.cpp @@ -0,0 +1,45 @@ +// Nothing here is a module: `table()` writes a plain header, the same as +// `file()`, and this file includes it by name the same way embed-consumer +// includes `message_txt.h`. +#include +#include +#include "shader_table.h" + +namespace { + +bool check(const fixture::shader_entry& row, const char* want_key, const char* want_text) { + const std::size_t want_len = std::strlen(want_text); + const bool key_ok = std::strcmp(row.key, want_key) == 0; + const bool size_ok = row.size == want_len; + const bool text_ok = size_ok && std::memcmp(row.data, want_text, want_len) == 0; + std::printf("%s: key=%s size=%zu %s\n", want_key, row.key, row.size, + (key_ok && size_ok && text_ok) ? "ok" : "BAD"); + return key_ok && size_ok && text_ok; +} + +} // namespace + +int main() { + bool ok = fixture::shader_table_size == 2; + + // The consumer iterates: that is `table()`'s whole point next to + // `file()`'s one accessor per input. + for (std::size_t i = 0; i < fixture::shader_table_size; ++i) + std::printf("row %zu: key=%s size=%zu\n", i, fixture::shader_table[i].key, + fixture::shader_table[i].size); + + ok = check(fixture::shader_table[0], "Standard.vert", "vertex stage source\n") && ok; + ok = check(fixture::shader_table[1], "Standard.frag", "fragment stage source\n") && ok; + + // Cheap insurance that the two rows are two objects. This is not the + // `#pragma once` collapse `mcpp.rules.spirv` measured -- that needs two + // generated headers, and a table is one -- but a generator that pointed + // every row at the last row read would look exactly like this. + if (fixture::shader_table[0].data == fixture::shader_table[1].data) { + std::printf("BAD: both rows resolve to one array\n"); + ok = false; + } + + std::printf(ok ? "all ok\n" : "FAILED\n"); + return ok ? 0 : 1; +} diff --git a/tests/msi-consumer/build.mcpp b/tests/msi-consumer/build.mcpp new file mode 100644 index 0000000..9846859 --- /dev/null +++ b/tests/msi-consumer/build.mcpp @@ -0,0 +1,13 @@ +import std; +import mcpp; +import mcpp.dist.wix; + +int main() { + mcpp::dist::wix::options opt; + opt.target = "msi-consumer"; + opt.product_name = "MsiConsumer"; + // The version, the manufacturer and the upgrade code all come from + // `[package]`: an installer that restated them would carry a copy that + // drifts from the manifest with nothing able to detect it. + return mcpp::dist::wix::generate(opt) ? 0 : 1; +} diff --git a/tests/msi-consumer/mcpp.toml b/tests/msi-consumer/mcpp.toml new file mode 100644 index 0000000..1042b1c --- /dev/null +++ b/tests/msi-consumer/mcpp.toml @@ -0,0 +1,25 @@ +# Fixture: a program that ships as a msi on Windows. +# +# It exists because a plan-level assertion is not a measurement. `plan_for()` +# returning `applies == false` on Linux says the gate works; it says nothing +# about whether the tool accepts what this member renders. That question is +# only answerable on the platform the member serves, which is why this fixture +# is built by the Windows runner and by no other. +[package] +name = "msi-consumer" +version = "0.3.0" +description = "Fixture: packages a program as a msi" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = true + +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["dist-wix"], host-module = true } + +[targets.msi-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/msi-consumer/src/main.cpp b/tests/msi-consumer/src/main.cpp new file mode 100644 index 0000000..4bc0312 --- /dev/null +++ b/tests/msi-consumer/src/main.cpp @@ -0,0 +1,2 @@ +#include +int main() { std::puts("msi-consumer ok"); return 0; } diff --git a/tools/embed.cppm b/tools/embed.cppm index 92d1f9e..21863af 100644 --- a/tools/embed.cppm +++ b/tools/embed.cppm @@ -88,6 +88,58 @@ struct options { unsigned width = 16; }; +// How a `table()` row's key is derived from its input path. `table()` refuses +// two inputs that derive one key -- see `table()` below -- so this also +// decides which inputs may sit in one table together. +enum class key_kind { + // "Standard.vert". The default, because it is what tells `Standard.vert` + // from `Standard.frag` apart and the bare stem below cannot. + file_name, + // "Standard" -- the file name with its extension removed. Collides with + // `file_name` whenever two inputs differ only by extension, which is the + // ordinary shape of a shader set, so this is an opt-in for a caller who + // has made the stem unique some other way rather than a safer default. + stem, + // The input's path relative to `mcpp::manifest_dir()`, e.g. + // "shaders/ui/panel.vert". For a nested input set where two directories + // hold a file of the same name -- where `file_name` collides -- this is + // the way out. + relative_path, +}; + +// `table()`'s options. A separate type from `options` rather than a second +// meaning for its fields: `options::identifier` names one symbol, so +// `files()` refuses a caller who sets it for several inputs rather than +// silently applying it to the first -- see `files()`. A table writes exactly +// one array and one struct regardless of how many inputs feed it, so there is +// always exactly one name to give, and `table_options::identifier` defaults +// to one instead of being refused or left for the caller to discover is +// required. +struct table_options { + // Means what it means in `options`. + std::string out_dir; + // The one symbol this call writes: it names the array and the header's + // file name (`.h`), the same way `identifier_for` names + // `file()`'s. Defaulted rather than derived, because there is no single + // input this call can derive a name from the way `file()` derives one + // from each input's own file name. + std::string identifier = "embedded_table"; + // The generated row struct's name. Two `table()` calls sharing a + // namespace need this and `identifier` to differ, the same way two + // `file()` calls sharing a namespace need different `options::identifier`s: + // the type is as much a symbol as the array is, and this generator does + // not check that it is unique any more than `file()` checks `identifier`. + std::string row_type = "embedded_file"; + // Means what it means in `options`. + std::string name_space; + // Means what it means in `options`, including `word32`'s "not a multiple + // of 4" refusal -- checked here per input, since a table has several. + element elem = element::byte_; + // Means what it means in `options`. + unsigned width = 16; + key_kind key = key_kind::file_name; +}; + // ---- internals ------------------------------------------------------------- // The accessor's own name, so a file called `default.bin` must not produce @@ -115,6 +167,16 @@ inline std::string header_path(const std::filesystem::path& input, const options return (std::filesystem::path(dir) / (identifier_for(input, opt) + ".h")).string(); } +// The header `table()` will produce, without producing it. Mirrors +// `header_path()`: a consumer that wants to `#include` it by an explicit path +// rather than by name asks here. Takes no input path, unlike `header_path()`, +// because a table's file name comes from `table_options::identifier` rather +// than from any one of its inputs. +inline std::string table_header_path(const table_options& opt = {}) { + const auto dir = opt.out_dir.empty() ? default_dir() : opt.out_dir; + return (std::filesystem::path(dir) / (opt.identifier + ".h")).string(); +} + inline bool write_if_different(const std::filesystem::path& path, std::string_view text) { std::error_code ec; std::filesystem::create_directories(path.parent_path(), ec); @@ -128,6 +190,101 @@ inline bool write_if_different(const std::filesystem::path& path, std::string_vi return static_cast(out); } +// The key `table()` puts in a row, from an input already resolved to an +// absolute path. `root` is `mcpp::manifest_dir()`, threaded through rather +// than read here so this stays a function of its arguments alone -- the same +// reason `identifier_for` above takes `input` rather than resolving it itself. +inline std::string table_key_for(const std::filesystem::path& absolute, + const std::string& root, key_kind kind) { + switch (kind) { + case key_kind::stem: + return absolute.stem().string(); + case key_kind::relative_path: + // `generic_string()`, not `string()`: the key is compared and then + // written into a generated file, and `lexically_relative` on + // Windows appends the PREFERRED separator -- `\` -- which would + // make one input's key differ by host for no reason a caller + // wrote. `mcpp::plugins::names::namespace_of` documents the same + // fix for the same reason: it was measured on windows-2022, where + // the equivalent path arithmetic produced a namespace segment no + // Linux or macOS run of the same fixture ever saw. + return absolute.lexically_relative(root).generic_string(); + case key_kind::file_name: + default: + return absolute.filename().string(); + } +} + +// A row's key becomes a C string literal in the generated header, and two +// characters cannot appear in one unescaped: an unescaped quote would close +// the literal early, and an unescaped backslash would fold the character +// after it into an escape sequence instead of leaving it as itself. Both are +// ordinary characters in a POSIX file name, so this is not a hypothetical the +// way it would be for an identifier. +inline std::string quote_for_literal(std::string_view s) { + std::string out = "\""; + for (std::size_t i = 0; i < s.size(); ++i) { + const char c = s[i]; + if (c == '"' || c == '\\') out += '\\'; + out += c; + } + out += '"'; + return out; +} + +// The numeric array `file()` writes, factored out because `table()` writes +// one such array per row instead of one per call. Takes no `null_terminate`: +// `table_options` has no such field -- a row's `size` is exact by +// construction, one member on one row, rather than a shared option applied +// across a whole file -- and `file()` keeps its own copy of this loop rather +// than being rewritten to call through a helper it has no use for, so its +// already-measured output does not change. +inline std::string element_array(std::string_view name, std::string_view bytes, + element elem, unsigned width) { + const bool word = elem == element::word32; + const auto count = word ? bytes.size() / 4 : bytes.size(); + std::string text = word ? "inline constexpr std::uint32_t " : "inline constexpr unsigned char "; + text += name; + text += "[] = {"; + const unsigned per_line = width == 0 ? 16 : width; + for (std::size_t i = 0; i < count; ++i) { + if (i % per_line == 0) text += "\n "; + if (word) { + const auto b = reinterpret_cast(bytes.data()) + i * 4; + text += std::format("0x{:08x}u,", static_cast(b[0]) + | (static_cast(b[1]) << 8) + | (static_cast(b[2]) << 16) + | (static_cast(b[3]) << 24)); + } else { + text += std::format("0x{:02x},", static_cast( + static_cast(bytes[i]))); + } + if (i + 1 < count) text += ' '; + } + text += "\n};\n"; + return text; +} + +// What `table()`'s duplicate-key refusal tells a caller to do about it, which +// depends on which derivation produced the collision: the fix for one is +// switching away from it, and the fix for another is that switching to it is +// what was already tried. +inline std::string_view key_collision_hint(key_kind kind) { + switch (kind) { + case key_kind::file_name: + return "Two inputs with the same name in different directories collide " + "under the default; table_options::key = key_kind::relative_path " + "tells them apart by directory."; + case key_kind::stem: + return "key_kind::stem drops the extension, so two inputs differing only " + "by it collide; key_kind::file_name, the default, keeps it."; + case key_kind::relative_path: + default: + return "key_kind::relative_path is already the input's path below the " + "manifest directory, so this is the same input named twice."; + } +} + // ---- the tool --------------------------------------------------------------- // One file. Returns false and explains on stderr when the input cannot be read @@ -298,4 +455,160 @@ inline bool group(std::span inputs, return out.ok; } +// N inputs, ONE header, ONE table: every row carries its input's key beside +// its bytes, and the consumer iterates the array or looks a row up by key. +// `files()` writes N headers for N inputs and leaves each included by name; +// this is the shape for a set the consumer wants to walk rather than name +// member by member -- a shader set is the motivating case, where a renderer +// wants "every shader the project has" rather than one accessor per shader. +// +// THE ROW STRUCT IS GENERATED HERE, BESIDE THE ARRAY, FOR THE REASON 0.2.6 +// FIXED FOR `mcpp.rules.spirv`'s HEADER: a generated header has to be +// includable on its own. Leaving the row type for the consumer to declare by +// hand would make it a second copy of a decision -- the field order and the +// element type both have to match this generator exactly -- and the failure +// mode of a mismatch is a device API reading a struct through the wrong +// layout, not a compile error. +// +// EACH ROW'S BYTES ARE A NUMERIC ARRAY, NEVER A RAW STRING LITERAL. A raw +// string literal delimits on a fixed marker -- `)"` closes `R"(...)"` -- and no +// byte sequence in an arbitrary payload is excluded strongly enough to promise +// it never contains that marker: shader source can carry it by accident, and a +// binary payload can carry it by construction. The motivating case for this +// entry point built its shader table by concatenating each file's text into +// one string in the build script, which is this exact bug -- any shader +// containing that four-character sequence truncates the string at that point, +// and every shader concatenated after it goes missing, with nothing but an +// unrelated compiler error to show for it. A numeric array has no delimiter +// for a payload to contain. +inline bool table(std::span inputs, table_options opt = {}) { + if (inputs.empty()) return true; + if (opt.identifier.empty()) { + std::cerr << "mcpp.tools.embed: table() needs `table_options::identifier`; " + "it names the one array and header this call writes\n"; + return false; + } + if (opt.row_type.empty()) { + std::cerr << "mcpp.tools.embed: table() needs `table_options::row_type`; " + "it names the struct each row is an instance of\n"; + return false; + } + + const std::string root = mcpp::manifest_dir(); + + struct resolved { + std::filesystem::path absolute; + std::string original; // as the caller wrote it, for diagnostics + std::string key; + }; + std::vector rows; + rows.reserve(inputs.size()); + for (auto const& one : inputs) { + const std::filesystem::path p(one); + const auto absolute = p.is_absolute() ? p : std::filesystem::path(root) / p; + rows.push_back({ absolute, one, table_key_for(absolute, root, opt.key) }); + } + + // TWO INPUTS PRODUCING ONE KEY ARE REFUSED HERE, BEFORE EITHER IS READ. + // + // A row is looked up by key, so letting this through would mean whichever + // row is kept depends on iteration order and the other is lost with no + // message at all -- the failure this shape is most exposed to, because a + // build that silently dropped a row still links and runs. `mcpp.rules.spirv` + // refuses the equivalent collision (two shaders producing one output) the + // same way: naming both inputs rather than only the one seen second. + { + std::map seen; // key -> first input + for (auto const& r : rows) { + auto [it, fresh] = seen.try_emplace(r.key, r.original); + if (!fresh) { + std::cerr << std::format( + "mcpp.tools.embed: table() found two inputs that produce one key.\n" + " {}\n" + " {}\n" + " both produce the key `{}`. A row is looked up by key, so keeping\n" + " both would mean the second silently replaces the first rather\n" + " than joining it. {}", + it->second, r.original, r.key, key_collision_hint(opt.key)) << '\n'; + return false; + } + } + } + + const bool word = opt.elem == element::word32; + std::vector bytes(rows.size()); + for (std::size_t i = 0; i < rows.size(); ++i) { + std::ifstream in(rows[i].absolute, std::ios::binary); + if (!in) { + std::cerr << std::format("mcpp.tools.embed: cannot read {}", + rows[i].absolute.string()) << '\n'; + return false; + } + bytes[i].assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); + if (word && bytes[i].size() % 4 != 0) { + std::cerr << std::format("mcpp.tools.embed: {} is {} bytes, which is not a " + "multiple of 4, and element::word32 was asked for", + rows[i].absolute.string(), bytes[i].size()) << '\n'; + return false; + } + } + + const auto id = opt.identifier; + const auto dir = opt.out_dir.empty() ? default_dir() : opt.out_dir; + const auto out = std::filesystem::path(dir) / (id + ".h"); + + std::string text; + text += std::format("// Generated by mcpp.tools.embed::table() from {} inputs. Do " + "not edit.\n", rows.size()); + text += "#pragma once\n\n#include \n#include \n\n"; + + if (!opt.name_space.empty()) text += "namespace " + opt.name_space + " {\n\n"; + + text += std::format( + "// One row per input: its key, a pointer to its bytes, and how many\n" + "// elements `data` points to -- words under element::word32, bytes\n" + "// otherwise, so a byte count is size * sizeof(*data). Declared here,\n" + "// beside the array below, so this header is includable on its own\n" + "// with nothing else.\n" + "struct {} {{\n" + " const char* key;\n" + " const {}* data;\n" + " std::size_t size;\n" + "}};\n\n", opt.row_type, word ? "std::uint32_t" : "unsigned char"); + + // EACH ROW'S BYTES ARE THEIR OWN ARRAY, NAMED BY INDEX RATHER THAN BY A + // SANITISED KEY. The key itself is checked for collisions above; a + // sanitised form of it is not, and two keys that sanitise to one + // identifier -- "a.b" and "a_b" both become "a_b" -- would collide here if + // this used it. That would be a second collision check guarding a name + // nothing outside this function ever reads. The index cannot collide. + for (std::size_t i = 0; i < rows.size(); ++i) { + const auto rowName = std::format("{}_{}", id, i); + text += element_array(rowName, bytes[i], opt.elem, opt.width); + } + + text += std::format("\ninline constexpr {} {}[] = {{\n", opt.row_type, id); + for (std::size_t i = 0; i < rows.size(); ++i) { + const auto count = word ? bytes[i].size() / 4 : bytes[i].size(); + text += std::format(" {{ {}, {}_{}, {} }},\n", + quote_for_literal(rows[i].key), id, i, count); + } + text += "};\n\n"; + text += std::format("inline constexpr std::size_t {}_size = {};\n", id, rows.size()); + + if (!opt.name_space.empty()) text += "\n} // namespace " + opt.name_space + "\n"; + + if (!write_if_different(out, text)) { + std::cerr << std::format("mcpp.tools.embed: cannot write {}", out.string()) << '\n'; + return false; + } + + // Same reason `file()` states it: the build program is cached on its + // inputs, so a file it reads has to be declared, and this holds for every + // one of the N inputs here rather than the one input `file()` has. + for (auto const& r : rows) mcpp::rerun_if_changed(r.absolute.string().c_str()); + if (opt.out_dir.empty()) mcpp::include_dir(dir.c_str()); + return true; +} + } // namespace mcpp::tools::embed