让目标真正成为"MAS 专用"

第 1 篇中,我们注册了 MAS 专用 Bundle ID 并复制了 FocusTimer MAS 构建目标。但该目标目前仍只是直接分发目标的副本。

MAS 构建必须与直接分发构建在三个方面有所不同:

  1. 权限 (entitlements) — 仅保留适合 App Store 的最小权限
  2. Info.plist — 移除 Sparkle 键,添加 App Store 元数据
  3. 代码 — 分支处理,确保没有 Sparkle 时也能编译

本文将逐一处理这三点。

与第 1 篇相同,FocusTimercom.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 中包含 SUFeedURLSUPublicEDKey 等 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_IDENTIFIERcom.example.FocusTimer.mas
INFOPLIST_FILEFocusTimer-MAS-Info.plist
CODE_SIGN_ENTITLEMENTSFocusTimer-MAS.entitlements
ENABLE_APP_SANDBOXYES
ENABLE_HARDENED_RUNTIMEYES
ENABLE_USER_SELECTED_FILESreadwrite
CODE_SIGN_STYLEAutomatic
MARKETING_VERSIONCURRENT_PROJECT_VERSION与直接分发目标保持一致

核心要点如下:

  • INFOPLIST_FILECODE_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 的界面PreferenceViewMenuBarContent)分为有 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 中注册应用记录,并介绍如何验证两个渠道在未来不会被破坏,从而收尾本系列。