macOS 的 App Sandbox 在提升安全性的同时,也让文件访问、网络监听等操作受到严格限制。对于需要执行敏感任务的桌面应用,把特权逻辑封装到独立的 XPC Service 中,再通过安全的进程间通信与主应用交互,是一种成熟且可靠的架构模式。XPC Service 由 launchd 按需拉起,崩溃时不会拖垮主进程,并且可以拥有与主应用不同的沙盒配置,从而实现最小权限边界内的能力扩展。

接下来先梳理 XPC Service 在工程中的存在形式。它并不是一个普通的命令行工具,而是打包在 Contents/XPCServices 目录下的独立 bundle。每个 XPC Service 都有自己的 Info.plist、签名和沙盒容器。主应用通过 NSXPCConnection 指定服务名进行连接,系统会根据服务对应的 launchd 配置启动进程。理解这一结构,是后续配置权限和调试连接问题的基础。
一、XPC 服务的进程模型与工程配置
XPC Service 的生命周期由 launchd 管理。与应用主进程不同,它不会在用户打开应用时立即运行,而是在第一次建立 NSXPCConnection 时按需启动。默认情况下,服务进程在空闲一段时间后会被系统回收,适合承载低频但耗时的任务。主应用与 XPC Service 之间使用私有 IPC 通道,消息以异步形式传递,调用方不会因为服务端阻塞而卡死主线程。
在 Xcode 中创建 XPC Service 非常直接:在已有应用工程中添加新的 Target,模板选择 XPC Service。系统会自动生成一个包含 main.swift 和监听器代理类的 bundle,同时把该 target 嵌入到主应用的 Contents/XPCServices 目录。要注意服务 bundle 的 Bundle Identifier 必须与主应用保持一致的前缀,通常为 com.example.MyApp.MyService。签名时所有 bundle 必须使用相同的 Team ID,否则连接会在启动阶段被拒绝。
XPC Service 的 Info.plist 需要额外声明 XPCService 字典,其中 ServiceType 决定服务进程的运行模式。对于大多数桌面应用,Application 类型表示服务与主应用生命周期绑定;User 类型则允许按用户会话独立运行。下面是一个典型配置:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>XPCService</key>
<dict>
<key>ServiceType</key>
<string>Application</string>
</dict>
</dict>
</plist>
上面的 XML 片段省略了 CFBundleIdentifier 等基础键,实际工程中 Xcode 模板已经生成完整内容。除了 ServiceType,还可以设置 EnvironmentVariables 或 _SandboxProfile 等键,但通常不建议在发布版本中临时修改沙盒配置文件,改用正式的 entitlements 声明更安全。
二、基于 NSXPCConnection 的接口设计与安全校验
XPC Service 的核心通信方式是通过 NSXPCConnection 暴露 Objective-C 协议。协议中的方法签名必须能被 Objective-C 运行时表示,因此使用 Swift 时需要标记 @objc。设计协议时,应优先考虑异步回调,避免在服务端执行阻塞操作后同步返回,这样既能保持 IPC 通道畅通,也能让主应用界面不被卡顿。
下面定义一组最小化的特权接口。服务端只提供两个方法:读取系统信息和写入固定配置文件。即使服务进程拥有更宽松的沙盒权限,也不开放任意路径的文件读写,这样做可以显著降低接口被恶意调用时造成的影响。
import Foundation
@objc public protocol PrivilegedServiceProtocol {
func fetchSystemInfo(withReply reply: @escaping (String) -> Void)
func writeConfig(_ data: Data, withReply reply: @escaping (Bool) -> Void)
}
class PrivilegedService: NSObject, PrivilegedServiceProtocol {
func fetchSystemInfo(withReply reply: @escaping (String) -> Void) {
let info = ProcessInfo.processInfo.operatingSystemVersionString
reply(info)
}
func writeConfig(_ data: Data, withReply reply: @escaping (Bool) -> Void) {
let url = try? FileManager.default.url(for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true)
let fileURL = url?.appendingPathComponent("config.plist")
do {
try data.write(to: fileURL!)
reply(true)
} catch {
reply(false)
}
}
}
协议定义完成后,需要在服务端监听器中创建导出对象,并对每个接入连接执行签名校验。NSXPCListenerDelegate 的 shouldAcceptNewConnection 方法会在连接建立前被调用,只有返回 true 才会继续协商。这里的校验不能只依赖调用方自报的 Bundle ID,因为该信息可以被伪造。正确做法是读取连接携带的 audit token,并转换为 SecCode 后验证代码签名。
import Foundation
import Security
class ServiceDelegate: NSObject, NSXPCListenerDelegate {
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
guard isTrustedConnection(newConnection) else {
return false
}
newConnection.exportedInterface = NSXPCInterface(with: PrivilegedServiceProtocol.self)
newConnection.exportedObject = PrivilegedService()
newConnection.resume()
return true
}
private func isTrustedConnection(_ connection: NSXPCConnection) -> Bool {
let auditToken = connection.auditToken
var code: SecCode?
let attributes = [kSecGuestAttributeAudit: NSData(bytes: &auditToken,
length: MemoryLayout<audit_token_t>.size)]
guard SecCodeCopyGuestWithAttributes(nil, attributes as CFDictionary, [], &code) == errSecSuccess,
let secCode = code else {
return false
}
let requirementString = "anchor apple generic and identifier \"com.example.MyApp\""
var requirement: SecRequirement?
guard SecRequirementCreateWithString(requirementString as CFString, [], &requirement) == errSecSuccess,
let req = requirement else {
return false
}
return SecCodeCheckValidity(secCode, [], req) == errSecSuccess
}
}
上述 requirementString 使用 Apple 的代码签名要求语法,限定只有指定 Bundle ID 且由 Apple 签名的代码可以连接。实际开发中也可以把主应用的签名要求写入服务配置,但更灵活的做法是在服务端维护一个允许列表。校验通过后,主应用拿到 remoteObjectProxy 才能正常发起请求。
主应用侧的连接代码相对简单:先创建 NSXPCConnection 并设置接口,然后调用 resume()。注意 remoteObjectProxy 的调用是异步的,回调可能不会在当前队列执行。如果需要在主线程更新 UI,应手动切换队列。
import Foundation
class ServiceClient {
private let connection: NSXPCConnection
init() {
connection = NSXPCConnection(serviceName: "com.example.MyApp.PrivilegedService")
connection.remoteObjectInterface = NSXPCInterface(with: PrivilegedServiceProtocol.self)
connection.resume()
}
func loadSystemInfo(completion: @escaping (String) -> Void) {
guard let proxy = connection.remoteObjectProxy as? PrivilegedServiceProtocol else {
return
}
proxy.fetchSystemInfo { info in
DispatchQueue.main.async {
completion(info)
}
}
}
deinit {
connection.invalidate()
}
}
注意代码中 remoteObjectProxy 的强制转换失败时不应默默返回,实际项目可接入 error handler 记录日志。频繁创建连接不是问题,因为 XPC 连接较为轻量,但最好复用同一个连接,避免每次请求都触发服务进程启动。
三、沙盒应用权限提升的配置与边界控制
主应用沙盒默认只能访问自身容器和用户明确授权的文件,网络客户端、打印、USB 设备等能力都需要在主应用的 entitlements 中声明。如果希望主应用保持最小权限,同时又能完成这些任务,可以把相应能力转移到 XPC Service。XPC Service 作为独立可执行文件,拥有自己的沙盒声明,因此可以对不同能力进行隔离。例如主应用不声明 com.apple.security.network.client,而是让 XPC Service 拥有该权限,主应用通过 IPC 发起网络请求。
XPC Service 的 entitlements 文件在 Target 的 Signing & Capabilities 中单独配置,不必与主应用完全一致。以下是一个服务端 entitlements 示例,它比主应用多申请了网络客户端和用户选择文件的读写权限:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
这种方案常被称为权限提升,但并非绕过沙盒。操作系统仍然强制 XPC Service 的沙盒策略,只是把不同权限分布到不同进程。攻击者即使控制了主应用,也无法直接继承服务进程的特权,还必须攻破签名校验和 IPC 接口约束。因此接口设计要避免提供通用文件读写或任意命令执行能力,而应围绕具体业务提供有限方法,例如 fetchFeed()、sendReport(_:),在服务端硬编码目标路径或使用安全书签。
如果确实需要让服务访问用户选择的文件,可以通过 NSOpenPanel 在主应用中获取安全书签,再把书签数据传递给 XPC Service。服务端调用 URL(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:) 解析后访问,这样不暴露完整文件系统路径给服务端调用者。即使主应用被攻破,能请求的文件范围仍受用户授权限制,不会退化为任意文件访问。
四、调试、签名与常见问题
XPC Service 的调试比普通模块困难,因为服务进程独立运行,主应用的断点不会自动停在服务代码中。可在服务端代码中设置断点,然后从主应用触发连接;Xcode 会自动附加到服务进程。若连接失败,优先检查 NSXPCConnection 的 interruptionHandler 和 invalidationHandler,它们能给出连接被中断或失效的时机。
签名不一致是发布阶段最常见的错误。主应用和 XPC Service 必须使用相同的 Team ID,并且在 Release 构建中都要启用 Hardened Runtime(如果主应用启用)。如果服务 bundle 未正确嵌入到 Contents/XPCServices,launchd 会直接报“service could not be instantiated”。使用 codesign -d --entitlements :- 查看实际签名和权限,能快速定位配置错误。
另一个易忽略的问题是连接泄漏。每次 NSXPCConnection 都会在服务端生成一个导出对象,若客户端不调用 invalidate(),服务进程可能长时间驻留。虽然系统会按空闲策略回收,但显式释放更利于资源控制。对于高频调用,建议把多个小请求合并为一个批量方法,减少跨进程往返开销。XPC 消息本身经过序列化,传递大型 Data 时注意内存峰值,必要时改为文件 URL 配合安全书签传递数据。
总体来看,XPC Services 提供了一套原生的 macOS 进程隔离机制,适合在沙盒环境中实现最小权限下的能力扩展。只要接口边界清晰、签名校验严格,就能兼顾安全性与功能完整性。正式上线前,建议在干净系统上测试服务启动、权限申请和连接失败等场景,避免依赖开发环境的宽松配置。