iOS快捷指令集成ShareExtension:从踩坑到实现

三天踩坑实录:第三方App如何让ShareExtension出现在快捷指令中

Posted by 杜文杰 on 2026-04-24

背景

我们的App(下称App A)有一个核心功能:用户截图后,通过ShareExtension将截图分享到App中进行AI分析并自动生成回复。我们希望用户能通过iOS快捷指令一键完成「截屏 → 分享到App A」的自动化流程,就像竞品(下称App B)那样。

竞品的快捷指令长这样:

  • 第一步:截屏
  • 第二步:与【App B】共享【截屏】

其中【App B】是一个可点击的参数,点击后显示App图标和名称;【截屏】连接了上一步的输出。整个流程非常优雅,用户体验极佳。

然而,就是这样一个看似简单的需求,让我踩了整整三天的坑。本文将完整记录踩坑过程和最终解决方案。

踩坑之路

坑1:ShareExtension不出现在快捷指令的"分享"操作列表中

最初的想法很直接:在快捷指令中添加"分享"操作,然后从App列表中选择App A的ShareExtension。

但现实是:第三方App根本无法出现在快捷指令"分享"操作的App列表中。这个列表只显示系统自带的应用(信息、邮件等)。Apple的工程师在开发者论坛中也确认了这一点——这是系统限制,非系统App无法出现在该列表。

我尝试了各种方法:

  • 修改 NSExtensionActivationRule 确保扩展能接收图片
  • NSExtensionPointIdentifiercom.apple.share-services 改为 com.apple.ui-services(Action Extension)
  • 在设备上运行一次ShareExtension,让系统"记住"它

结果都一样:列表里只有系统App,App A永远不会出现。

坑2:AppIntents打开的是主App而不是ShareExtension

既然无法通过"分享"操作选择第三方App,那能不能用AppIntents框架自定义一个快捷指令操作?

我实现了 ShareImageIntent,遵循 AppIntents 协议:

struct ShareImageIntent: AppIntent {
    static var title: LocalizedStringResource = "共享截屏"
    static var description = IntentDescription("将截屏分享到App A进行分析")
    
    @Parameter(title: "截屏")
    var image: IntentFile
    
    static var parameterSummary: some ParameterSummary {
        Summary("与App A共享\(\.$image)")
    }
    
    func perform() async throws -> some IntentResult {
        // 打开主App处理
    }
}

快捷指令中确实能搜到「与App A共享【截屏】」,格式也对。但有一个致命问题:AppIntent执行时打开的是主App,而不是ShareExtension的半屏界面

竞品打开的是ShareExtension那种从底部弹出的半屏UI,体验非常流畅;而AppIntent会切到主App,体验断裂。这是两条完全不同的技术路线:

用户明确要求必须用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

方案 UI形式 用户感知
AppIntent 跳转主App全屏 离开当前应用,体验断裂
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上导入使用。

整体技术方案

最终的完整方案如下:

  1. App端:配置ShareExtension,确保 NSExtensionActivationRule 支持图片类型
  2. 生成端:用Python脚本生成 .shortcut 文件,预绑定App信息,使用 runextension 操作
  3. 签名端:用 shortcuts sign 签名文件
  4. 分发端:将签名的 .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>

踩坑时间线

操作标识符 签名后结果 用途
is.workflow.actions.share 操作被清空 系统分享菜单,第三方无法使用
is.workflow.actions.runextension 操作完整保留 运行ShareExtension,第三方可用

参考工具

  • shortcut-sign - 用于解包/重新打包签名后的 .shortcut 文件
  • shortcuts sign - macOS自带签名工具
  • plutil - macOS自带plist转换工具

写在最后

这个需求看似简单——"让ShareExtension出现在快捷指令里",实际上涉及了iOS快捷指令文件格式的逆向分析、操作标识符的正确选择、数据流序列化方式的理解、以及签名工具的Bug规避。

最大的收获是:当正向文档和API无法解决问题时,逆向分析竞品的实现往往是最直接的出路。Apple在快捷指令领域对第三方App的支持非常有限,官方文档也几乎没有涉及 runextension 这种操作标识符。如果不是逆向分析竞品的文件,我可能永远不知道该用 runextension 而不是 share

希望这篇文章能帮到同样遇到这个问题的iOS开发者,少走一些弯路。





天数 尝试方向 结果
Day 1 让ShareExtension出现在快捷指令"分享"操作列表 ❌ 系统限制,第三方App无法出现在列表
Day 1 用AppIntent自定义操作 ❌ 打开主App,不是ShareExtension
Day 2 生成未签名的.shortcut文件 ❌ iOS 15+不支持导入未签名文件
Day 2 is.workflow.actions.share生成并签名 ❌ 签名后操作被清空
Day 3 逆向竞品.shortcut文件 ✅ 发现runextensionWFApp字典
Day 3 is.workflow.actions.runextension生成并签名 ✅ 签名后操作完整保留