📥

macOSアプリで見かける自動アップデート機能を実装する

に公開

概要

今回はmacOS向けのアプリで見かける、アップデートがあればお知らせしてくれる機能(👇のようなダイアログが出てお知らせしてくれる)を実装していきたいと思います。

image1.png

宣伝

macOS向けのメニューバーアプリ「QuickShelf」を公開しているので、良ければ使ってみて下さい。メニューバー上で特定のディレクトリを保存しといて、いつでもそのディレクトリ配下のファイル等をDrag&Dropできるアプリになります。事務作業などでよくファイルアップロードする為にFinderを探している方は試してみて下さい。

https://quickshelf-app.slowlab.dev/

今回はこのアプリに自動アップデート機能の実装を行なっていきたいと思います。

前提

  • 直配布(非 App Store)
  • 署名: Developer ID(Hardened Runtime 有効)でコード署名し、公証(notarize)して配布済み

実装方法

実装方法としては大きく以下の2つがある様です。今回は直配布(非 App Store)で自動アップデート機能を実装するので、フレームワーク Sparkle を使って実装を進めていきたいと思います。

1. フレームワークを使う Sparkle

https://sparkle-project.org/

macOS 向け定番の自動アップデートフレームワーク。ZIP/DMG などの配布と「Appcast(XML)」で差分更新も可能。EdDSA 署名 + Appleのコード署名で検証、サンドボックス対応。SwiftPMで導入可。

2. 自前実装(独自 updater + pkg/zip 配布)

自サーバでバージョン照会 → ダウンロード → 置換・再起動を自作。
ただし署名/公証、差分更新、安全な置換、ロールバック等を全部自分で担う必要があり、実務では Sparkle を使う方が安全・高速

Sparkle を使った実装

まずはセットアップから進めていきます。

Basic Setup

https://sparkle-project.org/documentation/

👆こちらを元に進めて行こうと思います。

全体の流れ

  1. プロジェクトにSparkleフレームワークを追加
  2. 署名鍵(EdDSA/ed25519)の作成
  3. appcast.xml の生成

1. プロジェクトにSparkleフレームワークを追加

「Xcode」 > 「File」 > 「Add Packages Dependencies…」から右上の検索窓に https://github.com/sparkle-project/Sparkle を入力します。

image2.png

「App Package」でフレームワークを追加します。

image3.png

「Add to Target」でTargetの選択も設定しときます。

2. 署名鍵(EdDSA/ed25519)の作成

DerivedData/QuickShelf/SourcePackages/artifacts/sparkle/Sparkle/bin (アプリ名の箇所は適宜変更して下さい)に generate_keys が作成されていると思うので、こちらで署名鍵の作成を行います。

$ ./generate_keys --account com.xxxx
Generating a new signing key. This may take a moment, depending on your machine.
A key has been generated and saved in your keychain. Add the `SUPublicEDKey` key to
the Info.plist of each app for which you intend to use Sparkle for distributing
updates. It should appear like this:

    <key>SUPublicEDKey</key>
    <string>xxxxxxxxxxxxxxxxxxxxxxxxxxx</string>

※アカウントはデフォルトで ed25519 になるとの事なので --acount で指定してます

作成が行われたら自動でKeyChainにも書き込まれています。また秘密鍵をエクスポートしたり、インポートするにも generate_keys を使用します。

# エクスポート
./generate_keys --account com.xxxx -x /path/to/exported_private_key

# インポート
./generate_keys --account com.xxxx -f /path/to/exported_private_key

次に鍵を生成された時に出力された公開鍵👇を Info.plist に追記します。

    <key>SUPublicEDKey</key>
    <string>xxxxxxxxxxxxxxxxxxxxxxxxxxx</string>

Xcode 12以降は物理的な Info.plist を置かなくてもXcodeがビルド時に生成するので Info.plist はないかと思います。なので「Target」>「Info」で「+」で適当にKeyを追加し、そのままKeyを編集してあげます。

image4.png

すると SUPublicEDKey が追加された Info.plist が作成されます。

3. appcast.xml の生成

次に現在配布している最新のrelease (DMG / ZIP) から appcast.xml を生成します。

./generate_appcast /path/to/releases/

すると /path/to/releases/appcast.xml が作成されているので enclosure url など必要な項目を修正してサーバーに設置します。

次に SUFeedURLInfo.plist に設定します。値は先ほど appcast.xml を設置したURLを設定すればOKです。

 <key>SUFeedURL</key>
 <string>https://xxxxx/appcast.xml</string>

