关于 generate-pcf-companion
generate-pcf-companion 是为现有原生模块生成 Power Apps Component Framework 控件的技能。它从已提交的 manifest.json 获取调用信息,并通过本地构建检查生成的 PCF。
主要功能
- 01生成 PCF 控件脚手架
- 02读取已提交的 manifest.json
- 03通过 sendAsync 路由调用
- 04校验 PCF 生命周期与清单
- 05运行本地构建检查
适用场景
- 01为现有原生模块添加 Canvas Studio 控件
- 02将 PCF 操作连接到原生方法
- 03部署前检查生成的 PCF
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ power-platform-skills --skill generate-pcf-companion安装后在 Claude Code 中输入/generate-pcf-companion确认已加载。
详细介绍
/generate-pcf-companion
生成 dispatcher PCF——Canvas Studio 控件,通过宿主注入的 window.PowerApps.NativeExtension.sendAsync 全局对象调用第三方原生模块,并根据从已提交的 ./manifest.json 中读取的组合键 <name>/<receiver> 进行路由(该文件是 /generate-native-extension 在脚手架阶段生成的权威来源)。位于与原生模块相同仓库的 pcf/<Pascal>PCF/ 中。PCF 是 Studio 端的配套组件;它不属于 .ppmplugin 包(该包仅包含原生二进制文件——manifest.json + android//ios/)。
此技能假定仓库中已存在原生模块。请在模块就位后运行。
PCF 框架参考(Microsoft Learn 公开文档)。 请以 Power Apps Component Framework 官方文档为准,核对
pac pcf init、ControlManifest.Input.xml架构、init/updateView/getOutputs/destroy生命周期,以及usage(bound/input/output)规则;当此技能中的模板与实际框架不一致时,官方文档具有权威性。(sendAsync传输方式和extractResponse响应解包细节属于此工作流自身的内容,见shared/ppmplugin-format.md §2——不在这些通用 PCF 文档中。)
- 概览: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——此独立工作流没有“基线”检查)。此处不会检查 .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.此技能需要生成的结构模式(manifest 形状、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(PCF 沙箱中不会暴露原始 cordova 全局对象;直接调用在设备上会静默无操作,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> 是宿主解析到模块的依据;方法是该接收器 methods[] 中的一个条目。一个 PCF 通过此全局对象同时驱动 iOS 和 Android — 无需平台分支。
⚠️ 两项不变量 — 均已在设备上确认;任一项出错都会导致静默失败:
- 通过
sendAsync分发,绝不要使用cordova.exec。信封是一个原始对象{ method, args: [request] }— PCF 不会将其字符串化;sendAsync会在内部执行JSON.stringify。如果 PCF 直接调用cordova.exec,或预先将负载字符串化,第一次在设备上点击时就会静默失败(屏幕上没有错误;不会进行任何分发 — Android 上尤其严重)。- 内部的
args必须是 JSON 数组(ppmplugin-format §2)。解析信封后,代理会运行Array.isArray(parsed.args) ? parsed.args : [],然后执行fn.apply(mod, args)— 将其展开为位置参数。单独一个对象 → 被丢弃 → 原生方法收不到请求数据。我们的约定:args: [request]— 一个请求对象,且原生方法的第一个参数恰好是一个ReadableMap/NSDictionary。
当 status === "ok" 时,sendAsync 会解析为 result.data — 即原生方法解析出的字符串。wrap 宿主会再将其字符串化一次,并且将其嵌套在 { isUpdate, message } 传输容器中,因此 PCF 会使用 extractResponse 辅助函数对其进行规范化(解析 result.data,然后 — 如果解析出的对象没有顶层 status — 就解开 message 容器,以取得模块的 {status, result} 对象;完全失败时 → PARSE 错误)。如果只解析一次,会得到容器并导致每次调用都失败,报错 UNEXPECTED_PAYLOAD,尽管原生代码已成功 — 请参阅 shared/ppmplugin-format.md §2。status !== "ok" → 显示 result.error(否则使用 BRIDGE_FAILED);缺少宿主全局对象 → NOT_IN_WRAP。
必须读取的内容 — 必须在 Step 2 之前成功读取
-
从已提交的
./manifest.json中解析复合路由键<name>/<receiver>— 这是/generate-native-extension在搭建脚手架时写入的事实来源,因此在正常流程中,此技能运行时它已经存在。优先使用它;然后回退到暂存副本,只有在尚不存在 manifest(手动编写的模块)时才使用 ARCHITECTURE。跨操作系统兼容:使用 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 注册的接收器 MUST 一致 — 如果 PCF 分发
<name>/<receiver>,而 manifest 注册了不同的接收器,则首次分发时会失败。由于./manifest.json在此技能运行之前(native-gen 时)编写,PCF 应遵循 manifest 中的接收器 — 必须绑定其声明的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 *)moduleNameMUST 与receivers[].nativeModule相同 - PCF 分发的每个
methodMUST 是模块中实际的@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 注册的接收器不一致 | PCF 和 manifest 必须使用相同的键。./manifest.json 最先编写(在 native-gen 时),因此 PCF 应遵循 manifest — 绑定其声明的 receivers[].name。(仅当用户有意在 PCF 中重命名接收器时,才更新 ./manifest.json 以保持一致并重新运行。) |
宿主 sendAsync 的载荷/响应线格式 ↔ 此技能生成的内容 | 确切的信封格式由宿主全局对象和 wrap proxy 定义;请对照 shared/ppmplugin-format.md §2 确认。PCF 仅保证复合键和桥接最终指向的 method。 |
使用解析后的契约 — 而非 PRD 的 §5.1/§5.2 — 作为 Step 5 中 PCF 分发参数和响应解析的依据。PRD 描述的是意图;./manifest.json + 原生代码才是实际的分发契约。三者一致时,它们相互吻合;如果不一致,以原生源代码为准(因为运行中的应用看到的就是它)。
Step 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 脚手架吗?
- Yes — 运行
pac pcf init,编写/重写文件,运行npm install+npm run build进行冒烟检查。全部在本地完成 — 不部署到环境。- Edit the PRD first — 退出;用户运行
/design-native-extension-feature修复 §8。- Cancel
部署到 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— 单一绑定值控件。符合「对 Maker 设置的输入触发原生操作」这一模式。v0 不要使用dataset。--framework none— 原生 DOM。不使用 React。这样可以保持捆绑包精简,并避免与主机托管构建所用 React 版本之间的兼容性问题。
pac pcf init 会创建 pcf/<Pascal>PCF/,其结构如下:
<Pascal>PCF.pcfproj(MSBuild 项目)package.json(使用 npm,这是 PCF 工具的约定)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 成功后,还要编写 pcf/README.md(比控件文件夹高一级)。章节如下:
- 概述 — 根据 PRD §1 用一段话说明此 PCF 的作用。
- 不包含在 npm 压缩包中 — 明确说明 PCF 文件夹已从
package.json的files数组中排除;它通过pac pcf push部署到 Power Platform,而不是通过 npm。 - 属性 — 根据 ARCHITECTURE §6 编写三个简短表格(绑定、可配置、输出)。
- 构建与迭代 —
npm install、npm run build、pac pcf push --publisher-prefix <2–8 char prefix>(前缀选择请参阅/publish-pcf-companion)。 - 触发器行为 — 根据 ARCHITECTURE §6.2 写一行。
控制在约 50 行。根据 PRD 为每个章节量身编写,不要编造模板内容。
步骤 4 — 重写 ControlManifest.Input.xml
pac pcf init 会生成一个单属性清单。请按 ARCHITECTURE §6 完整重写。
display-name-key 和 description-key 请使用易读文本。 这些属性会显示在 Power Apps Studio 的属性面板中,供创建者查看——它们不只是内部键。此脚手架未提供 .resx 资源文件,因此 Studio 会原样显示属性值。请使用友好的标签和句子,而不要使用程序员风格的键。
⚠️ 硬性规则——这些属性中不得包含撇号(也不得包含未转义的
<、>、&)。pac pcf push会根据 XSD 验证清单,其中display-name-key/description-key的类型为noAposStringType——包含字面 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 → SingleLine.Text,除非上下文表明它是 URL)。
标准诊断输出 — 始终发出这三项
除了操作的结果输出(以及上文的 <name>Json 原始响应绑定输出)之外,每个分发器 PCF 都必须声明三项诊断输出(全部为 of-type="SingleLine.Text"、usage="output")。对于第三方控件,这一点尤为重要:原生二进制文件运行在客户的包装 Shell 中,无法访问 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……);成功时为""。Maker 可据此进行分支判断。ErrorMessage— 原生端附带的人类可读message(异常文本、出错字段、被拒绝的权限);成功时为""。现场发生故障时,Maker 或支持工程师首先会查看此字段——没有它,故障就会悄无声息地被忽略。
这两个调试输出相辅相成:<name>Json 显示原始线路字节(传输层取证);ErrorCode/ErrorMessage 显示已解析的结构化故障(原生模块表达的含义)。Maker 可采用以下模式:If(Self.Status = "error", Notify(Self.ErrorMessage, NotificationType.Error))。在 IOutputs 中声明全部三项,在 setError / setSuccess 中设置它们,并从 getOutputs() 返回。
可选的视觉样式输入(颜色 + 边框)——仅在用户提出要求时添加
每个分发器 PCF 都自带美观的主题默认样式(参见第 5 步中的 applyStyles() / ensureStyleTag()),并遵循宿主 Fluent 主题,因此即使不添加任何额外输入,也不会渲染成原始浏览器按钮。默认情况下,不要添加面向 Maker 的颜色/边框输入;这会让不需要这些选项的控件属性面板变得杂乱。
仅当 PRD / 用户明确要求 Maker 可配置颜色或边框选项时,才添加这些输入。 如果有此要求,只添加所需的选项(从下方集合中选择),并设置为 usage="input"、required="false";除数值型圆角半径外,均使用 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>存在时才读取,因此省略它们不会影响默认外观。 - **对比度有保障,无需 Maker 操心。**如果添加了
AccentColor并为其设置值,但TextColor为空,applyStyles()会根据亮度计算黑色或白色,确保标签始终达到 WCAG AA 对比度——Maker 无法意外创建出标签不可见的按钮。 - 不要添加宽度/高度/字号输入——控件尺寸由宿主框决定;尺寸输入会与画布调整大小的控件发生冲突。
按属性选择 usage——在发出任何 <property> 之前作出决定
usage 是必需属性,也是最常见的出错点。清单架构准确规定了三个取值(属性元素参考)——属性“表示组件可更改的列(bound)、只读列(input)或输出值(output)”。请谨慎选择;选错会让 Maker 的输入面板变得杂乱(所有内容都变成可输入的字段),或隐藏本应可供公式读取的值(将输出误标为 bound)。
对于每个属性,请按顺序执行此判断——以第一个匹配项为准:
- 此控件是否会生成此值供创作者读取?(状态、结果、当前值、上次错误、计算/返回的文本——创作者在 Power Fx 中以
Self.<Name>/<Control>.<Name>引用的任何内容)→output。这是原生操作返回的所有内容的默认类型。如果创作者在公式中读取它,它就是输出——绝不能是bound。 - 创作者是否会设置/配置此值?(URL、表名、ID、颜色、间隔、开关、JSON 配置——在属性面板中输入或选择,或绑定到某个字段以供引用)→
input(required="false",并提供default-value)。 - 是否存在一个主要列,控件既会显示它,也会将更改写回?(双向编辑——文本编辑器、滚动条、图表)→
bound(至多一个)。否则,完全不要使用 bound 属性。
usage | 含义(权威定义) | 创作者 / Studio 行为 | TS 接线方式 |
|---|---|---|---|
output | 控件生成的值。由控件写入;创作者只读取。 | 在输入面板中隐藏;可在 Power Fx 中以 Self.<Name> 读取。 | 在 IOutputs 中声明;从 getOutputs() 返回;绝不从 context.parameters 读取。 |
input | 只读输入。由创作者提供——静态值(default-value)或绑定字段——控件读取但绝不写入。 | 属性面板中的可编辑字段。 | 通过 context.parameters.<Name>.raw 读取;不在 getOutputs() 中。 |
bound | 控件可以更改的列——双向。控件读取字段并将其写回。至多一个;操作/配置控件不要使用。 | 绑定到 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"——请根据上述判断方法重新分类每个属性。原生操作返回的值就是output,即使 §8 将其列在输入下也是如此。
**如果之后需要真正的本地化,**规范的 PCF 模式是在此处放置资源键(例如
<PropertyName>_Display),并创建strings/<Pascal>PCF.1033.resx(以及每种语言环境对应的其他.resx文件),将键映射到本地化字符串。对于没有国际化要求的 v0,像上文那样使用普通字符串是正确且更友好的做法。
编写后,请使用 pac pcf build --no-restore 验证 XML 是否能解析,或检查构建时 pcf-scripts 是否生成了 <Pascal>PCF/generated/ManifestTypes.d.ts 文件。
步骤 5 — 编写 index.ts
步骤 5.0 — 根据 ARCHITECTURE §6.0 的视觉风格进行选择
生成之前,请阅读 ARCHITECTURE §6.0,了解 PCF 应呈现哪种视觉风格:
| §8.0 值 | 生成的 UI 形式 |
|---|---|
minimal(默认) | 单个主题化按钮。点击 → onTrigger()。输出由制作方的 Power Fx 读取;PCF 本身不会渲染输出。 |
with-preview | 按钮 + 预览窗格。预览使用 <img>(用于 base64 PNG / 数据 URI 等图像输出)、<div>(用于扫描结果等文本输出)或 <span>(用于状态)。预览从成功输出字段读取内容,并在底层值变化时于 updateView 中更新。 |
inline-surface | 自定义 — PCF 自行渲染交互界面,而不是触发原生模态框。v0 会输出 // TODO: design the inline surface 占位符,并以 DONE_WITH_CONCERNS 停止。 |
下面的骨架适用于 minimal 模式。若要适配 with-preview,请添加预览元素和 renderPreview() 方法,并在 updateView 中以及 setSuccess 之后调用该方法。对于 inline-surface,此骨架不适用 — 请参阅 v1+ 指南。
请生成完整可运行的代码,不要生成带占位符的骨架。 index.ts 中的每一行都源自特定来源 — 调用参数和响应结构来自第 1.5 步中确定的原生事实依据契约,而不是直接来自 PRD。PRD 描述的是意图;实际运行的应用交换的内容以 manifest.json + 原生源代码为准。
index.ts 中的区块 | 来源 |
|---|---|
COMPOSITE_KEY + METHOD 常量 | manifest.json → COMPOSITE_KEY = "<name>/<receiver>"(复合路由键)和 METHOD = "<one of receivers[].methods>"。复合键 MUST 与清单注册的 receiver 匹配。 |
| 类型 | 在此文件中内联定义 <Pascal>Request、<Pascal>Response(仅原生代码的包不包含可供导入的共享 TS src/types.ts——应根据 @ReactMethod 解析的参数,以及其解析出的 {status, result, error} 来建模)。 |
| 私有输出字段声明 | ARCHITECTURE §6.1(输出属性)(每个输出对应一个私有字段,类型取自 §8.3 的 Type 列,并初始化为安全的默认值——文本为 "",数字为 0,布尔值为 false)——再加上 <name>Json 原始响应诊断字段。 |
applyStyles() 函数体 | ARCHITECTURE §6.1(可配置输入)(每个可配置输入对应一条赋值语句——按钮文本、背景、前景、内边距等),使用 §8.2 中的实际属性名称。 |
onTrigger() 载荷构建(分发参数) | <Pascal>Request(@ReactMethod / RCT_EXPORT_METHOD 作为唯一的 ReadableMap/NSDictionary 参数读取的对象)。ARCHITECTURE §6.1(绑定输入)指出哪些可配置/绑定输入流入哪个字段。它随 { method: METHOD, args: [request] } 一起放入 sendAsync 信封——这是一个原始对象(PCF 不会对其进行字符串化;sendAsync 会),并且内部的 args MUST 是数组(§2)。 |
onTrigger() 结果分支 | 单个嵌套 if/else,涵盖两层错误模型的四种情况,每种情况都向 setError 传入代码和消息:① 桥接成功 + payload.status === "ok" → setSuccess(payload.result)。② 桥接成功 + payload.status === "error" → setError(payload.error, payload.message ?? "")(原生端提供的可读原因)。③ 桥接成功但载荷结构无法识别(即使 extractResponse 解包了外层 message 容器后仍无法识别)→ setError("UNEXPECTED_PAYLOAD", "native response shape not recognized: " + this.<name>Json.slice(0, 200))——显示原始线上的字符串,而不是解析后的对象。④ sendAsync 状态 !== "ok" / 解析失败 / 没有 host 全局对象 → setError("BRIDGE_FAILED", <result.error / reason>) / setError("PARSE", <raw string that failed to parse>) / setError("NOT_IN_WRAP", <reason>)。原始线上的响应也会通过 <name>Json 输出。最后只调用一次 notifyOutputChanged()。 |
setSuccess(result) 函数体 | <Pascal>Response["result"] → ARCHITECTURE §6.1(输出属性)。每个 §8.3 输出对应一条赋值语句,值取自对应的响应字段。设置 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(输出属性)——每个输出对应一个返回项,读取私有字段。MUST 包含 Status、ErrorCode 和 ErrorMessage(以及 <name>Json 原始输出),确保无需原生调试器也能在 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根据当前输出字段重新渲染预览(以防 maker 通过 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 / data 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 写入一个环境声明文件:
//! 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必须使用原始{ method, args: [request] }封装调用window.PowerApps.NativeExtension.sendAsync。在 PCF 中直接调用cordova.exec(或引用任何cordova.*)都是缺陷——PCF 沙盒未公开该接口,在设备上会静默失败(Android 上尤其严重)。 - 在
applyErrorState中涵盖 ARCHITECTURE §5 中的每个错误代码。 默认分支仅用于未知代码;不得依赖默认分支而跳过具体情况。 - 验证与 ARCHITECTURE §4.2 的对称性。 §8.3 中声明的每个输出字段都必须映射到 §5.2 中的响应字段(或映射到根据错误状态派生的常量)。如果 §8.3 声明了一个 §5.2 中没有对应字段的输出,请停止并询问用户。
模式覆盖
- 单次请求 — 上述结构已足够。生成完整代码。
- 流式传输(§6 = 流式传输)—
sendAsync分发会解析出一个响应;要支持流式传输,需要原生模块推送更新,PCF 通过事件订阅接收。封装桥接不会公开简洁的 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 = 双向)— 为每个操作添加第二个按钮,用于发送后续消息。根据 ARCHITECTURE §3. 的后续交互规范生成第二个按钮和处理程序(设计技能会在实现演练中记录该规范)。如果 §3. 未指定后续交互界面,则停止,并输出
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 步 — 生成手动验证测试框架
在 PCF 文件夹的上一级(扩展仓库根目录)生成 test-harness/,以便工程师构建真正的 Canvas 应用,将 PCF 接入其中,并在设备上确认端到端行为。
有两种可行方案;两者都应提供,成本较低:
5b.1 — 编写包含准确 Power Fx 配方的类型化 test-harness/README.md
编写 test-harness/README.md,其中包含根据 PRD 定制的分步说明。应包含以下章节:
# 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 工具从公共 npm 注册表拉取,而不是从 Azure Artifacts 拉取。
- 如果仍然失败,请指出失败的行,并引导用户参照标准的
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(测试)、2(生成 ppmplugin)或 3(发布): 在同一轮中通过 Skill 工具调用相应技能——选择该选项即表示请求运行该技能(执行,不要描述——shared-instructions §9.1 强制规则)。不要停下来让用户自行运行;被调用的技能会执行自己的前置条件检查和关卡。
对于选项 4(暂不继续): 输出一行:PCF scaffold complete. Run any of the suggested skills when you're ready. 然后继续执行返回状态流程。
(需要诊断日志?直接提出即可——可以根据请求在任何代码区域添加 console.log / NSLog / Log.d;没有专门的技能负责此事。注意:在 wrap 运行时中,第三方控件的原生日志不会通过常规开发工具显示出来,因此日志在这里的作用有限。)
返回状态协议
最终消息的第一行必须是以下内容之一:
| 代码 | 含义 |
|---|---|
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 类型不是缺陷。使用 applyErrorState(code) 方法,而不是 setSuccess/setError,不是缺陷。手动编写解包逻辑来获取 {status,…} 对象,而不是使用 extractResponse,不是缺陷。模板偏好某种结构,但等效结构并没有“错”。
硬性规则——正确性(这些规则必须满足,PCF 才能正常运行)
- PCF 工具使用 npm,而不是 pnpm,用于内部的
pcf/<Pascal>PCF/包。这是 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原始响应属性,不能有其他此类属性。 所有领域属性都必须是input(由创作者设置)或output(由控件生成)。
推荐的模板风格(适用于新脚手架;现有可正常运行的代码无需修改)
以下内容描述了新生成的 PCF 代码最简洁的形式,也是此技能生成全新输出时采用的方式。现有可正常运行的代码即使采用不同方式,也不算违规——审核时不要将风格差异标记为缺陷。
- 优先使用
extractResponsehelper,而不是直接使用JSON.parse(result.data)。wrap host 会重新序列化响应,并将其嵌套在{ isUpdate, message }容器中;extractResponse会解析字符串层并解包message容器(如果顶层已有status,则直接返回result.data)。只进行一次解析会得到容器 → 尽管原生端已成功,仍会出现UNEXPECTED_PAYLOAD。能够取得{status,…}对象的内联解包方式也可以。 - 优先将解析结果收窄为
<Pascal>Response,并根据response.status进行分支,而不是临时检测结构。能达到相同效果的防御性代码也可以。 - 优先内联建模
<Pascal>Request/<Pascal>Response(仅包含原生代码的包不带可供导入的共享 TS 层)。准确映射@ReactMethod的解析和返回内容;散布在各调用点的内联注解更容易出现偏差。 - 优先使用
{ method, args: [request] }形式的sendAsync信封,并在组合COMPOSITE_KEY上分发,而不是手动构造临时信封。该信封是一个原始对象——PCF 不会对其进行序列化(sendAsync会);若 PCF 预先序列化,就会导致双重编码 →BRIDGE_FAILED。绝不要直接调用cordova.exec。 - 响应分支优先采用配合
setSuccess/setErrorhelpers 的嵌套 if/else。能够处理相同情况的applyErrorState方法在功能上等效。
该技能在生成时强制执行的事项(仅在生成全新代码时)
从头生成新的 index.ts 时,该技能会遵循上述模板。严格要求:
- 生成的文件中不得包含
<placeholder>文本。 从 PRD 中替换每个值,或使用合理的默认值。可编译的占位符(例如将<Pascal>替换为实际类名)没有问题;但不能将字面量<PLACEHOLDER>字符串带入运行时。 - 映射 ARCHITECTURE §6.1(输出属性)中声明的每个输出。 每个输出都需要在
setSuccess(或等效位置)中赋值、在字段声明处初始化,并在getOutputs()中有返回项。缺少输出会导致 TypeScript 通过IOutputs报错。 - 在步骤 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、结果字段=默认值”,适用于 PRD 未自定义的代码。即使代码无法识别,也始终会传递消息。 - 对可选响应字段使用防御性写法
?? <default>。如果<Pascal>Response中的响应字段类型为string | undefined,则在读取处使用result.fieldX ?? ""是正确的;不要因此拒绝编译。
当该技能遇到现有的 PCF 文件夹时,关卡会提供重新生成 / 恢复 / 中止选项——由用户决定。恢复或中止会保留现有代码;重新生成会覆盖代码。除非用户明确要求重新生成,否则该技能不会擅自质疑现有代码。
生成的 PCF 应具备的运行时回退机制
这些措施有助于确保正确性——技能应确保它们存在。它们能让 PCF 在遇到意料之外的运行时情况(但这些情况并非 PCF 本身的 bug)时保持健壮:
- 在
invokeBridge外围添加 try/catch,用于处理 Studio 预览(未注入宿主全局变量,抛出NOT_IN_WRAP)→setError("NOT_IN_WRAP", <reason>),这样就能诊断“未在 wrap 中”状态,而不是将其掩盖为通用内部错误。模板中已包含。 - 在
invokeBridge中使用extractResponse,剥离宿主重新字符串化的数据,并从result.data中解包 wrap 的{ isUpdate, message }响应容器;彻底解析失败时抛出PARSE。原始响应也会被捕获到<name>Json诊断输出中。模板中已包含。 - 结构化 JSON 日志(通过每次点击的
seq使用logDebug/logError),记录每个分发步骤,这样即使几乎无法访问 WebView 控制台,也能通过一行可 grep 的日志诊断故障。模板中已包含。 - 设备端的
<name>Json诊断输出(usage="bound"),用于呈现原始桥接响应;在发布版 wrap 构建中无法访问 WebView 控制台时,开发者可以通过Self.<name>Json读取返回内容。模板中已包含。 - 在
setError中提供默认错误映射,用于处理技能无法预见的代码(wrap shell 或模块在不同版本间发出新代码)→status='error',原样呈现代码和消息,并将结果字段清零。 - 在解析后的响应与
<Pascal>Response结构不匹配时,走UNEXPECTED_PAYLOAD分支 →setError("UNEXPECTED_PAYLOAD", <serialized-payload-snippet>)。这样就能优雅地应对已部署模块版本的漂移,而不会让 TypeError 向上抛出;代码片段还会准确显示实际收到的数据结构。 - 映射响应字段到输出时,保护可选字段(
result.fieldX ?? <default>)。如果<Pascal>Response文档将某个响应字段标记为可选,PCF 就应防御性地读取它。
这些是运行时安全网,不是模板风格要求。它们能让 PCF 在运行时遇到意外情况时优雅降级;无论代码采用何种其他风格决策,都允许并鼓励使用这些措施。
生成的 PCF 应具备的诊断日志
在生产环境中,PCF 无法正常工作时最常见的问题是“我完全不知道发生了什么”。设备上无法使用 Studio 调试器;Power Apps Mobile 的日志由 console.log 重定向至设备控制台(iOS 上为 Xcode Console,Android 上为 adb logcat)。
生成的 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 重蹈覆辙:
| 经验 | 模板的处理方式 |
|---|---|
Host 会再次将 result.data 字符串化,并将其包装在 { isUpdate, message } 容器中;直接读取会得到 {isUpdate, message}(没有 status)→ UNEXPECTED_PAYLOAD | invokeBridge 会通过 extractResponse 处理 result.data(解析字符串层并展开 message 容器);非对象 → PARSE。 |
Release 包装构建的 WebView 控制台无法通过 logcat / chrome://inspect 访问 | 模板会公开 <name>Json 这个 usage="bound" 输出,其中携带原始响应,可在设备上通过 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 中的易读文本 |
pac pcf push 拒绝长度超过 8 个字符的发布者前缀 | /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。