| 方案 |
UI形式 |
用户感知 |
| AppIntent |
跳转主App全屏 |
离开当前应用,体验断裂 |
| ShareExtension |
底部弹出半屏 |
不离开当前应用,体验流畅 |
用户明确要求必须用ShareExtension方式。
坑3:竞品也不是从列表里选出来的
仔细观察竞品的快捷指令后发现一个关键细节:竞品的App名称【App B】并不是从"分享"操作的App列表中选出来的,而是预先绑定在快捷指令文件中的。一旦绑定了,就无法再切换——即便切换到其他App,也无法再切回来,因为列表里根本找不到。
这意味着竞品的方案是:通过预编译的 .shortcut 文件,将App信息直接写死在快捷指令中。
坑4:未签名的.shortcut文件无法在iOS 15+上导入
明确了方向后,我开始尝试生成 .shortcut 文件。iOS快捷指令的文件格式是 .shortcut,本质上是一个签名后的AEA1容器,内含二进制plist格式的 WFWorkflow 结构。
我用Python生成了未签名的 .shortcut 文件(本质就是一个plist),然后在iPhone上尝试导入,结果报错:
无法打开快捷指令
不支持导入未签名的快捷指令选项,请使用其他共享选项
iOS 15+ 要求 .shortcut 文件必须经过签名才能导入。
坑5:macOS shortcuts sign 会清空操作内容
macOS 提供了签名工具:
shortcuts sign -i input.shortcut -o output.shortcut -m mode
我先用 is.workflow.actions.share 作为操作标识符生成文件,然后签名。签名后文件确实可以导入了,但打开一看:所有操作内容都是空的!签名过程把所有操作都清掉了。
这是一个已知的Bug:shortcuts sign 在处理 is.workflow.actions.share 操作时,会丢弃所有操作参数。
破局:逆向分析竞品的.shortcut文件
既然正向生成总是踩坑,我决定从竞品的 .shortcut 文件入手,逆向分析它的结构。
安装shortcut-sign工具
在GitHub上找到了 shortcut-sign 工具,它可以解包和重新打包签名后的 .shortcut 文件。
# 安装依赖
brew install libplist
# 克隆并编译
git clone https://github.com/0xilis/shortcut-sign.git
cd shortcut-sign
make
解包竞品文件
./shortcut-sign -i "AppB_AutoReply.shortcut" -e -o /tmp/appb_extracted.shortcut
解包后得到一个二进制plist文件,用 plutil 转换为XML格式查看:
plutil -convert xml1 /tmp/appb_extracted.shortcut -o /tmp/appb_extracted.plist
关键发现
分析竞品的plist后,发现了几个关键差异:
1. 操作标识符是 is.workflow.actions.runextension,不是 is.workflow.actions.share
竞品使用的是 runextension,而不是我之前用的 share。这才是ShareExtension在快捷指令中的正确标识符!
2. App绑定使用 WFApp 字典,不是简单的 WFAppIdentifier 字符串
<key>WFApp</key>
<dict>
<key>BundleIdentifier</key>
<string>com.app.b</string>
<key>Name</key>
<string>App B</string>
<key>TeamIdentifier</key>
<string>TEAMIDBXXXXX</string>
</dict>
3. 输入参数使用 WFTextTokenAttachment 序列化方式连接上一步输出
<key>WFInput</key>
<dict>
<key>Value</key>
<dict>
<key>OutputUUID</key>
<string>截屏操作的UUID</string>
<key>Type</key>
<string>ActionOutput</string>
<key>OutputName</key>
<string>截屏</string>
</dict>
<key>WFSerializationType</key>
<string>WFTextTokenAttachment</string>
</dict>
4. 更关键的是:shortcuts sign 不会清空 runextension 操作!
之前用 share 操作会被清空,但用 runextension 操作在签名后完整保留——这才是正确的操作标识符。
最终方案:生成预绑定的签名.shortcut文件
完整Python生成脚本
基于逆向分析的结论,我编写了最终的生成脚本:
#!/usr/bin/env python3
import plistlib
import uuid
import subprocess
import os
# UUID
screenshot_uuid = str(uuid.uuid4()).upper()
share_uuid = str(uuid.uuid4()).upper()
# 构建WFWorkflow结构
workflow = {
'WFWorkflowMinimumClientVersionString': '900',
'WFWorkflowMinimumClientVersion': 900,
'WFWorkflowActions': [
# 第一步:截屏
{
'WFWorkflowActionIdentifier': 'is.workflow.actions.takescreenshot',
'WFWorkflowActionParameters': {
'UUID': screenshot_uuid
}
},
# 第二步:通过扩展共享(关键!用runextension而不是share)
{
'WFWorkflowActionIdentifier': 'is.workflow.actions.runextension',
'WFWorkflowActionParameters': {
# 使用WFTextTokenAttachment连接上一步的截屏输出
'WFInput': {
'Value': {
'OutputUUID': screenshot_uuid,
'Type': 'ActionOutput',
'OutputName': '截屏',
},
'WFSerializationType': 'WFTextTokenAttachment',
},
# 预绑定App信息(关键!)
'WFApp': {
'BundleIdentifier': 'com.app.a',
'Name': 'App A',
'TeamIdentifier': 'TEAMIDAXXXXX', # 你的Team ID
},
'CustomOutputName': 'Share with Extensions',
'UUID': share_uuid,
'WFAppIdentifier': 'com.app.a',
}
},
],
'WFWorkflowClientVersion': '900',
'WFWorkflowClientRelease': '2.0',
'WFWorkflowIcon': {
'WFWorkflowIconStartColor': 2071128575,
'WFWorkflowIconGlyphNumber': 59511,
},
'WFWorkflowImportQuestions': [],
'WFWorkflowTypes': ['NCWidget', 'WatchKit'],
'WFWorkflowNoInputBehavior': {
'Type': 'NoInput',
},
}
# 写入未签名的plist
unsigned_path = '/tmp/unsigned.shortcut'
signed_path = '/tmp/signed.shortcut'
with open(unsigned_path, 'wb') as f:
plistlib.dump(workflow, f, fmt=plistlib.FMT_BINARY)
# 使用macOS shortcuts命令签名
# -m people 模式不需要额外的签名证书
subprocess.run([
'shortcuts', 'sign',
'-i', unsigned_path,
'-o', signed_path,
'-m', 'people'
], check=True)
print(f'签名后的文件: {signed_path}')
print('将此文件放入App的Bundle中,即可在App内引导用户安装快捷指令')
核心要点总结
1. runextension vs share
| 操作标识符 |
签名后结果 |
用途 |
is.workflow.actions.share |
操作被清空 |
系统分享菜单,第三方无法使用 |
is.workflow.actions.runextension |
操作完整保留 |
运行ShareExtension,第三方可用 |
这是最关键的发现:必须使用 is.workflow.actions.runextension。
2. WFApp预绑定
第三方App无法通过快捷指令UI选择,必须在 .shortcut 文件中预绑定App信息:
'WFApp': {
'BundleIdentifier': '你的BundleID',
'Name': '你的App名称',
'TeamIdentifier': '你的TeamID',
}
绑定后,用户在快捷指令中看到的就是「与【你的App】共享【截屏】」,App名称可点击显示详情,但无法更改。
3. WFTextTokenAttachment数据流连接
快捷指令中操作之间的数据传递通过 WFTextTokenAttachment 序列化实现。截屏操作的输出需要通过 OutputUUID 引用传递给下一步的输入:
'WFInput': {
'Value': {
'OutputUUID': screenshot_uuid, # 引用截屏操作的UUID
'Type': 'ActionOutput',
'OutputName': '截屏',
},
'WFSerializationType': 'WFTextTokenAttachment',
}
4. 签名模式
shortcuts sign 支持 -m people 模式,不需要开发者证书,任何人都可以签名。签名后的文件可以直接在iOS上导入使用。
整体技术方案
最终的完整方案如下:
- App端:配置ShareExtension,确保
NSExtensionActivationRule 支持图片类型
- 生成端:用Python脚本生成
.shortcut 文件,预绑定App信息,使用 runextension 操作
- 签名端:用
shortcuts sign 签名文件
- 分发端:将签名的 .shortcut 文件放入App Bundle,在App内引导用户安装快捷指令
ShareExtension的 Info.plist 关键配置:
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>ShareExtensionViewController</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>ShareExtension.ViewController</string>
</dict>
踩坑时间线
| 天数 |
尝试方向 |
结果 |
| Day 1 |
让ShareExtension出现在快捷指令"分享"操作列表 |
❌ 系统限制,第三方App无法出现在列表 |
| Day 1 |
用AppIntent自定义操作 |
❌ 打开主App,不是ShareExtension |
| Day 2 |
生成未签名的.shortcut文件 |
❌ iOS 15+不支持导入未签名文件 |
| Day 2 |
用is.workflow.actions.share生成并签名 |
❌ 签名后操作被清空 |
| Day 3 |
逆向竞品.shortcut文件 |
✅ 发现runextension和WFApp字典 |
| Day 3 |
用is.workflow.actions.runextension生成并签名 |
✅ 签名后操作完整保留 |
参考工具
- shortcut-sign - 用于解包/重新打包签名后的 .shortcut 文件
shortcuts sign - macOS自带签名工具
plutil - macOS自带plist转换工具
写在最后
这个需求看似简单——"让ShareExtension出现在快捷指令里",实际上涉及了iOS快捷指令文件格式的逆向分析、操作标识符的正确选择、数据流序列化方式的理解、以及签名工具的Bug规避。
最大的收获是:当正向文档和API无法解决问题时,逆向分析竞品的实现往往是最直接的出路。Apple在快捷指令领域对第三方App的支持非常有限,官方文档也几乎没有涉及 runextension 这种操作标识符。如果不是逆向分析竞品的文件,我可能永远不知道该用 runextension 而不是 share。
希望这篇文章能帮到同样遇到这个问题的iOS开发者,少走一些弯路。