サンドボックスアプリの場合の追加設定

https://sparkle-project.org/documentation/sandboxing/

Sparkle がサンドボックス環境で機能するには、アプリケーションが XPC サービスを呼び出し、アプリケーションの権限要件が満たされていることを確認する必要があります。

ここで登場した XPC サービスとは?

https://developer.apple.com/documentation/xpc

XPCは、基本的なプロセス間通信のための軽量なメカニズムを提供し、低レベルのプロセス間通信メカニズムにアクセスします

そのXPC サービスをSparkleでは以下の2つを使用している様です。

  • Installer.xpc (Sparkle 2.2 より前の org.sparkle-project.InstallerLauncher.xpc)
    • アプリケーションのサンドボックス外で更新プログラムをインストールできるようにするもの
  • Downloader.xpc (Sparkle 2.2 より前の org.sparkle-project.Downloader.xpc)
    • アプリ本体に Outgoing Connections を付与できない場合に使用
      image5.png

Installer.xpc の設定

Info.plistSUEnableInstallerLauncherService = YES を設定する

image6.png

.entitlementsファイルに以下を追加する

<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>

image7.png

Downloader.xpcの設定

こちらですが、ドキュメントに

ダウンローダーサービスの使用にはいくつかの欠点があり、考慮すべき点があります

となっているので、素直に「App Sandbox」>「Network」>「Outgoing Connections」にチェック入れても問題ない場合はそちらで対応した方が良いのかもしれません。

今回はチェックを入れて対応することにしました。

アプリに組み込む

次にメニュー内に「Check for Updates…」を設置しクリックしたらアップデートの確認を行うようにしたいと思います。

今回は Support/Updater ディレクトリを新規に作成しその中にアップデートチェックの実装を追加する形で進めていきたいと思います。

まずは SparkleSPUStandardUpdaterController を扱う AppUpdater クラスを作成します。

  • Support/Updater/AppUpdater.swift
import Sparkle

final class AppUpdater {
    private let updaterController = SPUStandardUpdaterController(
        startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil
    )

    func checkForUpdates() {
        updaterController.updater.checkForUpdates()
    }
}

次に QuickShelfAppNSMenuItem を追加し AppDelegate 経由で先ほど作成した AppUpdater のメソッドを呼ぶ様にします。

  • QuickShelfApp.swift
private func popupContextMenu(for item: NSStatusItem) {
  // ...
  let updaterItem = NSMenuItem(
      title: "Check for Updates…",
      action: #selector(AppDelegate.checkForUpdates(_:)),
      keyEquivalent: ""
  )
  updaterItem.target = AppDelegate.shared
  menu.addItem(updaterItem)
  menu.addItem(.separator())
  // ...
}

class AppDelegate: NSObject {
    static let shared = AppDelegate()
    // ...

    private let updater = AppUpdater()

    @objc func checkForUpdates(_ sender: Any?) {
        updater.checkForUpdates()
    }
    // ...
}

ここまでで実行してます。実行してメニュの「Check for Updates…」を選択すると、

image8.png

現在はアップデートが無い為、以下の様なダイアログが表示されるかと思います。

image9.png

アップデートチェック中に以下のWarningが出る

実行するとコンソールログに以下のWarningが表示される場合があるかと思います。

Warning: Background app automatically schedules for update checks but does not implement gentle reminders. As a result, users may not take notice to update alerts that show up in the background. Please visit https://sparkle-project.org/documentation/gentle-reminders for more information. This warning will only be logged once.

gentle reminders 機能が実装されていません。そのため、ユーザーはバックグラウンドに表示される更新通知に気づかない可能性があります

ここでいう gentle reminders 機能とは一体何でしょうか…? ログに表示されていたドキュメントを読んでみます👇

https://sparkle-project.org/documentation/gentle-reminders/

「アップデートを見つけても即モーダルを前面に出さず、アプリ側の“さりげない合図”で気づかせる」ための仕組み。とくに バッググラウンドのアプリ(Dockに表示されない)で、他アプリの作業を邪魔せずに更新へ誘導できる

バックグラウンドのアプリで未実装の場合、警告のログが出るのかもしれません。今回は UserNotifications を使って「さりげない合図」を実装しようと思います。

新規に以下内容を実装します。

  • Support/Updater/NotificationBridge.swift
import UserNotifications

