Files
Tam Nhu Tran 7d3a11a452 fix(bar): resolve pre-dev review findings (security gate, honesty, correctness)
Gate /api/bar/* behind the localhost-when-auth-disabled guard (single DRY
choke point) so native quota/tier/cost can't leak on a non-loopback bind with
auth disabled; add a guard test. Delete the dishonest maxRedirections test that
asserted the opposite of the production redirect hardening. Key per-account
today-cost on the local day (matching analytics) instead of UTC. Stop the
inner 429 retry in the Claude usage fetch so the outer cache + circuit breaker
honor Retry-After. Narrow the usage-transformer map type and fix stale
doc-comments; clarify the one-alert-per-reset-window quota rule; gitignore the
local demo scaffolding.
2026-06-09 18:04:31 -04:00

85 lines
3.2 KiB
Swift

import Foundation
import UserNotifications
import CCSBarCore
/// Real notification delivery backed by `UNUserNotificationCenter`.
///
/// Authorization is requested LAZILY on the first non-empty deliver, not at
/// launch. Ad-hoc-signed menu-bar apps launched via `open` get a flaky / silently
/// dropped prompt when authorization is requested during startup, so we defer the
/// request until there is actually something to show — the prompt then lands with
/// user-visible context.
///
/// When authorization is denied, `deliver` is a no-op, but the rule engine keeps
/// updating its fired-keys regardless (the App persists them independent of
/// delivery). That avoids a backlog of stale alerts replaying if the user later
/// grants permission — only conditions still true at that later poll re-fire.
@MainActor
final class BarNotifier: NotificationDelivering {
enum AuthState {
case unknown
case authorized
case denied
}
private let center: UNUserNotificationCenter?
private var authState: AuthState = .unknown
private var didRequest = false
/// `UNUserNotificationCenter.current()` traps when there is no main bundle
/// identifier (e.g. a bare `swift run` with no .app wrapper). Guard it so the
/// wiring still compiles and runs headlessly; delivery is simply a no-op there.
init() {
if Bundle.main.bundleIdentifier != nil {
center = UNUserNotificationCenter.current()
} else {
center = nil
}
}
/// Deliver one notification. The first call with a notification triggers a
/// one-time authorization request; subsequent calls reuse the cached state.
nonisolated func deliver(_ notification: BarNotification) {
Task { @MainActor in
self.send(notification)
}
}
private func send(_ notification: BarNotification) {
guard let center else { return }
if !didRequest {
didRequest = true
// Request once, lazily. The completion updates cached state; this in-flight
// notification is enqueued after, so an accepted prompt still shows it.
center.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
// Hop back to the main actor and re-read `self.center` there rather than
// capturing the non-Sendable center across the closure boundary.
Task { @MainActor in
guard let self else { return }
self.authState = granted ? .authorized : .denied
if granted, let c = self.center { self.post(notification, on: c) }
}
}
return
}
// Already requested: post when authorized or still-unknown; a denied state is a no-op.
if authState == .authorized || authState == .unknown {
post(notification, on: center)
}
}
private func post(_ notification: BarNotification, on center: UNUserNotificationCenter) {
let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = .default
// Identifier == fired-key so the OS de-dupes at the delivery layer too: a
// re-posted same-key request replaces rather than stacks.
let request = UNNotificationRequest(
identifier: notification.id, content: content, trigger: nil)
center.add(request, withCompletionHandler: nil)
}
}