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",
|
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186"
|
"revision" : "a1303629e532337594787b981b499560a99c6c93"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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." : {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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):")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue