Skip to content

Commit 9301832

Browse files
0.9.3: dist-apk carries the graph's shared libraries, and the manifest template gains the version tokens (#23)
Two things an application whose framework is a separate `lib<fw>.so` on Android (the shape Gradle builds) hits against 0.9.2. The graph's shared libraries never reached the APK. A dependency declared `linkage = "shared"` is linked as `lib<dep>.so` beside the app's object in the ordinary build's `bin/` and NEEDed by name; the engine stages the app's own object and the deployed files, but the closure on this row is `not-walked` (a host cannot run an Android artifact), so `lib/` carried the app and `libc++_shared.so` only, and the APK installed and died at `dlopen`. `apk:needed` now walks NEEDED from the app's own object at command time -- the set is not knowable at plan time without the closure -- and copies every name that exists beside the object, recursively, into `lib/<abi>/`; platform libraries live nowhere beside it and are skipped. The manifest template gains `{{version_name}}` (the package version as written) and `{{version_code}}` (major * 1000000 + minor * 1000 + patch over the leading numeric segments, 1 when none parses), so a template can carry `android:versionName` / `android:versionCode` without a second spelling of the version. The built-in default stays byte-identical to 0.8.0's. `tests/apk-consumer-shared` (a path dependency shared on the Android row; the APK lists `lib/x86_64/libapk-consumer-dep.so`) and criterion (h) of `tests/apk-consumer` (versionName 0.3.0, versionCode 3000). Both measured locally with mcpp 2026.9.13.2; (a) to (g) unchanged. Co-authored-by: sunrisepeak <x.d2learn.org@gmail.com>
1 parent ca4cb63 commit 9301832

13 files changed

Lines changed: 261 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1219,10 +1219,28 @@ jobs:
12191219
# both; (g) a project's own res/ links as the BASE -- two resources
12201220
# nothing else defines reach the apk (0.9.0 linked res/ as an aapt2
12211221
# overlay, which refuses any resource the base does not already have).
1222-
- name: dist-apk's manifest template, Java roots and res/, (a) to (g)
1222+
- name: dist-apk's manifest template, Java roots, res/ and version tokens, (a) to (h)
12231223
working-directory: tests/apk-consumer
12241224
run: MCPP="$MCPP" ./check-apk-features.sh
12251225

