From 9bd445986da026b550435a2c5a785753456ee185 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 13 Jan 2026 14:35:23 +0530 Subject: [PATCH] Something in the way --- DynamicIsland.xcodeproj/project.pbxproj | 16 + DynamicIsland/ContentView.swift | 75 +++ DynamicIsland/DynamicIslandApp.swift | 3 + DynamicIsland/Localizable.xcstrings | 116 ++++- .../ExtensionLiveActivityViews.swift | 96 ++++ .../ExtensionLockScreenWidgetView.swift | 204 +++++++++ .../ExtensionRenderingHelpers.swift | 349 ++++++++++++++ .../Settings/ExtensionsSettings.swift | 426 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 16 + .../Extensions/ExtensionActivityModels.swift | 53 +++ .../ExtensionAuthorizationManager.swift | 202 +++++++++ .../ExtensionDescriptorValidator.swift | 33 ++ .../ExtensionLiveActivityManager.swift | 90 ++++ .../ExtensionLockScreenWidgetManager.swift | 87 ++++ ...ckScreenWidgetPresentationController.swift | 248 ++++++++++ DynamicIsland/models/Constants.swift | 91 ++++ .../Extensions/ExtensionXPCService.swift | 159 +++++++ .../Extensions/ExtensionXPCServiceHost.swift | 131 ++++++ DynamicIsland/utils/Logger.swift | 1 + 19 files changed, 2395 insertions(+), 1 deletion(-) create mode 100644 DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift create mode 100644 DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift create mode 100644 DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift create mode 100644 DynamicIsland/components/Settings/ExtensionsSettings.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionActivityModels.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift create mode 100644 DynamicIsland/services/Extensions/ExtensionXPCService.swift create mode 100644 DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 6b9b319..2feae3d 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -26,6 +26,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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -58,6 +59,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */, 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -148,6 +150,7 @@ ); name = DynamicIsland; packageProductDependencies = ( + B2C8B0022F11AABB00887766 /* AtollExtensionKit */, 14D032192C68F32E0096E6A1 /* LaunchAtLogin */, 14D0321C2C68F3350096E6A1 /* Sparkle */, B18654382C6F4990000B926A /* KeyboardShortcuts */, @@ -190,6 +193,7 @@ mainGroup = F54406DE2CD4987A00C7AFD9; minimizedProjectReferenceProxies = 1; packageReferences = ( + B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */, F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */, 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */, 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */, @@ -524,6 +528,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../AtollExtensionKit; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = { isa = XCRemoteSwiftPackageReference; @@ -661,6 +672,11 @@ 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/ContentView.swift b/DynamicIsland/ContentView.swift index afa1455..1a5a376 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -34,6 +34,7 @@ struct ContentView: View { @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared @ObservedObject var capsLockManager = CapsLockManager.shared + @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -60,6 +61,7 @@ struct ContentView: View { @Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.enableCapsLockIndicator) var enableCapsLockIndicator + @Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { @@ -484,6 +486,8 @@ struct ContentView: View { && !vm.hideOnClosed && !lockScreenManager.isLocked let musicSecondary = resolveMusicSecondaryLiveActivity() + let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) + let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) let expansionMatchesSecondary: Bool = { guard let musicSecondary else { return false } switch musicSecondary { @@ -497,6 +501,8 @@ struct ContentView: View { return coordinator.expandingView.type == .doNotDisturb case .capsLock: return false + case .extensionPayload: + return false } }() let canShowMusicDuringExpansion = !coordinator.expandingView.show @@ -554,6 +560,14 @@ struct ContentView: View { LockScreenLiveActivity() } 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 { + ExtensionLiveActivityStandaloneView( + payload: extensionPayload, + notchWidth: extensionStandaloneNotchWidth(for: vm.effectiveClosedNotchHeight, isHovering: isHovering), + notchHeight: vm.effectiveClosedNotchHeight, + isHovering: isHovering + ) + .transition(.opacity.combined(with: .scale)) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { @@ -823,6 +837,10 @@ struct ContentView: View { return .capsLock(showLabel: showCapsLockLabel) } + if let extensionPayload = resolvedExtensionMusicPayload() { + return .extensionPayload(extensionPayload) + } + return nil } @@ -840,6 +858,9 @@ struct ContentView: View { return focusRightWingWidth(baseWidth: baseWidth) case .recording: return recordingRightWingWidth(baseWidth: baseWidth) + case .extensionPayload(let payload): + let maxWidth = baseWidth + centerBaseWidth * 0.6 + return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth) } } @@ -936,6 +957,17 @@ struct ContentView: View { Image(systemName: "capslock.fill") .font(.system(size: badgeSize * 0.5, weight: .semibold)) .foregroundStyle(capsLockTintMode.color) + case .extensionPayload(let payload): + ExtensionCompositeIconView( + leading: payload.descriptor.leadingIcon, + badge: payload.descriptor.badgeIcon, + accent: payload.descriptor.accentColor.swiftUIColor, + size: badgeSize + ) + .background( + Circle() + .fill(Color.black.opacity(0.85)) + ) } } .frame(width: badgeSize, height: badgeSize) @@ -976,6 +1008,8 @@ struct ContentView: View { spectrumView(forceSpectrum: true) case .recording: spectrumView(forceSpectrum: true, trailingInset: 6) + case .extensionPayload(let payload): + ExtensionMusicWingView(payload: payload, notchHeight: notchHeight) case .none: spectrumView(forceSpectrum: false) } @@ -1023,6 +1057,44 @@ struct ContentView: View { let remaining = reminder.event.start.timeIntervalSince(now) return remaining > 0 && remaining <= window } + + private func extensionSecondaryPayloadID(for secondary: MusicSecondaryLiveActivity?) -> String? { + guard case let .extensionPayload(payload) = secondary else { return nil } + return payload.id + } + + private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? { + guard enableExtensionLiveActivities, + vm.notchState == .closed, + !vm.hideOnClosed, + !lockScreenManager.isLocked, + coordinator.musicLiveActivityEnabled else { + return nil + } + return extensionLiveActivityManager.sortedActivities(for: true).first + } + + private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? { + guard enableExtensionLiveActivities, + vm.notchState == .closed, + !vm.hideOnClosed, + !lockScreenManager.isLocked, + vm.effectiveClosedNotchHeight > 0, + !coordinator.expandingView.show else { + return nil + } + + return extensionLiveActivityManager + .sortedActivities() + .first(where: { $0.id != musicPayloadID }) + } + + 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) + } @ViewBuilder var dragDetector: some View { @@ -1518,6 +1590,7 @@ private enum MusicSecondaryLiveActivity: Equatable { case recording case focus(FocusModeType) case capsLock(showLabel: Bool) + case extensionPayload(ExtensionLiveActivityPayload) var id: String { switch self { @@ -1531,6 +1604,8 @@ private enum MusicSecondaryLiveActivity: Equatable { return "focus-\(mode.rawValue)" case .capsLock(let showLabel): return showLabel ? "caps-lock-label" : "caps-lock-icon" + case .extensionPayload(let payload): + return "extension-\(payload.id)" } } } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 0640878..953c982 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -91,6 +91,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel let systemTimerBridge = SystemTimerBridge.shared + let extensionXPCServiceHost = ExtensionXPCServiceHost.shared var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? @@ -132,6 +133,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Cancel any pending window size updates windowSizeUpdateWorkItem?.cancel() NotificationCenter.default.removeObserver(self) + extensionXPCServiceHost.stop() } @objc func onScreenLocked(_: Notification) { @@ -340,6 +342,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm) LockScreenManager.shared.configure(viewModel: vm) + extensionXPCServiceHost.start() // Migrate legacy progress bar settings Defaults.Keys.migrateProgressBarStyle() diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 97c2f8d..b1c9c9c 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -765,6 +765,10 @@ "comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.", "isCommentAutoGenerated" : true }, + "%@:" : { + "comment" : "A row of text with a label and its corresponding value. The argument is the label text, and the second is the value text.", + "isCommentAutoGenerated" : true + }, "%@°" : { "comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).", "isCommentAutoGenerated" : true @@ -872,6 +876,18 @@ "%lld" : { "shouldTranslate" : false }, + "%lld %@" : { + "comment" : "A text label showing the number of apps with which the current app has granted permissions. The argument is the count of such apps.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + } + } + }, "%lld animations" : { "comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.", "isCommentAutoGenerated" : true, @@ -2853,6 +2869,10 @@ } } }, + "Allowed Features" : { + "comment" : "A label describing the allowed features for an app extension.", + "isCommentAutoGenerated" : true + }, "ALPHA" : { "comment" : "A label indicating a feature that is still in development.", "isCommentAutoGenerated" : true @@ -3200,6 +3220,10 @@ } } }, + "App Permissions" : { + "comment" : "A section header in the settings view that lists the permissions of installed apps.", + "isCommentAutoGenerated" : true + }, "Appearance" : { "localizations" : { "ar" : { @@ -3302,6 +3326,10 @@ }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { + }, + "Apps using AtollExtensionKit will appear here once they request permission" : { + "comment" : "A description below the placeholder text in the \"Search extensions...\" text field, explaining that apps using AtollExtensionKit will be listed here once they grant permission to use the app.", + "isCommentAutoGenerated" : true }, "Are you sure you want to delete \"%@\"?" : { "comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.", @@ -3354,6 +3382,14 @@ } } }, + "Authorize" : { + "comment" : "A button that authorizes an app extension.", + "isCommentAutoGenerated" : true + }, + "Authorized" : { + "comment" : "A label indicating that an app has been granted permission to use AtollExtensionKit.", + "isCommentAutoGenerated" : true + }, "Auto-scroll to next event" : { "comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.", "isCommentAutoGenerated" : true @@ -6782,6 +6818,14 @@ } } }, + "Denied/Revoked" : { + "comment" : "A label describing an extension with denied or revoked permissions.", + "isCommentAutoGenerated" : true + }, + "Deny" : { + "comment" : "A button that denies an app extension's access.", + "isCommentAutoGenerated" : true + }, "Description" : { "extractionState" : "stale", "localizations" : { @@ -7926,6 +7970,10 @@ "comment" : "A toggle that allows users to enable or disable customizable controls for media playback.", "isCommentAutoGenerated" : true }, + "Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll." : { + "comment" : "A footer text that explains the purpose of enabling extensions.", + "isCommentAutoGenerated" : true + }, "Enable global keyboard shortcuts above to customize your shortcuts." : { }, @@ -8730,7 +8778,6 @@ "isCommentAutoGenerated" : true }, "Extensions" : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9505,6 +9552,10 @@ } } }, + "Global Settings" : { + "comment" : "The header for the section that controls global settings related to extensions.", + "isCommentAutoGenerated" : true + }, "Got it!" : { "localizations" : { "ar" : { @@ -10859,6 +10910,10 @@ "comment" : "A help text explaining why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.", "isCommentAutoGenerated" : true }, + "Last Denied Reason:" : { + "comment" : "A label for the \"Last Denied Reason\" section in the extensions settings view.", + "isCommentAutoGenerated" : true + }, "Last Updated" : { "localizations" : { "fr" : { @@ -11101,6 +11156,10 @@ "comment" : "A section header for the lock screen reminder widget settings.", "isCommentAutoGenerated" : true }, + "Lock Screen Widgets" : { + "comment" : "A toggle that enables or disables the use of lock screen widgets by the extension.", + "isCommentAutoGenerated" : true + }, "Loop Mode" : { }, @@ -14193,6 +14252,10 @@ } } }, + "No extensions yet" : { + "comment" : "A message displayed when there are no extensions that have requested permission.", + "isCommentAutoGenerated" : true + }, "No favorites" : { "localizations" : { "fr" : { @@ -15045,6 +15108,10 @@ }, "Pause" : { + }, + "Pending" : { + "comment" : "A label indicating that a user's permission to access a feature is being checked.", + "isCommentAutoGenerated" : true }, "Percentage" : { "extractionState" : "stale", @@ -15057,6 +15124,10 @@ } } }, + "Permission States:" : { + "comment" : "A label describing the different permission states displayed in the footer of the extensions settings view.", + "isCommentAutoGenerated" : true + }, "Permissions" : { }, @@ -15602,6 +15673,10 @@ } } }, + "Re-authorize" : { + "comment" : "A button that re-authorizes an app when pressed.", + "isCommentAutoGenerated" : true + }, "Read" : { "comment" : "A heading displayed for the user's disk read speed.", "isCommentAutoGenerated" : true, @@ -15634,6 +15709,10 @@ } } }, + "Recent Activity (last 5 minutes)" : { + "comment" : "A label describing the recent activity of a particular extension.", + "isCommentAutoGenerated" : true + }, "Recent Colors" : { "localizations" : { "fr" : { @@ -15889,6 +15968,17 @@ } } } + }, + "Remove" : { + "comment" : "A button that removes an extension from the authorized list.", + "isCommentAutoGenerated" : true + }, + "Remove %@ from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app." : { + "comment" : "A message presented when a user confirms they want to remove an extension from the authorized list. The argument is the name of the extension.", + "isCommentAutoGenerated" : true + }, + "Remove Extension" : { + }, "Remove from shelf after dragging" : { @@ -15923,6 +16013,10 @@ } } }, + "Reset Rate Limits" : { + "comment" : "A button that resets the rate limits for a particular extension.", + "isCommentAutoGenerated" : true + }, "Reset to Default" : { "localizations" : { "fr" : { @@ -16074,6 +16168,10 @@ } } }, + "Revoke Access" : { + "comment" : "A button that revokes access to an app.", + "isCommentAutoGenerated" : true + }, "Right" : { }, @@ -16319,6 +16417,10 @@ } } }, + "Search extensions..." : { + "comment" : "A placeholder text for a text field used to filter through a list of extensions.", + "isCommentAutoGenerated" : true + }, "Search notes..." : { "comment" : "A placeholder text for a search bar in the note list view.", "isCommentAutoGenerated" : true @@ -18450,6 +18552,10 @@ } } }, + "Status:" : { + "comment" : "A label for the status of an extension.", + "isCommentAutoGenerated" : true + }, "Stop" : { "localizations" : { "fr" : { @@ -18869,6 +18975,10 @@ }, "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : { + }, + "Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below." : { + "comment" : "A description below the toggle to enable third-party extensions, explaining that third-party apps can display live activities and lock screen widgets, and that this view allows you to manage those permissions.", + "isCommentAutoGenerated" : true }, "This duration powers the \"Custom\" option inside the timer popover for quick access." : { "comment" : "A footer description for the custom timer settings, explaining their purpose.", @@ -20610,6 +20720,10 @@ }, "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { + }, + "Widget Updates" : { + "comment" : "A label describing the number of times the extension has updated its widget.", + "isCommentAutoGenerated" : true }, "Width:" : { "extractionState" : "stale", diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift new file mode 100644 index 0000000..d414e0a --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -0,0 +1,96 @@ +import SwiftUI +import AtollExtensionKit + +struct ExtensionLiveActivityStandaloneView: View { + let payload: ExtensionLiveActivityPayload + let notchWidth: CGFloat + let notchHeight: CGFloat + let isHovering: Bool + + private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } + private var contentHeight: CGFloat { + max(0, notchHeight - (isHovering ? 0 : 12)) + } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + + 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, + badge: descriptor.badgeIcon, + accent: accentColor, + size: contentHeight + ) + .frame(width: baseWingWidth, height: contentHeight) + + Rectangle() + .fill(Color.black) + .frame(width: centerWidth, height: contentHeight) + .overlay(centerContent) + + ExtensionMusicWingView(payload: payload, notchHeight: contentHeight) + .frame(width: trailingWidth, height: contentHeight) + } + .frame(width: notchWidth, height: notchHeight + (isHovering ? 8 : 0)) + .animation(.smooth(duration: 0.25), value: payload.id) + } + + 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 + + private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + VStack(alignment: .trailing, spacing: 6) { + if case .none = descriptor.trailingContent { + EmptyView() + } else { + ExtensionTrailingContentView(content: descriptor.trailingContent, accent: accentColor) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + if let indicator = descriptor.progressIndicator, indicator != .none { + ExtensionProgressIndicatorView( + indicator: indicator, + progress: descriptor.progress, + accent: accentColor, + estimatedDuration: descriptor.estimatedDuration + ) + .frame(maxWidth: .infinity, alignment: .trailing) + } else { + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.trailing, 8) + .padding(.vertical, 6) + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift new file mode 100644 index 0000000..89a3b06 --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -0,0 +1,204 @@ +import SwiftUI +import AtollExtensionKit + +struct ExtensionLockScreenWidgetView: View { + let payload: ExtensionLockScreenWidgetPayload + + private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + ZStack { + backgroundView + contentView + .padding(.horizontal, descriptor.layoutStyle == .circular ? 10 : 16) + .padding(.vertical, descriptor.layoutStyle == .circular ? 10 : 12) + } + .frame(width: descriptor.size.width, height: descriptor.size.height) + .clipShape(RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.04), lineWidth: 1) + ) + } + + @ViewBuilder + private var contentView: some View { + switch descriptor.layoutStyle { + case .inline, .card, .custom: + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in + view(for: element) + .frame(maxWidth: .infinity, alignment: alignment(for: element)) + .accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)") + } + } + case .circular: + ZStack { + ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in + view(for: element) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)") + } + } + } + } + + @ViewBuilder + private func view(for element: AtollWidgetContentElement) -> some View { + switch element { + case let .text(text, font: font, color: color, alignment: _): + Text(text) + .font(font.swiftUIFont()) + .foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9)) + .lineLimit(2) + case let .icon(iconDescriptor, tint): + ExtensionIconView( + descriptor: iconDescriptor, + tint: (tint?.swiftUIColor) ?? accentColor, + size: CGSize(width: 28, height: 28), + cornerRadius: 8 + ) + case let .progress(indicator, value, color): + ExtensionProgressIndicatorView( + indicator: indicator, + progress: value, + accent: (color?.swiftUIColor) ?? accentColor, + estimatedDuration: nil + ) + case let .graph(data, color, size): + ExtensionGraphView(data: data, color: color.swiftUIColor, size: size) + .frame(width: size.width, height: size.height) + case let .gauge(value, minValue, maxValue, style, color): + ExtensionGaugeView( + value: value, + minValue: minValue, + maxValue: maxValue, + style: style, + accent: (color?.swiftUIColor) ?? accentColor + ) + .frame(maxWidth: .infinity) + case let .spacer(height): + Color.clear + .frame(height: height) + case let .divider(color, thickness): + Rectangle() + .fill(color.swiftUIColor.opacity(0.4)) + .frame(height: thickness) + } + } + + private func alignment(for element: AtollWidgetContentElement) -> Alignment { + switch element { + case let .text(_, _, _, alignment): + return alignment.swiftUI + default: + return .leading + } + } + + @ViewBuilder + private var backgroundView: some View { + switch descriptor.material { + case .frosted: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + case .liquid: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(.regularMaterial) + case .solid: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(accentColor.opacity(0.9)) + case .semiTransparent: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(accentColor.opacity(0.35)) + case .clear: + Color.clear + } + } +} + +private struct ExtensionGraphView: View { + let data: [Double] + let color: Color + let size: CGSize + + var body: some View { + GeometryReader { proxy in + let minValue = data.min() ?? 0 + let maxValue = data.max() ?? 1 + let range = max(maxValue - minValue, 0.0001) + let step = proxy.size.width / CGFloat(max(data.count - 1, 1)) + Path { path in + for (index, value) in data.enumerated() { + let x = CGFloat(index) * step + let normalized = (value - minValue) / range + let y = proxy.size.height - (CGFloat(normalized) * proxy.size.height) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + .frame(width: size.width, height: size.height) + } +} + +private struct ExtensionGaugeView: View { + let value: Double + let minValue: Double + let maxValue: Double + let style: AtollWidgetContentElement.GaugeStyle + let accent: Color + + var body: some View { + switch style { + case .circular: + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 5) + Circle() + .trim(from: 0, to: CGFloat(normalizedValue)) + .stroke(accent, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.3), value: normalizedValue) + Text("\(Int(normalizedValue * 100))%") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .monospacedDigit() + } + .frame(width: 54, height: 54) + case .linear: + GeometryReader { proxy in + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: 8) + .overlay(alignment: .leading) { + Capsule() + .fill(accent) + .frame(width: proxy.size.width * CGFloat(normalizedValue), height: 8) + .animation(.smooth(duration: 0.3), value: normalizedValue) + } + } + .frame(height: 8) + } + } + + private var normalizedValue: Double { + guard maxValue > minValue else { return 0 } + return min(max((value - minValue) / (maxValue - minValue), 0), 1) + } +} + +private extension AtollWidgetContentElement.TextAlignment { + var swiftUI: Alignment { + switch self { + case .leading: return .leading + case .center: return .center + case .trailing: return .trailing + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift new file mode 100644 index 0000000..7bab79a --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -0,0 +1,349 @@ +import SwiftUI +import AppKit +import AtollExtensionKit + +// MARK: - Color Conversion + +extension AtollColorDescriptor { + var swiftUIColor: Color { + if isAccent { + return .accentColor + } + return Color(red: red, green: green, blue: blue, opacity: alpha) + } + + var nsColor: NSColor { + if isAccent { + return NSColor.controlAccentColor + } + return NSColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +// MARK: - Font Conversion + +extension AtollFontDescriptor { + func swiftUIFont() -> Font { + let design = self.design.swiftUI + let weight = self.weight.swiftUI + var font = Font.system(size: size, weight: weight, design: design) + if isMonospacedDigit { + font = font.monospacedDigit() + } + return font + } + + func nsFont() -> NSFont { + let weight = weight.nsFont + let font: NSFont + switch design { + case .serif: + font = NSFont.userFont(ofSize: size) ?? NSFont.systemFont(ofSize: size, weight: weight) + case .rounded: + if let descriptor = NSFont.systemFont(ofSize: size, weight: weight).fontDescriptor.withDesign(.rounded) { + font = NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size, weight: weight) + } else { + font = NSFont.systemFont(ofSize: size, weight: weight) + } + case .monospaced: + font = NSFont.monospacedSystemFont(ofSize: size, weight: weight) + case .default: + font = NSFont.systemFont(ofSize: size, weight: weight) + } + return font + } +} + +private extension AtollFontWeight { + var swiftUI: Font.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } + + var nsFont: NSFont.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } +} + +private extension AtollFontDesign { + var swiftUI: Font.Design { + switch self { + case .default: return .default + case .serif: return .serif + case .rounded: return .rounded + case .monospaced: return .monospaced + } + } +} + +// MARK: - Icon Rendering + +struct ExtensionIconView: View { + let descriptor: AtollIconDescriptor + let tint: Color + let size: CGSize + let cornerRadius: CGFloat + + var body: some View { + switch descriptor { + case let .symbol(name, glyphSize, weight): + Image(systemName: name) + .font(.system(size: glyphSize, weight: weight.swiftUI)) + .foregroundStyle(tint) + .frame(width: size.width, height: size.height) + case let .image(data, targetSize, radius): + if let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: targetSize.width, height: targetSize.height) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } else { + fallbackSymbol + } + case let .appIcon(bundleIdentifier, targetSize, radius): + if let icon = AppIconAsNSImage(for: bundleIdentifier) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: targetSize.width, height: targetSize.height) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } else { + fallbackSymbol + } + case .lottie, .none: + fallbackSymbol + } + } + + private var fallbackSymbol: some View { + Image(systemName: "app.dashed") + .font(.system(size: size.width * 0.6, weight: .medium)) + .foregroundStyle(tint) + .frame(width: size.width, height: size.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous) + .fill(tint.opacity(0.15)) + ) + } + + private func cornerRadius(for descriptor: AtollIconDescriptor) -> CGFloat { + switch descriptor { + case .image(_, _, let radius): return radius + case .appIcon(_, _, let radius): return radius + default: return cornerRadius + } + } +} + +struct ExtensionCompositeIconView: View { + let leading: AtollIconDescriptor + let badge: AtollIconDescriptor? + let accent: Color + let size: CGFloat + + var body: some View { + ZStack(alignment: .bottomTrailing) { + ExtensionIconView( + descriptor: leading, + tint: accent, + size: CGSize(width: size, height: size), + cornerRadius: size * 0.18 + ) + if let badge { + ExtensionIconView( + descriptor: badge, + tint: .white, + size: CGSize(width: max(size * 0.35, 12), height: max(size * 0.35, 12)), + cornerRadius: size * 0.12 + ) + .background( + Circle() + .fill(Color.black.opacity(0.7)) + .frame(width: size * 0.4, height: size * 0.4) + ) + } + } + .frame(width: size, height: size) + } +} + +// MARK: - Progress Rendering + +struct ExtensionProgressIndicatorView: View { + let indicator: AtollProgressIndicator + let progress: Double + let accent: Color + let estimatedDuration: TimeInterval? + + var body: some View { + switch indicator { + case let .ring(diameter, strokeWidth): + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: strokeWidth) + Circle() + .trim(from: 0, to: CGFloat(max(0, min(progress, 1)))) + .stroke(accent, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progress) + } + .frame(width: diameter, height: diameter) + case let .bar(width, height, cornerRadius): + Capsule() + .fill(Color.white.opacity(0.18)) + .frame(width: width ?? 80, height: height) + .overlay(alignment: .leading) { + Capsule() + .fill(accent) + .frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height) + .animation(.smooth(duration: 0.25), value: progress) + } + case let .percentage(font): + Text("\(Int(progress * 100))%") + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .monospacedDigit() + case let .countdown(font): + Text(countdownText) + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .monospacedDigit() + case .lottie: + Image(systemName: "sparkles") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(accent) + case .none: + EmptyView() + } + } + + private var countdownText: String { + guard let estimatedDuration else { return formatPercent } + let remaining = max(estimatedDuration * (1 - progress), 0) + let hours = Int(remaining) / 3600 + let minutes = (Int(remaining) % 3600) / 60 + let seconds = Int(remaining) % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } + + private var formatPercent: String { + "\(Int(progress * 100))%" + } +} + +// MARK: - Trailing Content Rendering + +struct ExtensionTrailingContentView: View { + let content: AtollTrailingContent + let accent: Color + + var body: some View { + switch content { + case let .text(value, font: font): + Text(value) + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .lineLimit(1) + case let .icon(descriptor): + ExtensionIconView( + descriptor: descriptor, + tint: accent, + size: CGSize(width: 26, height: 26), + cornerRadius: 6 + ) + case let .spectrum(color: colorDescriptor): + Rectangle() + .fill((colorDescriptor.isAccent ? accent : colorDescriptor.swiftUIColor).gradient) + .frame(width: 48, height: 14) + .mask { + AudioSpectrumView(isPlaying: .constant(true)) + .frame(width: 16, height: 12) + } + case .animation: + Image(systemName: "sparkles") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(accent) + case .none: + EmptyView() + } + } +} + +// 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)) + if let indicator = payload.descriptor.progressIndicator { + width = max(width, widthForProgress(indicator)) + } + if let maxWidth { + width = min(width, maxWidth) + } + return width + } + + private static func widthForTrailing(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 .icon: + return max(baseWidth, 52) + case .spectrum: + return max(baseWidth, 56) + case let .animation(data: _, size: size): + return max(baseWidth, size.width + 16) + case .none: + return baseWidth + } + } + + private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat { + switch indicator { + case let .ring(diameter, _): + return diameter + 20 + case let .bar(width, _, _): + return (width ?? 72) + 12 + case .percentage: + return 60 + case .countdown: + return 74 + case let .lottie(_, size): + return size.width + 16 + case .none: + return 0 + } + } +} + +enum ExtensionTextMeasurer { + static func width(for text: String, font: NSFont) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return max(1, text.size(withAttributes: attributes).width) + } +} diff --git a/DynamicIsland/components/Settings/ExtensionsSettings.swift b/DynamicIsland/components/Settings/ExtensionsSettings.swift new file mode 100644 index 0000000..4a051a1 --- /dev/null +++ b/DynamicIsland/components/Settings/ExtensionsSettings.swift @@ -0,0 +1,426 @@ +// +// ExtensionsSettings.swift +// DynamicIsland +// +// Created on 13/01/2026. +// + +import SwiftUI +import Defaults +import AtollExtensionKit + +struct ExtensionsSettingsView: View { + @ObservedObject private var authManager = ExtensionAuthorizationManager.shared + @State private var searchText = "" + @State private var selectedEntry: ExtensionAuthorizationEntry? + @State private var showingRemoveConfirmation = false + + private func highlightID(_ title: String) -> String { + "extensions-\(title)" + } + + private var filteredEntries: [ExtensionAuthorizationEntry] { + guard !searchText.isEmpty else { return authManager.entries } + let query = searchText.lowercased() + return authManager.entries.filter { + $0.bundleIdentifier.lowercased().contains(query) || + $0.appName.lowercased().contains(query) + } + } + + var body: some View { + Form { + globalTogglesSection + + if authManager.isExtensionsFeatureEnabled { + authorizedAppsSection + } + } + .navigationTitle("Extensions") + .alert("Remove Extension", isPresented: $showingRemoveConfirmation, presenting: selectedEntry) { entry in + Button("Cancel", role: .cancel) { } + Button("Remove", role: .destructive) { + authManager.removeEntry(bundleIdentifier: entry.bundleIdentifier) + selectedEntry = nil + } + } message: { entry in + Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app.") + } + } + + private var globalTogglesSection: some View { + Section { + Defaults.Toggle("Enable third-party extensions", key: .enableThirdPartyExtensions) + .settingsHighlight(id: highlightID("Enable third-party extensions")) + + if Defaults[.enableThirdPartyExtensions] { + Defaults.Toggle("Allow extension live activities", key: .enableExtensionLiveActivities) + .settingsHighlight(id: highlightID("Allow extension live activities")) + + Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets) + .settingsHighlight(id: highlightID("Allow extension lock screen widgets")) + + Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled) + .settingsHighlight(id: highlightID("Enable extension diagnostics logging")) + } + } header: { + Text("Global Settings") + } footer: { + if Defaults[.enableThirdPartyExtensions] { + Text("Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var authorizedAppsSection: some View { + Section { + if authManager.entries.isEmpty { + VStack(alignment: .center, spacing: 12) { + Image(systemName: "puzzlepiece.extension") + .font(.system(size: 48)) + .foregroundStyle(.secondary.opacity(0.5)) + + Text("No extensions yet") + .font(.headline) + .foregroundStyle(.secondary) + + Text("Apps using AtollExtensionKit will appear here once they request permission") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + if authManager.entries.count > 3 { + TextField("Search extensions...", text: $searchText) + .textFieldStyle(.roundedBorder) + } + + ForEach(filteredEntries) { entry in + ExtensionEntryRow(entry: entry, onRemove: { + selectedEntry = entry + showingRemoveConfirmation = true + }) + } + } + } header: { + HStack { + Text("App Permissions") + Spacer() + if !authManager.entries.isEmpty { + Text("\(authManager.entries.count) \(authManager.entries.count == 1 ? "app" : "apps")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .settingsHighlight(id: highlightID("App permissions list")) + } footer: { + if !authManager.entries.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Permission States:") + .font(.caption.weight(.semibold)) + + HStack(spacing: 16) { + Label("Authorized", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Label("Pending", systemImage: "clock.fill") + .foregroundStyle(.orange) + Label("Denied/Revoked", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + } + .font(.caption2) + } + .foregroundStyle(.secondary) + } + } + } +} + +private struct ExtensionEntryRow: View { + @ObservedObject private var authManager = ExtensionAuthorizationManager.shared + let entry: ExtensionAuthorizationEntry + let onRemove: () -> Void + + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Main row + HStack(spacing: 12) { + // Status indicator + statusIndicator + + // App info + VStack(alignment: .leading, spacing: 3) { + Text(entry.appName) + .font(.system(size: 13, weight: .medium)) + Text(entry.bundleIdentifier) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer() + + // Expand button + Button { + withAnimation(.snappy(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .buttonStyle(.plain) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.snappy(duration: 0.2)) { + isExpanded.toggle() + } + } + + // Expanded details + if isExpanded { + expandedDetails + .padding(.top, 12) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.vertical, 8) + } + + private var statusIndicator: some View { + ZStack { + Circle() + .fill(statusColor.opacity(0.15)) + .frame(width: 32, height: 32) + + Image(systemName: statusIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(statusColor) + } + } + + private var statusColor: Color { + switch entry.status { + case .authorized: return .green + case .pending: return .orange + case .denied, .revoked: return .red + } + } + + private var statusIcon: String { + switch entry.status { + case .authorized: return "checkmark.circle.fill" + case .pending: return "clock.fill" + case .denied, .revoked: return "xmark.circle.fill" + } + } + + private var expandedDetails: some View { + VStack(alignment: .leading, spacing: 14) { + // Status info + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Status:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(entry.status.rawValue.capitalized) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(statusColor.opacity(0.15)) + .foregroundStyle(statusColor) + .clipShape(Capsule()) + } + + if let grantedAt = entry.grantedAt { + infoRow(label: "Granted", value: formatDate(grantedAt)) + } + + if let lastActivity = entry.lastActivityAt { + infoRow(label: "Last Activity", value: formatDate(lastActivity)) + } + + if let deniedReason = entry.lastDeniedReason { + VStack(alignment: .leading, spacing: 3) { + Text("Last Denied Reason:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(deniedReason) + .font(.caption) + .foregroundStyle(.red.opacity(0.9)) + } + } + } + + Divider() + + // Scopes section + if entry.status == .authorized { + scopeToggles + Divider() + } + + // Rate limits info + if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), + !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty { + rateLimitInfo(record: rateLimitRecord) + Divider() + } + + // Actions + actionButtons + } + .padding(12) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var scopeToggles: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Allowed Features") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Toggle("Live Activities", isOn: Binding( + get: { entry.allowedScopes.contains(.liveActivities) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.liveActivities) + } else { + newScopes.remove(.liveActivities) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areLiveActivitiesEnabled) + + Toggle("Lock Screen Widgets", isOn: Binding( + get: { entry.allowedScopes.contains(.lockScreenWidgets) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.lockScreenWidgets) + } else { + newScopes.remove(.lockScreenWidgets) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areLockScreenWidgetsEnabled) + } + } + + private func rateLimitInfo(record: ExtensionRateLimitRecord) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Recent Activity (last 5 minutes)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 20) { + if !record.activityTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Live Activities") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.activityTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } + + if !record.widgetTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Widget Updates") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.widgetTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } + } + + Button("Reset Rate Limits") { + authManager.resetRateLimits(for: entry.bundleIdentifier) + } + .font(.caption) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private var actionButtons: some View { + HStack(spacing: 8) { + switch entry.status { + case .pending: + Button("Authorize") { + authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button("Deny") { + authManager.deny(bundleIdentifier: entry.bundleIdentifier, reason: "Denied by user") + } + .buttonStyle(.bordered) + .controlSize(.small) + + case .authorized: + Button("Revoke Access") { + authManager.revoke(bundleIdentifier: entry.bundleIdentifier, reason: "Revoked by user") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.orange) + + case .denied, .revoked: + Button("Re-authorize") { + authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + Spacer() + + Button("Remove") { + onRemove() + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + } + } + + private func infoRow(label: String, value: String) -> some View { + HStack { + Text("\(label):") + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.caption) + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: .now) + } +} + +#Preview { + ExtensionsSettingsView() +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 7253a29..cf04058 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -24,6 +24,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case lockScreen case media case devices + case extensions case timer case calendar case hudAndOSD @@ -48,6 +49,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .lockScreen: return "Lock Screen" case .media: return "Media" case .devices: return "Devices" + case .extensions: return "Extensions" case .timer: return "Timer" case .calendar: return "Calendar" case .hudAndOSD: return "Controls" @@ -72,6 +74,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .lockScreen: return "lock.laptopcomputer" case .media: return "play.laptopcomputer" case .devices: return "headphones" + case .extensions: return "puzzlepiece.extension" case .timer: return "timer" case .calendar: return "calendar" case .hudAndOSD: return "dial.medium.fill" @@ -96,6 +99,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .lockScreen: return .orange case .media: return .green case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12) + case .extensions: return Color(red: 0.557, green: 0.353, blue: 0.957) case .timer: return .red case .calendar: return .cyan case .hudAndOSD: return .indigo @@ -386,6 +390,7 @@ struct SettingsView: View { .lockScreen, .media, .devices, + .extensions, .timer, .calendar, .hudAndOSD, @@ -689,6 +694,13 @@ struct SettingsView: View { SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")), SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")), + // Extensions + SettingsSearchEntry(tab: .extensions, title: "Enable third-party extensions", keywords: ["extensions", "authorization", "third party"], highlightID: SettingsTab.extensions.highlightID(for: "Enable third-party extensions")), + SettingsSearchEntry(tab: .extensions, title: "Allow extension live activities", keywords: ["extensions", "live activities", "permissions"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension live activities")), + SettingsSearchEntry(tab: .extensions, title: "Allow extension lock screen widgets", keywords: ["extensions", "lock screen", "widgets"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension lock screen widgets")), + SettingsSearchEntry(tab: .extensions, title: "Enable extension diagnostics logging", keywords: ["extensions", "diagnostics", "logging"], highlightID: SettingsTab.extensions.highlightID(for: "Enable extension diagnostics logging")), + SettingsSearchEntry(tab: .extensions, title: "Manage app permissions", keywords: ["extensions", "permissions", "apps"], highlightID: SettingsTab.extensions.highlightID(for: "App permissions list")), + // Shortcuts SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"], highlightID: SettingsTab.shortcuts.highlightID(for: "Enable global keyboard shortcuts")), @@ -766,6 +778,10 @@ struct SettingsView: View { SettingsForm(tab: .devices) { DevicesSettingsView() } + case .extensions: + SettingsForm(tab: .extensions) { + ExtensionsSettingsView() + } case .timer: SettingsForm(tab: .timer) { TimerSettings() diff --git a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift new file mode 100644 index 0000000..78f6533 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift @@ -0,0 +1,53 @@ +import Foundation +import Defaults +import AtollExtensionKit +import SwiftUI + +struct ExtensionLiveActivityPayload: Identifiable, Hashable { + let bundleIdentifier: String + let descriptor: AtollLiveActivityDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } +} + +struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable { + let bundleIdentifier: String + let descriptor: AtollLockScreenWidgetDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } +} + +enum ExtensionValidationError: LocalizedError, Equatable { + case featureDisabled + case unauthorized + case invalidDescriptor(String) + case rateLimited + case exceedsCapacity + case duplicateIdentifier + case unsupportedContent + + var errorDescription: String? { + switch self { + case .featureDisabled: + return "Extensions feature is currently disabled in Atoll." + case .unauthorized: + return "Your app is not authorized to post this content." + case .invalidDescriptor(let reason): + return "Descriptor validation failed: \(reason)." + case .rateLimited: + return "Too many requests in a short period. Please slow down." + case .exceedsCapacity: + return "Atoll reached its limit for simultaneous extension content." + case .duplicateIdentifier: + return "An item with the same identifier already exists." + case .unsupportedContent: + return "Descriptor includes unsupported content for this surface." + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift new file mode 100644 index 0000000..6f852f1 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -0,0 +1,202 @@ +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionAuthorizationManager: ObservableObject { + static let shared = ExtensionAuthorizationManager() + + @Published private(set) var entries: [ExtensionAuthorizationEntry] + @Published private(set) var rateLimitRecords: [ExtensionRateLimitRecord] + + private let persistenceQueue = DispatchQueue(label: "com.atoll.extensions.authorization", qos: .utility) + + private init() { + self.entries = Defaults[.extensionAuthorizationEntries] + self.rateLimitRecords = Defaults[.extensionRateLimitRecords] + normalizeState() + } + + // MARK: - Public API + + var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] } + var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] } + var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] } + + func updateFeatureToggles(extensionsEnabled: Bool? = nil, + liveActivitiesEnabled: Bool? = nil, + lockScreenWidgetsEnabled: Bool? = nil) { + if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled } + if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled } + if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled } + objectWillChange.send() + } + + func authorizationEntry(for bundleIdentifier: String) -> ExtensionAuthorizationEntry? { + entries.first { $0.bundleIdentifier == bundleIdentifier } + } + + func ensureEntryExists(bundleIdentifier: String, appName: String?) -> ExtensionAuthorizationEntry { + if let existing = authorizationEntry(for: bundleIdentifier) { + return existing + } + let resolvedName = appName ?? bundleIdentifier + let entry = ExtensionAuthorizationEntry( + bundleIdentifier: bundleIdentifier, + appName: resolvedName, + status: .pending, + allowedScopes: defaultScopes() + ) + entries.append(entry) + persistEntries() + return entry + } + + func authorize(bundleIdentifier: String, appName: String?, scopes: Set? = nil) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.appName = appName ?? entry.appName + entry.status = .authorized + entry.allowedScopes = scopes ?? entry.allowedScopes + entry.grantedAt = .now + entry.lastDeniedReason = nil + } + } + + func deny(bundleIdentifier: String, reason: String?) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.status = .denied + entry.lastDeniedReason = reason + } + } + + func revoke(bundleIdentifier: String, reason: String?) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.status = .revoked + entry.lastDeniedReason = reason + } + } + + func recordActivity(for bundleIdentifier: String, scope: ExtensionPermissionScope) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.lastActivityAt = .now + } + + updateRateLimitRecord(bundleIdentifier: bundleIdentifier) { record in + let now = Date() + switch scope { + case .liveActivities: + record.activityTimestamps.append(now) + case .lockScreenWidgets: + record.widgetTimestamps.append(now) + } + record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) + record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) + } + } + + func updateAllowedScopes(bundleIdentifier: String, allowedScopes: Set) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.allowedScopes = allowedScopes + } + } + + func resetRateLimits(for bundleIdentifier: String) { + rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier } + persistRateLimitRecords() + } + + func removeEntry(bundleIdentifier: String) { + entries.removeAll { $0.bundleIdentifier == bundleIdentifier } + rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier } + persistEntries() + persistRateLimitRecords() + } + + // MARK: - Validation Helpers + + func canProcessLiveActivityRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .liveActivities) else { return false } + guard areLiveActivitiesEnabled else { return false } + return true + } + + func canProcessLockScreenRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .lockScreenWidgets) else { return false } + guard areLockScreenWidgetsEnabled else { return false } + return true + } + + func recordDeniedRequest(bundleIdentifier: String, reason: String) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.lastDeniedReason = reason + entry.status = entry.status == .authorized ? .revoked : entry.status + } + } + + // MARK: - Internal State Updates + + private func updateEntry(bundleIdentifier: String, mutate: (inout ExtensionAuthorizationEntry) -> Void) { + guard let index = entries.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) else { + var entry = ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: bundleIdentifier) + mutate(&entry) + entries.removeAll { $0.bundleIdentifier == bundleIdentifier } + entries.append(entry) + persistEntries() + return + } + var entry = entries[index] + mutate(&entry) + entries[index] = entry + persistEntries() + } + + private func updateRateLimitRecord(bundleIdentifier: String, mutate: (inout ExtensionRateLimitRecord) -> Void) { + if let index = rateLimitRecords.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) { + var record = rateLimitRecords[index] + mutate(&record) + rateLimitRecords[index] = record + persistRateLimitRecords() + } else { + var newRecord = ExtensionRateLimitRecord(bundleIdentifier: bundleIdentifier) + mutate(&newRecord) + rateLimitRecords.append(newRecord) + persistRateLimitRecords() + } + } + + private func preflight(bundleIdentifier: String, scope: ExtensionPermissionScope) -> Bool { + guard isExtensionsFeatureEnabled else { return false } + guard let entry = authorizationEntry(for: bundleIdentifier) else { return false } + guard entry.isAuthorized else { return false } + guard entry.allowedScopes.contains(scope) else { return false } + return true + } + + private func flushOldTimestamps(_ timestamps: [Date]) -> [Date] { + let threshold = Date().addingTimeInterval(-60 * 5) + return timestamps.filter { $0 >= threshold } + } + + private func defaultScopes() -> Set { + Set(ExtensionPermissionScope.allCases) + } + + private func persistEntries() { + let entriesSnapshot = entries + persistenceQueue.async { + Defaults[.extensionAuthorizationEntries] = entriesSnapshot + } + } + + private func persistRateLimitRecords() { + let recordsSnapshot = rateLimitRecords + persistenceQueue.async { + Defaults[.extensionRateLimitRecords] = recordsSnapshot + } + } + + private func normalizeState() { + entries = entries.filter { !$0.bundleIdentifier.isEmpty } + rateLimitRecords = rateLimitRecords.filter { !$0.bundleIdentifier.isEmpty } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift new file mode 100644 index 0000000..293836f --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift @@ -0,0 +1,33 @@ +import Foundation +import AtollExtensionKit + +struct ExtensionDescriptorValidator { + static func validate(_ descriptor: AtollLiveActivityDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + guard descriptor.leadingIcon.isValid else { + throw ExtensionValidationError.invalidDescriptor("Invalid leading icon data") + } + if let badge = descriptor.badgeIcon { + guard badge.isValid else { + throw ExtensionValidationError.invalidDescriptor("Invalid badge icon data") + } + } + if descriptor.metadata.count > 32 { + throw ExtensionValidationError.invalidDescriptor("Metadata keys must be ≤ 32") + } + } + + static func validate(_ descriptor: AtollLockScreenWidgetDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + guard descriptor.content.count <= 12 else { + throw ExtensionValidationError.invalidDescriptor("Too many content elements") + } + guard descriptor.size.width <= 480, descriptor.size.height <= 280 else { + throw ExtensionValidationError.invalidDescriptor("Widget exceeds size limits") + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift new file mode 100644 index 0000000..9155d3e --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -0,0 +1,90 @@ +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionLiveActivityManager: ObservableObject { + static let shared = ExtensionLiveActivityManager() + + @Published private(set) var activeActivities: [ExtensionLiveActivityPayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionLiveActivityCapacity + + private init() {} + + func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else { + throw ExtensionValidationError.unauthorized + } + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard activeActivities.count < Defaults[maxCapacityKey] else { + throw ExtensionValidationError.exceedsCapacity + } + guard activeActivities.contains(where: { $0.descriptor.id == descriptor.id }) == false else { + throw ExtensionValidationError.duplicateIdentifier + } + + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeActivities.append(payload) + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + } + + func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing activity") + } + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeActivities[index].receivedAt + ) + activeActivities[index] = payload + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + } + + func dismiss(activityID: String, bundleIdentifier: String) { + let previousCount = activeActivities.count + activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier } + if previousCount != activeActivities.count { + Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions) + ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) + } + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeActivities + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier } + ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) } + } + + func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] { + activeActivities + .filter { coexistence ? $0.descriptor.allowsMusicCoexistence : true } + .sorted(by: descriptorComparator) + } + + private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool { + if lhs.descriptor.priority == rhs.descriptor.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.descriptor.priority > rhs.descriptor.priority + } + + private func sortActivities() { + activeActivities.sort(by: descriptorComparator) + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift new file mode 100644 index 0000000..af24df5 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift @@ -0,0 +1,87 @@ +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionLockScreenWidgetManager: ObservableObject { + static let shared = ExtensionLockScreenWidgetManager() + + @Published private(set) var activeWidgets: [ExtensionLockScreenWidgetPayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity + private var presentationController: ExtensionLockScreenWidgetPresentationController! + + private init() { + presentationController = ExtensionLockScreenWidgetPresentationController(manager: self) + presentationController.activate() + } + + func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else { + throw ExtensionValidationError.unauthorized + } + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard activeWidgets.count < Defaults[maxCapacityKey] else { + throw ExtensionValidationError.exceedsCapacity + } + guard activeWidgets.contains(where: { $0.descriptor.id == descriptor.id }) == false else { + throw ExtensionValidationError.duplicateIdentifier + } + + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeWidgets.append(payload) + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + } + + func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing widget") + } + + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeWidgets[index].receivedAt + ) + activeWidgets[index] = payload + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + } + + func dismiss(widgetID: String, bundleIdentifier: String) { + let previousCount = activeWidgets.count + activeWidgets.removeAll { $0.descriptor.id == widgetID && $0.bundleIdentifier == bundleIdentifier } + if previousCount != activeWidgets.count { + Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions) + ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID) + } + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeWidgets + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier } + ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) } + } + + private func sortWidgets() { + activeWidgets.sort { lhs, rhs in + if lhs.priority == rhs.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.priority > rhs.priority + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift new file mode 100644 index 0000000..b877dc6 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift @@ -0,0 +1,248 @@ +import SwiftUI +import Combine +import Defaults +import AppKit +import SkyLightWindow +import AtollExtensionKit + +@MainActor +final class ExtensionLockScreenWidgetPresentationController { + private unowned let manager: ExtensionLockScreenWidgetManager + private var cancellables: Set = [] + private let windowPool = ExtensionLockScreenWidgetWindowPool() + private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = [] + private var isLocked: Bool = false + + init(manager: ExtensionLockScreenWidgetManager) { + self.manager = manager + } + + func activate() { + observeLockState() + observePayloads() + observeDefaults() + } + + private func observeLockState() { + LockScreenManager.shared.$isLocked + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] locked in + guard let self else { return } + self.isLocked = locked + self.refreshPresentation() + } + .store(in: &cancellables) + } + + private func observePayloads() { + manager.$activeWidgets + .receive(on: RunLoop.main) + .sink { [weak self] payloads in + guard let self else { return } + self.cachedPayloads = payloads + self.refreshPresentation() + } + .store(in: &cancellables) + } + + private func observeDefaults() { + Defaults.publisher(.enableExtensionLockScreenWidgets, options: []) + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self else { return } + if change.newValue { + self.refreshPresentation() + } else { + self.windowPool.hideAll() + } + } + .store(in: &cancellables) + } + + private func refreshPresentation() { + guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else { + windowPool.hideAll() + return + } + guard cachedPayloads.isEmpty == false else { + windowPool.hideAll() + return + } + windowPool.sync(with: cachedPayloads) + } +} + +@MainActor +private final class ExtensionLockScreenWidgetWindowPool { + private struct WindowRecord { + let window: NSWindow + } + + private var windows: [String: WindowRecord] = [:] + private var payloadsByID: [String: ExtensionLockScreenWidgetPayload] = [:] + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + + init() { + registerScreenChangeObservers() + } + + deinit { + if let screenChangeObserver { + NotificationCenter.default.removeObserver(screenChangeObserver) + } + let workspaceCenter = NSWorkspace.shared.notificationCenter + workspaceObservers.forEach { workspaceCenter.removeObserver($0) } + } + + func sync(with payloads: [ExtensionLockScreenWidgetPayload]) { + payloadsByID = Dictionary(uniqueKeysWithValues: payloads.map { ($0.id, $0) }) + let activeIDs = Set(payloadsByID.keys) + let staleIDs = Set(windows.keys).subtracting(activeIDs) + staleIDs.forEach { hideWindow(withID: $0) } + payloads.forEach { renderWindow(for: $0) } + } + + func hideAll() { + payloadsByID.removeAll() + windows.values.forEach { record in + record.window.orderOut(nil) + record.window.contentView = nil + } + windows.removeAll() + } + + private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) { + guard let screen = currentScreen() else { return } + let descriptor = payload.descriptor + let window = ensureWindow(for: payload.id) + let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload)) + hosting.frame = NSRect(origin: .zero, size: descriptor.size) + hosting.wantsLayer = true + hosting.layer?.masksToBounds = true + hosting.layer?.cornerRadius = descriptor.cornerRadius + window.contentView = hosting + window.setFrame(frame(for: descriptor, on: screen), display: true) + window.alphaValue = 1 + window.orderFrontRegardless() + } + + private func ensureWindow(for id: String) -> NSWindow { + if let record = windows[id] { + return record.window + } + + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: CGSize(width: 200, height: 80)), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + window.isReleasedWhenClosed = false + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + window.ignoresMouseEvents = true + window.isMovable = false + + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + SkyLightOperator.shared.delegateWindow(window) + + let record = WindowRecord(window: window) + windows[id] = record + return window + } + + private func hideWindow(withID id: String) { + guard let record = windows[id] else { return } + record.window.orderOut(nil) + record.window.contentView = nil + windows.removeValue(forKey: id) + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + + workspaceObservers = [wakeObserver] + } + + private func handleScreenGeometryChange(reason: String) { + guard windows.isEmpty == false else { return } + repositionAll(animated: false) + print("ExtensionLockScreenWidgetWindowPool: realigned windows due to \(reason)") + } + + private func repositionAll(animated: Bool) { + guard let screen = currentScreen() else { return } + for (id, record) in windows { + guard let payload = payloadsByID[id] else { continue } + let targetFrame = frame(for: payload.descriptor, on: screen) + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + record.window.animator().setFrame(targetFrame, display: true) + } + } else { + record.window.setFrame(targetFrame, display: true) + } + } + } + + private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect { + let size = descriptor.size + let safeInsets = NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48) + let screenFrame = screen.frame + + let minX = screenFrame.minX + safeInsets.left + let maxX = screenFrame.maxX - safeInsets.right - size.width + let minY = screenFrame.minY + safeInsets.bottom + let maxY = screenFrame.maxY - safeInsets.top - size.height + + var originX: CGFloat + switch descriptor.position.alignment { + case .leading: + originX = minX + case .center: + originX = screenFrame.midX - (size.width / 2) + case .trailing: + originX = maxX + } + + originX += descriptor.position.horizontalOffset + originX = clamp(originX, lower: min(minX, maxX), upper: max(minX, maxX)) + + var originY = screenFrame.midY - (size.height / 2) + descriptor.position.verticalOffset + originY = clamp(originY, lower: min(minY, maxY), upper: max(minY, maxY)) + + return NSRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + guard lower <= upper else { return value } + return min(max(value, lower), upper) + } + + private func currentScreen() -> NSScreen? { + LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 15a711e..16e58d2 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -8,6 +8,7 @@ import SwiftUI import Defaults import Lottie +import Foundation private let availableDirectories = FileManager .default @@ -97,6 +98,86 @@ enum AnimationSource: Codable, Hashable, Equatable { } } +// MARK: - Extension Authorization Models + +enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable { + case liveActivities + case lockScreenWidgets + + var displayName: String { + switch self { + case .liveActivities: return "Live Activities" + case .lockScreenWidgets: return "Lock Screen Widgets" + } + } +} + +enum ExtensionAuthorizationStatus: String, CaseIterable, Codable, Defaults.Serializable { + case pending + case authorized + case denied + case revoked + + var isActive: Bool { + switch self { + case .authorized: return true + case .pending, .denied, .revoked: return false + } + } +} + +struct ExtensionAuthorizationEntry: Codable, Defaults.Serializable, Identifiable, Hashable { + let bundleIdentifier: String + var appName: String + var status: ExtensionAuthorizationStatus + var allowedScopes: Set + var requestedAt: Date + var grantedAt: Date? + var lastActivityAt: Date? + var lastDeniedReason: String? + var notes: String? + + var id: String { bundleIdentifier } + + init( + bundleIdentifier: String, + appName: String, + status: ExtensionAuthorizationStatus, + allowedScopes: Set = Set(ExtensionPermissionScope.allCases), + requestedAt: Date = .now, + grantedAt: Date? = nil, + lastActivityAt: Date? = nil, + lastDeniedReason: String? = nil, + notes: String? = nil + ) { + self.bundleIdentifier = bundleIdentifier + self.appName = appName + self.status = status + self.allowedScopes = allowedScopes + self.requestedAt = requestedAt + self.grantedAt = grantedAt + self.lastActivityAt = lastActivityAt + self.lastDeniedReason = lastDeniedReason + self.notes = notes + } + + var isAuthorized: Bool { status.isActive } +} + +struct ExtensionRateLimitRecord: Codable, Defaults.Serializable, Hashable, Identifiable { + let bundleIdentifier: String + var activityTimestamps: [Date] + var widgetTimestamps: [Date] + + var id: String { bundleIdentifier } + + init(bundleIdentifier: String, activityTimestamps: [Date] = [], widgetTimestamps: [Date] = []) { + self.bundleIdentifier = bundleIdentifier + self.activityTimestamps = activityTimestamps + self.widgetTimestamps = widgetTimestamps + } +} + enum CalendarSelectionState: Codable, Defaults.Serializable { case all case selected(Set) @@ -714,6 +795,16 @@ extension Defaults.Keys { static let selectedAIModel = Key("selectedAIModel", default: nil) static let enableThinkingMode = Key("enableThinkingMode", default: false) static let localModelEndpoint = Key("localModelEndpoint", default: "http://localhost:11434") + + // MARK: Third-Party Extensions + static let enableThirdPartyExtensions = Key("enableThirdPartyExtensions", default: true) + static let enableExtensionLiveActivities = Key("enableExtensionLiveActivities", default: true) + static let enableExtensionLockScreenWidgets = Key("enableExtensionLockScreenWidgets", default: true) + static let extensionAuthorizationEntries = Key<[ExtensionAuthorizationEntry]>("extensionAuthorizationEntries", default: []) + static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: []) + static let extensionDiagnosticsLoggingEnabled = Key("extensionDiagnosticsLoggingEnabled", default: true) + static let extensionLiveActivityCapacity = Key("extensionLiveActivityCapacity", default: 4) + static let extensionLockScreenWidgetCapacity = Key("extensionLockScreenWidgetCapacity", default: 4) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/services/Extensions/ExtensionXPCService.swift b/DynamicIsland/services/Extensions/ExtensionXPCService.swift new file mode 100644 index 0000000..60d5b0c --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionXPCService.swift @@ -0,0 +1,159 @@ +import AppKit +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { + private let bundleIdentifier: String + private weak var host: ExtensionXPCServiceHost? + private weak var connection: NSXPCConnection? + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let liveActivityManager = ExtensionLiveActivityManager.shared + private let widgetManager = ExtensionLockScreenWidgetManager.shared + private let decoder = JSONDecoder() + + init(bundleIdentifier: String, host: ExtensionXPCServiceHost, connection: NSXPCConnection) { + self.bundleIdentifier = bundleIdentifier + self.host = host + self.connection = connection + super.init() + } + + // MARK: Authorization + + func requestAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + guard authorizationManager.isExtensionsFeatureEnabled else { + reply(false, ExtensionValidationError.featureDisabled.asNSError) + return + } + + 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 + } + + let isAuthorized = authorizationManager.authorizationEntry(for: 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) + try ExtensionDescriptorValidator.validate(descriptor) + try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func dismissLiveActivity(activityID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: 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) + try ExtensionDescriptorValidator.validate(descriptor) + try widgetManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try widgetManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func dismissLockScreenWidget(widgetID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bundleIdentifier) + reply(true, nil) + } + + // MARK: Diagnostics + + func getVersion(reply: @escaping (String) -> Void) { + 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) + } + 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) + reply(false, error.asNSError) + return false + } + return true + } + + private func resolvedApplicationName() -> String { + guard let processIdentifier = connection?.processIdentifier, + processIdentifier != 0, + let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)), + let name = app.localizedName else { + return bundleIdentifier + } + return name + } +} + +private enum ExtensionXPCServiceError: LocalizedError { + case bundleMismatch(expected: String, received: String) + + var errorDescription: String? { + switch self { + case let .bundleMismatch(expected, received): + return "Bundle identifier mismatch. Expected \(expected) but received \(received)." + } + } +} + +private extension Error { + var asNSError: NSError { self as NSError } +} diff --git a/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift new file mode 100644 index 0000000..670699e --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift @@ -0,0 +1,131 @@ +import AppKit +import Foundation +import AtollExtensionKit + +/// Shared constants for the Atoll extension XPC service. +enum ExtensionXPCServiceConstants { + static let machServiceName = "com.ebullioscopic.Atoll.xpc" +} + +@MainActor +final class ExtensionXPCServiceHost: NSObject, NSXPCListenerDelegate { + static let shared = ExtensionXPCServiceHost() + + private final class ClientContext { + weak var connection: NSXPCConnection? + let bundleIdentifier: String + + init(connection: NSXPCConnection, bundleIdentifier: String) { + self.connection = connection + self.bundleIdentifier = bundleIdentifier + } + } + + private var listener: NSXPCListener? + private var clientContexts: [ObjectIdentifier: ClientContext] = [:] + + func start() { + guard listener == nil else { return } + + let listener = NSXPCListener(machServiceName: ExtensionXPCServiceConstants.machServiceName) + listener.delegate = self + self.listener = listener + listener.resume() + + Logger.log("Started Atoll XPC listener", category: .extensions) + } + + func stop() { + listener?.invalidate() + listener = nil + clientContexts.removeAll() + Logger.log("Stopped Atoll XPC listener", category: .extensions) + } + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + guard let bundleIdentifier = resolveBundleIdentifier(for: connection) else { + Logger.log("Rejected XPC connection without bundle identifier", category: .extensions) + return false + } + + let service = ExtensionXPCService(bundleIdentifier: bundleIdentifier, host: self, connection: connection) + connection.exportedInterface = NSXPCInterface(with: AtollXPCServiceProtocol.self) + connection.exportedObject = service + connection.remoteObjectInterface = NSXPCInterface(with: AtollXPCClientProtocol.self) + + connection.invalidationHandler = { [weak self, weak connection] in + guard let connection else { return } + Task { @MainActor [weak self] in + self?.removeConnection(connection) + } + } + + connection.interruptionHandler = { [weak self, weak connection] in + guard let connection else { return } + Task { @MainActor [weak self] in + self?.removeConnection(connection) + } + } + + connection.resume() + clientContexts[ObjectIdentifier(connection)] = ClientContext(connection: connection, bundleIdentifier: bundleIdentifier) + Logger.log("Accepted XPC connection from \(bundleIdentifier)", category: .extensions) + return true + } + + func notifyAuthorizationChange(bundleIdentifier: String, isAuthorized: Bool) { + deliver(to: bundleIdentifier) { client in + client.authorizationDidChange(isAuthorized: isAuthorized) + } + } + + func notifyActivityDismiss(bundleIdentifier: String, activityID: String) { + deliver(to: bundleIdentifier) { client in + client.activityDidDismiss(activityID: activityID) + } + } + + func notifyWidgetDismiss(bundleIdentifier: String, widgetID: String) { + deliver(to: bundleIdentifier) { client in + client.widgetDidDismiss(widgetID: widgetID) + } + } + + private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? { + let processIdentifier = connection.processIdentifier + guard processIdentifier != 0 else { return nil } + + if let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)), + let bundleIdentifier = app.bundleIdentifier { + return bundleIdentifier + } + + return nil + } + + private func deliver(to bundleIdentifier: String, send block: (AtollXPCClientProtocol) -> Void) { + var staleIdentifiers: [ObjectIdentifier] = [] + + for (identifier, context) in clientContexts where context.bundleIdentifier == bundleIdentifier { + guard let connection = context.connection else { + staleIdentifiers.append(identifier) + continue + } + + guard let client = connection.remoteObjectProxyWithErrorHandler({ error in + Logger.log("Failed to deliver XPC callback to \(bundleIdentifier): \(error)", category: .extensions) + }) as? AtollXPCClientProtocol else { + continue + } + + block(client) + } + + staleIdentifiers.forEach { clientContexts.removeValue(forKey: $0) } + } + + private func removeConnection(_ connection: NSXPCConnection) { + clientContexts.removeValue(forKey: ObjectIdentifier(connection)) + Logger.log("Removed XPC connection", category: .extensions) + } +} diff --git a/DynamicIsland/utils/Logger.swift b/DynamicIsland/utils/Logger.swift index e95cdba..5a50e34 100644 --- a/DynamicIsland/utils/Logger.swift +++ b/DynamicIsland/utils/Logger.swift @@ -11,6 +11,7 @@ enum LogCategory: String { case warning = "⚠️" case success = "✅" case debug = "🔍" + case extensions = "🧩" } struct Logger {