mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Update stuff
This commit is contained in:
parent
1d6946134c
commit
9214c61544
18 changed files with 985 additions and 118 deletions
|
|
@ -7,7 +7,7 @@
|
|||
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186"
|
||||
"revision" : "a1303629e532337594787b981b499560a99c6c93"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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." : {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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):")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public enum NotchViews {
|
|||
case colorPicker
|
||||
case notes
|
||||
case clipboard
|
||||
case extensionExperience
|
||||
}
|
||||
|
||||
enum NotesLayoutState: Equatable {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue