From 9f53a8269f163b4ac56e36dc0669c52fd197f1dc Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 11 Sep 2026 04:16:29 +0800 Subject: [PATCH 1/5] 0.6.0: a third family for what comes out of the link, and a table shape for tools-embed THREE FAMILIES, AND THE PREFIX SAYS WHICH OF THREE QUESTIONS 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 compiles no translation unit and does no 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 engine holds the DISPATCH and no 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. It is the argument 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. DECLARE UNCONDITIONALLY, SUBMIT CONDITIONALLY, and `generate()` does both so a consumer cannot do one without the other. The declaration is what lets the engine answer "which formats does this graph provide" on a build that asked for nothing; the submission must be gated or a plain `mcpp build` grows an edge it must not have. 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. `dist-appimage`, Linux. The staged tree is ALREADY an AppDir bar three files, which is why this member is small: `mcpp pack --mode vendored` stages `bin/`, `lib/` and a `$ORIGIN`-relocatable launcher, and AppImage additionally wants an `AppRun`, a top-level `.desktop` entry and an icon. So it writes three small files into the tree and invokes one tool, and never copies or re-lays-out a directory that can be hundreds of megabytes. The three are declared as action inputs, which is what makes a change to `[package] description` reach the graph. Its payload is `xim:appimagetool`, declared on the `cfg(linux)` axis, which also carries the type-2 runtime stub carved out of the tool itself -- appimagetool otherwise downloads that stub on every invocation, and a build must not reach the network. A SIZE FLOOR WAS THE FIRST VERSION OF ITS SUCCESS CHECK AND 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; the question is answerable directly, so it now counts files the member did not itself write. `dist-wix`, Windows. Renders a `.wxs` and passes the program in as a preprocessor variable rather than binding a directory and harvesting it: a bind path that resolved to nothing produced a valid, empty, 52 KB installer with no diagnostic, and that measurement is the reason for the shape. The `UpgradeCode` is derived deterministically from the package identity, because WiX requires one, a random one breaks upgrades and a hardcoded one makes every mcpp project the same product. MSI's `ProductVersion` compares only three fields, so the four-segment date version is repacked and what is lost is stated. `$(Executable)`, NOT `$(var.Executable)`, AND THE DIFFERENCE IS A MEASUREMENT RATHER THAN A READING. WiX's documentation spells a `-d` define the second way. A working implementation that is green on Windows CI uses the bare form; since neither could be run here, the one with a measurement behind it is the one shipped, and both are recorded. `dist-apple`, macOS. A `.app`, its `Info.plist` written directly rather than through `plutil`, and `codesign` only when an identity is given -- a member that signed by default would fail every build on a machine with no identity in its keychain. Four chained actions rather than one, because an icon change must not force a re-copy of the whole staged tree. iOS is the same shape plus a target row, which is a payload rather than a redesign. `tools-embed` GAINS `table()`: N inputs, ONE header, ONE table, where each row carries the input's key beside its contents and the consumer iterates. `file()` writes one header per input and `files()` writes several; neither can express the shape a shader set wants. A duplicate key is refused naming BOTH inputs, and the bytes are a numeric array rather than a raw string literal -- a raw literal cannot carry arbitrary binary and its delimiter is terminable by the input, which is the defect in the CMake code this shape replaces. THE FIXTURE'S DENOMINATOR IS THE MANIFEST, so `tests/all-rules-compile` names every member a consumer can activate, `dist-appimage` included. Leaving it out was the alternative and is worse: that fixture exists so 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 what the two platform-specific members are there to catch. Its payload is gated on `cfg(linux)`, so the other two runners download nothing. CI gains three steps, and two of them exist because a plan-level assertion is not a measurement. `plan_for()` returning `applies == false` on the wrong OS says the gate works and says nothing about whether the tool accepts what the member renders. `dist-wix` installs the WiX CLI on windows-2022 and builds a real MSI, asserting its size against the linked program's rather than that a file exists -- the measured failure is a valid EMPTY installer, and an assertion that stops at existence passes on exactly that artifact. `dist-apple` builds a real bundle on macos-15, lints the plist with Apple's own parser, and runs the program THROUGH the bundle, because a bundle whose `Info.plist` names the wrong executable still contains a working program. A FALSE CLAIM IS CORRECTED IN `dist/wix.cppm` RATHER THAN LEFT TO BE DISCOVERED. It said WiX is "not a redistributable archive this ecosystem can vendor". Its own licence file says otherwise: the software is under the Microsoft Reciprocal License, the fee is a maintenance fee on revenue-generating use, and sections 2 and 3 state that binaries may be redistributed and that the source remains freely distributable. The package is anonymously fetchable and immutable from NuGet's flat container. So `xim:wix` is legitimate and this member should declare it with no PATH fallback, the way `dist-appimage` declares its own tool; the comment now records that as a gap with the measurement behind it. Verified locally against mcpp 2026.9.11.1: every fixture builds and runs, all three dist modules compile as host modules, and `mcpp pack --format appimage` produces an AppImage that executes and prints its program's output. --- .github/workflows/ci.yml | 189 ++++- README.md | 96 ++- dist/appimage.cppm | 516 +++++++++++++ dist/apple.cppm | 585 ++++++++++++++ dist/wix.cppm | 714 ++++++++++++++++++ mcpp.toml | 78 +- src/plugins.cppm | 2 +- tests/all-rules-compile/build.mcpp | 26 +- tests/all-rules-compile/mcpp.toml | 42 +- tests/app-consumer/build.mcpp | 13 + tests/app-consumer/mcpp.toml | 25 + tests/app-consumer/src/main.cpp | 2 + tests/appimage-consumer/build.mcpp | 17 + tests/appimage-consumer/mcpp.toml | 29 + tests/appimage-consumer/src/main.cpp | 7 + tests/embed-table-consumer/build.mcpp | 69 ++ tests/embed-table-consumer/data/Standard.frag | 1 + tests/embed-table-consumer/data/Standard.vert | 1 + .../embed-table-consumer/data/dup/a/clash.txt | 1 + .../embed-table-consumer/data/dup/b/clash.txt | 1 + tests/embed-table-consumer/data/eight.bin | 1 + tests/embed-table-consumer/data/four.bin | 1 + tests/embed-table-consumer/data/three.bin | 1 + tests/embed-table-consumer/mcpp.toml | 29 + tests/embed-table-consumer/src/main.cpp | 45 ++ tests/msi-consumer/build.mcpp | 13 + tests/msi-consumer/mcpp.toml | 25 + tests/msi-consumer/src/main.cpp | 2 + tools/embed.cppm | 313 ++++++++ 29 files changed, 2825 insertions(+), 19 deletions(-) create mode 100644 dist/appimage.cppm create mode 100644 dist/apple.cppm create mode 100644 dist/wix.cppm create mode 100644 tests/app-consumer/build.mcpp create mode 100644 tests/app-consumer/mcpp.toml create mode 100644 tests/app-consumer/src/main.cpp create mode 100644 tests/appimage-consumer/build.mcpp create mode 100644 tests/appimage-consumer/mcpp.toml create mode 100644 tests/appimage-consumer/src/main.cpp create mode 100644 tests/embed-table-consumer/build.mcpp create mode 100644 tests/embed-table-consumer/data/Standard.frag create mode 100644 tests/embed-table-consumer/data/Standard.vert create mode 100644 tests/embed-table-consumer/data/dup/a/clash.txt create mode 100644 tests/embed-table-consumer/data/dup/b/clash.txt create mode 100644 tests/embed-table-consumer/data/eight.bin create mode 100644 tests/embed-table-consumer/data/four.bin create mode 100644 tests/embed-table-consumer/data/three.bin create mode 100644 tests/embed-table-consumer/mcpp.toml create mode 100644 tests/embed-table-consumer/src/main.cpp create mode 100644 tests/msi-consumer/build.mcpp create mode 100644 tests/msi-consumer/mcpp.toml create mode 100644 tests/msi-consumer/src/main.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3ebee8..36725d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,13 @@ 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: `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. + MCPP_VERSION: 2026.9.11.1 # PINNED, AND WITHOUT IT THE CACHE BELOW CACHED NOTHING. # # A released mcpp is self-contained: with no `MCPP_HOME`, `mcpp self env` @@ -925,6 +931,96 @@ 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 + # 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. + h=$(find target/.build-mcpp/out -name '*.h' | head -1) + test -n "$h" || { echo "FAIL: no generated header"; exit 1; } + if grep -q 'R"' "$h"; then + echo "FAIL: the table emitted a raw string literal"; exit 1 + fi + # Self-contained: the row type is declared beside the array, so the + # header compiles on its own with nothing else included. + grep -q 'struct embedded_file' "$h" \ + || { echo "FAIL: the row type is not in the header"; head -30 "$h"; exit 1; } + 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 +1476,97 @@ 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. + dotnet tool install --global wix >/dev/null 2>&1 || dotnet tool update --global wix >/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") + # An installer carrying the program is at least as large as the + # program. A cabinet compresses, so this is a floor and not an + # equality -- but an empty installer is far below it. + [ "$size" -gt $((exesize / 2)) ] \ + || { echo "FAIL: the MSI ($size) is implausibly small for a $exesize-byte program"; exit 1; } + echo "ok: one MSI, and it is not the empty one" + + - 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/README.md b/README.md index 7d99042..7c029c0 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.1 | 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 | ### 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..d66d9c0 --- /dev/null +++ b/dist/apple.cppm @@ -0,0 +1,585 @@ +// 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; + } + + const std::string stage = mcpp::pack_stage_dir(); + if (stage.empty()) { + std::cerr << "mcpp.dist.apple: 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.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 = 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; + } + const std::string executableName = 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"). + layout.argv = { "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 From 873c5dc91b746557752911b2dce55ccd2e8b50d6 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 11 Sep 2026 06:10:49 +0800 Subject: [PATCH 2/5] ci: the table assertion was the defect, and WiX 7 will not run at all TWO CI FAILURES, TWO DIFFERENT KINDS. THE LINUX ONE WAS THE CHECK, NOT THE CODE. It grepped for `struct embedded_file`, which is the DEFAULT `row_type` -- while the 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. It now reads the struct's name out of the file and asserts that the table's element type IS that struct, for every generated header rather than whichever `find` listed first -- this fixture writes three, and `head -1` was asserting about a file nothing chose. THE WINDOWS ONE WAS A TOOL THAT REFUSES TO RUN. An unpinned `dotnet tool install wix` gets v7: wix.exe : error WIX7015: You must accept the Open Source Maintenance Fee (OSMF) EULA to use WiX Toolset v7. A runtime gate, not a redistribution term -- 6's own EULA permits redistributing binaries under the Microsoft Reciprocal License. So 6.0.2 is the version this ecosystem can drive, and the version `xim:wix` should carry when that package lands: one pinning 7 would install a tool that cannot run. The dispatch itself was already correct when this failed, which is why the fix is a pin. The same run reported `Distributing msi-consumer v0.3.0 (--format msi)` and the full `wix.exe build -arch x64 -d Executable=... MsiConsumer.wxs` argv, so the `.wxs` was rendered and the request reached the member. Only the tool declined. The macOS lane stays red for a third, unrelated reason -- `error: cannot package the Mach-O program 'app-consumer' yet`, which mcpp 2026.9.11.2 fixes (staging is a service to the provider, not a precondition for dispatch). That one needs the engine release and a `MCPP_VERSION` bump, not a change here. --- .github/workflows/ci.yml | 76 ++++++++++++++++++++++++++++++++-------- .gitignore | 1 + 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36725d1..afb1d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -951,19 +951,41 @@ jobs: 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 - # 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. - h=$(find target/.build-mcpp/out -name '*.h' | head -1) - test -n "$h" || { echo "FAIL: no generated header"; exit 1; } - if grep -q 'R"' "$h"; then - echo "FAIL: the table emitted a raw string literal"; exit 1 - fi - # Self-contained: the row type is declared beside the array, so the - # header compiles on its own with nothing else included. - grep -q 'struct embedded_file' "$h" \ - || { echo "FAIL: the row type is not in the header"; head -30 "$h"; exit 1; } + # 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 ──────────────────────────────── @@ -1499,7 +1521,33 @@ jobs: # 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. - dotnet tool install --global wix >/dev/null 2>&1 || dotnet tool update --global wix >/dev/null 2>&1 + # 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 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/ From ef861a3fb30432c38459fcae59ef3627de4a3af3 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 11 Sep 2026 06:19:41 +0800 Subject: [PATCH 3/5] ci: WiX 6 runs, and the MSI check was the thing that was wrong The pin worked: WIX7015 is gone, `wix build` completed with no warnings, and `Packed target/.build-mcpp/out/MsiConsumer-x64.msi` appeared. The check then refused it -- 32768 bytes against a 114688-byte program, under a `size > exesize / 2` floor whose comment admitted the ratio was invented ("a cabinet compresses, so this is a floor and not an equality"). 32768 is what a stripped hello-world looks like after a cabinet has had it. Mostly padding, highly compressible, plus the fixed overhead of an OLE compound document. The floor was refusing a correct artifact. THIS IS THE SECOND TIME IN THIS ECOSYSTEM. The AppImage floor was a 16 KB size bound and refused a correct 14999-byte bundle. Both times the check asked a proxy question. The AppImage one was replaced by running the AppImage and asserting the program's output; this one is now an administrative install (`msiexec /a`), which writes the payload out uncompressed, so the assertion is EQUALITY against the linked binary rather than a ratio -- and it also proves the package installs, which a size never did. The macOS lane stays red on `cannot package the Mach-O program`, which needs mcpp 2026.9.11.2. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afb1d70..857fbf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1570,12 +1570,35 @@ jobs: 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") - # An installer carrying the program is at least as large as the - # program. A cabinet compresses, so this is a floor and not an - # equality -- but an empty installer is far below it. - [ "$size" -gt $((exesize / 2)) ] \ - || { echo "FAIL: the MSI ($size) is implausibly small for a $exesize-byte program"; exit 1; } - echo "ok: one MSI, and it is not the empty one" + + # 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' From 006124b1f271c6427df7c93d1f2dfbc8d1edf314 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 11 Sep 2026 10:44:06 +0800 Subject: [PATCH 4/5] ci: the engine pin moves to 2026.9.11.2, which is what makes dist-apple reachable The red macOS lane was the dependency, not a convention. Under 2026.9.11.1: error: cannot package the Mach-O program 'app-consumer' yet. `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 reads no staged tree at all. 2026.9.11.2 makes staging a service to the provider, so `dist-apple` has been unreachable rather than broken since it was written. The README floor for `dist-apple` moves with it and says why it is one release higher than its siblings: the reason is not this member. --- .github/workflows/ci.yml | 28 +++++++++++++++++++++------- README.md | 2 +- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 857fbf2..fcf9f0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +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. - # 2026.9.11.1 is what `dist-*` needs: `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. - MCPP_VERSION: 2026.9.11.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` diff --git a/README.md b/README.md index 7c029c0..8e26cf7 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ engine's own module family and is not used here. | `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.1 | 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 | +| `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 From 083f23ce9e0a92591c870f5d9fa51000190acd60 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 11 Sep 2026 10:56:41 +0800 Subject: [PATCH 5/5] fix(dist-apple): the staged tree is optional, because on macOS it cannot exist 2026.9.11.2 made the dispatch reachable and the member then refused one layer up, for the same underlying reason: error: no action claimed --format 'app' That message is the ENGINE's. The member's own explanation -- "mcpp reported no staged tree" -- went to stderr, which is discarded on a successful build, so the refusal was unreadable. And the refusal was wrong. `mcpp pack`'s built-in closure walk refuses a Mach-O program because it uses `LD_TRACE_LOADED_OBJECTS`, which dyld answers by RUNNING the program -- so on macOS there is no staged tree to have, and a member that requires one can never run there. A `.app` needs ONE program, not a tree: `${mcpp.target_file:}` names it, which is the placeholder dist/wix.cppm uses for exactly this reason and what section 6 of the design record recommends for a member packaging a named target. The tree is still preferred when it exists -- 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. `${mcpp.stage_dir}` is now named only on the path that has a tree, because the engine REFUSES that placeholder when there is none -- that is its contract, and a member naming it unconditionally is a member that cannot run on a target whose built-in staging is refused. `CFBundleExecutable` takes the target's own name on that path, since a placeholder's basename is not knowable before the engine expands it. All eleven features still compile against released 2026.9.11.2. --- dist/apple.cppm | 54 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/dist/apple.cppm b/dist/apple.cppm index d66d9c0..43dce5d 100644 --- a/dist/apple.cppm +++ b/dist/apple.cppm @@ -374,13 +374,29 @@ inline plan plan_for(options opt = {}) { 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(); - if (stage.empty()) { - std::cerr << "mcpp.dist.apple: 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()) { @@ -390,7 +406,9 @@ inline plan plan_for(options opt = {}) { return p; } - const std::string launcher = launcher_in(stage, target); + 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 " @@ -400,7 +418,12 @@ inline plan plan_for(options opt = {}) { p.reason = "no launcher in the staged tree"; return p; } - const std::string executableName = bundle_executable_name(launcher); + // 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'; @@ -470,7 +493,20 @@ inline plan plan_for(options opt = {}) { // 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"). - layout.argv = { "ditto", "${mcpp.stage_dir}", contents + "/MacOS" }; + // + // 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);