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", "location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186" "revision" : "a1303629e532337594787b981b499560a99c6c93"
} }
}, },
{ {

View file

@ -36,6 +36,7 @@ struct ContentView: View {
@ObservedObject var lockScreenManager = LockScreenManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared
@ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var capsLockManager = CapsLockManager.shared
@ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared
@ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@State private var downloadManager = DownloadManager.shared @State private var downloadManager = DownloadManager.shared
@Default(.enableStatsFeature) var enableStatsFeature @Default(.enableStatsFeature) var enableStatsFeature
@ -78,6 +79,19 @@ struct ContentView: View {
return CGSize(width: baseSize.width, height: resolvedHeight) 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 { guard coordinator.currentView == .stats else {
return baseSize return baseSize
} }
@ -557,17 +571,17 @@ struct ContentView: View {
isHovering: isHovering, isHovering: isHovering,
gestureProgress: gestureProgress 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) { } 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) InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.transition( .transition(
coordinator.sneakPeek.type == .capsLock coordinator.sneakPeek.type == .capsLock
? .move(edge: .trailing).combined(with: .opacity) ? AnyTransition.move(edge: .trailing).combined(with: .opacity)
: .opacity : AnyTransition.opacity
) )
} else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { } 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) 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 { } else if canShowMusicDuringExpansion && musicPairingEligible {
MusicLiveActivity(secondary: musicSecondary) MusicLiveActivity(secondary: musicSecondary)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { } 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( ExtensionLiveActivityStandaloneView(
payload: extensionPayload, payload: extensionPayload,
layout: layout, layout: layout,
isHovering: isHovering, isHovering: isHovering
notchState: vm.notchState
) )
} else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
@ -712,6 +725,12 @@ struct ContentView: View {
NotchNotesView() NotchNotesView()
case .clipboard: case .clipboard:
NotchNotesView() NotchNotesView()
case .extensionExperience:
if let payload = currentExtensionTabPayload() {
ExtensionNotchExperienceTabView(payload: payload)
} else {
NotchHomeView(albumArtNamespace: albumArtNamespace)
}
} }
} }
.transition(.asymmetric( .transition(.asymmetric(
@ -1468,6 +1487,72 @@ struct ContentView: View {
return count <= 3 ? 1 : 2 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 // MARK: - Gesture Handling
private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { 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 statsSecondRowRevealDelay: TimeInterval = 0.5
private let statsSecondRowAnimationDuration: TimeInterval = 0.3 private let statsSecondRowAnimationDuration: TimeInterval = 0.3
@Published var notesLayoutState: NotesLayoutState = .list @Published var notesLayoutState: NotesLayoutState = .list
@Published var selectedExtensionExperienceID: String?
@AppStorage("firstLaunch") var firstLaunch: Bool = true @AppStorage("firstLaunch") var firstLaunch: Bool = true
@ -138,6 +139,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
@Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown" @Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown"
@Published var optionKeyPressed: Bool = true @Published var optionKeyPressed: Bool = true
private let extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
private init() { private init() {
selectedScreen = preferredScreen selectedScreen = preferredScreen
@ -161,6 +163,36 @@ class DynamicIslandViewCoordinator: ObservableObject {
self?.handleMinimalisticModeChange(change.newValue) self?.handleMinimalisticModeChange(change.newValue)
} }
.store(in: &cancellables) .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) { 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( func toggleSneakPeek(
status: Bool, status: Bool,
type: SneakContentType, type: SneakContentType,

View file

@ -8777,6 +8777,10 @@
"comment" : "A label describing the expanded notch width setting.", "comment" : "A label describing the expanded notch width setting.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Extension tab unavailable" : {
"comment" : "A message displayed when the extension tab is not available.",
"isCommentAutoGenerated" : true
},
"Extensions" : { "Extensions" : {
"localizations" : { "localizations" : {
"ar" : { "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" : { "Notch Height" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -15973,7 +15981,7 @@
"comment" : "A button that removes an extension from the authorized list.", "comment" : "A button that removes an extension from the authorized list.",
"isCommentAutoGenerated" : true "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.", "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 "isCommentAutoGenerated" : true
}, },
@ -16021,6 +16029,10 @@
"comment" : "A menu item that resets all lock screen widgets for a particular app.", "comment" : "A menu item that resets all lock screen widgets for a particular app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Reset Notch Experiences" : {
"comment" : "A menu item that resets all notch experiences for a particular app.",
"isCommentAutoGenerated" : true
},
"Reset Rate Limits" : { "Reset Rate Limits" : {
"comment" : "A button that resets the rate limits for a particular extension.", "comment" : "A button that resets the rate limits for a particular extension.",
"isCommentAutoGenerated" : true "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." : { "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." : { "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 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.", "comment" : "A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"This duration powers the \"Custom\" option inside the timer popover for quick access." : { "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 payload: ExtensionLiveActivityPayload
let layout: ExtensionStandaloneLayout let layout: ExtensionStandaloneLayout
let isHovering: Bool let isHovering: Bool
let notchState: NotchState
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var contentHeight: CGFloat { layout.contentHeight } private var contentHeight: CGFloat { layout.contentHeight }
@ -35,10 +34,6 @@ struct ExtensionLiveActivityStandaloneView: View {
} }
} }
private var shouldSuppressCenterText: Bool {
layout.suppressingCenterText
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ExtensionLeadingContentView( ExtensionLeadingContentView(
@ -56,7 +51,9 @@ struct ExtensionLiveActivityStandaloneView: View {
.frame(width: layout.centerWidth, height: contentHeight) .frame(width: layout.centerWidth, height: contentHeight)
.overlay( .overlay(
Group { Group {
if !shouldSuppressCenterText { if layout.suppressingCenterText {
EmptyView()
} else {
ExtensionCenterContentView( ExtensionCenterContentView(
descriptor: descriptor, descriptor: descriptor,
accent: accentColor, accent: accentColor,
@ -75,10 +72,12 @@ struct ExtensionLiveActivityStandaloneView: View {
.frame(width: layout.trailingWidth, height: contentHeight) .frame(width: layout.trailingWidth, height: contentHeight)
} }
.frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0)) .frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0))
.transition(.asymmetric( .transition(
insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)), .asymmetric(
removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9)) 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) .animation(.smooth(duration: 0.25), value: payload.id)
.onAppear { .onAppear {
logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view") logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view")
@ -106,7 +105,7 @@ struct ExtensionMusicWingView: View {
switch trailingRenderable { switch trailingRenderable {
case let .content(content): case let .content(content):
if case .none = content { if case .none = content {
EmptyView() Spacer(minLength: 0)
} else { } else {
ExtensionEdgeContentView( ExtensionEdgeContentView(
content: content, content: content,
@ -239,10 +238,152 @@ private func logExtensionDiagnostics(_ message: String) {
Logger.log(message, category: .extensions) 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 { struct ExtensionInlineSneakPeekView: View {
let payload: ExtensionLiveActivityPayload? let payload: ExtensionLiveActivityPayload?
let title: String let title: String
let subtitle: String let subtitle: String?
let accentColor: Color let accentColor: Color
let notchHeight: CGFloat let notchHeight: CGFloat
let closedNotchWidth: CGFloat let closedNotchWidth: CGFloat
@ -251,7 +392,6 @@ struct ExtensionInlineSneakPeekView: View {
private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor } private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor }
private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor } private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor }
private var contentHeight: CGFloat { private var contentHeight: CGFloat {
max(0, notchHeight - (isHovering ? 0 : 12)) max(0, notchHeight - (isHovering ? 0 : 12))
} }
@ -274,11 +414,19 @@ struct ExtensionInlineSneakPeekView: View {
} }
private var resolvedTitle: String { 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 { 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 { var body: some View {
@ -291,16 +439,14 @@ struct ExtensionInlineSneakPeekView: View {
.frame(width: centerWidth, height: contentHeight) .frame(width: centerWidth, height: contentHeight)
.overlay(alignment: .bottomLeading) { .overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
if !resolvedTitle.isEmpty { MarqueeText(
MarqueeText( .constant(resolvedTitle),
.constant(resolvedTitle), font: .system(size: 12, weight: .semibold),
font: .system(size: 12, weight: .semibold), nsFont: .subheadline,
nsFont: .subheadline, textColor: .white,
textColor: .white, minDuration: 0.4,
minDuration: 0.4, frameWidth: marqueeFrameWidth
frameWidth: marqueeFrameWidth )
)
}
if !resolvedSubtitle.isEmpty { if !resolvedSubtitle.isEmpty {
Text(resolvedSubtitle) Text(resolvedSubtitle)
.font(.system(size: 11, weight: .regular)) .font(.system(size: 11, weight: .regular))
@ -338,11 +484,6 @@ struct ExtensionInlineSneakPeekView: View {
@ViewBuilder @ViewBuilder
private var trailingWing: some View { private var trailingWing: some View {
spectrumPlaceholder
}
@ViewBuilder
private var spectrumPlaceholder: some View {
VStack { VStack {
Rectangle() Rectangle()
.fill(resolvedAccent.gradient) .fill(resolvedAccent.gradient)
@ -357,3 +498,77 @@ struct ExtensionInlineSneakPeekView: View {
.padding(.vertical, 6) .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 { private func view(for element: AtollWidgetContentElement) -> some View {
switch element { ExtensionWidgetElementView(
case let .text(text, font: font, color: color, alignment: _): element: element,
Text(text) accent: accentColor,
.font(font.swiftUIFont()) allowWebInteraction: false
.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)
}
} }
private func alignment(for element: AtollWidgetContentElement) -> Alignment { private func alignment(for element: AtollWidgetContentElement) -> Alignment {
@ -235,8 +194,62 @@ private func logWidgetDiagnostics(_ message: String) {
Logger.log(message, category: .extensions) 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 descriptor: AtollWidgetWebContentDescriptor
let allowInteraction: Bool
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(descriptor: descriptor) Coordinator(descriptor: descriptor)
@ -246,7 +259,8 @@ private struct ExtensionWebContentView: NSViewRepresentable {
let configuration = WKWebViewConfiguration() let configuration = WKWebViewConfiguration()
configuration.suppressesIncrementalRendering = false configuration.suppressesIncrementalRendering = false
configuration.preferences.javaScriptCanOpenWindowsAutomatically = 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.navigationDelegate = context.coordinator
webView.wantsLayer = true webView.wantsLayer = true
applyConfiguration(descriptor, to: webView) applyConfiguration(descriptor, to: webView)
@ -259,6 +273,9 @@ private struct ExtensionWebContentView: NSViewRepresentable {
context.coordinator.descriptor = descriptor context.coordinator.descriptor = descriptor
webView.navigationDelegate = context.coordinator webView.navigationDelegate = context.coordinator
applyConfiguration(descriptor, to: webView) applyConfiguration(descriptor, to: webView)
if let configurableView = webView as? ConfigurableWKWebView {
configurableView.allowInteraction = allowInteraction
}
if context.coordinator.lastHTML != descriptor.html { if context.coordinator.lastHTML != descriptor.html {
webView.loadHTMLString(descriptor.html, baseURL: nil) webView.loadHTMLString(descriptor.html, baseURL: nil)
context.coordinator.lastHTML = descriptor.html 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? { override func hitTest(_ point: NSPoint) -> NSView? {
nil guard allowInteraction else { return nil }
return super.hitTest(point)
} }
override var acceptsFirstResponder: Bool { override var acceptsFirstResponder: Bool {
false allowInteraction
} }
} }

View file

@ -378,6 +378,7 @@ struct NotchHomeView: View {
@ObservedObject var webcamManager = WebcamManager.shared @ObservedObject var webcamManager = WebcamManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
let albumArtNamespace: Namespace.ID let albumArtNamespace: Namespace.ID
var body: some View { var body: some View {
@ -392,8 +393,14 @@ struct NotchHomeView: View {
private var mainContent: some View { private var mainContent: some View {
HStack(alignment: .top, spacing: 20) { HStack(alignment: .top, spacing: 20) {
if Defaults[.enableMinimalisticUI] { if Defaults[.enableMinimalisticUI] {
// Minimalistic mode: Only show compact music player if let overridePayload = minimalisticOverridePayload {
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) ExtensionMinimalisticExperienceView(
payload: overridePayload,
albumArtNamespace: albumArtNamespace
)
} else {
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
}
} else { } else {
// Normal mode: Show full music player with optional calendar and webcam // Normal mode: Show full music player with optional calendar and webcam
MusicPlayerView(albumArtNamespace: albumArtNamespace) MusicPlayerView(albumArtNamespace: albumArtNamespace)
@ -422,6 +429,10 @@ struct NotchHomeView: View {
.combined(with: .move(edge: .top))) .combined(with: .move(edge: .top)))
.blur(radius: vm.notchState == .closed ? 30 : 0) .blur(radius: vm.notchState == .closed ? 30 : 0)
} }
private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? {
extensionNotchExperienceManager.minimalisticReplacementPayload()
}
} }
struct MusicSliderView: View { struct MusicSliderView: View {

View file

@ -44,7 +44,7 @@ struct ExtensionsSettingsView: View {
selectedEntry = nil selectedEntry = nil
} }
} message: { entry in } 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) Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets)
.settingsHighlight(id: highlightID("Allow extension lock screen widgets")) .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) Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled)
.settingsHighlight(id: highlightID("Enable extension diagnostics logging")) .settingsHighlight(id: highlightID("Enable extension diagnostics logging"))
} }
@ -67,7 +82,7 @@ struct ExtensionsSettingsView: View {
Text("Global Settings") Text("Global Settings")
} footer: { } footer: {
if Defaults[.enableThirdPartyExtensions] { 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
@ -148,6 +163,7 @@ private struct ExtensionEntryRow: View {
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared @ObservedObject private var authManager = ExtensionAuthorizationManager.shared
@ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared
@ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared @ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared
@ObservedObject private var notchExperienceManager = ExtensionNotchExperienceManager.shared
let entry: ExtensionAuthorizationEntry let entry: ExtensionAuthorizationEntry
let onRemove: () -> Void let onRemove: () -> Void
@ -275,8 +291,8 @@ private struct ExtensionEntryRow: View {
} }
// Rate limits info // Rate limits info
if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }),
!rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty { !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty || !rateLimitRecord.notchExperienceTimestamps.isEmpty {
rateLimitInfo(record: rateLimitRecord) rateLimitInfo(record: rateLimitRecord)
Divider() Divider()
} }
@ -324,6 +340,21 @@ private struct ExtensionEntryRow: View {
)) ))
.font(.caption) .font(.caption)
.disabled(!authManager.areLockScreenWidgetsEnabled) .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()) .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") { Button("Reset Rate Limits") {
@ -420,6 +461,11 @@ private struct ExtensionEntryRow: View {
widgetManager.dismissAll(for: entry.bundleIdentifier) widgetManager.dismissAll(for: entry.bundleIdentifier)
} }
.disabled(!hasWidgets) .disabled(!hasWidgets)
Button("Reset Notch Experiences") {
notchExperienceManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasNotchExperiences)
} label: { } label: {
Label("Reset", systemImage: "arrow.counterclockwise.circle") Label("Reset", systemImage: "arrow.counterclockwise.circle")
} }
@ -435,6 +481,10 @@ private struct ExtensionEntryRow: View {
widgetManager.activeWidgets.contains { $0.bundleIdentifier == entry.bundleIdentifier } 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 { private func infoRow(label: String, value: String) -> some View {
HStack { HStack {
Text("\(label):") Text("\(label):")

View file

@ -4,23 +4,38 @@
// //
// Created by Hugo Persson on 2024-08-25. // Created by Hugo Persson on 2024-08-25.
// Modified by Hariharan Mudaliar // Modified by Hariharan Mudaliar
import AtollExtensionKit
import SwiftUI import SwiftUI
import Defaults import Defaults
struct TabModel: Identifiable { struct TabModel: Identifiable {
let id = UUID() let id: String
let label: String let label: String
let icon: String let icon: String
let view: NotchViews 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 { struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@Default(.enableTimerFeature) var enableTimerFeature @Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature @Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature
@Default(.timerDisplayMode) var timerDisplayMode @Default(.timerDisplayMode) var timerDisplayMode
@Default(.enableThirdPartyExtensions) private var enableThirdPartyExtensions
@Default(.enableExtensionNotchExperiences) private var enableExtensionNotchExperiences
@Default(.enableExtensionNotchTabs) private var enableExtensionNotchTabs
@Namespace var animation @Namespace var animation
private var tabs: [TabModel] { private var tabs: [TabModel] {
@ -46,35 +61,73 @@ struct TabSelectionView: View {
let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard"
tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) 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 return tabsArray
} }
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(tabs) { tab in ForEach(tabs) { tab in
TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { let isSelected = isSelected(tab)
withAnimation(.smooth) { let activeAccent = tab.accentColor ?? .white
coordinator.currentView = tab.view 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) .frame(height: 26)
.background { .foregroundStyle(isSelected ? activeAccent : .gray)
if tab.view == coordinator.currentView { .background {
Capsule() if isSelected {
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) Capsule()
.matchedGeometryEffect(id: "capsule", in: animation) .fill((tab.accentColor ?? Color(nsColor: .secondarySystemFill)).opacity(0.25))
} else { .shadow(color: (tab.accentColor ?? .clear).opacity(0.4), radius: 8)
Capsule() .matchedGeometryEffect(id: "capsule", in: animation)
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) } else {
.matchedGeometryEffect(id: "capsule", in: animation) Capsule()
.hidden() .fill(Color.clear)
} .matchedGeometryEffect(id: "capsule", in: animation)
.hidden()
} }
}
} }
} }
.clipShape(Capsule()) .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 { #Preview {

View file

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

View file

@ -23,6 +23,20 @@ struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable {
var priority: AtollLiveActivityPriority { descriptor.priority } 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 { enum ExtensionValidationError: LocalizedError, Equatable {
case featureDisabled case featureDisabled
case unauthorized case unauthorized

View file

@ -22,13 +22,16 @@ final class ExtensionAuthorizationManager: ObservableObject {
var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] } var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] }
var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] } var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] }
var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] } var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] }
var areNotchExperiencesEnabled: Bool { Defaults[.enableExtensionNotchExperiences] }
func updateFeatureToggles(extensionsEnabled: Bool? = nil, func updateFeatureToggles(extensionsEnabled: Bool? = nil,
liveActivitiesEnabled: Bool? = nil, liveActivitiesEnabled: Bool? = nil,
lockScreenWidgetsEnabled: Bool? = nil) { lockScreenWidgetsEnabled: Bool? = nil,
notchExperiencesEnabled: Bool? = nil) {
if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled } if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled }
if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled } if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled }
if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled } if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled }
if let notchExperiencesEnabled { Defaults[.enableExtensionNotchExperiences] = notchExperiencesEnabled }
objectWillChange.send() objectWillChange.send()
} }
@ -88,9 +91,12 @@ final class ExtensionAuthorizationManager: ObservableObject {
record.activityTimestamps.append(now) record.activityTimestamps.append(now)
case .lockScreenWidgets: case .lockScreenWidgets:
record.widgetTimestamps.append(now) record.widgetTimestamps.append(now)
case .notchExperiences:
record.notchExperienceTimestamps.append(now)
} }
record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) record.activityTimestamps = flushOldTimestamps(record.activityTimestamps)
record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps)
record.notchExperienceTimestamps = flushOldTimestamps(record.notchExperienceTimestamps)
} }
} }
@ -126,6 +132,12 @@ final class ExtensionAuthorizationManager: ObservableObject {
return true 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) { func recordDeniedRequest(bundleIdentifier: String, reason: String) {
updateEntry(bundleIdentifier: bundleIdentifier) { entry in updateEntry(bundleIdentifier: bundleIdentifier) { entry in
entry.lastDeniedReason = reason entry.lastDeniedReason = reason

View file

@ -30,4 +30,15 @@ struct ExtensionDescriptorValidator {
throw ExtensionValidationError.invalidDescriptor("Widget exceeds size limits") 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) broadcast(payloads, channel: .lockScreenWidgets)
} }
func broadcastNotchExperienceSnapshot(_ payloads: [ExtensionNotchExperiencePayload]) {
broadcast(payloads, channel: .notchExperiences)
}
func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] { func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] {
loadSnapshot(channel: .liveActivities) loadSnapshot(channel: .liveActivities)
} }
@ -31,6 +35,10 @@ final class ExtensionEventBridge {
loadSnapshot(channel: .lockScreenWidgets) loadSnapshot(channel: .lockScreenWidgets)
} }
func loadPersistedNotchExperiences() -> [ExtensionNotchExperiencePayload] {
loadSnapshot(channel: .notchExperiences)
}
func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol { func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol {
observe(channel: .liveActivities, handler: handler) observe(channel: .liveActivities, handler: handler)
} }
@ -39,6 +47,10 @@ final class ExtensionEventBridge {
observe(channel: .lockScreenWidgets, handler: handler) observe(channel: .lockScreenWidgets, handler: handler)
} }
func observeNotchExperienceSnapshots(_ handler: @escaping ([ExtensionNotchExperiencePayload], Int32) -> Void) -> NSObjectProtocol {
observe(channel: .notchExperiences, handler: handler)
}
func removeObserver(_ observer: NSObjectProtocol) { func removeObserver(_ observer: NSObjectProtocol) {
notificationCenter.removeObserver(observer) notificationCenter.removeObserver(observer)
} }
@ -92,6 +104,8 @@ final class ExtensionEventBridge {
return directory.appendingPathComponent("live_activities.json", isDirectory: false) return directory.appendingPathComponent("live_activities.json", isDirectory: false)
case .lockScreenWidgets: case .lockScreenWidgets:
return directory.appendingPathComponent("lock_screen_widgets.json", isDirectory: false) 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 { private enum Channel: CustomStringConvertible {
case liveActivities case liveActivities
case lockScreenWidgets case lockScreenWidgets
case notchExperiences
var notificationName: Notification.Name { var notificationName: Notification.Name {
switch self { switch self {
@ -127,6 +142,8 @@ final class ExtensionEventBridge {
return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot") return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot")
case .lockScreenWidgets: case .lockScreenWidgets:
return Notification.Name("com.ebullioscopic.Atoll.extensions.lockScreenWidgetSnapshot") 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" return "live-activities"
case .lockScreenWidgets: case .lockScreenWidgets:
return "lock-screen-widgets" 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 { enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable {
case liveActivities case liveActivities
case lockScreenWidgets case lockScreenWidgets
case notchExperiences
var displayName: String { var displayName: String {
switch self { switch self {
case .liveActivities: return "Live Activities" case .liveActivities: return "Live Activities"
case .lockScreenWidgets: return "Lock Screen Widgets" 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 let bundleIdentifier: String
var activityTimestamps: [Date] var activityTimestamps: [Date]
var widgetTimestamps: [Date] var widgetTimestamps: [Date]
var notchExperienceTimestamps: [Date]
var id: String { bundleIdentifier } var id: String { bundleIdentifier }
init(bundleIdentifier: String, activityTimestamps: [Date] = [], widgetTimestamps: [Date] = []) { init(
bundleIdentifier: String,
activityTimestamps: [Date] = [],
widgetTimestamps: [Date] = [],
notchExperienceTimestamps: [Date] = []
) {
self.bundleIdentifier = bundleIdentifier self.bundleIdentifier = bundleIdentifier
self.activityTimestamps = activityTimestamps self.activityTimestamps = activityTimestamps
self.widgetTimestamps = widgetTimestamps 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 enableThirdPartyExtensions = Key<Bool>("enableThirdPartyExtensions", default: true)
static let enableExtensionLiveActivities = Key<Bool>("enableExtensionLiveActivities", default: true) static let enableExtensionLiveActivities = Key<Bool>("enableExtensionLiveActivities", default: true)
static let enableExtensionLockScreenWidgets = Key<Bool>("enableExtensionLockScreenWidgets", 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 extensionAuthorizationEntries = Key<[ExtensionAuthorizationEntry]>("extensionAuthorizationEntries", default: [])
static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: []) static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: [])
static let extensionDiagnosticsLoggingEnabled = Key<Bool>("extensionDiagnosticsLoggingEnabled", default: true) static let extensionDiagnosticsLoggingEnabled = Key<Bool>("extensionDiagnosticsLoggingEnabled", default: true)
static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4) static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4)
static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4) static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4)
static let extensionNotchExperienceCapacity = Key<Int>("extensionNotchExperienceCapacity", default: 2)
// MARK: Keyboard Shortcuts // MARK: Keyboard Shortcuts
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true) 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 authorizationManager = ExtensionAuthorizationManager.shared
private let liveActivityManager = ExtensionLiveActivityManager.shared private let liveActivityManager = ExtensionLiveActivityManager.shared
private let widgetManager = ExtensionLockScreenWidgetManager.shared private let widgetManager = ExtensionLockScreenWidgetManager.shared
private let notchExperienceManager = ExtensionNotchExperienceManager.shared
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
init(bundleIdentifier: String, host: ExtensionXPCServiceHost, connection: NSXPCConnection) { 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 // MARK: Diagnostics
func getVersion(reply: @escaping (String) -> Void) { 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? { private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? {
let processIdentifier = connection.processIdentifier let processIdentifier = connection.processIdentifier
guard processIdentifier != 0 else { return nil } guard processIdentifier != 0 else { return nil }