同一个 VPN 应用,在 macOS 上需要两种形态
打包成 app extension 的 NEPacketTunnelProvider 只能走 Mac App Store。要把同一个应用做成可直接下载的签名版本,隧道必须重做成 system extension。

同一个 VPN 应用,在 macOS 上需要两种形态
如果你的 macOS 应用里有 NEPacketTunnelProvider,那么选择哪条分发渠道不是最后阶段的打包决定,而是决定应用架构的前提。
- Mac App Store:provider 是 app extension(
.appex),位于Contents/PlugIns。 - Developer ID(直接下载):provider 必须是 system extension(
.systemextension),位于Contents/Library/SystemExtensions。
这不是同一个东西的两种叫法。它们是不同的 bundle 类型,entitlement 取值不同,安装流程不同,应用里要写的代码也不同。你没法用同一份产物同时投两条渠道。
我们是怎么用昂贵的方式发现这件事的
我们已经有一个能用的 App Store 版本,就假定直接下载版只是导出选项的问题:拿同一个归档用 Developer ID 证书签名、公证,完事。
它确实公证通过了,票据也装订了,Gatekeeper 也接受了。但它打不开 —— 内核在 execve 阶段就把它杀了,因为它申请了一个没有描述文件背书的受限权限。这个失败模式我们另写了一篇,简单说就是:标准检查一个都拦不住。
把描述文件签发出来之后,权限问题解决了,真正的问题才浮出水面。Apple 为 Developer ID 分发签发的描述文件授予的是:
com.apple.developer.networking.networkextension = [
"packet-tunnel-provider-systemextension",
"app-proxy-provider-systemextension",
"content-filter-provider-systemextension",
"dns-proxy-systemextension",
…
]
注意每一个隧道类取值的后缀。Apple 不会为直接分发签发 packet-tunnel-provider,它签发的是 packet-tunnel-provider-systemextension。描述文件已经在告诉你它期待什么形态,而 app extension 没资格认领这个取值。
工程上具体要改什么
四件事,其中第三件最出人意料。
1. 新增一个 target,而不是改造原有的。 App Store 版仍然需要那个 appex。两者共存意味着在现有 app extension 之外,再加一个 macOS 专用的 system-extension target,共享同一份 provider 源码。
2. Info.plist 完全不同。 appex 通过 NSExtension 声明自己:
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.packet-tunnel</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>system extension 通过 NetworkExtension 声明,并且 CFBundlePackageType 是 SYSX 而不是 XPC!:
<key>NetworkExtension</key>
<dict>
<key>NEProviderClasses</key>
<dict>
<key>com.apple.networkextension.packet-tunnel</key>
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
<key>NEMachServiceName</key>
<string>$(TeamIdentifierPrefix)group.your.app</string>
</dict>3. 它需要自己的 main。 app extension 由扩展宿主替你启动。system extension 是一个普通可执行文件:如果没有东西把它交给 NetworkExtension 并让 run loop 停住,进程启动后会立刻退出。
import Foundation
import NetworkExtension
autoreleasepool {
NEProvider.startSystemExtensionMode()
}
dispatchMain()这里有个让我们损失一次构建的坑:把这个文件放在其他 target 的源码通配能扫到的目录,每个 app target 都会把它编进去,然后报 'main' attribute cannot be used in a module that contains top-level code。把它放在只有系统扩展 target 会编译的目录里。
4. 应用必须主动请求安装。 用 appex 时,在 NETunnelProviderManager 里引用 provider 的 bundle identifier 就够了。用 sysex 时,在扩展被安装并经用户批准之前,macOS 根本解析不了那个标识符。所以应用要先提交激活请求:
let request = OSSystemExtensionRequest.activationRequest(
forExtensionWithIdentifier: extensionIdentifier,
queue: .main
)
request.delegate = self
OSSystemExtensionManager.shared.submitRequest(request)并在 requestNeedsUserApproval 回调里提示用户去系统设置 → 通用 → 登录项与扩展 → 网络扩展批准。在他们批准之前,隧道起不来。这是首次运行体验的实质变化,应该写进引导流程,而不是塞在一个错误提示里。
应用还需要 com.apple.developer.system-extension.install,它本身也是受限权限,而且只有在 App ID 上启用了 System Extension 能力之后,它才会出现在描述文件里。
两条运维提醒
启用那个能力会作废描述文件。 给某个 App ID 打开 System Extension,会把该 bundle ID 下所有现存描述文件标记为失效,包括别的平台在构建配置里按名字引用的那些。我们这次连带打掉了 tvOS 和 visionOS 的 App Store 描述文件。用同样的名字重建即可,但如果你周五做这件事,会在周一才发现。
加固运行时默认不开。 生成工程的工具未必会设 ENABLE_HARDENED_RUNTIME。如果没设,codesign -dv 会报 flags=0x0(none),公证服务直接拒收。应用和系统扩展两边都要开。
这么做值不值
对我们来说值,但诚实的答案取决于你的隧道要做什么。
我们考虑过的替代方案是:直接下载版关掉隧道,只保留进程内的代理模式 —— 完全不申请 NetworkExtension 权限,于是既没有受限权限问题,也没有系统扩展批准这一步。它确实简单得多,而且如果你的用户主要想要一个本地代理,这就是更该选的那条路。
我们最终选了 system extension,因为一个在 App Store 之外无法建立隧道的 VPN 客户端,是另一个更小的产品。但这是一周的工作量外加一项新的支持负担 —— 从此每次首次运行都多了一个用户可以拒绝的批准步骤 —— 而不是一个构建开关。
在动手写 provider 之前就把渠道定下来。事后改造当然可行,这篇文章讲的就是怎么改,但那不是省事的路。