...
Back

同一个 VPN 应用,在 macOS 上需要两种形态

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

同一个 VPN 应用,在 macOS 上需要两种形态

同一个 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 声明,并且 CFBundlePackageTypeSYSX 而不是 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 之前就把渠道定下来。事后改造当然可行,这篇文章讲的就是怎么改,但那不是省事的路。