Working stuff

This commit is contained in:
Hariharan Mudaliar 2026-01-14 15:11:57 +05:30
commit 94a612f327
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
13 changed files with 913 additions and 150 deletions

View file

@ -3,7 +3,7 @@ import Defaults
import AtollExtensionKit
import SwiftUI
struct ExtensionLiveActivityPayload: Identifiable, Hashable {
struct ExtensionLiveActivityPayload: Identifiable, Hashable, Codable {
let bundleIdentifier: String
let descriptor: AtollLiveActivityDescriptor
let receivedAt: Date
@ -13,7 +13,7 @@ struct ExtensionLiveActivityPayload: Identifiable, Hashable {
var priority: AtollLiveActivityPriority { descriptor.priority }
}
struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable {
struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable {
let bundleIdentifier: String
let descriptor: AtollLockScreenWidgetDescriptor
let receivedAt: Date

View file

@ -0,0 +1,147 @@
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 loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] {
loadSnapshot(channel: .liveActivities)
}
func loadPersistedLockScreenWidgets() -> [ExtensionLockScreenWidgetPayload] {
loadSnapshot(channel: .lockScreenWidgets)
}
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 removeObserver(_ observer: NSObjectProtocol) {
notificationCenter.removeObserver(observer)
}
// MARK: - Snapshot Storage
private func broadcast<T: Codable>(_ 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<T: Codable>(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)
}
}
// MARK: - Observation
private func observe<T: Codable>(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
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")
}
}
var description: String {
switch self {
case .liveActivities:
return "live-activities"
case .lockScreenWidgets:
return "lock-screen-widgets"
}
}
}
private enum UserInfoKey {
static let payloads = "payloads"
static let sourcePID = "sourcePID"
}
}

View file

