The
.ppmpluginis the binary distribution of a PAM native extension for the wrap runtime: a zip containing amanifest.jsonplus prebuilt native binaries. It ships a precompiled Android DEX (loaded at runtime viaDexClassLoader) and/or an iOS framework, and is uploaded for validation before the wrap build bundles it into the mobile app.Compatibility authority. This file defines the plugin's public bundle contract and local compatibility checks. The live upload service remains authoritative and can apply additional validation; if an upload error conflicts with this document, preserve the error and update these checks rather than bypassing it.
This file is the single source of truth for the third-party-control track. The only entry point is /generate-ppmplugin — it drives the five stages below in order; the user never invokes a stage directly:
/generate-ppmplugin-manifest— validates the committed./manifest.json+ stages a build copy (authors from source only as a fallback for hand-authored modules)./build-android-binary— compiles the Kotlin module to<Pascal>Plugin.dex./build-ios-binary— compiles the Obj-C module to a flat<Pascal>Plugin.framework(Mac-only)./assemble-ppmplugin— zips the manifest + binaries into the final.ppmpluginand verifies its layout./audit-ppmplugin— verifies the built bundle against the wrap-runtime contract (validator rules + DEX SDK-leakage scan + source-to-receiver contract); the final upload-readiness gate.
<name>.ppmplugin ← a zip (built with `jar cMf`)
├── manifest.json
├── android/ ← present iff `entrypoints.android` is declared
│ └── <PascalName>Plugin.dex
└── ios/ ← present iff `entrypoints.ios` is declared
└── <PascalName>Plugin.framework/ ← FLAT device-slice framework — NOT .xcframework (see §5b)
├── <PascalName>Plugin ← Mach-O dynamic binary (named exactly == framework; dlopen target) — REQUIRED
├── Info.plist ← REQUIRED — wrap codesign rejects a framework with no Info.plist
├── Headers/<PascalName>Plugin.h ← umbrella header — build-hygiene only (player dlopens, doesn't import)
└── Modules/module.modulemap ← optional — NOT needed at runtime (see §5b)
iOS ships a FLAT
.framework, not an.xcframework. The wrap pipeline expectsios/<Name>.frameworkand does not descend into an.xcframework— shipping an.xcframeworkfails with "Framework '.framework' not found in plugin." Ship the device slice (ios-arm64) as a flat<Name>.framework/atios/. See §5b.
The bundle is native-only: manifest.json plus the per-platform binaries — no TypeScript / JavaScript layer is shipped.
android/ships the DEX (via/build-android-binary) — present iff the manifest declaresentrypoints.android.ios/ships the flat.framework(via/build-ios-binary) — present iff the manifest declaresentrypoints.ios.
The manifest declares the native platform(s) targeted; at least one must be present (a manifest-only bundle has nothing for the wrap runtime to load). /assemble-ppmplugin zips the native slice(s) actually built and reconciles the manifest against them.
Filename convention — no version in the filename. The bundle is named
<name>.ppmplugin(e.g.pen-input.ppmplugin), not<name>-<version>.ppmplugin. The version lives only in the manifest'sversionfield; the wrap pipeline readsname+versionfrommanifest.jsonon ingest, not from the filename. (This matches the wrap reference'sdist/<name>.ppmpluginconvention.) Keep the version out of both thenamefield and the filename — putting it innamealso breaks the name regex (no dots allowed) and the canonical-prefix rule.
There are two manifest.json files, deliberately:
./manifest.json(repo root, committed) — the dispatch-contract source of truth. Authored by/generate-native-extensionat scaffold time, from the names the native modules expose. It declares every platform the module supports. The PCF reads it for the composite key; humans read it as the contract. This is the file you edit.ppmplugin/staging/manifest.json(gitignored) — the build copy./generate-ppmplugin-manifestproduces it from./manifest.json(validate + reconcileentrypointsdown to the shipped target);/assemble-ppmpluginzips it into the bundle. Never hand-edit it. (For a hand-authored module with no./manifest.json, the manifest stage authors both.)
Work happens under a gitignored ppmplugin/ directory at the repo root:
ppmplugin/
├── staging/
│ ├── manifest.json ← STAGED copy: /generate-ppmplugin-manifest produces it from ./manifest.json (target-aware: only the shipped platforms)
│ ├── android-build/ ← /build-android-binary's throwaway Gradle copy (NOT zipped)
│ ├── android/
│ │ └── <PascalName>Plugin.dex ← /build-android-binary (zipped)
│ ├── ios-build/ ← /build-ios-binary's throwaway Xcode project (NOT zipped)
│ └── ios/
│ └── <PascalName>Plugin.framework/ ← /build-ios-binary (zipped, flat device slice) [Mac-only]
└── <name>.ppmplugin ← /assemble-ppmplugin (zips manifest + only the staged platform slices)
/assemble-ppmplugin zips only manifest.json + the android/ and/or ios/ slices that are present — never the *-build/ working dirs — and reconciles the manifest's entrypoints against which slices actually exist (gating on any declared-but-not-built platform).
Replace-existing gate (every third-party-control build/assemble skill). When a staged binary (android/<…>.dex, ios/<…>.framework) or the final .ppmplugin already exists, the skill MUST surface it and ask Replace vs Keep before overwriting — never silently clobber an artifact the user may have produced deliberately (shared-instructions §7.1). This applies to /build-android-binary, /build-ios-binary, and /assemble-ppmplugin alike.
Each skill MUST ensure ppmplugin/ is in the repo's .gitignore (add the line if missing) — build artifacts never get committed.
{
"name": "<kebab>",
"version": "<semver from package.json>",
"abi": {
"compatibleShells": ">=1.0.0",
"builtAgainst": "1.0.0"
},
"entrypoints": {
"ios": {
"framework": "<PascalName>Plugin",
"moduleClass": "RCT<PascalName>Module"
},
"android": {
"dex": "<PascalName>Plugin.dex",
"packageClass": "com.powerapps.<lower>.<PascalName>Package"
}
},
"receivers": [
{
"name": "<PascalName>Extension",
"nativeModule": "<PascalName>Module",
"methods": ["<@ReactMethod name>", "..."]
}
]
}entrypoints.android / entrypoints.ios are per-target — declare only for the native platform(s) the bundle ships; at least one is required. The validator requires an entrypoint for every shipped native platform. An Android-only bundle omits entrypoints.ios; an iOS-only bundle omits entrypoints.android; a Both bundle carries both. /generate-ppmplugin-manifest is target-aware; /assemble-ppmplugin reconciles declared entrypoints against the binaries actually staged.
All names derive from the extension's class name (PascalCase) per naming-conventions.md, with one ppmplugin-specific rule:
| Field | Derived from | Pen-input example |
|---|---|---|
name |
kebab-case of the CLASS name (see §3 — NOT the capability name) | pen-input |
version |
package.json version |
0.1.4 |
entrypoints.android.dex |
<PascalName>Plugin.dex |
PenInputPlugin.dex |
entrypoints.android.packageClass |
FQN of the ReactPackage (read from the .kt file: package line + class name) |
com.powerapps.peninput.PenInputPackage |
entrypoints.ios.framework |
<PascalName>Plugin |
PenInputPlugin |
entrypoints.ios.moduleClass |
RCT<PascalName>Module |
RCTPenInputModule |
receivers[].name |
the receiver's routing-key suffix (see Runtime dispatch contract below) — our scaffold uses <PascalName>Extension; any JS-identifier works, and it need not carry a suffix (the wrap reference uses bare Battery) |
PenInputExtension |
receivers[].nativeModule |
the module's getName() return value (read from the .kt file) = <PascalName>Module (the Module suffix avoids the reserved-name denylist — §3/§4) — what the wrap host resolves as NativeModules.<nativeModule> |
PenInputModule |
receivers[].methods |
every @ReactMethod fun <m>( name in the module — the set of Method values the host may dispatch |
["capturePenInput"] |
The shipped .ppmplugin bundle is native-only — there is no TS INativeExtension / handleMessageAsync / sendAsync layer inside the bundle. (That bundle rule is unchanged and enforced by /audit-ppmplugin.) The companion PCF, however, is deployed separately (via pac pcf push, NOT part of the bundle) and dispatches into the native module through the host-injected window.PowerApps.NativeExtension.sendAsync global — NOT cordova.exec.
At boot, the wrap runtime reads every injected manifest.json and, for each receivers[] entry, registers a proxy under the composite key <name>/<receiver.name>. When the PCF calls sendAsync(key, …), the host global carries the message into the host context and the proxy dispatches into the customer's native module:
PCF (Canvas WebView sandbox)
→ window.PowerApps.NativeExtension.sendAsync("<name>/<receiver>", { method, args: [request] })
→ host global (runs INSIDE the host context): cordova.exec("SendMessagePlugin", "<name>/<receiver>",
[JSON.stringify({ method, args }), corrId])
→ proxy: JSON.parse(body) → NativeModules[<nativeModule>][<method>].apply(mod, parsed.args)
⚠️ The PCF must NOT callcordova.execdirectly. The rawcordovaglobal is not exposed to the PCF sandbox —cordovaisundefinedthere, so a directcordova.exec(...)throws aReferenceErrorthat the control's own try/catch swallows: the tap does nothing, with no error on screen (worst on Android, where the global is absent entirely).cordova.exec("SendMessagePlugin", …)is the real underlying transport, but it only works inside the host's own JS context — which is exactly whatsendAsyncbridges to. One PCF drives both iOS and Android through this same global; there is no platform branch.
Verified end-to-end on a physical iOS device (Face ID unlock) and with the DeviceTools sample on Android. The dispatch contract in this section is the public reference for the companion PCF and bundle.
- Composite routing key =
<manifest.name>/<receivers[].name>(e.g.pen-input/PenInputExtension). The PCF binds this as itsReceiverKey.namemust be globally unique across every plugin in the wrapped app — the injector keys the manifest intoplugins/<name>/, so two plugins sharing anameclobber each other. - Envelope (the
sendAsyncpayload) = a RAW{ method: string, args: unknown[] }object — the PCF does NOT stringify it.sendAsyncperforms theJSON.stringifyinternally (inside the host context) before handing it to the proxy, which reads it back viaJSON.parse(...). A PCF that pre-stringifies the payload double-encodes it → the proxyJSON.parseyields a string, not{method,args}→BRIDGE_FAILED. After parsing, the proxy runsArray.isArray(parsed.args) ? parsed.args : [], thenfn.apply(mod, args), spreading the inner array as positional arguments to the native method.⚠️ InnerargsMUST be a JSON array. Ifargsis a bare object,Array.isArrayfails, drops it to[], and the method is called with no request data — silent payload loss. Our convention:args: [request]— a single-element array carrying one request object, and the native method takes exactly oneReadableMap(Android) /NSDictionary(iOS) first parameter (then thePromise/resolver). A no-arg op usesargs: [{}].⚠️ Two distinct, equally-fatal PCF mistakes: (a) callingcordova.execdirectly instead ofsendAsync→ nothing dispatches (silent, esp. Android); (b) innerargsnot an array → silent empty call. Both pass every structural check and only surface on the first device tap. The PCF skeleton in/generate-pcf-companionand the/audit-ppmpluginPCF-transport checks guard these.
- Method =
envelope.method— must be the name of a real@ReactMethod(Android) /RCT_EXPORT_METHOD(iOS) on the module (unknown →method '<m>' not found). Note the proxy does not consultreceivers[].methodsat runtime;methods[]is the upload allowlist and is still required in the manifest (see §4). - nativeModule =
receivers[].nativeModule= the module's registered name (AndroidgetName()/ iOS+moduleName);NativeModules[<nativeModule>]missing →native module '<x>' not loaded(DEX/framework didn't load, or iOS usedRCT_EXPORT_MODULEinstead of+moduleName— see §5/§5b). - Return = the native method resolves a value; the proxy returns a string as-is and
JSON.stringifys anything else, then the bridge transport re-serializes. So resolve a scalar string or a JSON string (promise.resolve(json.toString())).sendAsyncresolves{ status, data?, error? }: onstatus === "ok",datais that native string; the host parses one layer, but the wrap host both re-stringifies it and nests it in a{ isUpdate, message }transport container, so the PCF peels it withextractResponse(below) — which parses AND unwraps themessagecontainer — not a single bare parse. - Response shape (convention). The module resolves a two-level object
{ status, result?, error?, message? }:status—"ok"|"error".result— the success payload (present whenstatus === "ok").error— a machine-readable code string (present whenstatus === "error"); the PCF branches on it.message— a human-readable failure reason (present whenstatus === "error"): the exception text, the offending field, the denied permission. The PCF surfaces it as itsErrorMessageoutput. This is critical for a third-party control: the binary runs inside the customer's wrap shell with no logcat / Xcode console / native debugger reachable, so a bare code with no message is undebuggable in the field. The nativeerrorJson(code, message)helper builds this via the platform JSON serializer (NSJSONSerialization/JSONObject) so a message containing quotes/newlines can't corrupt the JSON (which would surface as a misleadingPARSE). Every native error path resolves with BOTH a code and a message.
The PCF transport is
sendAsync, the same on both platforms. The companion PCF callswindow.PowerApps.NativeExtension.sendAsync("<name>/<receiver>", { method, args: [request] }). The.ppmpluginhas no TypeScript layer; the host proxy spreadsargspositionally into the native module. The wrap PCF is NOT platform-specific — one PCF drives iOS and Android, while the host provides the platform-specific bridge and React Native module resolution.
result.datais re-stringified AND wrapped in a transport container — unwrap it, don't just parse once. Onstatus === "ok",result.datais the native method's resolved value, but two things happen to it on the way back through the wrapSendMessagePlugintransport: (1) it may be re-stringified once, and (2) the module's JSON is nested (still stringified) under amessagekey. The confirmed on-device raw response for a successful call was:A single bare{"isUpdate":false,"message":"{\"status\":\"ok\",\"result\":{\"isOn\":false,\"isAvailable\":true}}"}JSON.parse(or a bare multi-layerdeepParse) lands on{isUpdate, message}— an object with no top-levelstatus→ the PCF falls through toUNEXPECTED_PAYLOADeven though native succeeded. The real{status, result}is still stringified undermessage. The PCF must unwrap the container withextractResponse— parseresult.data, and if the parsed object has no top-levelstatus, probemessagefirst (the confirmed key) then defensive fallbacks, deep-parsing the nested value and accepting the first that has a top-levelstatus:function extractResponse(raw: unknown): unknown { const top = deepParse(raw); if (top && typeof top === "object" && "status" in top) return top; if (top && typeof top === "object") { for (const k of ["message", "result", "data", "value", "response", "body", "payload"]) { if (k in (top as Record<string, unknown>)) { const inner = deepParse((top as Record<string, unknown>)[k]); if (inner && typeof inner === "object" && "status" in inner) return inner; } } } return top; // fall through — UNEXPECTED_PAYLOAD surfaces the raw string for diagnosis }
deepParsehere is a bounded parse-if-string helper (a few passes), NOT the retired blind 4-layer transport walk —extractResponse's job is the container unwrap, and it returnsresult.datadirectly when it already has a top-levelstatus(the simple already-unwrapped case the Face ID PCF hit), so it is a strict superset of a single guarded parse. The/generate-pcf-companionskeleton emitsextractResponseas the standard success handler, and/audit-ppmplugin(pcf-response-unwraps-message) checks a sibling PCF uses it (not a bare parse).- Handle
NOT_IN_WRAP. Ifwindow.PowerApps?.NativeExtensionis missing (Studio preview, non-PAM host, or CordovaV2 disabled), reject with a clearNOT_IN_WRAPrather than a rawundefinedaccess — this is expected in the Studio designer and should show a friendly state, not a crash. - Provide an on-device diagnostic. On a release wrap build the WebView console is not reachable from logcat /
chrome://inspect. So a wrap PCF should always expose oneusage="bound"text output that surfaces the raw bridge response (e.g.Self.<name>Json) — the maker can drop it on a Power Fx label and read what actually came back without a connected debugger. (This is the one legitimate use ofusage="bound"in an otherwise output-only control.)
When a release-mode WebView suppresses console.log and the app isn't debuggable (run-as fails), confirm the exact dispatch shape the wrap shell uses — and that your bundle loaded — by reading the installed APK directly. This pinpointed the raw-object-envelope bug above in ~10 minutes:
PKG=<wrap-package> # e.g. com.microsoft.…
adb shell pm path "$PKG" # → APK path on device
adb pull <that-path> wrap.apk
unzip -l wrap.apk | grep '\.js$' # find the host bridge bundle
unzip -p wrap.apk <bundle.js> > bundle.js
grep -oE 'cordova\.exec\([^)]*"SendMessagePlugin"[^)]*' bundle.js # confirm the host-side transport
grep -oE 'PowerApps[^;]*NativeExtension[^;]*sendAsync' bundle.js # confirms the host injects the sendAsync global your PCF calls
unzip -l wrap.apk | grep 'assets/plugins/<name>/' # confirms YOUR manifest.json + DEX were injected/loadedThe cordova.exec("SendMessagePlugin", …) line lives in the host bundle (that's where the transport legitimately runs) — it must NOT appear in your PCF bundle. If your PCF did nothing on device, confirm (a) the host injects window.PowerApps.NativeExtension.sendAsync (grep above), (b) your PCF calls that global and does not call cordova.* directly, and (c) your manifest+DEX were injected. (This is documented here rather than as a skill; if the adb→grep flow becomes routine it's a good candidate for a future /diagnose-wrap-bridge helper.)
The validator (§4) computes the canonical prefix precisely as:
Split
nameon-and_; PascalCase each segment (uppercase its first character); join. That's the canonical prefix. Everyreceivers[].nativeModuleMUST start with it (Ordinal, case-sensitive).
The separators matter: hello-world → HelloWorld, contoso-camera → ContosoCamera, but helloworld (no separator = one segment) → Helloworld — lowercase w, so a nativeModule of HelloWorld would be rejected. Hyphens/underscores in name are what produce the internal capitals.
The native module's nativeModule is <className>Module — the Module suffix dodges the reserved-name denylist (bare generic names like DeviceInfo are Microsoft-reserved; DeviceInfoModule is not — see §4). The canonical prefix must be a prefix of nativeModule, which is guaranteed when name is derived from the class name, kebab-cased:
name = kebab(className) canonicalPrefix(name) === className, nativeModule = className + "Module" starts with it ✓
kebab() MUST insert a hyphen at every camelCase boundary (PenInput → pen-input, not peninput). Otherwise the round-trip breaks: peninput → canonical prefix Peninput ≠ PenInput → validator rejects it.
PenInput→name: pen-input→ prefixPenInput→nativeModule PenInputModulestarts withPenInput✓PdfViewer→name: pdf-viewer→ prefixPdfViewer→nativeModule PdfViewerModulestarts withPdfViewer✓- ❌ Using the capability name
pdf-control→ prefixPdfControl, which is NOT a prefix ofPdfViewerModule→ validator rejects the upload.
/generate-ppmplugin-manifest MUST derive name from the class name and, if the capability name (repo/npm name) differs from kebab(className), surface a one-line note so the user understands the .ppmplugin name won't match the repo name.
/generate-ppmplugin-manifest runs these plugin-maintained checks locally as a pre-flight gate
so common manifest failures surface before a build. The upload service can apply additional checks.
| Rule | Check | Failure message |
|---|---|---|
name shape |
^[a-z0-9][a-z0-9-]{0,63}$ — lower-case kebab |
"must be a lower-case vendor-style identifier" |
| Canonical-prefix | each receivers[].nativeModule starts with the canonical prefix of name (split on -/_, PascalCase each segment, join — see §3) (Ordinal, case-sensitive) |
"nativeModule '…' must start with the plugin's canonical prefix '…'" |
| Reserved prefixes | nativeModule must NOT start with (case-insensitive): Microsoft, MS, Intune, MAM, Adal, Msal, Wrap, Pcf, PowerApps, Dataverse, MDL, Office, OneDrive, Teams, Sharepoint, SharePoint, Exchange, AzureAD, AAD, Graph |
"uses reserved prefix '…'" |
| Known incompatible names | nativeModule does not match the locally maintained subset below. A match is rejected before build. This subset is non-exhaustive, so the upload service may still reject another conflicting name. |
"uses a known incompatible native module name" |
| Methods required | receivers[].methods present + non-empty |
"must declare a non-empty 'methods' array" |
| Method-name shape | ^[a-zA-Z_$][a-zA-Z0-9_$]{0,127}$, ≤32 per receiver |
"is not a valid method-name identifier" / "exceeds the maximum" |
| Receiver-name shape | same JS-identifier regex as methods | "receivers[i].name is missing or contains unsafe characters" |
| No SDK-era / JS-layer fields (local policy, NOT a server rule) | the manifest carries NONE of entrypoints.js, entrypoints.ts, extension.js, extension.hbc, extensionClassName, jsLayer. The server accepts entrypoints.js (the injector treats it as optional); we reject it so the native-only bundle stays unambiguous — a WARNING, not an upload blocker |
"manifest carries SDK-era field '…' — the .ppmplugin is native-only" |
These checks intentionally avoid claiming complete parity with the upload service. Treat the reserved-prefix list as authoritative for this plugin and the exact-name subset as a fast local collision check.
These known incompatible bare names are checked locally as a hard pre-flight block so common collisions surface before a build (the list is non-exhaustive):
DeviceInfo, AuthenticationHelper, NetworkClient, DataverseOfflineProvider, IntuneMAM
Why
DeviceInfocollides — and how to avoid the whole class. The denylist holds bare, generic names (DeviceInfo,NetworkClient, …) — exactly what a third-party author reaches for first. The structural fix the wrap reference uses is aModulesuffix on thenativeModule(DeviceInfo→DeviceInfoModule): the suffixed name isn't in the bare-name namespace, so it sidesteps the denylist entirely. When a derivednativeModulematches a reserved name, the fix is to rename the module's registered name (AndroidgetName()/ iOS+moduleName) to a non-reserved form — addingModule, or a vendor prefix (ContosoDeviceInfo) — and updatereceivers[].nativeModuleto match.
The runtime reads entrypoints.android.{dex,packageClass}, loads the DEX with DexClassLoader, then instantiates packageClass through a public no-argument constructor. That imposes constraints a standard linked React Native package does not:
- No
@ReactModuleannotation on the module — it requires static symbols not visible toDexClassLoader-loaded code. (The current scaffold already omits it; the build skill asserts it.) - The
ReactPackage(packageClass) MUST have a public no-arg constructor. The loader instantiates it viagetDeclaredConstructor().newInstance()— aReactPackagewhose only constructor takes arguments throwsNoSuchMethodExceptionand the plugin silently fails to load (Loaded 0 plugin package(s)). The module itself may takeReactApplicationContext(theReactPackage.createNativeModules(reactContext)supplies it) — but the package class must be no-arg. The build skill asserts this. - Module construction runs eagerly at bridge startup, on an arbitrary thread — it MUST NOT throw. After
newInstance(), the player callscreateNativeModules(reactContext), which constructs your module immediately — before any UI, before any method call — on whatever thread starts the bridge, frequently one with no preparedLooper. A module constructor / Kotlininit{}that does side-effecting work and throws does so uncaught, inside a constructor → the whole host process crashes at launch. Two classic triggers: (a)register*Callback(cb, null)or a bareHandler()— anull/implicitLoopermakes Android throwRuntimeException: Can't create handler inside thread that has not called Looper.prepare(); (b) any un-try/caught I/O, file read, or hardware-manager acquisition in the constructor. The scaffold's rules: construct cheaply — defer listener/hardware registration to the first method call (lazy); pass an explicitHandler(Looper.getMainLooper()), nevernull; wrap any unavoidable constructor side-effect in try/catch so a subsystem hiccup can't take down the app. iOS analogue: keep+requiresMainQueueSetup=NOand do no throwing/heavy work ininit(instantiated eagerly via[cls new])./generate-native-extensiongenerates modules this way;/test-native-extensionLayer 0 +/audit-ppmpluginCategory F lint for the crash pattern. - React Native is
compileOnly— resolved at runtime by the wrap shell, never bundled. Pin the coordinate:compileOnly "com.facebook.react:react-android:<rnVersion>"(read<rnVersion>frompackage.jsondevDependencies — pen-input pins0.79.7). module.getName()MUST equalreceivers[].nativeModule.- The
ReactPackageFQN MUST equalentrypoints.android.packageClass. - Optional native
.solibraries — if the module has JNI dependencies, place them atandroid/lib/<abi>/*.soin the bundle (<abi>matching^[a-z0-9]+(-[a-z0-9_]+)*$, e.g.arm64-v8a). The injector copies each ABI dir to the APK'slib/<abi>/. Most controls have none. - Build path:
./gradlew :<module>:assembleRelease→ AAR → extractclasses.jar→d8 --min-api 24→classes.dex→ rename to<PascalName>Plugin.dex.d8warnings of the formType com.facebook.react.* was not foundare expected and benign (RN iscompileOnly).
Under the managed host build the extension is a subproject: the host supplies rootProject.ext (compileSdk, minSdk, kotlin_version) and React Native on the classpath. The standalone ppmplugin build has neither, so /build-android-binary builds from a throwaway copy (ppmplugin/staging/android-build/) with these pins — the canonical android/build.gradle is never edited:
| Setting | Pinned value | Why |
|---|---|---|
compileSdkVersion |
35 | RN 0.79's react-android AAR is compiled against compileSdk 35, so anything that compileOnly-depends on it must compile against ≥35 (AGP errors otherwise). This also clears the com.google.android.material:1.11.0 → v34-resources floor. |
| Android platform | platforms;android-35 (must be installed) |
compileSdk 35 needs the android-35 platform. Install via sdkmanager "platforms;android-35". |
| Build-Tools | 35.0.0 | Provides d8 + an aapt2 compatible with compileSdk 35. |
| AGP | 8.8.2 (canonical, matches RN 0.79) | RN 0.79's react-android AAR carries AGP-8 metadata that AGP 7.x cannot consume, and only AGP 8.x can compile against android-35. |
| Kotlin | 1.9.25 | The module applies kotlin-android, so the staging copy's plugin classpath must resolve 1.9.25 (align with AGP 8.8.2 — don't let a newer Kotlin bundled by a different AGP collide). |
| Gradle (wrapper) | 8.13 | The Gradle version RN 0.79 pairs with AGP 8.8.2. The staging copy's wrapper is pinned to 8.13 so the build never uses a too-old system gradle. Preferred: the skill ships a pre-generated gradle/wrapper/ (jar + properties pinned to 8.13) into the staging copy so the first ./gradlew invocation downloads 8.13 — no system-Gradle bootstrap needed. The properties MUST also carry distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 (the value Gradle publishes at …/gradle-8.13-bin.zip.sha256), and gradle-wrapper.jar MUST match 81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f (the value Gradle publishes at https://downloads.gradle.org/distributions/gradle-8.13-wrapper.jar.sha256) before the first ./gradlew — that archive and that jar are both executed, so HTTPS alone is not sufficient. Both checks are fail-closed. |
gradle.properties (generated) |
android.useAndroidX=true, android.enableJetifier=false, org.gradle.jvmargs=-Xmx2048m |
the managed host build supplies these ambiently; standalone has none. Without useAndroidX=true the androidx deps (appcompat, core-ktx) fail resource linking. /build-android-binary generates this file in the staging copy. (Confirmed: the Android path is one-shot once gradle.properties is present.) |
Why compileSdk 35 specifically (not arbitrary): RN 0.79's react-android artifact is built against compileSdk 35, and AGP enforces that any module compiling against it uses compileSdk ≥ 35 — anything lower is a hard build error. AGP 8.8.2 is the matching plugin (RN 0.79 ships it) and it's the first family that can compile against android-35. compileSdk is the compile-against level only; runtime floor is minSdk 24 (d8 --min-api 24, matching the react-android AAR's own minSdk 24), so the plugin runs on Android 24+.
| React Native dep | compileOnly "com.facebook.react:react-android:<rnVersion>" | What /generate-native-extension now writes into the canonical android/build.gradle. The legacy implementation "...:react-native:+" form (still present in older controls) doesn't resolve standalone — it relied on the managed host build to supply React, the pre-0.73 coordinate is gone, and implementation would bundle RN instead of leaving it host-provided. Modern coordinate is react-android, on mavenCentral. <rnVersion> from package.json. |
Do not "fix" a standalone build failure by editing the canonical build.gradle (downgrading Material, pinning an older AGP, lowering compileSdk) — those degrade or destabilize the real source to satisfy a throwaway build. The copy-and-pin approach keeps the two concerns separate.
Built by /build-ios-binary — Mac-only (Xcode). Like Android, it builds from a throwaway staged Xcode project (ppmplugin/staging/ios-build/); the canonical ios/ + podspec are never edited.
- Output is a FLAT device-slice
<PascalName>Plugin.framework/, NOT an.xcframework(learned the hard way). The wrap pipeline expectsios/<Name>.frameworkand does not descend into an.xcframework— an.xcframeworkupload fails with "Framework '.framework' not found in plugin." Build the device archive only (-destination "generic/platform=iOS"→ios-arm64), then copy itsProducts/Library/Frameworks/<Name>.frameworkstraight toppmplugin/staging/ios/<Name>.framework. Drop the-create-xcframeworkstep. (Tradeoff: no simulator slice → no iOS-Simulator testing; revisit if the wrap pipeline ever grows simulator support.) - How the runtime loads it:
dlopen("Frameworks/<framework>.framework/<framework>", RTLD_NOW)thenNSClassFromString(moduleClass)→[cls new]. So:- The Mach-O binary inside the framework MUST be named exactly
<framework>(=entrypoints.ios.framework) —dlopenopens that path. moduleClassMUST respond to a no-arg[cls new](the defaultinit) and conform toRCTBridgeModule. A module whose only initializer takes arguments won't instantiate → the plugin is skipped at load.- Because the player
dlopens the binary directly (it does NOTimportthe module), a Swift/clang module map and umbrella header are NOT required at runtime — they're build-hygiene only (they silence theDEFINES_MODULEwarning and let other codeimportthe framework). Generate them if convenient, but their absence does not break loading. Do not treat them as upload blockers.
- The Mach-O binary inside the framework MUST be named exactly
Info.plistIS required — setGENERATE_INFOPLIST_FILE=YESplusINFOPLIST_KEY_CFBundleDisplayName=<Name>,MARKETING_VERSION=<version>,CURRENT_PROJECT_VERSION=1,PRODUCT_BUNDLE_IDENTIFIER=com.powerapps.<lowername>. A framework bundle with no Info.plist is invalid and the wrap codesign step rejects it. Required keys:CFBundlePackageType=FMWK,CFBundleExecutable=<Name>,CFBundleIdentifier,CFBundleShortVersionString,MinimumOSVersion.- Critical build settings (each → a documented on-device failure): Mach-O Dynamic Library (Static → "module not found"); Dead Code Stripping NO (preserves the
+moduleNameclass method +RCT_EXPORT_METHODmetadata the host reads afterdlopen); Defines Module YES; Enable Bitcode NO;BUILD_LIBRARY_FOR_DISTRIBUTION=YES;SKIP_INSTALL=NO. - React-Core: headers only, NEVER embedded — the framework compiles against React's headers (
#import <React/RCTBridgeModule.h>) from the control repo's own pinnedreact-nativedevDep and links React weakly (the wrap host provides it at runtime; embedding duplicates symbols and crashes the host). Recommended path — header-only, no CocoaPods: aggregate the React headers into a flatinclude/React/fromnode_modules/react-native/React/Base/+Libraries/, setHEADER_SEARCH_PATHS = $(inherited) $(SRCROOT)/include, andOTHER_LDFLAGS = -undefined dynamic_lookupfor the React-symbol weak-link. This is cleaner/faster than the Pod chain and avoids the CocoaPods failures below. The coupling that matters: the RN pin (0.79.7) must match the wrap host's RN — React symbol/header errors mean the pin diverged, not a code bug. (Known limitation: the weak-link config isn't yet validated against a live wrap host.)- Caveat (
-undefined dynamic_lookup) is deprecated by Apple on iOS — works today, may break in a future Xcode. Long-term: a real-weak_framework Reactonce there's a stable wrap-host RN to validate against.
- Caveat (
- CocoaPods fallback + known Xcode-26 / RN-0.79 breakages (only if you don't use the header-only path; all are environment-transient — versions will shift): boost podspec URL bitrot → point at
archives.boost.io; Yoga_ptliteral promoted to error →-Wno-deprecated-literal-operator; RCT-Follyclockid_tredefinition →FOLLY_HAVE_CLOCK_GETTIME=1; booststd::unary_functionremoved in C++17 →-D_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION=1on every Pods target. Apply these in apost_installhook. Under Xcode 26's tighter sandbox the React-Codegen script phase can also fail (FBReactNativeSpec-generated.mmnot produced) — another reason to prefer the header-only path. - xcodeproj-gem trap (if generating the project programmatically):
new_group('Sources','Sources')+new_reference("Sources/<file>")yields a double pathSources/Sources/<file>→ file-not-found. When the group already carries the path, add file refs filename-only. - Conformance (asserted before build):
.hclass name ==entrypoints.ios.moduleClass; the.mdeclares+ (NSString *)moduleNamereturningnativeModule(and does NOT useRCT_EXPORT_MODULE);RCT_EXPORT_METHODnames ⊆receivers[].methods. - Build path: generate the framework project →
xcodebuild archivedevice only (-destination "generic/platform=iOS", distribution flags) → copy…/Products/Library/Frameworks/<Name>.framework→ppmplugin/staging/ios/<Name>.framework/. Verify the two required items — the Mach-O binary named<Name>andInfo.plist— are present (the umbrella header +Modules/module.modulemapare nice-to-have build hygiene, not required by the loader). - Signing: none in this skill — the wrap pipeline signs at packaging time, provided the framework has an Info.plist and was built
BUILD_LIBRARY_FOR_DISTRIBUTION=YES SKIP_INSTALL=NO. - On-device failure → cause: "Native module not found" → static-not-dynamic / dead-code-stripping / used
RCT_EXPORT_MODULEinstead of+ (NSString *)moduleName(its+load/_RCTRegisterModuleis invisible todlopen's flat namespace);dyld @rpath … not loaded→ framework not embedded by the wrap pipeline; symbol-not-found at launch → React linked strongly instead of weakly.
- iOS framework build — now produced by
/build-ios-binary(Mac-only; known limitation: not yet validated against a live wrap host, and the RN pin must match the wrap host's RN). See §5b. - Upload / wire-into-canvas (Stage 3 of the how-to:
msdyn_nativeextensionPOST + blob PATCH, wrap wizard, PCF wiring) — deferred. The third-party-control build stops at a verified.ppmpluginfile on disk. - Code signing — Android DEX needs none. iOS may; TBD with the wrap pipeline.
- No TS / JS layer in the bundle — the
.ppmpluginships native binaries only (manifest.json+android/+ios/). The TSINativeExtension/handleMessageAsynclayer is NOT shipped; dispatch is manifest-driven straight toNativeModules.<nativeModule>.<method>via the wrap proxy adapter (see Runtime dispatch contract in §2). Our native-only policy is to carry no TS/JS at all:/audit-ppmpluginflagsentrypoints.ts/extension.js/extension.hbc/asrc/tree/jsLayer/extensionClassNameandINativeExtension/sendAsyncsymbols in the DEX. Note this is our cleanliness rule, not a server requirement — the wrap injector actually treatsentrypoints.jsas optional and the player silently ignores a stray JS layer or SDK symbols (an old-pattern plugin still dispatches if itsreceivers[]map to real native modules). We reject them to keep the bundle unambiguous, not because upload would fail.