/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import Foundation /// Bridges extension payload updates between Atoll processes (main app, helpers, XPC services). /// Stores the latest snapshot on disk and delivers change notifications through DistributedNotificationCenter. final class ExtensionEventBridge { static let shared = ExtensionEventBridge() private let notificationCenter = DistributedNotificationCenter.default() private let encoder = JSONEncoder() private let decoder = JSONDecoder() private let processIdentifier = ProcessInfo.processInfo.processIdentifier private let ioQueue = DispatchQueue(label: "com.ebullioscopic.Atoll.extensions.bridge", qos: .utility) private init() {} // MARK: - Public API func broadcastLiveActivitySnapshot(_ payloads: [ExtensionLiveActivityPayload]) { broadcast(payloads, channel: .liveActivities) } func broadcastLockScreenWidgetSnapshot(_ payloads: [ExtensionLockScreenWidgetPayload]) { broadcast(payloads, channel: .lockScreenWidgets) } func broadcastNotchExperienceSnapshot(_ payloads: [ExtensionNotchExperiencePayload]) { broadcast(payloads, channel: .notchExperiences) } func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] { loadSnapshot(channel: .liveActivities) } func loadPersistedLockScreenWidgets() -> [ExtensionLockScreenWidgetPayload] { loadSnapshot(channel: .lockScreenWidgets) } func loadPersistedNotchExperiences() -> [ExtensionNotchExperiencePayload] { loadSnapshot(channel: .notchExperiences) } func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol { observe(channel: .liveActivities, handler: handler) } func observeLockScreenWidgetSnapshots(_ handler: @escaping ([ExtensionLockScreenWidgetPayload], Int32) -> Void) -> NSObjectProtocol { observe(channel: .lockScreenWidgets, handler: handler) } func observeNotchExperienceSnapshots(_ handler: @escaping ([ExtensionNotchExperiencePayload], Int32) -> Void) -> NSObjectProtocol { observe(channel: .notchExperiences, handler: handler) } func removeObserver(_ observer: NSObjectProtocol) { notificationCenter.removeObserver(observer) } // MARK: - Snapshot Storage private func broadcast(_ payloads: [T], channel: Channel) { ioQueue.async { [weak self] in guard let self else { return } guard let data = try? self.encoder.encode(payloads) else { return } self.persistSnapshot(data, channel: channel) let userInfo: [String: Any] = [ UserInfoKey.payloads: data, UserInfoKey.sourcePID: NSNumber(value: self.processIdentifier) ] self.notificationCenter.postNotificationName( channel.notificationName, object: nil, userInfo: userInfo, deliverImmediately: true ) } } private func loadSnapshot(channel: Channel) -> [T] { let url = snapshotURL(for: channel) guard FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url) else { return [] } return (try? decoder.decode([T].self, from: data)) ?? [] } private func persistSnapshot(_ data: Data, channel: Channel) { let url = snapshotURL(for: channel) do { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: .atomic) } catch { Logger.log("Failed to persist extension snapshot for \(channel): \(error.localizedDescription)", category: .extensions) } } private func snapshotURL(for channel: Channel) -> URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory()) let directory = appSupport.appendingPathComponent("AtollExtensions", isDirectory: true) switch channel { case .liveActivities: return directory.appendingPathComponent("live_activities.json", isDirectory: false) case .lockScreenWidgets: return directory.appendingPathComponent("lock_screen_widgets.json", isDirectory: false) case .notchExperiences: return directory.appendingPathComponent("notch_experiences.json", isDirectory: false) } } // MARK: - Observation private func observe(channel: Channel, handler: @escaping ([T], Int32) -> Void) -> NSObjectProtocol { notificationCenter.addObserver(forName: channel.notificationName, object: nil, queue: .main) { [weak self] notification in guard let self else { return } let sourcePID = (notification.userInfo?[UserInfoKey.sourcePID] as? NSNumber)?.int32Value ?? 0 guard sourcePID != self.processIdentifier else { return } if let data = notification.userInfo?[UserInfoKey.payloads] as? Data, let payloads = try? self.decoder.decode([T].self, from: data) { handler(payloads, sourcePID) return } let snapshot: [T] = self.loadSnapshot(channel: channel) handler(snapshot, sourcePID) } } // MARK: - Helpers private enum Channel: CustomStringConvertible { case liveActivities case lockScreenWidgets case notchExperiences var notificationName: Notification.Name { switch self { case .liveActivities: return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot") case .lockScreenWidgets: return Notification.Name("com.ebullioscopic.Atoll.extensions.lockScreenWidgetSnapshot") case .notchExperiences: return Notification.Name("com.ebullioscopic.Atoll.extensions.notchExperienceSnapshot") } } var description: String { switch self { case .liveActivities: return "live-activities" case .lockScreenWidgets: return "lock-screen-widgets" case .notchExperiences: return "notch-experiences" } } } private enum UserInfoKey { static let payloads = "payloads" static let sourcePID = "sourcePID" } }