Chimr のカレンダー予定通知を、「予定の5分前ぴったり」のような秒単位の精度で出せるようにしました。タイマーを2段階に分けて、30秒おきの periodic scan と、直近120秒以内の予定に対する one-shot timer を組み合わせています。
// 30秒おきの periodic scan
timerToken = timerProvider.scheduledTimer(
withTimeInterval: 30.0,
repeats: true
) { [weak self] in
Task { @MainActor [weak self] in
self?.checkForUpcomingEvents()
}
}
// scan の中で、直近 scanLookAheadSeconds(=120秒) 以内に
// 通知時刻が来る予定には one-shot timer を仕込む
private let scanLookAheadSeconds: TimeInterval = 120
ChimrChimr - Never Miss Another MeetingMenu bar meeting reminder with MCP server for Mac. Floating notifications with one-click join for Zoom, Meet, Teams. Let Claude access your calendar.
この記事では、30秒 periodic だけだと精度が出ない理由、one-shot timer を全件分仕込まない理由、2段階に分けたときの動き、そしてシステムスリープと App Nap への対応をまとめます。
30秒 periodic だけだと「5分前通知」が最大29秒ずれる
カレンダー予定の通知は、ユーザー設定の「N分前」に出すのが基本です。素直に実装すると、一定間隔の repeating timer で「いまから N分後付近に始まる予定」を探して通知する形になります。Chimr では当初、30秒間隔の periodic timer がこの役割を担っていました。
timerToken = timerProvider.scheduledTimer(
withTimeInterval: 30.0,
repeats: true
) { [weak self] in
Task { @MainActor [weak self] in
self?.checkForUpcomingEvents()
}
}
問題は、30秒間隔の scan だと通知のタイミングが最大29秒ずれることです。「10:00 開始の予定を5分前(=09:55)に通知したい」とき、scan が 09:54:31 と 09:55:01 で走ると、ユーザーから見ると通知は 09:55:01 に来ます。1秒の遅れは気にしませんが、09:54:30 と 09:55:00 のスケジュールだと、09:54:30 の時点では「まだ目標時刻ではない」と判定されて、09:55:00 で初めて通知が出ます。実際のずれは scan のタイミングに依存するため、ユーザーから見ると毎回違う秒数だけ遅れることになります。
scan 間隔を短くすれば精度は上がりますが、CPU と電池を食います。1秒間隔だとずれは1秒以内に収まる代わりに、アイドル時もメニューバーアプリが毎秒走るので、現実的ではありません。
一発 (one-shot) timer だけだと「先の予定まで全部仕込む」必要がある
逆に、すべての予定の通知時刻に対して Timer(fire:) の one-shot を仕込む方針もあります。fireDate を指定すると「その時刻に一度だけ発火」する timer になり、精度は秒単位で出せます。
ただし1日に予定が複数並ぶ状態で、すべての予定について「N分前」の絶対時刻を計算して全部 one-shot を仕込むのは無駄が多いです。10時間後の予定の通知を「いま」スケジュールしても、その時刻までにシステムスリープや復帰、カレンダー側の予定変更が入る可能性があり、結果として再スケジュールが要ります。
加えて、long timer はシステム的に発火の正確さに保証がありません。デバイスのスリープ復帰や App Nap で発火がずれる事例がよく知られています。
2段階に分けて「scan で当たりをつけて、近づいたら one-shot」にする
採用したのは、両方を組み合わせる形です。
- 粗く scan する repeating timer (30秒間隔) — 「いまから120秒以内に通知時刻が来る予定」を探す
- 見つかった予定に対して one-shot timer を仕込む —
fireDateぴったりで発火する
scan が見つけたタイミングで仕込む one-shot は、scanLookAheadSeconds(120秒) 以内に発火するものだけです。それより遠い予定は、次の scan か、その次の scan が拾います。
private let scanLookAheadSeconds: TimeInterval = 120
private func scheduleOneShotTimers() {
let now = Date()
let notificationMinutes = settings.notificationTiming.rawValue
let todayEvents = calendarViewModel.notificationEvents(for: now)
for event in todayEvents {
let key = NotificationKeyGenerator.generateKey(
for: event, notificationMinutes: notificationMinutes)
// 既に通知済み、もしくは既にスケジュール済みならスキップ
guard !notifiedEvents.contains(key), oneShotTokens[key] == nil else { continue }
// 通知時刻 (event.startDate の N分前) を計算
guard let fireDate = Calendar.current.date(
byAdding: .minute, value: -notificationMinutes, to: event.startDate
) else { continue }
// 直近 scanLookAheadSeconds (=120秒) 以内に発火するものだけ仕込む
let delay = fireDate.timeIntervalSince(now)
guard delay > 0, delay <= scanLookAheadSeconds else { continue }
// 厳密な時刻発火 (一度だけ)
let token = timerProvider.scheduledTimer(fire: fireDate) { [weak self] in
Task { @MainActor [weak self] in
self?.fireOneShotNotification(for: event, key: key)
}
}
oneShotTokens[key] = token
}
}
fireDate ぴったりで発火する one-shot のおかげで、秒単位精度の通知になります。同時に long timer を全件分仕込まないので、メモリと将来の再計算コストも抑えられます。30秒の scan で「次の2分以内に通知時刻が来る予定」を見つけ次第、その時刻に向けた one-shot を仕込むだけです。
システムスリープとカレンダー変更にも追従する
one-shot timer はスケジュール時の状態に依存します。途中でシステムがスリープしたり、カレンダーの予定が変わったりすると、スケジュール済みの timer が無効化されたまま忘れられがちです。
// スリープ復帰時に再スケジュール
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(handleSystemWake),
name: NSWorkspace.didWakeNotification,
object: nil
)
// カレンダー変更時に再スケジュール
NotificationCenter.default.addObserver(
self,
selector: #selector(handleCalendarStoreChanged),
name: NSNotification.Name.EKEventStoreChanged,
object: nil
)
NSWorkspace.didWakeNotification と EKEventStoreChanged の2つを購読しておき、それぞれのタイミングで全 one-shot を invalidate してから scan を走らせ直します。これで「スリープ中に通知時刻を跨いだ」「予定の開始時刻が変わった」のどちらにも追従できます。
App Nap を抑える ProcessInfo activity を握る
macOS には App Nap という機能があり、フォアグラウンドにいないアプリの実行頻度が抑えられます。メニューバー常駐アプリの Chimr は基本的にバックグラウンドにいるので、放っておくと App Nap で timer の発火がずれます。
そこで、one-shot timer をスケジュール中は ProcessInfo.beginActivity で activity を握っておき、最後の one-shot timer が片付いたら endActivity で解放する設計にしました。
private var processInfoActivity: NSObjectProtocol?
private func updateProcessInfoActivity() {
if oneShotTokens.isEmpty {
if let activity = processInfoActivity {
ProcessInfo.processInfo.endActivity(activity)
processInfoActivity = nil
}
} else if processInfoActivity == nil {
processInfoActivity = ProcessInfo.processInfo.beginActivity(
options: .userInitiatedAllowingIdleSystemSleep,
reason: "Precise notification timer pending"
)
}
}
オプションには .userInitiatedAllowingIdleSystemSleep を指定しています。アプリ側の App Nap や自動終了は抑止しつつ、ユーザーのマシン側のアイドルスリープまでは妨げない選択です。120秒以内に発火する分だけ activity を握る設計なので、電池への影響も1日のうちの累計で数分にとどまります。
「予定の N分前ぴったりに通知が来る」のは、書いてみれば当たり前の挙動です。それでも、単純な repeating timer 一本だと秒単位の精度は出ませんし、すべて one-shot で持つと運用が重くなります。30秒 scan + 120秒以内の one-shot という2段階の組み合わせは、いまのところ Chimr の通知の体感速度に効いています。