final class NotificationBridge: NSObject, UNUserNotificationCenterDelegate {
    func configure() {
        UNUserNotificationCenter.current().delegate = self
    }
    func requestAuth() {
        // 結果のハンドリングは今回はスキップ
        UNUserNotificationCenter.current()
            .requestAuthorization(options: [.badge, .alert, .sound]) { _, _ in }
    }
    func postGentleUpdate(version: String) {
        let content = UNMutableNotificationContent()
        content.title = "Update available"
        content.body  = "Version \(version) is now available"
        UNUserNotificationCenter.current().add(
            UNNotificationRequest(identifier: "QS_UPDATE", content: content, trigger: nil)
        )
    }
    func clearGentleUpdate() {
        UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ["QS_UPDATE"])
    }
}
  • Support/Updater/UpdaterDelegate.swift
import Sparkle

final class UpdaterDelegate: NSObject, SPUStandardUserDriverDelegate, SPUUpdaterDelegate {
    weak var updaterController: SPUStandardUpdaterController?
    private let notifier: NotificationBridge

    init(notifier: NotificationBridge) {
        self.notifier = notifier
    }

    // これを true にすると Gentle Reminders を使う宣言になる(必須)
    var supportsGentleScheduledUpdateReminders: Bool { true }

    func standardUserDriverShouldHandleShowingScheduledUpdate(_ update: SUAppcastItem,
                                                             andInImmediateFocus immediateFocus: Bool) -> Bool {
        // Sparkleに新しいスケジュールされた更新の表示の処理を任せるか?
        // 任せる場合 immediateFocus を返す
        return immediateFocus
    }

    // Sparkle が前面に出さない(= gentle に知らせる)ときに呼ばれる
    func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool,
                                                   forUpdate update: SUAppcastItem,
                                                   state: SPUUserUpdateState) {
        guard !handleShowingUpdate else { return } // ユーザー発火などは Sparkle に任せる
        // ユーザー発火でない場合は通知でお知らせする
        if !state.userInitiated { notifier.postGentleUpdate(version: update.displayVersionString) }
    }
    // ユーザーがアップデートに注意を向けたら、通知をクリア
    func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
        notifier.clearGentleUpdate()
    }
    // セッション終了時(スキップ/完了/エラー)
    func standardUserDriverWillFinishUpdateSession() {
        notifier.clearGentleUpdate()
    }
    // Sparkle が次回の定期チェックをスケジュールする時に通知許可を要求
    func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) {
        notifier.requestAuth()
    }
}
  • Support/Updater/AppUpdater.swift
import Sparkle

final class AppUpdater {
    // 先ほどの NotificationBridge を保持
    private let notifier = NotificationBridge()
    // delegate を SPUStandardUpdaterController初期化時に渡す
    private lazy var delegate = UpdaterDelegate(notifier: notifier)
    private lazy var updaterController = SPUStandardUpdaterController(
        startingUpdater: true, updaterDelegate: delegate, userDriverDelegate: delegate
    )

    init() {
        // updaterController を渡さないと動かない
        delegate.updaterController = updaterController
        notifier.configure()
    }

    func checkForUpdates() {
        updaterController.updater.checkForUpdates()
    }
}

実装できたのでテストしてみたいと思います。今回はまだ運用が始まってない前提なので、直接appcast.xml のバージョンをあげてサーバーにアップロードして確認してみます。

defaults delete [アプリbundleId] SULastCheckTime

👆を実施するとアプリ起動時にアップデートチェックが走ります。チェックが走ると以下のようなダイアログが表示されます。

image10.png

ちなみにこの時通知は表示されないです。👆のダイアログが「ユーザー発火」となりUpdaterDelegate.swiftで通知をスキップしている為です。テストの場合どうしても先にダイアログが走る為、通知のテストを行う場合UpdaterDelegate.swiftでスキップしている箇所をコメントアウトして確認が必要でした。

(コメントアウトしなくても確認できる方法ご存知の方教えて頂けると助かります 🙇)

まとめ

今回はmacOSアプリで見かける自動アップデート機能を実装してみました。Sparkleを使うと事前準備は少し手間ですが、
一度実装してしまえば簡単に自動アップデート機能を実装できるので便利だなと感じました。

参考URL

https://zenn.dev/usagimaru/scraps/276953d38b5222

https://yutokun.com/knowledges/unity-xcode-macos-tips/index.html

https://medium.com/@borto_ale/integrating-sparkle-updater-in-swiftui-for-macos-82ae4e0b4ac6

Discussion