Update stuff

This commit is contained in:
Hariharan Mudaliar 2026-01-18 01:46:35 +05:30
commit 9214c61544
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
18 changed files with 985 additions and 118 deletions

View file

@ -7,7 +7,7 @@
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
"state" : {
"branch" : "main",
"revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186"
"revision" : "a1303629e532337594787b981b499560a99c6c93"
}
},
{

View file

@ -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
@ -78,6 +79,19 @@ struct ContentView: View {
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(
@ -1468,6 +1487,72 @@ struct ContentView: View {
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
private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) {

View file

@ -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) {
@ -207,6 +239,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,
type: SneakContentType,

View file

@ -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." : {

View file

@ -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,
@ -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)
}
}
}
}

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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.")
}
}
@ -60,6 +60,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")
}
@ -435,6 +481,10 @@ private struct ExtensionEntryRow: View {
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 {
Text("\(label):")

View file

@ -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 {

View file

@ -33,6 +33,7 @@ public enum NotchViews {
case colorPicker
case notes
case clipboard
case extensionExperience
}
enum NotesLayoutState: Equatable {

View file

@ -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

View file

@ -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

View file

@ -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")
}
}
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
}

View file

@ -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<Bool>("enableThirdPartyExtensions", default: true)
static let enableExtensionLiveActivities = Key<Bool>("enableExtensionLiveActivities", default: true)
static let enableExtensionLockScreenWidgets = Key<Bool>("enableExtensionLockScreenWidgets", default: true)
static let enableExtensionNotchExperiences = Key<Bool>("enableExtensionNotchExperiences", default: true)
static let enableExtensionNotchTabs = Key<Bool>("enableExtensionNotchTabs", default: true)
static let enableExtensionNotchMinimalisticOverrides = Key<Bool>("enableExtensionNotchMinimalisticOverrides", default: true)
static let enableExtensionNotchInteractiveWebViews = Key<Bool>("enableExtensionNotchInteractiveWebViews", default: true)
static let extensionAuthorizationEntries = Key<[ExtensionAuthorizationEntry]>("extensionAuthorizationEntries", default: [])
static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: [])
static let extensionDiagnosticsLoggingEnabled = Key<Bool>("extensionDiagnosticsLoggingEnabled", default: true)
static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4)
static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4)
static let extensionNotchExperienceCapacity = Key<Int>("extensionNotchExperienceCapacity", default: 2)
// MARK: Keyboard Shortcuts
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)

View file

@ -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) {

View file

@ -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 }