@ -10,21 +10,52 @@ final class ExtensionLiveActivityManager: ObservableObject {
private let authorizationManager = ExtensionAuthorizationManager.shared
private let maxCapacityKey = Defaults.Keys.extensionLiveActivityCapacity
private let eventBridge = ExtensionEventBridge.shared
private var liveActivityObserver: NSObjectProtocol?
private var suppressBroadcast = false
private let currentProcessID = ProcessInfo.processInfo.processIdentifier
private init() {}
private init() {
activeActivities = eventBridge.loadPersistedLiveActivities()
sortActivities()
liveActivityObserver = eventBridge.observeLiveActivitySnapshots { [weak self] payloads, sourcePID in
self?.applySnapshot(payloads, sourcePID: sourcePID)
}
}
deinit {
if let token = liveActivityObserver {
eventBridge.removeObserver(token)
}
}
func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized")
throw ExtensionValidationError.unauthorized
}
guard descriptor.isValid else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
guard activeActivities.count < Defaults[maxCapacityKey] else {
throw ExtensionValidationError.exceedsCapacity
if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) {
let payload = ExtensionLiveActivityPayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: activeActivities[index].receivedAt
)
activeActivities[index] = payload
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions)
broadcastSnapshot()
return
}
guard activeActivities.contains(where: { $0.descriptor.id == descriptor.id }) == false else {
throw ExtensionValidationError.duplicateIdentifier
guard activeActivities.count < Defaults[maxCapacityKey] else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached")
throw ExtensionValidationError.exceedsCapacity
}
let payload = ExtensionLiveActivityPayload(
@ -35,10 +66,13 @@ final class ExtensionLiveActivityManager: ObservableObject {
activeActivities.append(payload)
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)")
broadcastSnapshot()
}
func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
guard descriptor.isValid else {
logDiagnostics("Rejected live activity update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
@ -52,6 +86,8 @@ final class ExtensionLiveActivityManager: ObservableObject {
activeActivities[index] = payload
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
logDiagnostics("Updated live activity \(descriptor.id) for \(bundleIdentifier)")
broadcastSnapshot()
}
func dismiss(activityID: String, bundleIdentifier: String) {
@ -60,6 +96,8 @@ final class ExtensionLiveActivityManager: ObservableObject {
if previousCount != activeActivities.count {
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
logDiagnostics("Removed live activity \(activityID) for \(bundleIdentifier); remaining: \(activeActivities.count)")
broadcastSnapshot()
}
}
@ -69,6 +107,10 @@ final class ExtensionLiveActivityManager: ObservableObject {
.map { $0.descriptor.id }
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
if !ids.isEmpty {
logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()
}
}
func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] {
@ -87,4 +129,23 @@ final class ExtensionLiveActivityManager: ObservableObject {
private func sortActivities() {
activeActivities.sort(by: descriptorComparator)
}
private func broadcastSnapshot() {
guard !suppressBroadcast else { return }
eventBridge.broadcastLiveActivitySnapshot(activeActivities)
logDiagnostics("Broadcasted live activity snapshot (count: \(activeActivities.count))")
}
private func applySnapshot(_ payloads: [ExtensionLiveActivityPayload], sourcePID: Int32) {
guard sourcePID != currentProcessID else { return }
suppressBroadcast = true
activeActivities = payloads.sorted(by: descriptorComparator)
suppressBroadcast = false
logDiagnostics("Applied external live activity snapshot from PID \(sourcePID) (count: \(payloads.count))")
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
}

View file

@ -11,24 +11,54 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
private let authorizationManager = ExtensionAuthorizationManager.shared
private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity
private var presentationController: ExtensionLockScreenWidgetPresentationController!
private let eventBridge = ExtensionEventBridge.shared
private var widgetObserver: NSObjectProtocol?
private var suppressBroadcast = false
private let currentProcessID = ProcessInfo.processInfo.processIdentifier
private init() {
activeWidgets = eventBridge.loadPersistedLockScreenWidgets()
sortWidgets()
presentationController = ExtensionLockScreenWidgetPresentationController(manager: self)
presentationController.activate()
widgetObserver = eventBridge.observeLockScreenWidgetSnapshots { [weak self] payloads, sourcePID in
self?.applySnapshot(payloads, sourcePID: sourcePID)
}
}
deinit {
if let token = widgetObserver {
eventBridge.removeObserver(token)
}
}
func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else {
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized")
throw ExtensionValidationError.unauthorized
}
guard descriptor.isValid else {
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
guard activeWidgets.count < Defaults[maxCapacityKey] else {
throw ExtensionValidationError.exceedsCapacity
if let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) {
let payload = ExtensionLockScreenWidgetPayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: activeWidgets[index].receivedAt
)
activeWidgets[index] = payload
sortWidgets()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
Logger.log("Replaced extension widget \(descriptor.id) for \(bundleIdentifier)", category: .extensions)
broadcastSnapshot()
return
}
guard activeWidgets.contains(where: { $0.descriptor.id == descriptor.id }) == false else {
throw ExtensionValidationError.duplicateIdentifier
guard activeWidgets.count < Defaults[maxCapacityKey] else {
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached")
throw ExtensionValidationError.exceedsCapacity
}
let payload = ExtensionLockScreenWidgetPayload(
@ -39,10 +69,13 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
activeWidgets.append(payload)
sortWidgets()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
logDiagnostics("Queued lock screen widget \(descriptor.id) for \(bundleIdentifier); total widgets: \(activeWidgets.count)")
broadcastSnapshot()
}
func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
guard descriptor.isValid else {
logDiagnostics("Rejected lock screen widget update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
@ -57,6 +90,8 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
activeWidgets[index] = payload
sortWidgets()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
logDiagnostics("Updated lock screen widget \(descriptor.id) for \(bundleIdentifier)")
broadcastSnapshot()
}
func dismiss(widgetID: String, bundleIdentifier: String) {
@ -65,6 +100,8 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
if previousCount != activeWidgets.count {
Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID)
logDiagnostics("Removed lock screen widget \(widgetID) for \(bundleIdentifier); remaining: \(activeWidgets.count)")
broadcastSnapshot()
}
}
@ -74,6 +111,10 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
.map { $0.descriptor.id }
activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier }
ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) }
if !ids.isEmpty {
logDiagnostics("Removed all lock screen widgets for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()
}
}
private func sortWidgets() {
@ -84,4 +125,28 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
return lhs.priority > rhs.priority
}
}
private func broadcastSnapshot() {
guard !suppressBroadcast else { return }
eventBridge.broadcastLockScreenWidgetSnapshot(activeWidgets)
logDiagnostics("Broadcasted lock screen widget snapshot (count: \(activeWidgets.count))")
}
private func applySnapshot(_ payloads: [ExtensionLockScreenWidgetPayload], sourcePID: Int32) {
guard sourcePID != currentProcessID else { return }
suppressBroadcast = true
activeWidgets = payloads.sorted { lhs, rhs in
if lhs.priority == rhs.priority {
return lhs.receivedAt < rhs.receivedAt
}
return lhs.priority > rhs.priority
}
suppressBroadcast = false
logDiagnostics("Applied external lock screen widget snapshot from PID \(sourcePID) (count: \(payloads.count))")
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
}

View file

@ -12,6 +12,7 @@ final class ExtensionLockScreenWidgetPresentationController {
private let windowPool = ExtensionLockScreenWidgetWindowPool()
private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = []
private var isLocked: Bool = false
private var lastVisibilityState: VisibilityState?
init(manager: ExtensionLockScreenWidgetManager) {
self.manager = manager
@ -55,6 +56,7 @@ final class ExtensionLockScreenWidgetPresentationController {
self.refreshPresentation()
} else {
self.windowPool.hideAll()
self.updateVisibilityLog(.hidden(reason: "feature-disabled"))
}
}
.store(in: &cancellables)
@ -63,13 +65,37 @@ final class ExtensionLockScreenWidgetPresentationController {
private func refreshPresentation() {
guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else {
windowPool.hideAll()
updateVisibilityLog(.hidden(reason: "lock-state"))
return
}
guard cachedPayloads.isEmpty == false else {
windowPool.hideAll()
updateVisibilityLog(.hidden(reason: "no-payloads"))
return
}
windowPool.sync(with: cachedPayloads)
updateVisibilityLog(.showing(count: cachedPayloads.count))
}
private func updateVisibilityLog(_ state: VisibilityState) {
guard state != lastVisibilityState else { return }
lastVisibilityState = state
switch state {
case let .hidden(reason):
logDiagnostics("Lock screen widgets hidden (reason: \(reason))")
case let .showing(count):
logDiagnostics("Presenting \(count) extension lock screen widget(s)")
}
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
private enum VisibilityState: Equatable {
case hidden(reason: String)
case showing(count: Int)
}
}
@ -111,11 +137,13 @@ private final class ExtensionLockScreenWidgetWindowPool {
record.window.contentView = nil
}
windows.removeAll()
logDiagnostics("Cleared all lock screen widget windows")
}
private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) {
guard let screen = currentScreen() else { return }
let descriptor = payload.descriptor
let isNewWindow = windows[payload.id] == nil
let window = ensureWindow(for: payload.id)
let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload))
hosting.frame = NSRect(origin: .zero, size: descriptor.size)
@ -126,6 +154,11 @@ private final class ExtensionLockScreenWidgetWindowPool {
window.setFrame(frame(for: descriptor, on: screen), display: true)
window.alphaValue = 1
window.orderFrontRegardless()
if isNewWindow {
logDiagnostics("Created lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id) style=\(descriptor.layoutStyle)")
} else {
logDiagnostics("Updated lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id)")
}
}
private func ensureWindow(for id: String) -> NSWindow {
@ -162,6 +195,7 @@ private final class ExtensionLockScreenWidgetWindowPool {
record.window.orderOut(nil)
record.window.contentView = nil
windows.removeValue(forKey: id)
logDiagnostics("Removed lock screen widget window id=\(id)")
}
private func registerScreenChangeObservers() {
@ -188,7 +222,7 @@ private final class ExtensionLockScreenWidgetWindowPool {
private func handleScreenGeometryChange(reason: String) {
guard windows.isEmpty == false else { return }
repositionAll(animated: false)
print("ExtensionLockScreenWidgetWindowPool: realigned windows due to \(reason)")
logDiagnostics("Realigned lock screen widget windows due to \(reason)")
}
private func repositionAll(animated: Bool) {
@ -246,3 +280,8 @@ private final class ExtensionLockScreenWidgetWindowPool {
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
}
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}