// // ContextOrchestrator.swift // iris // // Created by Codex. // import CoreLocation import Foundation import MediaPlayer import MusicKit import os @available(iOS 16.0, *) @MainActor final class ContextOrchestrator: NSObject, ObservableObject { @Published private(set) var authorization: CLAuthorizationStatus = .notDetermined @Published private(set) var lastLocation: CLLocation? = nil @Published private(set) var lastRecomputeAt: Date? = nil @Published private(set) var lastRecomputeReason: String? = nil @Published private(set) var lastWinner: WinnerEnvelope? = nil @Published private(set) var lastError: String? = nil @Published private(set) var lastCandidates: [Candidate] = [] @Published private(set) var lastWeatherDiagnostics: [String: String] = [:] @Published private(set) var lastPipelineElapsedMs: Int? = nil @Published private(set) var lastFetchFailed: Bool = false @Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined @Published private(set) var nowPlaying: NowPlayingSnapshot? = nil private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator") private let locationManager = CLLocationManager() private let weatherDataSource = WeatherDataSource() private let calendarDataSource = CalendarDataSource() private let poiDataSource = POIDataSource() private let ranker: HeuristicRanker private let store: FeedStore private let server: LocalServer private let ble: BlePeripheralManager private let nowPlayingMonitor = NowPlayingMonitor() private var lastRecomputeLocation: CLLocation? = nil private var lastRecomputeAccuracy: CLLocationAccuracy? = nil private var recomputeInFlight = false private var lastRecomputeAttemptAt: Date? = nil init(store: FeedStore = FeedStore(), server: LocalServer = LocalServer(), ble: BlePeripheralManager) { self.store = store self.server = server self.ble = ble self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(candidateId: id) }) super.init() locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBest locationManager.distanceFilter = kCLDistanceFilterNone locationManager.pausesLocationUpdatesAutomatically = false locationManager.allowsBackgroundLocationUpdates = true ble.onFirstSubscribe = { [weak self] in Task { @MainActor in self?.logger.info("BLE subscribed: pushing latest winner") self?.pushLatestWinnerToBle() } } ble.onControlCommand = { [weak self] command in Task { @MainActor in self?.handleBleControl(command) } } nowPlayingMonitor.onUpdate = { [weak self] update in Task { @MainActor in guard let self else { return } self.musicAuthorization = update.authorization self.nowPlaying = update.snapshot self.pushLatestWinnerToBle() } } let feed = store.getFeed() lastWinner = feed.asWinnerEnvelope() } func start() { authorization = locationManager.authorizationStatus logger.info("start auth=\(String(describing: self.authorization), privacy: .public)") server.start() nowPlayingMonitor.start() requestPermissionsIfNeeded() locationManager.startUpdatingLocation() } func stop() { locationManager.stopUpdatingLocation() nowPlayingMonitor.stop() } func recomputeNow(reason: String = "manual") { guard let location = lastLocation ?? locationManager.location else { logger.info("recomputeNow skipped: no location") return } maybeRecompute(for: location, reason: reason, force: true) } func sendFixtureFeedNow() { guard let url = Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json", subdirectory: "ProtocolFixtures") ?? Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json"), let data = try? Data(contentsOf: url) else { logger.error("fixture feed missing in bundle") return } ble.sendOpaque(data.trimmedTrailingWhitespace(), msgType: 1) logger.info("sent fixture feed bytes=\(data.count)") } private func requestPermissionsIfNeeded() { switch locationManager.authorizationStatus { case .notDetermined: logger.info("requestWhenInUseAuthorization") locationManager.requestWhenInUseAuthorization() case .authorizedWhenInUse: logger.info("requestAlwaysAuthorization") locationManager.requestAlwaysAuthorization() case .authorizedAlways: break case .restricted, .denied: lastError = "Location permission denied." @unknown default: break } } private func maybeRecompute(for location: CLLocation, reason: String, force: Bool) { let now = Date() let hardThrottleSec: TimeInterval = 60 if !force, let lastAttempt = lastRecomputeAttemptAt, now.timeIntervalSince(lastAttempt) < hardThrottleSec { logger.info("skip recompute (throttle) reason=\(reason, privacy: .public)") return } lastRecomputeAttemptAt = now if recomputeInFlight { logger.info("skip recompute (in-flight) reason=\(reason, privacy: .public)") return } recomputeInFlight = true lastRecomputeReason = reason logger.info("recompute start reason=\(reason, privacy: .public) lat=\(location.coordinate.latitude, format: .fixed(precision: 5)) lon=\(location.coordinate.longitude, format: .fixed(precision: 5)) acc=\(location.horizontalAccuracy, format: .fixed(precision: 1))") Task { await self.recomputePipeline(location: location, reason: reason) } } private func shouldTriggerRecompute(for location: CLLocation) -> (Bool, String) { if lastRecomputeAt == nil { return (true, "initial") } let now = Date() if let last = lastRecomputeAt, now.timeIntervalSince(last) > 15 * 60 { return (true, "timer_15m") } if let lastLoc = lastRecomputeLocation { let dist = location.distance(from: lastLoc) if dist > 250 { return (true, "moved_250m") } } if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 { if lastAcc - location.horizontalAccuracy > 50 { return (true, "accuracy_improved_50m") } } return (false, "no_trigger") } private func recomputePipeline(location: CLLocation, reason: String) async { defer { Task { @MainActor in self.recomputeInFlight = false } } let nowEpoch = Int(Date().timeIntervalSince1970) let userContext = UserContext(isMoving: location.speed >= 1.0, city: "London") let start = Date() async let weatherResult = withTimeoutResult(seconds: 6) { await self.weatherDataSource.candidatesWithDiagnostics(for: location, now: nowEpoch) } async let calendarResult = withTimeoutResult(seconds: 6) { await self.calendarDataSource.candidatesWithDiagnostics(now: nowEpoch) } async let poiResult = withTimeoutResult(seconds: 6) { try await self.poiDataSource.candidates(for: location, now: nowEpoch) } let wxRes = await weatherResult let calRes = await calendarResult let poiRes = await poiResult var candidates: [Candidate] = [] var fetchFailed = false var wxDiagnostics: [String: String] = [:] var weatherNowCandidate: Candidate? = nil switch wxRes { case .success(let wx): candidates.append(contentsOf: wx.candidates) wxDiagnostics = wx.diagnostics weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") }) if let wxErr = wx.weatherKitError { fetchFailed = true logger.warning("weather fetch error: \(wxErr, privacy: .public)") } case .failure(let error): fetchFailed = true logger.error("weather fetch failed: \(String(describing: error), privacy: .public)") } switch poiRes { case .success(let pois): candidates.append(contentsOf: pois) case .failure(let error): fetchFailed = true logger.error("poi fetch failed: \(String(describing: error), privacy: .public)") } switch calRes { case .success(let cal): candidates.append(contentsOf: cal.candidates) if let err = cal.error { fetchFailed = true logger.warning("calendar error: \(err, privacy: .public)") } case .failure(let error): fetchFailed = true logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)") } let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) lastPipelineElapsedMs = elapsedMs lastFetchFailed = fetchFailed lastCandidates = candidates lastWeatherDiagnostics = wxDiagnostics logger.info("pipeline candidates total=\(candidates.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") if fetchFailed, candidates.isEmpty { let fallbackFeed = store.getFeed(now: nowEpoch) let fallbackWinner = fallbackFeed.asWinnerEnvelope() lastWinner = fallbackWinner lastError = "Fetch failed; using previous winner." server.broadcastWinner(fallbackWinner) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1) return } let unsuppressed = candidates .filter { $0.type != .currentWeather } .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } let winner = ranker.pickWinner(from: unsuppressed, now: nowEpoch, context: userContext) let envelope = WinnerEnvelope(schema: 1, generatedAt: nowEpoch, winner: winner, debug: nil) let validated = (try? validateEnvelope(envelope)) ?? envelope let feedEnvelope = FeedEnvelope.fromWinnerAndWeather(now: nowEpoch, winner: validated, weather: weatherNowCandidate) store.setFeed(feedEnvelope, now: nowEpoch) lastWinner = validated lastRecomputeAt = Date() lastRecomputeLocation = location lastRecomputeAccuracy = location.horizontalAccuracy lastError = fetchFailed ? "Partial fetch failure." : nil logger.info("winner id=\(validated.winner.id, privacy: .public) type=\(validated.winner.type.rawValue, privacy: .public) prio=\(validated.winner.priority, format: .fixed(precision: 2)) ttl=\(validated.winner.ttlSec)") server.broadcastWinner(validated) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1) } private func pushLatestWinnerToBle() { let nowEpoch = Int(Date().timeIntervalSince1970) let feed = store.getFeed(now: nowEpoch) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1) } private func handleBleControl(_ command: String) { guard !command.isEmpty else { return } if command == "REQ_FULL" { logger.info("BLE control REQ_FULL") pushLatestWinnerToBle() return } if command.hasPrefix("ACK:") { logger.info("BLE control \(command, privacy: .public)") return } logger.info("BLE control unknown=\(command, privacy: .public)") } private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope { guard let nowPlayingCard = nowPlaying?.asFeedCard(baseGeneratedAt: base.generatedAt, now: now) else { return base } var cards = base.feed.filter { $0.type != .nowPlaying } // Append after existing FYI cards (e.g. weather). cards.append(nowPlayingCard) return FeedEnvelope( schema: base.schema, generatedAt: base.generatedAt, feed: cards, meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count) ) } } extension ContextOrchestrator: CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorization = manager.authorizationStatus logger.info("auth changed=\(String(describing: self.authorization), privacy: .public)") requestPermissionsIfNeeded() } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { logger.error("location error: \(String(describing: error), privacy: .public)") lastError = "Location error: \(error)" } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let best = locations.sorted(by: { $0.horizontalAccuracy < $1.horizontalAccuracy }).first else { return } lastLocation = best let (should, reason) = shouldTriggerRecompute(for: best) if should { maybeRecompute(for: best, reason: reason, force: false) } } } enum TimeoutError: Error { case timedOut } func withTimeoutResult(seconds: Double, operation: @escaping () async throws -> T) async -> Result { do { let value = try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw TimeoutError.timedOut } let result = try await group.next()! group.cancelAll() return result } return .success(value) } catch { return .failure(error) } } @available(iOS 16.0, *) struct NowPlayingSnapshot: Equatable, Sendable { let itemId: String let title: String let artist: String? let album: String? let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus func asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard { let desiredLifetimeSec = 30 let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec) let subtitleParts = [artist, album] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } let subtitle = subtitleParts.isEmpty ? "Apple Music" : subtitleParts.joined(separator: " • ") return FeedCard( id: "music:now:\(itemId)", type: .nowPlaying, title: title.truncated(maxLength: TextConstraints.titleMax), subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: playbackStatus == .playing ? 0.35 : 0.2, ttlSec: ttl, condition: nil, bucket: .fyi, actions: ["DISMISS"] ) } } @available(iOS 16.0, *) @MainActor final class NowPlayingMonitor { struct Update: Sendable { let authorization: MusicAuthorization.Status let snapshot: NowPlayingSnapshot? } var onUpdate: ((Update) -> Void)? = nil private let player = SystemMusicPlayer.shared private let mpController = MPMusicPlayerController.systemMusicPlayer private var observers: [NSObjectProtocol] = [] private var pollTimer: DispatchSourceTimer? private var isRunning = false private var authorization: MusicAuthorization.Status = .notDetermined private var lastSnapshot: NowPlayingSnapshot? = nil func start() { guard !isRunning else { return } isRunning = true mpController.beginGeneratingPlaybackNotifications() observers.append( NotificationCenter.default.addObserver( forName: .MPMusicPlayerControllerNowPlayingItemDidChange, object: mpController, queue: .main ) { [weak self] _ in Task { @MainActor in self?.refresh(reason: "mp_now_playing_changed") } } ) observers.append( NotificationCenter.default.addObserver( forName: .MPMusicPlayerControllerPlaybackStateDidChange, object: mpController, queue: .main ) { [weak self] _ in Task { @MainActor in self?.refresh(reason: "mp_playback_state_changed") } } ) startPolling() Task { @MainActor in await ensureAuthorization() refresh(reason: "start") } } func stop() { guard isRunning else { return } isRunning = false pollTimer?.cancel() pollTimer = nil for token in observers { NotificationCenter.default.removeObserver(token) } observers.removeAll() mpController.endGeneratingPlaybackNotifications() } private func startPolling() { guard pollTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + 2, repeating: 2) timer.setEventHandler { [weak self] in guard let self else { return } self.refresh(reason: "poll") } timer.resume() pollTimer = timer } private func ensureAuthorization() async { if authorization == .notDetermined { authorization = await MusicAuthorization.request() onUpdate?(Update(authorization: authorization, snapshot: lastSnapshot)) } } private func refresh(reason: String) { guard authorization == .authorized else { if lastSnapshot != nil { lastSnapshot = nil onUpdate?(Update(authorization: authorization, snapshot: nil)) } return } let playback = player.state.playbackStatus guard playback != .stopped else { if lastSnapshot != nil { lastSnapshot = nil onUpdate?(Update(authorization: authorization, snapshot: nil)) } return } let mpItem = mpController.nowPlayingItem let musicKitItem = player.queue.currentEntry?.item let itemId = sanitizeId( musicKitItem.map { String(describing: $0.id) } ?? mpItem.map { "mp:\($0.persistentID)" } ?? UUID().uuidString ) let musicKitTitle = musicKitItem.map(nowPlayingTitle(from:)) let title = normalizeTitle(musicKitTitle) ?? normalizeTitle(mpItem?.title) ?? "Now Playing" let artist = normalizePart(musicKitItem.flatMap(nowPlayingArtist(from:))) ?? normalizePart(mpItem?.artist) let album = normalizePart(musicKitItem.flatMap(nowPlayingAlbum(from:))) ?? normalizePart(mpItem?.albumTitle) let snapshot = NowPlayingSnapshot(itemId: itemId, title: title, artist: artist, album: album, playbackStatus: playback) guard snapshot != lastSnapshot else { return } lastSnapshot = snapshot onUpdate?(Update(authorization: authorization, snapshot: snapshot)) } private func nowPlayingTitle(from item: MusicItem) -> String { if let song = item as? Song { return song.title } if let album = item as? Album { return album.title } if let playlist = item as? Playlist { return playlist.name } return "Now Playing" } private func nowPlayingArtist(from item: MusicItem) -> String? { if let song = item as? Song { return song.artistName } if let album = item as? Album { return album.artistName } return nil } private func nowPlayingAlbum(from item: MusicItem) -> String? { if let song = item as? Song { return song.albumTitle } return nil } private func sanitizeId(_ raw: String) -> String { raw .replacingOccurrences(of: " ", with: "_") .replacingOccurrences(of: "\n", with: "_") .replacingOccurrences(of: "\t", with: "_") } private func normalizePart(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private func normalizeTitle(_ raw: String?) -> String? { guard let value = normalizePart(raw) else { return nil } if value == "Now Playing" { return nil } return value } }