讓目標真正成為「MAS 專用」
在第 1 篇中,我們註冊了 MAS 專用 Bundle ID 並複製了 FocusTimer MAS 建置目標。但該目標目前仍只是直接分發目標的副本。
MAS 建置必須與直接分發建置在三個方面有所不同:
- 權限 (entitlements) — 僅保留適合 App Store 的最小權限
- Info.plist — 移除 Sparkle 金鑰,新增 App Store 中繼資料
- 程式碼 — 分支處理,確保沒有 Sparkle 時也能編譯
本文將逐一處理這三點。
與第 1 篇相同,
FocusTimer、com.example.FocusTimer.mas等均為範例值。
第 1 步 — MAS 專用 entitlements 檔案
entitlements 是列出應用程式請求哪些系統權限的檔案。直接分發建置中,Sparkle 為了安裝更新需要額外權限;但 MAS 建置沒有 Sparkle,因此那些權限是不必要的。
在專案根目錄建立新檔案 FocusTimer-MAS.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://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.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
與直接分發用 FocusTimer.entitlements 相比,差異如下:
- 無 Sparkle 相關權限 — 直接分發建置中的
temporary-exception.mach-lookup.*(Sparkle 與安裝輔助程式通訊所需的例外)在 MAS 建置中沒有存在的理由。 - 無
network.client— 移除 Sparkle 後,FocusTimer 本體不進行任何網路呼叫。不使用的權限理應移除,審核中多餘權限也可能被指出。應用程式請求的權限面越小越好。
上述範例是僅擁有「讀寫使用者主動選擇的檔案」權限的最簡形式。若你的應用程式實際使用了網路或其他資源,請相應新增對應權限,但絕對不要包含不使用的權限。
第 2 步 — MAS 專用 Info.plist 檔案
同樣,在專案根目錄建立新檔案 FocusTimer-MAS-Info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>
與直接分發用 FocusTimer-Info.plist 相比,有兩點不同:
① 去掉所有 Sparkle 的 SU* 金鑰
直接分發用 Info.plist 中包含 SUFeedURL、SUPublicEDKey 等 Sparkle 設定金鑰。MAS 用 Info.plist 中一個都不應有。App Store 審核禁止自帶自動更新機制,即使 plist 中僅殘留暗示此功能的金鑰也可能成為問題。不包含這些金鑰才是安全的做法。
② 新增 App Store 中繼資料金鑰
LSApplicationCategoryType— 應用程式在 App Store 中所屬的分類。上述範例中的public.app-category.productivity表示「效率工具」分類。該值必須與第 3 篇中在 App Store Connect 設定的分類一致,否則審核階段會出現不符警告。ITSAppUsesNonExemptEncryption— 應用程式是否使用受出口管制的加密技術。若僅使用標準加密(如 HTTPS、標準系統 API)或不使用加密,可設為false。提前寫入此金鑰,可自動通過每次上傳建置時出現的加密調查問卷。
將
ITSAppUsesNonExemptEncryption設為false之前,請確認你的應用程式確實未使用非標準加密。若不確定,建議參考 Apple 的加密相關文件。
第 3 步 — MAS 目標建置設定
這是第 1 篇 2-3 節中推遲的建置設定更新。在 TARGETS → FocusTimer MAS → Build Settings 中對齊以下設定:
| 建置設定金鑰 | 值 |
|---|---|
PRODUCT_BUNDLE_IDENTIFIER | com.example.FocusTimer.mas |
INFOPLIST_FILE | FocusTimer-MAS-Info.plist |
CODE_SIGN_ENTITLEMENTS | FocusTimer-MAS.entitlements |
ENABLE_APP_SANDBOX | YES |
ENABLE_HARDENED_RUNTIME | YES |
ENABLE_USER_SELECTED_FILES | readwrite |
CODE_SIGN_STYLE | Automatic |
MARKETING_VERSION、CURRENT_PROJECT_VERSION | 與直接分發目標保持一致 |
核心要點如下:
INFOPLIST_FILE和CODE_SIGN_ENTITLEMENTS必須指向剛才建立的 MAS 專用檔案。常見的錯誤是將這兩行保持為直接分發用檔案不變。- 將
CODE_SIGN_STYLE設為Automatic,Xcode 會自動頒發和匹配 Mac App Store 佈建設定檔和 Apple Distribution 憑證,無需手動管理簽署。 - 若應用程式不使用網路,則不設定
ENABLE_OUTGOING_NETWORK_CONNECTIONS,這與在 entitlements 中移除network.client的邏輯一致。 - 版本號碼(
MARKETING_VERSION等)保持與直接分發目標相同的值,以免兩個渠道的版本號碼出現差異。
第 4 步 — Sparkle 程式碼分支(#if canImport(Sparkle))
即使拆分了設定檔,還有一個問題未解決:原始碼中某處有 import Sparkle 及使用 Sparkle 的程式碼,而 MAS 目標中未連結 Sparkle,因此 import 陳述式本身會產生編譯錯誤。
解決方案是用 Swift 的條件編譯指令 #if canImport(Sparkle) 包裹 Sparkle 相關程式碼。
為何用 canImport — 不使用自訂旗標的原因
也可以用 #if MAS_BUILD 等自訂編譯旗標來分支。但這樣就需要人工同步「MAS 目標開啟該旗標,直接分發目標關閉該旗標」的設定。隨著目標增多或設定變化,容易出現不一致。
canImport(Sparkle) 則不同。它由編譯器直接檢查「Sparkle.framework 是否連結到此目標」。由於第 1 篇中已從 MAS 目標的套件相依性中刪除了 Sparkle,在 MAS 目標中 canImport(Sparkle) 會自動為假 (false),無需單獨同步任何旗標。這就是 macOS 應用程式分離 MAS 渠道時將此模式作為事實標準的原因。
分支 ① — 完全包裹僅含 Sparkle 程式碼的檔案
僅包含自動更新邏輯的檔案(如 UpdaterCoordinator.swift)整體用 #if 包裹:
#if canImport(Sparkle)
import Foundation
import Sparkle
import Combine
@Observable
@MainActor
final class UpdaterCoordinator {
// 包裹 Sparkle 更新器的程式碼 …
}
#endif
在 MAS 建置中,此檔案會像空檔案一樣編譯,不產生任何影響。
分支 ② — 同時包裹使用 Sparkle 的程式碼
建立 UpdaterCoordinator 並將其傳遞給介面的程式碼也需要分支。否則 MAS 建置會參照「不存在的型別」。
@main
struct FocusTimerApp: App {
@State private var viewModel = CompositionRoot.makeContentViewModel()
#if canImport(Sparkle)
@State private var updater = UpdaterCoordinator()
#endif
var body: some Scene {
Settings {
#if canImport(Sparkle)
PreferenceView(viewModel: viewModel, updater: updater)
#else
PreferenceView(viewModel: viewModel)
#endif
}
MenuBarExtra {
#if canImport(Sparkle)
MenuBarContent(updater: updater)
#else
MenuBarContent()
#endif
} label: {
// 選單列圖示 …
}
}
}
private struct MenuBarContent: View {
#if canImport(Sparkle)
@Bindable var updater: UpdaterCoordinator
#endif
var body: some View {
Button("顯示 FocusTimer") { /* … */ }
#if canImport(Sparkle)
Button("檢查更新…") { updater.checkForUpdates() }
.disabled(!updater.canCheckForUpdates)
#endif
// 其他共用選單項目 …
}
}
要點有三:
updater欄位宣告本身用#if包裹。- 接收
updater的介面(PreferenceView、MenuBarContent)分為有 Sparkle 和無 Sparkle 兩種形式。 - 「檢查更新…」等 Sparkle 專用 UI 元素也用
#if包裹。在 MAS 建置中,這些選單項目不會出現——App Store 版不應有自帶更新選單,這是正確的。
設定介面(PreferenceView)中的「啟用自動更新」開關等元素也都以相同的模式包裹即可。
第 2 篇小結
跟到這裡,你現在已經準備好以下內容:
- ✅ MAS 專用權限檔案
FocusTimer-MAS.entitlements(最小權限) - ✅ MAS 專用
FocusTimer-MAS-Info.plist(移除 Sparkle 金鑰,新增 App Store 中繼資料) - ✅ MAS 目標建置設定已對齊
- ✅ 用
#if canImport(Sparkle)完成了自動更新程式碼的分支
現在,FocusTimer MAS 目標已真正有別於直接分發目標,成為可以上架 App Store 的形態。剩下的工作是搭建將此建置實際上傳到 App Store Connect 的路徑。
下一篇將建立用於上傳的 ExportOptions-MAS.plist、在 App Store Connect 中註冊應用程式記錄,並介紹如何驗證兩個渠道在未來不會被破壞,從而收尾本系列。