macOS 27 不再詢問,直接拒絕跨團隊容器存取
在 macOS 上,即使您對某個 app group 完全沒有 entitlement,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 底下的「新功能」,而非錯誤修正。Apple 自己那份 app group 容器指南,至今仍在描述 macOS 27 已經移除的提示行為。由於 macOS 的 API 即使面對您無權存取的群組,也照樣回傳格式完整的 URL,拒絕會出現在讀取當下,而不是呼叫 API 的當下。同團隊之間的存取不受影響:分界線是 Team ID。
這次改了什麼
macOS 27 的發行說明在 System Integrity Protection 一節裡只有一句話:1
「存取其他開發團隊的 app 資料容器與 app group 容器中的檔案時,不再向使用者要求授權;這類存取預設一律拒絕,使用者可在『隱私權與安全性』設定中管理。」
Radar 161835690。兩個子句,其實是兩項獨立的變更。
前半句拿掉了提示,後半句把拒絕定為預設。談到這項變更的報導,多半從「預設拒絕」講起,但那其實是比較不有趣的一半——預設值收緊本來就是家常便飯。真正關鍵的是消失的那個提示:它改變了使用者遇到的失敗長什麼樣子,也改變了您收到的錯誤回報長什麼樣子。
從前,使用者會看到對話框並做出決定。若他們拒絕,您的 app 至少知道這是一個真人做過的選擇。現在沒有人被問到。存取就是不會發生,而唯一能開啟它的路徑,藏在一個設定面板裡——除非有人特地告訴使用者,否則他們根本沒有理由打開它。
Apple 文件仍在描述的舊行為
這項限制,是疊在兩個版本之前就已經加上的保護之上。Apple 的 app group 容器指南寫道:2
「在 macOS 15 以後,即使 app 沒有啟用 App 沙箱功能,app group 容器仍會為其本機檔案提供 [System Integrity Protection]。這些 app group 容器會限制不屬於該 app group 的 app 存取。不在 app group 內的 app 若試圖存取 app group 或 app 資料容器內的位置,會觸發一個向使用者要求授權的提示。」
那個頁面用現在式描述這個提示。截至撰稿時,Apple 尚未針對 macOS 27 更新它。
實際後果是:開發者踩到這個問題、去翻文件、找到官方頁面,得到的答案是「會跳出授權對話框」。但它不會。接著他們很合理地推論:不是提示壞了,就是自己的 entitlement 設錯了——於是把力氣花在完全錯誤的地方。
建議自行確認那個頁面的現況,不要只信這篇文章的時間戳。文件終究會補上。
為什麼失敗會延後才浮現
把一項政策變更變成除錯難題的,正是 API 的行為。
Apple 在 containerURL(forSecurityApplicationGroupIdentifier:) 的參考文件中,明確點出了平台差異:4
「回傳值為指向該群組共享目錄在檔案系統中位置的 URL。在 iOS 上,當群組識別碼無效時,該值為
nil。在 macOS 上,即使 app group 無效,也一律回傳格式正確的 URL,因此在使用之前,務必先測試您是否真的能存取底層目錄。」
討論一節又重申了一次:用一個您沒有 entitlement 的群組識別碼呼叫這個方法,依然會拿到格式正確的 URL,但該目錄並不存在,而沙箱化的 app 也無法自行建立它。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、隨後讀取仍然失敗的前置檢查,比完全不檢查更糟,因為它把意外推得離原因更遠。
讀過〈canOpenURL 在 iOS 27 已棄用〉的人會認得這個模式。Apple 不斷收回「事先詢問」的能力,只留下「直接嘗試」的能力。先做、再處理錯誤,正在變成通則,而不是針對某一個 API 的權宜之計。
值得注意的是兩個平台之間的不對稱。iOS 回傳 nil,在呼叫點誠實地失敗;macOS 回傳 URL,把失敗往後推。而 macOS 27 拿掉的那個提示,正是這個本來就比較安靜的平台上,僅存的一個訊號。
還有一個陷阱:Apple 的建議是一律使用這個方法回傳的 URL,不要自己手動組出 ~/Library/Group Containers/...,因為該位置在未來版本中可能改變。4 把路徑寫死的人,根本沒有任何方法呼叫可以檢查,也沒有地方擺放可讀性測試。
多半與建置用的 SDK 無關
很自然會想問:繼續用舊版 SDK 能不能延後這項變更?發行說明沒有明說。
但它確實透露了一種模式。macOS 27 的發行說明在 11 個獨立條目中,都明確使用了以 SDK 為條件的措辭:「在以 macOS 27.0 SDK 建置的 app 中」、「在以 27.0 SDK 建置的 app 中」、「當專案的最低部署目標低於 27.0 時」。1 只要這種但書成立,Apple 就會加上去。
容器那一條沒有任何這類但書。若把這個缺席視為刻意安排——這是合理的解讀——那麼這項限制就屬於作業系統層級的政策,適用於所有在 macOS 27 上執行的二進位檔,無論它是用哪個 SDK 建置的。
請把它當成一個有力的推論,而非既定事實,因為 Apple 並沒有直接這樣說。但不論如何,實務上的結論都一樣:別把「繼續用舊版 SDK」當成緩解方案,就當這個存取一定會失敗來規劃。
真正受影響的是誰
同團隊之間的存取完全不受影響。Apple 的規則讓 Team ID 這條界線成為結構性的、而非偶然的限制:「不同的開發團隊不能使用同一個 app group」,但同一個團隊可以在自己的 app 與支援行程之間共用一個群組。2
會壞掉的,是那些跨過這條線的 app:
移轉與匯入工具。 任何為了匯入使用者資料而去讀取競品或前代產品容器的程式。這是最明顯的一類;過去它前面本來就擋著一道提示,只是使用者有時還是會按下同意。
備份與同步工具。 那些會列舉 app 容器來進行備份的工具,如今會靜靜跳過所有不屬於自己團隊的內容。
易主過的搭配 app。 這是最尖銳的一類,因為程式碼完全沒有改動。兩個 app 原本在同一個 Team ID 底下發行,共用一個群組,相安無事。一次併購、一次團隊拆分,或是搬到另一個開發者帳號,就會讓它們落在不同的 Team ID 之下。app group 識別碼看起來依然正確,URL 依然解析得出來,資料卻再也送不到了。
最後這一類值得多談幾句,因為時間點對您極為不利。Team ID 的變更通常由負責簽署與發布的人處理,而共用容器這層依賴,從那個位置往往完全看不見。程式碼照樣編譯通過,兩個 app 照樣發行。兩邊的測試套件都抓不到,因為單元測試不會真的去碰一個跨團隊的容器,而 CI 是用建置機器手上那組身分同時建置兩個 target。失敗只會出現在正式環境、出現在使用者的機器上——表現為兩個在使用者眼中理應屬於同一產品的 app,之間的資料不再同步。
如果您正在規劃 Team ID 移轉,稽核方式很機械:用 grep 找出所有 target 宣告過的 app group 識別碼,逐一列出有哪些 bundle 會讀取它。只要某個識別碼會被落在不同 Team ID 底下的 bundle 讀取,那就是一處斷裂。搬遷之前,這只是一次十分鐘的檢查;搬遷之後,它就是一起客服事件。
Apple 列出了可以參與 app group 的項目:bundle 結構中的主執行檔、app extension、App Clips 以及 XPC Services。2 每一項都可能是這個問題浮現的地方。
如何診斷
在斷定這是程式錯誤之前,有兩項檢查值得先跑一遍。
先確認系統究竟有沒有驗證過您的 entitlement。Apple 記載了一種在執行中的行程上檢查 entitlements-validated 旗標的方法:2
sudo launchctl procinfo <pid>
在 app group 授權機制出現之前建立的佈建描述檔,可能不帶這項授權,於是產生的存取失敗看起來與 macOS 27 的拒絕一模一樣,成因卻完全不同。只要開啟「Automatically manage signing」,且 REGISTER_APP_GROUPS 這項建置設定為 Yes,Xcode 就會自動更新描述檔。2
也要清楚自己用的是哪一種識別碼形式。以 group. 為前綴的群組,必須納入 app 的佈建描述檔。採用 <TeamID>.<group name> 形式的群組則不需要描述檔,因為系統會拿團隊識別碼前綴與簽署身分比對;但這種形式僅限 macOS,且 Keychain Access Groups 不支援。2
接著再去看「隱私權與安全性」,因為發行說明說使用者的控制權現在就落在那裡。1
為「無法詢問的拒絕」做設計
那個提示做的不只是安全性的工作,也是產品的工作。它告訴使用者「這裡有一個決定要做」,也告訴您的 app「決定已經做完了」。這兩件事,現在都落到您頭上。
在邊界上探測一次就好。 第一次解析出容器時,就先做一次成本極低的讀取,而不是等到同步迴圈跑到一半才發現被拒絕。失敗若發生在佇列深處,得到的會是一份殘缺的結果,加上一個被歸咎於「剛好排到的那個檔案」的錯誤。在解析當下嘗試一次,您就只有一個地方需要分支;而且不同於權限前置檢查,這次嘗試走的正是真正讀取時會走的那條路徑。
用使用者聽得懂的話說明發生了什麼。「無法讀取資料」只會換來一張抱怨「資料遺失」的客服單。準確的訊息會同時點名界線與解法:這些資料屬於另一位開發者的 app,macOS 預設封鎖這類存取,而開關就在「隱私權與安全性」裡。使用者無法對一個自己定位不到的失敗採取任何行動。
不要重試。 拒絕是一種政策狀態,不是暫時性錯誤。對著被封鎖的容器跑退避重試迴圈,只會耗電、灌爆日誌,什麼也改變不了。失敗一次就好,把狀態呈現出來,並提供一個使用者改完設定後可以自行觸發的重新檢查。
在 app 重新回到前景時重新檢查,而不是輪詢。 使用者離開去改設定、然後回來的那一刻,才是重新測試存取的時機。用計時器輪詢一條被封鎖的路徑,只會照著您挑的間隔,一次又一次拿到同一個拒絕。
問問自己是否真的需要這個跨團隊讀取。 這是最讓人不舒服的一項,卻往往是正確答案。讀取競品容器的移轉工具,做的正是平台連續三個版本一直在限縮的事:macOS 15 加上沙箱、接著加上提示,現在則是直接拒絕。由對方 app 掌控的匯出路徑、由使用者自己操作檔案選取器完成的文件式匯入,或是一份有文件記載的交換格式,這三種做法都不受這次變更影響。跨越 Team ID 界線的容器讀取有一條清楚的軌跡,而這條軌跡只朝一個方向走。
檔案選取器值得特別一提,因為它正是平台預留的出口。使用者親手選取檔案,等於透過 security-scoped 機制明確授予存取權;這在權限的正當性上,比剛剛消失的那個提示更站得住腳,而且今天就能用,不需要動任何設定。
重點整理
給 macOS app 開發者:
- 在 macOS 上,絕對不要把 containerURL(forSecurityApplicationGroupIdentifier:) 回傳非 nil 當成有存取權的證明。它一律會回傳 URL。請直接嘗試讀取並處理錯誤。
- 別把 isReadableFile(atPath:) 當前置檢查。Apple 反對預測檔案系統的結果,而且它判斷的是 POSIX 權限——政策層的拒絕未必會動到那裡。
- 稽核所有會讀取其他 Team ID 容器的程式路徑。在 macOS 27 上,它們會在沒有問過任何人的情況下失敗。
- 如果您把 ~/Library/Group Containers/... 寫死了,就沒有任何方法呼叫可以加上防護。請改用 API,至少讓檢查有地方可放。
給推出移轉或備份工具的團隊: - 跨團隊讀取不再只差一個提示。請把「拒絕」當成預設狀態來設計,並告訴使用者設定藏在哪裡。 - 這種失敗表現為「資料不見了」,而不是一則錯誤。請加上明確的說明文案,否則使用者只會把它當成資料遺失來回報。
給任何要變更 Team ID 的人: - 併購或帳號拆分,會悄悄切斷原本共用同一個 Team ID 前綴的 app 之間的 app group 共享。原始碼裡沒有任何跡象會告訴您這件事。
常見問題
同一個開發者帳號底下共用容器的 app 會受影響嗎?
不會。分界線是 Team ID。Apple 的規則本來就禁止不同團隊使用同一個 app group,而同一個團隊可以在自己的 app、extension、App Clips 與 XPC Services 之間共用群組。2
使用者還會看到可以按下同意的提示嗎?
在 macOS 27 上不會。發行說明明白寫著,這類存取不再要求授權,而是預設拒絕,管理權移到「隱私權與安全性」設定。1
改用較舊的 SDK 建置可以避開嗎?
多半不行。發行說明對其他 11 項變更都使用了以 SDK 為條件的措辭,唯獨這一項沒有,這暗示它是適用於所有二進位檔的作業系統層級政策。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,新功能:「存取其他開發團隊的 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,來判斷檔案是否可讀」這項說明也出自此處;這正是為什麼 POSIX 層級的檢查無法可靠地代表政策層的拒絕。 ↩↩
-
Apple,〈containerURL(forSecurityApplicationGroupIdentifier:)〉。回傳值:「在 iOS 上,當群組識別碼無效時,該值為
nil。在 macOS 上,即使 app group 無效,也一律回傳格式正確的 URL,因此在使用之前,務必先測試您是否真的能存取底層目錄。」不要手動組出容器路徑的指引亦出自此處。 ↩↩↩↩