Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 146 additions & 3 deletions Packages/BiscottiKit/Sources/AppCore/AppCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,11 @@ public final class AppCore {
/// Calendar-start notification timer tasks, keyed by event composite key.
private var calendarTimerTasks: [String: Task<Void, Never>] = [:]

/// Removal timers for posted calendar notifications, keyed by event key.
/// Separate from `calendarTimerTasks` so `scheduleCalendarTimers()` (which
/// runs on every calendar refresh) never cancels them (C1).
private var calendarNotificationExpiryTasks: [String: Task<Void, Never>] = [:]

/// Background tasks for consuming detector events and notification actions.
private var detectorConsumerTask: Task<Void, Never>?
private var notificationConsumerTask: Task<Void, Never>?
Expand Down Expand Up @@ -468,6 +473,10 @@ public final class AppCore {
upcoming = calendar.upcoming
logger.info("startBackgroundServices: calendar done")

// L1: clean up stale notifications from previous process before
// detection starts (so a fresh ad-hoc can't race the sweep).
await cleanUpStaleNotificationsOnLaunch()

logger.info("startBackgroundServices: detector start")
detector.start()

Expand Down Expand Up @@ -520,6 +529,10 @@ public final class AppCore {
// persist on screen during an active recording.
await notifications.cancelAdHocDetected()

// C2: remove all calendar notifications on recording start.
cancelAllCalendarNotificationExpiryTasks()
await notifications.cancelAllMeetingStarting()

// Stash the eventKey/title so retry can re-use them.
pendingStartupEventKey = eventKey
pendingStartupTitle = title
Expand Down Expand Up @@ -1215,7 +1228,7 @@ extension AppCore {
case let .started(app):
await handleDetectionStarted(app: app)
case let .stopped(app):
handleDetectionStopped(app: app)
await handleDetectionStopped(app: app)
case .allMicUsersStopped:
handleAllMicUsersStopped()
}
Expand Down Expand Up @@ -1274,7 +1287,12 @@ extension AppCore {
runState = .detectedPending
}

private func handleDetectionStopped(app: DetectedApp) {
private func handleDetectionStopped(app: DetectedApp) async {
// D1: remove the stopped app's ad-hoc notification regardless of
// run state. This is a no-op when the notification was already
// dismissed or removed by a recording start.
await notifications.cancelAdHocDetected(bundleID: app.bundleID)

// If pending detection and the stopped app matches, revert to idle
if runState == .detectedPending,
activeDetectedBundleID == app.bundleID
Expand Down Expand Up @@ -1378,6 +1396,9 @@ extension AppCore {
// MARK: - Calendar-start timers

extension AppCore {
/// How long a calendar notification stays after the meeting starts (C1).
nonisolated static let calendarNotificationLifetime: TimeInterval = 300

/// Filters upcoming events by the calendar notification mode.
/// Pure and testable.
nonisolated static func eventsToNotify(
Expand Down Expand Up @@ -1432,15 +1453,70 @@ extension AppCore {
guard runState == .idle || runState == .detectedPending
else { return }

// Skip if the Mac slept through start + lifetime (notification
// would already be expired).
guard Self.shouldPostCalendarNotification(
eventStart: event.start, now: Date()
) else {
logger.info(
"handleCalendarTimerFired: skipped expired notification for \(event.id)"
)
return
}

lastCalendarNotificationDate = Date()

await notifications.present(
.meetingStarting(
eventKey: event.id,
title: event.title,
joinURL: event.conferenceURL
joinURL: event.conferenceURL,
start: event.start
)
)

let expiry = event.start.addingTimeInterval(
Self.calendarNotificationLifetime
)
scheduleCalendarNotificationExpiry(
eventKey: event.id, at: expiry
)
}

/// False once the notification would already be expired
/// (now >= start + lifetime).
nonisolated static func shouldPostCalendarNotification(
eventStart: Date, now: Date
) -> Bool {
let expiry = eventStart.addingTimeInterval(
calendarNotificationLifetime
)
return now < expiry
}

private func scheduleCalendarNotificationExpiry(
eventKey: String, at expiry: Date
) {
calendarNotificationExpiryTasks[eventKey]?.cancel()
let delay = expiry.timeIntervalSinceNow
let sched = scheduler
calendarNotificationExpiryTasks[eventKey] = Task { [weak self] in
if delay > 0 {
do {
try await sched.sleep(for: .seconds(delay))
} catch { return }
}
guard let self, !Task.isCancelled else { return }
calendarNotificationExpiryTasks[eventKey] = nil
await notifications.cancelMeetingStarting(eventKey: eventKey)
}
}

private func cancelAllCalendarNotificationExpiryTasks() {
for (_, task) in calendarNotificationExpiryTasks {
task.cancel()
}
calendarNotificationExpiryTasks.removeAll()
}

/// Mirrors `calendar.upcoming` into `self.upcoming` and reschedules
Expand Down Expand Up @@ -1516,6 +1592,73 @@ extension AppCore {
}
}

// MARK: - Notification cleanup (L1)

extension AppCore {
struct ScheduledExpiry: Equatable {
let eventKey: String
let expiry: Date
}

struct LaunchNotificationCleanupPlan: Equatable {
var adHocBundleIDsToRemove: [String] = []
var meetingEventKeysToRemove: [String] = []
var meetingExpiriesToSchedule: [ScheduledExpiry] = []
}

nonisolated static func launchNotificationCleanupPlan(
_ delivered: [DeliveredOfferNotification], now: Date
) -> LaunchNotificationCleanupPlan {
var plan = LaunchNotificationCleanupPlan()
for item in delivered {
switch item {
case let .adHocDetected(bundleID):
plan.adHocBundleIDsToRemove.append(bundleID)
case let .meetingStarting(eventKey, meetingStart):
let expiry = meetingStart.addingTimeInterval(
calendarNotificationLifetime
)
if expiry <= now {
plan.meetingEventKeysToRemove.append(eventKey)
} else {
plan.meetingExpiriesToSchedule.append(
ScheduledExpiry(
eventKey: eventKey, expiry: expiry
)
)
}
}
}
return plan
}

private func cleanUpStaleNotificationsOnLaunch() async {
let delivered = await notifications
.deliveredOfferNotifications()
let plan = Self.launchNotificationCleanupPlan(
delivered, now: Date()
)
logger.info(
"L1 cleanup: \(plan.adHocBundleIDsToRemove.count) ad-hoc, \(plan.meetingEventKeysToRemove.count) expired cal, \(plan.meetingExpiriesToSchedule.count) deferred cal"
)
for bundleID in plan.adHocBundleIDsToRemove {
await notifications.cancelAdHocDetected(
bundleID: bundleID
)
}
for eventKey in plan.meetingEventKeysToRemove {
await notifications.cancelMeetingStarting(
eventKey: eventKey
)
}
for item in plan.meetingExpiriesToSchedule {
scheduleCalendarNotificationExpiry(
eventKey: item.eventKey, at: item.expiry
)
}
}
}

// MARK: - Association

extension AppCore {
Expand Down
4 changes: 4 additions & 0 deletions Packages/BiscottiKit/Sources/AppCore/PreviewAppCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@
func alertStyle() async -> UNAlertStyle {
.banner
}

func deliveredNotifications() async -> [DeliveredNotification] {
[]
}
}

/// No-op LLM runner for previews.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,23 @@ public struct LiveNotificationCenter: NotificationCenterProviding, Sendable {
public func alertStyle() async -> UNAlertStyle {
await UNUserNotificationCenter.current().notificationSettings().alertStyle
}

public func deliveredNotifications() async -> [DeliveredNotification] {
let notifications = await UNUserNotificationCenter.current()
.deliveredNotifications()
return notifications.map { notification in
let content = notification.request.content
var stringInfo: [String: String] = [:]
for (key, value) in content.userInfo {
if let strKey = key as? String, let strVal = value as? String {
stringInfo[strKey] = strVal
}
}
return DeliveredNotification(
identifier: notification.request.identifier,
date: notification.date,
userInfo: stringInfo
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,25 @@ public protocol NotificationCenterProviding: Sendable {

/// Current on-screen alert style (banner vs. alert vs. none).
func alertStyle() async -> UNAlertStyle

/// Notifications currently in Notification Center for this app.
func deliveredNotifications() async -> [DeliveredNotification]
}

/// A delivered notification snapshot, safe to send across isolation boundaries.
///
/// Maps from `UNNotification` (a non-Sendable class) so nothing non-Sendable
/// leaves `LiveNotificationCenter`.
public struct DeliveredNotification: Sendable, Equatable {
public let identifier: String
public let date: Date
public let userInfo: [String: String]

public init(
identifier: String, date: Date, userInfo: [String: String]
) {
self.identifier = identifier
self.date = date
self.userInfo = userInfo
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ enum UserInfoKey {
static let bundleID = "biscotti.bundleID"
static let joinURL = "biscotti.joinURL"
static let meetingID = "biscotti.meetingID"
static let eventStart = "biscotti.eventStart"
}

// MARK: - Kind string values stored in userInfo
Expand All @@ -46,15 +47,27 @@ enum KindValue {
/// notification in-place (UNNotificationRequest semantics).
func requestIdentifier(for kind: NotificationKind) -> String {
switch kind {
case let .meetingStarting(eventKey, _, _):
"biscotti.notif.meeting-start.\(eventKey)"
case let .meetingStarting(eventKey, _, _, _):
meetingStartRequestIdentifier(eventKey: eventKey)
case let .adHocDetected(bundleID, _):
"biscotti.notif.adhoc.\(bundleID)"
adHocRequestIdentifier(bundleID: bundleID)
case let .stopCountdown(meetingID, _):
countdownRequestIdentifier(meetingID: meetingID)
}
}

/// Standalone meeting-start ID builder for cancel methods that
/// don't receive a full `NotificationKind`.
func meetingStartRequestIdentifier(eventKey: String) -> String {
"biscotti.notif.meeting-start.\(eventKey)"
}

/// Standalone ad-hoc ID builder for cancel methods that
/// don't receive a full `NotificationKind`.
func adHocRequestIdentifier(bundleID: String) -> String {
"biscotti.notif.adhoc.\(bundleID)"
}

/// Standalone countdown ID builder for `cancelCountdown` which
/// doesn't receive a full `NotificationKind`.
func countdownRequestIdentifier(meetingID: UUID) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
/// The three notification kinds the app can present.
public enum NotificationKind: Sendable, Equatable {
/// A calendar-driven meeting is starting (or imminent).
case meetingStarting(eventKey: String, title: String, joinURL: URL?)
case meetingStarting(eventKey: String, title: String, joinURL: URL?, start: Date)

/// An ad-hoc meeting was detected in a meeting app.
case adHocDetected(bundleID: String, appName: String)
Expand Down
Loading
Loading