mcpField
G
generate-pcf-companion

generate-pcf-companion

github.com/microsoft开发1.1k71 次安装今天更新
代码仓库

关于 generate-pcf-companion

generate-pcf-companion 是为现有原生模块生成 Power Apps Component Framework 控件的技能。它从已提交的 manifest.json 获取调用信息,并通过本地构建检查生成的 PCF。

主要功能

  1. 01生成 PCF 控件脚手架
  2. 02读取已提交的 manifest.json
  3. 03通过 sendAsync 路由调用
  4. 04校验 PCF 生命周期与清单
  5. 05运行本地构建检查

适用场景

  1. 01为现有原生模块添加 Canvas Studio 控件
  2. 02将 PCF 操作连接到原生方法
  3. 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 文档中。)


第 1 步 — 阅读共享文档和 PRD

  1. 阅读 shared/shared-instructions.md、shared/naming-conventions.md、shared/ppmplugin-format.md、shared/repo-layout.md。

  2. 应用各技能的最小前置条件策略(shared-instructions.md §1.5)。此技能只需要 Node 和 pac CLI——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 之前)执行部署前置条件单行检查。

  3. 阅读 ./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.

  4. 阅读 ./.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 — 无需平台分支。

⚠️ 两项不变量 — 均已在设备上确认;任一项出错都会导致静默失败:

  1. 通过 sendAsync 分发,绝不要使用 cordova.exec。信封是一个原始对象 { method, args: [request] } — PCF 不会将其字符串化;sendAsync 会在内部执行 JSON.stringify。如果 PCF 直接调用 cordova.exec,或预先将负载字符串化,第一次在设备上点击时就会静默失败(屏幕上没有错误;不会进行任何分发 — Android 上尤其严重)。
  2. 内部的 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 之前成功读取
  1. 从已提交的 ./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"
    fi
    

    PCF 绑定的分发键和 manifest 注册的接收器 MUST 一致 — 如果 PCF 分发 <name>/<receiver>,而 manifest 注册了不同的接收器,则首次分发时会失败。由于 ./manifest.json 在此技能运行之前(native-gen 时)编写,PCF 应遵循 manifest 中的接收器 — 必须绑定其声明的 receivers[].name。

  2. 读取 manifest.json 中的 receivers[](规范分发目标):

    • receivers[].name — 复合键中的 <receiver> 部分
    • receivers[].nativeModule — 宿主解析为 NativeModules.<nativeModule> 的名称(模块的 getName())
    • receivers[].methods — 宿主可能分发到的 Method 值;每个值都是实际的 @ReactMethod / RCT_EXPORT_METHOD 名称。PCF 的 onTrigger 会调用其中一个。
  3. 原生源代码(仅用于验证 — ios/RCT<Pascal>Module.m、android/src/main/java/.../<Pascal>Module.kt):

    • Android 的 getName() / iOS 的 + (NSString *)moduleName MUST 与 receivers[].nativeModule 相同
    • PCF 分发的每个 method MUST 是模块中实际的 @ReactMethod / RCT_EXPORT_METHOD(未知方法会在设备上报错 method '<m>' not found)
    • 如果原生代码与 manifest 不一致,则停止并提示 NEEDS_CONTEXT: native module drifted from manifest.json receivers[]; reconcile and re-run
组合解析后的契约
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.json
  • tsconfig.json
  • eslint.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(比控件文件夹高一级)。章节如下:

  1. 概述 — 根据 PRD §1 用一段话说明此 PCF 的作用。
  2. 不包含在 npm 压缩包中 — 明确说明 PCF 文件夹已从 package.json 的 files 数组中排除;它通过 pac pcf push 部署到 Power Platform,而不是通过 npm。
  3. 属性 — 根据 ARCHITECTURE §6 编写三个简短表格(绑定、可配置、输出)。
  4. 构建与迭代 — npm install、npm run build、pac pcf push --publisher-prefix <2–8 char prefix>(前缀选择请参阅 /publish-pcf-companion)。
  5. 触发器行为 — 根据 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 验证,但仍建议优先改写措辞。
  • 请转义或避免使用 &(&amp;)、<、>。这些属性中请使用纯 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)。

对于每个属性,请按顺序执行此判断——以第一个匹配项为准:

  1. 此控件是否会生成此值供创作者读取?(状态、结果、当前值、上次错误、计算/返回的文本——创作者在 Power Fx 中以 Self.<Name> / <Control>.<Name> 引用的任何内容)→ output。这是原生操作返回的所有内容的默认类型。如果创作者在公式中读取它,它就是输出——绝不能是 bound。
  2. 创作者是否会设置/配置此值?(URL、表名、ID、颜色、间隔、开关、JSON 配置——在属性面板中输入或选择,或绑定到某个字段以供引用)→ input(required="false",并提供 default-value)。
  3. 是否存在一个主要列,控件既会显示它,也会将更改写回?(双向编辑——文本编辑器、滚动条、图表)→ 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 时,在上述骨架中添加预览窗格:

  1. 在 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);
    
  2. 更新 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";
      }
    }
    
  3. 更新 setError 以隐藏预览(或显示占位内容):

    private setError(code: string, message: string): void {
      // ... map error state as before (set status/errorCode/errorMessage) ...
      this.previewEl.style.display = "none";
    }
    
  4. 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 运行时中,第三方控件的原生日志不会通过常规开发工具显示出来,因此日志在这里的作用有限。)


返回状态协议

最终消息的第一行必须是以下内容之一:

代码含义
DONEPCF 已生成,清单与 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 代码最简洁的形式,也是此技能生成全新输出时采用的方式。现有可正常运行的代码即使采用不同方式,也不算违规——审核时不要将风格差异标记为缺陷。

  • 优先使用 extractResponse helper,而不是直接使用 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/setError helpers 的嵌套 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_PAYLOADinvokeBridge 会通过 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 foundMETHOD 取自 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。

评论

支持 Markdown