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",
"state" : {
"branch" : "main",
"revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f"
"revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355"
}
},
{

View file

@ -495,6 +495,7 @@ struct ContentView: View {
let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible)
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
let activeSneakPeekStyle = resolvedSneakPeekStyle()
let expansionMatchesSecondary: Bool = {
guard let musicSecondary else { return false }
switch musicSecondary {
@ -540,6 +541,23 @@ struct ContentView: View {
.frame(width: 76, alignment: .trailing)
}
.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) {
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
.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 {
PrivacyLiveActivity()
} else if let extensionPayload = extensionStandalonePayload {
let suppressingCenterText = shouldSuppressExtensionCenter(for: extensionPayload)
let layout = extensionStandaloneLayout(
for: extensionPayload,
notchHeight: vm.effectiveClosedNotchHeight,
isHovering: isHovering
isHovering: isHovering,
suppressingCenterText: suppressingCenterText
)
ExtensionLiveActivityStandaloneView(
payload: extensionPayload,
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 {
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
} else if vm.notchState == .open {
@ -599,7 +619,7 @@ struct ContentView: View {
}
// Old sneak peek 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) {
Image(systemName: "music.note")
GeometryReader { geo in
@ -612,7 +632,7 @@ struct ContentView: View {
}
// Timer sneak peek
else if coordinator.sneakPeek.type == .timer {
if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard {
if !vm.hideOnClosed && activeSneakPeekStyle == .standard {
HStack(alignment: .center) {
Image(systemName: "timer")
GeometryReader { geo in
@ -624,7 +644,7 @@ struct ContentView: View {
}
}
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
let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
HStack(spacing: 6) {
@ -642,10 +662,33 @@ struct ContentView: View {
.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
.fixedSize()
}
@ -1224,11 +1267,17 @@ struct ContentView: View {
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 contentHeight = max(0, notchHeight - (isHovering ? 0 : 12))
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(
for: payload,
baseWidth: leadingWidth,
@ -1241,10 +1290,22 @@ struct ContentView: View {
contentHeight: contentHeight,
leadingWidth: leadingWidth,
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
private final class ExtensionRoutingDiagnostics {
static let shared = ExtensionRoutingDiagnostics()
@ -1771,6 +1832,28 @@ struct ContentView: View {
private func hideMusicControlWindow() {}
#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 {

View file

@ -9,7 +9,7 @@ import Combine
import Defaults
import SwiftUI
enum SneakContentType {
enum SneakContentType: Equatable {
case brightness
case volume
case backlight
@ -25,6 +25,34 @@ enum SneakContentType {
case privacy
case lockScreen
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 {
@ -32,6 +60,10 @@ struct sneakPeek {
var type: SneakContentType = .music
var value: CGFloat = 0
var icon: String = ""
var title: String = ""
var subtitle: String = ""
var accentColor: Color?
var styleOverride: SneakPeekStyle? = nil
}
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
switch type {
case .timer:
resolvedDuration = 10
case .reminder:
resolvedDuration = Defaults[.reminderSneakPeekDuration]
case .extensionLiveActivity:
resolvedDuration = duration
default:
resolvedDuration = duration
}
sneakPeekDuration = resolvedDuration
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
}
DispatchQueue.main.async {
@ -196,6 +249,10 @@ class DynamicIslandViewCoordinator: ObservableObject {
self.sneakPeek.type = type
self.sneakPeek.value = value
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" : {
"comment" : "A button that resets the rate limits for a particular extension.",
"isCommentAutoGenerated" : true

View file

@ -9,18 +9,20 @@ struct ExtensionStandaloneLayout {
let leadingWidth: CGFloat
let centerWidth: CGFloat
let trailingWidth: CGFloat
let suppressingCenterText: Bool
}
struct ExtensionLiveActivityStandaloneView: View {
let payload: ExtensionLiveActivityPayload
let layout: ExtensionStandaloneLayout
let isHovering: Bool
let notchState: NotchState
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var contentHeight: CGFloat { layout.contentHeight }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var resolvedLeadingContent: AtollTrailingContent {
descriptor.leadingContent ?? .icon(descriptor.leadingIcon)
resolvedExtensionLeadingContent(for: descriptor)
}
private var resolvedCenterStyle: ExtensionCenterContentView.Style {
switch descriptor.centerTextStyle {
@ -33,6 +35,10 @@ struct ExtensionLiveActivityStandaloneView: View {
}
}
private var shouldSuppressCenterText: Bool {
layout.suppressingCenterText
}
var body: some View {
HStack(spacing: 0) {
ExtensionLeadingContentView(
@ -40,7 +46,8 @@ struct ExtensionLiveActivityStandaloneView: View {
badge: descriptor.badgeIcon,
accent: accentColor,
frameWidth: layout.leadingWidth,
frameHeight: contentHeight
frameHeight: contentHeight,
defaultIcon: descriptor.leadingIcon
)
.frame(width: layout.leadingWidth, height: contentHeight)
@ -48,12 +55,16 @@ struct ExtensionLiveActivityStandaloneView: View {
.fill(Color.black)
.frame(width: layout.centerWidth, height: contentHeight)
.overlay(
Group {
if !shouldSuppressCenterText {
ExtensionCenterContentView(
descriptor: descriptor,
accent: accentColor,
width: layout.centerWidth,
style: resolvedCenterStyle
)
}
}
)
ExtensionMusicWingView(
@ -64,6 +75,10 @@ struct ExtensionLiveActivityStandaloneView: View {
.frame(width: layout.trailingWidth, height: contentHeight)
}
.frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0))
.transition(.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)),
removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9))
))
.animation(.smooth(duration: 0.25), value: payload.id)
.onAppear {
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 accentColor: Color { descriptor.accentColor.swiftUIColor }
private var trailingRenderable: ExtensionTrailingRenderable {
resolvedExtensionTrailingRenderable(for: descriptor)
}
var body: some View {
VStack(alignment: .trailing, spacing: 6) {
if case .none = descriptor.trailingContent {
switch trailingRenderable {
case let .content(content):
if case .none = content {
EmptyView()
} else {
ExtensionEdgeContentView(
content: descriptor.trailingContent,
content: content,
accent: accentColor,
availableWidth: trailingWidth,
alignment: .trailing
)
.frame(maxWidth: .infinity, alignment: .trailing)
}
if let indicator = descriptor.progressIndicator, indicator != .none {
case let .indicator(indicator):
ExtensionProgressIndicatorView(
indicator: indicator,
progress: descriptor.progress,
accent: accentColor,
estimatedDuration: descriptor.estimatedDuration
estimatedDuration: descriptor.estimatedDuration,
maxVisualHeight: notchHeight
)
.frame(maxWidth: .infinity, alignment: .trailing)
} else {
Spacer(minLength: 0)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(.trailing, 8)
@ -127,24 +147,40 @@ struct ExtensionLeadingContentView: View {
let accent: Color
let frameWidth: CGFloat
let frameHeight: CGFloat
let defaultIcon: AtollIconDescriptor
var body: some View {
if case let .icon(iconDescriptor) = content {
Group {
switch content {
case let .icon(iconDescriptor):
ExtensionCompositeIconView(
leading: iconDescriptor,
badge: badge,
accent: accent,
size: frameHeight
)
} else {
ExtensionEdgeContentView(
content: content,
case let .animation(data, size):
let resolvedSize = CGSize(
width: min(size.width, frameHeight),
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,
availableWidth: frameWidth,
alignment: .leading
size: frameHeight
)
}
}
.frame(width: frameWidth, height: frameHeight)
}
}
struct ExtensionCenterContentView: View {
@ -202,3 +238,125 @@ private func logExtensionDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
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 AppKit
import AtollExtensionKit
import Lottie
import LottieUI
import CryptoKit
// MARK: - Color Conversion
@ -130,7 +133,15 @@ struct ExtensionIconView: View {
} else {
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
}
}
@ -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
struct ExtensionProgressIndicatorView: View {
@ -194,39 +268,59 @@ struct ExtensionProgressIndicatorView: View {
let progress: Double
let accent: Color
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 {
switch indicator {
case let .ring(diameter, strokeWidth):
case let .ring(diameter, strokeWidth, color):
let ringColor = color?.swiftUIColor ?? accent
let resolvedDiameter = resolvedRingDiameter(requested: diameter)
ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: strokeWidth)
Circle()
.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))
.animation(.smooth(duration: 0.25), value: progress)
}
.frame(width: diameter, height: diameter)
case let .bar(width, height, cornerRadius):
.frame(width: resolvedDiameter, height: resolvedDiameter)
case let .bar(width, height, cornerRadius, color):
let barColor = color?.swiftUIColor ?? accent
Capsule()
.fill(Color.white.opacity(0.18))
.frame(width: width ?? 80, height: height)
.overlay(alignment: .leading) {
Capsule()
.fill(accent)
.fill(barColor)
.frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height)
.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))%")
.font(font.swiftUIFont())
.foregroundStyle(accent)
.foregroundStyle(textColor)
.monospacedDigit()
case let .countdown(font):
case let .countdown(font, color):
let textColor = color?.swiftUIColor ?? accent
Text(countdownText)
.font(font.swiftUIFont())
.foregroundStyle(accent)
.foregroundStyle(textColor)
.monospacedDigit()
case .lottie:
Image(systemName: "sparkles")
@ -252,6 +346,12 @@ struct ExtensionProgressIndicatorView: View {
private var formatPercent: String {
"\(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
@ -264,23 +364,23 @@ struct ExtensionEdgeContentView: View {
var body: some View {
switch content {
case let .text(value, font: font):
case let .text(value, font: font, color: color):
Text(value)
.font(font.swiftUIFont())
.foregroundStyle(accent)
.foregroundStyle(resolvedColor(color))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: alignment)
case let .marquee(value, font: font, minDuration: duration):
case let .marquee(value, font: font, minDuration: duration, color: color):
MarqueeText(
.constant(value),
font: font.swiftUIFont(),
nsFont: .body,
textColor: accent,
textColor: resolvedColor(color),
minDuration: duration,
frameWidth: max(24, availableWidth - 12)
)
case let .countdownText(targetDate, font: font):
ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent)
case let .countdownText(targetDate, font: font, color: color):
ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.swiftUIColor)
.frame(maxWidth: .infinity, alignment: alignment)
case let .icon(descriptor):
ExtensionIconView(
@ -297,26 +397,30 @@ struct ExtensionEdgeContentView: View {
AudioSpectrumView(isPlaying: .constant(true))
.frame(width: 16, height: 12)
}
case .animation:
Image(systemName: "sparkles")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(accent)
case let .animation(data, size):
let resolvedWidth = min(size.width, availableWidth)
ExtensionLottieView(data: data, size: CGSize(width: resolvedWidth, height: size.height))
case .none:
EmptyView()
}
}
private func resolvedColor(_ descriptorColor: AtollColorDescriptor?) -> Color {
descriptorColor?.swiftUIColor ?? accent
}
}
struct ExtensionCountdownTextView: View {
let targetDate: Date
let font: AtollFontDescriptor
let accent: Color
let customColor: Color?
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(formattedRemaining(since: context.date))
.font(font.swiftUIFont())
.foregroundStyle(accent)
.foregroundStyle(customColor ?? accent)
.monospacedDigit()
}
}
@ -337,8 +441,13 @@ struct ExtensionCountdownTextView: View {
enum ExtensionLayoutMetrics {
static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
var width = edgeWidth(for: payload.descriptor.trailingContent, baseWidth: baseWidth, maxWidth: maxWidth)
if let indicator = payload.descriptor.progressIndicator {
let renderable = resolvedExtensionTrailingRenderable(for: payload.descriptor)
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))
}
if let maxWidth {
@ -357,13 +466,13 @@ enum ExtensionLayoutMetrics {
private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat {
switch content {
case let .text(text, font: font):
case let .text(text, font: font, color: _):
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
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())
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 measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont())
return max(baseWidth, measured + 24)
@ -380,13 +489,13 @@ enum ExtensionLayoutMetrics {
private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat {
switch indicator {
case let .ring(diameter, _):
case let .ring(diameter, _, _):
return diameter + 20
case let .bar(width, _, _):
case let .bar(width, _, _, _):
return (width ?? 72) + 12
case .percentage:
case .percentage(_, _):
return 60
case .countdown:
case .countdown(_, _):
return 74
case let .lottie(_, size):
return size.width + 16
@ -402,3 +511,41 @@ enum ExtensionTextMeasurer {
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 {
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared
@ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared
@ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared
let entry: ExtensionAuthorizationEntry
let onRemove: () -> Void
@ -395,6 +398,8 @@ private struct ExtensionEntryRow: View {
Spacer()
resetMenu
Button("Remove") {
onRemove()
}
@ -404,6 +409,32 @@ private struct ExtensionEntryRow: View {
}
}
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 {
HStack {
Text("\(label):")

View file

@ -1,6 +1,7 @@
import Foundation
import Defaults
import AtollExtensionKit
import SwiftUI
@MainActor
final class ExtensionLiveActivityManager: ObservableObject {
@ -39,6 +40,7 @@ final class ExtensionLiveActivityManager: ObservableObject {
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
let isUpdate: Bool
if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) {
let payload = ExtensionLiveActivityPayload(
bundleIdentifier: bundleIdentifier,
@ -49,10 +51,8 @@ final class ExtensionLiveActivityManager: ObservableObject {
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions)
broadcastSnapshot()
return
}
isUpdate = true
} else {
guard activeActivities.count < Defaults[maxCapacityKey] else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached")
throw ExtensionValidationError.exceedsCapacity
@ -67,7 +67,19 @@ final class ExtensionLiveActivityManager: ObservableObject {
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)")
isUpdate = false
}
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 {
@ -92,7 +104,9 @@ final class ExtensionLiveActivityManager: ObservableObject {
func dismiss(activityID: String, bundleIdentifier: String) {
let previousCount = activeActivities.count
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier }
}
if previousCount != activeActivities.count {
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
@ -105,7 +119,9 @@ final class ExtensionLiveActivityManager: ObservableObject {
let ids = activeActivities
.filter { $0.bundleIdentifier == bundleIdentifier }
.map { $0.descriptor.id }
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
}
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
if !ids.isEmpty {
logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
@ -119,6 +135,10 @@ final class ExtensionLiveActivityManager: ObservableObject {
.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 {
if lhs.descriptor.priority == rhs.descriptor.priority {
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))")
}
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) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)