如何使用Sparkle实现Mac App自动更新

2021年3月15日 · 4 years ago

如何使用Sparkle实现Mac App自动更新

Updated at 2024/05/09: 本文写的时候已经比较旧啦,现在直接读官方文档就足够了: Documentation - Sparkle: open source software update framework for macOS

有做Mac App开发的朋友应该多少都听说过Sparkle Framework。因为macOS允许在Mac App Store以外进行App分发,所以如果你的App不上App Store那就得自己解决App更新问题。Sparkle就是一个非常好用的解决方案。

Sparkle的原理也很简单,以appcast.xml文件为数据规范,提供客户端的检查更新、下载、数据校验、自动替换等通用能力。App的CDN存储、更新信息的XML文件hosting由开发者自行解决。

客户端的部分提供了多个进程,被打包进主App,non-sandbox app逻辑比较简单,直接由Autoupdate.app来下载更新就行,但是sandboxed app就比较麻烦了,光是xpc的部分Sparkle就做了多个binary processes

Sparkle的接入并不复杂,一般根据Sparkle官方文档操作就能完成,这里我把接入过程和遇到的问题写出来记录分享一下。

1. 接入Sparkle

推荐使用CocoaPods接入,最简单。

use_frameworks!
//...
pod 'Sparkle'

主工程里初始化SUUpdater,我一般放在AppDelegate.swift里面,或者跟它同级别的实例:

var updater: SUUpdater!

func setupUpdater() {
    updater.feedURL = URL(string: url)
    updater.automaticallyChecksForUpdates = true
    updater.updateCheckInterval = 60*60*12 // 12hrs
    updater.sendsSystemProfile = true
    updater.delegate = self
}

url就是你的server host的appcast.xml,比如Just Focus的就是这样的:

<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
    <channel>
        <title>JustFocus</title>
        <item>
            <title>2.0.0</title>
            <pubDate>Mon, 15 Mar 2021 00:28:29 +0800</pubDate>
            <sparkle:minimumSystemVersion>10.13</sparkle:minimumSystemVersion>
            <enclosure url="https://your/update/file.zip" sparkle:version="601" sparkle:shortVersionString="2.0.0" length="15981682" type="application/octet-stream" sparkle:edSignature="<you-ed-signature>"/>
            <description>
                <h2>2.0.0 (Beta 1)</h2>
                <p>Build 599, 14 March 2021</p>
                <ul>
                    <li>New: Custom quotes supported.</li>
                </ul>
            </description>
        </item>
    </channel>
</rss>

最好是自己有一个一键打包上传脚本,自动更新一下appcast.xml文件。其中edSignature是Sparkle用于校验下载的包的,采用EdDSA (ed25519)算法,是一种非对称加密算法。

我们可以通过Sparkle提供的./bin/generate_keys工具来生成公私钥,公钥写进App的Info.plist文件里,私钥默认存进Mac的Keychains。每次打包完就用Sparkle提供的generate_appcast工具自动生成签名和xml文件即可。

需要注意的是:如果你换了一台编译机,那么私钥务必记得带过去,否则工具可能不会报错,但是会无法生成edSignature导致App自动更新失败。

2. Hardened Runtime & Notarization

以往开发者最熟悉的安全需求应该是Code Signing,macOS可以很好地校验代码的安全性,主要是防止被第三方篡改,后来苹果在此基础上又增加了一大堆有的没的权限校验,给开发者带来不少麻烦。

苹果在2018年发布的macOS Mojave系统带上了一个安全性更新:Hardened Runtime。在code signing阶段,Xcode会自动给app打上一个flag,这样Cocoa runtime会在运行时进行一系列检查校验,未经授权的操作就会失败。对于开发者来讲,就是要在Xcode工程中的Capability选项中打开Hardened Runtime,并且勾选自己需要的权限,比如说MAP_JIT允许你的App用mmap()分配一块可写可运行的内存,比如JSCore需要的JIT优化。

这个选项本来不是必须的,但是2019年WWDC之后苹果要求所有在Mac App Store以外分发的应用都需要进行Notarization。简单说就是把编译好的App上传到苹果的服务器,进行机器安全校验(不进行App Store的人工审核),如果校验通过就会在服务器端记录这个请求,并返回给你一个标记,你可以打进你的App里面(使用苹果的xcrun stapler工具)。

这样macOS的Gatekeeper在打开你的App时,如果有联网就会去服务器请求这个App是否通过了检查,否则默认阻止用户打开(需要右键打开)。

断网时就可以通过你staple进去的tag来识别。

那么这跟Sparkle有什么关系呢?前文提到Sparkle自带了几个App比如Autoupdate.app,这些不在我们的主工程里做code signing但会带进我们的包里,所以传到notary服务器就会报错,notarization失败: Your Mac software was not notarized。

解决方案是针对这些自带的app做一次重签名:

post_install do |installer|
    system("codesign --force -o runtime -s '<your-deveoploer-id-cert>' Pods/Sparkle/Sparkle.framework/Versions/A/Sparkle")
    system("codesign --force -o runtime -s '<your-deveoploer-id-cert>' Pods/Sparkle/Sparkle.framework/Resources/Autoupdate.app/Contents/MacOS/Autoupdate")
    system("codesign --force -o runtime -s '<your-deveoploer-id-cert>' Pods/Sparkle/Sparkle.framework/Resources/Autoupdate.app/Contents/MacOS/fileop")
end

3. Sandboxing

众所周知,Mac App Store要求上传的app全部都要打开App Sandbox,而Sparkle默认的实现无法对sandboxed app进行安装更新。如果你的app不上App Store那一切都好说,直接去掉就好了,但是如果App Store和自己的官网都要分发呢?

我们有两个做法:

  1. 针对App Store外的Build禁用Sandbox,引入Sparkle,App Store Build删掉Sparkle,开启Sandbox
  2. 采用Sparkle 2.x的Sandboxing做法

我还没有试过第二种方法,所以本文先看第一种方法,第二种以后试了再分享吧。

首先在Xcode中,我们在Project Settings里面设置两种Configurations,我这里设置的是BetaAppStore

因为我用xcconfig文件来管理多个不同Schemes的宏变量,所以对应的Configuration Set也要选好。关键是针对不同的Scheme增加标识宏:

// Beta的
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 BETA=1

接下来新建对应的两个Schemes,Archive的Build Configuration选择对应的Configuration,我这里分别是BetaAppStore

这样在代码中引用到Sparkle的地方,都是用宏包起来即可:

#if BETA || DEBUG
import Sparkle
#endif

这样就只有BetaDebug对应的Build才有Sparkle Framework了。但是这样还是会导致Sparkle被打包进去,只是没被使用而已,所以我们还得修改Podfile:

  pod 'Sparkle', :configurations => ['Debug', 'Beta']

这样Sparkle就不会出现出现在App Store Build里了,完美。