generate-pcf-companion について
generate-pcf-companionは、既存のネイティブモジュールと連携するPower Apps Component Frameworkコントロールを生成するスキルです。コミット済みのmanifest.jsonから呼び出し情報を取得し、ローカルビルドで生成物を確認します。
主な機能
- 01PCFコントロールのスキャフォールド生成
- 02コミット済みmanifest.jsonの読み取り
- 03sendAsync経由の呼び出しルーティング
- 04PCFのライフサイクルとマニフェストの確認
- 05ローカルビルドのスモークチェック
活用シーン
- 01既存のネイティブモジュールへのCanvas Studioコントロール追加
- 02PCF操作とネイティブメソッドの接続
- 03デプロイ前の生成PCFの確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ microsoft/ power-platform-skills --skill generate-pcf-companionインストール後、Claude Code で/generate-pcf-companionを入力して読み込まれたか確認します。
詳細
/generate-pcf-companion
ディスパッチャー PCF を生成します。これは、ホストから注入される window.PowerApps.NativeExtension.sendAsync グローバルを通じてサードパーティのネイティブモジュールを呼び出す Canvas Studio コントロールです。コミット済みの ./manifest.json(足場作成時に /generate-native-extension が作成する信頼できる情報源)から読み込んだ複合キー <name>/<receiver> によってルーティングします。同じリポジトリ内のネイティブモジュールの隣にある pcf/<Pascal>PCF/ に配置されます。PCF は Studio 側のコンパニオンであり、.ppmplugin バンドルには含まれません(バンドルに含まれるのはネイティブバイナリのみです。つまり manifest.json と android//ios/ です)。
このスキルは、ネイティブモジュールがすでにリポジトリに存在することを前提とします。モジュールを配置してから実行してください。
PCF フレームワークのリファレンス(Microsoft Learn の公開ドキュメント)。
pac pcf init、ControlManifest.Input.xmlスキーマ、init/updateView/getOutputs/destroyのライフサイクル、およびusage(bound/input/output)のルールは、このスキルのテンプレートと実際のフレームワークが異なる場合に正しい判断基準となる、公式の Power Apps Component Framework ドキュメントに基づいてください。(sendAsyncトランスポートとextractResponseによるレスポンスのラップ解除の詳細は、このトラック独自のものであり、一般的な PCF ドキュメントには記載されていません。詳細はshared/ppmplugin-format.md §2を参照してください。)
- 概要:https://learn.microsoft.com/en-us/power-apps/developer/component-framework/overview
- コードコンポーネントを作成する:https://learn.microsoft.com/en-us/power-apps/developer/component-framework/create-custom-controls-using-pcf
- カスタムコントロールの概要:https://learn.microsoft.com/en-us/power-apps/developer/component-framework/custom-controls-overview
ステップ 1 — 共通ドキュメントと PRD を読む
-
shared/shared-instructions.md、shared/naming-conventions.md、shared/ppmplugin-format.md、shared/repo-layout.mdを読みます。 -
スキルごとの最小限の前提条件ポリシー(
shared-instructions.md §1.5)を適用します。このスキルで必要なのは Node とpacCLI のみです。pac pcf initはローカルのファイル生成ツールであり、pcf/内でのnpm install/npm run buildに必要なのは Node のみです。pnpm、パッケージフィードの認証、.NET SDK ランタイム、有効なpac authは必要ありません。続行する前に、
shared-instructions.md §9.2に従って、前提条件の状態を目に見えるブロックとして出力してください。━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Prereq check — /generate-pcf-companion ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🟢 ✓ Node 20+ installed (for npm install + tsc under pcf/) 🟢 ✓ pac CLI installed (for pac pcf init) 🟢 2 checks passed. Ready to proceed.pacがない場合は、修正コマンド(dotnet tool install -g Microsoft.PowerApps.CLI.Tool)を示して停止します。pacのインストールには一度だけ .NET SDK が必要ですが、足場作成時の実行時には .NET もpac authも必要ありません。Node がない場合は、インストール手順を示して停止します。prereq-check.mdの**/generate-pcf-companionチェック**(Node とpacのみ。この自己完結型トラックには「baseline」チェックはありません)を実行してください。ここでは .NET SDK と有効な
pac authはチェックしません。 ユーザーが後のステップ 2 で任意の「はい、今すぐデプロイする」手順を選んだ場合、デプロイの前提条件を確認するワンライナーは、その時点で(pac pcf pushの直前に)実行します。 -
./PRD.mdを読みます。ファイルがない場合、または §8(PCF サーフェス)が不完全な場合(§8.1~§8.4 に<NEEDS INPUT>がある、もしくは項目が欠けている場合)は、BLOCKED: PRD.md §8 PCF surface is incomplete — re-run /design-native-extension-feature and complete the PCF section.と表示して停止します。 -
./.extension-state.mdを読みます。Phase がscaffold以前の場合は、BLOCKED: run /generate-native-extension first.と表示して停止します。
このスキルが出力する必要のある構造パターン(マニフェストの形状、index.ts のブリッジ配線、出力マッピング)は、この SKILL.md(§4~§5)と shared/ppmplugin-format.md §2(実行時ディスパッチ契約)で完全に規定されています。実行時に参照用拡張機能のリポジトリを取得しないでください。そこから得られる知見はすでにここに記載されており、取得すると参照先固有の UI ロジックが無関係な PCF に混入するおそれがあります。
ステップ 1.5 — ./manifest.json とネイティブモジュールからディスパッチ契約を確認する
wrap のランタイムディスパッチ契約(shared/ppmplugin-format.md §2)は、ホスト呼び出しがバンドルに到達する方法についての正式な仕様です。.ppmplugin バンドルはネイティブのみ(バンドル内に TS の handleMessageAsync レイヤーはありません)ですが、コンパニオン PCF はホストから注入される window.PowerApps.NativeExtension.sendAsync グローバルを介してディスパッチします。cordova.exec を直接呼び出しては絶対にいけません(生の cordova グローバルは PCF サンドボックスには公開されていません。直接呼び出すとデバイス上で何も起きず、特に Android では問題が深刻です)。sendAsync はホストコンテキスト内で基盤となる cordova.exec("SendMessagePlugin", …) トランスポートを実行し、バイナリに含まれる React Native モジュールへルーティングします。
PCF → window.PowerApps.NativeExtension.sendAsync("<name>/<receiver>", { method, args: [request] })
→ host global (host context): cordova.exec("SendMessagePlugin", "<name>/<receiver>", [JSON.stringify({method,args}), corrId])
→ proxy → NativeModules[<nativeModule>][<method>].apply(mod, <args-array>)
複合ルーティングキー <name>/<receiver> によってホストがモジュールを解決します。メソッドは、その receiver の methods[] に含まれるエントリーの 1 つです。1 つの PCF がこのグローバルを介して iOS と Android の両方を制御します。プラットフォーム分岐はありません。
⚠️ 2 つの不変条件 — どちらも実機で確認済みです。どちらかを誤ると何も起きません:
- ディスパッチには
sendAsyncを使い、cordova.execは絶対に使わないでください。 エンベロープは RAW オブジェクト{ method, args: [request] }であり、PCF はこれを文字列化しません。JSON.stringifyはsendAsync内部で実行されます。cordova.execを直接呼び出す PCF や、ペイロードを事前に文字列化する PCF は、デバイスで最初にタップしたときに何も起きません(画面にエラーは表示されず、ディスパッチもされません。特に Android では問題が深刻です)。- 内部の
argsは JSON 配列でなければなりません(ppmplugin-format §2)。 エンベロープの解析後、プロキシはArray.isArray(parsed.args) ? parsed.args : []を実行し、続いてfn.apply(mod, args)を呼び出して、位置引数として展開します。単独のオブジェクトは破棄され、ネイティブメソッドにはリクエストデータが渡りません。規約:args: [request]— リクエストオブジェクトを 1 つ渡し、ネイティブメソッドの最初のパラメーターとしてReadableMap/NSDictionaryを 1 つだけ受け取ります。
status === "ok" の場合、sendAsync は result.data(ネイティブメソッドが解決した文字列)を返します。wrap ホストはこれをもう一度文字列化し、さらに { isUpdate, message } トランスポートコンテナーにネストするため、PCF は extractResponse ヘルパーで正規化します(result.data を解析し、解析されたオブジェクトにトップレベルの status がない場合は、message コンテナーを展開してモジュールの {status, result} オブジェクトに到達します。すべて失敗した場合は PARSE エラーになります)。1 回だけ解析するとコンテナーで止まってしまい、ネイティブ側で成功していても、すべての呼び出しが UNEXPECTED_PAYLOAD で失敗します。shared/ppmplugin-format.md §2 を参照してください。status !== "ok" の場合は result.error を通知し、なければ BRIDGE_FAILED にフォールバックします。ホストグローバルがない場合は NOT_IN_WRAP です。
必須の読み込み — ステップ 2 の前に必ず成功させること
-
コミット済みの
./manifest.jsonから複合ルーティングキー<name>/<receiver>を解決します。これは/generate-native-extensionがスキャフォールド時に書き込む正とする情報源であり、通常のフローではこのスキルの実行時にすでに存在しています。これを優先し、ステージ済みのコピー、さらに manifest がまだ存在しない場合(手書きのモジュール)のみ ARCHITECTURE を順に使用します。OS に依存しない方法で、Read ツールを使って./manifest.jsonを読み込み、JSON を直接解析してください。grep/sedをシェルから実行しないでください(以下の bash は例示であり、Windows では動作しません):MANIFEST=$( [ -f ./manifest.json ] && echo ./manifest.json || echo ppmplugin/staging/manifest.json ) if [ -f "$MANIFEST" ]; then NAME=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$MANIFEST" | head -1 | sed 's/.*"\([^"]*\)"$/\1/') echo "manifest ($MANIFEST) name: $NAME — receiver/nativeModule/methods read from receivers[]" else echo "no manifest.json yet (hand-authored module) — derive <name>=kebab(className), <receiver>=<Pascal>Extension, nativeModule=<className> from ARCHITECTURE; /generate-ppmplugin-manifest will author it" fiPCF がバインドするディスパッチキーと、manifest が登録する receiver は一致していなければなりません。PCF が
<name>/<receiver>をディスパッチする一方で、manifest が別の receiver を登録していると、最初のディスパッチで失敗します。./manifest.jsonはこのスキルの実行前(native-gen 時)に作成されるため、PCF は manifest の receiver に従います。宣言されているreceivers[].nameに正確にバインドしてください。 -
manifest.jsonのreceivers[]を読み込みます(正規のディスパッチ先):receivers[].name— 複合キーの<receiver>部分receivers[].nativeModule— ホストがNativeModules.<nativeModule>として解決する値(モジュールのgetName())receivers[].methods— ホストがディスパッチ可能なMethodの値。それぞれ実在する@ReactMethod/RCT_EXPORT_METHODの名前です。PCF のonTriggerはこれらのいずれかを呼び出します。
-
ネイティブソース(検証のみ —
ios/RCT<Pascal>Module.m、android/src/main/java/.../<Pascal>Module.kt):- Android の
getName()/iOS の+ (NSString *)moduleNameはreceivers[].nativeModuleと一致していなければなりません - PCF がディスパッチするすべての
methodは、モジュール上の実在する@ReactMethod/RCT_EXPORT_METHODでなければなりません(不明なメソッドの場合、デバイス上でmethod '<m>' not foundが発生します) - ネイティブ側と manifest が食い違っている場合は、
NEEDS_CONTEXT: native module drifted from manifest.json receivers[]; reconcile and re-runとして停止します
- Android の
解決済みコントラクトの構成
Transport (host global — fixed):
window.PowerApps.NativeExtension.sendAsync("<name>/<receiver>", { method, args: [request] })
↑ envelope is a RAW object; sendAsync stringifies it internally
result.status === "ok" → extractResponse(result.data) yields the module's response object (unwraps the wrap `message` container)
result.status !== "ok" → bridge/transport failure (result.error ?? BRIDGE_FAILED); parse-fail → PARSE
no window.PowerApps.NativeExtension → NOT_IN_WRAP (Studio preview / non-PAM host / CordovaV2 off)
Dispatch target (from manifest.json receivers[]):
Composite key: <name>/<receiver>
nativeModule: <NativeModules.<nativeModule>>
method: <one of methods[]>
args: [request] — a JSON ARRAY (spread positionally via fn.apply). Our convention: ONE request
object at args[0]; the @ReactMethod / RCT_EXPORT_METHOD takes one ReadableMap/NSDictionary param.
Response shape: <list — module's own {status, result, error, message}> (message = human-readable failure reason)
Module error codes: <list — USER_CANCELLED, INVALID_INPUT, ...> (canonical set + meanings: shared/error-codes.md)
Verification (native source):
All checks: <pass | fail with mismatch>
食い違いの検出
| 不一致 | 対応 |
|---|---|
manifest.json receivers[] とネイティブソースで nativeModule/メソッド名が一致しない | NEEDS_CONTEXT として停止します。不一致の内容を列挙し、PCF の生成前に解消してください。 |
PCF の複合キー <name>/<receiver> と manifest に登録された receiver が一致しない | PCF と manifest のキーは一致していなければなりません。./manifest.json は先に(native-gen 時に)作成されるため、PCF は manifest に従います。宣言されている receivers[].name にバインドしてください。(ユーザーが意図的に PCF の receiver 名を変更する場合に限り、./manifest.json も一致するよう更新して、再実行してください。) |
ホストの sendAsync のペイロード/レスポンスのワイヤーフォーマットと、このスキルが出力するものが一致しない | 正確なエンベロープはホストのグローバルと wrap プロキシが管理します。shared/ppmplugin-format.md §2 を確認してください。PCF が保証するのは、ブリッジが最終的に対象とする複合キーと method のみです。 |
ステップ 5 で PCF のディスパッチ引数とレスポンス解析の情報源として使うのは、PRD の §5.1/§5.2 ではなく、解決済みコントラクトです。PRD が示すのは意図であり、実際のディスパッチコントラクトは ./manifest.json とネイティブソースで決まります。両者が一致している場合は 3 つすべてが整合しています。一致していない場合は、実行中のアプリが参照するネイティブソースを優先します。
ステップ 2 — 計画をユーザーに確認する
ARCHITECTURE §6 と導出した名前に基づく概要を表示し、承認を求めます。
PCF scaffold plan
─────────────────
Folder: pcf/<Pascal>PCF/
Namespace: PowerApps (constant for all native-extension PCFs)
Control name: <Pascal>PCF
Dispatches: composite key '<name>/<receiver>' → NativeModules.<nativeModule>.<method>
via window.PowerApps.NativeExtension.sendAsync (host global)
Bound input (ARCHITECTURE §6.1 (bound input)):
<Name> : <Type> <— bound, required>
Configurable inputs (ARCHITECTURE §6.1 (configurable inputs)):
<Name> : <Type> = <default> <— purpose>
...
Output properties (ARCHITECTURE §6.1 (output properties)):
<Name> : <Type> <— purpose>
...
Trigger (ARCHITECTURE §6.2): <one line>
AskUserQuestion を使用します:
この PCF のスキャフォールドを進めますか?
- はい —
pac pcf initを実行し、ファイルを作成/書き換え、npm installとnpm run buildでスモークチェックを実行します。すべてローカルで行われ、環境にはデプロイされません。- 先に PRD を編集する — 終了します。ユーザーが
/design-native-extension-featureを実行して §8 を修正します。- キャンセル
Power Platform 環境へのデプロイは、必要に応じて別の手順として /publish-pcf-companion から実行します。このスキルはローカルでのみ動作します。pac auth には触れず、pac pcf push も呼び出さず、.NET SDK も必要ありません。
ステップ 3 — pac pcf init を実行する
リポジトリのルートで:
mkdir -p pcf
cd pcf
pac pcf init --namespace PowerApps --name <Pascal>PCF --template field --framework none
フラグに関する注意事項:
--namespace PowerApps— 固定値です。すべてのネイティブ拡張 PCF でこの名前空間を共有し、Canvas Studio の[挿入]パネルでまとめて表示します。--template field— 単一のバインド値を扱うコントロールです。「作成者が設定した入力に対してネイティブ操作をトリガーする」パターンに合致します。v0 ではdatasetを使用しないでください。--framework none— バニラ DOM です。React は使用しません。バンドルを小さく保ち、ホストのマネージドビルドに含まれる React とのバージョンの不整合を避けます。
pac pcf init は、次の構成で pcf/<Pascal>PCF/ を作成します。
<Pascal>PCF.pcfproj(MSBuild プロジェクト)package.json(PCF ツールの規約に従い npm を使用)pcfconfig.jsontsconfig.jsoneslint.config.mjs<Pascal>PCF/(入れ子)—ControlManifest.Input.xml、index.ts、PowerAppsNativeExtension.d.ts(ホストのグローバル宣言。ステップ 5.5 で出力)、および(後ほど)generated/ManifestTypes.d.ts
pac pcf init が失敗した場合:
- 「pac not found」 → 前提条件の確認を再実行してください。Windows では
pwshのプレフィックスが必要な場合があります。 - 「folder already exists」 → 削除して再生成するか、マージするかを確認してください(手動編集が行われていない場合に限り安全です)。
- 認証関連 →
pac auth listを実行して、アクティブなプロファイルを提示してください。プロファイルがない場合はpac auth createを提案してください。
pac pcf init が成功したら、コントロールフォルダーの1階層上に pcf/README.md も作成します。セクションは次のとおりです。
- 概要 — PRD §1 に基づき、この PCF の機能を説明する1段落。
- npm tarball に含まれないもの — PCF フォルダーが
package.jsonのfiles配列から除外されていることを明記します。npm ではなくpac pcf pushを使用して Power Platform にデプロイします。 - プロパティ — ARCHITECTURE §6 に基づく3つの簡潔な表(バインド、設定可能、出力)。
- ビルドと反復 —
npm install、npm run build、pac pcf push --publisher-prefix <2–8 char prefix>(プレフィックスの選定については/publish-pcf-companionを参照)。 - トリガーの動作 — ARCHITECTURE §6.2 に基づく1行。
約50行に収めてください。すべてのセクションを PRD に合わせ、定型文を創作しないでください。
ステップ 4 — ControlManifest.Input.xml を書き換える
pac pcf init が生成するのは、単一プロパティのマニフェストです。ARCHITECTURE §6 に正確に一致するよう書き換えてください。
display-name-key と description-key には、人が読めるテキストを使用してください。 これらの属性は、作成者が Power Apps Studio のプロパティパネルで見る内容であり、単なる内部キーではありません。このスキャフォールドには .resx リソースファイルが含まれていないため、Studio には属性の値がそのまま表示されます。プログラマー向けのキーではなく、わかりやすいラベルや文章を書いてください。
⚠️ 厳守事項 — これらの属性にアポストロフィ(および未加工の
<>&)を使用しないでください。pac pcf pushは、display-name-key/description-keyがnoAposStringTypeである XSD に照らしてマニフェストを検証します。ASCII のアポストロフィ(')が含まれていると、noAposStringTypeの検証エラーでプッシュに失敗します。 また、未加工の XML メタ文字も問題を引き起こします。そのため、これらの文字列を作る際は、次の点に従ってください。
- 所有格や短縮形を使わずに言い換えてください。 たとえば、「the phone's flashlight」→ 「the device flashlight」/「the phone flashlight」、「doesn't」→「does not」、「user's」→「the user」とします。こうするのが最も自然です。
- 句読点をどうしても残す必要がある場合は、右シングル引用符の
’(U+2019) を使用してください。これは ASCII のアポストロフィではないため、XSD を通過します。ただし、できるだけ言い換えてください。&(&)、<、>はエスケープするか、使用しないでください。これらの属性には、プレーンな ASCII の文章を使用してください。- これは、マニフェスト内のすべての
display-name-key/description-key(コントロールと各プロパティ)に適用されます。記述後、マニフェストに'が含まれていないか確認してください。
導出ルール:
| 属性 | 値 |
|---|---|
<control display-name-key="..."> | PRD §2 の「Human-readable name」があればそれを使用します。なければ <Pascal>PCF をタイトルケースに変換します(例:BarcodeScannerPCF → Barcode scanner)。 |
<control description-key="..."> | PRD §1 の Summary を、約120文字の単一文に整えます。 |
<property display-name-key="..."> | プロパティの name を、空白を含むタイトルケースに変換します(例:PenColor → Pen color、SignatureBase64 → Signature base64)。 |
<property description-key="..."> | そのプロパティの ARCHITECTURE §6.1(バインド入力)/§8.2/§8.3 にある「Purpose」列の内容を使用します。 |
マニフェストの構造(プレースホルダーキーではなく、人が読める文字列に置き換えてください):
<?xml version="1.0" encoding="utf-8" ?>
<manifest>
<control namespace="PowerApps"
constructor="<Pascal>PCF"
version="0.0.1"
display-name-key="<human-readable name from PRD §2>"
description-key="<short summary from PRD §1>"
control-type="standard">
<property name="<BoundName>"
display-name-key="<title-cased BoundName>"
description-key="<Purpose from ARCHITECTURE §6.1 (bound input)>"
of-type="<Type>"
usage="bound"
required="true" />
<property name="<ConfigName>"
display-name-key="<title-cased ConfigName>"
description-key="<Purpose from ARCHITECTURE §6.1 (configurable inputs)>"
of-type="<Type>"
usage="input"
required="false"
default-value="<default>" />
<property name="<OutputName>"
display-name-key="<title-cased OutputName>"
description-key="<Purpose from ARCHITECTURE §6.1 (output properties)>"
of-type="<Type>"
usage="output" />
<property name="<name>Json"
display-name-key="<title-cased name> raw response"
description-key="Raw bridge response for on-device debugging — drop on a label as Self.<name>Json."
of-type="SingleLine.Text"
usage="bound" />
<resources>
<code path="index.ts" order="1" />
</resources>
</control>
</manifest>
例(PRD §2 と §8 にある実際の <Pascal> 名とプロパティ名に置き換えてください):
<control namespace="PowerApps"
constructor="<Pascal>PCF"
version="0.0.1"
display-name-key="<Human-readable name from PRD §2>"
description-key="<One-line description from PRD §1.>"
control-type="standard">
<property name="<InputPropertyName from ARCHITECTURE §6.1 (configurable inputs)>"
display-name-key="<Human-readable label>"
description-key="<One-line description>"
of-type="SingleLine.Text"
usage="input"
required="false"
default-value="<default from ARCHITECTURE §6.1 (configurable inputs)>" />
<property name="<OutputPropertyName from ARCHITECTURE §6.1 (output properties)>"
display-name-key="<Human-readable label>"
description-key="<One-line description>"
of-type="SingleLine.Text"
usage="output" />
...
</control>
よく見られる PCF プロパティ型:SingleLine.Text、SingleLine.URL、SingleLine.Email、Whole.None、Decimal、TwoOptions、DateAndTime.DateOnly、DateAndTime.DateAndTime。PRD の TypeScript 型を適切な型に対応付けてください(例:string → コンテキストで URL と判断できる場合を除き、SingleLine.Text)。
標準の診断出力 — 必ずこれら3つを出力します
操作の結果出力(および上記の <name>Json 生レスポンスバインド出力)に加えて、すべてのディスパッチャー PCF は3つの診断出力を宣言しなければなりません(すべて of-type="SingleLine.Text"、usage="output")。サードパーティ製コントロールでは、これはファーストパーティ製以上に重要です。ネイティブバイナリは顧客のラップシェル内で実行され、logcat / Xcode コンソール / ネイティブデバッガーには一切到達できないため、障害がそもそも可視化される唯一の方法は、コードとメッセージがブリッジを通って数式から読み取り可能な出力に戻ってくる場合だけです。
<property name="Status" display-name-key="Status" description-key="ok | error | cancelled" of-type="SingleLine.Text" usage="output" />
<property name="ErrorCode" display-name-key="Error Code" description-key="Machine-readable error code; empty on success" of-type="SingleLine.Text" usage="output" />
<property name="ErrorMessage" display-name-key="Error Message" description-key="Human-readable failure reason; empty on success" of-type="SingleLine.Text" usage="output" />
Status—"ok"|"error"| コントロールが使用する任意のライフサイクル状態(例:"cancelled")。ErrorCode— ネイティブのエラー応答からの機械可読なコード(USER_CANCELLED、INVALID_INPUT、BRIDGE_FAILED、PARSE、…)。成功時は""。メイカーはこれで分岐します。ErrorMessage— ネイティブ側が付加した人間が読めるmessage(例外テキスト、問題のあるフィールド、拒否された権限)。成功時は""。これは、現場で何かが失敗したときにメイカーやサポートエンジニアが最初に読むフィールドです — これがなければ、障害はサイレントな no-op になります。
2つのデバッグ出力は補完関係にあります。<name>Json は生のワイヤバイト(トランスポートレベルのフォレンジック)を示し、ErrorCode/ErrorMessage は解析済みの構造化された障害(ネイティブモジュールが意図した内容)を示します。メイカーのパターン: If(Self.Status = "error", Notify(Self.ErrorMessage, NotificationType.Error))。3つすべてを IOutputs で宣言し、setError / setSuccess で設定し、getOutputs() から返します。
オプションのビジュアルスタイル入力(色 + ボーダー) — ユーザーが要求した場合のみ
すべてのディスパッチャー PCF には、見栄えの良いテーマ付きデフォルト(ステップ5の applyStyles() / ensureStyleTag() を参照)がすでに同梱されており、ホストの Fluent テーマに従います — したがって、追加の入力がなくても生のブラウザボタンとしてレンダリングされることはありません。メイカー向けの色/ボーダー入力は、デフォルトでは追加しないでください。必要としないコントロールのプロパティパネルを煩雑にするだけです。
PRD / ユーザーがメイカー設定可能な色またはボーダーのオプションを明示的に求めている場合にのみ、これらを出力してください。 その場合、要求されたノブだけを(以下のセットから)usage="input"、required="false"、数値の radius を除いてすべて SingleLine.Text として追加します。
<property name="AccentColor" display-name-key="Accent color" description-key="Button background color (hex, e.g. #0f6cbd). Blank = host theme." of-type="SingleLine.Text" usage="input" required="false" default-value="" />
<property name="TextColor" display-name-key="Text color" description-key="Label color (hex). Blank = auto for contrast on the accent." of-type="SingleLine.Text" usage="input" required="false" default-value="" />
<property name="BorderColor" display-name-key="Border color" description-key="Border color (hex). Blank = matches the accent color." of-type="SingleLine.Text" usage="input" required="false" default-value="" />
<property name="BorderRadius" display-name-key="Corner radius" description-key="Corner radius in pixels (0 = square, 4 = default, 20 = pill)." of-type="Whole.None" usage="input" required="false" default-value="4" />
これを小さく安全に保つルール(テーマエンジンではありません):
- デフォルトではこれらを一切出力しません。 テーマ付きベースライン + ホストテーマですでに適切に見えるため、ユーザーが実際に要求したノブだけを表に出します。
applyStyles()は各ノブをその<property>が存在する場合にのみ読み取るので、省略してもデフォルトの見た目は何も変わりません。 - コントラストは保証されており、メイカーの問題ではありません。
AccentColorが出力されて設定されているがTextColorが空の場合、applyStyles()は輝度によって黒/白を計算するので、ラベルは常に WCAG AA を満たします — メイカーが誤ってラベルが見えないボタンを作ることはできません。 - 幅/高さ/フォントサイズの入力は追加しないでください — ホストボックスがコントロールのサイズを決めます。サイズ入力はキャンバスのリサイズハンドルと競合します。
プロパティごとの usage の選択 — 任意の <property> を出力する前に決める
usage は必須属性であり、最もよく間違えられる単一の項目です。マニフェストスキーマはちょうど3つの値を定義しています(property 要素リファレンス) — プロパティは「コンポーネントが変更できる列(bound)、読み取り専用(input)、または値を出力する(output)を表します」。意図的に選んでください。誤った選択はメイカーの入力パネルを煩雑にし(すべてが入力可能な input になる)、または数式から読み取り可能であるべき値を隠します(output を誤って bound にする)。
すべてのプロパティについて、この判断を順番に実行します — 最初に一致したものが優先されます:
- コントロールは、作成者が読み取るこの値を 生成 しますか?(ステータス、結果、現在の値、最後のエラー、計算されたテキスト/返されたテキストなど、Power Fx で作成者が
Self.<Name>/<Control>.<Name>として参照するもの)→output。ネイティブ操作が返すすべての値は、これがデフォルトです。作成者が数式内で読み取る値はすべて出力であり、boundではありません。 - 作成者はこの値を 設定/構成 しますか?(URL、テーブル名、ID、色、間隔、トグル、JSON 構成など、プロパティパネルで入力または選択する値、あるいは参照用にフィールドへバインドする値)→
input(required="false"を指定し、default-valueを設定します)。 - コントロールが表示と書き戻しの両方を行う、単一の主列がありますか?(テキストエディター、スクラバー、チャートなどの双方向編集)→
bound(最大 1 つ)。それ以外の場合は、boundプロパティを一切設定しません。
usage | 意味(正式な定義) | 作成者/Studio の動作 | TS の接続方法 |
|---|---|---|---|
output | コントロールが生成する値です。コントロールが書き込み、作成者は読み取るだけです。 | 入力パネルには表示されません。Power Fx で Self.<Name> として読み取れます。 | IOutputs で宣言し、getOutputs() から返します。context.parameters から読み取ることはありません。 |
input | 読み取り専用の入力です。作成者が静的な値(default-value)またはバインドされたフィールドを指定し、コントロールは読み取りますが、書き込むことはありません。 | プロパティパネルで編集できるフィールドです。 | context.parameters.<Name>.raw から読み取ります。getOutputs() には含めません。 |
bound | コントロールが変更できる双方向の列です。コントロールはフィールドを読み取り、書き戻します。最大 1 つです。アクション/構成コントロールでは省略します。 | Dataverse の列にバインドされます。context.parameters.<Name> から .formatted / .security / .attributes も利用できます。 | updateView で読み取り、getOutputs() からも返します。変更時に notifyOutputChanged() を呼び出します。 |
ネイティブ拡張 PCF のデフォルト(一般的なケース):bound プロパティはありません。 これらのほとんどは、アクション/構成/ステータスコントロールです(作成者が設定した入力値でネイティブ操作を実行し、その結果を表示します)。作成者が設定する値には input を、コントロールが返すすべての値には output を使用し、bound は一切設定しません。 プロパティが「主な入力」のように感じられるという理由で bound を選ぶのは、最大の間違いです。作成者が数式内で読み取る値は output、作成者が設定する値は input です。bound を使うのは、コントロールが単一の列をその場で編集する場合だけです。
ラップ PCF で許可される唯一の
boundは、上記のテンプレートに追加される<name>Json診断用プロパティです。 WebView のコンソールにアクセスできないデバイス上でのデバッグ用に、生のブリッジ応答を表示します(shared/ppmplugin-format.md§2)。これだけが例外です。すべてのドメインプロパティは上記の判断基準に従って分類し、それらにboundを使用しないでください。
ARCHITECTURE §8.1 の
boundをそのまま引き継がないでください。 設計ドキュメントの「§8.1 Bound input」という見出しは、そのプロパティをusage="bound"にしなければならないという意味ではありません。各プロパティを上記の判断基準で再分類してください。ネイティブ操作が返す値は、§8 で入力として記載されていてもoutputです。
後で本格的なローカライズが必要になった場合は、 PCF の標準パターンとして、ここにはリソースキー(例:
<PropertyName>_Display)を指定し、strings/<Pascal>PCF.1033.resx(およびロケールごとに追加の.resx)を作成して、キーをローカライズされた文字列に対応付けます。i18n の要件がない v0 では、上記のように通常の文字列を使うのが適切で、より扱いやすい方法です。
記述後、pac pcf build --no-restore を実行するか、ビルド時に pcf-scripts が生成する <Pascal>PCF/generated/ManifestTypes.d.ts ファイルを確認して、XML が正しく解析されることを検証します。
ステップ 5 — index.ts を作成する
ステップ 5.0 — ARCHITECTURE §6.0 のビジュアルスタイルに基づいて分岐する
生成する前に、ARCHITECTURE §6.0 を読み、PCF がどのビジュアルスタイルで描画されるべきか確認します。
| §8.0 の値 | 生成される UI の形状 |
|---|---|
minimal(デフォルト) | テーマ付きボタンが 1 つ表示されます。クリックすると → onTrigger() を呼び出します。出力は作成者の Power Fx で読み取ります。PCF 自体は出力をレンダリングしません。 |
with-preview | ボタン + プレビュー領域が表示されます。プレビューは、画像出力(base64 PNG / data URI など)の場合は <img>、テキスト出力(スキャン結果など)の場合は <div>、ステータスの場合は <span> です。プレビューは成功出力フィールドから値を読み取り、基になる値が変化すると updateView で更新されます。 |
inline-surface | カスタム — PCF はネイティブのモーダルを起動するのではなく、インタラクティブな領域を独自にレンダリングします。v0 は // TODO: design the inline surface プレースホルダーを出力し、DONE_WITH_CONCERNS で停止します。 |
以下のスケルトンは minimal モード用です。with-preview に対応するには、プレビュー要素を追加し、updateView および setSuccess の後に呼び出す renderPreview() メソッドを追加します。inline-surface にはこのスケルトンは適用されません。v1 以降のガイダンスを参照してください。
プレースホルダーを含むスケルトンではなく、完全に動作するコードを生成してください。 index.ts の各行は特定のソースに基づいており、ディスパッチ引数とレスポンスの形状は、PRD から直接取得するのではなく、ステップ 1.5 で解決したネイティブの正確な契約に基づいています。PRD が記述するのは意図です。実行中のアプリが実際にやり取りする内容は、manifest.json とネイティブソースで決まります。
index.ts 内のブロック | 派生元 |
|---|---|
COMPOSITE_KEY + METHOD 定数 | manifest.json → COMPOSITE_KEY = "<name>/<receiver>"(複合ルーティングキー)と METHOD = "<one of receivers[].methods>"。複合キーは、マニフェストが登録するレシーバーと一致する必要があります。 |
| 型 | <Pascal>Request、<Pascal>Response をこのファイル内で定義します(ネイティブ専用バンドルには、インポートできる共有 TS src/types.ts が含まれていません。@ReactMethod が解析する引数と、解決する {status, result, error} をモデル化してください)。 |
| プライベートな出力フィールド宣言 | ARCHITECTURE §6.1(出力プロパティ)(出力ごとにプライベートフィールドを 1 つ定義し、§8.3 の Type 列に基づく型を指定して、安全なデフォルト値で初期化します。テキストは ""、数値は 0、ブール値は false)。これに加えて、<name>Json raw レスポンス診断フィールドを定義します。 |
applyStyles() 本体 | ARCHITECTURE §6.1(設定可能な入力)(§8.2 の実際のプロパティ名を使い、ボタンテキスト、背景、前景、パディングなど、設定可能な入力ごとに代入を 1 つ行います)。 |
onTrigger() ペイロード構築(ディスパッチ引数) | <Pascal>Request(@ReactMethod / RCT_EXPORT_METHOD が唯一の ReadableMap/NSDictionary パラメーターとして読み取るオブジェクト)。ARCHITECTURE §6.1(バインドされた入力)に、どの設定可能/バインド入力がどのフィールドに渡されるかが記載されています。このオブジェクトは sendAsync エンベロープ { method: METHOD, args: [request] } に含まれます。RAW オブジェクトです(PCF は文字列化しません。sendAsync が行います)。また、内部の args は配列である必要があります(§2)。 |
onTrigger() 結果分岐 | 2 段階のエラーモデルにおける 4 つのケースを網羅する、単一のネストされた if/else です。各ケースで setError にコードとメッセージの両方を渡します。① ブリッジ OK + payload.status === "ok" → setSuccess(payload.result)。② ブリッジ OK + payload.status === "error" → setError(payload.error, payload.message ?? "")(ネイティブから渡された人間が読める理由)。③ ブリッジ OK だがペイロードの形状を認識できない場合(extractResponse がラップされた message コンテナーを展開した後も同様)→ setError("UNEXPECTED_PAYLOAD", "native response shape not recognized: " + this.<name>Json.slice(0, 200)) — 解析後のオブジェクトではなく、RAW のワイヤー文字列を表面化させます。④ sendAsync のステータスが "ok" 以外/解析失敗/ホストグローバルなし → setError("BRIDGE_FAILED", <result.error / reason>) / setError("PARSE", <解析に失敗した raw 文字列>) / setError("NOT_IN_WRAP", <理由>)。RAW のワイヤーレスポンスは <name>Json 出力にも設定します。最後に notifyOutputChanged() を 1 回呼び出します。 |
setSuccess(result) 本体 | <Pascal>Response["result"] → ARCHITECTURE §6.1(出力プロパティ)。§8.3 の各出力について 1 つずつ、対応するレスポンスフィールドを割り当てます。status="ok" を設定し、errorCode="" と errorMessage="" をクリアします。 |
setError(code, message) 本体 | ARCHITECTURE §5(コード)+ ARCHITECTURE §6.3(エラー UX のマッピング)(コードごとの UX)。最も簡単な形式は、結果フィールドをゼロ値にし、status="error"、errorCode=code、errorMessage=message(人間が読める理由。決して破棄しないでください)を設定します。ARCHITECTURE §6.3 で特定のコードに異なる出力 UX(例:USER_CANCELLED → status="cancelled")が指定されている場合は、setError 内で分岐します。その場合も errorMessage を設定してください。 |
getOutputs() 本体 | ARCHITECTURE §6.1(出力プロパティ)— 出力ごとに 1 つのエントリーを返し、プライベートフィールドを読み取ります。Status、ErrorCode、ErrorMessage(および <name>Json raw 出力)を必ず含めてください。これにより、ネイティブデバッガーがなくても Power Fx で失敗を確認できます。 |
スケルトン(派生ルールをインラインで記載)
デフォルトでスキャフォールドされた pcf/<Pascal>PCF/<Pascal>PCF/index.ts をこの構造に置き換え、PRD のすべての値に置き換えてください。
import { IInputs, IOutputs } from "./generated/ManifestTypes";
// Domain contract — modeled INLINE. A native-only .ppmplugin ships NO shared TS layer,
// so there's nothing to import: <Pascal>Request is the args the @ReactMethod parses,
// <Pascal>Response is the {status, result, error} object it resolves. Mirror the module.
interface <Pascal>Request { /* one field per dispatch arg the @ReactMethod parses */ }
interface <Pascal>Response { status: "ok" | "error"; result?: Record<string, unknown>; error?: string; message?: string; }
// error — machine code (present when status === "error"); the PCF branches on it.
// message — HUMAN-READABLE failure reason (present when status === "error"); the PCF surfaces it as ErrorMessage.
// Bridge declaration — the wrap host injects `window.PowerApps.NativeExtension`
// onto the Canvas WebView at boot (when CordovaV2 is enabled). The PCF dispatches
// through its `sendAsync` global — it must NEVER call `cordova.exec` directly (the
// raw `cordova` global is NOT exposed to the PCF sandbox, so a direct call is a
// silent no-op on device, worst on Android). See shared/ppmplugin-format.md §2.
//
// Type it with a local ambient declaration in PowerAppsNativeExtension.d.ts (emitted
// alongside this file) so the PCF stays host-agnostic and pins no SDK package.
// deepParse: the wrap host double-/triple-stringifies the bridge response, so peel string
// layers (bounded) until we reach an object. This is a BOUNDED helper used by extractResponse
// to reach the container/payload — NOT a blind transport walk used on its own.
// See shared/ppmplugin-format.md §2 "Wrap-bridge response quirks".
function deepParse(v: unknown, max = 4): unknown {
let cur = v;
for (let i = 0; i < max && typeof cur === "string"; i++) {
try { cur = JSON.parse(cur); } catch { break; }
}
return cur;
}
// extractResponse: the wrap transport ALSO wraps the module's JSON in a container object,
// nesting it (still stringified) under a `message` key:
// {"isUpdate":false,"message":"{\"status\":\"ok\",\"result\":{…}}"}
// A bare parse lands on {isUpdate, message} (no top-level `status`) → UNEXPECTED_PAYLOAD
// even though native succeeded. So peel string layers, THEN unwrap the container: probe
// `message` (the confirmed wrap key) first, then defensive fallbacks, accepting the first
// nested value that has a top-level `status`. When result.data already IS the {status,…}
// object (the simple already-unwrapped case), the first check returns it directly — so this
// is a strict superset of a single guarded parse. See shared/ppmplugin-format.md §2.
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
}
const COMPOSITE_KEY = "<name>/<receiver>"; // manifest.json — composite routing key; MUST match the receiver the manifest registers
const METHOD = "<method>"; // manifest.json receivers[].methods — a real @ReactMethod / RCT_EXPORT_METHOD name
export class <Pascal>PCF implements ComponentFramework.StandardControl<IInputs, IOutputs> {
private container!: HTMLDivElement;
private notifyOutputChanged!: () => void;
private context!: ComponentFramework.Context<IInputs>;
private button!: HTMLButtonElement;
private seq = 0; // per-tap correlation id for the structured logs
// ── Structured JSON logging (crash-proof) — the only way to diagnose on-device. ──
// On a release wrap build the WebView console is unreachable, so every dispatch step
// emits a single-line JSON record (grep-able if a console IS attached, and the raw
// response is ALSO surfaced via the usage="bound" <name>Json output for Power Fx).
private static readonly LOG_TAG = "<Pascal>PCF";
private logDebug(event: string, data?: Record<string, unknown>): void {
try { console.log(`[${<Pascal>PCF.LOG_TAG}] ` + JSON.stringify({ ts: Date.now(), level: "debug", event, ...data })); } catch { /* logging must never throw */ }
}
private logError(event: string, data?: Record<string, unknown>): void {
try { console.error(`[${<Pascal>PCF.LOG_TAG}] ` + JSON.stringify({ ts: Date.now(), level: "error", event, ...data })); } catch { /* logging must never throw */ }
}
// ── Outputs: one private field per ARCHITECTURE §6.1 (output properties) row ──
// Emit one declaration per output, typed from §8.3's "Type" column (mapping the
// module's response field type to its PCF property type, inverse direction),
// initialized to a safe default for that type.
//
// Defaults by type:
// SingleLine.Text / .URL / .Email → ""
// Whole.None / Decimal → 0
// TwoOptions → false
// DateAndTime.* → new Date(0) (or a sentinel; ARCHITECTURE §6.1 (output properties) may specify)
//
// Shape: `private <fieldName>: <tsType> = <default>;`
// Always include these diagnostic fields (back the standard outputs):
// private status: string = "idle"; // "idle" | "ok" | "error" | extension-specific values
// private errorCode: string = ""; // empty in success state; the machine code in error state
// private errorMessage: string = ""; // empty in success state; the HUMAN-READABLE reason in error state
// // (the field the maker/support reads first — no native debugger on device)
// private <name>Json: string = ""; // RAW bridge response (wire string, before extractResponse) — the
// // usage="bound" on-device diagnostic; surfaced via Self.<name>Json
public init(
context: ComponentFramework.Context<IInputs>,
notifyOutputChanged: () => void,
_state: ComponentFramework.Dictionary,
container: HTMLDivElement,
): void {
this.container = container;
this.notifyOutputChanged = notifyOutputChanged;
this.context = context;
this.button = document.createElement("button");
this.button.className = "pam-ext-trigger"; // scopes the injected <style> below
// ── Default: fill the host-allocated rectangle ──────────────────────────
// The maker resizes the control on the canvas; the host gives us the
// resulting box via `container`. The button fills that box so the whole
// control area is the tap target (no dead zone around a small button).
// To make the control SMALLER by default, shrink the control's default
// Width/Height in the canvas (see ARCHITECTURE §6 default size), NOT the
// button — the button just fills whatever box it's given.
this.button.style.width = "100%";
this.button.style.height = "100%";
this.button.style.boxSizing = "border-box";
this.ensureStyleTag(); // interaction states (hover/active/disabled/focus) — pseudo-classes need a <style>
this.applyStyles();
this.button.addEventListener("click", () => { void this.onTrigger(); });
container.appendChild(this.button);
this.logDebug("init", { inWrap: !!window.PowerApps?.NativeExtension, key: COMPOSITE_KEY, method: METHOD });
}
// Inject a scoped <style> ONCE. Inline styles can't express :hover / :active /
// :disabled / :focus-visible, and a control with no interaction feedback reads
// as "dead" on device. Keep the selectors scoped to .pam-ext-trigger so we never
// leak styles into the host page. `--pam-accent` / `--pam-fg` are set per-instance
// in applyStyles(), so these rules follow the resolved (possibly maker-overridden) palette.
private ensureStyleTag(): void {
if (document.getElementById("pam-ext-trigger-style")) return;
const s = document.createElement("style");
s.id = "pam-ext-trigger-style";
s.textContent = `
.pam-ext-trigger {
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
font-size: 14px; font-weight: 600; cursor: pointer;
border-style: solid; border-width: 1px;
transition: filter .1s ease, box-shadow .1s ease;
}
.pam-ext-trigger:hover:not(:disabled) { filter: brightness(0.94); }
.pam-ext-trigger:active:not(:disabled) { filter: brightness(0.88); }
.pam-ext-trigger:disabled { opacity: .5; cursor: default; }
.pam-ext-trigger:focus-visible { outline: 2px solid var(--pam-fg, #201f1e); outline-offset: 2px; }
`;
document.head.appendChild(s);
}
private applyStyles(): void {
const p = this.context.parameters;
const b = this.button;
// ── 1. Themed defaults (a real control, not a raw browser button) ────────
// A polished baseline the maker gets for free. Prefer the host Fluent theme
// when the runtime exposes it, so the control looks native in maker portal
// AND on mobile; fall back to these Fluent-ish hexes in Studio preview / older hosts.
const theme = (this.context as unknown as { fluentDesignLanguage?: { palette?: Record<string, string> } }).fluentDesignLanguage;
let accent = theme?.palette?.themePrimary ?? "#0f6cbd"; // brand blue
let fg = theme?.palette?.white ?? "#ffffff"; // text on accent
let border = accent; // border matches accent by default
let radius = "4px"; // Fluent-standard corner
// ── 2. Maker overrides (the color + border "options") ────────────────────
// Read ONLY if the corresponding configurable input exists in ARCHITECTURE §6.1.
// Emit a line per knob the manifest declares (AccentColor / TextColor / BorderColor / BorderRadius).
// accent = (p.AccentColor?.raw || "").trim() || accent;
// fg = (p.TextColor?.raw || "").trim() || fg;
// border = (p.BorderColor?.raw || "").trim() || border;
// radius = (p.BorderRadius?.raw != null ? p.BorderRadius.raw + "px" : radius);
// ── 3. Guarantee legible contrast (accessibility, not optional) ──────────
// If the maker set a background but no explicit text color, pick black/white
// by luminance so text on the accent always clears WCAG AA (~4.5:1). Prevents
// the classic "pale button, invisible label" a hand-set color causes.
if (!((p as Record<string, { raw?: string }>).TextColor?.raw ?? "").trim()) {
fg = this.readableTextOn(accent);
}
b.style.backgroundColor = accent;
b.style.color = fg;
b.style.borderColor = border;
b.style.borderRadius = radius;
b.style.padding = "8px 16px";
b.style.setProperty("--pam-accent", accent);
b.style.setProperty("--pam-fg", fg);
// ── 4. Label ─────────────────────────────────────────────────────────────
// Button text from the §8.2 label input (or a sensible default). For a
// toggleable control label it "<Function> : <State>" (e.g. "Location Tracking : On")
// so the tap-to-toggle affordance is discoverable — not a bare state word.
b.textContent = ((p as Record<string, { raw?: string }>).ButtonLabel?.raw ?? "").trim() || "<default label from PRD §6>";
// Map any remaining §8.2 configurable inputs to DOM properties here (one line each).
// DO NOT set width/height from inputs — the host box already sizes the control;
// a width/height input just fights the canvas resize handle.
}
// Black or white text for the given background, chosen by relative luminance so
// the label always clears WCAG AA contrast on the resolved accent color.
private readableTextOn(hex: string): string {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return "#ffffff";
const n = parseInt(m[1], 16);
const [r, g, bl] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
const L = 0.2126 * r + 0.7152 * g + 0.0722 * bl; // relative luminance
return L > 0.4 ? "#201f1e" : "#ffffff"; // dark text on light bg, white on dark
}
private async onTrigger(): Promise<void> {
// ─────────────────────────────────────────────────────────────────────────
// 1. Build the REQUEST args (the object the @ReactMethod parses)
// ─────────────────────────────────────────────────────────────────────────
// The request rides inside the sendAsync envelope as a RAW object:
// { method: METHOD, args: [request] }
// (the PCF does NOT stringify it — sendAsync does; inner args MUST be an array — §2).
//
// Shape: one assignment per <Pascal>Request field; source values from
// this.context.parameters.<§8.2-name>.raw (configurable inputs), or
// this.context.parameters.<§8.1-name>.raw (bound input)
const request: <Pascal>Request = { /* one entry per <Pascal>Request field */ };
const seq = ++this.seq;
this.logDebug("dispatch", { seq, key: COMPOSITE_KEY, method: METHOD, request });
// ─────────────────────────────────────────────────────────────────────────
// 2. Dispatch via window.PowerApps.NativeExtension.sendAsync — invokeBridge
// resolves the parsed response object, or THROWS on a bridge/transport
// failure or when the host global isn't available (Studio preview / non-PAM host).
// ─────────────────────────────────────────────────────────────────────────
let response: <Pascal>Response;
try {
response = await this.invokeBridge(request);
this.logDebug("bridge_returned", { seq, status: response?.status, error: response?.error });
} catch (e) {
// status!=="ok" / parse-fail / no host global. The thrown message carries the code.
const code = (e as Error)?.message === "PARSE" ? "PARSE"
: (e as Error)?.message === "NOT_IN_WRAP" ? "NOT_IN_WRAP"
: "BRIDGE_FAILED";
// Message: prefer the raw wire string (set on this.<name>Json by invokeBridge) so a
// transport/parse failure is debuggable from Power Fx; fall back to the error string.
const reason = this.<name>Json || String((e as Error)?.message ?? e);
this.logError("bridge_error", { seq, code, reason });
this.setError(code, reason);
this.notifyOutputChanged();
return;
}
// ─────────────────────────────────────────────────────────────────────────
// 3. Branch on the module's two-level {status, result, error} response.
// extractResponse already peeled the host's string layers and unwrapped the
// wrap `message` container — `response` is the module's {status,...} object.
// ─────────────────────────────────────────────────────────────────────────
if (response?.status === "ok" && response.result) {
// Operation succeeded. Map response.result.* → output fields.
this.logDebug("module_success", { seq });
this.setSuccess(response.result);
} else if (response?.status === "error") {
// Operation failed with a module-defined code (USER_CANCELLED, etc., per ARCHITECTURE §5).
// Pass the native-supplied human-readable message through to the ErrorMessage output.
this.logError("module_error", { seq, code: response.error, message: response.message });
this.setError(response.error ?? "INTERNAL_ERROR", response.message ?? "");
} else {
// Bridge succeeded but the payload doesn't match <Pascal>Response shape — even after
// extractResponse tried to unwrap the wrap `message` container. Surface the RAW wire
// string (not the post-parse object) so the real shape is diagnosable from Power Fx.
this.logError("unexpected_payload", { seq, raw: this.<name>Json });
this.setError("UNEXPECTED_PAYLOAD", "native response shape not recognized: " + this.<name>Json.slice(0, 200));
}
this.notifyOutputChanged();
this.logDebug("final_state", { seq, status: this.status, errorCode: this.errorCode, errorMessage: this.errorMessage });
}
private setSuccess(result: NonNullable<<Pascal>Response["result"]>): void {
// ── Map the module's `result` payload → PCF output fields ──
// For each output declared in ARCHITECTURE §6.1 (output properties), assign from the matching field in `result`
// (the shape is the inline `<Pascal>Response["result"]` type modeled at the top of this file).
// Set the status/errorCode/errorMessage outputs to their success values.
//
// Shape:
// this.<§8.3-field-A> = result.<response-field> ?? <default>;
// this.<§8.3-field-B> = result.<response-field> ?? <default>;
// ...
// this.status = "ok";
// this.errorCode = "";
// this.errorMessage = ""; // clear any prior failure's message
//
// One assignment per §8.3 row. If a §8.3 output has no matching source field
// in <Pascal>Response["result"], the scaffold STOPS in Step 1.5 — it shouldn't reach here.
// (this.<name>Json is set in invokeBridge from the raw response — don't touch it here.)
}
private setError(code: string, message: string): void {
// ── Map error code → output fields per ARCHITECTURE §6.3 (error UX mapping) ──
// Default behavior (covers most extensions): zero out result-bearing outputs, set
// status="error", surface the code AND the human-readable message as-is.
//
// Shape:
// this.<§8.3-result-field-A> = <default>;
// this.<§8.3-result-field-B> = <default>;
// ...
// this.status = "error";
// this.errorCode = code;
// this.errorMessage = message; // NEVER drop this — it's what makes the failure debuggable on-device
//
// If ARCHITECTURE §6.3 (error UX mapping) specifies that certain codes need different output state (e.g. a code
// that maps to status="cancelled" instead of "error", or a code that preserves a
// partial result), branch on the code value:
//
// switch (code) {
// case "<CODE_A>":
// /* ARCHITECTURE §6.3 (error UX mapping) row for CODE_A */
// break;
// case "<CODE_B>":
// /* ARCHITECTURE §6.3 (error UX mapping) row for CODE_B */
// break;
// default:
// /* the standard zero-and-surface mapping above */
// }
//
// The codes that reach here include BOTH bridge/transport codes (BRIDGE_FAILED, PARSE,
// UNEXPECTED_PAYLOAD, NOT_IN_WRAP) and module-defined codes from ARCHITECTURE §5.
// Cover what ARCHITECTURE §6.3 (error UX mapping) specifies; let the default branch handle
// anything else.
}
/**
* Bridge transport. Dispatches the composite key COMPOSITE_KEY ("<name>/<receiver>")
* to NativeModules.<nativeModule>.<METHOD> through the host-injected
* `window.PowerApps.NativeExtension.sendAsync` global, and RESOLVES the parsed
* <Pascal>Response object.
*
* REJECTS with:
* - new Error("BRIDGE_FAILED") when sendAsync resolves status !== "ok" (transport/proxy
* failure, e.g. "native module 'X' not loaded")
* - new Error("PARSE") when the resolved data can't be reduced to an object
* - new Error("NOT_IN_WRAP") when the host global is absent — Studio preview, a non-PAM
* web host, or CordovaV2 disabled. Do NOT fall back to
* cordova.exec — it is not exposed to the PCF sandbox.
*
* sendAsync resolves { status, data?, error? }. On status==="ok", `data` is the native
* method's resolved string; the host both re-stringifies it AND wraps it in a { isUpdate,
* message } transport container, so the raw is run through extractResponse (parse + unwrap
* the `message` container). The raw string is also captured into the usage="bound" <name>Json
* output so a maker can read it on-device via Self.<name>Json when the WebView console is unreachable.
*
* The envelope is a RAW object { method, args: [request] } — the PCF does NOT stringify it;
* sendAsync does that internally. The inner `args` MUST be an array (the proxy spreads it
* positionally via fn.apply), so the single request object rides at args[0] → the native
* method's one ReadableMap/NSDictionary param. There is no SDK-side timeoutMs to tune here.
*/
private async invokeBridge(payload: <Pascal>Request): Promise<<Pascal>Response> {
const bridge = window.PowerApps?.NativeExtension;
if (!bridge || typeof bridge.sendAsync !== "function") {
throw new Error("NOT_IN_WRAP");
}
// Envelope is a RAW object; args MUST be an array (one request object at args[0]).
const result = await bridge.sendAsync(COMPOSITE_KEY, { method: METHOD, args: [payload] });
// Capture the raw wire response for the on-device diagnostic output.
this.<name>Json = typeof result === "string" ? result : JSON.stringify(result);
if (!result || result.status !== "ok") {
throw new Error((result && result.error) || "BRIDGE_FAILED");
}
// result.data is re-stringified AND may be wrapped in the wrap transport's response
// container ({ isUpdate, message:"<json of {status,result}>" }). extractResponse parses
// it and, if there's no top-level `status`, unwraps the `message` container — NOT a bare
// parse (which would land on {isUpdate, message} → UNEXPECTED_PAYLOAD though native succeeded). §2.
const parsed = extractResponse(result.data);
if (parsed && typeof parsed === "object") {
return parsed as <Pascal>Response;
}
throw new Error("PARSE");
}
public updateView(context: ComponentFramework.Context<IInputs>): void {
this.context = context;
this.applyStyles();
}
public getOutputs(): IOutputs {
return {
// ── One returned key per ARCHITECTURE §6.1 (output properties) output, reading the private field ──
// Shape: `<§8.3-property-name>: this.<privateFieldName>,`
// The property name on the LEFT matches `<property name="...">` in ControlManifest.Input.xml.
// The field on the RIGHT is the private declared at the top of the class.
// MUST include the three standard diagnostic outputs so the failure is visible in
// Power Fx with no native debugger:
// Status: this.status,
// ErrorCode: this.errorCode,
// ErrorMessage: this.errorMessage,
// ALSO include the usage="bound" diagnostic so the raw response reaches Self.<name>Json:
// <name>Json: this.<name>Json,
};
}
public destroy(): void { /* No-op for v0. */ }
}
with-preview ビジュアルスタイルに合わせたスケルトンの調整
ARCHITECTURE §6.0 が with-preview の場合は、上記のスケルトンにプレビューペインを追加します。
-
initにプレビュー要素を追加します。this.previewEl = document.createElement("img"); // <img> for image outputs; <div> for text this.previewEl.style.maxWidth = "100%"; this.previewEl.style.maxHeight = "200px"; // or per ARCHITECTURE §6.0 sizing this.previewEl.style.display = "none"; // hidden until first result container.appendChild(this.previewEl); -
プレビューを設定するように
setSuccessを更新します。private setSuccess(result: <Pascal>Response["result"]): void { // ... map to outputs as before ... this.<successField> = result.<field>; // Show preview if (this.previewEl instanceof HTMLImageElement) { this.previewEl.src = result.<imageField>; this.previewEl.style.display = "block"; } } -
プレビューを非表示にする(またはプレースホルダーを表示する)ように
setErrorを更新します。private setError(code: string, message: string): void { // ... map error state as before (set status/errorCode/errorMessage) ... this.previewEl.style.display = "none"; } -
updateViewでは現在の出力フィールドからプレビューを再描画します(作成者が Power Fx 経由でバインドされた入力を変更した場合に対応します)。public updateView(context: ComponentFramework.Context<IInputs>): void { this.context = context; this.applyStyles(); if (this.<successField>) { this.renderPreview(this.<successField>); } }
プレビューの種類は、ARCHITECTURE §6.1(出力プロパティ)→ §8.0 のマッピングに従います。
- Base64 PNG/データ URI →
<img>要素、src = result.<field> - SVG 文字列 →
innerHTMLを使う<div>(注意:SVG ソースが信頼できる場合に限ります。それ以外の場合はサニタイズしてください) - プレーンテキストの結果 →
textContentを使う<span> - 数値出力 → 書式設定した数値を表示する
<span>
inline-surface(v1 以降のパターン)への対応
トリガーボタンをインタラクティブサーフェス用の <div> に置き換えた最小限のスケルトンを出力し、// TODO: implement the inline surface per ARCHITECTURE §6.0 コメントを追加します。DONE_WITH_CONCERNS: ARCHITECTURE §6.0 inline-surface style requires custom UI design beyond the v0 scaffold template で停止してください。インタラクティブサーフェスはエンジニアが実装します。ブリッジの配線(sendAsync のディスパッチ、extractResponse、エラー処理、出力マッピング)はそのままです。
ステップ 5.5 — アンビエント宣言 PowerAppsNativeExtension.d.ts を出力する
index.ts のスケルトンでは window.PowerApps.NativeExtension.sendAsync を呼び出すため、TypeScript のビルドでグローバル型が必要です。ホスト SDK はインポートしないでください(PCF はホストに依存しない状態を保ち、SDK パッケージを固定しないようにします)。index.ts の隣にある pcf/<Pascal>PCF/PowerAppsNativeExtension.d.ts に、1 ファイルのアンビエント宣言を記述します。
//! Ambient declaration for the host-injected `window.PowerApps.NativeExtension`
//! global. Wired at boot by PAM's published-app-loader (when CordovaV2 is enabled).
//! A local declaration (rather than importing an SDK package) keeps the PCF
//! host-agnostic — any PCF can call `sendAsync` without pinning the SDK.
interface PowerAppsNativeExtensionResult {
status: "ok" | "error";
data?: unknown;
error?: string;
}
interface Window {
PowerApps?: {
NativeExtension?: {
sendAsync(
extensionName: string,
payload: unknown,
options?: { timeoutMs?: number },
): Promise<PowerAppsNativeExtensionResult>;
};
};
}
これは必須の出力です。これがないと、npm run build(ステップ 6)で window.PowerApps の型チェックに失敗します。pcf-scripts はコントロールフォルダー内の *.d.ts を自動的に取り込むため、tsconfig の変更は不要です。
index.ts 生成に関する厳守事項
- 出力ファイルに
<placeholder>のテキストを含めないでください。すべての値を PRD から置き換えてください。 // TODOコメントを含めないでください。必須のマッピングが PRD にない場合は、NEEDS_CONTEXT: PRD §<n>.<n> is incomplete for PCF code generation — re-run /design-native-extension-featureで停止し、不足している列を示してください。- ディスパッチには
sendAsyncを使用し、cordova.execは使用しないでください。 出力するinvokeBridgeは、RAW{ method, args: [request] }エンベロープを指定してwindow.PowerApps.NativeExtension.sendAsyncを呼び出す必要があります。PCF 内でcordova.execを直接呼び出す(またはcordova.*を参照する)ことは欠陥です。PCF サンドボックスには公開されておらず、デバイス上でエラーが通知されないまま失敗します(Android では特に深刻です)。 - ARCHITECTURE §5 のすべてのエラーコードを
applyErrorStateで網羅してください。デフォルト分岐は未知のコード専用です。デフォルトに頼ってケースを省略しないでください。 - ARCHITECTURE §4.2 との対称性を検証してください。§8.3 で宣言されたすべての出力フィールドは、§5.2 のレスポンスフィールド(またはエラー状態から導出される定数)にマッピングする必要があります。§8.3 で宣言された出力に対応するフィールドが §5.2 にない場合は、停止してユーザーに確認してください。
パターンの網羅範囲
- ワンショット — 上記の構造で十分です。完全なコードを生成します。
- ストリーミング(§6 = ストリーミング)—
sendAsyncのディスパッチでは 1 つのレスポンスが解決されます。ストリーミングには、ネイティブモジュールが更新をプッシュし、PCF がイベントサブスクリプションを介して受け取る仕組みが必要です。wrap bridge は、PCF 側で使える明確なサブスクリプション API を公開していません。v0 ではワンショットの構造を出力し、ファイルの先頭に// TODO (streaming): wire up update subscription when the wrap bridge exposes itコメントを追加します。DONE_WITH_CONCERNS: streaming PCF needs wrap-bridge support not yet available — emitted one-shot fallbackを出力して、スキャフォールディングを中止します。存在しない API をでっち上げないでください。 - 双方向(ARCHITECTURE §2 = 双方向)— 各操作に、後続メッセージを送信する 2 つ目のボタンを追加します。ARCHITECTURE §3. の後続処理仕様から、2 つ目のボタンとハンドラーを生成します(デザインスキルが実装の手順でこの仕様を取り込みます)。§3. で後続処理の UI 表示方法が指定されていない場合は、
NEEDS_CONTEXT: ARCHITECTURE §3.<n> doesn't specify the two-way UI affordance — re-run design walkthroughを出力して中止します。
NOT_IN_WRAP による拒否は、テストハーネス(pcf-scripts start watch によってブラウザープレビューが開きますが、そこには window.PowerApps.NativeExtension が注入されていません)で発生します。これは想定どおりです。ハーネスでブリッジをモックしないでください。これは既知の制約です。
ステップ 5b — 手動検証用のテストハーネスを生成する
エンジニアが実際の Canvas アプリを構築し、PCF を接続してデバイス上でエンドツーエンドの動作を確認できるよう、拡張機能リポジトリのルートにある PCF フォルダーの 1 つ上の階層に test-harness/ を生成します。
実現可能な方法は 2 つあります。コストが低いため、両方を用意します。
5b.1 — 正確な Power Fx の手順を記載した型付きの test-harness/README.md
PRD に合わせて、手順を段階的に説明する test-harness/README.md を作成します。含めるセクション:
# Test harness — <Human-Readable Name>
Manual validation recipe. Builds a Canvas app in Studio that wires the `<Pascal>PCF` control to confirm end-to-end behavior on a real wrap build.
## Prerequisites
- The dispatcher PCF is deployed to your environment: run `/publish-pcf-companion` (handles publisher prefix selection, version bump, env confirmation, and the actual push).
- A wrap build that has the `.ppmplugin` bundle loaded (the DEX/framework + `manifest.json` whose `<name>/<receiver>` this PCF dispatches to) — see /test-native-extension for the load recipe.
- `pac auth list` shows your test environment as active.
## Build the test app in Canvas Studio
1. Open <https://make.powerapps.com>; switch to your test environment.
2. Apps → New app → Blank canvas → Phone layout.
3. Insert → Custom → search `<Pascal>PCF` (under the `PowerApps` namespace).
4. Drop it onto Screen1.
5. In the right-side properties panel, set:
- `<BoundName>` = <one realistic test value, e.g. an HTTPS URL or a base64 sample>
- `<each configurable input>` = <leave default or set what ARCHITECTURE §6.1 (configurable inputs) says>
6. Insert → Text label, name it `lblResult`. Set its `Text` property to:
`<Pascal>PCF1.<OutputName>` (the primary output from ARCHITECTURE §6.1 (output properties))
7. (For more outputs:) Add one label per additional output property.
## Run
- Studio preview (▶): runs the PCF in the browser, where `window.PowerApps.NativeExtension` isn't injected; bridge calls reject with `NOT_IN_WRAP` — that's the expected signal that you need a real wrap build to test end-to-end.
- Wrap on device: open the app in Power Apps Mobile (logged into the same tenant). The bridge dispatches, the native module fires, and the output property updates.
## What to verify
Per ARCHITECTURE §6.3 (error UX mapping) (Edge cases):
| Trigger | Expected `<OutputName>` value |
|---|---|
| <error code 1> | <expected user experience> |
| <error code 2> | <expected user experience> |
| <happy path> | <expected user experience> |
## Optional — save as a reusable .msapp
If you want to share the test app with another machine or check it into source control:
```bash
# Inside Studio: File → Save as → save to your Power Platform environment first.
# Then export from Studio (File → See all versions → Download) — gives you a .msapp file.
# Drop it into ./test-harness/<Pascal>.msapp and commit.
後で展開してソースとして編集するには:
pac canvas unpack --msapp ./test-harness/<Pascal>.msapp --sources ./test-harness/<Pascal>-source/
編集後に再パックするには:
pac canvas pack --sources ./test-harness/<Pascal>-source/ --msapp ./test-harness/<Pascal>.msapp
Tailor the recipe to the PRD: substitute the actual bound input name + a realistic example value, list every configurable input from §8.2, list every output from §8.3, fill the verification table from §9 (one row per error code + the happy path).
### 5b.2 — Skeleton Canvas source folder (best-effort)
Create `test-harness/canvas-app-source/` with the bare minimum Canvas source format:
test-harness/canvas-app-source/ ├── CanvasManifest.json ← アプリ名を「-test」とする最小限のマニフェスト ├── Src/ │ ├── App.pa.yaml ← App.OnStart = false。特別な設定なし │ └── Screen1.pa.yaml ← PCF インスタンス(既定のコンパクトサイズ)+ 出力にバインドされた lblResult └── README.md ← 「実行:pac canvas pack --sources . --msapp ../.msapp」
**Give the control instance a compact default size** in `Screen1.pa.yaml` (`Width: =200`, `Height: =44` for a button-style control — adjust per ARCHITECTURE §6 if it specifies a preferred footprint). The button fills the control box (`width/height: 100%`), so the control's `Width`/`Height` here ARE its rendered size — set them small so the sample app shows a normal-sized button, not one that fills a tall default cell.
> ⚠️ **Canvas source format is fragile.** `Pa.yaml` is sensitive to indentation and specific control-property casing. The skeleton you generate may not import cleanly into Studio. If `pac canvas pack` errors at this step, **fall back to the manual-in-Studio recipe in 5b.1** — that's the supported v0 path. Treat the generated source folder as a convenience, not a guarantee.
After 5b.2 succeeds (or is skipped on failure), the engineer always has 5b.1 as a fallback.
Update `.gitignore` to allow the harness sources but exclude the `.msapp` binary by default (it's user-environment-specific):
テストハーネス — ソースはコミットし、バイナリはマシンごとに用意する
test-harness/*.msapp
---
## Step 5.7 — Self-verify the round-trip contract (before building)
The bridge round-trip is the class of bug that **passes `npm run build` and every zip/manifest/DEX check, then fails silently on device** — a composite key that doesn't match the manifest receiver, a `cordova.exec` call the sandbox drops, a pre-stringified envelope, a bare `args` object, or a success path that doesn't unwrap the wrap `message` container. Historically these were only caught at the very end by `/audit-ppmplugin` Category F. Run them **here**, over the `index.ts` you just emitted, so a gap is caught at generation instead.
This is **Gate 11 (PCF ↔ native round-trip contract)** from [`shared/self-critique-protocol.md`](../../shared/self-critique-protocol.md). Re-read the `index.ts` (and `PowerAppsNativeExtension.d.ts`) fresh from disk, then check each invariant against the **resolved contract from Step 1.5** and [`shared/ppmplugin-format.md §2`](../../shared/ppmplugin-format.md):
| Check | Assert | Severity |
|---|---|---|
| Composite key | `COMPOSITE_KEY` in `index.ts` == `<manifest.name>/<receivers[].name>` resolved in Step 1.5; `METHOD` ∈ `receivers[].methods` | blocker |
| Transport | dispatch is `window.PowerApps.NativeExtension.sendAsync`; NO `cordova.exec` / `cordova.*` anywhere in `index.ts` | blocker |
| Envelope | `sendAsync` payload is a **raw** `{ method: METHOD, args: [request] }` object — NOT `JSON.stringify(...)`'d | blocker |
| Args array | the inner `args` is a JSON **array** with the request object at `args[0]` (not a bare object) | blocker |
| Response unwrap | the success path calls `extractResponse` (parse `result.data` + unwrap the `{ isUpdate, message }` container), NOT a bare single `JSON.parse` | blocker |
| Error mapping | every ARCHITECTURE §5 code AND the transport codes (`BRIDGE_FAILED`, `PARSE`, `UNEXPECTED_PAYLOAD`, `NOT_IN_WRAP`) reach a PCF output carrying **both** code and human message | concern |
| Ambient global | `PowerAppsNativeExtension.d.ts` typing `window.PowerApps.NativeExtension.sendAsync` was emitted (Step 5.5) | concern |
**Print a visible verdict block** (`shared-instructions.md §9.1`) and gate on it:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ラウンドトリップの自己検証 — PCF(/ をディスパッチ) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🟢 ✓ 複合キーがマニフェストのレシーバーと一致。METHOD ∈ receivers[].methods 🟢 ✓ sendAsync のみ(cordova.exec なし)。未加工のエンベロープ。args は [request] 🟢 ✓ extractResponse が { isUpdate, message } コンテナーをアンラップ 🟢 ✓ §5 + トランスポートのすべてのエラーコードがメッセージ付きの出力にマッピングされる
- Any **blocker** → **fix the emitted `index.ts` inline** (these are mechanical wiring fixes, not design choices — align the key, replace a `cordova.exec` with `sendAsync`, drop a stray `JSON.stringify`, wrap `args` in an array, route the success path through `extractResponse`), re-read, re-check. Only if a blocker is a genuine drift between the manifest and native source (not a PCF-emit mistake) → STOP with `NEEDS_CONTEXT` and route back per the Step 1.5 drift table.
- Any **concern** → note it in the final summary and in `.extension-state.md`.
- All clear → proceed to Step 6.
This does not replace `/audit-ppmplugin` Category F (the final gate over the built artifact) — it front-loads the same reasoning so the round-trip is right before the PCF is even built.
---
## Step 6 — Install and smoke-build
Inside `pcf/<Pascal>PCF/`:
```bash
npm install # PCF tooling is npm; do NOT use pnpm here
npm run build # runs `pcf-scripts build`
これにより、マニフェストから <Pascal>PCF/generated/ManifestTypes.d.ts が再生成され、index.ts の型チェックとバンドルが行われます。出力先は pcf/<Pascal>PCF/out/(Gitignore 対象)です。
npm install が失敗した場合:
- ネットワークやフィードの問題が原因である可能性は、拡張機能の pnpm インストールの場合より低いです。PCF ツールは Azure Artifacts ではなく、公開 npm レジストリから取得されます。
- それでも失敗する場合は、失敗した行を提示し、標準的な
npm installのトラブルシューティングを案内します。
npm run build が失敗した場合:
- マニフェスト XML のエラー — pcf-scripts は行番号とともにエラーを表示します。問題のあるプロパティを書き直します。
index.tsの TypeScript エラー — ほとんどの場合、マニフェストとコードのプロパティ名が一致していません。マニフェストの<property name="...">の値を読み直し、一致させます。
npm run build が成功するまで、.extension-state.md で PCF のスキャフォールドを完了としてマークしないでください。
Power Platform 環境へのデプロイ
このスキルでは行いません。準備ができたら /publish-pcf-companion を実行してください。これにより、デプロイ直前の前提条件チェック(.NET SDK とアクティブな pac auth)、環境の確認ゲート、pac pcf push が処理されます。このように分けることで、認証を設定せずにエンジニアがローカルで index.ts とマニフェストを反復的に編集し、作業を共有する準備ができてからデプロイできます。
ステップ 7 — 状態を更新して要約する
./.extension-state.md を更新します。
- フェーズ:
Last completed: scaffold-pcf/Next: /test-native-extension。 - 「Validation history」の下に、次の行を追加します:
PCF build (Layer 4): pass — <ISO timestamp>。 - 次の行を追加します:
Test harness: README ✓。5b.2 が成功した場合はcanvas-app-source ✓も追加します(成功しなかった場合はcanvas-app-source skipped — pa.yaml generation failed)。 - 「PCF deployments」セクションには触れないでください。デプロイは
/publish-pcf-companionの担当です。
次を出力します。
PCF scaffold complete
─────────────────────
Folder: pcf/<Pascal>PCF/
Manifest properties: 1 bound (<name>Json diagnostic), <count input> input, <count output> output
Dispatches: <name>/<receiver> → NativeModules.<nativeModule>.<method> via window.PowerApps.NativeExtension.sendAsync
Build: npm install ✓ | npm run build ✓
Deployment: not yet deployed (run /publish-pcf-companion when ready)
Next steps
──────────
1. Run /test-native-extension to validate the contract now that native + manifest + PCF all exist (Layer 0 cross-check + Layer 4 PCF compile) — cheap pre-flight before the binary build.
2. Run /generate-ppmplugin to produce the verified `.ppmplugin` binary bundle — it reads this PCF's composite key back so the manifest's receiver stays aligned.
3. Run /publish-pcf-companion to push the dispatcher PCF to a Power Platform environment (handles deploy prereq check, env confirmation, and `pac pcf push`).
4. (Iterate) If ARCHITECTURE §6 changes, re-run /generate-pcf-companion — it will detect the existing folder and ask before regenerating.
ステップ 8 — 次のステップに使うスキルを提示する
PCF の生成は、いくつかの判断を行うのに適したタイミングです。ディスパッチャー PCF ができたため、.ppmplugin バンドルのビルドとデプロイの両方が選択肢になります。shared/shared-instructions.md §9.1 に従い、もっともらしい次のスキルをすべて選択肢として提示し(「はい/いいえ」ではなく)、やめておくための選択肢も含めて AskUserQuestion を使用します。
Question: "What would you like to do next?"
Header: "Next step"
Options:
1. "Run /test-native-extension"
description: "Validate the contract now that all three sides exist — native module ↔ ./manifest.json ↔ this PCF (Layer 0 cross-check: composite key, nativeModule, methods, request/response/error parity) + Layer 4 PCF compile. Cheap structural pre-flight (seconds) that catches drift before the minutes-long binary build. Recommended next step."
2. "Run /generate-ppmplugin"
description: "Produce the verified .ppmplugin binary bundle end-to-end (manifest → build → assemble → audit). Reads this PCF's composite key back so the manifest's receiver stays aligned."
3. "Run /publish-pcf-companion"
description: "Deploy the dispatcher PCF to a Power Platform environment for Studio testing. Three gates: publisher prefix, version bump, env confirmation."
4. "Stay — I'll review the PCF code first"
description: "Skill exits. Inspect pcf/<X>PCF/index.ts and the manifest, decide what's next yourself."
選択肢 1(test)、2(generate-ppmplugin)、または 3(publish)の場合: 同じターン内に Skill ツールでそのスキルを呼び出してください。 選択肢を選ぶことは、スキルの実行を依頼することです(説明するのではなく実行してください — shared-instructions §9.1 の絶対ルール)。そこで止めて、ユーザー自身に実行するよう伝えてはいけません。呼び出されたスキルが独自の前提条件チェックとゲートを実行します。
選択肢 4(stay)の場合: 次の1行を出力します:PCF scaffold complete. Run any of the suggested skills when you're ready. その後、return-status に進みます。
(診断ログが必要ですか? 必要であればお尋ねください。リクエストに応じて、どのコード領域にも console.log / NSLog / Log.d を追加できます。専用のスキルはありません。注:wrap ランタイムでは、サードパーティ製コントロールのネイティブログは通常の開発ツールでは表示されないため、ここではログの用途は限られます。)
Return-status プロトコル
最終メッセージの文字どおり最初の行は、必ず次のいずれかにしてください。
| コード | 意味 |
|---|---|
DONE | PCF が生成され、マニフェストが PRD と一致し、npm run build が成功しました。状態ファイルも更新済みです。 |
DONE_WITH_CONCERNS: <list> | ビルドは成功しましたが、致命的ではない警告があります(例:ストリーミングパターンに UI 作業が必要で、スキャフォールドでは TODO のままです)。 |
NEEDS_CONTEXT: <missing> | 必須の ARCHITECTURE §6 サブセクションが不完全で、先に進めませんでした。 |
BLOCKED: <reason> | 前提条件チェック、pac pcf init、または自動修正できなかった npm run build が失敗しました。 |
最初の行の後に空行を入れ、その後に人が読める要約を記載します。
このスキルの対象範囲 — 生成と監査
このスキルはPRDからPCFコードを生成します。リンターではなく、新しいテンプレートに照らして既存の動作中のコードを監査するものではありません。すでに pcf/ フォルダーがあるリポジトリでスキルを再実行すると、再生成/再開/中止の選択肢を提示し、上書きする前に確認します。新しいテンプレートを一から作成したい場合を除き、ユーザーは「再開」または「中止」を選ぶ必要があります。
**「今生成するとしたらどうなるか」と「既存のコード」の差分は、欠陥の一覧ではありません。**異なるコード構造で同じ実行時の動作を実現している既存のPCFコードに問題はありません。スタイル上の差異を問題として列挙しないでください。指摘するのは、次のものだけです。
- コンパイルできないコード(
npm run buildが失敗する) - 不正な出力を生成するコード(フィールド名が間違っている、クラッシュを伝播させてしまうエラーハンドリングの欠如など)
- ディスパッチ契約に対する真の違反(複合キーの誤り、
method名の誤り、または@ReactMethodが解析できず実行時に問題を引き起こすペイロード形式)
BridgeResponse 型を別の名前で定義していても、欠陥ではありません。setSuccess/setError の代わりに applyErrorState(code) メソッドを使っていても、欠陥ではありません。extractResponse の代わりに、{status,…} オブジェクトに到達する独自のアンラップ処理を実装していても、欠陥ではありません。テンプレートでは特定の形式を推奨していますが、同等の形式は「誤り」ではありません。
ハードルール — 正確性(PCFが動作するために必須)
- **内側の
pcf/<Pascal>PCF/パッケージのPCFツールでは、pnpmではなくnpmを使用します。**これはPCFエコシステムの慣例です。混在させるとロックファイルが混乱します。 - **マニフェストの名前空間は必ず
PowerAppsにします。**ユーザーが変更できないようにしてください。これは、Canvasがネイティブ拡張PCFをグループ化する方法です。 - **複合キー(
COMPOSITE_KEY定数)は、manifest.jsonの<name>/<receiver>と一致し、マニフェストに登録されているレシーバーとも一致しなければなりません。**一致しない場合、ラップホストはルーティングできず、実行時に何も通知されないまま失敗します。 - **
METHODは、receivers[].methodsにある実際の@ReactMethod/RCT_EXPORT_METHOD名でなければなりません。**存在しないメソッドを指定すると、デバイス上でmethod '<m>' not foundになります。 - **
<Pascal>Requestの必須フィールドはすべて、ディスパッチする引数に含めなければなりません。**フィールドが欠けていると、ネイティブ側でundefinedとして解析され、実行時にINVALID_INPUTになります。 display-name-keyとdescription-keyには、プログラマー向けの_Display/_Descで終わるキーではなく、人が読める文字列を指定しなければなりません。.resxが含まれない場合、Studioにはこれらがそのまま表示されます。- **ARCHITECTURE §6.1(出力プロパティ)で宣言した出力はすべて、
getOutputs()から返さなければなりません。**TypeScriptではIOutputsの型付けにより検出できます。これを無効にしないでください。 - スキャフォールドの成功を宣言する前に、
npm run buildが成功しなければなりません。 - **ARCHITECTURE §6 のマニフェストプロパティ型は、PCFで有効な型(
SingleLine.Text、Whole.Noneなど)でなければなりません。**モジュールの応答フィールド型を、対応するPCFプロパティ型に割り当ててください。 - **
usage="bound"のプロパティは、未加工応答の診断用である<name>Jsonの1つだけにし、それ以外は指定しないでください。**すべてのドメインプロパティは、メイカーが設定するinputまたはコントロールが生成するoutputにしてください。
推奨テンプレートのスタイル(新しいスキャフォールドで推奨。既存の動作するコードは問題ありません)
これらは、新しく生成される PCF コードの最もシンプルな形を示しています。このスキルが新しい出力を生成するときに採用するスタイルです。異なる方法を採用していても、既存のコードが動作しているなら違反ではありません。監査時に、スタイルの違いを欠陥として指摘しないでください。
JSON.parse(result.data)を直接使うより、extractResponseヘルパーを優先してください。ラップホストはレスポンスを再度文字列化し、さらに{ isUpdate, message }コンテナーに格納します。extractResponseは文字列の階層を解析してmessageコンテナーを展開します(すでにトップレベルにstatusがある場合はresult.dataをそのまま返します)。単一のparseだけではコンテナーが返されるため、ネイティブ側の処理が成功していてもUNEXPECTED_PAYLOADになります。{status,…}オブジェクトに到達するインラインの展開処理でも問題ありません。- 解析した結果を
<Pascal>Responseに絞り込み、場当たり的な形状の検出ではなくresponse.statusに基づいて分岐する方法を優先してください。同じ結果になる防御的なコードでも問題ありません。 <Pascal>Request/<Pascal>Responseはインラインで定義する方法を優先してください(ネイティブ専用バンドルにはインポートできる共有 TS レイヤーがありません)。@ReactMethodが解析して解決する内容と正確に一致させてください。各呼び出し箇所にインライン注釈を散在させると、ずれが生じやすくなります。- 独自に構築した場当たり的なエンベロープではなく、コンポジット
COMPOSITE_KEYでディスパッチするsendAsyncエンベロープ{ method, args: [request] }を優先してください。このエンベロープは生のオブジェクトです。PCF はこれを文字列化しません(sendAsyncが文字列化します)。PCF 側で先に文字列化すると二重エンコードされ、BRIDGE_FAILEDになります。cordova.execを直接呼び出さないでください。 - レスポンス分岐には、
setSuccess/setErrorヘルパーを使ったネストされた if/else を優先してください。同じケースを処理するapplyErrorStateメソッドも、機能的には同等です。
スキルが生成時に適用するルール(新規コードを生成する場合のみ)
新しい index.ts を一から生成する場合、スキルは上記のテンプレートに従います。厳守事項:
-
生成ファイルに
<placeholder>というテキストを含めないでください。 PRD の値をすべて置き換えるか、妥当なデフォルト値を使用してください。コンパイルできるプレースホルダー(実際のクラス名に置き換えられる<Pascal>など)は問題ありませんが、実行時に使われるリテラルの<PLACEHOLDER>文字列は含めないでください。 -
ARCHITECTURE §6.1(出力プロパティ)で宣言された出力をすべて対応付けてください。 各出力には、
setSuccess(または同等の処理)での代入、フィールド宣言時の初期化、getOutputs()の返却エントリーが必要です。出力が不足していると、IOutputsにより TypeScript がエラーを報告します。 -
Step 1.5 で
manifest.json receivers[]とネイティブソースを照合してください。nativeModuleやメソッド名にずれがある場合は、本当の不具合としてNEEDS_CONTEXTで停止してください。これはユーザーが整合させる必要があります。 -
ブロックされた場合に限り、
NEEDS_CONTEXTで停止してください。 本当にブロックとなるのは、manifest/ARCHITECTURE の名前とレシーバーが不足しており(複合キーを導出できない)、ARCHITECTURE §4 が不足しており(リクエスト/レスポンスの形式が不明)、ARCHITECTURE §6 が不足している(PCF のインターフェースが不明)場合です。停止すべきではないもの:ARCHITECTURE §6.3(エラー UX のマッピング)で特定のエラーコードに対する UX の詳細が不足している場合(デフォルトのエラーマッピングを使用)、§8.2 で設定可能な入力のデフォルト値が不足している場合(型に応じた自然なデフォルト値を使用)、PRD §1 の要約が簡潔な場合(表現上の理由で停止する必要はありません)。 -
生成コードで許容されるもの(不具合ではありません):
- エンジニアの判断が必要な項目への
// TODO:または// Customize:コメント。該当するのは、SDK がまだ対応していない場合のストリーミング購読の配線、プレビューサムネイルのレイアウト、ARCHITECTURE §6.1(設定可能な入力)で指定されていないボタンの角丸やアニメーションのタイミング、PRD で意図的に保留されているエッジケースの UX などです。根拠のないデフォルト値を作り上げるより、明確な注釈付き TODO を記載するほうが適切です。 - ARCHITECTURE §6.3(エラー UX のマッピング)に明示されていないコードに対する、
setError内の 汎用エラーマッピングへのフォールバック。status='error'、errorCode=code、errorMessage=message、result-fields=defaultsとする「すべてのコード →」のデフォルト分岐は、PRD で個別に指定されていないコードへの正しい対応です。認識されないコードであっても、メッセージは常に引き継がれます。 - 省略可能なレスポンスフィールドへの防御的な
?? <default>。<Pascal>Responseでレスポンスフィールドの型がstring | undefinedの場合、読み取り箇所でのresult.fieldX ?? ""は適切です。ソースが省略可能だからといって、コンパイルを拒否しないでください。
- エンジニアの判断が必要な項目への
既存の PCF フォルダーを見つけた場合、ゲートでは再生成/再開/中止を選べます。その選択はユーザーに委ねられます。再開または中止では既存のコードが保持され、再生成では上書きされます。ユーザーが明示的に再生成を依頼しない限り、スキルは既存のコードに異議を唱えません。
生成される PCF に備えるべきランタイムフォールバック
これらは正しさを高めるものであり、スキルによって実装されていることを保証すべきです。PCF のバグではない予期しないランタイム条件に対して、PCF の耐性を高めます。
- Studio プレビュー用の
invokeBridge周辺の try/catch(ホストのグローバルが注入されておらず、NOT_IN_WRAPがスローされる)→setError("NOT_IN_WRAP", <reason>)を呼び出し、汎用的な内部エラーとして覆い隠さずに、wrap 外の状態を診断できるようにします。テンプレートに実装済みです。 invokeBridge内のextractResponseで、ホストによる再文字列化を取り除き、wrap の{ isUpdate, message }レスポンスコンテナーをresult.dataからアンラップします。完全に失敗した場合はPARSEをスローします。生のレスポンスも<name>Json診断出力に記録します。テンプレートに実装済みです。- 構造化 JSON ロギング(各ディスパッチステップで、タップごとの
seqを付けてlogDebug/logErrorを使用)により、WebView のコンソールにほとんどアクセスできない場合でも、grep 可能な 1 行から障害を診断できます。テンプレートに実装済みです。 <name>Jsonによるデバイス上の診断(usage="bound")では、生のブリッジレスポンスを提示します。リリース用 wrap ビルドで WebView のコンソールにアクセスできない場合でも、作成者はSelf.<name>Jsonを通じて返された内容を確認できます。テンプレートに実装済みです。setErrorのデフォルトエラーマッピングでは、スキルが予測できなかったコード(リリース間に wrap シェルまたはモジュールが新しいコードを出力した場合)に対して、status='error' とし、コードとメッセージをそのまま提示し、結果フィールドをゼロ値にします。UNEXPECTED_PAYLOAD分岐では、解析されたレスポンスが<Pascal>Responseの形状と一致しない場合に、setError("UNEXPECTED_PAYLOAD", <serialized-payload-snippet>)を呼び出します。デプロイ済みモジュールのバージョン差異を、TypeError を伝播させずに適切に処理できます。さらに、このスニペットで実際に届いた形状を正確に確認できます。- レスポンスフィールドを出力にマッピングする際の オプションフィールドのガード(
result.fieldX ?? <default>)。<Pascal>Responseでレスポンスフィールドがオプションと記載されている場合、PCF は防御的に読み取ります。
これらはランタイムの安全策であり、テンプレートのスタイルではありません。ランタイムに予期しないことが起きても PCF が適切に縮退できるようにするもので、ほかのコードスタイルの判断にかかわらず、実装が認められ、推奨されています。
生成される PCF に備えるべき診断ログ
本番環境で、PCF が動作しないときに最もよくある問題は、「何が起きたのか、まったく分からない」ということです。Studio のデバッガーはデバイス上では使用できません。Power Apps Mobile のログ出力先は、デバイスのコンソール(iOS の Xcode Console、Android の adb logcat)にリダイレクトされた console.log です。
生成される PCF は、再デプロイして追加の計装を行わなくても本番環境でデバッグできるよう、判断ポイントで一貫したプレフィックスを付けて console.log を出力すべきです。
// Pattern: console.log("[<Pascal>PCF] <event>:", <relevant context>);
console.log("[<Pascal>PCF] Dispatching:", COMPOSITE_KEY, METHOD, request);
console.log("[<Pascal>PCF] Bridge returned - status:", response?.status, "error:", response?.error);
console.log("[<Pascal>PCF] Module success - <key field>:", response.<field>);
console.error("[<Pascal>PCF] Module error:", response.error);
console.error("[<Pascal>PCF] Unexpected payload shape:", response);
console.error("[<Pascal>PCF] Bridge error:", code);
console.log("[<Pascal>PCF] Output state - status:", this.status, "errorCode:", this.errorCode, "errorMessage:", this.errorMessage);
これならログが過剰にならず、フィールドで発生した問題をログダンプから診断するのに十分な情報が得られます。[<Pascal>PCF] をプレフィックスとして付けると、adb logcat | grep PCF でフィルターできます。
このテンプレートに組み込まれた教訓(本番向け PCF の知見)
以前の拡張機能から苦労して学んだことを踏まえ、今後の PCF で同じ失敗を繰り返さないよう、テンプレートがデフォルトで対処します。
| 教訓 | テンプレートでの対処方法 |
|---|---|
ホストは result.data を再度文字列化し、さらに { isUpdate, message } コンテナーでラップします。生の読み取り結果は {isUpdate, message}(status なし)となり、UNEXPECTED_PAYLOAD が発生します。 | invokeBridge は result.data を extractResponse に渡します(文字列レイヤーを解析し、message コンテナーをアンラップ)。オブジェクト以外の場合は PARSE になります。 |
リリース用ラップビルドの WebView コンソールには、logcat や chrome://inspect からアクセスできません。 | テンプレートは、生のレスポンスを含む usage="bound" 出力 <name>Json を公開します。デバイス上で Self.<name>Json を使って読み取れます。 |
PCF がマニフェストに登録されたものとは異なるレシーバーをディスパッチしました(実際の不具合:PCF → Snapshot、マニフェスト → DeviceInfoExtension)。 | COMPOSITE_KEY は manifest.json から解決される <name>/<receiver> です。ステップ 1.5 で、登録済みのレシーバーと一致することを確認します。 |
不明なメソッドがディスパッチされ、デバイス上で method '<m>' not found が発生しました。 | METHOD は receivers[].methods から取得し、ステップ 1.5 で実際の @ReactMethod / RCT_EXPORT_METHOD に対して検証します。 |
表示名に _Display_Key という文字列がそのまま表示され、作成者を困惑させます。 | マニフェストでは、PRD §2 / §8 の人間が読めるテキストを使用します。 |
発行元のプレフィックスが 8 文字を超えると、pac pcf push で拒否されます。 | /publish-pcf-companion の Gate 3.0 で、長さが 2~8 文字であることを検証します。 |
| ハードコードされたパスは、レイアウトが異なると機能しません。 | find pcf -name ControlManifest.Input.xml を使い、ネストの深さにかかわらず検索します。 |
| 計測を組み込んで再デプロイしないと、フィールドでの問題を観測できません。 | 判断ポイントでの診断ログをデフォルトで有効にします。 |
| バージョンを上げずに再公開すると、アプリのキャッシュが更新されません。 | /publish-pcf-companion の Gate 3.1 で、バージョンを上げるよう促します。 |
その他の運用ルール
- 明示的な確認ゲートなしに PCF をプッシュしないでください。 デプロイは
/publish-pcf-companionの役割です。このスキルはローカルのみで動作します。 - ここで PCF のエンドツーエンドテストをしようとしないでください。
.ppmpluginバンドルを読み込んだラップビルドが必要です。これは/test-native-extension(手動の手順)の役割です。 - 双方向パターン(ARCHITECTURE §2 = two-way):ARCHITECTURE §3. の仕様に従って、フォローアップボタンを追加します。§3. に UI の指定がない場合は、
NEEDS_CONTEXTで停止します。 - ストリーミングパターン(ARCHITECTURE §2 = streaming):ラップの
SendMessagePluginブリッジは、PCF 側のサブスクリプション API を公開していません。ファイル先頭のコメントでストリーミングの制約を示したうえで、一度だけ送信する構造を出力し、DONE_WITH_CONCERNSで停止します。