1226+
# A dependency linked as a shared object on the Android row reaches
1227+
# `lib/<abi>/` beside the app's own: walked from the app's NEEDED at
1228+
# command time, because the closure on this row is `not-walked` and the
1229+
# engine stages the app's object and the deployed files only (0.9.3).
1230+
- name: dist-apk carries the graph's shared libraries
1231+
working-directory: tests/apk-consumer-shared
1232+
run: |
1233+
"$MCPP" build --target x86_64-linux-android
1234+
"$MCPP" pack --format apk --target x86_64-linux-android | tee pack.log
1235+
apk=$(find target -name 'apk-consumer-shared.apk' | head -1)
1236+
test -n "$apk" || { cat pack.log; echo "FAIL: no apk-consumer-shared.apk"; exit 1; }
1237+
JDK=$(find "$MCPP_HOME/registry/data/xpkgs/xim-x-jdk-temurin" -mindepth 1 -maxdepth 1 -type d | head -1)
1238+
"$JDK/bin/jar" tf "$apk" | tee listing.log
1239+
for f in "lib/x86_64/libapk-consumer-shared.so" "lib/x86_64/libapk-consumer-dep.so"; do
1240+
grep -qxF "$f" listing.log || { echo "FAIL: the apk does not list $f"; exit 1; }
1241+
done
1242+
echo "ok: the dependency's shared object is in the apk beside the app's"
1243+
12261244
# Compiles the device unit on a machine with no GPU: the clang route
12271245
# produces sm_89 code from the payload toolkit. Running it needs a
12281246
# device, so the run is of the CPU variant, which the same seam serves.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ engine's own module family and is not used here.
7575
| `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 |
7676
| `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. From 0.9.2 the staged tree's deployed files (`bin/<to>/...`, which the engine stages for a Mach-O program before the closure walk since the release for mcpp#630) land at the bundle's resource destination -- `Contents/Resources/<to>/...` on macOS, the bundle root on iOS -- and the launcher alone goes to the executable directory, so `CFBundleExecutable` names a file that is where it says. The iOS fixture declares `llvm.libcxx` and `llvm.compiler-rt-builtins` under `cfg(os = "ios")`, which is what an application that imports `std` on those rows declares |
7777
| `dist-web` | `mcpp.dist.web` | 2026.9.13.1, the release that carries `${mcpp.self}` and `mcpp stage`'s argument shape as an engine contract (`stage --verify content --output <dst> <src>`) -- what lets this member's copy run on every host mcpp does, Windows included, in place of the `cp` this member used through 0.8.0 | 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 `<out_dir>/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 `<script src>`. Each staged file and the rendered page are copied with `${mcpp.self} stage --verify content --output <dst> <src>`, the same copier every `stage_file` edge in `build.ninja` already runs -- no host-specific copy tool, no plan-time `create_directories` (`stage` creates the destination's parent), and a second `mcpp pack --format web` with nothing changed copies nothing |
78-
| `dist-apk` | `mcpp.dist.apk` | 2026.9.13.1, raised alongside `dist-web` in the same 0.9.0 release: this member'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 | `xim:android-build-tools`, `xim:android-platform` (versioned by API level, read back for `targetSdkVersion`), `xim:jdk-temurin` (`javac`/`jar`; `android-build-tools`' own runtime dependency provisions a JDK for its OWN wrappers only), `xim:android-debug-keystore`, all on the `cfg(env = "android")` axis. Generates `AndroidManifest.xml` and signs with the published Android debug key by default. Level 0 needs no Java (`hasCode="false"`, `android.app.NativeActivity`); `options::java_sources` adds `javac` + `d8` and a real `<activity>`. `options::manifest_template` renders a project manifest with six tokens substituted verbatim; `{{application_id}}` and `{{activity}}` are required always and `{{lib_name}}` at level 0, each refused by name at plan time when missing (naming `assets/mcpp-run.json`, which `adb-run` reads them from too) or when the template names an unknown token; empty renders 0.8.0's manifest byte-identically. `options::resources` is a project's own `res/`, linked as the application's base resources from 0.9.1 (0.9.0 linked it as an aapt2 overlay, which refuses every resource the base does not already define -- a launcher icon could not be supplied). `options::java_sources` is an array: one `javac` over every root's `.java` files and one `d8` over the result, so a project's own sources and a path dependency's join without being merged into one directory first, and `rerun_if_changed_glob` is declared only for a root under `mcpp::manifest_dir()` -- a dependency root's files are already inputs of the `javac` action and its version is already in the build's fingerprint. Android only -- an `app` target is a shared object on this row (#622 A3), and this member reads its native library from the staged tree and whatever `mcpp::deploy` placed beside the ordinary build's link output, because Android's own closure stages neither the dependency set nor the deployed files (see `dist/apk.cppm`'s header). CI packages both level 0 and level 1 and checks the archive (`libc++_shared.so` bundled only when the closure's `NEEDED` entries require it, `mcpp::deploy`'d files under `assets/`); the runner has no emulator or device, so the two rows that actually run were measured locally on 2026-09-12, through `adb-run`, against a KVM-accelerated x86_64 emulator and a physical arm64-v8a phone, both printing `1-2-3` and exiting 0 |
78+
| `dist-apk` | `mcpp.dist.apk` | 2026.9.13.1, raised alongside `dist-web` in the same 0.9.0 release: this member'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 | `xim:android-build-tools`, `xim:android-platform` (versioned by API level, read back for `targetSdkVersion`), `xim:jdk-temurin` (`javac`/`jar`; `android-build-tools`' own runtime dependency provisions a JDK for its OWN wrappers only), `xim:android-debug-keystore`, all on the `cfg(env = "android")` axis. Generates `AndroidManifest.xml` and signs with the published Android debug key by default. Level 0 needs no Java (`hasCode="false"`, `android.app.NativeActivity`); `options::java_sources` adds `javac` + `d8` and a real `<activity>`. `options::manifest_template` renders a project manifest with six tokens substituted verbatim; `{{application_id}}` and `{{activity}}` are required always and `{{lib_name}}` at level 0, each refused by name at plan time when missing (naming `assets/mcpp-run.json`, which `adb-run` reads them from too) or when the template names an unknown token; empty renders 0.8.0's manifest byte-identically. From 0.9.3 the member walks the app object's `NEEDED` at command time and copies every graph-built shared library it finds beside the object (a dependency declared `linkage = "shared"`) into `lib/<abi>/`, since the closure on this row is `not-walked`; and the manifest template gains `{{version_name}}` / `{{version_code}}` (the package version, and `major * 1000000 + minor * 1000 + patch`). `options::resources` is a project's own `res/`, linked as the application's base resources from 0.9.1 (0.9.0 linked it as an aapt2 overlay, which refuses every resource the base does not already define -- a launcher icon could not be supplied). `options::java_sources` is an array: one `javac` over every root's `.java` files and one `d8` over the result, so a project's own sources and a path dependency's join without being merged into one directory first, and `rerun_if_changed_glob` is declared only for a root under `mcpp::manifest_dir()` -- a dependency root's files are already inputs of the `javac` action and its version is already in the build's fingerprint. Android only -- an `app` target is a shared object on this row (#622 A3), and this member reads its native library from the staged tree and whatever `mcpp::deploy` placed beside the ordinary build's link output, because Android's own closure stages neither the dependency set nor the deployed files (see `dist/apk.cppm`'s header). CI packages both level 0 and level 1 and checks the archive (`libc++_shared.so` bundled only when the closure's `NEEDED` entries require it, `mcpp::deploy`'d files under `assets/`); the runner has no emulator or device, so the two rows that actually run were measured locally on 2026-09-12, through `adb-run`, against a KVM-accelerated x86_64 emulator and a physical arm64-v8a phone, both printing `1-2-3` and exiting 0 |
7979

8080
### Each rule brings its own environment
8181

dist/apk.cppm

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,13 +386,39 @@ inline std::string replace_all_copy(std::string s, std::string_view from, std::s
386386
return s;
387387
}
388388

389-
// The six tokens a manifest template may use.
389+
// The eight tokens a manifest template may use. `version_name` is the
390+
// package version as written; `version_code` is Android's monotonic integer
391+
// derived from its leading numeric segments (`version_code_for`).
390392
inline const std::vector<std::string>& manifest_tokens() {
391393
static const std::vector<std::string> v = {
392-
"application_id", "label", "activity", "lib_name", "min_sdk", "target_sdk"};
394+
"application_id", "label", "activity", "lib_name", "min_sdk", "target_sdk",
395+
"version_name", "version_code"};
393396
return v;
394397
}
395398

399+
// `android:versionCode` from a version string: major * 1000000 + minor * 1000
400+
// + patch over the first three dot-separated numeric segments, so that every
401+
// release a project cuts orders after the one before it, and "1" when the
402+
// string starts with no number at all -- Android refuses 0 and a manifest
403+
// without the attribute installs but never updates.
404+
inline std::string version_code_for(const std::string& version) {
405+
long long code = 0, seg = 0;
406+
int segments = 0;
407+
bool digits = false;
408+
const long long weights[3] = {1000000, 1000, 1};
409+
for (std::size_t i = 0; i <= version.size() && segments < 3; ++i) {
410+
const char c = i < version.size() ? version[i] : '\0';
411+
if (c >= '0' && c <= '9') { seg = seg * 10 + (c - '0'); digits = true; continue; }
412+
if (!digits) break;
413+
code += std::min<long long>(seg, 999) * weights[segments] ;
414+
++segments; seg = 0; digits = false;
415+
if (c != '.') break;
416+
}
417+
if (segments == 0) return "1";
418+
if (code <= 0) return "1";
419+
return std::to_string(code);
420+
}
421+
396422
// Every `{{...}}` a template names, in first-appearance order, duplicates
397423
// dropped -- what both checks in `render_manifest` below read.
398424
inline std::vector<std::string> tokens_in(const std::string& text) {
@@ -478,13 +504,14 @@ inline bool render_manifest(const std::string& templateText, bool has_code,
478504
const std::string& appId, const std::string& label,
479505
const std::string& activityName, const std::string& libName,
480506
const std::string& minSdk, const std::string& targetSdk,
507+
const std::string& versionName, const std::string& versionCode,
481508
std::string& out, std::string& reason) {
482509
for (auto const& tok : tokens_in(templateText)) {
483510
if (std::ranges::find(manifest_tokens(), tok) == manifest_tokens().end()) {
484511
std::cerr << "mcpp.dist.apk: the manifest template names an unknown "
485512
"token '{{" << tok << "}}' -- expected one of "
486513
"application_id, label, activity, lib_name, min_sdk, "
487-
"target_sdk\n";
514+
"target_sdk, version_name, version_code\n";
488515
reason = "unknown manifest template token '" + tok + "'";
489516
return false;
490517
}
@@ -507,6 +534,8 @@ inline bool render_manifest(const std::string& templateText, bool has_code,
507534
out = replace_all_copy(std::move(out), "{{lib_name}}", libName);
508535
out = replace_all_copy(std::move(out), "{{min_sdk}}", minSdk);
509536
out = replace_all_copy(std::move(out), "{{target_sdk}}", targetSdk);
537+
out = replace_all_copy(std::move(out), "{{version_name}}", versionName);
538+
out = replace_all_copy(std::move(out), "{{version_code}}", versionCode);
510539
return true;
511540
}
512541

@@ -818,9 +847,12 @@ inline plan plan_for(options opt = {}) {
818847
manifestTemplateText = default_manifest_template(hasCode);
819848
}
820849

850+
const std::string versionName = mcpp::package_version() ? mcpp::package_version() : "";
851+
const std::string versionCode = version_code_for(versionName);
821852
std::string manifestBytes;
822853
if (!render_manifest(manifestTemplateText, hasCode, appId, label, activityName,
823-
target, minSdk, targetSdk, manifestBytes, p.reason)) {
854+
target, minSdk, targetSdk, versionName, versionCode,
855+
manifestBytes, p.reason)) {
824856
return p;
825857
}
826858

@@ -932,6 +964,42 @@ inline plan plan_for(options opt = {}) {
932964
{ std::error_code ec; fs::permissions(copyThenJar,
933965
fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec,
934966
fs::perm_options::add, ec); }
967+
// THE SHARED LIBRARIES THE GRAPH BUILT, WHICH THE STAGED TREE DOES NOT
968+
// CARRY. A dependency declared `linkage = "shared"` is linked as its own
969+
// `lib<dep>.so` beside the app's in the ordinary build's `bin/`, and the
970+
// app's dynamic section NEEDs it by name. The engine stages the app's
971+
// own object and the deployed files, but on this row the closure is
972+
// `not-walked` (a host cannot run an Android artifact), so nothing else
973+
// reaches `lib/` -- and an APK without `lib<dep>.so` installs and dies
974+
// at `dlopen` (measured: HuxerUI's framework as a shared library,
975+
// `NEEDED libhuxerui.so`, 2026.9.13.2 + 0.9.2). This helper walks NEEDED
976+
// from the app's own object at command time -- the set is not knowable
977+
// at plan time without the closure -- and copies every name that exists
978+
// beside the object, recursively, into `lib/<abi>/`; names that live
979+
// nowhere beside it (the platform's `libandroid.so`, `libc.so`) are
980+
// skipped, and `libc++_shared.so` is the plan-time copy above.
981+
const std::string collectNeeded = (helpersDir / "collect-needed.sh").string();
982+
write_if_different(collectNeeded,
983+
"#!/bin/sh\n"
984+
"# mcpp.dist.apk helper. Do not edit.\n"
985+
"set -e\n"
986+
"readelf=\"$1\"; so=\"$2\"; dst=\"$3\"; stamp=\"$4\"\n"
987+
"dir=$(dirname \"$so\")\n"
988+
"mkdir -p \"$dst\"\n"
989+
"copy_needed() {\n"
990+
" \"$readelf\" -d \"$1\" | sed -n 's/.*(NEEDED).*\\[\\(.*\\)\\].*/\\1/p' | while IFS= read -r n; do\n"
991+
" if [ -f \"$dir/$n\" ] && [ ! -f \"$dst/$n\" ]; then\n"
992+
" cp \"$dir/$n\" \"$dst/$n\"\n"
993+
" copy_needed \"$dir/$n\"\n"
994+
" fi\n"
995+
" done\n"
996+
"}\n"
997+
"copy_needed \"$so\"\n"
998+
"mkdir -p \"$(dirname \"$stamp\")\"\n"
999+
": > \"$stamp\"\n");
1000+
{ std::error_code ec; fs::permissions(collectNeeded,
1001+
fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec,
1002+
fs::perm_options::add, ec); }
9351003
const std::string runAndStamp = (helpersDir / "run-and-stamp.sh").string();
9361004
write_if_different(runAndStamp,
9371005
"#!/bin/sh\n"
@@ -1088,6 +1156,21 @@ inline plan plan_for(options opt = {}) {
10881156
javaOutputs.push_back(d8Step.output);
10891157
}
10901158

1159+
// ── the graph's shared libraries, walked from the app's NEEDED ────────
1160+
step needed;
1161+
needed.id = "apk:needed";
1162+
needed.role = "artifact";
1163+
needed.description = "APK NEEDED";
1164+
needed.output = (outDir / "needed.stamp").string();
1165+
{
1166+
const std::string toolchainDir = mcpp::toolchain_dir();
1167+
const std::string readelf = (fs::path(toolchainDir) / "bin" / "llvm-readelf").string();
1168+
const std::string targetFile = std::format("${{mcpp.target_file:{}}}", target);
1169+
needed.argv = { collectNeeded, readelf, targetFile, libAbiDir.string(), needed.output };
1170+
needed.inputs = { targetFile };
1171+
}
1172+
p.steps.push_back(needed);
1173+
10911174
// ── native library and assets join the archive ─────────────────────
10921175
step libs;
10931176
libs.id = "apk:libs";
@@ -1102,7 +1185,7 @@ inline plan plan_for(options opt = {}) {
11021185
libs.argv.push_back((outDir / "dex").string());
11031186
libs.argv.push_back("classes.dex");
11041187
}
1105-
libs.inputs = { apkPath };
1188+
libs.inputs = { apkPath, needed.output };
11061189
for (auto const& f : libInputs) libs.inputs.push_back(f);
11071190
for (auto const& f : assetInputs) libs.inputs.push_back(f);
11081191
for (auto const& f : javaOutputs) libs.inputs.push_back(f);

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "plugins"
33
namespace = "mcpp"
4-
version = "0.9.2"
4+
version = "0.9.3"
55
description = "Official mcpp build plugins: rule packages under mcpp.rules.*, build-time utilities under mcpp.tools.*, each member selected by a feature"
66
license = "Apache-2.0"
77
authors = ["mcpp-community"]

src/plugins.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export namespace mcpp::plugins {
4949
//
5050
// One package, one version: the number lives in mcpp.toml, and the CI step
5151
// `the collection states its own version` compares the two.
52-
inline constexpr std::string_view version = "0.9.2";
52+
inline constexpr std::string_view version = "0.9.3";
5353

5454
} // namespace mcpp::plugins
5555

0 commit comments

Comments
 (0)