diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 80701d0..ab7b105 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186" + "revision" : "a1303629e532337594787b981b499560a99c6c93" } }, { diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 2c5f857..2490de2 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -36,6 +36,7 @@ struct ContentView: View { @ObservedObject var lockScreenManager = LockScreenManager.shared @ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared + @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -77,6 +78,19 @@ struct ContentView: View { let resolvedHeight = max(baseSize.height, preferredHeight) return CGSize(width: baseSize.width, height: resolvedHeight) } + + if coordinator.currentView == .extensionExperience { + if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) { + return CGSize(width: baseSize.width, height: preferredHeight) + } + return baseSize + } + + if enableMinimalisticUI, + coordinator.currentView == .home, + let preferredHeight = extensionMinimalisticPreferredHeight(baseSize: baseSize) { + return CGSize(width: baseSize.width, height: preferredHeight) + } guard coordinator.currentView == .stats else { return baseSize @@ -557,17 +571,17 @@ struct ContentView: View { isHovering: isHovering, gestureProgress: gestureProgress ) - .transition(.move(edge: .trailing).combined(with: .opacity)) + .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( coordinator.sneakPeek.type == .capsLock - ? .move(edge: .trailing).combined(with: .opacity) - : .opacity + ? AnyTransition.move(edge: .trailing).combined(with: .opacity) + : AnyTransition.opacity ) } else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress) - .transition(.move(edge: .trailing).combined(with: .opacity)) + .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) } else if canShowMusicDuringExpansion && musicPairingEligible { MusicLiveActivity(secondary: musicSecondary) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { @@ -596,8 +610,7 @@ struct ContentView: View { ExtensionLiveActivityStandaloneView( payload: extensionPayload, layout: layout, - isHovering: isHovering, - notchState: vm.notchState + isHovering: isHovering ) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) @@ -712,6 +725,12 @@ struct ContentView: View { NotchNotesView() case .clipboard: NotchNotesView() + case .extensionExperience: + if let payload = currentExtensionTabPayload() { + ExtensionNotchExperienceTabView(payload: payload) + } else { + NotchHomeView(albumArtNamespace: albumArtNamespace) + } } } .transition(.asymmetric( @@ -1467,6 +1486,72 @@ struct ContentView: View { if count == 0 { return 0 } return count <= 3 ? 1 : 2 } + + private func currentExtensionTabPayload() -> ExtensionNotchExperiencePayload? { + guard Defaults[.enableThirdPartyExtensions], + Defaults[.enableExtensionNotchExperiences], + Defaults[.enableExtensionNotchTabs] else { + return nil + } + if let selectedID = coordinator.selectedExtensionExperienceID, + let payload = extensionNotchExperienceManager.payload(experienceID: selectedID) { + return payload + } + return extensionNotchExperienceManager.highestPriorityTabPayload() + } + + private func extensionTabPreferredHeight(baseSize: CGSize) -> CGFloat? { + guard let preferred = currentExtensionTabPayload()?.descriptor.tab?.preferredHeight else { + return nil + } + let minHeight = baseSize.height + let maxHeight = baseSize.height + statsAdditionalRowHeight + return min(max(preferred, minHeight), maxHeight) + } + + // Estimate the height required for minimalistic overrides (notably web content) and clamp it to the notch bounds. + private func extensionMinimalisticPreferredHeight(baseSize: CGSize) -> CGFloat? { + guard let configuration = extensionNotchExperienceManager.minimalisticReplacementPayload()?.descriptor.minimalistic else { + return nil + } + + let minHeight = baseSize.height + let maxHeight = baseSize.height + statsAdditionalRowHeight + + var contentHeight: CGFloat = 0 + var blockCount = 0 + + if configuration.headline != nil { + contentHeight += 24 + blockCount += 1 + } + + if configuration.subtitle != nil { + contentHeight += 20 + blockCount += 1 + } + + if !configuration.sections.isEmpty { + let sectionEstimate: CGFloat = 98 + contentHeight += CGFloat(configuration.sections.count) * sectionEstimate + blockCount += configuration.sections.count + } + + if let webDescriptor = configuration.webContent { + contentHeight += webDescriptor.preferredHeight + blockCount += 1 + } + + guard blockCount > 0 else { return nil } + + let spacingAllowance = CGFloat(max(blockCount - 1, 0)) * 16 + let topPadding: CGFloat = 10 + let bottomPadding: CGFloat = configuration.webContent == nil ? 10 : 0 + let estimatedHeight = contentHeight + spacingAllowance + topPadding + bottomPadding + + let clampedHeight = min(max(estimatedHeight, minHeight), maxHeight) + return clampedHeight > minHeight ? clampedHeight : nil + } // MARK: - Gesture Handling diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 873e996..c3ec73b 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -97,6 +97,7 @@ class DynamicIslandViewCoordinator: ObservableObject { private let statsSecondRowRevealDelay: TimeInterval = 0.5 private let statsSecondRowAnimationDuration: TimeInterval = 0.3 @Published var notesLayoutState: NotesLayoutState = .list + @Published var selectedExtensionExperienceID: String? @AppStorage("firstLaunch") var firstLaunch: Bool = true @@ -138,6 +139,7 @@ class DynamicIslandViewCoordinator: ObservableObject { @Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown" @Published var optionKeyPressed: Bool = true + private let extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared private init() { selectedScreen = preferredScreen @@ -161,6 +163,36 @@ class DynamicIslandViewCoordinator: ObservableObject { self?.handleMinimalisticModeChange(change.newValue) } .store(in: &cancellables) + + extensionNotchExperienceManager.$activeExperiences + .receive(on: DispatchQueue.main) + .sink { [weak self] experiences in + self?.handleExtensionExperienceSnapshot(experiences) + } + .store(in: &cancellables) + + Defaults.publisher(.enableThirdPartyExtensions) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + Defaults.publisher(.enableExtensionNotchExperiences) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + Defaults.publisher(.enableExtensionNotchTabs) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences) } private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { @@ -206,6 +238,45 @@ class DynamicIslandViewCoordinator: ObservableObject { } } } + + private func handleExtensionExperienceSnapshot(_ experiences: [ExtensionNotchExperiencePayload]) { + guard extensionTabsAllowed else { + selectedExtensionExperienceID = nil + resetExtensionViewIfNeeded() + return + } + + let tabCapablePayloads = experiences.filter { $0.descriptor.tab != nil } + guard !tabCapablePayloads.isEmpty else { + selectedExtensionExperienceID = nil + resetExtensionViewIfNeeded() + return + } + + if let currentID = selectedExtensionExperienceID, + tabCapablePayloads.contains(where: { $0.descriptor.id == currentID }) { + return + } + + selectedExtensionExperienceID = tabCapablePayloads.first?.descriptor.id + } + + private func handleExtensionFeatureToggle() { + handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences) + } + + private func resetExtensionViewIfNeeded() { + guard currentView == .extensionExperience else { return } + withAnimation(.smooth) { + currentView = .home + } + } + + private var extensionTabsAllowed: Bool { + Defaults[.enableThirdPartyExtensions] + && Defaults[.enableExtensionNotchExperiences] + && Defaults[.enableExtensionNotchTabs] + } func toggleSneakPeek( status: Bool, diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 83d66c5..4b14356 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -8777,6 +8777,10 @@ "comment" : "A label describing the expanded notch width setting.", "isCommentAutoGenerated" : true }, + "Extension tab unavailable" : { + "comment" : "A message displayed when the extension tab is not available.", + "isCommentAutoGenerated" : true + }, "Extensions" : { "localizations" : { "ar" : { @@ -14720,6 +14724,10 @@ } } }, + "Notch Experiences" : { + "comment" : "A toggle that enables or disables the \"Notch Experiences\" feature for a specific app.", + "isCommentAutoGenerated" : true + }, "Notch Height" : { "localizations" : { "ar" : { @@ -15973,7 +15981,7 @@ "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." : { + "Remove %@ from the authorized extensions list? This will dismiss all active live activities, lock screen widgets, and notch experiences 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 }, @@ -16021,6 +16029,10 @@ "comment" : "A menu item that resets all lock screen widgets for a particular app.", "isCommentAutoGenerated" : true }, + "Reset Notch Experiences" : { + "comment" : "A menu item that resets all notch experiences for a particular app.", + "isCommentAutoGenerated" : true + }, "Reset Rate Limits" : { "comment" : "A button that resets the rate limits for a particular extension.", "isCommentAutoGenerated" : true @@ -18984,8 +18996,8 @@ "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.", + "Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below." : { + "comment" : "A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view.", "isCommentAutoGenerated" : true }, "This duration powers the \"Custom\" option inside the timer popover for quick access." : { diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 55fcbc8..3789054 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -16,7 +16,6 @@ struct ExtensionLiveActivityStandaloneView: View { let payload: ExtensionLiveActivityPayload let layout: ExtensionStandaloneLayout let isHovering: Bool - let notchState: NotchState private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var contentHeight: CGFloat { layout.contentHeight } @@ -35,10 +34,6 @@ struct ExtensionLiveActivityStandaloneView: View { } } - private var shouldSuppressCenterText: Bool { - layout.suppressingCenterText - } - var body: some View { HStack(spacing: 0) { ExtensionLeadingContentView( @@ -56,7 +51,9 @@ struct ExtensionLiveActivityStandaloneView: View { .frame(width: layout.centerWidth, height: contentHeight) .overlay( Group { - if !shouldSuppressCenterText { + if layout.suppressingCenterText { + EmptyView() + } else { ExtensionCenterContentView( descriptor: descriptor, accent: accentColor, @@ -75,10 +72,12 @@ struct ExtensionLiveActivityStandaloneView: View { .frame(width: layout.trailingWidth, height: contentHeight) } .frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0)) - .transition(.asymmetric( - insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)), - removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9)) - )) + .transition( + .asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)), + removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9)) + ) + ) .animation(.smooth(duration: 0.25), value: payload.id) .onAppear { logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view") @@ -106,7 +105,7 @@ struct ExtensionMusicWingView: View { switch trailingRenderable { case let .content(content): if case .none = content { - EmptyView() + Spacer(minLength: 0) } else { ExtensionEdgeContentView( content: content, @@ -126,7 +125,7 @@ struct ExtensionMusicWingView: View { ) .frame(maxWidth: .infinity, alignment: .trailing) } - + Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) @@ -239,10 +238,152 @@ private func logExtensionDiagnostics(_ message: String) { Logger.log(message, category: .extensions) } +struct ExtensionNotchExperienceTabView: View { + let payload: ExtensionNotchExperiencePayload + + @Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled + + private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor } + private var tabConfiguration: AtollNotchExperienceDescriptor.TabConfiguration? { descriptor.tab } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var allowInteractiveWebViews: Bool { + interactiveWebViewsEnabled && (tabConfiguration?.allowWebInteraction ?? false) + } + + var body: some View { + Group { + if let tabConfiguration { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + header(for: tabConfiguration) + ForEach(Array(tabConfiguration.sections.enumerated()), id: \.offset) { index, section in + ExtensionNotchSectionView( + section: section, + accent: accentColor, + allowWebInteraction: allowInteractiveWebViews + ) + .accessibilityIdentifier("extension-notch-section-\(payload.descriptor.id)-\(index)") + } + if let webDescriptor = tabConfiguration.webContent { + ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowInteractiveWebViews) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + if let footnote = tabConfiguration.footnote { + Text(footnote) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.65)) + .lineLimit(2) + } + } + .padding(.vertical, 14) + .padding(.horizontal, 16) + } + } else { + Text("Extension tab unavailable") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(tabBackground) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + + @ViewBuilder + private func header(for configuration: AtollNotchExperienceDescriptor.TabConfiguration) -> some View { + HStack(spacing: 10) { + Group { + if let badgeIcon = configuration.badgeIcon { + ExtensionIconView( + descriptor: badgeIcon, + tint: accentColor, + size: CGSize(width: 32, height: 32), + cornerRadius: 10 + ) + } else { + Image(systemName: configuration.iconSymbolName ?? "puzzlepiece.extension") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(accentColor.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(configuration.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + } + Spacer(minLength: 0) + } + } + + private var tabBackground: some View { + AnyView( + LinearGradient( + colors: [Color.white.opacity(0.04), accentColor.opacity(0.08)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } +} + +struct ExtensionNotchSectionView: View { + let section: AtollNotchContentSection + let accent: Color + let allowWebInteraction: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ExtensionNotchSectionHeader(section: section) + layoutContent + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + @ViewBuilder + private var layoutContent: some View { + switch section.layout { + case .stack: + VStack(alignment: .leading, spacing: 10) { + elementViews + } + case .columns: + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 12) { + elementViews + } + case .metrics: + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + elementViews + } + } + } + + @ViewBuilder + private var elementViews: some View { + ForEach(Array(section.elements.enumerated()), id: \.offset) { index, element in + ExtensionWidgetElementView( + element: element, + accent: accent, + allowWebInteraction: allowWebInteraction + ) + .accessibilityIdentifier("extension-notch-element-\(index)") + } + } +} + struct ExtensionInlineSneakPeekView: View { let payload: ExtensionLiveActivityPayload? let title: String - let subtitle: String + let subtitle: String? let accentColor: Color let notchHeight: CGFloat let closedNotchWidth: CGFloat @@ -251,7 +392,6 @@ struct ExtensionInlineSneakPeekView: View { private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor } private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor } - private var contentHeight: CGFloat { max(0, notchHeight - (isHovering ? 0 : 12)) } @@ -274,11 +414,19 @@ struct ExtensionInlineSneakPeekView: View { } private var resolvedTitle: String { - title.isEmpty ? "Extension" : title + let descriptorTitle = descriptor?.title ?? "Extension" + let preferred = title.isEmpty ? descriptorTitle : title + return preferred.isEmpty ? "Extension" : preferred } private var resolvedSubtitle: String { - subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + if let subtitle, !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + } + if let descriptorSubtitle = descriptor?.subtitle, !descriptorSubtitle.isEmpty { + return descriptorSubtitle + } + return "" } var body: some View { @@ -291,16 +439,14 @@ struct ExtensionInlineSneakPeekView: View { .frame(width: centerWidth, height: contentHeight) .overlay(alignment: .bottomLeading) { VStack(alignment: .leading, spacing: 2) { - if !resolvedTitle.isEmpty { - MarqueeText( - .constant(resolvedTitle), - font: .system(size: 12, weight: .semibold), - nsFont: .subheadline, - textColor: .white, - minDuration: 0.4, - frameWidth: marqueeFrameWidth - ) - } + MarqueeText( + .constant(resolvedTitle), + font: .system(size: 12, weight: .semibold), + nsFont: .subheadline, + textColor: .white, + minDuration: 0.4, + frameWidth: marqueeFrameWidth + ) if !resolvedSubtitle.isEmpty { Text(resolvedSubtitle) .font(.system(size: 11, weight: .regular)) @@ -338,11 +484,6 @@ struct ExtensionInlineSneakPeekView: View { @ViewBuilder private var trailingWing: some View { - spectrumPlaceholder - } - - @ViewBuilder - private var spectrumPlaceholder: some View { VStack { Rectangle() .fill(resolvedAccent.gradient) @@ -357,3 +498,77 @@ struct ExtensionInlineSneakPeekView: View { .padding(.vertical, 6) } } + +struct ExtensionNotchSectionHeader: View { + let section: AtollNotchContentSection + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let title = section.title { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + } + if let subtitle = section.subtitle { + Text(subtitle) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.7)) + } + } + } +} + +struct ExtensionMinimalisticExperienceView: View { + let payload: ExtensionNotchExperiencePayload + let albumArtNamespace: Namespace.ID + + @Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled + + private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor } + private var configuration: AtollNotchExperienceDescriptor.MinimalisticConfiguration? { descriptor.minimalistic } + private var accent: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + Group { + if let configuration { + let hasWebContent = configuration.webContent != nil + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if let headline = configuration.headline { + Text(headline) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + if let subtitle = configuration.subtitle { + Text(subtitle) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.75)) + } + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { index, section in + ExtensionNotchSectionView( + section: section, + accent: accent, + allowWebInteraction: interactiveWebViewsEnabled + ) + .accessibilityIdentifier("extension-minimalistic-section-\(payload.descriptor.id)-\(index)") + } + if let webDescriptor = configuration.webContent { + ExtensionWebContentView( + descriptor: webDescriptor, + allowInteraction: interactiveWebViewsEnabled + ) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, hasWebContent ? 0 : 10) + } + } else { + MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + } + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index e7722f0..c00f26b 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -52,53 +52,12 @@ struct ExtensionLockScreenWidgetView: View { } } - @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) - case let .webView(webDescriptor): - ExtensionWebContentView(descriptor: webDescriptor) - .frame(height: webDescriptor.preferredHeight) - .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) - .allowsHitTesting(false) - } + ExtensionWidgetElementView( + element: element, + accent: accentColor, + allowWebInteraction: false + ) } private func alignment(for element: AtollWidgetContentElement) -> Alignment { @@ -235,8 +194,62 @@ private func logWidgetDiagnostics(_ message: String) { Logger.log(message, category: .extensions) } -private struct ExtensionWebContentView: NSViewRepresentable { +struct ExtensionWidgetElementView: View { + let element: AtollWidgetContentElement + let accent: Color + let allowWebInteraction: Bool + + var body: 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) ?? accent, + size: CGSize(width: 28, height: 28), + cornerRadius: 8 + ) + case let .progress(indicator, value, color): + ExtensionProgressIndicatorView( + indicator: indicator, + progress: value, + accent: (color?.swiftUIColor) ?? accent, + 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) ?? accent + ) + .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) + case let .webView(webDescriptor): + ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowWebInteraction) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + } + } +} + +struct ExtensionWebContentView: NSViewRepresentable { let descriptor: AtollWidgetWebContentDescriptor + let allowInteraction: Bool func makeCoordinator() -> Coordinator { Coordinator(descriptor: descriptor) @@ -246,7 +259,8 @@ private struct ExtensionWebContentView: NSViewRepresentable { let configuration = WKWebViewConfiguration() configuration.suppressesIncrementalRendering = false configuration.preferences.javaScriptCanOpenWindowsAutomatically = false - let webView = NonInteractiveWKWebView(frame: .zero, configuration: configuration) + let webView = ConfigurableWKWebView(frame: .zero, configuration: configuration) + webView.allowInteraction = allowInteraction webView.navigationDelegate = context.coordinator webView.wantsLayer = true applyConfiguration(descriptor, to: webView) @@ -259,6 +273,9 @@ private struct ExtensionWebContentView: NSViewRepresentable { context.coordinator.descriptor = descriptor webView.navigationDelegate = context.coordinator applyConfiguration(descriptor, to: webView) + if let configurableView = webView as? ConfigurableWKWebView { + configurableView.allowInteraction = allowInteraction + } if context.coordinator.lastHTML != descriptor.html { webView.loadHTMLString(descriptor.html, baseURL: nil) context.coordinator.lastHTML = descriptor.html @@ -277,13 +294,16 @@ private struct ExtensionWebContentView: NSViewRepresentable { } } - private final class NonInteractiveWKWebView: WKWebView { + private final class ConfigurableWKWebView: WKWebView { + var allowInteraction = false + override func hitTest(_ point: NSPoint) -> NSView? { - nil + guard allowInteraction else { return nil } + return super.hitTest(point) } override var acceptsFirstResponder: Bool { - false + allowInteraction } } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 4fa9b92..53ad2d4 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -378,6 +378,7 @@ struct NotchHomeView: View { @ObservedObject var webcamManager = WebcamManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared let albumArtNamespace: Namespace.ID var body: some View { @@ -392,8 +393,14 @@ struct NotchHomeView: View { private var mainContent: some View { HStack(alignment: .top, spacing: 20) { if Defaults[.enableMinimalisticUI] { - // Minimalistic mode: Only show compact music player - MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + if let overridePayload = minimalisticOverridePayload { + ExtensionMinimalisticExperienceView( + payload: overridePayload, + albumArtNamespace: albumArtNamespace + ) + } else { + MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + } } else { // Normal mode: Show full music player with optional calendar and webcam MusicPlayerView(albumArtNamespace: albumArtNamespace) @@ -422,6 +429,10 @@ struct NotchHomeView: View { .combined(with: .move(edge: .top))) .blur(radius: vm.notchState == .closed ? 30 : 0) } + + private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? { + extensionNotchExperienceManager.minimalisticReplacementPayload() + } } struct MusicSliderView: View { diff --git a/DynamicIsland/components/Settings/ExtensionsSettings.swift b/DynamicIsland/components/Settings/ExtensionsSettings.swift index 3a8d2bc..ff4a79e 100644 --- a/DynamicIsland/components/Settings/ExtensionsSettings.swift +++ b/DynamicIsland/components/Settings/ExtensionsSettings.swift @@ -44,7 +44,7 @@ struct ExtensionsSettingsView: View { 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.") + Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities, lock screen widgets, and notch experiences from this app.") } } @@ -59,6 +59,21 @@ struct ExtensionsSettingsView: View { Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets) .settingsHighlight(id: highlightID("Allow extension lock screen widgets")) + + Defaults.Toggle("Allow extension notch experiences", key: .enableExtensionNotchExperiences) + .settingsHighlight(id: highlightID("Allow extension notch experiences")) + + if Defaults[.enableExtensionNotchExperiences] { + VStack(alignment: .leading, spacing: 8) { + Defaults.Toggle("Show extension tabs", key: .enableExtensionNotchTabs) + .tint(.accentColor) + Defaults.Toggle("Allow minimalistic overrides", key: .enableExtensionNotchMinimalisticOverrides) + .tint(.accentColor) + Defaults.Toggle("Allow interactive web content", key: .enableExtensionNotchInteractiveWebViews) + .tint(.accentColor) + } + .padding(.leading, 4) + } Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled) .settingsHighlight(id: highlightID("Enable extension diagnostics logging")) @@ -67,7 +82,7 @@ struct ExtensionsSettingsView: View { 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.") + Text("Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below.") .font(.caption) .foregroundStyle(.secondary) } else { @@ -148,6 +163,7 @@ private struct ExtensionEntryRow: View { @ObservedObject private var authManager = ExtensionAuthorizationManager.shared @ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared + @ObservedObject private var notchExperienceManager = ExtensionNotchExperienceManager.shared let entry: ExtensionAuthorizationEntry let onRemove: () -> Void @@ -275,8 +291,8 @@ private struct ExtensionEntryRow: View { } // Rate limits info - if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), - !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty { + if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), + !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty || !rateLimitRecord.notchExperienceTimestamps.isEmpty { rateLimitInfo(record: rateLimitRecord) Divider() } @@ -324,6 +340,21 @@ private struct ExtensionEntryRow: View { )) .font(.caption) .disabled(!authManager.areLockScreenWidgetsEnabled) + + Toggle("Notch Experiences", isOn: Binding( + get: { entry.allowedScopes.contains(.notchExperiences) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.notchExperiences) + } else { + newScopes.remove(.notchExperiences) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areNotchExperiencesEnabled) } } @@ -353,6 +384,16 @@ private struct ExtensionEntryRow: View { .font(.caption.monospacedDigit()) } } + + if !record.notchExperienceTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Notch Experiences") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.notchExperienceTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } } Button("Reset Rate Limits") { @@ -420,6 +461,11 @@ private struct ExtensionEntryRow: View { widgetManager.dismissAll(for: entry.bundleIdentifier) } .disabled(!hasWidgets) + + Button("Reset Notch Experiences") { + notchExperienceManager.dismissAll(for: entry.bundleIdentifier) + } + .disabled(!hasNotchExperiences) } label: { Label("Reset", systemImage: "arrow.counterclockwise.circle") } @@ -434,6 +480,10 @@ private struct ExtensionEntryRow: View { private var hasWidgets: Bool { widgetManager.activeWidgets.contains { $0.bundleIdentifier == entry.bundleIdentifier } } + + private var hasNotchExperiences: Bool { + notchExperienceManager.activeExperiences.contains { $0.bundleIdentifier == entry.bundleIdentifier } + } private func infoRow(label: String, value: String) -> some View { HStack { diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 7916761..e88e826 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -4,23 +4,38 @@ // // Created by Hugo Persson on 2024-08-25. // Modified by Hariharan Mudaliar - +import AtollExtensionKit import SwiftUI import Defaults struct TabModel: Identifiable { - let id = UUID() + let id: String let label: String let icon: String let view: NotchViews + let experienceID: String? + let accentColor: Color? + + init(label: String, icon: String, view: NotchViews, experienceID: String? = nil, accentColor: Color? = nil) { + self.id = experienceID.map { "extension-\($0)" } ?? "system-\(view)-\(label)" + self.label = label + self.icon = icon + self.view = view + self.experienceID = experienceID + self.accentColor = accentColor + } } struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableStatsFeature) var enableStatsFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature @Default(.timerDisplayMode) var timerDisplayMode + @Default(.enableThirdPartyExtensions) private var enableThirdPartyExtensions + @Default(.enableExtensionNotchExperiences) private var enableExtensionNotchExperiences + @Default(.enableExtensionNotchTabs) private var enableExtensionNotchTabs @Namespace var animation private var tabs: [TabModel] { @@ -46,35 +61,73 @@ struct TabSelectionView: View { let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) } + if extensionTabsEnabled { + for payload in extensionTabPayloads { + guard let tab = payload.descriptor.tab else { continue } + let accent = payload.descriptor.accentColor.swiftUIColor + let iconName = tab.iconSymbolName ?? "puzzlepiece.extension" + tabsArray.append( + TabModel( + label: tab.title, + icon: iconName, + view: .extensionExperience, + experienceID: payload.descriptor.id, + accentColor: accent + ) + ) + } + } return tabsArray } var body: some View { HStack(spacing: 0) { ForEach(tabs) { tab in - TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { - withAnimation(.smooth) { - coordinator.currentView = tab.view + let isSelected = isSelected(tab) + let activeAccent = tab.accentColor ?? .white + TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { + withAnimation(.smooth) { + if tab.view == .extensionExperience { + coordinator.selectedExtensionExperienceID = tab.experienceID } + coordinator.currentView = tab.view } - .frame(height: 26) - .foregroundStyle(tab.view == coordinator.currentView ? .white : .gray) - .background { - if tab.view == coordinator.currentView { - Capsule() - .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) - .matchedGeometryEffect(id: "capsule", in: animation) - } else { - Capsule() - .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) - .matchedGeometryEffect(id: "capsule", in: animation) - .hidden() - } + } + .frame(height: 26) + .foregroundStyle(isSelected ? activeAccent : .gray) + .background { + if isSelected { + Capsule() + .fill((tab.accentColor ?? Color(nsColor: .secondarySystemFill)).opacity(0.25)) + .shadow(color: (tab.accentColor ?? .clear).opacity(0.4), radius: 8) + .matchedGeometryEffect(id: "capsule", in: animation) + } else { + Capsule() + .fill(Color.clear) + .matchedGeometryEffect(id: "capsule", in: animation) + .hidden() } + } } } .clipShape(Capsule()) } + + private var extensionTabsEnabled: Bool { + enableThirdPartyExtensions && enableExtensionNotchExperiences && enableExtensionNotchTabs + } + + private var extensionTabPayloads: [ExtensionNotchExperiencePayload] { + extensionNotchExperienceManager.activeExperiences.filter { $0.descriptor.tab != nil } + } + + private func isSelected(_ tab: TabModel) -> Bool { + if tab.view == .extensionExperience { + return coordinator.currentView == .extensionExperience + && coordinator.selectedExtensionExperienceID == tab.experienceID + } + return coordinator.currentView == tab.view + } } #Preview { diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 9656bc1..ab33e05 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -33,6 +33,7 @@ public enum NotchViews { case colorPicker case notes case clipboard + case extensionExperience } enum NotesLayoutState: Equatable { diff --git a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift index 1ce692a..cb53836 100644 --- a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift +++ b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift @@ -23,6 +23,20 @@ struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable { var priority: AtollLiveActivityPriority { descriptor.priority } } +struct ExtensionNotchExperiencePayload: Identifiable, Hashable, Codable { + let bundleIdentifier: String + let descriptor: AtollNotchExperienceDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } + + var hasTabConfiguration: Bool { descriptor.tab != nil } + + var hasMinimalisticConfiguration: Bool { descriptor.minimalistic != nil } +} + enum ExtensionValidationError: LocalizedError, Equatable { case featureDisabled case unauthorized diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift index 6f852f1..43faf6f 100644 --- a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -22,13 +22,16 @@ final class ExtensionAuthorizationManager: ObservableObject { var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] } var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] } var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] } + var areNotchExperiencesEnabled: Bool { Defaults[.enableExtensionNotchExperiences] } func updateFeatureToggles(extensionsEnabled: Bool? = nil, liveActivitiesEnabled: Bool? = nil, - lockScreenWidgetsEnabled: Bool? = nil) { + lockScreenWidgetsEnabled: Bool? = nil, + notchExperiencesEnabled: Bool? = nil) { if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled } if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled } if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled } + if let notchExperiencesEnabled { Defaults[.enableExtensionNotchExperiences] = notchExperiencesEnabled } objectWillChange.send() } @@ -88,9 +91,12 @@ final class ExtensionAuthorizationManager: ObservableObject { record.activityTimestamps.append(now) case .lockScreenWidgets: record.widgetTimestamps.append(now) + case .notchExperiences: + record.notchExperienceTimestamps.append(now) } record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) + record.notchExperienceTimestamps = flushOldTimestamps(record.notchExperienceTimestamps) } } @@ -126,6 +132,12 @@ final class ExtensionAuthorizationManager: ObservableObject { return true } + func canProcessNotchExperienceRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .notchExperiences) else { return false } + guard areNotchExperiencesEnabled else { return false } + return true + } + func recordDeniedRequest(bundleIdentifier: String, reason: String) { updateEntry(bundleIdentifier: bundleIdentifier) { entry in entry.lastDeniedReason = reason diff --git a/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift index 293836f..5ae58ed 100644 --- a/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift +++ b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift @@ -30,4 +30,15 @@ struct ExtensionDescriptorValidator { throw ExtensionValidationError.invalidDescriptor("Widget exceeds size limits") } } + + static func validate(_ descriptor: AtollNotchExperienceDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + if let durationHint = descriptor.durationHint { + guard durationHint > 0, durationHint <= 21_600 else { + throw ExtensionValidationError.invalidDescriptor("Duration hint must be between 0 and 6 hours") + } + } + } } diff --git a/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift index caa226c..709a045 100644 --- a/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift +++ b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift @@ -23,6 +23,10 @@ final class ExtensionEventBridge { broadcast(payloads, channel: .lockScreenWidgets) } + func broadcastNotchExperienceSnapshot(_ payloads: [ExtensionNotchExperiencePayload]) { + broadcast(payloads, channel: .notchExperiences) + } + func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] { loadSnapshot(channel: .liveActivities) } @@ -31,6 +35,10 @@ final class ExtensionEventBridge { loadSnapshot(channel: .lockScreenWidgets) } + func loadPersistedNotchExperiences() -> [ExtensionNotchExperiencePayload] { + loadSnapshot(channel: .notchExperiences) + } + func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol { observe(channel: .liveActivities, handler: handler) } @@ -39,6 +47,10 @@ final class ExtensionEventBridge { observe(channel: .lockScreenWidgets, handler: handler) } + func observeNotchExperienceSnapshots(_ handler: @escaping ([ExtensionNotchExperiencePayload], Int32) -> Void) -> NSObjectProtocol { + observe(channel: .notchExperiences, handler: handler) + } + func removeObserver(_ observer: NSObjectProtocol) { notificationCenter.removeObserver(observer) } @@ -92,6 +104,8 @@ final class ExtensionEventBridge { return directory.appendingPathComponent("live_activities.json", isDirectory: false) case .lockScreenWidgets: return directory.appendingPathComponent("lock_screen_widgets.json", isDirectory: false) + case .notchExperiences: + return directory.appendingPathComponent("notch_experiences.json", isDirectory: false) } } @@ -120,6 +134,7 @@ final class ExtensionEventBridge { private enum Channel: CustomStringConvertible { case liveActivities case lockScreenWidgets + case notchExperiences var notificationName: Notification.Name { switch self { @@ -127,6 +142,8 @@ final class ExtensionEventBridge { return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot") case .lockScreenWidgets: return Notification.Name("com.ebullioscopic.Atoll.extensions.lockScreenWidgetSnapshot") + case .notchExperiences: + return Notification.Name("com.ebullioscopic.Atoll.extensions.notchExperienceSnapshot") } } @@ -136,6 +153,8 @@ final class ExtensionEventBridge { return "live-activities" case .lockScreenWidgets: return "lock-screen-widgets" + case .notchExperiences: + return "notch-experiences" } } } diff --git a/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift new file mode 100644 index 0000000..932faf3 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift @@ -0,0 +1,215 @@ +import Foundation +import Defaults +import AtollExtensionKit +import SwiftUI + +@MainActor +final class ExtensionNotchExperienceManager: ObservableObject { + static let shared = ExtensionNotchExperienceManager() + + @Published private(set) var activeExperiences: [ExtensionNotchExperiencePayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionNotchExperienceCapacity + private let eventBridge = ExtensionEventBridge.shared + private var observerToken: NSObjectProtocol? + private var suppressBroadcast = false + private let currentProcessID = ProcessInfo.processInfo.processIdentifier + + private init() { + activeExperiences = eventBridge.loadPersistedNotchExperiences() + sortExperiences() + observerToken = eventBridge.observeNotchExperienceSnapshots { [weak self] payloads, sourcePID in + self?.applySnapshot(payloads, sourcePID: sourcePID) + } + } + + deinit { + if let observerToken { + eventBridge.removeObserver(observerToken) + } + } + + // MARK: - Presentation Lifecycle + + func present(descriptor: AtollNotchExperienceDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessNotchExperienceRequest(from: bundleIdentifier) else { + logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized") + throw ExtensionValidationError.unauthorized + } + guard descriptor.bundleIdentifier == bundleIdentifier else { + logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): bundle mismatch (descriptor: \(descriptor.bundleIdentifier))") + throw ExtensionValidationError.invalidDescriptor("Bundle identifier mismatch") + } + + try ExtensionDescriptorValidator.validate(descriptor) + try ensureWebContentSupport(for: descriptor) + + let isUpdate: Bool + if let index = activeExperiences.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { + let payload = ExtensionNotchExperiencePayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeExperiences[index].receivedAt + ) + activeExperiences[index] = payload + sortExperiences() + isUpdate = true + Logger.log("Replaced notch experience \(descriptor.id) for \(bundleIdentifier)", category: .extensions) + } else { + guard activeExperiences.count < Defaults[maxCapacityKey] else { + logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") + throw ExtensionValidationError.exceedsCapacity + } + + let payload = ExtensionNotchExperiencePayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeExperiences.append(payload) + sortExperiences() + isUpdate = false + logDiagnostics("Queued notch experience \(descriptor.id) for \(bundleIdentifier); total experiences: \(activeExperiences.count)") + } + + authorizationManager.recordActivity(for: bundleIdentifier, scope: .notchExperiences) + broadcastSnapshot() + + if let tabConfig = descriptor.tab, Defaults[.enableExtensionNotchTabs] { + Logger.log("Notch experience tab ready (title: \(tabConfig.title))", category: .extensions) + } + + if descriptor.minimalistic != nil && Defaults[.enableExtensionNotchMinimalisticOverrides] { + Logger.log("Notch experience minimalistic override available", category: .extensions) + } + + if !isUpdate { + logDiagnostics("Stored notch experience \(descriptor.id) for \(bundleIdentifier); priority \(descriptor.priority.rawValue)") + } + } + + func update(descriptor: AtollNotchExperienceDescriptor, bundleIdentifier: String) throws { + try ExtensionDescriptorValidator.validate(descriptor) + guard descriptor.bundleIdentifier == bundleIdentifier else { + logDiagnostics("Rejected notch experience update \(descriptor.id) from \(bundleIdentifier): bundle mismatch (descriptor: \(descriptor.bundleIdentifier))") + throw ExtensionValidationError.invalidDescriptor("Bundle identifier mismatch") + } + guard let index = activeExperiences.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing notch experience") + } + try ensureWebContentSupport(for: descriptor) + let payload = ExtensionNotchExperiencePayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeExperiences[index].receivedAt + ) + activeExperiences[index] = payload + sortExperiences() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .notchExperiences) + logDiagnostics("Updated notch experience \(descriptor.id) for \(bundleIdentifier)") + broadcastSnapshot() + } + + func dismiss(experienceID: String, bundleIdentifier: String) { + let previousCount = activeExperiences.count + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeExperiences.removeAll { $0.descriptor.id == experienceID && $0.bundleIdentifier == bundleIdentifier } + } + guard previousCount != activeExperiences.count else { return } + Logger.log("Dismissed notch experience \(experienceID) from \(bundleIdentifier)", category: .extensions) + ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID) + logDiagnostics("Removed notch experience \(experienceID) for \(bundleIdentifier); remaining: \(activeExperiences.count)") + broadcastSnapshot() + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeExperiences + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeExperiences.removeAll { $0.bundleIdentifier == bundleIdentifier } + } + ids.forEach { ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) } + if !ids.isEmpty { + logDiagnostics("Removed all notch experiences for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") + broadcastSnapshot() + } + } + + // MARK: - Presentation Resolution + + func highestPriorityTabPayload() -> ExtensionNotchExperiencePayload? { + guard Defaults[.enableThirdPartyExtensions], + Defaults[.enableExtensionNotchExperiences], + Defaults[.enableExtensionNotchTabs] else { + return nil + } + return activeExperiences.first(where: { $0.descriptor.tab != nil }) + } + + func minimalisticReplacementPayload() -> ExtensionNotchExperiencePayload? { + guard Defaults[.enableThirdPartyExtensions], + Defaults[.enableExtensionNotchExperiences], + Defaults[.enableExtensionNotchMinimalisticOverrides] else { + return nil + } + return activeExperiences.first(where: { $0.descriptor.minimalistic != nil }) + } + + func payload(bundleIdentifier: String, experienceID: String) -> ExtensionNotchExperiencePayload? { + activeExperiences.first { $0.bundleIdentifier == bundleIdentifier && $0.descriptor.id == experienceID } + } + + func payload(experienceID: String) -> ExtensionNotchExperiencePayload? { + activeExperiences.first { $0.descriptor.id == experienceID } + } + + // MARK: - Snapshot Sync + + private func sortExperiences() { + activeExperiences.sort(by: descriptorComparator) + } + + private func descriptorComparator(lhs: ExtensionNotchExperiencePayload, rhs: ExtensionNotchExperiencePayload) -> Bool { + if lhs.priority == rhs.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.priority > rhs.priority + } + + private func broadcastSnapshot() { + guard !suppressBroadcast else { return } + eventBridge.broadcastNotchExperienceSnapshot(activeExperiences) + logDiagnostics("Broadcasted notch experience snapshot (count: \(activeExperiences.count))") + } + + private func applySnapshot(_ payloads: [ExtensionNotchExperiencePayload], sourcePID: Int32) { + guard sourcePID != currentProcessID else { return } + suppressBroadcast = true + activeExperiences = payloads.sorted(by: descriptorComparator) + suppressBroadcast = false + logDiagnostics("Applied external notch experience snapshot from PID \(sourcePID) (count: \(payloads.count))") + } + + private func ensureWebContentSupport(for descriptor: AtollNotchExperienceDescriptor) throws { + guard descriptor.hasWebContent else { return } + guard Defaults[.enableExtensionNotchInteractiveWebViews] else { + logDiagnostics("Rejected notch experience \(descriptor.id) due to web content while interactive web views are disabled") + throw ExtensionValidationError.unsupportedContent + } + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } +} + +private extension AtollNotchExperienceDescriptor { + var hasWebContent: Bool { + if tab?.webContent != nil { return true } + if minimalistic?.webContent != nil { return true } + return false + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 16e58d2..85c2859 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -103,11 +103,13 @@ enum AnimationSource: Codable, Hashable, Equatable { enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable { case liveActivities case lockScreenWidgets + case notchExperiences var displayName: String { switch self { case .liveActivities: return "Live Activities" case .lockScreenWidgets: return "Lock Screen Widgets" + case .notchExperiences: return "Notch Experiences" } } } @@ -168,13 +170,43 @@ struct ExtensionRateLimitRecord: Codable, Defaults.Serializable, Hashable, Ident let bundleIdentifier: String var activityTimestamps: [Date] var widgetTimestamps: [Date] + var notchExperienceTimestamps: [Date] var id: String { bundleIdentifier } - init(bundleIdentifier: String, activityTimestamps: [Date] = [], widgetTimestamps: [Date] = []) { + init( + bundleIdentifier: String, + activityTimestamps: [Date] = [], + widgetTimestamps: [Date] = [], + notchExperienceTimestamps: [Date] = [] + ) { self.bundleIdentifier = bundleIdentifier self.activityTimestamps = activityTimestamps self.widgetTimestamps = widgetTimestamps + self.notchExperienceTimestamps = notchExperienceTimestamps + } + + private enum CodingKeys: String, CodingKey { + case bundleIdentifier + case activityTimestamps + case widgetTimestamps + case notchExperienceTimestamps + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + bundleIdentifier = try container.decode(String.self, forKey: .bundleIdentifier) + activityTimestamps = try container.decodeIfPresent([Date].self, forKey: .activityTimestamps) ?? [] + widgetTimestamps = try container.decodeIfPresent([Date].self, forKey: .widgetTimestamps) ?? [] + notchExperienceTimestamps = try container.decodeIfPresent([Date].self, forKey: .notchExperienceTimestamps) ?? [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(bundleIdentifier, forKey: .bundleIdentifier) + try container.encode(activityTimestamps, forKey: .activityTimestamps) + try container.encode(widgetTimestamps, forKey: .widgetTimestamps) + try container.encode(notchExperienceTimestamps, forKey: .notchExperienceTimestamps) } } @@ -800,11 +832,16 @@ extension Defaults.Keys { static let enableThirdPartyExtensions = Key("enableThirdPartyExtensions", default: true) static let enableExtensionLiveActivities = Key("enableExtensionLiveActivities", default: true) static let enableExtensionLockScreenWidgets = Key("enableExtensionLockScreenWidgets", default: true) + static let enableExtensionNotchExperiences = Key("enableExtensionNotchExperiences", default: true) + static let enableExtensionNotchTabs = Key("enableExtensionNotchTabs", default: true) + static let enableExtensionNotchMinimalisticOverrides = Key("enableExtensionNotchMinimalisticOverrides", default: true) + static let enableExtensionNotchInteractiveWebViews = Key("enableExtensionNotchInteractiveWebViews", 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) + static let extensionNotchExperienceCapacity = Key("extensionNotchExperienceCapacity", default: 2) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/services/Extensions/ExtensionXPCService.swift b/DynamicIsland/services/Extensions/ExtensionXPCService.swift index bd72334..8bf8c24 100644 --- a/DynamicIsland/services/Extensions/ExtensionXPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionXPCService.swift @@ -12,6 +12,7 @@ final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { private let authorizationManager = ExtensionAuthorizationManager.shared private let liveActivityManager = ExtensionLiveActivityManager.shared private let widgetManager = ExtensionLockScreenWidgetManager.shared + private let notchExperienceManager = ExtensionNotchExperienceManager.shared private let decoder = JSONDecoder() init(bundleIdentifier: String, host: ExtensionXPCServiceHost, connection: NSXPCConnection) { @@ -127,6 +128,40 @@ final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { } } + // MARK: Notch Experiences + + func presentNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received notch experience payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue))") + try service.notchExperienceManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Notch experience \(descriptor.id) stored for \(service.bundleIdentifier); active experiences: \(service.notchExperienceManager.activeExperiences.count)") + } + } + + func updateNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received notch experience update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.notchExperienceManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Notch experience \(descriptor.id) updated for \(service.bundleIdentifier)") + } + } + + func dismissNotchExperience(experienceID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + self.logDiagnostics("Received notch experience dismissal from \(self.bundleIdentifier) (id: \(experienceID))") + self.notchExperienceManager.dismiss(experienceID: experienceID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Notch experience \(experienceID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } + } + // MARK: Diagnostics func getVersion(reply: @escaping (String) -> Void) { diff --git a/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift index 670699e..f0d329b 100644 --- a/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift +++ b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift @@ -91,6 +91,12 @@ final class ExtensionXPCServiceHost: NSObject, NSXPCListenerDelegate { } } + func notifyNotchExperienceDismiss(bundleIdentifier: String, experienceID: String) { + deliver(to: bundleIdentifier) { client in + client.notchExperienceDidDismiss(experienceID: experienceID) + } + } + private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? { let processIdentifier = connection.processIdentifier guard processIdentifier != 0 else { return nil }