From f4195847a992f6918790e78b6aaa9b1d7002f50f Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 13 Sep 2026 04:00:12 +0800 Subject: [PATCH 1/5] dist-apk: a project manifest template with six tokens, and java_sources as an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit options::manifest_template renders a project AndroidManifest.xml through six tokens substituted verbatim: {{application_id}}, {{label}}, {{activity}}, {{lib_name}}, {{min_sdk}}, {{target_sdk}}. Three are required, not merely substituted, because their value is also written to assets/mcpp-run.json, which adb-run reads to start the application: {{application_id}} and {{activity}} always, {{lib_name}} at level 0. A missing required token or an unknown {{...}} token is refused at plan time, naming it. The built-in default is 0.8.0's manifest_xml output expressed with these tokens, so level 0 with no template renders byte-identical to 0.8.0's. options::java_sources becomes std::vector: one javac over every root's .java files and one d8 over the result. rerun_if_changed_glob is declared only for a root under mcpp::manifest_dir(); a root outside it (a dependency's own Java tree) is not walked by the glob fingerprint regardless, and its files are already inputs of the javac action while its version is already in the build's fingerprint. Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md, §3. --- dist/apk.cppm | 285 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 244 insertions(+), 41 deletions(-) diff --git a/dist/apk.cppm b/dist/apk.cppm index 37d9fc3..174fdcf 100644 --- a/dist/apk.cppm +++ b/dist/apk.cppm @@ -109,16 +109,46 @@ struct options { // ``. Empty means the package name. std::string label; + // A project file, package-root-relative, that replaces the built-in + // manifest. Six tokens are substituted verbatim wherever they appear -- + // `{{application_id}}`, `{{label}}`, `{{activity}}`, `{{lib_name}}`, + // `{{min_sdk}}`, `{{target_sdk}}` -- and everything else in the file is + // the project's, verbatim: permissions, receivers, meta-data, an icon, + // an activity-alias, `configChanges`. This member adds nothing to it + // (design record `2026-09-13-four-upstream-asks-from-a-ui-framework.md`, + // §3.2). + // + // THREE TOKENS ARE REQUIRED, NOT MERELY SUBSTITUTED, because their value + // is also written to `assets/mcpp-run.json`, which `adb-run` reads to + // start the application without `aapt2` on the machine that runs it: a + // template missing `{{application_id}}` or `{{activity}}` is refused at + // plan time, naming the token and that reader; `{{lib_name}}` joins them + // at level 0 (`java_sources` empty), because the manifest's own + // `` element is the only place the loaded library's name is + // recorded. An unknown `{{...}}` token is refused too, naming it -- see + // the render function below for why that check belongs to this member + // and is not proposed for `dist-web`. + // + // Empty means the built-in default, which is `manifest_xml`'s own 0.8.0 + // output expressed with these tokens; level 0 with no template renders a + // manifest byte-identical to 0.8.0's (`tests/apk-consumer`). + std::string manifest_template; + // A `res/`-shaped directory `aapt2 compile --dir` compiles. Empty means // no resources at all -- a legal, common case for a NativeActivity // application that draws everything itself. std::string resources; - // LEVEL 1. A directory of `.java` sources this member compiles with - // `javac` and dexes with `d8`. Empty (the default) is level 0: no Java, - // `hasCode="false"`, `android.app.NativeActivity` as the manifest's - // activity. - std::string java_sources; + // LEVEL 1. One or more directories of `.java` sources; one `javac` over + // every root's files and one `d8` over the result (the member compiles + // what it is given, and a second root is more of the same input, not a + // second step). A project with a path-dependency framework that also + // hosts Java lists that dependency's own directory alongside its own + // rather than merging the two trees itself. Empty (the default) is + // level 0: no Java, `hasCode="false"`, `android.app.NativeActivity` as + // the manifest's activity. A single string is still accepted in a + // `build.mcpp`: a one-element initialiser list is the same spelling. + std::vector java_sources; // LEVEL 1, REQUIRED WHEN `java_sources` IS SET. The fully-qualified // activity class the manifest names as `` and the @@ -180,6 +210,26 @@ inline bool is_dir(const std::string& p) { return !p.empty() && fs::is_directory(p, ec); } +// Is `root` under the package root, `mcpp::manifest_dir()`? A project root is +// declared with `rerun_if_changed_glob` below (a file appearing there re-runs +// the build program); a dependency root is not -- its file set changes only +// with the dependency's version, already in the build's fingerprint, and the +// glob's own walk does not reach outside the package root regardless +// (design record §3.3). Same shape as `mcpp.tools.island`'s overlap check: +// `weakly_canonical` plus `lexically_relative`, never the iterator that +// poisons an importer under GCC 16 / MSVC (`mcpp::plugins::names:: +// relative_to`'s own header) -- safe here because this member is its own +// module and imports no sibling that would inherit the instantiation. +inline bool root_in_project(const std::string& root) { + std::error_code ec; + const auto a = fs::weakly_canonical(root, ec); + if (ec) return false; + const auto b = fs::weakly_canonical(mcpp::manifest_dir(), ec); + if (ec) return false; + const auto rel = a.lexically_relative(b).generic_string(); + return !rel.empty() && !rel.starts_with(".."); +} + inline bool write_if_different(const fs::path& path, std::string_view bytes) { std::error_code ec; fs::create_directories(path.parent_path(), ec); @@ -311,22 +361,81 @@ inline std::string api_level_from_platform_dir(const std::string& dir) { return digits; } -inline std::string manifest_xml(const std::string& app_id, const std::string& label, - const std::string& min_sdk, const std::string& target_sdk, - const std::string& target, bool has_code, - const std::string& activity_name) { - std::string a = "\n" +inline std::string replace_all_copy(std::string s, std::string_view from, std::string_view to) { + if (from.empty()) return s; + std::size_t pos = 0; + while ((pos = s.find(from, pos)) != std::string::npos) { + s.replace(pos, from.size(), to); + pos += to.size(); + } + return s; +} + +// The six tokens a manifest template may use. +inline const std::vector& manifest_tokens() { + static const std::vector v = { + "application_id", "label", "activity", "lib_name", "min_sdk", "target_sdk"}; + return v; +} + +// Every `{{...}}` a template names, in first-appearance order, duplicates +// dropped -- what both checks in `render_manifest` below read. +inline std::vector tokens_in(const std::string& text) { + std::vector out; + std::size_t i = 0; + while ((i = text.find("{{", i)) != std::string::npos) { + const auto close = text.find("}}", i + 2); + if (close == std::string::npos) break; + std::string name = text.substr(i + 2, close - (i + 2)); + if (std::ranges::find(out, name) == out.end()) out.push_back(std::move(name)); + i = close + 2; + } + return out; +} + +// The tokens `assets/mcpp-run.json` is ALSO written from -- a template that +// omits one silently ships a run sidecar the manifest disagrees with. +// `application_id` and `activity` are required unconditionally; `lib_name` +// joins them at level 0, where the manifest's own `` element is +// the only place the loaded library's name is recorded. +inline std::vector required_manifest_tokens(bool has_code) { + std::vector v = {"application_id", "activity"}; + if (!has_code) v.push_back("lib_name"); + return v; +} + +// THE BUILT-IN DEFAULT, EXPRESSED WITH THE TOKENS. This is `manifest_xml`'s +// 0.8.0 output verbatim, with every literal value it used to compute +// replaced by the token that value now comes through -- so level 0 with no +// project template renders byte-identical to what 0.8.0 wrote +// (`tests/apk-consumer`). `{{activity}}` carries the level-0 constant +// (`android.app.NativeActivity`) as well as a level-1 project's own class, +// because the run sidecar needs the activity name at both levels and the +// required-token check reads the TEMPLATE TEXT, not the level -- so the same +// token has to appear on both of this function's two branches. +// `{{lib_name}}`'s `` element exists only at level 0: it +// announces which shared object `NativeActivity` should load, and a +// Java-hosted activity finds its own native library another way. +// +// NO XML COMMENT MARKS THE THREE REQUIRED TOKENS IN THIS STRING, ON PURPOSE: +// this exact text is compared byte-for-byte against 0.8.0's output, which +// carried none, and a template with no author to read a comment gains +// nothing from one. The design record's "mark the required tokens" is done +// here instead, in the `REQUIRED` labels on the C++ lines that build them. +inline std::string default_manifest_template(bool has_code) { + std::string a = + "\n" "\n" - " \n" - " \n" - " \n"; + " package=\"{{application_id}}\">\n" // REQUIRED + " \n" + " \n" + " \n"; if (!has_code) { a += " \n"; + "android:value=\"{{lib_name}}\"/>\n"; // REQUIRED at level 0 } a += " \n" " \n" @@ -338,6 +447,54 @@ inline std::string manifest_xml(const std::string& app_id, const std::string& la return a; } +// Checks a manifest template and substitutes it, or refuses (returning +// `false` with `reason` set) naming exactly what is wrong. +// +// THIS CHECK IS `dist-apk`'S OWN, DELIBERATELY NOT `dist-web`'S. A manifest +// has a closed, six-token vocabulary this member itself defines; a web page +// template may legitimately carry `{{ }}` for a front-end framework (Vue, +// Mustache, ...) this member never reads, so `dist-web` leaves an unknown +// token literal for that project's own tooling to read. An unknown token +// here would otherwise reach `aapt2` unsubstituted and fail there with a +// worse message, and the refusal belongs where the name is known -- the same +// rule read against two different facts, not an inconsistency between the +// two members. +inline bool render_manifest(const std::string& templateText, bool has_code, + const std::string& appId, const std::string& label, + const std::string& activityName, const std::string& libName, + const std::string& minSdk, const std::string& targetSdk, + std::string& out, std::string& reason) { + for (auto const& tok : tokens_in(templateText)) { + if (std::ranges::find(manifest_tokens(), tok) == manifest_tokens().end()) { + std::cerr << "mcpp.dist.apk: the manifest template names an unknown " + "token '{{" << tok << "}}' -- expected one of " + "application_id, label, activity, lib_name, min_sdk, " + "target_sdk\n"; + reason = "unknown manifest template token '" + tok + "'"; + return false; + } + } + for (auto const& tok : required_manifest_tokens(has_code)) { + if (templateText.find("{{" + tok + "}}") == std::string::npos) { + std::cerr << "mcpp.dist.apk: the manifest template does not use " + "'{{" << tok << "}}', and assets/mcpp-run.json -- " + "which adb-run starts the application from -- is " + "written from the same value: add {{" << tok + << "}} to the template.\n"; + reason = "manifest template missing required token '" + tok + "'"; + return false; + } + } + out = templateText; + out = replace_all_copy(std::move(out), "{{application_id}}", appId); + out = replace_all_copy(std::move(out), "{{label}}", label); + out = replace_all_copy(std::move(out), "{{activity}}", activityName); + out = replace_all_copy(std::move(out), "{{lib_name}}", libName); + out = replace_all_copy(std::move(out), "{{min_sdk}}", minSdk); + out = replace_all_copy(std::move(out), "{{target_sdk}}", targetSdk); + return true; +} + inline std::string run_json(const std::string& app_id, const std::string& activity_name) { // Hand-built, not through a JSON library this collection does not // depend on: two string fields, neither of which this member lets @@ -624,9 +781,36 @@ inline plan plan_for(options opt = {}) { const std::string label = label_for(opt); const std::string activityName = hasCode ? opt.activity : std::string("android.app.NativeActivity"); + std::string manifestTemplateText; + if (!opt.manifest_template.empty()) { + const std::string tplPath = + (fs::path(mcpp::manifest_dir()) / opt.manifest_template).string(); + if (!is_file(tplPath)) { + std::cerr << std::format( + "mcpp.dist.apk: the manifest template {} was not found", tplPath) << '\n'; + p.reason = "manifest template not found"; + return p; + } + // The template is declared so an edit to it reaches the graph -- the + // one thing the ask's workaround (overwriting the manifest after + // `plan_for()` returns) cannot do, because it depends on this + // member's own internal path. + mcpp::rerun_if_changed(tplPath.c_str()); + std::ifstream in(tplPath, std::ios::binary); + manifestTemplateText.assign((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + } else { + manifestTemplateText = default_manifest_template(hasCode); + } + + std::string manifestBytes; + if (!render_manifest(manifestTemplateText, hasCode, appId, label, activityName, + target, minSdk, targetSdk, manifestBytes, p.reason)) { + return p; + } + const std::string manifestPath = (fs::path(opt.out_dir) / "dist-apk" / "AndroidManifest.xml").string(); - if (!write_if_different(manifestPath, - manifest_xml(appId, label, minSdk, targetSdk, target, hasCode, activityName))) { + if (!write_if_different(manifestPath, manifestBytes)) { std::cerr << std::format("mcpp.dist.apk: cannot write {}", manifestPath) << '\n'; p.reason = "cannot write AndroidManifest.xml"; return p; @@ -806,29 +990,48 @@ inline plan plan_for(options opt = {}) { std::vector javaOutputs; // classes.dex, when level 1 if (hasCode) { - if (!is_dir(opt.java_sources)) { - std::cerr << std::format( - "mcpp.dist.apk: options::java_sources '{}' is not a " - "directory", opt.java_sources) << '\n'; - p.reason = "java_sources directory not found"; - return p; - } + // ONE `javac` OVER EVERY ROOT'S `.java` FILES. The member compiles + // what it is given (design record §3.3) and a second root is more of + // the same input, not a second step -- `javaFiles` below is one flat + // list across every root, and one `javac` invocation compiles all of + // it into one `classesDir`, exactly as it did over one root before. std::vector javaFiles; - { std::error_code ec; - for (auto& e : fs::recursive_directory_iterator(opt.java_sources, ec)) { - if (ec) break; - if (e.is_regular_file(ec) && e.path().extension() == ".java") - javaFiles.push_back(e.path().string()); - } - } - if (javaFiles.empty()) { - std::cerr << std::format( - "mcpp.dist.apk: options::java_sources '{}' carries no .java " - "file", opt.java_sources) << '\n'; - p.reason = "no .java sources"; - return p; + for (auto const& root : opt.java_sources) { + if (!is_dir(root)) { + std::cerr << std::format( + "mcpp.dist.apk: options::java_sources root '{}' is not a " + "directory", root) << '\n'; + p.reason = "java_sources directory not found"; + return p; + } + const std::size_t before = javaFiles.size(); + { std::error_code ec; + for (auto& e : fs::recursive_directory_iterator(root, ec)) { + if (ec) break; + if (e.is_regular_file(ec) && e.path().extension() == ".java") + javaFiles.push_back(e.path().string()); + } + } + if (javaFiles.size() == before) { + std::cerr << std::format( + "mcpp.dist.apk: options::java_sources root '{}' carries " + "no .java file", root) << '\n'; + p.reason = "no .java sources"; + return p; + } + // THE RE-RUN QUESTION (design record §3.3). `glob_fingerprint` + // walks the PACKAGE ROOT and matches paths relative to it; a + // root outside that walk (a dependency's unpack directory) + // matches nothing, and the fingerprint would be the same as "no + // files" -- a criterion whose "no" reads as silence. So a + // project root (under `mcpp::manifest_dir()`) is declared with + // the glob, as today; a dependency root is not: its file set + // changes only with the dependency's version, already in the + // build's fingerprint, and each of its files is already an + // input of the `javac` action below. + if (root_in_project(root)) + mcpp::rerun_if_changed_glob((root + "/**/*.java").c_str()); } - mcpp::rerun_if_changed_glob((opt.java_sources + "/**/*.java").c_str()); const std::string classesDir = (outDir / "classes").string(); step javacStep; From 1922a467699b187206a9be6a7e56ed8956972e17 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 13 Sep 2026 04:00:17 +0800 Subject: [PATCH 2/5] dist-web: copy through ${mcpp.self} stage instead of cp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two copy steps become argv { "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src }, the same copier every stage_file edge in build.ninja already runs: it creates the destination's parent, compares content and writes only on a difference. Plan-time create_directories is removed with it. The POSIX-only note leaves the header; this member's floor rises to the mcpp release that carries ${mcpp.self} and mcpp stage's argument shape as an engine contract. Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md, §4. --- dist/web.cppm | 63 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/dist/web.cppm b/dist/web.cppm index c8b7a00..941f38a 100644 --- a/dist/web.cppm +++ b/dist/web.cppm @@ -45,19 +45,36 @@ // inside a directory named for a browser, which is a worse failure than a // refusal naming the one target this format serves. // -// `cp` PER FILE, ARGV ONLY, NO SHELL -- AND THAT IS WHY THIS MEMBER IS -// POSIX-HOST ONLY FOR NOW. `dist/appimage.cppm`'s own copies are single +// THE ENGINE COPIES, NOT `cp`. Each staged file was one `cp SRC DST` action, +// argv only, no shell -- exactly the shape `mcpp::action` is built for (a +// graph edge per file, skippable on a cache hit) -- and that made this +// member POSIX-host only, because neither precedent (`dist/appimage.cppm`'s // files handed straight to `appimagetool`'s own argument list; `dist/ -// apple.cppm` copies whole directories with `ditto`, which exists only on -// macOS. Neither precedent is a portable multi-file copier this member -// could reuse on Windows, so it declares one `cp SRC DST` action per file in -// the discovered set instead of shelling out to a recursive copy -- exactly -// the shape `mcpp::action` is built for (a graph edge per file, skippable on -// a cache hit), and the one the design record's implementation notes accept -// ("one cp/copy per file is acceptable"). The README states the POSIX-host -// limitation; lifting it needs either a `copy`-argv branch on the host OS or -// a small copier this member carries itself, and neither is written here -// because nothing in this collection has needed one yet. +// apple.cppm`'s directories copied with `ditto`, macOS only) is a portable +// multi-file copier. Lifting that needed either a `copy`-argv branch on the +// host OS or a small copier this member carried itself, and both are the +// wrong shape: `cmd /c copy` is a shell, is the 8191-character limit, and is +// the switch-quoting this repository has already been bitten by twice; a +// copier carried by the member is a host tool sub-build (#355) for one `cp`. +// +// The engine already has the copier. `mcpp stage --output ` is +// the subcommand every `stage_file` edge in `build.ninja` already runs: it +// creates the destination's parent, compares content and writes only on +// difference. `${mcpp.self}`, an action argv substitution for the engine's +// own absolute path, is what lets an action NAME it, so each copy step is +// `{ "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src }` +// -- `--verify content` spelled out rather than defaulted, because a +// contract must not depend on which of "the help text's default" (`size`) +// and "the code's default" (`content`) a reader believes. Plan-time +// `create_directories` is gone with it: `stage` creates the destination's +// parent itself. +// +// `${mcpp.self}` AND `mcpp stage`'S ARGUMENT SHAPE ARE THE ENGINE CONTRACT +// SINCE 2026.9.13.1, not a convenience this member happens to use -- see +// docs/30's substitution table in that release. This member's floor is that +// release for exactly this reason: an older engine leaves `${mcpp.self}` +// literal in the command, and the action fails at run time with a +// not-found for a path that reads as a token. // // WHY `index.html` IS WRITTEN AT PLAN TIME TO A SIDE FILE AND COPIED, RATHER // THAN WRITTEN DIRECTLY TO ITS FINAL PATH. `dist/apple.cppm`'s own header @@ -317,14 +334,9 @@ inline plan plan_for(options opt = {}) { for (auto const& rel : relFiles) { const std::string dst = webDir + "/" + rel; - // Directories are created here, at plan time -- cheap (a handful of - // path segments, never hundreds of megabytes) and the same trade - // `write_if_different` already makes for `index.html`'s own parent. - // The CONTENT copy is the action; an empty directory existing a - // build early is not a cache-visible effect. - std::error_code ec; - std::filesystem::create_directories( - std::filesystem::path(dst).parent_path(), ec); + // No `create_directories` here: `mcpp stage` creates the + // destination's parent itself, which is the part of this step the + // engine now does that the member used to. step s; s.id = "mcpp.dist.web.file"; @@ -334,9 +346,13 @@ inline plan plan_for(options opt = {}) { // program just read `stageBin` from -- the same reasoning every // other member gives: the path in the graph and the path here // cannot disagree, and naming it earns this action the engine's - // automatic dependency on the staged tree's manifest. + // automatic dependency on the staged tree's manifest. `${mcpp.self}` + // is the same substitution family, naming the engine's own + // executable so this action's command is an argv the engine + // interprets on every host, with no shell and no host-specific copy + // tool. const std::string src = "${mcpp.stage_dir}/bin/" + rel; - s.argv = { "cp", src, dst }; + s.argv = { "${mcpp.self}", "stage", "--verify", "content", "--output", dst, src }; s.inputs = { src }; s.outputs = { dst }; p.steps.push_back(std::move(s)); @@ -346,7 +362,8 @@ inline plan plan_for(options opt = {}) { page.id = "mcpp.dist.web.index"; page.role = "artifact"; page.description = "INDEX.HTML"; - page.argv = { "cp", indexSrc, webDir + "/index.html" }; + page.argv = { "${mcpp.self}", "stage", "--verify", "content", "--output", + webDir + "/index.html", indexSrc }; page.inputs = { indexSrc }; page.outputs = { webDir + "/index.html" }; p.steps.push_back(std::move(page)); From d09e247e090c37dc9968b08718df771479bec3e8 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 13 Sep 2026 04:00:29 +0800 Subject: [PATCH 3/5] tests: dist-apk's manifest-template and Java-root criteria, dist-web's idempotent pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/apk-consumer: build.mcpp reads APK_CONSUMER_TEMPLATE and APK_CONSUMER_LEVEL1 to reach five configurations without a second fixture. check-apk-features.sh runs all five: (a) level 0 with no template, byte-identical to the 0.8.0 manifest fixture; (b) a template naming a uses-permission and a receiver, both present in aapt2 dump xmltree on the linked base.apk; (c) a template missing {{application_id}}, refused naming the token and assets/mcpp-run.json; (d) a template naming {{bogus}}, refused naming it; (e) two Java roots, one under the project (tests/apk-consumer/java) and one a sibling directory reached by an absolute path (tests/apk-consumer-external-java), producing one classes.dex with classes from both. (c) and (d) read build.mcpp's own stdout/stderr by re-invoking the compiled binary directly with the documented MCPP_* contract, because mcpp discards a build program's captured output on a plan-time refusal's exit code of 0. tests/web-consumer/check-web-idempotent.sh: a second, no-op mcpp pack --format web changes no staged file's mtime, the criterion ${mcpp.self} stage gives for free and cp never could. A sibling of check-web-plan.sh, which keeps passing unchanged. .github/workflows/ci.yml: both wired in beside the existing "dist-apk produces a signed APK, level 0 and level 1" and "dist-web produces a static directory, and node runs it" steps; MCPP_VERSION raised to 2026.9.13.1, the release both P1-P3 need. Design record: .agents/docs/2026-09-13-four-upstream-asks-from-a-ui-framework.md, §3.5, §4.3, §9.2. --- .github/workflows/ci.yml | 30 +++- .../org/mcpp/apkconsumer/ExternalHelper.java | 13 ++ tests/apk-consumer/build.mcpp | 29 ++++ tests/apk-consumer/check-apk-features.sh | 129 ++++++++++++++++++ .../expected-manifest-level0-0.8.0.xml | 14 ++ .../org/mcpp/apkconsumer/MainActivity.java | 10 ++ .../manifest-template-bogus-token.xml | 17 +++ tests/apk-consumer/manifest-template-good.xml | 21 +++ .../manifest-template-missing-appid.xml | 17 +++ tests/web-consumer/check-web-idempotent.sh | 53 +++++++ 10 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 tests/apk-consumer-external-java/org/mcpp/apkconsumer/ExternalHelper.java create mode 100755 tests/apk-consumer/check-apk-features.sh create mode 100644 tests/apk-consumer/fixtures/expected-manifest-level0-0.8.0.xml create mode 100644 tests/apk-consumer/java/org/mcpp/apkconsumer/MainActivity.java create mode 100644 tests/apk-consumer/manifest-template-bogus-token.xml create mode 100644 tests/apk-consumer/manifest-template-good.xml create mode 100644 tests/apk-consumer/manifest-template-missing-appid.xml create mode 100755 tests/web-consumer/check-web-idempotent.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1594c1..16c16ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ env: # 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.12.3 + MCPP_VERSION: 2026.9.13.1 # PINNED, AND WITHOUT IT THE CACHE BELOW CACHED NOTHING. # # A released mcpp is self-contained: with no `MCPP_HOME`, `mcpp self env` @@ -1127,7 +1127,11 @@ jobs: # --target wasm32-emscripten` -- see `tests/web-consumer/ # check-web-plan.sh`'s own header for the host-toolchain defect this # needed fixed first (mcpp commit 23974c5e, #622 e2e 657) and the - # MCPP_VERSION this step therefore needs at least. `mcpp toolchain + # MCPP_VERSION this step therefore needs at least. Since 0.9.0 the same + # pin is also the floor for `${mcpp.self}` and `mcpp stage`'s argument + # shape as an engine contract (design record + # `2026-09-13-four-upstream-asks-from-a-ui-framework.md`, §4), which + # `dist-web`'s two copy steps now use in place of `cp`. `mcpp toolchain # install` first, because this job's `~/.mcpp` cache does not carry # the emsdk payload the way the local sandbox this member was # developed against does. @@ -1137,6 +1141,14 @@ jobs: - name: dist-web produces a static directory, and node runs it working-directory: tests/web-consumer run: MCPP="$MCPP" ./check-web-plan.sh + + # P3's own criterion: a second, no-op pack copies nothing, which `cp` + # could never give and `${mcpp.self} stage --verify content` gives for + # free. A sibling script, not an addition to the one above, so that + # one keeps passing exactly as it did before this batch. + - name: dist-web's second pack copies nothing + working-directory: tests/web-consumer + run: MCPP="$MCPP" ./check-web-idempotent.sh # `kind = "app"` on `*-linux-android` links a shared object (#622 A3), # so this fixture's own toolchain resolution provisions `xim:android- # ndk` for `--target x86_64-linux-android` exactly as mcpp's own CI @@ -1195,6 +1207,20 @@ jobs: || { echo "FAIL: minSdkVersion is not 24 (this fixture's own min_api_level)"; cat -A badging.log | head -12; exit 1; } echo "ok: dist-apk level 0 -- a signed, aapt2-readable APK carrying the native library, the deployed asset and the run sidecar" + # P1/P2's own criteria (design record §3.5 / §9.2), beside the step + # above rather than folded into it: (a) level 0 with no template is + # 0.8.0's manifest byte-for-byte; (b) a template's own permission and + # receiver reach the linked apk; (c),(d) a template missing a required + # token, or naming an unknown one, is refused at plan time, checked on + # build.mcpp's own stdout/stderr since mcpp discards it on a plan-time + # refusal's exit code of 0 (the script's own header says why); (e) two + # Java roots, one under this project and one a sibling directory + # reached by an absolute path, dex into one file carrying classes from + # both. + - name: dist-apk's manifest template and Java roots, (a) to (e) + working-directory: tests/apk-consumer + run: MCPP="$MCPP" ./check-apk-features.sh + # 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. diff --git a/tests/apk-consumer-external-java/org/mcpp/apkconsumer/ExternalHelper.java b/tests/apk-consumer-external-java/org/mcpp/apkconsumer/ExternalHelper.java new file mode 100644 index 0000000..6e18306 --- /dev/null +++ b/tests/apk-consumer-external-java/org/mcpp/apkconsumer/ExternalHelper.java @@ -0,0 +1,13 @@ +// Fixture: a Java root OUTSIDE `tests/apk-consumer`, a sibling directory +// reached by an absolute path `build.mcpp` computes from +// `mcpp::manifest_dir()` -- standing in for a path dependency's own Java +// tree the way design record §3.3 describes. `options::java_sources` +// compiles this root alongside the project's own, one `javac`, one `d8`; +// `root_in_project` (`dist/apk.cppm`) reads it as OUTSIDE the package root, +// so this class's file is not declared with `rerun_if_changed_glob` -- only +// as an input of the `javac` action. +package org.mcpp.apkconsumer; + +public class ExternalHelper { + public static String marker() { return "external"; } +} diff --git a/tests/apk-consumer/build.mcpp b/tests/apk-consumer/build.mcpp index a264b38..4a3bee5 100644 --- a/tests/apk-consumer/build.mcpp +++ b/tests/apk-consumer/build.mcpp @@ -3,6 +3,22 @@ // `dist-apk` picks it up from there and stages it into the APK's `assets/` // -- see dist/apk.cppm's header for why that source is the ordinary build // tree and not the pack pipeline's own staged tree on this row. +// +// TWO ENVIRONMENT VARIABLES, READ ONLY HERE, SELECT THE FIVE CRITERIA OF +// design record §3.5 / §9.2 P1-P2 WITHOUT A SECOND FIXTURE. +// +// APK_CONSUMER_TEMPLATE a manifest template file, package-root-relative. +// Unset is the byte-identity criterion (a): the +// built-in default, level 0. +// APK_CONSUMER_LEVEL1 non-empty selects level 1: a real activity and +// two Java roots, one under this package and one a +// sibling directory this build program reaches by +// an absolute path, standing in for a path +// dependency's own Java tree (design record §3.3). +// +// Neither variable is read by `dist-apk` itself -- both are this fixture's +// own choice of which of its options to set, the same way any project would +// choose them from its own configuration. import std; import mcpp; import mcpp.dist.apk; @@ -24,5 +40,18 @@ int main() { mcpp::dist::apk::options opt; opt.target = "apk-consumer"; + + if (const char* tpl = std::getenv("APK_CONSUMER_TEMPLATE"); tpl && *tpl) { + opt.manifest_template = tpl; + } + if (const char* level1 = std::getenv("APK_CONSUMER_LEVEL1"); level1 && *level1) { + opt.activity = "org.mcpp.apkconsumer.MainActivity"; + // The project's own Java root, and a SIBLING of this fixture's own + // directory, reached by an absolute path computed from + // `mcpp::manifest_dir()` rather than a second package -- see + // `tests/apk-consumer-external-java/`'s own header. + opt.java_sources = { root + "/java", root + "/../apk-consumer-external-java" }; + } + return mcpp::dist::apk::generate(opt) ? 0 : 1; } diff --git a/tests/apk-consumer/check-apk-features.sh b/tests/apk-consumer/check-apk-features.sh new file mode 100755 index 0000000..1fbae60 --- /dev/null +++ b/tests/apk-consumer/check-apk-features.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# End-to-end checks for dist-apk's manifest template (design record +# `2026-09-13-four-upstream-asks-from-a-ui-framework.md`, §3.2 / §9.2 P1) and +# Java-array (§3.3 / P2) changes, criteria (a) to (e) of §3.5 read against +# this fixture. `build.mcpp` here reads two environment variables this +# script sets to reach each configuration without a second fixture -- see +# its own header. +# +# Usage: MCPP= ./check-apk-features.sh (run from this +# directory, after `tests/apk-consumer`'s own level-0 CI step, whose target/ +# this script clears and rebuilds itself) +set -e + +MCPP="${MCPP:-mcpp}" +TARGET=x86_64-linux-android +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +"$MCPP" self env > mcpp-env.txt +MCPP_HOME_DIR=$(awk -F'= *' '/^MCPP_HOME/{print $2; exit}' mcpp-env.txt) +[ -n "$MCPP_HOME_DIR" ] || fail "could not read MCPP_HOME" mcpp-env.txt +BT=$(find "$MCPP_HOME_DIR/registry/data/xpkgs/xim-x-android-build-tools" -mindepth 1 -maxdepth 1 -type d | head -1) +AAPT2="$BT/aapt2" +DEXDUMP="$BT/dexdump" +[ -x "$AAPT2" ] || fail "aapt2 not found under $BT" +[ -x "$DEXDUMP" ] || fail "dexdump not found under $BT" + +# ── (a) level 0, no template: byte-identical to 0.8.0's manifest ─────────── +echo "== (a) level 0, no template ==" +rm -rf target +unset APK_CONSUMER_TEMPLATE APK_CONSUMER_LEVEL1 || true +"$MCPP" build --target "$TARGET" > build-a.log 2>&1 || fail "build failed" build-a.log +"$MCPP" pack --format apk --target "$TARGET" > pack-a.log 2>&1 || fail "pack failed" pack-a.log +MANIFEST=target/.build-mcpp/out/dist-apk/AndroidManifest.xml +cmp fixtures/expected-manifest-level0-0.8.0.xml "$MANIFEST" \ + || fail "level 0's manifest is not byte-identical to 0.8.0's" "$MANIFEST" fixtures/expected-manifest-level0-0.8.0.xml +echo "ok: level 0 with no template renders 0.8.0's manifest byte-for-byte" + +# ── (b) a template with uses-permission and a receiver ───────────────────── +echo "== (b) a template with uses-permission and receiver ==" +rm -rf target +export APK_CONSUMER_TEMPLATE=manifest-template-good.xml +"$MCPP" build --target "$TARGET" > build-b.log 2>&1 || fail "build failed" build-b.log +"$MCPP" pack --format apk --target "$TARGET" > pack-b.log 2>&1 || fail "pack failed" pack-b.log +APK=$(find target -name 'apk-consumer.apk' | head -1) +[ -n "$APK" ] || fail "no apk-consumer.apk" pack-b.log +"$AAPT2" dump xmltree "$APK" --file AndroidManifest.xml > xmltree-b.log 2>&1 +grep -q 'android.permission.INTERNET' xmltree-b.log || fail "uses-permission missing from the linked apk" xmltree-b.log +grep -q 'org.mcpp.apkconsumer.SampleReceiver' xmltree-b.log || fail "receiver missing from the linked apk" xmltree-b.log +echo "ok: aapt2 dump xmltree on the linked base.apk lists both the permission and the receiver" + +# ── (c),(d): plan-time refusals name the token ────────────────────────────── +# +# mcpp discards a build program's captured stdout/stderr when it exits 0 +# (`build_program.cppm`: the capture is surfaced only on a non-zero exit or a +# timeout) -- and a `plan_for` refusal in this member never makes build.mcpp +# exit non-zero, `submit()` returns true for `applies=false` exactly as +# every other refusal in this member does (design record §3.2, "printing to +# stderr exactly as its other refusals do"). So the refusal message itself +# is checked by re-invoking the ALREADY-COMPILED build.mcpp binary directly, +# with the documented MCPP_* build-program contract (docs/30) reconstructed +# from the real payloads a prior build just resolved. Every variable set +# below is one docs/30 already publishes; the compiled binary's own path is +# the only thing this script assumes about mcpp's own layout. +echo "== (c),(d) plan-time refusals name the token ==" +BIN=target/.build-mcpp/build.mcpp.bin +[ -x "$BIN" ] || fail "no compiled build.mcpp to re-invoke" pack-b.log +STAGE_DIR=$(find target/dist -mindepth 1 -maxdepth 1 -type d | head -1) +[ -n "$STAGE_DIR" ] || fail "no staged tree from the prior pack" pack-b.log +PLATFORM=$(find "$MCPP_HOME_DIR/registry/data/xpkgs/xim-x-android-platform" -mindepth 1 -maxdepth 1 -type d | head -1) +KEYSTORE=$(find "$MCPP_HOME_DIR/registry/data/xpkgs/xim-x-android-debug-keystore" -mindepth 1 -maxdepth 1 -type d | head -1) +JDK=$(find "$MCPP_HOME_DIR/registry/data/xpkgs/xim-x-jdk-temurin" -mindepth 1 -maxdepth 1 -type d | head -1) + +run_build_program() { + env -i \ + MCPP_TARGET_ARCH=x86_64 MCPP_TARGET_ENV=android \ + MCPP_TARGET_MIN_PLATFORM_VERSION=24 \ + MCPP_OUT_DIR="$PWD/target/.build-mcpp/out" \ + MCPP_MANIFEST_DIR="$PWD" \ + MCPP_PKG_NAME=apk-consumer MCPP_PKG_VERSION=0.3.0 \ + MCPP_PACK_FORMAT=apk MCPP_PACK_STAGE_DIR="$STAGE_DIR" \ + MCPP_XPKG_XIM_ANDROID_BUILD_TOOLS_DIR="$BT" \ + MCPP_XPKG_XIM_ANDROID_PLATFORM_DIR="$PLATFORM" \ + MCPP_XPKG_XIM_ANDROID_DEBUG_KEYSTORE_DIR="$KEYSTORE" \ + MCPP_XPKG_XIM_JDK_TEMURIN_DIR="$JDK" \ + APK_CONSUMER_TEMPLATE="$1" \ + "$BIN" > "$2" 2>&1 +} + +run_build_program manifest-template-missing-appid.xml refusal-c.log +grep -qF "does not use '{{application_id}}'" refusal-c.log \ + || fail "the missing-token refusal does not name application_id" refusal-c.log +grep -qF "assets/mcpp-run.json" refusal-c.log \ + || fail "the missing-token refusal does not name assets/mcpp-run.json" refusal-c.log +echo "ok: a template missing {{application_id}} is refused, naming the token and assets/mcpp-run.json" + +run_build_program manifest-template-bogus-token.xml refusal-d.log +grep -qF "unknown token '{{bogus}}'" refusal-d.log \ + || fail "the unknown-token refusal does not name bogus" refusal-d.log +echo "ok: a template with {{bogus}} is refused, naming the token" + +# Functional confirmation through the ordinary CLI: `mcpp pack` itself fails +# when the manifest template is bad, because dist-apk declared the format +# and submitted no artifact action for it. +export APK_CONSUMER_TEMPLATE=manifest-template-missing-appid.xml +if "$MCPP" pack --format apk --target "$TARGET" > pack-c.log 2>&1; then + fail "mcpp pack succeeded with a manifest template missing a required token" pack-c.log +fi +grep -q "no action claimed" pack-c.log || fail "mcpp pack did not refuse the bad template" pack-c.log +echo "ok: mcpp pack itself refuses (no action claimed for --format apk)" +unset APK_CONSUMER_TEMPLATE + +# ── (e) two Java roots produce one classes.dex with classes from both ───── +echo "== (e) two Java roots ==" +rm -rf target +export APK_CONSUMER_LEVEL1=1 +"$MCPP" build --target "$TARGET" > build-e.log 2>&1 || fail "build failed" build-e.log +"$MCPP" pack --format apk --target "$TARGET" > pack-e.log 2>&1 || fail "pack failed" pack-e.log +DEX=$(find target -name 'classes.dex' | head -1) +[ -n "$DEX" ] || fail "no classes.dex" pack-e.log +"$DEXDUMP" -l plain "$DEX" > dexdump-e.log 2>&1 || fail "dexdump failed" dexdump-e.log +grep -q "org.mcpp.apkconsumer.MainActivity;" dexdump-e.log \ + || fail "classes.dex does not contain MainActivity (the project's own root)" dexdump-e.log +grep -q "org.mcpp.apkconsumer.ExternalHelper;" dexdump-e.log \ + || fail "classes.dex does not contain ExternalHelper (the external root)" dexdump-e.log +echo "ok: one classes.dex, carrying classes from both the project root and the external root" +unset APK_CONSUMER_LEVEL1 + +rm -f build-*.log pack-*.log xmltree-*.log refusal-*.log dexdump-*.log mcpp-env.txt +echo "PASS: dist-apk's manifest template and Java-array criteria (a) to (e)" diff --git a/tests/apk-consumer/fixtures/expected-manifest-level0-0.8.0.xml b/tests/apk-consumer/fixtures/expected-manifest-level0-0.8.0.xml new file mode 100644 index 0000000..4941744 --- /dev/null +++ b/tests/apk-consumer/fixtures/expected-manifest-level0-0.8.0.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/tests/apk-consumer/java/org/mcpp/apkconsumer/MainActivity.java b/tests/apk-consumer/java/org/mcpp/apkconsumer/MainActivity.java new file mode 100644 index 0000000..e4e1f52 --- /dev/null +++ b/tests/apk-consumer/java/org/mcpp/apkconsumer/MainActivity.java @@ -0,0 +1,10 @@ +// Fixture: the project-local Java root of `tests/apk-consumer`, used when +// `APK_CONSUMER_LEVEL1` selects level 1 (see `build.mcpp`). One class, +// naming the activity `options::manifest_template` renders through +// `{{activity}}` and `assets/mcpp-run.json` also carries. +package org.mcpp.apkconsumer; + +import android.app.Activity; + +public class MainActivity extends Activity { +} diff --git a/tests/apk-consumer/manifest-template-bogus-token.xml b/tests/apk-consumer/manifest-template-bogus-token.xml new file mode 100644 index 0000000..ae23b0a --- /dev/null +++ b/tests/apk-consumer/manifest-template-bogus-token.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/tests/apk-consumer/manifest-template-good.xml b/tests/apk-consumer/manifest-template-good.xml new file mode 100644 index 0000000..dea5d8f --- /dev/null +++ b/tests/apk-consumer/manifest-template-good.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/tests/apk-consumer/manifest-template-missing-appid.xml b/tests/apk-consumer/manifest-template-missing-appid.xml new file mode 100644 index 0000000..b12ca10 --- /dev/null +++ b/tests/apk-consumer/manifest-template-missing-appid.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/tests/web-consumer/check-web-idempotent.sh b/tests/web-consumer/check-web-idempotent.sh new file mode 100755 index 0000000..002d921 --- /dev/null +++ b/tests/web-consumer/check-web-idempotent.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# A second `mcpp pack --format web` with nothing changed copies nothing +# (design record `2026-09-13-four-upstream-asks-from-a-ui-framework.md`, +# §4.3 / §9.2 P3) -- the criterion `${mcpp.self} stage --verify content` +# gives for free and `cp` never could, since `stage` writes only on a +# content difference. +# +# A sibling of `check-web-plan.sh` rather than an addition to it, so that +# script keeps passing exactly as it did before this batch (its own +# criterion). `ninja -n` is not used here: this repository's own `.ninja_log` +# is written by mcpp's INTERNAL ninja, and a system `ninja` of a different +# version reads that log, decides it is "too old", and starts over -- which +# reports every edge as pending regardless of whether anything actually +# reran. mtimes are the criterion the design record itself offers as the +# alternative, and they do not depend on which ninja binary happens to be on +# the runner's PATH. +# +# Usage: MCPP= ./check-web-idempotent.sh (run from this +# directory, after `check-web-plan.sh`, or on its own -- it packs fresh) +set -e + +MCPP="${MCPP:-mcpp}" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +rm -rf target +"$MCPP" build --target wasm32-emscripten > build.log 2>&1 \ + || fail "mcpp build --target wasm32-emscripten failed" build.log +"$MCPP" pack --format web --target wasm32-emscripten > pack1.log 2>&1 \ + || fail "the first mcpp pack --format web failed" pack1.log +WEB=$(find target -type d -name web | head -1) +[ -n "$WEB" ] || fail "no web/ directory was produced" pack1.log + +mapfile -t files < <(cd "$WEB" && find . -type f | sort) +before=$(for f in "${files[@]}"; do stat -c '%Y %n' "$WEB/$f"; done) + +# A full second of separation: some filesystems keep only whole-second +# mtimes, and a stage that DID rewrite a file one second later would still +# have to show a changed reading. +sleep 1 + +"$MCPP" pack --format web --target wasm32-emscripten > pack2.log 2>&1 \ + || fail "the second mcpp pack --format web failed" pack2.log +after=$(for f in "${files[@]}"; do stat -c '%Y %n' "$WEB/$f"; done) + +[ "$before" = "$after" ] || { + echo "FAIL: a second pack changed at least one file's mtime, so something was recopied" + diff <(echo "$before") <(echo "$after") + exit 1 +} +echo "ok: every staged file's mtime is unchanged after a second, no-op pack" + +rm -f build.log pack1.log pack2.log +echo "PASS: a second mcpp pack --format web copies nothing" From a074e34eb072dfec2a21871954e0450cb6d382fb Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 13 Sep 2026 04:00:37 +0800 Subject: [PATCH 4/5] 0.9.0: raise the floor of dist-apk and dist-web to 2026.9.13.1 README rows for dist-apk and dist-web state the new floor and why: dist-web needs ${mcpp.self} and mcpp stage's argument shape as an engine contract; dist-apk's own manifest-template and Java-array changes ask nothing new of the engine, but this collection publishes one package at one version, and this is the release CI verifies it under from here on. mcpp.toml and src/plugins.cppm: version 0.9.0. The mcpp 2026.9.13.1 release this depends on does not exist yet, so CI is red on the fetch step until it does (see the pull request body). --- README.md | 4 ++-- mcpp.toml | 2 +- src/plugins.cppm | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 37c1228..9a45fa4 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ engine's own module family and is not used here. | `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 | `xim:wix`, which this feature declares on the Windows target axis; the .NET 6 runtime the tool needs is a Windows component the payload does not carry, and `wix --version` names it when it is missing. Windows only. Renders a `.wxs` and passes the program in as a preprocessor variable, because a bind path that resolves to nothing is silent | | `dist-apple` | `mcpp.dist.apple` | 2026.9.11.2 (macOS), 2026.9.12.3 (iOS) | the base macOS install (`ditto`, and `codesign` only when an identity is given). macOS: `Contents/`-shaped, as always. iOS (`aarch64-ios-sim`, `aarch64-ios`): a flat bundle at the same call site -- no separate feature, no separate module -- with `MinimumOSVersion` from `mcpp::min_platform_version()` (#622 A11), `CFBundleSupportedPlatforms` read from `env == "sim"`, `UIDeviceFamily`, `LSRequiresIPhoneOS`, and a directory of flat PNGs listed under `CFBundleIcons` in place of macOS's single `.icns` file. Signing is skipped on the simulator row (`options::identity` is ignored, with a `mcpp::warning` naming why) and unchanged on the device row. The iOS row is measured end to end on `macos-15`: a real `mcpp build`, `mcpp pack --format app` and `mcpp run` against `aarch64-ios-sim`, through `xim:apple-simulator-tools`' `simctl-run`. **The macOS 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 | -| `dist-web` | `mcpp.dist.web` | 2026.9.12.3, the release that carries #622: the `.js` launcher and the staged stem family, `mcpp::deploy`, and the build program's host compiler under the Web row | nothing beyond mcpp: `wasm32-emscripten` only. Copies `${mcpp.stage_dir}/bin/` -- the `.js` launcher, the implicit `.wasm`, the `.data` when present, and every `mcpp::deploy`'d file, all of which #622 A5 and A4 already stage there -- to `/web/`, dropping the `bin/` prefix a browser has no use for, and writes an `index.html` rendered from a project template or a built-in default that loads the script with a plain `