macOS 27 拒绝跨团队容器访问,而且不再询问用户
在 macOS 上,即使您对某个 App Group 根本没有相应授权,containerURL(forSecurityApplicationGroupIdentifier:) 也会返回一个看似有效的 URL。Apple 对此写得很直白:在 iOS 上,标识符无效时该方法返回 nil;而在 macOS 上,“即使 App Group 无效,也始终返回一个格式正确的 URL”。4 到了 macOS 27,这一行为与一项新的限制迎面撞上。
macOS 27 不再询问用户,而是直接拒绝跨团队的容器访问。 读取另一个开发者团队的 App 数据容器或 App Group 容器,过去会弹出授权提示;如今默认失败,只有当用户自己在“隐私与安全性”中找到对应条目时才可恢复。1
这两点叠加,结果是一次在失败点上毫无信号的失败。没有对话框。方法返回的是 URL,而不是 nil。路径看上去与预期分毫不差。拒绝要等到后面的文件操作才浮现,而且读起来就像文件不存在。
摘要(TL;DR)
macOS 27 取消了访问其他团队 App 数据容器和 App Group 容器时的用户授权提示,此类访问默认被拒绝,控制权移交到“隐私与安全性”设置。1 这项变更被归在 System Integrity Protection 之下,算作新特性,而非 bug 修复。Apple 自己关于 App Group 容器的指南,至今仍在描述 macOS 27 已经移除的提示行为。由于 macOS 版的 API 即便面对您无法访问的 App Group 也会返回格式完整的 URL,拒绝出现在读取时,而不是在调用 API 时。同团队内部的访问不受影响:边界是 Team ID。
究竟改了什么
macOS 27 的发行说明在 System Integrity Protection 一节下只有一句话:1
“访问其他开发者团队的 App 数据容器和 App Group 容器时,系统不再向用户请求授权;此类访问默认被拒绝,用户可在‘隐私与安全性’设置中管理。”
Radar 161835690。两个分句,两处独立的变化。
前半句移除了提示,后半句把拒绝确立为默认行为。关于这项变更的报道,大多以“默认拒绝”开头,而那恰恰是不那么有意思的一半——收紧默认值本就是常规操作。真正要紧的是消失的提示:它改变了用户遭遇失败的形态,也改变了您收到的 bug 报告的形态。
从前,用户会看到一个对话框,然后做出选择。如果他们拒绝,您的应用至少知道有人做过决定。现在没有人被问到。访问就是不会发生,而唯一的开启路径通往一个设置面板——除非有人明确告诉用户,否则他们根本没有理由去那里。
Apple 文档仍在描述的旧行为
这项限制建立在两个版本之前引入的保护之上。Apple 关于 App Group 容器的指南写道:2
“在 macOS 15 及更高版本中,即使应用不具备 App 沙箱能力,App Group 容器也会为应用的本地文件提供 [System Integrity Protection]。这些 App Group 容器会限制不属于该 App Group 的应用的访问。不属于该 App Group 的应用若尝试访问 App Group 或 App 数据容器内的位置,会触发一个请求用户授权的提示。”
那个页面至今仍用现在时描述这个提示。截至本文写作时,Apple 尚未针对 macOS 27 更新它。
实际后果是:开发者撞上这个问题,去查文档,找到官方页面,得到的答复是“会出现授权对话框”。但它不会出现。于是他们会顺理成章地认定提示坏了,或者自己的 entitlements 配错了,然后把时间耗在错误的地方。
建议您亲自确认那个页面的当前状态,而不是依赖本文的时间戳。文档总会补上。
为什么失败来得这么晚
把一次策略变更变成调试难题的,正是 API 的行为。
Apple 关于 containerURL(forSecurityApplicationGroupIdentifier:) 的参考文档,把平台差异说得很清楚:4
“一个指向该 App Group 在文件系统中共享目录位置的 URL。在 iOS 中,当 App Group 标识符无效时,该值为
nil。在 macOS 中,即使 App Group 无效,也始终返回一个格式正确的 URL,因此在使用之前,请务必测试您确实能够访问底层目录。”
讨论章节重复了同一条警告:用一个您并不持有相应 entitlement 的 App Group 标识符调用该方法,仍会得到格式正确的 URL,但目录并不存在,而沙箱应用也无法创建它。4
所以常见的防御式写法在这里形同虚设:
// This guard passes on macOS regardless of entitlement.
guard let url = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "ABCDE12345.com.example.shared"
) else {
return // never taken on macOS
}
URL 本身格式完好。它指向 ~/Library/Group Containers/<team>.<group>,正是该容器本该所在的位置。在真正对它执行文件操作之前,一切都像成功。
正确的做法是直接尝试读取并处理失败,而不是先去追问它能否成功:
let fm = FileManager.default
guard let url = fm.containerURL(
forSecurityApplicationGroupIdentifier: groupID
) else { return } // macOS never takes this branch
do {
_ = try fm.contentsOfDirectory(atPath: url.path)
// Reached the container.
} catch {
// On macOS 27 a cross-team denial lands here.
// No prompt fired. The user was never asked.
presentContainerUnavailable(error)
}
不要被 isReadableFile(atPath:) 这类预检查诱惑。Apple 明确反对整整一类这样的判断:3
“不建议依据文件系统当前的状态、或文件系统上某个文件当前的状态来预判行为。这样做可能引发奇怪的行为或竞态条件。与其提前判断某个操作是否会成功,不如直接尝试该操作(例如加载文件或创建目录),检查错误,并优雅地处理这些错误。”
针对这次变更,还有第二个理由。同一页文档指出,isReadableFile(atPath:) “使用真实用户 ID 和组 ID”来判定可读性。3 那是 POSIX 权限判定。而跨团队容器的拒绝,是叠加在 POSIX 之上的策略决策,因此权限位检查未必能反映它。一个返回 true、随后读取却照样失败的预检查,比没有预检查更糟糕——它把意外推得离原因更远。
读过 iOS 27 弃用 canOpenURL 的人会认出同样的形状。Apple 在不断收回“事先询问”的能力,只留下“直接尝试”的能力。尝试并处理错误正在成为通用答案,而不是针对某一个 API 的权宜之计。
请注意两个平台之间的不对称。iOS 返回 nil,在调用点就诚实地失败。macOS 返回 URL,把失败往后推。而 macOS 27 移除的提示,恰恰是这个本就更沉默的平台上仅剩的那点信号。
还有一个陷阱:Apple 建议始终使用该方法返回的 URL,而不要手工拼出 ~/Library/Group Containers/...,因为该位置在未来版本中可能变动。4 谁要是把这个路径写死了,连一个可供检查的方法调用都没有,也就没地方安放可读性测试。
多半与 SDK 版本无关
一个自然的疑问是:继续使用较旧的 SDK 能否推迟这项变更?发行说明没有说。
它们呈现出来的,是一种模式。macOS 27 的说明在 11 处条目中使用了明确的 SDK 限定语:“在使用 macOS 27.0 SDK 构建的应用中”“在使用 27.0 SDK 构建的应用中”“当您的项目最低部署目标低于 27.0 时”。1 需要限定的时候,Apple 会写明限定。
关于容器的这一条没有任何限定语。若按合理解读,这种缺失是有意为之,那么该限制就是一项 OS 层面的策略,适用于 macOS 27 上运行的每一个二进制文件,无论它由哪个 SDK 构建。
请把这当作一个有力的推断,而非既成事实,因为 Apple 并没有直接这样表述。但无论如何,操作层面的结论一致:不要指望靠旧 SDK 来规避,而要按访问会失败来规划。
真正受影响的是谁
同团队内的访问毫发无损。Apple 的规则让 Team ID 这条边界成为结构性的,而非偶然的:“不同的开发者团队不能使用同一个 App Group”,而同一个团队可以在自己的应用与配套进程之间共享同一个 App Group。2
会坏掉的,是那些跨过这条线的应用:
迁移与导入工具。 任何读取竞品或前代产品容器来导入用户数据的功能。这是最清楚的一类,而且此前它就已经被一个提示挡着——只是用户有时会同意。
备份与同步工具。 通过枚举 App 容器来备份的工具,现在会静默跳过所有不属于自己团队的内容。
易主过的配套应用。 这是最刺眼的一类,因为代码一个字都没变。两个应用原本挂在同一个 Team ID 下发布,共享同一个 App Group,相安无事。一次收购、一次团队拆分,或者迁往另一个开发者账号,就会让它们落到不同的 Team ID 之下。App Group 标识符看上去依旧正确,URL 依旧能解析,数据却不再送达。
最后这一类值得多说两句,因为时机对您不利。Team ID 的变更由负责签名与分发的人经手,而共享容器的依赖关系从那个视角通常是看不见的。代码照常编译。两个应用照常发布。两边的测试套件都抓不到,因为单元测试不会真的去碰一个真实的跨团队容器,CI 又是用构建机自己的身份同时构建两个 target。失败出现在生产环境、用户的机器上,表现为两个应用之间的数据不再同步——而在用户眼里,这两个应用理所当然是同一个产品。
如果您正在筹划 Team ID 迁移,审计工作是机械性的:把各个 target 声明的所有 App Group 标识符 grep 出来,逐个列出哪些 bundle 会读取它。凡是被将要落在不同 Team ID 下的 bundle 读取的标识符,都是一处断裂。这在迁移前是一次十分钟的检查,在迁移后则是一起客服事故。
Apple 列出了可以参与 App Group 的对象:bundle 结构中的主可执行文件、App Extension、App Clip 以及 XPC Service。2 每一处都可能暴露这个问题。
如何排查
在断定是代码 bug 之前,有两项检查值得先跑一遍。
先确认系统是否真的校验过您的 entitlements。Apple 记录了一个针对运行中进程查看 entitlements-validated 标志的运行时检查:2
sudo launchctl procinfo <pid>
在 App Group 授权机制出现之前创建的描述文件(provisioning profile)可能不带这项授权,由此产生的访问失败与 macOS 27 的拒绝看上去一模一样,成因却完全不同。当“Automatically manage signing”开启、且 REGISTER_APP_GROUPS 构建设置为 Yes 时,Xcode 会刷新描述文件。2
还要弄清您用的是哪一种标识符形式。以 group. 为前缀的 App Group 必须包含在应用的描述文件中;采用 <TeamID>.<group name> 形式的则不需要描述文件,因为系统会拿签名身份来校验团队标识符前缀——但这种形式仅限 macOS,且不被 Keychain Access Groups 支持。2
然后去看“隐私与安全性”,因为发行说明称,用户控制权如今就在那里。1
为一种无法提示的拒绝做设计
那个提示做的是产品的活,不只是安全的活。它告诉用户“这里存在一个选择”,也告诉您的应用“选择已经做出”。如今这两件事都落到您头上。
在边界上探一次,只探一次。 首次解析容器时就尝试一次开销很小的读取,而不是等同步循环跑到一半才发现被拒。深藏在队列里的失败会产生半截结果,而错误又会被归咎到恰好轮到的那个文件头上。在解析处尝试一次,您就只有一个分支点;而且与权限预检查不同,它走的正是真实读取会走的那条路径。
用用户听得懂的话说明发生了什么。 “无法读取数据”只会换来一张关于数据丢失的工单。准确的提示要点出边界和补救办法:这份数据属于另一位开发者的应用,macOS 默认阻止此类访问,开关在“隐私与安全性”里。用户没办法对一个他们找不到的失败采取行动。
不要重试。 拒绝是一种策略状态,不是瞬时错误。对着被封锁的容器做退避重试,只会耗电、刷日志,却什么都改变不了。失败一次,把状态呈现出来,再提供一个用户改完设置后可以自己触发的“重新检查”。
用激活时重查代替轮询。 用户离开去改设置、然后回来,那一刻才是重新测试访问的时机。定时轮询一条被封锁的路径,只会按您设定的间隔一次次拿到同样的拒绝。
问问自己是否真的需要这次跨团队读取。 这个问题不太舒服,但答案往往是“不需要”。读取竞品容器的迁移工具,做的正是平台连着三个版本在收紧的事情:macOS 15 上被沙箱化,随后被提示挡住,如今直接被拒。由对方应用自己提供的导出路径、由用户驱动的文件选择器导入、或者一份有文档的交换格式,都不会被这次变更影响。跨 Team ID 的容器读取正走在一条轨迹上,而这条轨迹只指向一个方向。
文件选择器值得单独一提,因为它正是平台为您准备的出口。用户选中一个文件,就通过 security-scoped 机制显式授予了访问权;比起那个刚刚消失的提示,这套授权逻辑更站得住脚,而且今天就能用,不需要改任何设置。
要点回顾
给 macOS 应用开发者:
- 在 macOS 上,永远不要把 containerURL(forSecurityApplicationGroupIdentifier:) 返回非 nil 当作有访问权的证据。它总会返回一个 URL。请尝试读取,并处理错误。
- 别拿 isReadableFile(atPath:) 做预检查。Apple 反对预判文件系统的结果,而且它评估的是 POSIX 权限,策略层的拒绝未必会体现在那里。
- 审计所有读取其他 Team ID 名下容器的代码路径。在 macOS 27 上,它们会失败,而且不会问任何人。
- 如果您把 ~/Library/Group Containers/... 写死了,就没有可加保护的方法调用。请改用 API,至少让这项检查有地方可放。
给做迁移或备份工具的团队: - 跨团队读取不再只隔着一个提示。请按“默认被拒绝”来设计,并告诉用户设置在哪里。 - 失败表现为数据缺失,而不是报错。请加上明确的提示文案,否则用户会当成数据丢失来上报。
给要更换 Team ID 的人: - 一次收购或账号拆分,会悄无声息地切断此前共享同一 Team ID 前缀的应用之间的 App Group 共享。源代码里没有任何东西会提示这一点。
常见问题
同一个开发者账号下共享容器的应用会受影响吗?
不会。边界是 Team ID。Apple 的规则本就禁止不同团队使用同一个 App Group,而同一个团队可以在自己的应用、App Extension、App Clip 和 XPC Service 之间共享同一个 App Group。2
用户会看到一个可以点“允许”的提示吗?
在 macOS 27 上不会。发行说明写明,此类访问不再请求授权,默认被拒绝,管理入口移到了“隐私与安全性”设置中。1
用较旧的 SDK 构建能绕开吗?
多半不能。发行说明为其他 11 项变更使用了明确的 SDK 限定语,唯独这一条没有,这暗示它是一项适用于所有二进制文件的 OS 层策略。1 Apple 没有把话说死,所以请把它当作有力推断,并按访问会失败来规划。
怎么把它和描述文件的问题区分开?
运行 sudo launchctl procinfo <pid>,看看系统有没有给您的进程设置 entitlements-validated 标志。较旧的描述文件可能早于 App Group 授权机制,会产生外观相似、成因却毫不相干的失败。2
为什么文档里还写着有提示?
Apple 的 App Group 容器指南描述的是 macOS 15 的行为,截至本文写作时尚未针对 macOS 27 的变更更新。2 请自行确认该页面的当前状态,不要只依赖本文的日期。
参考来源
-
Apple,“macOS 27 Golden Gate Beta 4 Release Notes.” System Integrity Protection,New Features 部分:“访问其他开发者团队的 App 数据容器和 App Group 容器时,系统不再向用户请求授权;此类访问默认被拒绝,用户可在‘隐私与安全性’设置中管理。”Radar 161835690。此文同时也是另外 11 处条目中 SDK 限定语的出处——而本条目恰恰没有这类限定语。该 HTML 由客户端渲染,机器可读的副本位于
developer.apple.com/tutorials/data/documentation/macos-release-notes/macos-27-release-notes.json。 ↩↩↩↩↩↩↩ -
Apple,“Accessing app group containers in your existing macOS app.” 以下内容的出处:macOS 15 的提示行为、不同开发者团队不能共享同一 App Group 的规则、
group.与<TeamID>.<group name>两种标识符形式的区别、sudo launchctl procinfo的 entitlements-validated 检查、REGISTER_APP_GROUPS构建设置,以及可参与容器共享的对象清单。检索于 2026年8月1日,页面仍在描述提示行为。 ↩↩↩↩↩↩↩↩↩ -
Apple,“isReadableFile(atPath:).” “不要预判文件系统状态”这条建议的出处:“与其提前判断某个操作是否会成功,不如直接尝试该操作(例如加载文件或创建目录),检查错误,并优雅地处理这些错误。”同时也是这一说明的出处:该方法“使用真实用户 ID 和组 ID,而非有效用户 ID 和组 ID,来判定文件是否可读”——这正是 POSIX 层面的检查无法可靠代表策略层拒绝的原因。 ↩↩
-
Apple,“containerURL(forSecurityApplicationGroupIdentifier:).” 返回值说明:“在 iOS 中,当 App Group 标识符无效时,该值为
nil。在 macOS 中,即使 App Group 无效,也始终返回一个格式正确的 URL,因此在使用之前,请务必测试您确实能够访问底层目录。”同时也是“不要手工拼接容器路径”这条建议的出处。 ↩↩↩↩