From 94a612f3276784174b8e506805eecb3a836e1ef2 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 14 Jan 2026 15:11:57 +0530 Subject: [PATCH] Working stuff --- DynamicIsland.xcodeproj/project.pbxproj | 36 +-- .../xcshareddata/swiftpm/Package.resolved | 11 +- DynamicIsland/ContentView.swift | 249 +++++++++++++++--- .../ExtensionLiveActivityViews.swift | 188 ++++++++++--- .../ExtensionLockScreenWidgetView.swift | 12 + .../ExtensionRenderingHelpers.swift | 65 ++++- .../Extensions/ExtensionActivityModels.swift | 4 +- .../Extensions/ExtensionEventBridge.swift | 147 +++++++++++ .../ExtensionLiveActivityManager.swift | 71 ++++- .../ExtensionLockScreenWidgetManager.swift | 73 ++++- ...ckScreenWidgetPresentationController.swift | 41 ++- .../Extensions/ExtensionXPCService.swift | 131 +++++---- DynamicIsland/utils/Logger.swift | 43 ++- 13 files changed, 917 insertions(+), 154 deletions(-) create mode 100644 DynamicIsland/managers/Extensions/ExtensionEventBridge.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 2feae3d..a09045d 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7E61181E2E24071E0050006C /* SkyLightWindow */; }; + 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7E288D112F1659EF0020C117 /* AtollExtensionKit */; }; 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; }; 7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; }; 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; }; @@ -26,7 +27,7 @@ 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; - B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = B2C8B0022F11AABB00887766 /* AtollExtensionKit */; }; + 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -42,6 +43,7 @@ 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = ""; }; 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtensionEventBridge.swift; path = DynamicIsland/managers/Extensions/ExtensionEventBridge.swift; sourceTree = SOURCE_ROOT; }; F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -59,7 +61,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */, 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -70,6 +71,7 @@ 7E6118172E24071E0050006C /* Collections in Frameworks */, 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */, 7E6118152E24071E0050006C /* Sparkle in Frameworks */, + 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */, 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -150,7 +152,6 @@ ); name = DynamicIsland; packageProductDependencies = ( - B2C8B0022F11AABB00887766 /* AtollExtensionKit */, 14D032192C68F32E0096E6A1 /* LaunchAtLogin */, 14D0321C2C68F3350096E6A1 /* Sparkle */, B18654382C6F4990000B926A /* KeyboardShortcuts */, @@ -160,6 +161,7 @@ B194240A2CD10017003E5DC2 /* LottieUI */, 146BC4362D366E9B0047C66A /* MacroVisionKit */, 7E61181E2E24071E0050006C /* SkyLightWindow */, + 7E288D112F1659EF0020C117 /* AtollExtensionKit */, ); productName = DynamicIsland; productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */; @@ -193,7 +195,6 @@ mainGroup = F54406DE2CD4987A00C7AFD9; minimizedProjectReferenceProxies = 1; packageReferences = ( - B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */, F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */, 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */, 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */, @@ -205,6 +206,7 @@ 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */, 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, + 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -239,6 +241,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -528,13 +531,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../AtollExtensionKit; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = { isa = XCRemoteSwiftPackageReference; @@ -568,6 +564,14 @@ minimumVersion = 1.4.16; }; }; + 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Ebullioscopic/AtollExtensionKit"; + requirement = { + branch = main; + kind = branch; + }; + }; 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Lakr233/SkyLightWindow"; @@ -642,6 +646,11 @@ package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 7E288D112F1659EF0020C117 /* AtollExtensionKit */ = { + isa = XCSwiftPackageProductDependency; + package = 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */; + productName = AtollExtensionKit; + }; 7E61181E2E24071E0050006C /* SkyLightWindow */ = { isa = XCSwiftPackageProductDependency; package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */; @@ -672,11 +681,6 @@ package = B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */; productName = LottieUI; }; - B2C8B0022F11AABB00887766 /* AtollExtensionKit */ = { - isa = XCSwiftPackageProductDependency; - package = B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */; - productName = AtollExtensionKit; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F54406DF2CD4987A00C7AFD9 /* Project object */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13ffff6..2cf39bc 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "3b2c9100f16643012ece44ae6bfed9555c2d858d8ac2f98547c70e4c1738f0f6", + "originHash" : "6fbfa8cda0c383746edd66361426fdc2bd266fc082867294374a1818f02798f3", "pins" : [ + { + "identity" : "atollextensionkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", + "state" : { + "branch" : "main", + "revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f" + } + }, { "identity" : "defaults", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1a5a376..f320769 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -9,6 +9,7 @@ import AVFoundation import Combine import Defaults +import Foundation import KeyboardShortcuts import SwiftUI import SwiftUIIntrospect @@ -480,12 +481,18 @@ struct ContentView: View { .padding(.top, 40) Spacer() } else { + let hasMusicMetadata = !musicManager.songTitle.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty + || !musicManager.artistName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty + let hasActiveMusicSnapshot: Bool = { + if musicManager.isPlaying { return true } + return !musicManager.isPlayerIdle && hasMusicMetadata + }() let musicPairingEligible = vm.notchState == .closed - && (musicManager.isPlaying || !musicManager.isPlayerIdle) + && hasActiveMusicSnapshot && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked - let musicSecondary = resolveMusicSecondaryLiveActivity() + let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) let expansionMatchesSecondary: Bool = { @@ -561,10 +568,14 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { PrivacyLiveActivity() } else if let extensionPayload = extensionStandalonePayload { + let layout = extensionStandaloneLayout( + for: extensionPayload, + notchHeight: vm.effectiveClosedNotchHeight, + isHovering: isHovering + ) ExtensionLiveActivityStandaloneView( payload: extensionPayload, - notchWidth: extensionStandaloneNotchWidth(for: vm.effectiveClosedNotchHeight, isHovering: isHovering), - notchHeight: vm.effectiveClosedNotchHeight, + layout: layout, isHovering: isHovering ) .transition(.opacity.combined(with: .scale)) @@ -805,7 +816,7 @@ struct ContentView: View { } ) - musicRightWing(for: secondary, notchHeight: notchContentHeight) + musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth) .frame(width: rightWingWidth, height: notchContentHeight, alignment: .center) .id(secondary?.id ?? "music-spectrum") .contentTransition(.symbolEffect(.replace)) @@ -815,7 +826,7 @@ struct ContentView: View { .animation(.smooth(duration: 0.25), value: secondary?.id) } - private func resolveMusicSecondaryLiveActivity() -> MusicSecondaryLiveActivity? { + private func resolveMusicSecondaryLiveActivity(isMusicPairingEligible: Bool = true) -> MusicSecondaryLiveActivity? { if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive { return .timer } @@ -837,7 +848,7 @@ struct ContentView: View { return .capsLock(showLabel: showCapsLockLabel) } - if let extensionPayload = resolvedExtensionMusicPayload() { + if isMusicPairingEligible, let extensionPayload = resolvedExtensionMusicPayload() { return .extensionPayload(extensionPayload) } @@ -979,7 +990,7 @@ struct ContentView: View { } @ViewBuilder - private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat) -> some View { + private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat, trailingWidth: CGFloat) -> some View { switch secondary { case .timer: MusicTimerSupplementView( @@ -1004,12 +1015,12 @@ struct ContentView: View { } else { spectrumView(forceSpectrum: true) } - case .focus(let mode): + case .focus: spectrumView(forceSpectrum: true) case .recording: spectrumView(forceSpectrum: true, trailingInset: 6) case .extensionPayload(let payload): - ExtensionMusicWingView(payload: payload, notchHeight: notchHeight) + ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth) case .none: spectrumView(forceSpectrum: false) } @@ -1064,36 +1075,214 @@ struct ContentView: View { } private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? { - guard enableExtensionLiveActivities, - vm.notchState == .closed, - !vm.hideOnClosed, - !lockScreenManager.isLocked, - coordinator.musicLiveActivityEnabled else { + let candidates = extensionLiveActivityManager.sortedActivities(for: true) + guard let payload = candidates.first else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "no eligible coexistence payloads", + pendingCount: candidates.count + ) + ExtensionRoutingDiagnostics.shared.reset(.music) return nil } - return extensionLiveActivityManager.sortedActivities(for: true).first + + guard enableExtensionLiveActivities else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "feature toggle disabled", + pendingCount: candidates.count + ) + return nil + } + + guard vm.notchState == .closed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "notch is \(vm.notchState)", + pendingCount: candidates.count + ) + return nil + } + + guard !vm.hideOnClosed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "hideOnClosed engaged (fullscreen)", + pendingCount: candidates.count + ) + return nil + } + + guard !lockScreenManager.isLocked else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "lock screen currently active", + pendingCount: candidates.count + ) + return nil + } + + guard coordinator.musicLiveActivityEnabled else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "music live activity disabled in settings", + pendingCount: candidates.count + ) + return nil + } + + ExtensionRoutingDiagnostics.shared.logDisplay(.music, payload: payload) + return payload } private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? { - guard enableExtensionLiveActivities, - vm.notchState == .closed, - !vm.hideOnClosed, - !lockScreenManager.isLocked, - vm.effectiveClosedNotchHeight > 0, - !coordinator.expandingView.show else { + let baseCandidates = extensionLiveActivityManager.sortedActivities() + guard !baseCandidates.isEmpty else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "no active extension payloads", + pendingCount: 0 + ) + ExtensionRoutingDiagnostics.shared.reset(.standalone) return nil } - return extensionLiveActivityManager - .sortedActivities() - .first(where: { $0.id != musicPayloadID }) + let candidates = baseCandidates.filter { $0.id != musicPayloadID } + guard let payload = candidates.first else { + if let musicPayloadID { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "all pending payloads are paired with music (\(musicPayloadID))", + pendingCount: baseCandidates.count + ) + } else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "no standalone payloads after filtering", + pendingCount: baseCandidates.count + ) + ExtensionRoutingDiagnostics.shared.reset(.standalone) + } + return nil + } + + guard enableExtensionLiveActivities else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "feature toggle disabled", + pendingCount: candidates.count + ) + return nil + } + + guard vm.notchState == .closed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "notch is \(vm.notchState)", + pendingCount: candidates.count + ) + return nil + } + + guard !vm.hideOnClosed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "hideOnClosed engaged (fullscreen)", + pendingCount: candidates.count + ) + return nil + } + + guard !lockScreenManager.isLocked else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "lock screen currently active", + pendingCount: candidates.count + ) + return nil + } + + guard vm.effectiveClosedNotchHeight > 0 else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "effective notch height is \(vm.effectiveClosedNotchHeight)", + pendingCount: candidates.count + ) + return nil + } + + guard !coordinator.expandingView.show else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "expanding view \(coordinator.expandingView.type) visible", + pendingCount: candidates.count + ) + return nil + } + + ExtensionRoutingDiagnostics.shared.logDisplay(.standalone, payload: payload) + return payload } - private func extensionStandaloneNotchWidth(for height: CGFloat, isHovering: Bool) -> CGFloat { - let effectiveCenter = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) - let wing = max(height - (isHovering ? 0 : 12), 0) - let total = effectiveCenter + (wing * 2) - return max(total, effectiveCenter) + private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout { + let outerHeight = notchHeight + let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12)) + let leadingWidth = max(contentHeight, 44) + let centerWidth = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) + let trailingWidth = ExtensionLayoutMetrics.trailingWidth( + for: payload, + baseWidth: leadingWidth, + maxWidth: leadingWidth + centerWidth * 0.6 + ) + let totalWidth = leadingWidth + centerWidth + trailingWidth + return ExtensionStandaloneLayout( + totalWidth: totalWidth, + outerHeight: outerHeight, + contentHeight: contentHeight, + leadingWidth: leadingWidth, + centerWidth: centerWidth, + trailingWidth: trailingWidth + ) + } + + @MainActor + private final class ExtensionRoutingDiagnostics { + static let shared = ExtensionRoutingDiagnostics() + + enum Channel: Hashable { + case music + case standalone + + var label: String { + switch self { + case .music: + return "music pairing" + case .standalone: + return "standalone notch" + } + } + } + + private var lastMessages: [Channel: String] = [:] + + func logSuppression(_ channel: Channel, reason: String, pendingCount: Int) { + log("Extension \(channel.label) suppressed: \(reason) (pending: \(pendingCount))", channel: channel) + } + + func logDisplay(_ channel: Channel, payload: ExtensionLiveActivityPayload) { + log("Extension \(channel.label) showing \(payload.descriptor.id) from \(payload.bundleIdentifier)", channel: channel) + } + + func reset(_ channel: Channel) { + lastMessages.removeValue(forKey: channel) + } + + private func log(_ message: String, channel: Channel) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + guard lastMessages[channel] != message else { return } + lastMessages[channel] = message + Logger.log(message, category: .extensions) + } } @ViewBuilder diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index d414e0a..641e11f 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -1,69 +1,84 @@ import SwiftUI +import Defaults import AtollExtensionKit +struct ExtensionStandaloneLayout { + let totalWidth: CGFloat + let outerHeight: CGFloat + let contentHeight: CGFloat + let leadingWidth: CGFloat + let centerWidth: CGFloat + let trailingWidth: CGFloat +} + struct ExtensionLiveActivityStandaloneView: View { let payload: ExtensionLiveActivityPayload - let notchWidth: CGFloat - let notchHeight: CGFloat + let layout: ExtensionStandaloneLayout let isHovering: Bool private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } - private var contentHeight: CGFloat { - max(0, notchHeight - (isHovering ? 0 : 12)) - } + private var contentHeight: CGFloat { layout.contentHeight } private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var resolvedLeadingContent: AtollTrailingContent { + descriptor.leadingContent ?? .icon(descriptor.leadingIcon) + } + private var resolvedCenterStyle: ExtensionCenterContentView.Style { + switch descriptor.centerTextStyle { + case .inline: + return .inline + case .standard: + return .stacked + case .inheritUser: + return Defaults[.sneakPeekStyles] == .inline ? .inline : .stacked + } + } var body: some View { - let baseWingWidth = max(contentHeight, 44) - let trailingWidth = ExtensionLayoutMetrics.trailingWidth( - for: payload, - baseWidth: baseWingWidth, - maxWidth: notchWidth * 0.42 - ) - let centerWidth = max(96, notchWidth - baseWingWidth - trailingWidth) - - return HStack(spacing: 0) { - ExtensionCompositeIconView( - leading: descriptor.leadingIcon, + HStack(spacing: 0) { + ExtensionLeadingContentView( + content: resolvedLeadingContent, badge: descriptor.badgeIcon, accent: accentColor, - size: contentHeight + frameWidth: layout.leadingWidth, + frameHeight: contentHeight ) - .frame(width: baseWingWidth, height: contentHeight) + .frame(width: layout.leadingWidth, height: contentHeight) Rectangle() .fill(Color.black) - .frame(width: centerWidth, height: contentHeight) - .overlay(centerContent) + .frame(width: layout.centerWidth, height: contentHeight) + .overlay( + ExtensionCenterContentView( + descriptor: descriptor, + accent: accentColor, + width: layout.centerWidth, + style: resolvedCenterStyle + ) + ) - ExtensionMusicWingView(payload: payload, notchHeight: contentHeight) - .frame(width: trailingWidth, height: contentHeight) + ExtensionMusicWingView( + payload: payload, + notchHeight: contentHeight, + trailingWidth: layout.trailingWidth + ) + .frame(width: layout.trailingWidth, height: contentHeight) } - .frame(width: notchWidth, height: notchHeight + (isHovering ? 8 : 0)) + .frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0)) .animation(.smooth(duration: 0.25), value: payload.id) + .onAppear { + logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view") + } + .onDisappear { + logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) standalone view") + } } - private var centerContent: some View { - VStack(alignment: .leading, spacing: 2) { - Text(descriptor.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - if let subtitle = descriptor.subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.7)) - .lineLimit(1) - } - } - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, alignment: .leading) - } } struct ExtensionMusicWingView: View { let payload: ExtensionLiveActivityPayload let notchHeight: CGFloat + let trailingWidth: CGFloat private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var accentColor: Color { descriptor.accentColor.swiftUIColor } @@ -73,7 +88,12 @@ struct ExtensionMusicWingView: View { if case .none = descriptor.trailingContent { EmptyView() } else { - ExtensionTrailingContentView(content: descriptor.trailingContent, accent: accentColor) + ExtensionEdgeContentView( + content: descriptor.trailingContent, + accent: accentColor, + availableWidth: trailingWidth, + alignment: .trailing + ) .frame(maxWidth: .infinity, alignment: .trailing) } @@ -92,5 +112,93 @@ struct ExtensionMusicWingView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding(.trailing, 8) .padding(.vertical, 6) + .onAppear { + logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) within music wing") + } + .onDisappear { + logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) from music wing") + } } } + +struct ExtensionLeadingContentView: View { + let content: AtollTrailingContent + let badge: AtollIconDescriptor? + let accent: Color + let frameWidth: CGFloat + let frameHeight: CGFloat + + var body: some View { + if case let .icon(iconDescriptor) = content { + ExtensionCompositeIconView( + leading: iconDescriptor, + badge: badge, + accent: accent, + size: frameHeight + ) + } else { + ExtensionEdgeContentView( + content: content, + accent: accent, + availableWidth: frameWidth, + alignment: .leading + ) + } + } +} + +struct ExtensionCenterContentView: View { + enum Style { + case stacked + case inline + } + + let descriptor: AtollLiveActivityDescriptor + let accent: Color + let width: CGFloat + let style: Style + + var body: some View { + switch style { + case .stacked: + VStack(alignment: .leading, spacing: 2) { + Text(descriptor.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + if let subtitle = descriptor.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.7)) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + case .inline: + HStack(alignment: .center, spacing: 8) { + MarqueeText( + .constant(descriptor.title), + font: .system(size: 13, weight: .semibold), + nsFont: .body, + textColor: .white, + minDuration: 0.4, + frameWidth: max(40, width * 0.55) + ) + Spacer(minLength: 4) + if let subtitle = descriptor.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.75)) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + } + } +} + +private func logExtensionDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index 89a3b06..10faf4d 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Defaults import AtollExtensionKit struct ExtensionLockScreenWidgetView: View { @@ -20,6 +21,12 @@ struct ExtensionLockScreenWidgetView: View { RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) .stroke(Color.white.opacity(0.04), lineWidth: 1) ) + .onAppear { + logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)") + } + .onDisappear { + logWidgetDiagnostics("Lock screen widget \(payload.descriptor.id) removed") + } } @ViewBuilder @@ -118,6 +125,11 @@ struct ExtensionLockScreenWidgetView: View { } } +private func logWidgetDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} + private struct ExtensionGraphView: View { let data: [Double] let color: Color diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index 7bab79a..9bf6c80 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -254,11 +254,13 @@ struct ExtensionProgressIndicatorView: View { } } -// MARK: - Trailing Content Rendering +// MARK: - Edge Content Rendering -struct ExtensionTrailingContentView: View { +struct ExtensionEdgeContentView: View { let content: AtollTrailingContent let accent: Color + let availableWidth: CGFloat + let alignment: Alignment var body: some View { switch content { @@ -267,6 +269,19 @@ struct ExtensionTrailingContentView: View { .font(font.swiftUIFont()) .foregroundStyle(accent) .lineLimit(1) + .frame(maxWidth: .infinity, alignment: alignment) + case let .marquee(value, font: font, minDuration: duration): + MarqueeText( + .constant(value), + font: font.swiftUIFont(), + nsFont: .body, + textColor: accent, + minDuration: duration, + frameWidth: max(24, availableWidth - 12) + ) + case let .countdownText(targetDate, font: font): + ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent) + .frame(maxWidth: .infinity, alignment: alignment) case let .icon(descriptor): ExtensionIconView( descriptor: descriptor, @@ -292,12 +307,37 @@ struct ExtensionTrailingContentView: View { } } +struct ExtensionCountdownTextView: View { + let targetDate: Date + let font: AtollFontDescriptor + let accent: Color + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + Text(formattedRemaining(since: context.date)) + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .monospacedDigit() + } + } + + private func formattedRemaining(since date: Date) -> String { + let remaining = max(Int(targetDate.timeIntervalSince(date)), 0) + let hours = remaining / 3600 + let minutes = (remaining % 3600) / 60 + let seconds = remaining % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } +} + // MARK: - Layout Metrics enum ExtensionLayoutMetrics { static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { - var width = baseWidth - width = max(width, widthForTrailing(content: payload.descriptor.trailingContent, baseWidth: baseWidth)) + var width = edgeWidth(for: payload.descriptor.trailingContent, baseWidth: baseWidth, maxWidth: maxWidth) if let indicator = payload.descriptor.progressIndicator { width = max(width, widthForProgress(indicator)) } @@ -307,11 +347,26 @@ enum ExtensionLayoutMetrics { return width } - private static func widthForTrailing(content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { + static func edgeWidth(for content: AtollTrailingContent, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { + var width = widthForContent(content, baseWidth: baseWidth) + if let maxWidth { + width = min(width, maxWidth) + } + return width + } + + private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { switch content { case let .text(text, font: font): let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) return max(baseWidth, measured + 32) + case let .marquee(text, font: font, _): + let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) + return max(baseWidth, min(measured + 40, baseWidth * 2)) + case let .countdownText(targetDate, font: font): + let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00" + let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont()) + return max(baseWidth, measured + 24) case .icon: return max(baseWidth, 52) case .spectrum: diff --git a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift index 78f6533..1ce692a 100644 --- a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift +++ b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift @@ -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 diff --git a/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift new file mode 100644 index 0000000..caa226c --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift @@ -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(_ 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) + } + } + + // 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 + + 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" + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift index 9155d3e..a51bcdb 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -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) + } } diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift index af24df5..200f99f 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift @@ -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) + } } diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift index b877dc6..a11ca09 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift @@ -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) +} diff --git a/DynamicIsland/services/Extensions/ExtensionXPCService.swift b/DynamicIsland/services/Extensions/ExtensionXPCService.swift index 60d5b0c..bd72334 100644 --- a/DynamicIsland/services/Extensions/ExtensionXPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionXPCService.swift @@ -24,114 +24,149 @@ final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { // MARK: Authorization func requestAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { - guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } - guard authorizationManager.isExtensionsFeatureEnabled else { - reply(false, ExtensionValidationError.featureDisabled.asNSError) - return + guard self.authorizationManager.isExtensionsFeatureEnabled else { + reply(false, ExtensionValidationError.featureDisabled.asNSError) + return + } + + let entry = self.authorizationManager.ensureEntryExists(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName()) + + if entry.status == .pending { + self.authorizationManager.authorize(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName()) + self.host?.notifyAuthorizationChange(bundleIdentifier: self.bundleIdentifier, isAuthorized: true) + reply(true, nil) + return + } + + reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError) } - - let entry = authorizationManager.ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName()) - - if entry.status == .pending { - authorizationManager.authorize(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName()) - host?.notifyAuthorizationChange(bundleIdentifier: bundleIdentifier, isAuthorized: true) - reply(true, nil) - return - } - - reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError) } func checkAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool) -> Void) { - guard providedBundleIdentifier == bundleIdentifier else { - reply(false) - return - } + Task { @MainActor [weak self] in + guard let self else { return } + guard providedBundleIdentifier == self.bundleIdentifier else { + reply(false) + return + } - let isAuthorized = authorizationManager.authorizationEntry(for: bundleIdentifier)?.isAuthorized ?? false - reply(isAuthorized) + let isAuthorized = self.authorizationManager.authorizationEntry(for: self.bundleIdentifier)?.isAuthorized ?? false + reply(isAuthorized) + } } // MARK: Live Activities func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received live activity payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue), coexistence: \(descriptor.allowsMusicCoexistence))") + try service.liveActivityManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Live activity \(descriptor.id) stored for \(service.bundleIdentifier); active activities: \(service.liveActivityManager.activeActivities.count)") } } func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received live activity update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.liveActivityManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Live activity \(descriptor.id) updated for \(service.bundleIdentifier)") } } func dismissLiveActivity(activityID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { - guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } - liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: bundleIdentifier) - reply(true, nil) + self.logDiagnostics("Received live activity dismissal from \(self.bundleIdentifier) (id: \(activityID))") + self.liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Live activity \(activityID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } } // MARK: Lock Screen Widgets func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try widgetManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received lock screen widget payload from \(service.bundleIdentifier) (id: \(descriptor.id), style: \(descriptor.layoutStyle))") + try service.widgetManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Lock screen widget \(descriptor.id) stored for \(service.bundleIdentifier); active widgets: \(service.widgetManager.activeWidgets.count)") } } func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try widgetManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received lock screen widget update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.widgetManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Lock screen widget \(descriptor.id) updated for \(service.bundleIdentifier)") } } func dismissLockScreenWidget(widgetID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { - guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } - widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bundleIdentifier) - reply(true, nil) + self.logDiagnostics("Received lock screen widget dismissal from \(self.bundleIdentifier) (id: \(widgetID))") + self.widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Lock screen widget \(widgetID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } } // MARK: Diagnostics func getVersion(reply: @escaping (String) -> Void) { - reply(appVersion) + Task { @MainActor in + reply(appVersion) + } } // MARK: Helpers - private func respond(reply: @escaping (Bool, Error?) -> Void, operation: () throws -> Void) { - do { - try operation() - reply(true, nil) - } catch { - if Defaults[.extensionDiagnosticsLoggingEnabled] { - Logger.log("Extension XPC request failed: \(error)", category: .extensions) + private func respond(reply: @escaping (Bool, Error?) -> Void, operation: @escaping (ExtensionXPCService) throws -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + do { + try operation(self) + reply(true, nil) + } catch { + if Defaults[.extensionDiagnosticsLoggingEnabled] { + Logger.log("Extension XPC request failed: \(error)", category: .extensions) + } + reply(false, error.asNSError) } - reply(false, error.asNSError) } } private func validate(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) -> Bool { guard providedBundleIdentifier == bundleIdentifier else { let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier) + logDiagnostics("Rejected XPC request due to bundle mismatch. Expected \(bundleIdentifier) received \(providedBundleIdentifier)") reply(false, error.asNSError) return false } return true } + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } + private func resolvedApplicationName() -> String { guard let processIdentifier = connection?.processIdentifier, processIdentifier != 0, diff --git a/DynamicIsland/utils/Logger.swift b/DynamicIsland/utils/Logger.swift index 5a50e34..27d6d9f 100644 --- a/DynamicIsland/utils/Logger.swift +++ b/DynamicIsland/utils/Logger.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog import SwiftUI enum LogCategory: String { @@ -12,9 +13,41 @@ enum LogCategory: String { case success = "✅" case debug = "🔍" case extensions = "🧩" + + var osCategoryName: String { + switch self { + case .lifecycle: return "lifecycle" + case .memory: return "memory" + case .performance: return "performance" + case .ui: return "ui" + case .network: return "network" + case .error: return "error" + case .warning: return "warning" + case .success: return "success" + case .debug: return "debug" + case .extensions: return "extensions" + } + } } struct Logger { + private static let subsystem = "com.ebullioscopic.Atoll" + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static var osLoggerCache: [LogCategory: OSLog] = [:] + + private static func osLogger(for category: LogCategory) -> OSLog { + if let cached = osLoggerCache[category] { + return cached + } + let logger = OSLog(subsystem: subsystem, category: category.osCategoryName) + osLoggerCache[category] = logger + return logger + } + static func log( _ message: String, category: LogCategory, @@ -23,8 +56,14 @@ struct Logger { line: Int = #line ) { let fileName = (file as NSString).lastPathComponent - let timestamp = ISO8601DateFormatter().string(from: Date()) - print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)") + let timestamp = dateFormatter.string(from: Date()) + let entry = "\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)" + let logger = osLogger(for: category) + os_log("%{public}@", log: logger, type: .default, entry) + +#if DEBUG + Swift.print(entry) +#endif } static func trackMemory(