Update Third Party stuff

This commit is contained in:
Hariharan Mudaliar 2026-01-15 23:31:07 +05:30
commit 27b16a3d15
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
8 changed files with 636 additions and 99 deletions

View file

@ -7,7 +7,7 @@
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f" "revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355"
} }
}, },
{ {

View file

@ -495,6 +495,7 @@ struct ContentView: View {
let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible)
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
let activeSneakPeekStyle = resolvedSneakPeekStyle()
let expansionMatchesSecondary: Bool = { let expansionMatchesSecondary: Bool = {
guard let musicSecondary else { return false } guard let musicSecondary else { return false }
switch musicSecondary { switch musicSecondary {
@ -540,6 +541,23 @@ struct ContentView: View {
.frame(width: 76, alignment: .trailing) .frame(width: 76, alignment: .trailing)
} }
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
} else if coordinator.sneakPeek.show,
case let .extensionLiveActivity(bundleID, activityID) = coordinator.sneakPeek.type,
activeSneakPeekStyle == .inline,
vm.notchState == .closed,
!vm.hideOnClosed {
let inlinePayload = extensionLiveActivityManager.payload(bundleIdentifier: bundleID, activityID: activityID)
ExtensionInlineSneakPeekView(
payload: inlinePayload,
title: coordinator.sneakPeek.title,
subtitle: coordinator.sneakPeek.subtitle,
accentColor: coordinator.sneakPeek.accentColor ?? .gray,
notchHeight: vm.effectiveClosedNotchHeight,
closedNotchWidth: vm.closedNotchSize.width,
isHovering: isHovering,
gestureProgress: gestureProgress
)
.transition(.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(
@ -568,17 +586,19 @@ struct ContentView: View {
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
PrivacyLiveActivity() PrivacyLiveActivity()
} else if let extensionPayload = extensionStandalonePayload { } else if let extensionPayload = extensionStandalonePayload {
let suppressingCenterText = shouldSuppressExtensionCenter(for: extensionPayload)
let layout = extensionStandaloneLayout( let layout = extensionStandaloneLayout(
for: extensionPayload, for: extensionPayload,
notchHeight: vm.effectiveClosedNotchHeight, notchHeight: vm.effectiveClosedNotchHeight,
isHovering: isHovering isHovering: isHovering,
suppressingCenterText: suppressingCenterText
) )
ExtensionLiveActivityStandaloneView( ExtensionLiveActivityStandaloneView(
payload: extensionPayload, payload: extensionPayload,
layout: layout, layout: layout,
isHovering: isHovering isHovering: isHovering,
notchState: vm.notchState
) )
.transition(.opacity.combined(with: .scale))
} 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)
} else if vm.notchState == .open { } else if vm.notchState == .open {
@ -599,7 +619,7 @@ struct ContentView: View {
} }
// Old sneak peek music // Old sneak peek music
else if coordinator.sneakPeek.type == .music { else if coordinator.sneakPeek.type == .music {
if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { if vm.notchState == .closed && !vm.hideOnClosed && activeSneakPeekStyle == .standard {
HStack(alignment: .center) { HStack(alignment: .center) {
Image(systemName: "music.note") Image(systemName: "music.note")
GeometryReader { geo in GeometryReader { geo in
@ -612,7 +632,7 @@ struct ContentView: View {
} }
// Timer sneak peek // Timer sneak peek
else if coordinator.sneakPeek.type == .timer { else if coordinator.sneakPeek.type == .timer {
if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { if !vm.hideOnClosed && activeSneakPeekStyle == .standard {
HStack(alignment: .center) { HStack(alignment: .center) {
Image(systemName: "timer") Image(systemName: "timer")
GeometryReader { geo in GeometryReader { geo in
@ -624,7 +644,7 @@ struct ContentView: View {
} }
} }
else if coordinator.sneakPeek.type == .reminder { else if coordinator.sneakPeek.type == .reminder {
if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard, let reminder = reminderManager.activeReminder { if !vm.hideOnClosed && activeSneakPeekStyle == .standard, let reminder = reminderManager.activeReminder {
GeometryReader { geo in GeometryReader { geo in
let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7) let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
HStack(spacing: 6) { HStack(spacing: 6) {
@ -642,10 +662,33 @@ struct ContentView: View {
.padding(.bottom, 10) .padding(.bottom, 10)
} }
} }
// Extension live activity sneak peek
else if case .extensionLiveActivity = coordinator.sneakPeek.type {
if !vm.hideOnClosed && activeSneakPeekStyle == .standard {
HStack(alignment: .center, spacing: 6) {
if !coordinator.sneakPeek.icon.isEmpty {
Image(systemName: coordinator.sneakPeek.icon)
}
GeometryReader { geo in
let combinedText = coordinator.sneakPeek.subtitle.isEmpty ?
coordinator.sneakPeek.title :
"\(coordinator.sneakPeek.title) - \(coordinator.sneakPeek.subtitle)"
MarqueeText(
.constant(combinedText),
textColor: coordinator.sneakPeek.accentColor ?? .gray,
minDuration: 1,
frameWidth: geo.size.width
)
}
}
.foregroundStyle(coordinator.sneakPeek.accentColor ?? .gray)
.padding(.bottom, 10)
}
}
} }
} }
} }
.conditionalModifier((coordinator.sneakPeek.show && coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer) && (vm.notchState == .closed))) { view in .conditionalModifier(shouldFixSizeForSneakPeek()) { view in
view view
.fixedSize() .fixedSize()
} }
@ -1224,11 +1267,17 @@ struct ContentView: View {
return payload return payload
} }
private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout { private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool, suppressingCenterText: Bool) -> ExtensionStandaloneLayout {
let outerHeight = notchHeight let outerHeight = notchHeight
let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12)) let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12))
let leadingWidth = max(contentHeight, 44) let leadingWidth = max(contentHeight, 44)
let centerWidth = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) let centerWidth: CGFloat = {
if suppressingCenterText {
let base = vm.closedNotchSize.width * 0.35
return max(64, min(base, 96))
}
return max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96)
}()
let trailingWidth = ExtensionLayoutMetrics.trailingWidth( let trailingWidth = ExtensionLayoutMetrics.trailingWidth(
for: payload, for: payload,
baseWidth: leadingWidth, baseWidth: leadingWidth,
@ -1241,10 +1290,22 @@ struct ContentView: View {
contentHeight: contentHeight, contentHeight: contentHeight,
leadingWidth: leadingWidth, leadingWidth: leadingWidth,
centerWidth: centerWidth, centerWidth: centerWidth,
trailingWidth: trailingWidth trailingWidth: trailingWidth,
suppressingCenterText: suppressingCenterText
) )
} }
private func shouldSuppressExtensionCenter(for payload: ExtensionLiveActivityPayload) -> Bool {
guard vm.notchState == .closed else { return false }
let descriptor = payload.descriptor
guard descriptor.sneakPeekConfig?.enabled ?? true else { return false }
guard coordinator.sneakPeek.show else { return false }
guard case let .extensionLiveActivity(bundleID, activityID) = coordinator.sneakPeek.type else { return false }
guard bundleID == payload.bundleIdentifier && activityID == descriptor.id else { return false }
let style = coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles]
return style == .inline
}
@MainActor @MainActor
private final class ExtensionRoutingDiagnostics { private final class ExtensionRoutingDiagnostics {
static let shared = ExtensionRoutingDiagnostics() static let shared = ExtensionRoutingDiagnostics()
@ -1771,6 +1832,28 @@ struct ContentView: View {
private func hideMusicControlWindow() {} private func hideMusicControlWindow() {}
#endif #endif
private func shouldFixSizeForSneakPeek() -> Bool {
guard coordinator.sneakPeek.show else { return false }
let style = resolvedSneakPeekStyle()
// Check for extension sneak peek
if case .extensionLiveActivity = coordinator.sneakPeek.type {
return vm.notchState == .closed && style == .standard
}
// Original logic for other types
let isMusicSneak = coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && style == .standard
let isTimerSneak = coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && style == .standard
let isReminderSneak = coordinator.sneakPeek.type == .reminder && !vm.hideOnClosed && style == .standard
let isOtherSneak = coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer && coordinator.sneakPeek.type != .reminder && vm.notchState == .closed
return isMusicSneak || isTimerSneak || isReminderSneak || isOtherSneak
}
private func resolvedSneakPeekStyle() -> SneakPeekStyle {
coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles]
}
} }
private enum MusicSecondaryLiveActivity: Equatable { private enum MusicSecondaryLiveActivity: Equatable {

View file

@ -9,7 +9,7 @@ import Combine
import Defaults import Defaults
import SwiftUI import SwiftUI
enum SneakContentType { enum SneakContentType: Equatable {
case brightness case brightness
case volume case volume
case backlight case backlight
@ -25,6 +25,34 @@ enum SneakContentType {
case privacy case privacy
case lockScreen case lockScreen
case capsLock case capsLock
case extensionLiveActivity(bundleID: String, activityID: String)
}
extension SneakContentType {
static func == (lhs: SneakContentType, rhs: SneakContentType) -> Bool {
switch (lhs, rhs) {
case (.brightness, .brightness),
(.volume, .volume),
(.backlight, .backlight),
(.music, .music),
(.mic, .mic),
(.battery, .battery),
(.download, .download),
(.timer, .timer),
(.reminder, .reminder),
(.recording, .recording),
(.doNotDisturb, .doNotDisturb),
(.bluetoothAudio, .bluetoothAudio),
(.privacy, .privacy),
(.lockScreen, .lockScreen),
(.capsLock, .capsLock):
return true
case let (.extensionLiveActivity(lb, la), .extensionLiveActivity(rb, ra)):
return lb == rb && la == ra
default:
return false
}
}
} }
struct sneakPeek { struct sneakPeek {
@ -32,6 +60,10 @@ struct sneakPeek {
var type: SneakContentType = .music var type: SneakContentType = .music
var value: CGFloat = 0 var value: CGFloat = 0
var icon: String = "" var icon: String = ""
var title: String = ""
var subtitle: String = ""
var accentColor: Color?
var styleOverride: SneakPeekStyle? = nil
} }
enum BrowserType { enum BrowserType {
@ -175,19 +207,40 @@ class DynamicIslandViewCoordinator: ObservableObject {
} }
} }
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { func toggleSneakPeek(
status: Bool,
type: SneakContentType,
duration: TimeInterval = 1.5,
value: CGFloat = 0,
icon: String = "",
title: String = "",
subtitle: String = "",
accentColor: Color? = nil,
styleOverride: SneakPeekStyle? = nil
) {
let resolvedDuration: TimeInterval let resolvedDuration: TimeInterval
switch type { switch type {
case .timer: case .timer:
resolvedDuration = 10 resolvedDuration = 10
case .reminder: case .reminder:
resolvedDuration = Defaults[.reminderSneakPeekDuration] resolvedDuration = Defaults[.reminderSneakPeekDuration]
case .extensionLiveActivity:
resolvedDuration = duration
default: default:
resolvedDuration = duration resolvedDuration = duration
} }
sneakPeekDuration = resolvedDuration sneakPeekDuration = resolvedDuration
let bypassedTypes: [SneakContentType] = [.music, .timer, .reminder, .bluetoothAudio] let bypassedTypes: [SneakContentType] = [.music, .timer, .reminder, .bluetoothAudio]
if !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] {
// Check if it's an extension type
let isExtensionType: Bool
if case .extensionLiveActivity = type {
isExtensionType = true
} else {
isExtensionType = false
}
if !isExtensionType && !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] {
return return
} }
DispatchQueue.main.async { DispatchQueue.main.async {
@ -196,6 +249,10 @@ class DynamicIslandViewCoordinator: ObservableObject {
self.sneakPeek.type = type self.sneakPeek.type = type
self.sneakPeek.value = value self.sneakPeek.value = value
self.sneakPeek.icon = icon self.sneakPeek.icon = icon
self.sneakPeek.title = title
self.sneakPeek.subtitle = subtitle
self.sneakPeek.accentColor = accentColor
self.sneakPeek.styleOverride = styleOverride
} }
} }
} }

View file

@ -16013,6 +16013,14 @@
} }
} }
}, },
"Reset Live Activities" : {
"comment" : "A menu item that resets live activities for a particular app.",
"isCommentAutoGenerated" : true
},
"Reset Lock Screen Widgets" : {
"comment" : "A menu item that resets all lock screen widgets 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

View file

@ -9,18 +9,20 @@ struct ExtensionStandaloneLayout {
let leadingWidth: CGFloat let leadingWidth: CGFloat
let centerWidth: CGFloat let centerWidth: CGFloat
let trailingWidth: CGFloat let trailingWidth: CGFloat
let suppressingCenterText: Bool
} }
struct ExtensionLiveActivityStandaloneView: View { 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 }
private var accentColor: Color { descriptor.accentColor.swiftUIColor } private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var resolvedLeadingContent: AtollTrailingContent { private var resolvedLeadingContent: AtollTrailingContent {
descriptor.leadingContent ?? .icon(descriptor.leadingIcon) resolvedExtensionLeadingContent(for: descriptor)
} }
private var resolvedCenterStyle: ExtensionCenterContentView.Style { private var resolvedCenterStyle: ExtensionCenterContentView.Style {
switch descriptor.centerTextStyle { switch descriptor.centerTextStyle {
@ -33,6 +35,10 @@ 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(
@ -40,7 +46,8 @@ struct ExtensionLiveActivityStandaloneView: View {
badge: descriptor.badgeIcon, badge: descriptor.badgeIcon,
accent: accentColor, accent: accentColor,
frameWidth: layout.leadingWidth, frameWidth: layout.leadingWidth,
frameHeight: contentHeight frameHeight: contentHeight,
defaultIcon: descriptor.leadingIcon
) )
.frame(width: layout.leadingWidth, height: contentHeight) .frame(width: layout.leadingWidth, height: contentHeight)
@ -48,12 +55,16 @@ struct ExtensionLiveActivityStandaloneView: View {
.fill(Color.black) .fill(Color.black)
.frame(width: layout.centerWidth, height: contentHeight) .frame(width: layout.centerWidth, height: contentHeight)
.overlay( .overlay(
ExtensionCenterContentView( Group {
descriptor: descriptor, if !shouldSuppressCenterText {
accent: accentColor, ExtensionCenterContentView(
width: layout.centerWidth, descriptor: descriptor,
style: resolvedCenterStyle accent: accentColor,
) width: layout.centerWidth,
style: resolvedCenterStyle
)
}
}
) )
ExtensionMusicWingView( ExtensionMusicWingView(
@ -64,6 +75,10 @@ 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(
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")
@ -82,32 +97,37 @@ struct ExtensionMusicWingView: View {
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var accentColor: Color { descriptor.accentColor.swiftUIColor } private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var trailingRenderable: ExtensionTrailingRenderable {
resolvedExtensionTrailingRenderable(for: descriptor)
}
var body: some View { var body: some View {
VStack(alignment: .trailing, spacing: 6) { VStack(alignment: .trailing, spacing: 6) {
if case .none = descriptor.trailingContent { switch trailingRenderable {
EmptyView() case let .content(content):
} else { if case .none = content {
ExtensionEdgeContentView( EmptyView()
content: descriptor.trailingContent, } else {
accent: accentColor, ExtensionEdgeContentView(
availableWidth: trailingWidth, content: content,
alignment: .trailing accent: accentColor,
) availableWidth: trailingWidth,
alignment: .trailing
)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} }
case let .indicator(indicator):
if let indicator = descriptor.progressIndicator, indicator != .none {
ExtensionProgressIndicatorView( ExtensionProgressIndicatorView(
indicator: indicator, indicator: indicator,
progress: descriptor.progress, progress: descriptor.progress,
accent: accentColor, accent: accentColor,
estimatedDuration: descriptor.estimatedDuration estimatedDuration: descriptor.estimatedDuration,
maxVisualHeight: notchHeight
) )
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
} else {
Spacer(minLength: 0)
} }
Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(.trailing, 8) .padding(.trailing, 8)
@ -127,23 +147,39 @@ struct ExtensionLeadingContentView: View {
let accent: Color let accent: Color
let frameWidth: CGFloat let frameWidth: CGFloat
let frameHeight: CGFloat let frameHeight: CGFloat
let defaultIcon: AtollIconDescriptor
var body: some View { var body: some View {
if case let .icon(iconDescriptor) = content { Group {
ExtensionCompositeIconView( switch content {
leading: iconDescriptor, case let .icon(iconDescriptor):
badge: badge, ExtensionCompositeIconView(
accent: accent, leading: iconDescriptor,
size: frameHeight badge: badge,
) accent: accent,
} else { size: frameHeight
ExtensionEdgeContentView( )
content: content, case let .animation(data, size):
accent: accent, let resolvedSize = CGSize(
availableWidth: frameWidth, width: min(size.width, frameHeight),
alignment: .leading height: min(size.height, frameHeight)
) )
ExtensionLottieView(data: data, size: resolvedSize)
.frame(width: frameHeight, height: frameHeight)
.background(
RoundedRectangle(cornerRadius: frameHeight * 0.18, style: .continuous)
.fill(Color.white.opacity(0.08))
)
default:
ExtensionCompositeIconView(
leading: defaultIcon,
badge: badge,
accent: accent,
size: frameHeight
)
}
} }
.frame(width: frameWidth, height: frameHeight)
} }
} }
@ -202,3 +238,125 @@ private func logExtensionDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions) Logger.log(message, category: .extensions)
} }
struct ExtensionInlineSneakPeekView: View {
let payload: ExtensionLiveActivityPayload?
let title: String
let subtitle: String
let accentColor: Color
let notchHeight: CGFloat
let closedNotchWidth: CGFloat
let isHovering: Bool
let gestureProgress: CGFloat
private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor }
private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor }
private var contentHeight: CGFloat {
max(0, notchHeight - (isHovering ? 0 : 12))
}
private var leadingWidth: CGFloat {
let base = max(contentHeight, 44)
return max(base * 0.85, base + gestureProgress / 2)
}
private var centerWidth: CGFloat {
max(closedNotchWidth + (isHovering ? 8 : 0), 120)
}
private var trailingWidth: CGFloat {
guard let payload else { return max(leadingWidth, 120) }
return ExtensionLayoutMetrics.trailingWidth(
for: payload,
baseWidth: leadingWidth,
maxWidth: leadingWidth + centerWidth * 0.6
)
}
private var marqueeFrameWidth: CGFloat {
max(48, centerWidth - 24)
}
private var resolvedTitle: String {
title.isEmpty ? "Extension" : title
}
private var resolvedSubtitle: String {
subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
HStack(spacing: 0) {
leadingWing
.frame(width: leadingWidth, height: contentHeight)
Rectangle()
.fill(Color.black)
.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
)
}
if !resolvedSubtitle.isEmpty {
Text(resolvedSubtitle)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(Color.white.opacity(0.75))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 6)
}
trailingWing
.frame(width: trailingWidth, height: contentHeight)
}
.frame(height: notchHeight + (isHovering ? 8 : 0), alignment: .center)
}
@ViewBuilder
private var leadingWing: some View {
if let descriptor {
ExtensionLeadingContentView(
content: resolvedExtensionLeadingContent(for: descriptor),
badge: descriptor.badgeIcon,
accent: resolvedAccent,
frameWidth: leadingWidth,
frameHeight: contentHeight,
defaultIcon: descriptor.leadingIcon
)
} else {
RoundedRectangle(cornerRadius: contentHeight * 0.25, style: .continuous)
.fill(resolvedAccent.gradient)
}
}
@ViewBuilder
private var trailingWing: some View {
if let payload {
ExtensionMusicWingView(
payload: payload,
notchHeight: contentHeight,
trailingWidth: trailingWidth
)
} else {
Rectangle()
.fill(resolvedAccent.gradient)
.mask {
AudioSpectrumView(isPlaying: .constant(true))
.frame(width: 18, height: 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}

View file

@ -1,6 +1,9 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import AtollExtensionKit import AtollExtensionKit
import Lottie
import LottieUI
import CryptoKit
// MARK: - Color Conversion // MARK: - Color Conversion
@ -130,7 +133,15 @@ struct ExtensionIconView: View {
} else { } else {
fallbackSymbol fallbackSymbol
} }
case .lottie, .none: case let .lottie(animationData, targetSize):
let resolvedSize = CGSize(width: min(targetSize.width, size.width), height: min(targetSize.height, size.height))
ExtensionLottieView(data: animationData, size: resolvedSize)
.frame(width: size.width, height: size.height)
.background(
RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous)
.fill(Color.white.opacity(0.08))
)
case .none:
fallbackSymbol fallbackSymbol
} }
} }
@ -187,6 +198,69 @@ struct ExtensionCompositeIconView: View {
} }
} }
// MARK: - Lottie Rendering
struct ExtensionLottieView: View {
let data: Data
let size: CGSize
var loopMode: LottieLoopMode = .loop
@State private var cachedURL: URL?
var body: some View {
Group {
if let cachedURL {
LottieView(
state: LUStateData(
type: .loadedFrom(cachedURL),
speed: 1.0,
loopMode: loopMode
)
)
} else {
ProgressView()
.controlSize(.small)
.tint(.white.opacity(0.7))
}
}
.frame(width: size.width, height: size.height)
.task {
if cachedURL == nil {
cachedURL = ExtensionLottieCache.shared.url(for: data)
}
}
}
}
private final class ExtensionLottieCache {
static let shared = ExtensionLottieCache()
private let queue = DispatchQueue(label: "com.atoll.extension-lottie-cache")
private var cache: [String: URL] = [:]
func url(for data: Data) -> URL? {
queue.sync {
let key = Self.hash(data: data)
if let existing = cache[key], FileManager.default.fileExists(atPath: existing.path) {
return existing
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent("extension-lottie-\(key).json", conformingTo: .json)
do {
try data.write(to: url, options: .atomic)
cache[key] = url
return url
} catch {
cache.removeValue(forKey: key)
return nil
}
}
}
private static func hash(data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Progress Rendering // MARK: - Progress Rendering
struct ExtensionProgressIndicatorView: View { struct ExtensionProgressIndicatorView: View {
@ -194,39 +268,59 @@ struct ExtensionProgressIndicatorView: View {
let progress: Double let progress: Double
let accent: Color let accent: Color
let estimatedDuration: TimeInterval? let estimatedDuration: TimeInterval?
let maxVisualHeight: CGFloat?
init(
indicator: AtollProgressIndicator,
progress: Double,
accent: Color,
estimatedDuration: TimeInterval?,
maxVisualHeight: CGFloat? = nil
) {
self.indicator = indicator
self.progress = progress
self.accent = accent
self.estimatedDuration = estimatedDuration
self.maxVisualHeight = maxVisualHeight
}
var body: some View { var body: some View {
switch indicator { switch indicator {
case let .ring(diameter, strokeWidth): case let .ring(diameter, strokeWidth, color):
let ringColor = color?.swiftUIColor ?? accent
let resolvedDiameter = resolvedRingDiameter(requested: diameter)
ZStack { ZStack {
Circle() Circle()
.stroke(Color.white.opacity(0.15), lineWidth: strokeWidth) .stroke(Color.white.opacity(0.15), lineWidth: strokeWidth)
Circle() Circle()
.trim(from: 0, to: CGFloat(max(0, min(progress, 1)))) .trim(from: 0, to: CGFloat(max(0, min(progress, 1))))
.stroke(accent, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) .stroke(ringColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: progress) .animation(.smooth(duration: 0.25), value: progress)
} }
.frame(width: diameter, height: diameter) .frame(width: resolvedDiameter, height: resolvedDiameter)
case let .bar(width, height, cornerRadius): case let .bar(width, height, cornerRadius, color):
let barColor = color?.swiftUIColor ?? accent
Capsule() Capsule()
.fill(Color.white.opacity(0.18)) .fill(Color.white.opacity(0.18))
.frame(width: width ?? 80, height: height) .frame(width: width ?? 80, height: height)
.overlay(alignment: .leading) { .overlay(alignment: .leading) {
Capsule() Capsule()
.fill(accent) .fill(barColor)
.frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height) .frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height)
.animation(.smooth(duration: 0.25), value: progress) .animation(.smooth(duration: 0.25), value: progress)
} }
case let .percentage(font): case let .percentage(font, color):
let textColor = color?.swiftUIColor ?? accent
Text("\(Int(progress * 100))%") Text("\(Int(progress * 100))%")
.font(font.swiftUIFont()) .font(font.swiftUIFont())
.foregroundStyle(accent) .foregroundStyle(textColor)
.monospacedDigit() .monospacedDigit()
case let .countdown(font): case let .countdown(font, color):
let textColor = color?.swiftUIColor ?? accent
Text(countdownText) Text(countdownText)
.font(font.swiftUIFont()) .font(font.swiftUIFont())
.foregroundStyle(accent) .foregroundStyle(textColor)
.monospacedDigit() .monospacedDigit()
case .lottie: case .lottie:
Image(systemName: "sparkles") Image(systemName: "sparkles")
@ -252,6 +346,12 @@ struct ExtensionProgressIndicatorView: View {
private var formatPercent: String { private var formatPercent: String {
"\(Int(progress * 100))%" "\(Int(progress * 100))%"
} }
private func resolvedRingDiameter(requested: CGFloat) -> CGFloat {
guard let maxVisualHeight else { return requested }
let clamped = min(requested, max(maxVisualHeight - 6, 18))
return max(18, clamped)
}
} }
// MARK: - Edge Content Rendering // MARK: - Edge Content Rendering
@ -264,23 +364,23 @@ struct ExtensionEdgeContentView: View {
var body: some View { var body: some View {
switch content { switch content {
case let .text(value, font: font): case let .text(value, font: font, color: color):
Text(value) Text(value)
.font(font.swiftUIFont()) .font(font.swiftUIFont())
.foregroundStyle(accent) .foregroundStyle(resolvedColor(color))
.lineLimit(1) .lineLimit(1)
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
case let .marquee(value, font: font, minDuration: duration): case let .marquee(value, font: font, minDuration: duration, color: color):
MarqueeText( MarqueeText(
.constant(value), .constant(value),
font: font.swiftUIFont(), font: font.swiftUIFont(),
nsFont: .body, nsFont: .body,
textColor: accent, textColor: resolvedColor(color),
minDuration: duration, minDuration: duration,
frameWidth: max(24, availableWidth - 12) frameWidth: max(24, availableWidth - 12)
) )
case let .countdownText(targetDate, font: font): case let .countdownText(targetDate, font: font, color: color):
ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent) ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.swiftUIColor)
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
case let .icon(descriptor): case let .icon(descriptor):
ExtensionIconView( ExtensionIconView(
@ -297,26 +397,30 @@ struct ExtensionEdgeContentView: View {
AudioSpectrumView(isPlaying: .constant(true)) AudioSpectrumView(isPlaying: .constant(true))
.frame(width: 16, height: 12) .frame(width: 16, height: 12)
} }
case .animation: case let .animation(data, size):
Image(systemName: "sparkles") let resolvedWidth = min(size.width, availableWidth)
.font(.system(size: 14, weight: .medium)) ExtensionLottieView(data: data, size: CGSize(width: resolvedWidth, height: size.height))
.foregroundStyle(accent)
case .none: case .none:
EmptyView() EmptyView()
} }
} }
private func resolvedColor(_ descriptorColor: AtollColorDescriptor?) -> Color {
descriptorColor?.swiftUIColor ?? accent
}
} }
struct ExtensionCountdownTextView: View { struct ExtensionCountdownTextView: View {
let targetDate: Date let targetDate: Date
let font: AtollFontDescriptor let font: AtollFontDescriptor
let accent: Color let accent: Color
let customColor: Color?
var body: some View { var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in TimelineView(.periodic(from: .now, by: 1)) { context in
Text(formattedRemaining(since: context.date)) Text(formattedRemaining(since: context.date))
.font(font.swiftUIFont()) .font(font.swiftUIFont())
.foregroundStyle(accent) .foregroundStyle(customColor ?? accent)
.monospacedDigit() .monospacedDigit()
} }
} }
@ -337,8 +441,13 @@ struct ExtensionCountdownTextView: View {
enum ExtensionLayoutMetrics { enum ExtensionLayoutMetrics {
static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
var width = edgeWidth(for: payload.descriptor.trailingContent, baseWidth: baseWidth, maxWidth: maxWidth) let renderable = resolvedExtensionTrailingRenderable(for: payload.descriptor)
if let indicator = payload.descriptor.progressIndicator { var width: CGFloat
switch renderable {
case let .content(content):
width = edgeWidth(for: content, baseWidth: baseWidth, maxWidth: maxWidth)
case let .indicator(indicator):
width = edgeWidth(for: .none, baseWidth: baseWidth, maxWidth: maxWidth)
width = max(width, widthForProgress(indicator)) width = max(width, widthForProgress(indicator))
} }
if let maxWidth { if let maxWidth {
@ -357,13 +466,13 @@ enum ExtensionLayoutMetrics {
private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat {
switch content { switch content {
case let .text(text, font: font): case let .text(text, font: font, color: _):
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
return max(baseWidth, measured + 32) return max(baseWidth, measured + 32)
case let .marquee(text, font: font, _): case let .marquee(text, font: font, minDuration: _, color: _):
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
return max(baseWidth, min(measured + 40, baseWidth * 2)) return max(baseWidth, min(measured + 40, baseWidth * 2))
case let .countdownText(targetDate, font: font): case let .countdownText(targetDate, font: font, color: _):
let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00" let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00"
let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont()) let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont())
return max(baseWidth, measured + 24) return max(baseWidth, measured + 24)
@ -380,13 +489,13 @@ enum ExtensionLayoutMetrics {
private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat { private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat {
switch indicator { switch indicator {
case let .ring(diameter, _): case let .ring(diameter, _, _):
return diameter + 20 return diameter + 20
case let .bar(width, _, _): case let .bar(width, _, _, _):
return (width ?? 72) + 12 return (width ?? 72) + 12
case .percentage: case .percentage(_, _):
return 60 return 60
case .countdown: case .countdown(_, _):
return 74 return 74
case let .lottie(_, size): case let .lottie(_, size):
return size.width + 16 return size.width + 16
@ -402,3 +511,41 @@ enum ExtensionTextMeasurer {
return max(1, text.size(withAttributes: attributes).width) return max(1, text.size(withAttributes: attributes).width)
} }
} }
enum ExtensionTrailingRenderable {
case content(AtollTrailingContent)
case indicator(AtollProgressIndicator)
}
func resolvedExtensionLeadingContent(for descriptor: AtollLiveActivityDescriptor) -> AtollTrailingContent {
guard let override = descriptor.leadingContent else {
return .icon(descriptor.leadingIcon)
}
switch override {
case .icon, .animation:
return override
default:
return .icon(descriptor.leadingIcon)
}
}
func resolvedExtensionTrailingRenderable(for descriptor: AtollLiveActivityDescriptor) -> ExtensionTrailingRenderable {
if let indicator = descriptor.progressIndicator,
indicator.isRenderable,
descriptor.trailingContent == .none {
return .indicator(indicator)
}
return .content(descriptor.trailingContent)
}
private extension AtollProgressIndicator {
var isRenderable: Bool {
switch self {
case .none:
return false
default:
return true
}
}
}

View file

@ -143,8 +143,11 @@ struct ExtensionsSettingsView: View {
} }
} }
@MainActor
private struct ExtensionEntryRow: View { 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 widgetManager = ExtensionLockScreenWidgetManager.shared
let entry: ExtensionAuthorizationEntry let entry: ExtensionAuthorizationEntry
let onRemove: () -> Void let onRemove: () -> Void
@ -395,6 +398,8 @@ private struct ExtensionEntryRow: View {
Spacer() Spacer()
resetMenu
Button("Remove") { Button("Remove") {
onRemove() onRemove()
} }
@ -403,6 +408,32 @@ private struct ExtensionEntryRow: View {
.tint(.red) .tint(.red)
} }
} }
private var resetMenu: some View {
Menu {
Button("Reset Live Activities") {
liveActivityManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasLiveActivities)
Button("Reset Lock Screen Widgets") {
widgetManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasWidgets)
} label: {
Label("Reset", systemImage: "arrow.counterclockwise.circle")
}
.menuStyle(.borderlessButton)
.controlSize(.small)
}
private var hasLiveActivities: Bool {
liveActivityManager.activeActivities.contains { $0.bundleIdentifier == entry.bundleIdentifier }
}
private var hasWidgets: Bool {
widgetManager.activeWidgets.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 {

View file

@ -1,6 +1,7 @@
import Foundation import Foundation
import Defaults import Defaults
import AtollExtensionKit import AtollExtensionKit
import SwiftUI
@MainActor @MainActor
final class ExtensionLiveActivityManager: ObservableObject { final class ExtensionLiveActivityManager: ObservableObject {
@ -39,6 +40,7 @@ final class ExtensionLiveActivityManager: ObservableObject {
throw ExtensionValidationError.invalidDescriptor("Structure validation failed") throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
} }
let isUpdate: Bool
if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) {
let payload = ExtensionLiveActivityPayload( let payload = ExtensionLiveActivityPayload(
bundleIdentifier: bundleIdentifier, bundleIdentifier: bundleIdentifier,
@ -49,25 +51,35 @@ final class ExtensionLiveActivityManager: ObservableObject {
sortActivities() sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions) Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions)
broadcastSnapshot() isUpdate = true
return } else {
} guard activeActivities.count < Defaults[maxCapacityKey] else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached")
throw ExtensionValidationError.exceedsCapacity
}
guard activeActivities.count < Defaults[maxCapacityKey] else { let payload = ExtensionLiveActivityPayload(
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") bundleIdentifier: bundleIdentifier,
throw ExtensionValidationError.exceedsCapacity descriptor: descriptor,
receivedAt: .now
)
activeActivities.append(payload)
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)")
isUpdate = false
} }
let payload = ExtensionLiveActivityPayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: .now
)
activeActivities.append(payload)
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)")
broadcastSnapshot() broadcastSnapshot()
// Trigger sneak peek (defaulting to enabled for legacy descriptors)
let resolvedConfig = descriptor.sneakPeekConfig ?? .default
if resolvedConfig.enabled {
let shouldShow = !isUpdate || resolvedConfig.showOnUpdate
if shouldShow {
triggerSneakPeek(for: descriptor, bundleIdentifier: bundleIdentifier, config: resolvedConfig)
}
}
} }
func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
@ -92,7 +104,9 @@ final class ExtensionLiveActivityManager: ObservableObject {
func dismiss(activityID: String, bundleIdentifier: String) { func dismiss(activityID: String, bundleIdentifier: String) {
let previousCount = activeActivities.count let previousCount = activeActivities.count
activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier } withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier }
}
if previousCount != activeActivities.count { if previousCount != activeActivities.count {
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions) Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
@ -105,7 +119,9 @@ final class ExtensionLiveActivityManager: ObservableObject {
let ids = activeActivities let ids = activeActivities
.filter { $0.bundleIdentifier == bundleIdentifier } .filter { $0.bundleIdentifier == bundleIdentifier }
.map { $0.descriptor.id } .map { $0.descriptor.id }
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier } withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
}
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) } ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
if !ids.isEmpty { if !ids.isEmpty {
logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
@ -119,6 +135,10 @@ final class ExtensionLiveActivityManager: ObservableObject {
.sorted(by: descriptorComparator) .sorted(by: descriptorComparator)
} }
func payload(bundleIdentifier: String, activityID: String) -> ExtensionLiveActivityPayload? {
activeActivities.first { $0.bundleIdentifier == bundleIdentifier && $0.descriptor.id == activityID }
}
private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool { private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool {
if lhs.descriptor.priority == rhs.descriptor.priority { if lhs.descriptor.priority == rhs.descriptor.priority {
return lhs.receivedAt < rhs.receivedAt return lhs.receivedAt < rhs.receivedAt
@ -144,6 +164,39 @@ final class ExtensionLiveActivityManager: ObservableObject {
logDiagnostics("Applied external live activity snapshot from PID \(sourcePID) (count: \(payloads.count))") logDiagnostics("Applied external live activity snapshot from PID \(sourcePID) (count: \(payloads.count))")
} }
private func triggerSneakPeek(for descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String, config: AtollSneakPeekConfig) {
let coordinator = DynamicIslandViewCoordinator.shared
let duration = config.duration ?? 2.5
let accentColor = descriptor.accentColor.swiftUIColor
let styleOverride: SneakPeekStyle? = {
guard let requestedStyle = config.style else { return nil }
switch requestedStyle {
case .inline:
return .inline
case .standard:
return .standard
}
}()
let resolvedTitle = descriptor.sneakPeekTitle?.isEmpty == false ? descriptor.sneakPeekTitle! : descriptor.title
let resolvedSubtitle = descriptor.sneakPeekSubtitle ?? descriptor.subtitle ?? ""
// Pass the activity's id and bundleID to the sneak peek so the specialized view can look up details if needed
coordinator.toggleSneakPeek(
status: true,
type: .extensionLiveActivity(bundleID: bundleIdentifier, activityID: descriptor.id),
duration: duration,
value: 0,
icon: "",
title: resolvedTitle,
subtitle: resolvedSubtitle,
accentColor: accentColor,
styleOverride: styleOverride
)
logDiagnostics("Triggered sneak peek for \(descriptor.id) from \(bundleIdentifier) with duration \(duration)s")
}
private func logDiagnostics(_ message: String) { private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions) Logger.log(message, category: .extensions)