wheatandcatの開発ブログ

技術系記事を投稿してます

Macアプリでスケジューラ + ローカルPush通知を実装

概要

現在開発中の Mac アプリで、定期実行と実行完了時のローカル Push 通知を実装したので、その内容をまとめる。

今回作ったもの

以下の UI で実行時間を設定する。

Mac アプリ上でスケジューラに登録し、指定した時間に処理を実行。完了後はローカル Push 通知を表示するように実装した。

今回はセキュリティの観点から、サーバー側で定期実行するのではなく、Mac アプリ自身が定期実行する 方式にしている。

Swiftでスケジューラを登録する

Swift では Timer.scheduledTimerを使うことでスケジュール実行できる。

JavaScript の setTimeoutsetInterval に近い感覚で使える。

import Foundation

var timer: Timer?

timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
    print("1秒後に実行")
}

実装

スケジューラのクラスを作成

■ AutoFetchScheduler.swift

import Foundation

/// 一定間隔で「データを更新」を呼ぶだけのシンプルなスケジューラ
final class AutoFetchScheduler {
    static let shared = AutoFetchScheduler()

    private var timer: Timer?
    private var settings: AutoFetchSettings?

    // スケジュールで実行したい処理を設定
    var fetchHandler: (() -> Void)?

    private func scheduleNext() {
        // 次回の実行日時を取得してスケジューラに登録
        let delay = max(nextFireDate().timeIntervalSinceNow, 1)
        timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
            self?.fireAndReschedule()
        }
        RunLoop.main.add(timer!, forMode: .common)
    }

    private func fireAndReschedule() {
        // 処理実行後に次のスケジューラを登録
        defer { scheduleNext() }

        // 処理実行
        fetchHandler?()
    }

    (略)
}

スケジューラで実行したい内容を外部から設定する

■ AppDelegate

/// macOSアプリ起動時の処理を担当
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {

    func applicationDidFinishLaunching(_: Notification) {

        AutoFetchScheduler.shared.fetchHandler = { [weak self] in
            self?.runUpdateBinary() // ← ここにスケジューラで実行したい処理を設定
        }
    }
}

Macがスリープしていた時の挙動を実装する

Timer.scheduledTimer は Mac がスリープ中だとタイマー自体が動作しない。
そのため、スリープ解除時に本来の実行予定時刻を過ぎていたら、そのタイミングで実行する 仕様で実装した。

■ AutoFetchScheduler.swift

final class AutoFetchScheduler {
    (略)

    /// スリープ解除時に呼ぶ。1スロット以上経過していれば即実行してタイマーを再設定する
    func wakeFromSleep() {
        guard let settings = settings else { return }

        // タイマーをリセット(スリープ中に発火予定時刻が過ぎている可能性があるため)
        stop()

        if let last = lastExecutedAt {
            let elapsed = Date().timeIntervalSince(last)
            if elapsed >= settings.timeInterval,
               ignoreTime() == false,
               checkWeekday() == true
            {
                lastExecutedAt = Date()
                fetchHandler?()
            }
        }

        scheduleNext()
    }

    (略)
}

Swift でスリープ解除を検知するには NSWorkspace.shared.notificationCenter.addObserver を使う。

■ AppDelegate

class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {

    func applicationDidFinishLaunching(_: Notification) {

        // スリープ解除を検知
        NSWorkspace.shared.notificationCenter.addObserver(
            self,
            selector: #selector(onWakeFromSleep),
            name: NSWorkspace.didWakeNotification,
            object: nil
        )
    }

    @objc private func onWakeFromSleep() {
        AutoFetchScheduler.shared.wakeFromSleep()
    }
}

ここまででスケジューラの実装は完了。

Push通知の権限をリクエストする

UNUserNotificationCenter.current().requestAuthorization を使って Push 通知の許可をリクエストする。

■ AppDelegate

class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {

    func applicationDidFinishLaunching(_: Notification) {
        // Push通知の許可をリクエスト
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
            NSLog("通知権限: granted=\(granted), error=\(String(describing: error))")
        }
    }
}

Push通知の権限がOFFの場合の表示

Push 通知の権限が OFF の場合は以下のような表示を出す。

UNUserNotificationCenter.current().getNotificationSettings で権限状態を取得できるため、OFF の場合に表示を追加する。

private func checkNotificationPermission() {
    UNUserNotificationCenter.current().getNotificationSettings { settings in
        DispatchQueue.main.async {
            notificationDenied = settings.authorizationStatus == .denied
        }
    }
}

ローカルPush通知を登録する

UNUserNotificationCenter.current().add(request) でローカル Push 通知を登録できる。
スケジューラの実行完了後に以下の sendUpdateNotification を呼べば完了。

■ AppDelegate

class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {

    private func sendUpdateNotification() {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm"
        let dateStr = formatter.string(from: Date())

        let content = UNMutableNotificationContent()
        content.title = "自動更新完了"
        content.body = "\(dateStr)で更新"
        content.sound = .default

        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: nil
        )
        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
    }
}

これで以下のようにローカル Push 通知が表示される。

まとめ

  • 常駐型の Mac アプリを作るうえで、スケジューラ + ローカル Push 通知はかなり使いどころの多い機能
  • Timer.scheduledTimer をベースにしつつ、スリープ復帰時の補正を入れることで実用的に動かせた