From 67509b01a06011a8c2d143ddac290f40bd62d9a2 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 11 Jan 2026 23:15:19 +0530 Subject: [PATCH 01/16] Boilerplate --- DynamicIsland/ContentView.swift | 412 +++++++++++++++++++++++++++++--- 1 file changed, 383 insertions(+), 29 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index ebcc50e..f4707a2 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -41,10 +41,24 @@ struct ContentView: View { @Default(.enableTimerFeature) var enableTimerFeature @Default(.timerDisplayMode) var timerDisplayMode @Default(.enableHorizontalMusicGestures) var enableHorizontalMusicGestures + @Default(.reminderPresentationStyle) var reminderPresentationStyle + @Default(.timerShowsCountdown) var timerShowsCountdown + @Default(.timerShowsProgress) var timerShowsProgress + @Default(.timerProgressStyle) var timerProgressStyle + @Default(.timerIconColorMode) var timerIconColorMode + @Default(.timerSolidColor) var timerSolidColor + @Default(.timerPresets) var timerPresets + @Default(.showCapsLockLabel) var showCapsLockLabel + @Default(.capsLockIndicatorTintMode) var capsLockTintMode + @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection + @Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator + @Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent + @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection + @Default(.enableCapsLockIndicator) var enableCapsLockIndicator // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { - var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize if coordinator.currentView == .timer { return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets @@ -620,9 +634,7 @@ struct ContentView: View { } private func reminderColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color { - let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) - let remaining = reminder.event.start.timeIntervalSince(now) - if window > 0 && remaining > 0 && remaining <= window { + if isReminderCritical(reminder, now: now) { return .red } return Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7) @@ -673,8 +685,18 @@ struct ContentView: View { @ViewBuilder func MusicLiveActivity() -> some View { - HStack { - HStack { + let secondary = resolveMusicSecondaryLiveActivity() + let notchContentHeight = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + let wingBaseWidth = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) + let centerBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0) + let inlineSneakPeekActive = (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) + let rightWingWidth = resolvedRightWingWidth(for: secondary, baseWidth: wingBaseWidth, centerBaseWidth: centerBaseWidth) + let centerWidth = resolvedCenterWidth(baseWidth: centerBaseWidth, baseRightWidth: wingBaseWidth, desiredRightWidth: rightWingWidth) + let badgeSize = max(16, notchContentHeight * 0.45) + let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerWidth + + HStack(spacing: 0) { + ZStack(alignment: .bottomTrailing) { Color.clear .aspectRatio(1, contentMode: .fit) .background( @@ -686,12 +708,16 @@ struct ContentView: View { .clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed)) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .albumArtFlip(angle: musicManager.flipAngle) - .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + albumArtBadge(for: secondary, badgeSize: badgeSize) + .offset(x: badgeSize * 0.2, y: badgeSize * 0.25) + .id(secondary?.id ?? "music-badge") + .contentTransition(.symbolEffect(.replace)) } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) + .frame(width: wingBaseWidth, height: notchContentHeight) Rectangle() .fill(.black) + .frame(width: effectiveCenterWidth, height: notchContentHeight) .overlay( HStack(alignment: .top){ if(coordinator.expandingView.show && coordinator.expandingView.type == .music) { @@ -727,30 +753,193 @@ struct ContentView: View { } } ) - .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) - - HStack { - if useMusicVisualizer { - Rectangle() - .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) - .frame(width: 50, alignment: .center) - .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) - .mask { - AudioSpectrumView(isPlaying: $musicManager.isPlaying) - .frame(width: 16, height: 12) - } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) - } else { - LottieAnimationView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + musicRightWing(for: secondary) + .frame(width: rightWingWidth, height: notchContentHeight, alignment: .center) + .id(secondary?.id ?? "music-spectrum") + .contentTransition(.symbolEffect(.replace)) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + .animation(.smooth(duration: 0.25), value: secondary?.id) + } + + private func resolveMusicSecondaryLiveActivity() -> MusicSecondaryLiveActivity? { + if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive { + return .timer + } + + if enableReminderLiveActivity, reminderManager.isActive, let reminder = reminderManager.activeReminder { + return .reminder(reminder) + } + + if enableScreenRecordingDetection && (recordingManager.isRecording || !recordingManager.isRecorderIdle) { + return .recording + } + + if enableDoNotDisturbDetection && showDoNotDisturbIndicator && (doNotDisturbManager.isDoNotDisturbActive || focusIndicatorNonPersistent) { + let mode = FocusModeType.resolve(identifier: doNotDisturbManager.currentFocusModeIdentifier, name: doNotDisturbManager.currentFocusModeName) + return .focus(mode) + } + + if enableCapsLockIndicator && capsLockManager.isCapsLockActive { + return .capsLock(showLabel: showCapsLockLabel) + } + + if showNotHumanFace { + return .coolFace + } + + return nil + } + + private func resolvedRightWingWidth(for secondary: MusicSecondaryLiveActivity?, baseWidth: CGFloat, centerBaseWidth: CGFloat) -> CGFloat { + guard let secondary else { return baseWidth } + + func scaledWidth(factor: CGFloat, extra: CGFloat = 0) -> CGFloat { + max(baseWidth, max(centerBaseWidth * factor, baseWidth + extra)) + } + + switch secondary { + case .timer: + return scaledWidth(factor: 0.58, extra: 28) + case .reminder: + return scaledWidth(factor: 0.52, extra: 22) + case .capsLock(let showLabel): + return showLabel ? scaledWidth(factor: 0.4, extra: 12) : baseWidth + case .coolFace: + return scaledWidth(factor: 0.38, extra: 8) + case .focus: + return scaledWidth(factor: 0.42, extra: 10) + case .recording: + return scaledWidth(factor: 0.45, extra: 14) + } + } + + private func resolvedCenterWidth(baseWidth: CGFloat, baseRightWidth: CGFloat, desiredRightWidth: CGFloat) -> CGFloat { + let delta = max(0, desiredRightWidth - baseRightWidth) + return max(96, baseWidth - delta) + } + + @ViewBuilder + private func albumArtBadge(for secondary: MusicSecondaryLiveActivity?, badgeSize: CGFloat) -> some View { + if let secondary, badgeSize > 0 { + ZStack { + Circle() + .fill(Color.black) + + switch secondary { + case .timer: + Image(systemName: "timer") + .font(.system(size: badgeSize * 0.55, weight: .semibold)) + .foregroundStyle(timerAccentColor) + case .reminder(let entry): + let accent = reminderColor(for: entry, now: reminderManager.currentDate) + Image(systemName: "calendar.badge.clock") + .font(.system(size: badgeSize * 0.55, weight: .semibold)) + .foregroundStyle(accent) + case .focus(let mode): + mode.resolvedActiveIcon(usePrivateSymbol: true) + .renderingMode(.template) + .font(.system(size: badgeSize * 0.5, weight: .semibold)) + .foregroundStyle(mode.accentColor) + case .recording: + Circle() + .fill(Color.red) + .frame(width: badgeSize * 0.45, height: badgeSize * 0.45) + .modifier(PulsingModifier()) + case .capsLock: + Image(systemName: "capslock.fill") + .font(.system(size: badgeSize * 0.5, weight: .semibold)) + .foregroundStyle(capsLockTintMode.color) + case .coolFace: + Image(systemName: "face.smiling") + .font(.system(size: badgeSize * 0.5, weight: .semibold)) + .foregroundStyle(.mint) + } + } + .frame(width: badgeSize, height: badgeSize) + .shadow(color: .black.opacity(0.35), radius: 3, x: 0, y: 1) + .transition(.opacity.combined(with: .scale)) + } else { + EmptyView() + } + } + + @ViewBuilder + private func musicRightWing(for secondary: MusicSecondaryLiveActivity?) -> some View { + switch secondary { + case .timer: + MusicTimerSupplementView( + timerManager: timerManager, + accentColor: timerAccentColor, + showsCountdown: timerShowsCountdown, + showsProgress: timerShowsProgress, + progressStyle: timerProgressStyle + ) + case .reminder(let entry): + MusicReminderSupplementView( + entry: entry, + now: reminderManager.currentDate, + style: reminderPresentationStyle, + accent: reminderColor(for: entry, now: reminderManager.currentDate) + ) + case .capsLock(let showLabel): + if showLabel { + MusicCapsLockLabelView(color: capsLockTintMode.color) + } else { + spectrumView(forceSpectrum: true) + } + case .coolFace: + IdleAnimationView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 4) + case .focus, .recording: + spectrumView(forceSpectrum: true) + case .none: + spectrumView(forceSpectrum: false) + } + } + + @ViewBuilder + private func spectrumView(forceSpectrum: Bool) -> some View { + if useMusicVisualizer || forceSpectrum { + Rectangle() + .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) + .frame(width: 50, alignment: .center) + .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) + .mask { + AudioSpectrumView(isPlaying: $musicManager.isPlaying) + .frame(width: 16, height: 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + LottieAnimationView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var timerAccentColor: Color { + switch timerIconColorMode { + case .adaptive: + if let presetId = timerManager.activePresetId, + let preset = timerPresets.first(where: { $0.id == presetId }) { + return preset.color + } + return timerManager.timerColor + case .solid: + return timerSolidColor + } + } + + private func reminderIconName(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + isReminderCritical(reminder, now: now) ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName + } + + private func isReminderCritical(_ reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool { + let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) + guard window > 0 else { return false } + let remaining = reminder.event.start.timeIntervalSince(now) + return remaining > 0 && remaining <= window } @ViewBuilder @@ -1241,6 +1430,171 @@ struct ContentView: View { #endif } +private enum MusicSecondaryLiveActivity: Equatable { + case timer + case reminder(ReminderLiveActivityManager.ReminderEntry) + case recording + case focus(FocusModeType) + case capsLock(showLabel: Bool) + case coolFace + + var id: String { + switch self { + case .timer: + return "timer" + case .reminder(let entry): + return "reminder-\(entry.id)" + case .recording: + return "recording" + case .focus(let mode): + return "focus-\(mode.rawValue)" + case .capsLock(let showLabel): + return showLabel ? "caps-lock-label" : "caps-lock-icon" + case .coolFace: + return "cool-face" + } + } +} + +private struct MusicTimerSupplementView: View { + @ObservedObject var timerManager: TimerManager + let accentColor: Color + let showsCountdown: Bool + let showsProgress: Bool + let progressStyle: TimerProgressStyle + + private var clampedProgress: Double { + min(max(timerManager.progress, 0), 1) + } + + var body: some View { + HStack(spacing: 10) { + if showsProgress { + if progressStyle == .ring { + ZStack { + Circle() + .stroke(Color.white.opacity(0.18), lineWidth: 3) + Circle() + .trim(from: 0, to: clampedProgress) + .stroke(accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: clampedProgress) + } + .frame(width: 24, height: 24) + } else if !showsCountdown { + barView() + } + } + + if showsCountdown { + VStack(alignment: .trailing, spacing: 2) { + Text(timerManager.formattedRemainingTime()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(timerManager.isOvertime ? .red : .white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + + if showsProgress && progressStyle == .bar { + barView() + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } else if !showsProgress { + Text(timerManager.timerName) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private func barView() -> some View { + Capsule() + .fill(Color.white.opacity(0.15)) + .frame(width: 68, height: 4) + .overlay(alignment: .leading) { + Capsule() + .fill(accentColor) + .frame(width: 68 * max(0, CGFloat(clampedProgress)), height: 4) + .animation(.smooth(duration: 0.25), value: clampedProgress) + } + } +} + +private struct MusicReminderSupplementView: View { + let entry: ReminderLiveActivityManager.ReminderEntry + let now: Date + let style: ReminderPresentationStyle + let accent: Color + + private var progressValue: Double { + guard entry.leadTime > 0 else { return 1 } + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let elapsed = entry.leadTime - remaining + return min(max(elapsed / entry.leadTime, 0), 1) + } + + private var digitalCountdownText: String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let totalSeconds = Int(remaining.rounded(.down)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private var minutesCountdownText: String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let minutes = max(1, Int(ceil(remaining / 60))) + return minutes == 1 ? "in 1 min" : "in \(minutes) min" + } + + var body: some View { + switch style { + case .ringCountdown: + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 3) + Circle() + .trim(from: 0, to: progressValue) + .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progressValue) + } + .frame(width: 24, height: 24) + .frame(maxWidth: .infinity, alignment: .trailing) + + case .digital: + Text(digitalCountdownText) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundColor(accent) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: digitalCountdownText) + .frame(maxWidth: .infinity, alignment: .trailing) + + case .minutes: + Text(minutesCountdownText) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(accent) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } +} + +private struct MusicCapsLockLabelView: View { + let color: Color + + var body: some View { + Text("Caps Lock") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(color) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + .contentTransition(.opacity) + } +} + struct FullScreenDropDelegate: DropDelegate { @Binding var isTargeted: Bool let onDrop: () -> Void From 911ed0da21648c5a6a2cff84c9be55cc008a2712 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 12 Jan 2026 00:24:58 +0530 Subject: [PATCH 02/16] BugFix stuff --- DynamicIsland/ContentView.swift | 409 +++++++++++++++++++++++--------- 1 file changed, 302 insertions(+), 107 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index f4707a2..0d20fd6 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -12,6 +12,11 @@ import Defaults import KeyboardShortcuts import SwiftUI import SwiftUIIntrospect +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif @MainActor struct ContentView: View { @@ -520,17 +525,15 @@ struct ContentView: View { .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || Defaults[.focusIndicatorNonPersistent]) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { - LockScreenLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { - PrivacyLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { + LockScreenLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { + PrivacyLiveActivity() } 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 { DynamicIslandHeader() .frame(height: max(24, vm.effectiveClosedNotchHeight)) - .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) - .animation(.spring(response: 1, dampingFraction: 1, blendDuration: 0.8), value: vm.notchState) } else { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight) } @@ -688,12 +691,23 @@ struct ContentView: View { let secondary = resolveMusicSecondaryLiveActivity() let notchContentHeight = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) let wingBaseWidth = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) - let centerBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0) - let inlineSneakPeekActive = (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) - let rightWingWidth = resolvedRightWingWidth(for: secondary, baseWidth: wingBaseWidth, centerBaseWidth: centerBaseWidth) - let centerWidth = resolvedCenterWidth(baseWidth: centerBaseWidth, baseRightWidth: wingBaseWidth, desiredRightWidth: rightWingWidth) + let rawCenterBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0) + let centerBaseWidth = max(rawCenterBaseWidth, 96) + let inlineSneakPeekActive = ( + coordinator.expandingView.show && + (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && + Defaults[.enableSneakPeek] && + Defaults[.sneakPeekStyles] == .inline + ) + let rightWingWidth = resolvedRightWingWidth( + for: secondary, + baseWidth: wingBaseWidth, + centerBaseWidth: centerBaseWidth, + notchHeight: notchContentHeight + ) + let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerBaseWidth + let notchWidth = wingBaseWidth + effectiveCenterWidth + rightWingWidth let badgeSize = max(16, notchContentHeight * 0.45) - let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerWidth HStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { @@ -729,7 +743,6 @@ struct ContentView: View { ) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) - // Song Artist Text(musicManager.artistName) .lineLimit(1) .truncationMode(.tail) @@ -744,7 +757,6 @@ struct ContentView: View { ) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) - // Timer Status Text(timerManager.formattedRemainingTime()) .lineLimit(1) .truncationMode(.tail) @@ -754,11 +766,12 @@ struct ContentView: View { } ) - musicRightWing(for: secondary) + musicRightWing(for: secondary, notchHeight: notchContentHeight) .frame(width: rightWingWidth, height: notchContentHeight, alignment: .center) .id(secondary?.id ?? "music-spectrum") .contentTransition(.symbolEffect(.replace)) } + .frame(width: notchWidth, height: notchContentHeight) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) .animation(.smooth(duration: 0.25), value: secondary?.id) } @@ -792,32 +805,81 @@ struct ContentView: View { return nil } - private func resolvedRightWingWidth(for secondary: MusicSecondaryLiveActivity?, baseWidth: CGFloat, centerBaseWidth: CGFloat) -> CGFloat { + private func resolvedRightWingWidth(for secondary: MusicSecondaryLiveActivity?, baseWidth: CGFloat, centerBaseWidth: CGFloat, notchHeight: CGFloat) -> CGFloat { guard let secondary else { return baseWidth } - func scaledWidth(factor: CGFloat, extra: CGFloat = 0) -> CGFloat { - max(baseWidth, max(centerBaseWidth * factor, baseWidth + extra)) - } - switch secondary { case .timer: - return scaledWidth(factor: 0.58, extra: 28) - case .reminder: - return scaledWidth(factor: 0.52, extra: 22) + return timerRightWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth) + case .reminder(let entry): + return reminderRightWingWidth(for: entry, baseWidth: baseWidth, notchHeight: notchHeight, now: reminderManager.currentDate) case .capsLock(let showLabel): - return showLabel ? scaledWidth(factor: 0.4, extra: 12) : baseWidth + return showLabel ? scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.4, extra: 12) : baseWidth case .coolFace: - return scaledWidth(factor: 0.38, extra: 8) + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.36, extra: 8) case .focus: - return scaledWidth(factor: 0.42, extra: 10) + return focusRightWingWidth(baseWidth: baseWidth) case .recording: - return scaledWidth(factor: 0.45, extra: 14) + return recordingRightWingWidth(baseWidth: baseWidth) } } - private func resolvedCenterWidth(baseWidth: CGFloat, baseRightWidth: CGFloat, desiredRightWidth: CGFloat) -> CGFloat { - let delta = max(0, desiredRightWidth - baseRightWidth) - return max(96, baseWidth - delta) + private func timerRightWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat) -> CGFloat { + if timerShowsCountdown { + return timerCountdownWingWidth(baseWidth: baseWidth) + } + + let showsProgress = timerShowsProgress + let usesRingProgress = timerProgressStyle == .ring + + switch (showsProgress, usesRingProgress) { + case (true, true): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.46, extra: 18) + case (true, false): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.52, extra: 24) + case (false, _): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.38, extra: 12) + } + } + + private func timerCountdownWingWidth(baseWidth: CGFloat) -> CGFloat { + let padding: CGFloat = 18 + let ringWidth: CGFloat = (timerShowsProgress && timerProgressStyle == .ring) ? 30 : 0 + let spacing: CGFloat = (ringWidth > 0) ? 8 : 0 + let countdownText = timerManager.formattedRemainingTime() + let countdownWidth = TimerSupplementMetrics.countdownFrameWidth(for: countdownText) + return max(baseWidth, padding + ringWidth + spacing + countdownWidth) + } + + private func reminderRightWingWidth(for entry: ReminderLiveActivityManager.ReminderEntry, baseWidth: CGFloat, notchHeight: CGFloat, now: Date) -> CGFloat { + let padding: CGFloat = 16 + switch reminderPresentationStyle { + case .ringCountdown: + let diameter = ReminderSupplementMetrics.ringDiameter(for: notchHeight) + return max(baseWidth, padding + diameter) + case .digital: + let countdownText = ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now) + let width = ReminderSupplementMetrics.digitalFrameWidth(for: countdownText) + return max(baseWidth, padding + width) + case .minutes: + let minutesText = ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now) + let width = ReminderSupplementMetrics.minutesFrameWidth(for: minutesText) + return max(baseWidth, padding + width) + } + } + + private func focusRightWingWidth(baseWidth: CGFloat) -> CGFloat { + // Focus pairings now mirror the default music spectrum width to keep the notch compact. + return baseWidth + } + + private func recordingRightWingWidth(baseWidth: CGFloat) -> CGFloat { + // Recording keeps the spectrum but should stay near the default width with a small inset. + return max(baseWidth + 18, 54) + } + + private func scaledWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat, factor: CGFloat, extra: CGFloat) -> CGFloat { + max(baseWidth, max(centerBaseWidth * factor, baseWidth + extra)) } @ViewBuilder @@ -834,7 +896,7 @@ struct ContentView: View { .foregroundStyle(timerAccentColor) case .reminder(let entry): let accent = reminderColor(for: entry, now: reminderManager.currentDate) - Image(systemName: "calendar.badge.clock") + Image(systemName: "clock") .font(.system(size: badgeSize * 0.55, weight: .semibold)) .foregroundStyle(accent) case .focus(let mode): @@ -866,7 +928,7 @@ struct ContentView: View { } @ViewBuilder - private func musicRightWing(for secondary: MusicSecondaryLiveActivity?) -> some View { + private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat) -> some View { switch secondary { case .timer: MusicTimerSupplementView( @@ -874,14 +936,16 @@ struct ContentView: View { accentColor: timerAccentColor, showsCountdown: timerShowsCountdown, showsProgress: timerShowsProgress, - progressStyle: timerProgressStyle + progressStyle: timerProgressStyle, + notchHeight: notchHeight ) case .reminder(let entry): MusicReminderSupplementView( entry: entry, now: reminderManager.currentDate, style: reminderPresentationStyle, - accent: reminderColor(for: entry, now: reminderManager.currentDate) + accent: reminderColor(for: entry, now: reminderManager.currentDate), + notchHeight: notchHeight ) case .capsLock(let showLabel): if showLabel { @@ -893,15 +957,17 @@ struct ContentView: View { IdleAnimationView() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 4) - case .focus, .recording: + case .focus(let mode): spectrumView(forceSpectrum: true) + case .recording: + spectrumView(forceSpectrum: true, trailingInset: 6) case .none: spectrumView(forceSpectrum: false) } } @ViewBuilder - private func spectrumView(forceSpectrum: Bool) -> some View { + private func spectrumView(forceSpectrum: Bool, trailingInset: CGFloat = 0) -> some View { if useMusicVisualizer || forceSpectrum { Rectangle() .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) @@ -912,6 +978,7 @@ struct ContentView: View { .frame(width: 16, height: 12) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.trailing, trailingInset) } else { LottieAnimationView() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -1462,65 +1529,113 @@ private struct MusicTimerSupplementView: View { let showsCountdown: Bool let showsProgress: Bool let progressStyle: TimerProgressStyle + let notchHeight: CGFloat private var clampedProgress: Double { min(max(timerManager.progress, 0), 1) } + private var showsRingProgress: Bool { + showsProgress && progressStyle == .ring + } + + private var showsBarProgress: Bool { + showsProgress && progressStyle == .bar + } + + private var countdownText: String { + timerManager.formattedRemainingTime() + } + + private var countdownTextWidth: CGFloat { + max(1, TimerSupplementMetrics.countdownTextWidth(for: countdownText)) + } + + private var countdownFrameWidth: CGFloat { + TimerSupplementMetrics.countdownFrameWidth(for: countdownText) + } + + private var timerNameFrameWidth: CGFloat { + TimerSupplementMetrics.timerNameFrameWidth(for: timerManager.timerName) + } + + private var ringDiameter: CGFloat { + max(min(notchHeight - 4, 26), 20) + } + var body: some View { - HStack(spacing: 10) { - if showsProgress { - if progressStyle == .ring { - ZStack { - Circle() - .stroke(Color.white.opacity(0.18), lineWidth: 3) - Circle() - .trim(from: 0, to: clampedProgress) - .stroke(accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.smooth(duration: 0.25), value: clampedProgress) - } - .frame(width: 24, height: 24) - } else if !showsCountdown { - barView() - } + HStack(spacing: showsRingProgress && showsCountdown ? 8 : 0) { + if showsRingProgress { + ringView } if showsCountdown { - VStack(alignment: .trailing, spacing: 2) { - Text(timerManager.formattedRemainingTime()) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundColor(timerManager.isOvertime ? .red : .white) - .contentTransition(.numericText()) - .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) - - if showsProgress && progressStyle == .bar { - barView() - } - } - .frame(maxWidth: .infinity, alignment: .trailing) - } else if !showsProgress { - Text(timerManager.timerName) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .trailing) + countdownStack + } else if showsBarProgress { + standaloneBarView + } else { + timerNameView } } .frame(maxWidth: .infinity, alignment: .trailing) } - private func barView() -> some View { + private var countdownStack: some View { + VStack(alignment: .trailing, spacing: showsBarProgress ? 4 : 0) { + Text(countdownText) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(timerManager.isOvertime ? .red : .white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .frame(width: countdownFrameWidth, alignment: .trailing) + + if showsBarProgress { + barView(width: countdownTextWidth) + } + } + .padding(.trailing, 8) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var ringView: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.18), lineWidth: 3) + Circle() + .trim(from: 0, to: clampedProgress) + .stroke(accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: clampedProgress) + } + .frame(width: ringDiameter, height: ringDiameter) + .frame(width: max(ringDiameter + 4, 30), height: notchHeight, alignment: .center) + } + + private var standaloneBarView: some View { + barView(width: 68) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var timerNameView: some View { + Text(timerManager.timerName) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .frame(width: timerNameFrameWidth, alignment: .trailing) + } + + private func barView(width: CGFloat) -> some View { Capsule() .fill(Color.white.opacity(0.15)) - .frame(width: 68, height: 4) + .frame(width: width, height: 4) .overlay(alignment: .leading) { Capsule() .fill(accentColor) - .frame(width: 68 * max(0, CGFloat(clampedProgress)), height: 4) + .frame(width: width * max(0, CGFloat(clampedProgress)), height: 4) .animation(.smooth(duration: 0.25), value: clampedProgress) } } + } private struct MusicReminderSupplementView: View { @@ -1528,6 +1643,53 @@ private struct MusicReminderSupplementView: View { let now: Date let style: ReminderPresentationStyle let accent: Color + let notchHeight: CGFloat + + var body: some View { + Group { + switch style { + case .ringCountdown: + ringCountdownView + case .digital: + digitalCountdownView + case .minutes: + minutesCountdownView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + + private var ringCountdownView: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 3) + Circle() + .trim(from: 0, to: progressValue) + .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progressValue) + } + .frame(width: ringDiameter, height: ringDiameter) + .frame(width: max(ringDiameter + 4, 26), height: notchHeight, alignment: .center) + } + + private var digitalCountdownView: some View { + Text(digitalCountdownText) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundColor(accent) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: digitalCountdownText) + .frame(width: digitalFrameWidth, alignment: .trailing) + .frame(height: notchHeight, alignment: .center) + } + + private var minutesCountdownView: some View { + Text(minutesCountdownText) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(accent) + .frame(width: minutesFrameWidth, alignment: .trailing) + .frame(height: notchHeight, alignment: .center) + } private var progressValue: Double { guard entry.leadTime > 0 else { return 1 } @@ -1537,48 +1699,23 @@ private struct MusicReminderSupplementView: View { } private var digitalCountdownText: String { - let remaining = max(entry.event.start.timeIntervalSince(now), 0) - let totalSeconds = Int(remaining.rounded(.down)) - let minutes = totalSeconds / 60 - let seconds = totalSeconds % 60 - return String(format: "%02d:%02d", minutes, seconds) + ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now) } private var minutesCountdownText: String { - let remaining = max(entry.event.start.timeIntervalSince(now), 0) - let minutes = max(1, Int(ceil(remaining / 60))) - return minutes == 1 ? "in 1 min" : "in \(minutes) min" + ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now) } - var body: some View { - switch style { - case .ringCountdown: - ZStack { - Circle() - .stroke(Color.white.opacity(0.15), lineWidth: 3) - Circle() - .trim(from: 0, to: progressValue) - .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.smooth(duration: 0.25), value: progressValue) - } - .frame(width: 24, height: 24) - .frame(maxWidth: .infinity, alignment: .trailing) + private var ringDiameter: CGFloat { + ReminderSupplementMetrics.ringDiameter(for: notchHeight) + } - case .digital: - Text(digitalCountdownText) - .font(.system(size: 15, weight: .semibold, design: .monospaced)) - .foregroundColor(accent) - .contentTransition(.numericText()) - .animation(.smooth(duration: 0.25), value: digitalCountdownText) - .frame(maxWidth: .infinity, alignment: .trailing) + private var digitalFrameWidth: CGFloat { + ReminderSupplementMetrics.digitalFrameWidth(for: digitalCountdownText) + } - case .minutes: - Text(minutesCountdownText) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(accent) - .frame(maxWidth: .infinity, alignment: .trailing) - } + private var minutesFrameWidth: CGFloat { + ReminderSupplementMetrics.minutesFrameWidth(for: minutesCountdownText) } } @@ -1595,6 +1732,64 @@ private struct MusicCapsLockLabelView: View { } } +#if canImport(AppKit) +private typealias MusicSupplementFont = NSFont +#elseif canImport(UIKit) +private typealias MusicSupplementFont = UIFont +#endif + +private enum TimerSupplementMetrics { + static func countdownTextWidth(for text: String) -> CGFloat { + musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold)) + } + + static func countdownFrameWidth(for text: String) -> CGFloat { + max(countdownTextWidth(for: text) + 16, 72) + } + + static func timerNameFrameWidth(for text: String) -> CGFloat { + guard !text.isEmpty else { return 64 } + let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 12, weight: .medium)) + return max(width + 14, 64) + } +} + +private enum ReminderSupplementMetrics { + static func digitalCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let totalSeconds = Int(remaining.rounded(.down)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + static func minutesCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let minutes = max(1, Int(ceil(remaining / 60))) + return minutes == 1 ? "in 1 min" : "in \(minutes) min" + } + + static func digitalFrameWidth(for text: String) -> CGFloat { + let width = musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 15, weight: .semibold)) + return max(width + 18, 76) + } + + static func minutesFrameWidth(for text: String) -> CGFloat { + let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 13, weight: .semibold)) + return max(width + 18, 88) + } + + static func ringDiameter(for notchHeight: CGFloat) -> CGFloat { + max(min(notchHeight - 12, 22), 16) + } +} + +private func musicMeasureText(_ text: String, font: MusicSupplementFont) -> CGFloat { + guard !text.isEmpty else { return 0 } + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return CGFloat(ceil(NSAttributedString(string: text, attributes: attributes).size().width)) +} + struct FullScreenDropDelegate: DropDelegate { @Binding var isTargeted: Bool let onDrop: () -> Void From 6a2d74777f22187d078e1a96eaf6e2a568567492 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 12 Jan 2026 00:37:28 +0530 Subject: [PATCH 03/16] Completely working --- DynamicIsland/ContentView.swift | 43 +++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 0d20fd6..4b9d19c 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -478,6 +478,31 @@ struct ContentView: View { .padding(.top, 40) Spacer() } else { + let musicPairingEligible = vm.notchState == .closed + && (musicManager.isPlaying || !musicManager.isPlayerIdle) + && coordinator.musicLiveActivityEnabled + && !vm.hideOnClosed + && !lockScreenManager.isLocked + let musicSecondary = resolveMusicSecondaryLiveActivity() + let expansionMatchesSecondary: Bool = { + guard let musicSecondary else { return false } + switch musicSecondary { + case .timer: + return coordinator.expandingView.type == .timer + case .reminder: + return coordinator.expandingView.type == .reminder + case .recording: + return coordinator.expandingView.type == .recording + case .focus: + return coordinator.expandingView.type == .doNotDisturb + case .capsLock, .coolFace: + return false + } + }() + let canShowMusicDuringExpansion = !coordinator.expandingView.show + || coordinator.expandingView.type == .music + || expansionMatchesSecondary + if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { HStack(spacing: 0) { HStack { @@ -512,13 +537,13 @@ struct ContentView: View { } else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.move(edge: .trailing).combined(with: .opacity)) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked { - MusicLiveActivity() + } else if canShowMusicDuringExpansion && musicPairingEligible { + MusicLiveActivity(secondary: musicSecondary) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { ReminderLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { RecordingLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() @@ -687,8 +712,8 @@ struct ContentView: View { } @ViewBuilder - func MusicLiveActivity() -> some View { - let secondary = resolveMusicSecondaryLiveActivity() + private func MusicLiveActivity(secondary preResolvedSecondary: MusicSecondaryLiveActivity? = nil) -> some View { + let secondary = preResolvedSecondary ?? resolveMusicSecondaryLiveActivity() let notchContentHeight = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) let wingBaseWidth = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) let rawCenterBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0) @@ -874,8 +899,12 @@ struct ContentView: View { } private func recordingRightWingWidth(baseWidth: CGFloat) -> CGFloat { - // Recording keeps the spectrum but should stay near the default width with a small inset. - return max(baseWidth + 18, 54) + // Keep recording pairings compact by reducing the width relative to the notch height. + let absoluteMin: CGFloat = 38 + let preferredWidth = max(baseWidth * 0.6, 0) + let maxWidth = min(baseWidth - 6, 52) + let clampedPreferred = min(preferredWidth, maxWidth) + return min(baseWidth, max(absoluteMin, clampedPreferred)) } private func scaledWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat, factor: CGFloat, extra: CGFloat) -> CGFloat { From 018050081d3ae56e676b6a470f2ad90b869d6767 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 12 Jan 2026 13:17:39 +0530 Subject: [PATCH 04/16] Update Cool Face Animation --- DynamicIsland/ContentView.swift | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 4b9d19c..afa1455 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -495,7 +495,7 @@ struct ContentView: View { return coordinator.expandingView.type == .recording case .focus: return coordinator.expandingView.type == .doNotDisturb - case .capsLock, .coolFace: + case .capsLock: return false } }() @@ -823,10 +823,6 @@ struct ContentView: View { return .capsLock(showLabel: showCapsLockLabel) } - if showNotHumanFace { - return .coolFace - } - return nil } @@ -840,8 +836,6 @@ struct ContentView: View { return reminderRightWingWidth(for: entry, baseWidth: baseWidth, notchHeight: notchHeight, now: reminderManager.currentDate) case .capsLock(let showLabel): return showLabel ? scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.4, extra: 12) : baseWidth - case .coolFace: - return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.36, extra: 8) case .focus: return focusRightWingWidth(baseWidth: baseWidth) case .recording: @@ -942,10 +936,6 @@ struct ContentView: View { Image(systemName: "capslock.fill") .font(.system(size: badgeSize * 0.5, weight: .semibold)) .foregroundStyle(capsLockTintMode.color) - case .coolFace: - Image(systemName: "face.smiling") - .font(.system(size: badgeSize * 0.5, weight: .semibold)) - .foregroundStyle(.mint) } } .frame(width: badgeSize, height: badgeSize) @@ -982,10 +972,6 @@ struct ContentView: View { } else { spectrumView(forceSpectrum: true) } - case .coolFace: - IdleAnimationView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 4) case .focus(let mode): spectrumView(forceSpectrum: true) case .recording: @@ -1532,7 +1518,6 @@ private enum MusicSecondaryLiveActivity: Equatable { case recording case focus(FocusModeType) case capsLock(showLabel: Bool) - case coolFace var id: String { switch self { @@ -1546,8 +1531,6 @@ private enum MusicSecondaryLiveActivity: Equatable { return "focus-\(mode.rawValue)" case .capsLock(let showLabel): return showLabel ? "caps-lock-label" : "caps-lock-icon" - case .coolFace: - return "cool-face" } } } From 9bd445986da026b550435a2c5a785753456ee185 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 13 Jan 2026 14:35:23 +0530 Subject: [PATCH 05/16] Something in the way --- DynamicIsland.xcodeproj/project.pbxproj | 16 + DynamicIsland/ContentView.swift | 75 +++ DynamicIsland/DynamicIslandApp.swift | 3 + DynamicIsland/Localizable.xcstrings | 116 ++++- .../ExtensionLiveActivityViews.swift | 96 ++++ .../ExtensionLockScreenWidgetView.swift | 204 +++++++++ .../ExtensionRenderingHelpers.swift | 349 ++++++++++++++ .../Settings/ExtensionsSettings.swift | 426 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 16 + .../Extensions/ExtensionActivityModels.swift | 53 +++ .../ExtensionAuthorizationManager.swift | 202 +++++++++ .../ExtensionDescriptorValidator.swift | 33 ++ .../ExtensionLiveActivityManager.swift | 90 ++++ .../ExtensionLockScreenWidgetManager.swift | 87 ++++ ...ckScreenWidgetPresentationController.swift | 248 ++++++++++ DynamicIsland/models/Constants.swift | 91 ++++ .../Extensions/ExtensionXPCService.swift | 159 +++++++ .../Extensions/ExtensionXPCServiceHost.swift | 131 ++++++ DynamicIsland/utils/Logger.swift | 1 + 19 files changed, 2395 insertions(+), 1 deletion(-) create mode 100644 DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift create mode 100644 DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift create mode 100644 DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift create mode 100644 DynamicIsland/components/Settings/ExtensionsSettings.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionActivityModels.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift create mode 100644 DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift create mode 100644 DynamicIsland/services/Extensions/ExtensionXPCService.swift create mode 100644 DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 6b9b319..2feae3d 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; + B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = B2C8B0022F11AABB00887766 /* AtollExtensionKit */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -58,6 +59,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */, 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -148,6 +150,7 @@ ); name = DynamicIsland; packageProductDependencies = ( + B2C8B0022F11AABB00887766 /* AtollExtensionKit */, 14D032192C68F32E0096E6A1 /* LaunchAtLogin */, 14D0321C2C68F3350096E6A1 /* Sparkle */, B18654382C6F4990000B926A /* KeyboardShortcuts */, @@ -190,6 +193,7 @@ mainGroup = F54406DE2CD4987A00C7AFD9; minimizedProjectReferenceProxies = 1; packageReferences = ( + B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */, F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */, 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */, 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */, @@ -524,6 +528,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../AtollExtensionKit; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = { isa = XCRemoteSwiftPackageReference; @@ -661,6 +672,11 @@ package = B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */; productName = LottieUI; }; + B2C8B0022F11AABB00887766 /* AtollExtensionKit */ = { + isa = XCSwiftPackageProductDependency; + package = B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */; + productName = AtollExtensionKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F54406DF2CD4987A00C7AFD9 /* Project object */; diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index afa1455..1a5a376 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -34,6 +34,7 @@ struct ContentView: View { @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared @ObservedObject var capsLockManager = CapsLockManager.shared + @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -60,6 +61,7 @@ struct ContentView: View { @Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.enableCapsLockIndicator) var enableCapsLockIndicator + @Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { @@ -484,6 +486,8 @@ struct ContentView: View { && !vm.hideOnClosed && !lockScreenManager.isLocked let musicSecondary = resolveMusicSecondaryLiveActivity() + let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) + let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) let expansionMatchesSecondary: Bool = { guard let musicSecondary else { return false } switch musicSecondary { @@ -497,6 +501,8 @@ struct ContentView: View { return coordinator.expandingView.type == .doNotDisturb case .capsLock: return false + case .extensionPayload: + return false } }() let canShowMusicDuringExpansion = !coordinator.expandingView.show @@ -554,6 +560,14 @@ struct ContentView: View { LockScreenLiveActivity() } 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 { + ExtensionLiveActivityStandaloneView( + payload: extensionPayload, + notchWidth: extensionStandaloneNotchWidth(for: vm.effectiveClosedNotchHeight, isHovering: isHovering), + notchHeight: vm.effectiveClosedNotchHeight, + isHovering: isHovering + ) + .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 { @@ -823,6 +837,10 @@ struct ContentView: View { return .capsLock(showLabel: showCapsLockLabel) } + if let extensionPayload = resolvedExtensionMusicPayload() { + return .extensionPayload(extensionPayload) + } + return nil } @@ -840,6 +858,9 @@ struct ContentView: View { return focusRightWingWidth(baseWidth: baseWidth) case .recording: return recordingRightWingWidth(baseWidth: baseWidth) + case .extensionPayload(let payload): + let maxWidth = baseWidth + centerBaseWidth * 0.6 + return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth) } } @@ -936,6 +957,17 @@ struct ContentView: View { Image(systemName: "capslock.fill") .font(.system(size: badgeSize * 0.5, weight: .semibold)) .foregroundStyle(capsLockTintMode.color) + case .extensionPayload(let payload): + ExtensionCompositeIconView( + leading: payload.descriptor.leadingIcon, + badge: payload.descriptor.badgeIcon, + accent: payload.descriptor.accentColor.swiftUIColor, + size: badgeSize + ) + .background( + Circle() + .fill(Color.black.opacity(0.85)) + ) } } .frame(width: badgeSize, height: badgeSize) @@ -976,6 +1008,8 @@ struct ContentView: View { spectrumView(forceSpectrum: true) case .recording: spectrumView(forceSpectrum: true, trailingInset: 6) + case .extensionPayload(let payload): + ExtensionMusicWingView(payload: payload, notchHeight: notchHeight) case .none: spectrumView(forceSpectrum: false) } @@ -1023,6 +1057,44 @@ struct ContentView: View { let remaining = reminder.event.start.timeIntervalSince(now) return remaining > 0 && remaining <= window } + + private func extensionSecondaryPayloadID(for secondary: MusicSecondaryLiveActivity?) -> String? { + guard case let .extensionPayload(payload) = secondary else { return nil } + return payload.id + } + + private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? { + guard enableExtensionLiveActivities, + vm.notchState == .closed, + !vm.hideOnClosed, + !lockScreenManager.isLocked, + coordinator.musicLiveActivityEnabled else { + return nil + } + return extensionLiveActivityManager.sortedActivities(for: true).first + } + + private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? { + guard enableExtensionLiveActivities, + vm.notchState == .closed, + !vm.hideOnClosed, + !lockScreenManager.isLocked, + vm.effectiveClosedNotchHeight > 0, + !coordinator.expandingView.show else { + return nil + } + + return extensionLiveActivityManager + .sortedActivities() + .first(where: { $0.id != musicPayloadID }) + } + + private func extensionStandaloneNotchWidth(for height: CGFloat, isHovering: Bool) -> CGFloat { + let effectiveCenter = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) + let wing = max(height - (isHovering ? 0 : 12), 0) + let total = effectiveCenter + (wing * 2) + return max(total, effectiveCenter) + } @ViewBuilder var dragDetector: some View { @@ -1518,6 +1590,7 @@ private enum MusicSecondaryLiveActivity: Equatable { case recording case focus(FocusModeType) case capsLock(showLabel: Bool) + case extensionPayload(ExtensionLiveActivityPayload) var id: String { switch self { @@ -1531,6 +1604,8 @@ private enum MusicSecondaryLiveActivity: Equatable { return "focus-\(mode.rawValue)" case .capsLock(let showLabel): return showLabel ? "caps-lock-label" : "caps-lock-icon" + case .extensionPayload(let payload): + return "extension-\(payload.id)" } } } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 0640878..953c982 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -91,6 +91,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel let systemTimerBridge = SystemTimerBridge.shared + let extensionXPCServiceHost = ExtensionXPCServiceHost.shared var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? @@ -132,6 +133,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Cancel any pending window size updates windowSizeUpdateWorkItem?.cancel() NotificationCenter.default.removeObserver(self) + extensionXPCServiceHost.stop() } @objc func onScreenLocked(_: Notification) { @@ -340,6 +342,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm) LockScreenManager.shared.configure(viewModel: vm) + extensionXPCServiceHost.start() // Migrate legacy progress bar settings Defaults.Keys.migrateProgressBarStyle() diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 97c2f8d..b1c9c9c 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -765,6 +765,10 @@ "comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.", "isCommentAutoGenerated" : true }, + "%@:" : { + "comment" : "A row of text with a label and its corresponding value. The argument is the label text, and the second is the value text.", + "isCommentAutoGenerated" : true + }, "%@°" : { "comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).", "isCommentAutoGenerated" : true @@ -872,6 +876,18 @@ "%lld" : { "shouldTranslate" : false }, + "%lld %@" : { + "comment" : "A text label showing the number of apps with which the current app has granted permissions. The argument is the count of such apps.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + } + } + }, "%lld animations" : { "comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.", "isCommentAutoGenerated" : true, @@ -2853,6 +2869,10 @@ } } }, + "Allowed Features" : { + "comment" : "A label describing the allowed features for an app extension.", + "isCommentAutoGenerated" : true + }, "ALPHA" : { "comment" : "A label indicating a feature that is still in development.", "isCommentAutoGenerated" : true @@ -3200,6 +3220,10 @@ } } }, + "App Permissions" : { + "comment" : "A section header in the settings view that lists the permissions of installed apps.", + "isCommentAutoGenerated" : true + }, "Appearance" : { "localizations" : { "ar" : { @@ -3302,6 +3326,10 @@ }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { + }, + "Apps using AtollExtensionKit will appear here once they request permission" : { + "comment" : "A description below the placeholder text in the \"Search extensions...\" text field, explaining that apps using AtollExtensionKit will be listed here once they grant permission to use the app.", + "isCommentAutoGenerated" : true }, "Are you sure you want to delete \"%@\"?" : { "comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.", @@ -3354,6 +3382,14 @@ } } }, + "Authorize" : { + "comment" : "A button that authorizes an app extension.", + "isCommentAutoGenerated" : true + }, + "Authorized" : { + "comment" : "A label indicating that an app has been granted permission to use AtollExtensionKit.", + "isCommentAutoGenerated" : true + }, "Auto-scroll to next event" : { "comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.", "isCommentAutoGenerated" : true @@ -6782,6 +6818,14 @@ } } }, + "Denied/Revoked" : { + "comment" : "A label describing an extension with denied or revoked permissions.", + "isCommentAutoGenerated" : true + }, + "Deny" : { + "comment" : "A button that denies an app extension's access.", + "isCommentAutoGenerated" : true + }, "Description" : { "extractionState" : "stale", "localizations" : { @@ -7926,6 +7970,10 @@ "comment" : "A toggle that allows users to enable or disable customizable controls for media playback.", "isCommentAutoGenerated" : true }, + "Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll." : { + "comment" : "A footer text that explains the purpose of enabling extensions.", + "isCommentAutoGenerated" : true + }, "Enable global keyboard shortcuts above to customize your shortcuts." : { }, @@ -8730,7 +8778,6 @@ "isCommentAutoGenerated" : true }, "Extensions" : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9505,6 +9552,10 @@ } } }, + "Global Settings" : { + "comment" : "The header for the section that controls global settings related to extensions.", + "isCommentAutoGenerated" : true + }, "Got it!" : { "localizations" : { "ar" : { @@ -10859,6 +10910,10 @@ "comment" : "A help text explaining why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.", "isCommentAutoGenerated" : true }, + "Last Denied Reason:" : { + "comment" : "A label for the \"Last Denied Reason\" section in the extensions settings view.", + "isCommentAutoGenerated" : true + }, "Last Updated" : { "localizations" : { "fr" : { @@ -11101,6 +11156,10 @@ "comment" : "A section header for the lock screen reminder widget settings.", "isCommentAutoGenerated" : true }, + "Lock Screen Widgets" : { + "comment" : "A toggle that enables or disables the use of lock screen widgets by the extension.", + "isCommentAutoGenerated" : true + }, "Loop Mode" : { }, @@ -14193,6 +14252,10 @@ } } }, + "No extensions yet" : { + "comment" : "A message displayed when there are no extensions that have requested permission.", + "isCommentAutoGenerated" : true + }, "No favorites" : { "localizations" : { "fr" : { @@ -15045,6 +15108,10 @@ }, "Pause" : { + }, + "Pending" : { + "comment" : "A label indicating that a user's permission to access a feature is being checked.", + "isCommentAutoGenerated" : true }, "Percentage" : { "extractionState" : "stale", @@ -15057,6 +15124,10 @@ } } }, + "Permission States:" : { + "comment" : "A label describing the different permission states displayed in the footer of the extensions settings view.", + "isCommentAutoGenerated" : true + }, "Permissions" : { }, @@ -15602,6 +15673,10 @@ } } }, + "Re-authorize" : { + "comment" : "A button that re-authorizes an app when pressed.", + "isCommentAutoGenerated" : true + }, "Read" : { "comment" : "A heading displayed for the user's disk read speed.", "isCommentAutoGenerated" : true, @@ -15634,6 +15709,10 @@ } } }, + "Recent Activity (last 5 minutes)" : { + "comment" : "A label describing the recent activity of a particular extension.", + "isCommentAutoGenerated" : true + }, "Recent Colors" : { "localizations" : { "fr" : { @@ -15889,6 +15968,17 @@ } } } + }, + "Remove" : { + "comment" : "A button that removes an extension from the authorized list.", + "isCommentAutoGenerated" : true + }, + "Remove %@ from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app." : { + "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 + }, + "Remove Extension" : { + }, "Remove from shelf after dragging" : { @@ -15923,6 +16013,10 @@ } } }, + "Reset Rate Limits" : { + "comment" : "A button that resets the rate limits for a particular extension.", + "isCommentAutoGenerated" : true + }, "Reset to Default" : { "localizations" : { "fr" : { @@ -16074,6 +16168,10 @@ } } }, + "Revoke Access" : { + "comment" : "A button that revokes access to an app.", + "isCommentAutoGenerated" : true + }, "Right" : { }, @@ -16319,6 +16417,10 @@ } } }, + "Search extensions..." : { + "comment" : "A placeholder text for a text field used to filter through a list of extensions.", + "isCommentAutoGenerated" : true + }, "Search notes..." : { "comment" : "A placeholder text for a search bar in the note list view.", "isCommentAutoGenerated" : true @@ -18450,6 +18552,10 @@ } } }, + "Status:" : { + "comment" : "A label for the status of an extension.", + "isCommentAutoGenerated" : true + }, "Stop" : { "localizations" : { "fr" : { @@ -18869,6 +18975,10 @@ }, "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : { + }, + "Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below." : { + "comment" : "A description below the toggle to enable third-party extensions, explaining that third-party apps can display live activities and lock screen widgets, and that this view allows you to manage those permissions.", + "isCommentAutoGenerated" : true }, "This duration powers the \"Custom\" option inside the timer popover for quick access." : { "comment" : "A footer description for the custom timer settings, explaining their purpose.", @@ -20610,6 +20720,10 @@ }, "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { + }, + "Widget Updates" : { + "comment" : "A label describing the number of times the extension has updated its widget.", + "isCommentAutoGenerated" : true }, "Width:" : { "extractionState" : "stale", diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift new file mode 100644 index 0000000..d414e0a --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -0,0 +1,96 @@ +import SwiftUI +import AtollExtensionKit + +struct ExtensionLiveActivityStandaloneView: View { + let payload: ExtensionLiveActivityPayload + let notchWidth: CGFloat + let notchHeight: CGFloat + let isHovering: Bool + + private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } + private var contentHeight: CGFloat { + max(0, notchHeight - (isHovering ? 0 : 12)) + } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + let baseWingWidth = max(contentHeight, 44) + let trailingWidth = ExtensionLayoutMetrics.trailingWidth( + for: payload, + baseWidth: baseWingWidth, + maxWidth: notchWidth * 0.42 + ) + let centerWidth = max(96, notchWidth - baseWingWidth - trailingWidth) + + return HStack(spacing: 0) { + ExtensionCompositeIconView( + leading: descriptor.leadingIcon, + badge: descriptor.badgeIcon, + accent: accentColor, + size: contentHeight + ) + .frame(width: baseWingWidth, height: contentHeight) + + Rectangle() + .fill(Color.black) + .frame(width: centerWidth, height: contentHeight) + .overlay(centerContent) + + ExtensionMusicWingView(payload: payload, notchHeight: contentHeight) + .frame(width: trailingWidth, height: contentHeight) + } + .frame(width: notchWidth, height: notchHeight + (isHovering ? 8 : 0)) + .animation(.smooth(duration: 0.25), value: payload.id) + } + + private var centerContent: some View { + VStack(alignment: .leading, spacing: 2) { + Text(descriptor.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + if let subtitle = descriptor.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.7)) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct ExtensionMusicWingView: View { + let payload: ExtensionLiveActivityPayload + let notchHeight: CGFloat + + private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + VStack(alignment: .trailing, spacing: 6) { + if case .none = descriptor.trailingContent { + EmptyView() + } else { + ExtensionTrailingContentView(content: descriptor.trailingContent, accent: accentColor) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + if let indicator = descriptor.progressIndicator, indicator != .none { + ExtensionProgressIndicatorView( + indicator: indicator, + progress: descriptor.progress, + accent: accentColor, + estimatedDuration: descriptor.estimatedDuration + ) + .frame(maxWidth: .infinity, alignment: .trailing) + } else { + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.trailing, 8) + .padding(.vertical, 6) + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift new file mode 100644 index 0000000..89a3b06 --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -0,0 +1,204 @@ +import SwiftUI +import AtollExtensionKit + +struct ExtensionLockScreenWidgetView: View { + let payload: ExtensionLockScreenWidgetPayload + + private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + ZStack { + backgroundView + contentView + .padding(.horizontal, descriptor.layoutStyle == .circular ? 10 : 16) + .padding(.vertical, descriptor.layoutStyle == .circular ? 10 : 12) + } + .frame(width: descriptor.size.width, height: descriptor.size.height) + .clipShape(RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.04), lineWidth: 1) + ) + } + + @ViewBuilder + private var contentView: some View { + switch descriptor.layoutStyle { + case .inline, .card, .custom: + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in + view(for: element) + .frame(maxWidth: .infinity, alignment: alignment(for: element)) + .accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)") + } + } + case .circular: + ZStack { + ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in + view(for: element) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)") + } + } + } + } + + @ViewBuilder + private func view(for element: AtollWidgetContentElement) -> some View { + switch element { + case let .text(text, font: font, color: color, alignment: _): + Text(text) + .font(font.swiftUIFont()) + .foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9)) + .lineLimit(2) + case let .icon(iconDescriptor, tint): + ExtensionIconView( + descriptor: iconDescriptor, + tint: (tint?.swiftUIColor) ?? accentColor, + size: CGSize(width: 28, height: 28), + cornerRadius: 8 + ) + case let .progress(indicator, value, color): + ExtensionProgressIndicatorView( + indicator: indicator, + progress: value, + accent: (color?.swiftUIColor) ?? accentColor, + estimatedDuration: nil + ) + case let .graph(data, color, size): + ExtensionGraphView(data: data, color: color.swiftUIColor, size: size) + .frame(width: size.width, height: size.height) + case let .gauge(value, minValue, maxValue, style, color): + ExtensionGaugeView( + value: value, + minValue: minValue, + maxValue: maxValue, + style: style, + accent: (color?.swiftUIColor) ?? accentColor + ) + .frame(maxWidth: .infinity) + case let .spacer(height): + Color.clear + .frame(height: height) + case let .divider(color, thickness): + Rectangle() + .fill(color.swiftUIColor.opacity(0.4)) + .frame(height: thickness) + } + } + + private func alignment(for element: AtollWidgetContentElement) -> Alignment { + switch element { + case let .text(_, _, _, alignment): + return alignment.swiftUI + default: + return .leading + } + } + + @ViewBuilder + private var backgroundView: some View { + switch descriptor.material { + case .frosted: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + case .liquid: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(.regularMaterial) + case .solid: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(accentColor.opacity(0.9)) + case .semiTransparent: + RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) + .fill(accentColor.opacity(0.35)) + case .clear: + Color.clear + } + } +} + +private struct ExtensionGraphView: View { + let data: [Double] + let color: Color + let size: CGSize + + var body: some View { + GeometryReader { proxy in + let minValue = data.min() ?? 0 + let maxValue = data.max() ?? 1 + let range = max(maxValue - minValue, 0.0001) + let step = proxy.size.width / CGFloat(max(data.count - 1, 1)) + Path { path in + for (index, value) in data.enumerated() { + let x = CGFloat(index) * step + let normalized = (value - minValue) / range + let y = proxy.size.height - (CGFloat(normalized) * proxy.size.height) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + .frame(width: size.width, height: size.height) + } +} + +private struct ExtensionGaugeView: View { + let value: Double + let minValue: Double + let maxValue: Double + let style: AtollWidgetContentElement.GaugeStyle + let accent: Color + + var body: some View { + switch style { + case .circular: + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 5) + Circle() + .trim(from: 0, to: CGFloat(normalizedValue)) + .stroke(accent, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.3), value: normalizedValue) + Text("\(Int(normalizedValue * 100))%") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .monospacedDigit() + } + .frame(width: 54, height: 54) + case .linear: + GeometryReader { proxy in + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: 8) + .overlay(alignment: .leading) { + Capsule() + .fill(accent) + .frame(width: proxy.size.width * CGFloat(normalizedValue), height: 8) + .animation(.smooth(duration: 0.3), value: normalizedValue) + } + } + .frame(height: 8) + } + } + + private var normalizedValue: Double { + guard maxValue > minValue else { return 0 } + return min(max((value - minValue) / (maxValue - minValue), 0), 1) + } +} + +private extension AtollWidgetContentElement.TextAlignment { + var swiftUI: Alignment { + switch self { + case .leading: return .leading + case .center: return .center + case .trailing: return .trailing + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift new file mode 100644 index 0000000..7bab79a --- /dev/null +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -0,0 +1,349 @@ +import SwiftUI +import AppKit +import AtollExtensionKit + +// MARK: - Color Conversion + +extension AtollColorDescriptor { + var swiftUIColor: Color { + if isAccent { + return .accentColor + } + return Color(red: red, green: green, blue: blue, opacity: alpha) + } + + var nsColor: NSColor { + if isAccent { + return NSColor.controlAccentColor + } + return NSColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +// MARK: - Font Conversion + +extension AtollFontDescriptor { + func swiftUIFont() -> Font { + let design = self.design.swiftUI + let weight = self.weight.swiftUI + var font = Font.system(size: size, weight: weight, design: design) + if isMonospacedDigit { + font = font.monospacedDigit() + } + return font + } + + func nsFont() -> NSFont { + let weight = weight.nsFont + let font: NSFont + switch design { + case .serif: + font = NSFont.userFont(ofSize: size) ?? NSFont.systemFont(ofSize: size, weight: weight) + case .rounded: + if let descriptor = NSFont.systemFont(ofSize: size, weight: weight).fontDescriptor.withDesign(.rounded) { + font = NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size, weight: weight) + } else { + font = NSFont.systemFont(ofSize: size, weight: weight) + } + case .monospaced: + font = NSFont.monospacedSystemFont(ofSize: size, weight: weight) + case .default: + font = NSFont.systemFont(ofSize: size, weight: weight) + } + return font + } +} + +private extension AtollFontWeight { + var swiftUI: Font.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } + + var nsFont: NSFont.Weight { + switch self { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + } + } +} + +private extension AtollFontDesign { + var swiftUI: Font.Design { + switch self { + case .default: return .default + case .serif: return .serif + case .rounded: return .rounded + case .monospaced: return .monospaced + } + } +} + +// MARK: - Icon Rendering + +struct ExtensionIconView: View { + let descriptor: AtollIconDescriptor + let tint: Color + let size: CGSize + let cornerRadius: CGFloat + + var body: some View { + switch descriptor { + case let .symbol(name, glyphSize, weight): + Image(systemName: name) + .font(.system(size: glyphSize, weight: weight.swiftUI)) + .foregroundStyle(tint) + .frame(width: size.width, height: size.height) + case let .image(data, targetSize, radius): + if let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: targetSize.width, height: targetSize.height) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } else { + fallbackSymbol + } + case let .appIcon(bundleIdentifier, targetSize, radius): + if let icon = AppIconAsNSImage(for: bundleIdentifier) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: targetSize.width, height: targetSize.height) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + } else { + fallbackSymbol + } + case .lottie, .none: + fallbackSymbol + } + } + + private var fallbackSymbol: some View { + Image(systemName: "app.dashed") + .font(.system(size: size.width * 0.6, weight: .medium)) + .foregroundStyle(tint) + .frame(width: size.width, height: size.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous) + .fill(tint.opacity(0.15)) + ) + } + + private func cornerRadius(for descriptor: AtollIconDescriptor) -> CGFloat { + switch descriptor { + case .image(_, _, let radius): return radius + case .appIcon(_, _, let radius): return radius + default: return cornerRadius + } + } +} + +struct ExtensionCompositeIconView: View { + let leading: AtollIconDescriptor + let badge: AtollIconDescriptor? + let accent: Color + let size: CGFloat + + var body: some View { + ZStack(alignment: .bottomTrailing) { + ExtensionIconView( + descriptor: leading, + tint: accent, + size: CGSize(width: size, height: size), + cornerRadius: size * 0.18 + ) + if let badge { + ExtensionIconView( + descriptor: badge, + tint: .white, + size: CGSize(width: max(size * 0.35, 12), height: max(size * 0.35, 12)), + cornerRadius: size * 0.12 + ) + .background( + Circle() + .fill(Color.black.opacity(0.7)) + .frame(width: size * 0.4, height: size * 0.4) + ) + } + } + .frame(width: size, height: size) + } +} + +// MARK: - Progress Rendering + +struct ExtensionProgressIndicatorView: View { + let indicator: AtollProgressIndicator + let progress: Double + let accent: Color + let estimatedDuration: TimeInterval? + + var body: some View { + switch indicator { + case let .ring(diameter, strokeWidth): + 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)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progress) + } + .frame(width: diameter, height: diameter) + case let .bar(width, height, cornerRadius): + Capsule() + .fill(Color.white.opacity(0.18)) + .frame(width: width ?? 80, height: height) + .overlay(alignment: .leading) { + Capsule() + .fill(accent) + .frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height) + .animation(.smooth(duration: 0.25), value: progress) + } + case let .percentage(font): + Text("\(Int(progress * 100))%") + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .monospacedDigit() + case let .countdown(font): + Text(countdownText) + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .monospacedDigit() + case .lottie: + Image(systemName: "sparkles") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(accent) + case .none: + EmptyView() + } + } + + private var countdownText: String { + guard let estimatedDuration else { return formatPercent } + let remaining = max(estimatedDuration * (1 - progress), 0) + let hours = Int(remaining) / 3600 + let minutes = (Int(remaining) % 3600) / 60 + let seconds = Int(remaining) % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } + + private var formatPercent: String { + "\(Int(progress * 100))%" + } +} + +// MARK: - Trailing Content Rendering + +struct ExtensionTrailingContentView: View { + let content: AtollTrailingContent + let accent: Color + + var body: some View { + switch content { + case let .text(value, font: font): + Text(value) + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .lineLimit(1) + case let .icon(descriptor): + ExtensionIconView( + descriptor: descriptor, + tint: accent, + size: CGSize(width: 26, height: 26), + cornerRadius: 6 + ) + case let .spectrum(color: colorDescriptor): + Rectangle() + .fill((colorDescriptor.isAccent ? accent : colorDescriptor.swiftUIColor).gradient) + .frame(width: 48, height: 14) + .mask { + AudioSpectrumView(isPlaying: .constant(true)) + .frame(width: 16, height: 12) + } + case .animation: + Image(systemName: "sparkles") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(accent) + case .none: + EmptyView() + } + } +} + +// MARK: - Layout Metrics + +enum ExtensionLayoutMetrics { + static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { + var width = baseWidth + width = max(width, widthForTrailing(content: payload.descriptor.trailingContent, baseWidth: baseWidth)) + if let indicator = payload.descriptor.progressIndicator { + width = max(width, widthForProgress(indicator)) + } + if let maxWidth { + width = min(width, maxWidth) + } + return width + } + + private static func widthForTrailing(content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { + switch content { + case let .text(text, font: font): + let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) + return max(baseWidth, measured + 32) + case .icon: + return max(baseWidth, 52) + case .spectrum: + return max(baseWidth, 56) + case let .animation(data: _, size: size): + return max(baseWidth, size.width + 16) + case .none: + return baseWidth + } + } + + private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat { + switch indicator { + case let .ring(diameter, _): + return diameter + 20 + case let .bar(width, _, _): + return (width ?? 72) + 12 + case .percentage: + return 60 + case .countdown: + return 74 + case let .lottie(_, size): + return size.width + 16 + case .none: + return 0 + } + } +} + +enum ExtensionTextMeasurer { + static func width(for text: String, font: NSFont) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return max(1, text.size(withAttributes: attributes).width) + } +} diff --git a/DynamicIsland/components/Settings/ExtensionsSettings.swift b/DynamicIsland/components/Settings/ExtensionsSettings.swift new file mode 100644 index 0000000..4a051a1 --- /dev/null +++ b/DynamicIsland/components/Settings/ExtensionsSettings.swift @@ -0,0 +1,426 @@ +// +// ExtensionsSettings.swift +// DynamicIsland +// +// Created on 13/01/2026. +// + +import SwiftUI +import Defaults +import AtollExtensionKit + +struct ExtensionsSettingsView: View { + @ObservedObject private var authManager = ExtensionAuthorizationManager.shared + @State private var searchText = "" + @State private var selectedEntry: ExtensionAuthorizationEntry? + @State private var showingRemoveConfirmation = false + + private func highlightID(_ title: String) -> String { + "extensions-\(title)" + } + + private var filteredEntries: [ExtensionAuthorizationEntry] { + guard !searchText.isEmpty else { return authManager.entries } + let query = searchText.lowercased() + return authManager.entries.filter { + $0.bundleIdentifier.lowercased().contains(query) || + $0.appName.lowercased().contains(query) + } + } + + var body: some View { + Form { + globalTogglesSection + + if authManager.isExtensionsFeatureEnabled { + authorizedAppsSection + } + } + .navigationTitle("Extensions") + .alert("Remove Extension", isPresented: $showingRemoveConfirmation, presenting: selectedEntry) { entry in + Button("Cancel", role: .cancel) { } + Button("Remove", role: .destructive) { + authManager.removeEntry(bundleIdentifier: entry.bundleIdentifier) + selectedEntry = nil + } + } message: { entry in + Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app.") + } + } + + private var globalTogglesSection: some View { + Section { + Defaults.Toggle("Enable third-party extensions", key: .enableThirdPartyExtensions) + .settingsHighlight(id: highlightID("Enable third-party extensions")) + + if Defaults[.enableThirdPartyExtensions] { + Defaults.Toggle("Allow extension live activities", key: .enableExtensionLiveActivities) + .settingsHighlight(id: highlightID("Allow extension live activities")) + + Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets) + .settingsHighlight(id: highlightID("Allow extension lock screen widgets")) + + Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled) + .settingsHighlight(id: highlightID("Enable extension diagnostics logging")) + } + } header: { + Text("Global Settings") + } footer: { + if Defaults[.enableThirdPartyExtensions] { + Text("Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var authorizedAppsSection: some View { + Section { + if authManager.entries.isEmpty { + VStack(alignment: .center, spacing: 12) { + Image(systemName: "puzzlepiece.extension") + .font(.system(size: 48)) + .foregroundStyle(.secondary.opacity(0.5)) + + Text("No extensions yet") + .font(.headline) + .foregroundStyle(.secondary) + + Text("Apps using AtollExtensionKit will appear here once they request permission") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + if authManager.entries.count > 3 { + TextField("Search extensions...", text: $searchText) + .textFieldStyle(.roundedBorder) + } + + ForEach(filteredEntries) { entry in + ExtensionEntryRow(entry: entry, onRemove: { + selectedEntry = entry + showingRemoveConfirmation = true + }) + } + } + } header: { + HStack { + Text("App Permissions") + Spacer() + if !authManager.entries.isEmpty { + Text("\(authManager.entries.count) \(authManager.entries.count == 1 ? "app" : "apps")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .settingsHighlight(id: highlightID("App permissions list")) + } footer: { + if !authManager.entries.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Permission States:") + .font(.caption.weight(.semibold)) + + HStack(spacing: 16) { + Label("Authorized", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Label("Pending", systemImage: "clock.fill") + .foregroundStyle(.orange) + Label("Denied/Revoked", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + } + .font(.caption2) + } + .foregroundStyle(.secondary) + } + } + } +} + +private struct ExtensionEntryRow: View { + @ObservedObject private var authManager = ExtensionAuthorizationManager.shared + let entry: ExtensionAuthorizationEntry + let onRemove: () -> Void + + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Main row + HStack(spacing: 12) { + // Status indicator + statusIndicator + + // App info + VStack(alignment: .leading, spacing: 3) { + Text(entry.appName) + .font(.system(size: 13, weight: .medium)) + Text(entry.bundleIdentifier) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + Spacer() + + // Expand button + Button { + withAnimation(.snappy(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .buttonStyle(.plain) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.snappy(duration: 0.2)) { + isExpanded.toggle() + } + } + + // Expanded details + if isExpanded { + expandedDetails + .padding(.top, 12) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.vertical, 8) + } + + private var statusIndicator: some View { + ZStack { + Circle() + .fill(statusColor.opacity(0.15)) + .frame(width: 32, height: 32) + + Image(systemName: statusIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(statusColor) + } + } + + private var statusColor: Color { + switch entry.status { + case .authorized: return .green + case .pending: return .orange + case .denied, .revoked: return .red + } + } + + private var statusIcon: String { + switch entry.status { + case .authorized: return "checkmark.circle.fill" + case .pending: return "clock.fill" + case .denied, .revoked: return "xmark.circle.fill" + } + } + + private var expandedDetails: some View { + VStack(alignment: .leading, spacing: 14) { + // Status info + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Status:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(entry.status.rawValue.capitalized) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(statusColor.opacity(0.15)) + .foregroundStyle(statusColor) + .clipShape(Capsule()) + } + + if let grantedAt = entry.grantedAt { + infoRow(label: "Granted", value: formatDate(grantedAt)) + } + + if let lastActivity = entry.lastActivityAt { + infoRow(label: "Last Activity", value: formatDate(lastActivity)) + } + + if let deniedReason = entry.lastDeniedReason { + VStack(alignment: .leading, spacing: 3) { + Text("Last Denied Reason:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(deniedReason) + .font(.caption) + .foregroundStyle(.red.opacity(0.9)) + } + } + } + + Divider() + + // Scopes section + if entry.status == .authorized { + scopeToggles + Divider() + } + + // Rate limits info + if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), + !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty { + rateLimitInfo(record: rateLimitRecord) + Divider() + } + + // Actions + actionButtons + } + .padding(12) + .background(Color.secondary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var scopeToggles: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Allowed Features") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Toggle("Live Activities", isOn: Binding( + get: { entry.allowedScopes.contains(.liveActivities) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.liveActivities) + } else { + newScopes.remove(.liveActivities) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areLiveActivitiesEnabled) + + Toggle("Lock Screen Widgets", isOn: Binding( + get: { entry.allowedScopes.contains(.lockScreenWidgets) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.lockScreenWidgets) + } else { + newScopes.remove(.lockScreenWidgets) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areLockScreenWidgetsEnabled) + } + } + + private func rateLimitInfo(record: ExtensionRateLimitRecord) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Recent Activity (last 5 minutes)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 20) { + if !record.activityTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Live Activities") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.activityTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } + + if !record.widgetTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Widget Updates") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.widgetTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } + } + + Button("Reset Rate Limits") { + authManager.resetRateLimits(for: entry.bundleIdentifier) + } + .font(.caption) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private var actionButtons: some View { + HStack(spacing: 8) { + switch entry.status { + case .pending: + Button("Authorize") { + authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button("Deny") { + authManager.deny(bundleIdentifier: entry.bundleIdentifier, reason: "Denied by user") + } + .buttonStyle(.bordered) + .controlSize(.small) + + case .authorized: + Button("Revoke Access") { + authManager.revoke(bundleIdentifier: entry.bundleIdentifier, reason: "Revoked by user") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.orange) + + case .denied, .revoked: + Button("Re-authorize") { + authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + Spacer() + + Button("Remove") { + onRemove() + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + } + } + + private func infoRow(label: String, value: String) -> some View { + HStack { + Text("\(label):") + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.caption) + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: .now) + } +} + +#Preview { + ExtensionsSettingsView() +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 7253a29..cf04058 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -24,6 +24,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case lockScreen case media case devices + case extensions case timer case calendar case hudAndOSD @@ -48,6 +49,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .lockScreen: return "Lock Screen" case .media: return "Media" case .devices: return "Devices" + case .extensions: return "Extensions" case .timer: return "Timer" case .calendar: return "Calendar" case .hudAndOSD: return "Controls" @@ -72,6 +74,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .lockScreen: return "lock.laptopcomputer" case .media: return "play.laptopcomputer" case .devices: return "headphones" + case .extensions: return "puzzlepiece.extension" case .timer: return "timer" case .calendar: return "calendar" case .hudAndOSD: return "dial.medium.fill" @@ -96,6 +99,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable { case .lockScreen: return .orange case .media: return .green case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12) + case .extensions: return Color(red: 0.557, green: 0.353, blue: 0.957) case .timer: return .red case .calendar: return .cyan case .hudAndOSD: return .indigo @@ -386,6 +390,7 @@ struct SettingsView: View { .lockScreen, .media, .devices, + .extensions, .timer, .calendar, .hudAndOSD, @@ -689,6 +694,13 @@ struct SettingsView: View { SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")), SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")), + // Extensions + SettingsSearchEntry(tab: .extensions, title: "Enable third-party extensions", keywords: ["extensions", "authorization", "third party"], highlightID: SettingsTab.extensions.highlightID(for: "Enable third-party extensions")), + SettingsSearchEntry(tab: .extensions, title: "Allow extension live activities", keywords: ["extensions", "live activities", "permissions"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension live activities")), + SettingsSearchEntry(tab: .extensions, title: "Allow extension lock screen widgets", keywords: ["extensions", "lock screen", "widgets"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension lock screen widgets")), + SettingsSearchEntry(tab: .extensions, title: "Enable extension diagnostics logging", keywords: ["extensions", "diagnostics", "logging"], highlightID: SettingsTab.extensions.highlightID(for: "Enable extension diagnostics logging")), + SettingsSearchEntry(tab: .extensions, title: "Manage app permissions", keywords: ["extensions", "permissions", "apps"], highlightID: SettingsTab.extensions.highlightID(for: "App permissions list")), + // Shortcuts SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"], highlightID: SettingsTab.shortcuts.highlightID(for: "Enable global keyboard shortcuts")), @@ -766,6 +778,10 @@ struct SettingsView: View { SettingsForm(tab: .devices) { DevicesSettingsView() } + case .extensions: + SettingsForm(tab: .extensions) { + ExtensionsSettingsView() + } case .timer: SettingsForm(tab: .timer) { TimerSettings() diff --git a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift new file mode 100644 index 0000000..78f6533 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift @@ -0,0 +1,53 @@ +import Foundation +import Defaults +import AtollExtensionKit +import SwiftUI + +struct ExtensionLiveActivityPayload: Identifiable, Hashable { + let bundleIdentifier: String + let descriptor: AtollLiveActivityDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } +} + +struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable { + let bundleIdentifier: String + let descriptor: AtollLockScreenWidgetDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } +} + +enum ExtensionValidationError: LocalizedError, Equatable { + case featureDisabled + case unauthorized + case invalidDescriptor(String) + case rateLimited + case exceedsCapacity + case duplicateIdentifier + case unsupportedContent + + var errorDescription: String? { + switch self { + case .featureDisabled: + return "Extensions feature is currently disabled in Atoll." + case .unauthorized: + return "Your app is not authorized to post this content." + case .invalidDescriptor(let reason): + return "Descriptor validation failed: \(reason)." + case .rateLimited: + return "Too many requests in a short period. Please slow down." + case .exceedsCapacity: + return "Atoll reached its limit for simultaneous extension content." + case .duplicateIdentifier: + return "An item with the same identifier already exists." + case .unsupportedContent: + return "Descriptor includes unsupported content for this surface." + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift new file mode 100644 index 0000000..6f852f1 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -0,0 +1,202 @@ +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionAuthorizationManager: ObservableObject { + static let shared = ExtensionAuthorizationManager() + + @Published private(set) var entries: [ExtensionAuthorizationEntry] + @Published private(set) var rateLimitRecords: [ExtensionRateLimitRecord] + + private let persistenceQueue = DispatchQueue(label: "com.atoll.extensions.authorization", qos: .utility) + + private init() { + self.entries = Defaults[.extensionAuthorizationEntries] + self.rateLimitRecords = Defaults[.extensionRateLimitRecords] + normalizeState() + } + + // MARK: - Public API + + var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] } + var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] } + var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] } + + func updateFeatureToggles(extensionsEnabled: Bool? = nil, + liveActivitiesEnabled: Bool? = nil, + lockScreenWidgetsEnabled: Bool? = nil) { + if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled } + if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled } + if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled } + objectWillChange.send() + } + + func authorizationEntry(for bundleIdentifier: String) -> ExtensionAuthorizationEntry? { + entries.first { $0.bundleIdentifier == bundleIdentifier } + } + + func ensureEntryExists(bundleIdentifier: String, appName: String?) -> ExtensionAuthorizationEntry { + if let existing = authorizationEntry(for: bundleIdentifier) { + return existing + } + let resolvedName = appName ?? bundleIdentifier + let entry = ExtensionAuthorizationEntry( + bundleIdentifier: bundleIdentifier, + appName: resolvedName, + status: .pending, + allowedScopes: defaultScopes() + ) + entries.append(entry) + persistEntries() + return entry + } + + func authorize(bundleIdentifier: String, appName: String?, scopes: Set? = nil) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.appName = appName ?? entry.appName + entry.status = .authorized + entry.allowedScopes = scopes ?? entry.allowedScopes + entry.grantedAt = .now + entry.lastDeniedReason = nil + } + } + + func deny(bundleIdentifier: String, reason: String?) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.status = .denied + entry.lastDeniedReason = reason + } + } + + func revoke(bundleIdentifier: String, reason: String?) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.status = .revoked + entry.lastDeniedReason = reason + } + } + + func recordActivity(for bundleIdentifier: String, scope: ExtensionPermissionScope) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.lastActivityAt = .now + } + + updateRateLimitRecord(bundleIdentifier: bundleIdentifier) { record in + let now = Date() + switch scope { + case .liveActivities: + record.activityTimestamps.append(now) + case .lockScreenWidgets: + record.widgetTimestamps.append(now) + } + record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) + record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) + } + } + + func updateAllowedScopes(bundleIdentifier: String, allowedScopes: Set) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.allowedScopes = allowedScopes + } + } + + func resetRateLimits(for bundleIdentifier: String) { + rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier } + persistRateLimitRecords() + } + + func removeEntry(bundleIdentifier: String) { + entries.removeAll { $0.bundleIdentifier == bundleIdentifier } + rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier } + persistEntries() + persistRateLimitRecords() + } + + // MARK: - Validation Helpers + + func canProcessLiveActivityRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .liveActivities) else { return false } + guard areLiveActivitiesEnabled else { return false } + return true + } + + func canProcessLockScreenRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .lockScreenWidgets) else { return false } + guard areLockScreenWidgetsEnabled else { return false } + return true + } + + func recordDeniedRequest(bundleIdentifier: String, reason: String) { + updateEntry(bundleIdentifier: bundleIdentifier) { entry in + entry.lastDeniedReason = reason + entry.status = entry.status == .authorized ? .revoked : entry.status + } + } + + // MARK: - Internal State Updates + + private func updateEntry(bundleIdentifier: String, mutate: (inout ExtensionAuthorizationEntry) -> Void) { + guard let index = entries.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) else { + var entry = ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: bundleIdentifier) + mutate(&entry) + entries.removeAll { $0.bundleIdentifier == bundleIdentifier } + entries.append(entry) + persistEntries() + return + } + var entry = entries[index] + mutate(&entry) + entries[index] = entry + persistEntries() + } + + private func updateRateLimitRecord(bundleIdentifier: String, mutate: (inout ExtensionRateLimitRecord) -> Void) { + if let index = rateLimitRecords.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) { + var record = rateLimitRecords[index] + mutate(&record) + rateLimitRecords[index] = record + persistRateLimitRecords() + } else { + var newRecord = ExtensionRateLimitRecord(bundleIdentifier: bundleIdentifier) + mutate(&newRecord) + rateLimitRecords.append(newRecord) + persistRateLimitRecords() + } + } + + private func preflight(bundleIdentifier: String, scope: ExtensionPermissionScope) -> Bool { + guard isExtensionsFeatureEnabled else { return false } + guard let entry = authorizationEntry(for: bundleIdentifier) else { return false } + guard entry.isAuthorized else { return false } + guard entry.allowedScopes.contains(scope) else { return false } + return true + } + + private func flushOldTimestamps(_ timestamps: [Date]) -> [Date] { + let threshold = Date().addingTimeInterval(-60 * 5) + return timestamps.filter { $0 >= threshold } + } + + private func defaultScopes() -> Set { + Set(ExtensionPermissionScope.allCases) + } + + private func persistEntries() { + let entriesSnapshot = entries + persistenceQueue.async { + Defaults[.extensionAuthorizationEntries] = entriesSnapshot + } + } + + private func persistRateLimitRecords() { + let recordsSnapshot = rateLimitRecords + persistenceQueue.async { + Defaults[.extensionRateLimitRecords] = recordsSnapshot + } + } + + private func normalizeState() { + entries = entries.filter { !$0.bundleIdentifier.isEmpty } + rateLimitRecords = rateLimitRecords.filter { !$0.bundleIdentifier.isEmpty } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift new file mode 100644 index 0000000..293836f --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift @@ -0,0 +1,33 @@ +import Foundation +import AtollExtensionKit + +struct ExtensionDescriptorValidator { + static func validate(_ descriptor: AtollLiveActivityDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + guard descriptor.leadingIcon.isValid else { + throw ExtensionValidationError.invalidDescriptor("Invalid leading icon data") + } + if let badge = descriptor.badgeIcon { + guard badge.isValid else { + throw ExtensionValidationError.invalidDescriptor("Invalid badge icon data") + } + } + if descriptor.metadata.count > 32 { + throw ExtensionValidationError.invalidDescriptor("Metadata keys must be ≤ 32") + } + } + + static func validate(_ descriptor: AtollLockScreenWidgetDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + guard descriptor.content.count <= 12 else { + throw ExtensionValidationError.invalidDescriptor("Too many content elements") + } + guard descriptor.size.width <= 480, descriptor.size.height <= 280 else { + throw ExtensionValidationError.invalidDescriptor("Widget exceeds size limits") + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift new file mode 100644 index 0000000..9155d3e --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -0,0 +1,90 @@ +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionLiveActivityManager: ObservableObject { + static let shared = ExtensionLiveActivityManager() + + @Published private(set) var activeActivities: [ExtensionLiveActivityPayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionLiveActivityCapacity + + private init() {} + + func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else { + throw ExtensionValidationError.unauthorized + } + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard activeActivities.count < Defaults[maxCapacityKey] else { + throw ExtensionValidationError.exceedsCapacity + } + guard activeActivities.contains(where: { $0.descriptor.id == descriptor.id }) == false else { + throw ExtensionValidationError.duplicateIdentifier + } + + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeActivities.append(payload) + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + } + + func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing activity") + } + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeActivities[index].receivedAt + ) + activeActivities[index] = payload + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + } + + func dismiss(activityID: String, bundleIdentifier: String) { + let previousCount = activeActivities.count + 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) + } + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeActivities + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier } + ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) } + } + + func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] { + activeActivities + .filter { coexistence ? $0.descriptor.allowsMusicCoexistence : true } + .sorted(by: descriptorComparator) + } + + private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool { + if lhs.descriptor.priority == rhs.descriptor.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.descriptor.priority > rhs.descriptor.priority + } + + private func sortActivities() { + activeActivities.sort(by: descriptorComparator) + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift new file mode 100644 index 0000000..af24df5 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift @@ -0,0 +1,87 @@ +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionLockScreenWidgetManager: ObservableObject { + static let shared = ExtensionLockScreenWidgetManager() + + @Published private(set) var activeWidgets: [ExtensionLockScreenWidgetPayload] = [] + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity + private var presentationController: ExtensionLockScreenWidgetPresentationController! + + private init() { + presentationController = ExtensionLockScreenWidgetPresentationController(manager: self) + presentationController.activate() + } + + func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { + guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else { + throw ExtensionValidationError.unauthorized + } + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard activeWidgets.count < Defaults[maxCapacityKey] else { + throw ExtensionValidationError.exceedsCapacity + } + guard activeWidgets.contains(where: { $0.descriptor.id == descriptor.id }) == false else { + throw ExtensionValidationError.duplicateIdentifier + } + + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeWidgets.append(payload) + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + } + + func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Structure validation failed") + } + guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { + throw ExtensionValidationError.invalidDescriptor("Missing existing widget") + } + + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeWidgets[index].receivedAt + ) + activeWidgets[index] = payload + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + } + + func dismiss(widgetID: String, bundleIdentifier: String) { + let previousCount = activeWidgets.count + activeWidgets.removeAll { $0.descriptor.id == widgetID && $0.bundleIdentifier == bundleIdentifier } + if previousCount != activeWidgets.count { + Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions) + ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID) + } + } + + func dismissAll(for bundleIdentifier: String) { + let ids = activeWidgets + .filter { $0.bundleIdentifier == bundleIdentifier } + .map { $0.descriptor.id } + activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier } + ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) } + } + + private func sortWidgets() { + activeWidgets.sort { lhs, rhs in + if lhs.priority == rhs.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.priority > rhs.priority + } + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift new file mode 100644 index 0000000..b877dc6 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift @@ -0,0 +1,248 @@ +import SwiftUI +import Combine +import Defaults +import AppKit +import SkyLightWindow +import AtollExtensionKit + +@MainActor +final class ExtensionLockScreenWidgetPresentationController { + private unowned let manager: ExtensionLockScreenWidgetManager + private var cancellables: Set = [] + private let windowPool = ExtensionLockScreenWidgetWindowPool() + private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = [] + private var isLocked: Bool = false + + init(manager: ExtensionLockScreenWidgetManager) { + self.manager = manager + } + + func activate() { + observeLockState() + observePayloads() + observeDefaults() + } + + private func observeLockState() { + LockScreenManager.shared.$isLocked + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] locked in + guard let self else { return } + self.isLocked = locked + self.refreshPresentation() + } + .store(in: &cancellables) + } + + private func observePayloads() { + manager.$activeWidgets + .receive(on: RunLoop.main) + .sink { [weak self] payloads in + guard let self else { return } + self.cachedPayloads = payloads + self.refreshPresentation() + } + .store(in: &cancellables) + } + + private func observeDefaults() { + Defaults.publisher(.enableExtensionLockScreenWidgets, options: []) + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self else { return } + if change.newValue { + self.refreshPresentation() + } else { + self.windowPool.hideAll() + } + } + .store(in: &cancellables) + } + + private func refreshPresentation() { + guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else { + windowPool.hideAll() + return + } + guard cachedPayloads.isEmpty == false else { + windowPool.hideAll() + return + } + windowPool.sync(with: cachedPayloads) + } +} + +@MainActor +private final class ExtensionLockScreenWidgetWindowPool { + private struct WindowRecord { + let window: NSWindow + } + + private var windows: [String: WindowRecord] = [:] + private var payloadsByID: [String: ExtensionLockScreenWidgetPayload] = [:] + private var screenChangeObserver: NSObjectProtocol? + private var workspaceObservers: [NSObjectProtocol] = [] + + init() { + registerScreenChangeObservers() + } + + deinit { + if let screenChangeObserver { + NotificationCenter.default.removeObserver(screenChangeObserver) + } + let workspaceCenter = NSWorkspace.shared.notificationCenter + workspaceObservers.forEach { workspaceCenter.removeObserver($0) } + } + + func sync(with payloads: [ExtensionLockScreenWidgetPayload]) { + payloadsByID = Dictionary(uniqueKeysWithValues: payloads.map { ($0.id, $0) }) + let activeIDs = Set(payloadsByID.keys) + let staleIDs = Set(windows.keys).subtracting(activeIDs) + staleIDs.forEach { hideWindow(withID: $0) } + payloads.forEach { renderWindow(for: $0) } + } + + func hideAll() { + payloadsByID.removeAll() + windows.values.forEach { record in + record.window.orderOut(nil) + record.window.contentView = nil + } + windows.removeAll() + } + + private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) { + guard let screen = currentScreen() else { return } + let descriptor = payload.descriptor + let window = ensureWindow(for: payload.id) + let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload)) + hosting.frame = NSRect(origin: .zero, size: descriptor.size) + hosting.wantsLayer = true + hosting.layer?.masksToBounds = true + hosting.layer?.cornerRadius = descriptor.cornerRadius + window.contentView = hosting + window.setFrame(frame(for: descriptor, on: screen), display: true) + window.alphaValue = 1 + window.orderFrontRegardless() + } + + private func ensureWindow(for id: String) -> NSWindow { + if let record = windows[id] { + return record.window + } + + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: CGSize(width: 200, height: 80)), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + window.isReleasedWhenClosed = false + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + window.ignoresMouseEvents = true + window.isMovable = false + + ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface) + SkyLightOperator.shared.delegateWindow(window) + + let record = WindowRecord(window: window) + windows[id] = record + return window + } + + private func hideWindow(withID id: String) { + guard let record = windows[id] else { return } + record.window.orderOut(nil) + record.window.contentView = nil + windows.removeValue(forKey: id) + } + + private func registerScreenChangeObservers() { + screenChangeObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } + + workspaceObservers = [wakeObserver] + } + + private func handleScreenGeometryChange(reason: String) { + guard windows.isEmpty == false else { return } + repositionAll(animated: false) + print("ExtensionLockScreenWidgetWindowPool: realigned windows due to \(reason)") + } + + private func repositionAll(animated: Bool) { + guard let screen = currentScreen() else { return } + for (id, record) in windows { + guard let payload = payloadsByID[id] else { continue } + let targetFrame = frame(for: payload.descriptor, on: screen) + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + record.window.animator().setFrame(targetFrame, display: true) + } + } else { + record.window.setFrame(targetFrame, display: true) + } + } + } + + private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect { + let size = descriptor.size + let safeInsets = NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48) + let screenFrame = screen.frame + + let minX = screenFrame.minX + safeInsets.left + let maxX = screenFrame.maxX - safeInsets.right - size.width + let minY = screenFrame.minY + safeInsets.bottom + let maxY = screenFrame.maxY - safeInsets.top - size.height + + var originX: CGFloat + switch descriptor.position.alignment { + case .leading: + originX = minX + case .center: + originX = screenFrame.midX - (size.width / 2) + case .trailing: + originX = maxX + } + + originX += descriptor.position.horizontalOffset + originX = clamp(originX, lower: min(minX, maxX), upper: max(minX, maxX)) + + var originY = screenFrame.midY - (size.height / 2) + descriptor.position.verticalOffset + originY = clamp(originY, lower: min(minY, maxY), upper: max(minY, maxY)) + + return NSRect(x: originX, y: originY, width: size.width, height: size.height) + } + + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + guard lower <= upper else { return value } + return min(max(value, lower), upper) + } + + private func currentScreen() -> NSScreen? { + LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 15a711e..16e58d2 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -8,6 +8,7 @@ import SwiftUI import Defaults import Lottie +import Foundation private let availableDirectories = FileManager .default @@ -97,6 +98,86 @@ enum AnimationSource: Codable, Hashable, Equatable { } } +// MARK: - Extension Authorization Models + +enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable { + case liveActivities + case lockScreenWidgets + + var displayName: String { + switch self { + case .liveActivities: return "Live Activities" + case .lockScreenWidgets: return "Lock Screen Widgets" + } + } +} + +enum ExtensionAuthorizationStatus: String, CaseIterable, Codable, Defaults.Serializable { + case pending + case authorized + case denied + case revoked + + var isActive: Bool { + switch self { + case .authorized: return true + case .pending, .denied, .revoked: return false + } + } +} + +struct ExtensionAuthorizationEntry: Codable, Defaults.Serializable, Identifiable, Hashable { + let bundleIdentifier: String + var appName: String + var status: ExtensionAuthorizationStatus + var allowedScopes: Set + var requestedAt: Date + var grantedAt: Date? + var lastActivityAt: Date? + var lastDeniedReason: String? + var notes: String? + + var id: String { bundleIdentifier } + + init( + bundleIdentifier: String, + appName: String, + status: ExtensionAuthorizationStatus, + allowedScopes: Set = Set(ExtensionPermissionScope.allCases), + requestedAt: Date = .now, + grantedAt: Date? = nil, + lastActivityAt: Date? = nil, + lastDeniedReason: String? = nil, + notes: String? = nil + ) { + self.bundleIdentifier = bundleIdentifier + self.appName = appName + self.status = status + self.allowedScopes = allowedScopes + self.requestedAt = requestedAt + self.grantedAt = grantedAt + self.lastActivityAt = lastActivityAt + self.lastDeniedReason = lastDeniedReason + self.notes = notes + } + + var isAuthorized: Bool { status.isActive } +} + +struct ExtensionRateLimitRecord: Codable, Defaults.Serializable, Hashable, Identifiable { + let bundleIdentifier: String + var activityTimestamps: [Date] + var widgetTimestamps: [Date] + + var id: String { bundleIdentifier } + + init(bundleIdentifier: String, activityTimestamps: [Date] = [], widgetTimestamps: [Date] = []) { + self.bundleIdentifier = bundleIdentifier + self.activityTimestamps = activityTimestamps + self.widgetTimestamps = widgetTimestamps + } +} + enum CalendarSelectionState: Codable, Defaults.Serializable { case all case selected(Set) @@ -714,6 +795,16 @@ extension Defaults.Keys { static let selectedAIModel = Key("selectedAIModel", default: nil) static let enableThinkingMode = Key("enableThinkingMode", default: false) static let localModelEndpoint = Key("localModelEndpoint", default: "http://localhost:11434") + + // MARK: Third-Party Extensions + static let enableThirdPartyExtensions = Key("enableThirdPartyExtensions", default: true) + static let enableExtensionLiveActivities = Key("enableExtensionLiveActivities", default: true) + static let enableExtensionLockScreenWidgets = Key("enableExtensionLockScreenWidgets", default: true) + static let extensionAuthorizationEntries = Key<[ExtensionAuthorizationEntry]>("extensionAuthorizationEntries", default: []) + static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: []) + static let extensionDiagnosticsLoggingEnabled = Key("extensionDiagnosticsLoggingEnabled", default: true) + static let extensionLiveActivityCapacity = Key("extensionLiveActivityCapacity", default: 4) + static let extensionLockScreenWidgetCapacity = Key("extensionLockScreenWidgetCapacity", default: 4) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/services/Extensions/ExtensionXPCService.swift b/DynamicIsland/services/Extensions/ExtensionXPCService.swift new file mode 100644 index 0000000..60d5b0c --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionXPCService.swift @@ -0,0 +1,159 @@ +import AppKit +import Foundation +import Defaults +import AtollExtensionKit + +@MainActor +final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { + private let bundleIdentifier: String + private weak var host: ExtensionXPCServiceHost? + private weak var connection: NSXPCConnection? + + private let authorizationManager = ExtensionAuthorizationManager.shared + private let liveActivityManager = ExtensionLiveActivityManager.shared + private let widgetManager = ExtensionLockScreenWidgetManager.shared + private let decoder = JSONDecoder() + + init(bundleIdentifier: String, host: ExtensionXPCServiceHost, connection: NSXPCConnection) { + self.bundleIdentifier = bundleIdentifier + self.host = host + self.connection = connection + super.init() + } + + // MARK: Authorization + + func requestAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + guard authorizationManager.isExtensionsFeatureEnabled else { + reply(false, ExtensionValidationError.featureDisabled.asNSError) + return + } + + let entry = authorizationManager.ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName()) + + if entry.status == .pending { + authorizationManager.authorize(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName()) + host?.notifyAuthorizationChange(bundleIdentifier: bundleIdentifier, isAuthorized: true) + reply(true, nil) + return + } + + reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError) + } + + func checkAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool) -> Void) { + guard providedBundleIdentifier == bundleIdentifier else { + reply(false) + return + } + + let isAuthorized = authorizationManager.authorizationEntry(for: bundleIdentifier)?.isAuthorized ?? false + reply(isAuthorized) + } + + // MARK: Live Activities + + func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func dismissLiveActivity(activityID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: bundleIdentifier) + reply(true, nil) + } + + // MARK: Lock Screen Widgets + + func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try widgetManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try widgetManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + } + } + + func dismissLockScreenWidget(widgetID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bundleIdentifier) + reply(true, nil) + } + + // MARK: Diagnostics + + func getVersion(reply: @escaping (String) -> Void) { + reply(appVersion) + } + + // MARK: Helpers + + private func respond(reply: @escaping (Bool, Error?) -> Void, operation: () throws -> Void) { + do { + try operation() + reply(true, nil) + } catch { + if Defaults[.extensionDiagnosticsLoggingEnabled] { + Logger.log("Extension XPC request failed: \(error)", category: .extensions) + } + reply(false, error.asNSError) + } + } + + private func validate(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) -> Bool { + guard providedBundleIdentifier == bundleIdentifier else { + let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier) + reply(false, error.asNSError) + return false + } + return true + } + + private func resolvedApplicationName() -> String { + guard let processIdentifier = connection?.processIdentifier, + processIdentifier != 0, + let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)), + let name = app.localizedName else { + return bundleIdentifier + } + return name + } +} + +private enum ExtensionXPCServiceError: LocalizedError { + case bundleMismatch(expected: String, received: String) + + var errorDescription: String? { + switch self { + case let .bundleMismatch(expected, received): + return "Bundle identifier mismatch. Expected \(expected) but received \(received)." + } + } +} + +private extension Error { + var asNSError: NSError { self as NSError } +} diff --git a/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift new file mode 100644 index 0000000..670699e --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift @@ -0,0 +1,131 @@ +import AppKit +import Foundation +import AtollExtensionKit + +/// Shared constants for the Atoll extension XPC service. +enum ExtensionXPCServiceConstants { + static let machServiceName = "com.ebullioscopic.Atoll.xpc" +} + +@MainActor +final class ExtensionXPCServiceHost: NSObject, NSXPCListenerDelegate { + static let shared = ExtensionXPCServiceHost() + + private final class ClientContext { + weak var connection: NSXPCConnection? + let bundleIdentifier: String + + init(connection: NSXPCConnection, bundleIdentifier: String) { + self.connection = connection + self.bundleIdentifier = bundleIdentifier + } + } + + private var listener: NSXPCListener? + private var clientContexts: [ObjectIdentifier: ClientContext] = [:] + + func start() { + guard listener == nil else { return } + + let listener = NSXPCListener(machServiceName: ExtensionXPCServiceConstants.machServiceName) + listener.delegate = self + self.listener = listener + listener.resume() + + Logger.log("Started Atoll XPC listener", category: .extensions) + } + + func stop() { + listener?.invalidate() + listener = nil + clientContexts.removeAll() + Logger.log("Stopped Atoll XPC listener", category: .extensions) + } + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + guard let bundleIdentifier = resolveBundleIdentifier(for: connection) else { + Logger.log("Rejected XPC connection without bundle identifier", category: .extensions) + return false + } + + let service = ExtensionXPCService(bundleIdentifier: bundleIdentifier, host: self, connection: connection) + connection.exportedInterface = NSXPCInterface(with: AtollXPCServiceProtocol.self) + connection.exportedObject = service + connection.remoteObjectInterface = NSXPCInterface(with: AtollXPCClientProtocol.self) + + connection.invalidationHandler = { [weak self, weak connection] in + guard let connection else { return } + Task { @MainActor [weak self] in + self?.removeConnection(connection) + } + } + + connection.interruptionHandler = { [weak self, weak connection] in + guard let connection else { return } + Task { @MainActor [weak self] in + self?.removeConnection(connection) + } + } + + connection.resume() + clientContexts[ObjectIdentifier(connection)] = ClientContext(connection: connection, bundleIdentifier: bundleIdentifier) + Logger.log("Accepted XPC connection from \(bundleIdentifier)", category: .extensions) + return true + } + + func notifyAuthorizationChange(bundleIdentifier: String, isAuthorized: Bool) { + deliver(to: bundleIdentifier) { client in + client.authorizationDidChange(isAuthorized: isAuthorized) + } + } + + func notifyActivityDismiss(bundleIdentifier: String, activityID: String) { + deliver(to: bundleIdentifier) { client in + client.activityDidDismiss(activityID: activityID) + } + } + + func notifyWidgetDismiss(bundleIdentifier: String, widgetID: String) { + deliver(to: bundleIdentifier) { client in + client.widgetDidDismiss(widgetID: widgetID) + } + } + + private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? { + let processIdentifier = connection.processIdentifier + guard processIdentifier != 0 else { return nil } + + if let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)), + let bundleIdentifier = app.bundleIdentifier { + return bundleIdentifier + } + + return nil + } + + private func deliver(to bundleIdentifier: String, send block: (AtollXPCClientProtocol) -> Void) { + var staleIdentifiers: [ObjectIdentifier] = [] + + for (identifier, context) in clientContexts where context.bundleIdentifier == bundleIdentifier { + guard let connection = context.connection else { + staleIdentifiers.append(identifier) + continue + } + + guard let client = connection.remoteObjectProxyWithErrorHandler({ error in + Logger.log("Failed to deliver XPC callback to \(bundleIdentifier): \(error)", category: .extensions) + }) as? AtollXPCClientProtocol else { + continue + } + + block(client) + } + + staleIdentifiers.forEach { clientContexts.removeValue(forKey: $0) } + } + + private func removeConnection(_ connection: NSXPCConnection) { + clientContexts.removeValue(forKey: ObjectIdentifier(connection)) + Logger.log("Removed XPC connection", category: .extensions) + } +} diff --git a/DynamicIsland/utils/Logger.swift b/DynamicIsland/utils/Logger.swift index e95cdba..5a50e34 100644 --- a/DynamicIsland/utils/Logger.swift +++ b/DynamicIsland/utils/Logger.swift @@ -11,6 +11,7 @@ enum LogCategory: String { case warning = "⚠️" case success = "✅" case debug = "🔍" + case extensions = "🧩" } struct Logger { From 94a612f3276784174b8e506805eecb3a836e1ef2 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 14 Jan 2026 15:11:57 +0530 Subject: [PATCH 06/16] Working stuff --- DynamicIsland.xcodeproj/project.pbxproj | 36 +-- .../xcshareddata/swiftpm/Package.resolved | 11 +- DynamicIsland/ContentView.swift | 249 +++++++++++++++--- .../ExtensionLiveActivityViews.swift | 188 ++++++++++--- .../ExtensionLockScreenWidgetView.swift | 12 + .../ExtensionRenderingHelpers.swift | 65 ++++- .../Extensions/ExtensionActivityModels.swift | 4 +- .../Extensions/ExtensionEventBridge.swift | 147 +++++++++++ .../ExtensionLiveActivityManager.swift | 71 ++++- .../ExtensionLockScreenWidgetManager.swift | 73 ++++- ...ckScreenWidgetPresentationController.swift | 41 ++- .../Extensions/ExtensionXPCService.swift | 131 +++++---- DynamicIsland/utils/Logger.swift | 43 ++- 13 files changed, 917 insertions(+), 154 deletions(-) create mode 100644 DynamicIsland/managers/Extensions/ExtensionEventBridge.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 2feae3d..a09045d 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7E61181E2E24071E0050006C /* SkyLightWindow */; }; + 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7E288D112F1659EF0020C117 /* AtollExtensionKit */; }; 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; }; 7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; }; 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; }; @@ -26,7 +27,7 @@ 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; - B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = B2C8B0022F11AABB00887766 /* AtollExtensionKit */; }; + 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -42,6 +43,7 @@ 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = ""; }; 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtensionEventBridge.swift; path = DynamicIsland/managers/Extensions/ExtensionEventBridge.swift; sourceTree = SOURCE_ROOT; }; F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -59,7 +61,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */, 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -70,6 +71,7 @@ 7E6118172E24071E0050006C /* Collections in Frameworks */, 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */, 7E6118152E24071E0050006C /* Sparkle in Frameworks */, + 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */, 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -150,7 +152,6 @@ ); name = DynamicIsland; packageProductDependencies = ( - B2C8B0022F11AABB00887766 /* AtollExtensionKit */, 14D032192C68F32E0096E6A1 /* LaunchAtLogin */, 14D0321C2C68F3350096E6A1 /* Sparkle */, B18654382C6F4990000B926A /* KeyboardShortcuts */, @@ -160,6 +161,7 @@ B194240A2CD10017003E5DC2 /* LottieUI */, 146BC4362D366E9B0047C66A /* MacroVisionKit */, 7E61181E2E24071E0050006C /* SkyLightWindow */, + 7E288D112F1659EF0020C117 /* AtollExtensionKit */, ); productName = DynamicIsland; productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */; @@ -193,7 +195,6 @@ mainGroup = F54406DE2CD4987A00C7AFD9; minimizedProjectReferenceProxies = 1; packageReferences = ( - B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */, F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */, 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */, 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */, @@ -205,6 +206,7 @@ 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */, 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, + 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -239,6 +241,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -528,13 +531,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../AtollExtensionKit; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = { isa = XCRemoteSwiftPackageReference; @@ -568,6 +564,14 @@ minimumVersion = 1.4.16; }; }; + 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Ebullioscopic/AtollExtensionKit"; + requirement = { + branch = main; + kind = branch; + }; + }; 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Lakr233/SkyLightWindow"; @@ -642,6 +646,11 @@ package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 7E288D112F1659EF0020C117 /* AtollExtensionKit */ = { + isa = XCSwiftPackageProductDependency; + package = 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */; + productName = AtollExtensionKit; + }; 7E61181E2E24071E0050006C /* SkyLightWindow */ = { isa = XCSwiftPackageProductDependency; package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */; @@ -672,11 +681,6 @@ package = B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */; productName = LottieUI; }; - B2C8B0022F11AABB00887766 /* AtollExtensionKit */ = { - isa = XCSwiftPackageProductDependency; - package = B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */; - productName = AtollExtensionKit; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F54406DF2CD4987A00C7AFD9 /* Project object */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13ffff6..2cf39bc 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "3b2c9100f16643012ece44ae6bfed9555c2d858d8ac2f98547c70e4c1738f0f6", + "originHash" : "6fbfa8cda0c383746edd66361426fdc2bd266fc082867294374a1818f02798f3", "pins" : [ + { + "identity" : "atollextensionkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", + "state" : { + "branch" : "main", + "revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f" + } + }, { "identity" : "defaults", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1a5a376..f320769 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -9,6 +9,7 @@ import AVFoundation import Combine import Defaults +import Foundation import KeyboardShortcuts import SwiftUI import SwiftUIIntrospect @@ -480,12 +481,18 @@ struct ContentView: View { .padding(.top, 40) Spacer() } else { + let hasMusicMetadata = !musicManager.songTitle.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty + || !musicManager.artistName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty + let hasActiveMusicSnapshot: Bool = { + if musicManager.isPlaying { return true } + return !musicManager.isPlayerIdle && hasMusicMetadata + }() let musicPairingEligible = vm.notchState == .closed - && (musicManager.isPlaying || !musicManager.isPlayerIdle) + && hasActiveMusicSnapshot && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked - let musicSecondary = resolveMusicSecondaryLiveActivity() + let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) let expansionMatchesSecondary: Bool = { @@ -561,10 +568,14 @@ 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 layout = extensionStandaloneLayout( + for: extensionPayload, + notchHeight: vm.effectiveClosedNotchHeight, + isHovering: isHovering + ) ExtensionLiveActivityStandaloneView( payload: extensionPayload, - notchWidth: extensionStandaloneNotchWidth(for: vm.effectiveClosedNotchHeight, isHovering: isHovering), - notchHeight: vm.effectiveClosedNotchHeight, + layout: layout, isHovering: isHovering ) .transition(.opacity.combined(with: .scale)) @@ -805,7 +816,7 @@ struct ContentView: View { } ) - musicRightWing(for: secondary, notchHeight: notchContentHeight) + musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth) .frame(width: rightWingWidth, height: notchContentHeight, alignment: .center) .id(secondary?.id ?? "music-spectrum") .contentTransition(.symbolEffect(.replace)) @@ -815,7 +826,7 @@ struct ContentView: View { .animation(.smooth(duration: 0.25), value: secondary?.id) } - private func resolveMusicSecondaryLiveActivity() -> MusicSecondaryLiveActivity? { + private func resolveMusicSecondaryLiveActivity(isMusicPairingEligible: Bool = true) -> MusicSecondaryLiveActivity? { if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive { return .timer } @@ -837,7 +848,7 @@ struct ContentView: View { return .capsLock(showLabel: showCapsLockLabel) } - if let extensionPayload = resolvedExtensionMusicPayload() { + if isMusicPairingEligible, let extensionPayload = resolvedExtensionMusicPayload() { return .extensionPayload(extensionPayload) } @@ -979,7 +990,7 @@ struct ContentView: View { } @ViewBuilder - private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat) -> some View { + private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat, trailingWidth: CGFloat) -> some View { switch secondary { case .timer: MusicTimerSupplementView( @@ -1004,12 +1015,12 @@ struct ContentView: View { } else { spectrumView(forceSpectrum: true) } - case .focus(let mode): + case .focus: spectrumView(forceSpectrum: true) case .recording: spectrumView(forceSpectrum: true, trailingInset: 6) case .extensionPayload(let payload): - ExtensionMusicWingView(payload: payload, notchHeight: notchHeight) + ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth) case .none: spectrumView(forceSpectrum: false) } @@ -1064,36 +1075,214 @@ struct ContentView: View { } private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? { - guard enableExtensionLiveActivities, - vm.notchState == .closed, - !vm.hideOnClosed, - !lockScreenManager.isLocked, - coordinator.musicLiveActivityEnabled else { + let candidates = extensionLiveActivityManager.sortedActivities(for: true) + guard let payload = candidates.first else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "no eligible coexistence payloads", + pendingCount: candidates.count + ) + ExtensionRoutingDiagnostics.shared.reset(.music) return nil } - return extensionLiveActivityManager.sortedActivities(for: true).first + + guard enableExtensionLiveActivities else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "feature toggle disabled", + pendingCount: candidates.count + ) + return nil + } + + guard vm.notchState == .closed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "notch is \(vm.notchState)", + pendingCount: candidates.count + ) + return nil + } + + guard !vm.hideOnClosed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "hideOnClosed engaged (fullscreen)", + pendingCount: candidates.count + ) + return nil + } + + guard !lockScreenManager.isLocked else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "lock screen currently active", + pendingCount: candidates.count + ) + return nil + } + + guard coordinator.musicLiveActivityEnabled else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .music, + reason: "music live activity disabled in settings", + pendingCount: candidates.count + ) + return nil + } + + ExtensionRoutingDiagnostics.shared.logDisplay(.music, payload: payload) + return payload } private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? { - guard enableExtensionLiveActivities, - vm.notchState == .closed, - !vm.hideOnClosed, - !lockScreenManager.isLocked, - vm.effectiveClosedNotchHeight > 0, - !coordinator.expandingView.show else { + let baseCandidates = extensionLiveActivityManager.sortedActivities() + guard !baseCandidates.isEmpty else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "no active extension payloads", + pendingCount: 0 + ) + ExtensionRoutingDiagnostics.shared.reset(.standalone) return nil } - return extensionLiveActivityManager - .sortedActivities() - .first(where: { $0.id != musicPayloadID }) + let candidates = baseCandidates.filter { $0.id != musicPayloadID } + guard let payload = candidates.first else { + if let musicPayloadID { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "all pending payloads are paired with music (\(musicPayloadID))", + pendingCount: baseCandidates.count + ) + } else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "no standalone payloads after filtering", + pendingCount: baseCandidates.count + ) + ExtensionRoutingDiagnostics.shared.reset(.standalone) + } + return nil + } + + guard enableExtensionLiveActivities else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "feature toggle disabled", + pendingCount: candidates.count + ) + return nil + } + + guard vm.notchState == .closed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "notch is \(vm.notchState)", + pendingCount: candidates.count + ) + return nil + } + + guard !vm.hideOnClosed else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "hideOnClosed engaged (fullscreen)", + pendingCount: candidates.count + ) + return nil + } + + guard !lockScreenManager.isLocked else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "lock screen currently active", + pendingCount: candidates.count + ) + return nil + } + + guard vm.effectiveClosedNotchHeight > 0 else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "effective notch height is \(vm.effectiveClosedNotchHeight)", + pendingCount: candidates.count + ) + return nil + } + + guard !coordinator.expandingView.show else { + ExtensionRoutingDiagnostics.shared.logSuppression( + .standalone, + reason: "expanding view \(coordinator.expandingView.type) visible", + pendingCount: candidates.count + ) + return nil + } + + ExtensionRoutingDiagnostics.shared.logDisplay(.standalone, payload: payload) + return payload } - private func extensionStandaloneNotchWidth(for height: CGFloat, isHovering: Bool) -> CGFloat { - let effectiveCenter = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) - let wing = max(height - (isHovering ? 0 : 12), 0) - let total = effectiveCenter + (wing * 2) - return max(total, effectiveCenter) + private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: 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 trailingWidth = ExtensionLayoutMetrics.trailingWidth( + for: payload, + baseWidth: leadingWidth, + maxWidth: leadingWidth + centerWidth * 0.6 + ) + let totalWidth = leadingWidth + centerWidth + trailingWidth + return ExtensionStandaloneLayout( + totalWidth: totalWidth, + outerHeight: outerHeight, + contentHeight: contentHeight, + leadingWidth: leadingWidth, + centerWidth: centerWidth, + trailingWidth: trailingWidth + ) + } + + @MainActor + private final class ExtensionRoutingDiagnostics { + static let shared = ExtensionRoutingDiagnostics() + + enum Channel: Hashable { + case music + case standalone + + var label: String { + switch self { + case .music: + return "music pairing" + case .standalone: + return "standalone notch" + } + } + } + + private var lastMessages: [Channel: String] = [:] + + func logSuppression(_ channel: Channel, reason: String, pendingCount: Int) { + log("Extension \(channel.label) suppressed: \(reason) (pending: \(pendingCount))", channel: channel) + } + + func logDisplay(_ channel: Channel, payload: ExtensionLiveActivityPayload) { + log("Extension \(channel.label) showing \(payload.descriptor.id) from \(payload.bundleIdentifier)", channel: channel) + } + + func reset(_ channel: Channel) { + lastMessages.removeValue(forKey: channel) + } + + private func log(_ message: String, channel: Channel) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + guard lastMessages[channel] != message else { return } + lastMessages[channel] = message + Logger.log(message, category: .extensions) + } } @ViewBuilder diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index d414e0a..641e11f 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -1,69 +1,84 @@ import SwiftUI +import Defaults import AtollExtensionKit +struct ExtensionStandaloneLayout { + let totalWidth: CGFloat + let outerHeight: CGFloat + let contentHeight: CGFloat + let leadingWidth: CGFloat + let centerWidth: CGFloat + let trailingWidth: CGFloat +} + struct ExtensionLiveActivityStandaloneView: View { let payload: ExtensionLiveActivityPayload - let notchWidth: CGFloat - let notchHeight: CGFloat + let layout: ExtensionStandaloneLayout let isHovering: Bool private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } - private var contentHeight: CGFloat { - max(0, notchHeight - (isHovering ? 0 : 12)) - } + private var contentHeight: CGFloat { layout.contentHeight } private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var resolvedLeadingContent: AtollTrailingContent { + descriptor.leadingContent ?? .icon(descriptor.leadingIcon) + } + private var resolvedCenterStyle: ExtensionCenterContentView.Style { + switch descriptor.centerTextStyle { + case .inline: + return .inline + case .standard: + return .stacked + case .inheritUser: + return Defaults[.sneakPeekStyles] == .inline ? .inline : .stacked + } + } var body: some View { - let baseWingWidth = max(contentHeight, 44) - let trailingWidth = ExtensionLayoutMetrics.trailingWidth( - for: payload, - baseWidth: baseWingWidth, - maxWidth: notchWidth * 0.42 - ) - let centerWidth = max(96, notchWidth - baseWingWidth - trailingWidth) - - return HStack(spacing: 0) { - ExtensionCompositeIconView( - leading: descriptor.leadingIcon, + HStack(spacing: 0) { + ExtensionLeadingContentView( + content: resolvedLeadingContent, badge: descriptor.badgeIcon, accent: accentColor, - size: contentHeight + frameWidth: layout.leadingWidth, + frameHeight: contentHeight ) - .frame(width: baseWingWidth, height: contentHeight) + .frame(width: layout.leadingWidth, height: contentHeight) Rectangle() .fill(Color.black) - .frame(width: centerWidth, height: contentHeight) - .overlay(centerContent) + .frame(width: layout.centerWidth, height: contentHeight) + .overlay( + ExtensionCenterContentView( + descriptor: descriptor, + accent: accentColor, + width: layout.centerWidth, + style: resolvedCenterStyle + ) + ) - ExtensionMusicWingView(payload: payload, notchHeight: contentHeight) - .frame(width: trailingWidth, height: contentHeight) + ExtensionMusicWingView( + payload: payload, + notchHeight: contentHeight, + trailingWidth: layout.trailingWidth + ) + .frame(width: layout.trailingWidth, height: contentHeight) } - .frame(width: notchWidth, height: notchHeight + (isHovering ? 8 : 0)) + .frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0)) .animation(.smooth(duration: 0.25), value: payload.id) + .onAppear { + logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view") + } + .onDisappear { + logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) standalone view") + } } - private var centerContent: some View { - VStack(alignment: .leading, spacing: 2) { - Text(descriptor.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - if let subtitle = descriptor.subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.7)) - .lineLimit(1) - } - } - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, alignment: .leading) - } } struct ExtensionMusicWingView: View { let payload: ExtensionLiveActivityPayload let notchHeight: CGFloat + let trailingWidth: CGFloat private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var accentColor: Color { descriptor.accentColor.swiftUIColor } @@ -73,7 +88,12 @@ struct ExtensionMusicWingView: View { if case .none = descriptor.trailingContent { EmptyView() } else { - ExtensionTrailingContentView(content: descriptor.trailingContent, accent: accentColor) + ExtensionEdgeContentView( + content: descriptor.trailingContent, + accent: accentColor, + availableWidth: trailingWidth, + alignment: .trailing + ) .frame(maxWidth: .infinity, alignment: .trailing) } @@ -92,5 +112,93 @@ struct ExtensionMusicWingView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding(.trailing, 8) .padding(.vertical, 6) + .onAppear { + logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) within music wing") + } + .onDisappear { + logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) from music wing") + } } } + +struct ExtensionLeadingContentView: View { + let content: AtollTrailingContent + let badge: AtollIconDescriptor? + let accent: Color + let frameWidth: CGFloat + let frameHeight: CGFloat + + var body: some View { + if case let .icon(iconDescriptor) = content { + ExtensionCompositeIconView( + leading: iconDescriptor, + badge: badge, + accent: accent, + size: frameHeight + ) + } else { + ExtensionEdgeContentView( + content: content, + accent: accent, + availableWidth: frameWidth, + alignment: .leading + ) + } + } +} + +struct ExtensionCenterContentView: View { + enum Style { + case stacked + case inline + } + + let descriptor: AtollLiveActivityDescriptor + let accent: Color + let width: CGFloat + let style: Style + + var body: some View { + switch style { + case .stacked: + VStack(alignment: .leading, spacing: 2) { + Text(descriptor.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + if let subtitle = descriptor.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.7)) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + case .inline: + HStack(alignment: .center, spacing: 8) { + MarqueeText( + .constant(descriptor.title), + font: .system(size: 13, weight: .semibold), + nsFont: .body, + textColor: .white, + minDuration: 0.4, + frameWidth: max(40, width * 0.55) + ) + Spacer(minLength: 4) + if let subtitle = descriptor.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.75)) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + } + } +} + +private func logExtensionDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index 89a3b06..10faf4d 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Defaults import AtollExtensionKit struct ExtensionLockScreenWidgetView: View { @@ -20,6 +21,12 @@ struct ExtensionLockScreenWidgetView: View { RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) .stroke(Color.white.opacity(0.04), lineWidth: 1) ) + .onAppear { + logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)") + } + .onDisappear { + logWidgetDiagnostics("Lock screen widget \(payload.descriptor.id) removed") + } } @ViewBuilder @@ -118,6 +125,11 @@ struct ExtensionLockScreenWidgetView: View { } } +private func logWidgetDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} + private struct ExtensionGraphView: View { let data: [Double] let color: Color diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index 7bab79a..9bf6c80 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -254,11 +254,13 @@ struct ExtensionProgressIndicatorView: View { } } -// MARK: - Trailing Content Rendering +// MARK: - Edge Content Rendering -struct ExtensionTrailingContentView: View { +struct ExtensionEdgeContentView: View { let content: AtollTrailingContent let accent: Color + let availableWidth: CGFloat + let alignment: Alignment var body: some View { switch content { @@ -267,6 +269,19 @@ struct ExtensionTrailingContentView: View { .font(font.swiftUIFont()) .foregroundStyle(accent) .lineLimit(1) + .frame(maxWidth: .infinity, alignment: alignment) + case let .marquee(value, font: font, minDuration: duration): + MarqueeText( + .constant(value), + font: font.swiftUIFont(), + nsFont: .body, + textColor: accent, + minDuration: duration, + frameWidth: max(24, availableWidth - 12) + ) + case let .countdownText(targetDate, font: font): + ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent) + .frame(maxWidth: .infinity, alignment: alignment) case let .icon(descriptor): ExtensionIconView( descriptor: descriptor, @@ -292,12 +307,37 @@ struct ExtensionTrailingContentView: View { } } +struct ExtensionCountdownTextView: View { + let targetDate: Date + let font: AtollFontDescriptor + let accent: Color + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + Text(formattedRemaining(since: context.date)) + .font(font.swiftUIFont()) + .foregroundStyle(accent) + .monospacedDigit() + } + } + + private func formattedRemaining(since date: Date) -> String { + let remaining = max(Int(targetDate.timeIntervalSince(date)), 0) + let hours = remaining / 3600 + let minutes = (remaining % 3600) / 60 + let seconds = remaining % 60 + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%02d:%02d", minutes, seconds) + } +} + // MARK: - Layout Metrics enum ExtensionLayoutMetrics { static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { - var width = baseWidth - width = max(width, widthForTrailing(content: payload.descriptor.trailingContent, baseWidth: baseWidth)) + var width = edgeWidth(for: payload.descriptor.trailingContent, baseWidth: baseWidth, maxWidth: maxWidth) if let indicator = payload.descriptor.progressIndicator { width = max(width, widthForProgress(indicator)) } @@ -307,11 +347,26 @@ enum ExtensionLayoutMetrics { return width } - private static func widthForTrailing(content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { + static func edgeWidth(for content: AtollTrailingContent, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { + var width = widthForContent(content, baseWidth: baseWidth) + if let maxWidth { + width = min(width, maxWidth) + } + return width + } + + private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { switch content { case let .text(text, font: font): let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) return max(baseWidth, measured + 32) + case let .marquee(text, font: font, _): + let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) + return max(baseWidth, min(measured + 40, baseWidth * 2)) + case let .countdownText(targetDate, font: font): + let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00" + let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont()) + return max(baseWidth, measured + 24) case .icon: return max(baseWidth, 52) case .spectrum: diff --git a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift index 78f6533..1ce692a 100644 --- a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift +++ b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift @@ -3,7 +3,7 @@ import Defaults import AtollExtensionKit import SwiftUI -struct ExtensionLiveActivityPayload: Identifiable, Hashable { +struct ExtensionLiveActivityPayload: Identifiable, Hashable, Codable { let bundleIdentifier: String let descriptor: AtollLiveActivityDescriptor let receivedAt: Date @@ -13,7 +13,7 @@ struct ExtensionLiveActivityPayload: Identifiable, Hashable { var priority: AtollLiveActivityPriority { descriptor.priority } } -struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable { +struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable { let bundleIdentifier: String let descriptor: AtollLockScreenWidgetDescriptor let receivedAt: Date diff --git a/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift new file mode 100644 index 0000000..caa226c --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift @@ -0,0 +1,147 @@ +import Foundation + +/// Bridges extension payload updates between Atoll processes (main app, helpers, XPC services). +/// Stores the latest snapshot on disk and delivers change notifications through DistributedNotificationCenter. +final class ExtensionEventBridge { + static let shared = ExtensionEventBridge() + + private let notificationCenter = DistributedNotificationCenter.default() + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let processIdentifier = ProcessInfo.processInfo.processIdentifier + private let ioQueue = DispatchQueue(label: "com.ebullioscopic.Atoll.extensions.bridge", qos: .utility) + + private init() {} + + // MARK: - Public API + + func broadcastLiveActivitySnapshot(_ payloads: [ExtensionLiveActivityPayload]) { + broadcast(payloads, channel: .liveActivities) + } + + func broadcastLockScreenWidgetSnapshot(_ payloads: [ExtensionLockScreenWidgetPayload]) { + broadcast(payloads, channel: .lockScreenWidgets) + } + + func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] { + loadSnapshot(channel: .liveActivities) + } + + func loadPersistedLockScreenWidgets() -> [ExtensionLockScreenWidgetPayload] { + loadSnapshot(channel: .lockScreenWidgets) + } + + func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol { + observe(channel: .liveActivities, handler: handler) + } + + func observeLockScreenWidgetSnapshots(_ handler: @escaping ([ExtensionLockScreenWidgetPayload], Int32) -> Void) -> NSObjectProtocol { + observe(channel: .lockScreenWidgets, handler: handler) + } + + func removeObserver(_ observer: NSObjectProtocol) { + notificationCenter.removeObserver(observer) + } + + // MARK: - Snapshot Storage + + private func broadcast(_ payloads: [T], channel: Channel) { + ioQueue.async { [weak self] in + guard let self else { return } + guard let data = try? self.encoder.encode(payloads) else { return } + self.persistSnapshot(data, channel: channel) + let userInfo: [String: Any] = [ + UserInfoKey.payloads: data, + UserInfoKey.sourcePID: NSNumber(value: self.processIdentifier) + ] + self.notificationCenter.postNotificationName( + channel.notificationName, + object: nil, + userInfo: userInfo, + deliverImmediately: true + ) + } + } + + private func loadSnapshot(channel: Channel) -> [T] { + let url = snapshotURL(for: channel) + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url) else { + return [] + } + return (try? decoder.decode([T].self, from: data)) ?? [] + } + + private func persistSnapshot(_ data: Data, channel: Channel) { + let url = snapshotURL(for: channel) + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } catch { + Logger.log("Failed to persist extension snapshot for \(channel): \(error.localizedDescription)", category: .extensions) + } + } + + private func snapshotURL(for channel: Channel) -> URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let directory = appSupport.appendingPathComponent("AtollExtensions", isDirectory: true) + switch channel { + case .liveActivities: + return directory.appendingPathComponent("live_activities.json", isDirectory: false) + case .lockScreenWidgets: + return directory.appendingPathComponent("lock_screen_widgets.json", isDirectory: false) + } + } + + // MARK: - Observation + + private func observe(channel: Channel, + handler: @escaping ([T], Int32) -> Void) -> NSObjectProtocol { + notificationCenter.addObserver(forName: channel.notificationName, object: nil, queue: .main) { [weak self] notification in + guard let self else { return } + let sourcePID = (notification.userInfo?[UserInfoKey.sourcePID] as? NSNumber)?.int32Value ?? 0 + guard sourcePID != self.processIdentifier else { return } + + if let data = notification.userInfo?[UserInfoKey.payloads] as? Data, + let payloads = try? self.decoder.decode([T].self, from: data) { + handler(payloads, sourcePID) + return + } + + let snapshot: [T] = self.loadSnapshot(channel: channel) + handler(snapshot, sourcePID) + } + } + + // MARK: - Helpers + + private enum Channel: CustomStringConvertible { + case liveActivities + case lockScreenWidgets + + var notificationName: Notification.Name { + switch self { + case .liveActivities: + return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot") + case .lockScreenWidgets: + return Notification.Name("com.ebullioscopic.Atoll.extensions.lockScreenWidgetSnapshot") + } + } + + var description: String { + switch self { + case .liveActivities: + return "live-activities" + case .lockScreenWidgets: + return "lock-screen-widgets" + } + } + } + + private enum UserInfoKey { + static let payloads = "payloads" + static let sourcePID = "sourcePID" + } +} diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift index 9155d3e..a51bcdb 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -10,21 +10,52 @@ final class ExtensionLiveActivityManager: ObservableObject { private let authorizationManager = ExtensionAuthorizationManager.shared private let maxCapacityKey = Defaults.Keys.extensionLiveActivityCapacity + private let eventBridge = ExtensionEventBridge.shared + private var liveActivityObserver: NSObjectProtocol? + private var suppressBroadcast = false + private let currentProcessID = ProcessInfo.processInfo.processIdentifier - private init() {} + private init() { + activeActivities = eventBridge.loadPersistedLiveActivities() + sortActivities() + liveActivityObserver = eventBridge.observeLiveActivitySnapshots { [weak self] payloads, sourcePID in + self?.applySnapshot(payloads, sourcePID: sourcePID) + } + } + + deinit { + if let token = liveActivityObserver { + eventBridge.removeObserver(token) + } + } func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else { + logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized") throw ExtensionValidationError.unauthorized } guard descriptor.isValid else { + logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") throw ExtensionValidationError.invalidDescriptor("Structure validation failed") } - guard activeActivities.count < Defaults[maxCapacityKey] else { - throw ExtensionValidationError.exceedsCapacity + + if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeActivities[index].receivedAt + ) + activeActivities[index] = payload + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions) + broadcastSnapshot() + return } - guard activeActivities.contains(where: { $0.descriptor.id == descriptor.id }) == false else { - throw ExtensionValidationError.duplicateIdentifier + + guard activeActivities.count < Defaults[maxCapacityKey] else { + logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") + throw ExtensionValidationError.exceedsCapacity } let payload = ExtensionLiveActivityPayload( @@ -35,10 +66,13 @@ final class ExtensionLiveActivityManager: ObservableObject { activeActivities.append(payload) sortActivities() authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)") + broadcastSnapshot() } func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { guard descriptor.isValid else { + logDiagnostics("Rejected live activity update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") throw ExtensionValidationError.invalidDescriptor("Structure validation failed") } guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { @@ -52,6 +86,8 @@ final class ExtensionLiveActivityManager: ObservableObject { activeActivities[index] = payload sortActivities() authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + logDiagnostics("Updated live activity \(descriptor.id) for \(bundleIdentifier)") + broadcastSnapshot() } func dismiss(activityID: String, bundleIdentifier: String) { @@ -60,6 +96,8 @@ final class ExtensionLiveActivityManager: ObservableObject { if previousCount != activeActivities.count { Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions) ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) + logDiagnostics("Removed live activity \(activityID) for \(bundleIdentifier); remaining: \(activeActivities.count)") + broadcastSnapshot() } } @@ -69,6 +107,10 @@ final class ExtensionLiveActivityManager: ObservableObject { .map { $0.descriptor.id } 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: ", "))") + broadcastSnapshot() + } } func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] { @@ -87,4 +129,23 @@ final class ExtensionLiveActivityManager: ObservableObject { private func sortActivities() { activeActivities.sort(by: descriptorComparator) } + + private func broadcastSnapshot() { + guard !suppressBroadcast else { return } + eventBridge.broadcastLiveActivitySnapshot(activeActivities) + logDiagnostics("Broadcasted live activity snapshot (count: \(activeActivities.count))") + } + + private func applySnapshot(_ payloads: [ExtensionLiveActivityPayload], sourcePID: Int32) { + guard sourcePID != currentProcessID else { return } + suppressBroadcast = true + activeActivities = payloads.sorted(by: descriptorComparator) + suppressBroadcast = false + logDiagnostics("Applied external live activity snapshot from PID \(sourcePID) (count: \(payloads.count))") + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } } diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift index af24df5..200f99f 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift @@ -11,24 +11,54 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { private let authorizationManager = ExtensionAuthorizationManager.shared private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity private var presentationController: ExtensionLockScreenWidgetPresentationController! + private let eventBridge = ExtensionEventBridge.shared + private var widgetObserver: NSObjectProtocol? + private var suppressBroadcast = false + private let currentProcessID = ProcessInfo.processInfo.processIdentifier private init() { + activeWidgets = eventBridge.loadPersistedLockScreenWidgets() + sortWidgets() presentationController = ExtensionLockScreenWidgetPresentationController(manager: self) presentationController.activate() + widgetObserver = eventBridge.observeLockScreenWidgetSnapshots { [weak self] payloads, sourcePID in + self?.applySnapshot(payloads, sourcePID: sourcePID) + } + } + + deinit { + if let token = widgetObserver { + eventBridge.removeObserver(token) + } } func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else { + logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized") throw ExtensionValidationError.unauthorized } guard descriptor.isValid else { + logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") throw ExtensionValidationError.invalidDescriptor("Structure validation failed") } - guard activeWidgets.count < Defaults[maxCapacityKey] else { - throw ExtensionValidationError.exceedsCapacity + + if let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { + let payload = ExtensionLockScreenWidgetPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: activeWidgets[index].receivedAt + ) + activeWidgets[index] = payload + sortWidgets() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + Logger.log("Replaced extension widget \(descriptor.id) for \(bundleIdentifier)", category: .extensions) + broadcastSnapshot() + return } - guard activeWidgets.contains(where: { $0.descriptor.id == descriptor.id }) == false else { - throw ExtensionValidationError.duplicateIdentifier + + guard activeWidgets.count < Defaults[maxCapacityKey] else { + logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") + throw ExtensionValidationError.exceedsCapacity } let payload = ExtensionLockScreenWidgetPayload( @@ -39,10 +69,13 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { activeWidgets.append(payload) sortWidgets() authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + logDiagnostics("Queued lock screen widget \(descriptor.id) for \(bundleIdentifier); total widgets: \(activeWidgets.count)") + broadcastSnapshot() } func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws { guard descriptor.isValid else { + logDiagnostics("Rejected lock screen widget update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed") throw ExtensionValidationError.invalidDescriptor("Structure validation failed") } guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else { @@ -57,6 +90,8 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { activeWidgets[index] = payload sortWidgets() authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets) + logDiagnostics("Updated lock screen widget \(descriptor.id) for \(bundleIdentifier)") + broadcastSnapshot() } func dismiss(widgetID: String, bundleIdentifier: String) { @@ -65,6 +100,8 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { if previousCount != activeWidgets.count { Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions) ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID) + logDiagnostics("Removed lock screen widget \(widgetID) for \(bundleIdentifier); remaining: \(activeWidgets.count)") + broadcastSnapshot() } } @@ -74,6 +111,10 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { .map { $0.descriptor.id } activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier } ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) } + if !ids.isEmpty { + logDiagnostics("Removed all lock screen widgets for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") + broadcastSnapshot() + } } private func sortWidgets() { @@ -84,4 +125,28 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { return lhs.priority > rhs.priority } } + + private func broadcastSnapshot() { + guard !suppressBroadcast else { return } + eventBridge.broadcastLockScreenWidgetSnapshot(activeWidgets) + logDiagnostics("Broadcasted lock screen widget snapshot (count: \(activeWidgets.count))") + } + + private func applySnapshot(_ payloads: [ExtensionLockScreenWidgetPayload], sourcePID: Int32) { + guard sourcePID != currentProcessID else { return } + suppressBroadcast = true + activeWidgets = payloads.sorted { lhs, rhs in + if lhs.priority == rhs.priority { + return lhs.receivedAt < rhs.receivedAt + } + return lhs.priority > rhs.priority + } + suppressBroadcast = false + logDiagnostics("Applied external lock screen widget snapshot from PID \(sourcePID) (count: \(payloads.count))") + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } } diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift index b877dc6..a11ca09 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift @@ -12,6 +12,7 @@ final class ExtensionLockScreenWidgetPresentationController { private let windowPool = ExtensionLockScreenWidgetWindowPool() private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = [] private var isLocked: Bool = false + private var lastVisibilityState: VisibilityState? init(manager: ExtensionLockScreenWidgetManager) { self.manager = manager @@ -55,6 +56,7 @@ final class ExtensionLockScreenWidgetPresentationController { self.refreshPresentation() } else { self.windowPool.hideAll() + self.updateVisibilityLog(.hidden(reason: "feature-disabled")) } } .store(in: &cancellables) @@ -63,13 +65,37 @@ final class ExtensionLockScreenWidgetPresentationController { private func refreshPresentation() { guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else { windowPool.hideAll() + updateVisibilityLog(.hidden(reason: "lock-state")) return } guard cachedPayloads.isEmpty == false else { windowPool.hideAll() + updateVisibilityLog(.hidden(reason: "no-payloads")) return } windowPool.sync(with: cachedPayloads) + updateVisibilityLog(.showing(count: cachedPayloads.count)) + } + + private func updateVisibilityLog(_ state: VisibilityState) { + guard state != lastVisibilityState else { return } + lastVisibilityState = state + switch state { + case let .hidden(reason): + logDiagnostics("Lock screen widgets hidden (reason: \(reason))") + case let .showing(count): + logDiagnostics("Presenting \(count) extension lock screen widget(s)") + } + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } + + private enum VisibilityState: Equatable { + case hidden(reason: String) + case showing(count: Int) } } @@ -111,11 +137,13 @@ private final class ExtensionLockScreenWidgetWindowPool { record.window.contentView = nil } windows.removeAll() + logDiagnostics("Cleared all lock screen widget windows") } private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) { guard let screen = currentScreen() else { return } let descriptor = payload.descriptor + let isNewWindow = windows[payload.id] == nil let window = ensureWindow(for: payload.id) let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload)) hosting.frame = NSRect(origin: .zero, size: descriptor.size) @@ -126,6 +154,11 @@ private final class ExtensionLockScreenWidgetWindowPool { window.setFrame(frame(for: descriptor, on: screen), display: true) window.alphaValue = 1 window.orderFrontRegardless() + if isNewWindow { + logDiagnostics("Created lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id) style=\(descriptor.layoutStyle)") + } else { + logDiagnostics("Updated lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id)") + } } private func ensureWindow(for id: String) -> NSWindow { @@ -162,6 +195,7 @@ private final class ExtensionLockScreenWidgetWindowPool { record.window.orderOut(nil) record.window.contentView = nil windows.removeValue(forKey: id) + logDiagnostics("Removed lock screen widget window id=\(id)") } private func registerScreenChangeObservers() { @@ -188,7 +222,7 @@ private final class ExtensionLockScreenWidgetWindowPool { private func handleScreenGeometryChange(reason: String) { guard windows.isEmpty == false else { return } repositionAll(animated: false) - print("ExtensionLockScreenWidgetWindowPool: realigned windows due to \(reason)") + logDiagnostics("Realigned lock screen widget windows due to \(reason)") } private func repositionAll(animated: Bool) { @@ -246,3 +280,8 @@ private final class ExtensionLockScreenWidgetWindowPool { LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main } } + +private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) +} diff --git a/DynamicIsland/services/Extensions/ExtensionXPCService.swift b/DynamicIsland/services/Extensions/ExtensionXPCService.swift index 60d5b0c..bd72334 100644 --- a/DynamicIsland/services/Extensions/ExtensionXPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionXPCService.swift @@ -24,114 +24,149 @@ final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { // MARK: Authorization func requestAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { - guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } - guard authorizationManager.isExtensionsFeatureEnabled else { - reply(false, ExtensionValidationError.featureDisabled.asNSError) - return + guard self.authorizationManager.isExtensionsFeatureEnabled else { + reply(false, ExtensionValidationError.featureDisabled.asNSError) + return + } + + let entry = self.authorizationManager.ensureEntryExists(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName()) + + if entry.status == .pending { + self.authorizationManager.authorize(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName()) + self.host?.notifyAuthorizationChange(bundleIdentifier: self.bundleIdentifier, isAuthorized: true) + reply(true, nil) + return + } + + reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError) } - - let entry = authorizationManager.ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName()) - - if entry.status == .pending { - authorizationManager.authorize(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName()) - host?.notifyAuthorizationChange(bundleIdentifier: bundleIdentifier, isAuthorized: true) - reply(true, nil) - return - } - - reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError) } func checkAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool) -> Void) { - guard providedBundleIdentifier == bundleIdentifier else { - reply(false) - return - } + Task { @MainActor [weak self] in + guard let self else { return } + guard providedBundleIdentifier == self.bundleIdentifier else { + reply(false) + return + } - let isAuthorized = authorizationManager.authorizationEntry(for: bundleIdentifier)?.isAuthorized ?? false - reply(isAuthorized) + let isAuthorized = self.authorizationManager.authorizationEntry(for: self.bundleIdentifier)?.isAuthorized ?? false + reply(isAuthorized) + } } // MARK: Live Activities func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received live activity payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue), coexistence: \(descriptor.allowsMusicCoexistence))") + try service.liveActivityManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Live activity \(descriptor.id) stored for \(service.bundleIdentifier); active activities: \(service.liveActivityManager.activeActivities.count)") } } func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received live activity update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.liveActivityManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Live activity \(descriptor.id) updated for \(service.bundleIdentifier)") } } func dismissLiveActivity(activityID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { - guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } - liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: bundleIdentifier) - reply(true, nil) + self.logDiagnostics("Received live activity dismissal from \(self.bundleIdentifier) (id: \(activityID))") + self.liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Live activity \(activityID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } } // MARK: Lock Screen Widgets func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try widgetManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received lock screen widget payload from \(service.bundleIdentifier) (id: \(descriptor.id), style: \(descriptor.layoutStyle))") + try service.widgetManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Lock screen widget \(descriptor.id) stored for \(service.bundleIdentifier); active widgets: \(service.widgetManager.activeWidgets.count)") } } func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { - respond(reply: reply) { - let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) try ExtensionDescriptorValidator.validate(descriptor) - try widgetManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier) + service.logDiagnostics("Received lock screen widget update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.widgetManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Lock screen widget \(descriptor.id) updated for \(service.bundleIdentifier)") } } func dismissLockScreenWidget(widgetID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { - guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } - widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bundleIdentifier) - reply(true, nil) + self.logDiagnostics("Received lock screen widget dismissal from \(self.bundleIdentifier) (id: \(widgetID))") + self.widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Lock screen widget \(widgetID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } } // MARK: Diagnostics func getVersion(reply: @escaping (String) -> Void) { - reply(appVersion) + Task { @MainActor in + reply(appVersion) + } } // MARK: Helpers - private func respond(reply: @escaping (Bool, Error?) -> Void, operation: () throws -> Void) { - do { - try operation() - reply(true, nil) - } catch { - if Defaults[.extensionDiagnosticsLoggingEnabled] { - Logger.log("Extension XPC request failed: \(error)", category: .extensions) + private func respond(reply: @escaping (Bool, Error?) -> Void, operation: @escaping (ExtensionXPCService) throws -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + do { + try operation(self) + reply(true, nil) + } catch { + if Defaults[.extensionDiagnosticsLoggingEnabled] { + Logger.log("Extension XPC request failed: \(error)", category: .extensions) + } + reply(false, error.asNSError) } - reply(false, error.asNSError) } } private func validate(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) -> Bool { guard providedBundleIdentifier == bundleIdentifier else { let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier) + logDiagnostics("Rejected XPC request due to bundle mismatch. Expected \(bundleIdentifier) received \(providedBundleIdentifier)") reply(false, error.asNSError) return false } return true } + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } + private func resolvedApplicationName() -> String { guard let processIdentifier = connection?.processIdentifier, processIdentifier != 0, diff --git a/DynamicIsland/utils/Logger.swift b/DynamicIsland/utils/Logger.swift index 5a50e34..27d6d9f 100644 --- a/DynamicIsland/utils/Logger.swift +++ b/DynamicIsland/utils/Logger.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog import SwiftUI enum LogCategory: String { @@ -12,9 +13,41 @@ enum LogCategory: String { case success = "✅" case debug = "🔍" case extensions = "🧩" + + var osCategoryName: String { + switch self { + case .lifecycle: return "lifecycle" + case .memory: return "memory" + case .performance: return "performance" + case .ui: return "ui" + case .network: return "network" + case .error: return "error" + case .warning: return "warning" + case .success: return "success" + case .debug: return "debug" + case .extensions: return "extensions" + } + } } struct Logger { + private static let subsystem = "com.ebullioscopic.Atoll" + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static var osLoggerCache: [LogCategory: OSLog] = [:] + + private static func osLogger(for category: LogCategory) -> OSLog { + if let cached = osLoggerCache[category] { + return cached + } + let logger = OSLog(subsystem: subsystem, category: category.osCategoryName) + osLoggerCache[category] = logger + return logger + } + static func log( _ message: String, category: LogCategory, @@ -23,8 +56,14 @@ struct Logger { line: Int = #line ) { let fileName = (file as NSString).lastPathComponent - let timestamp = ISO8601DateFormatter().string(from: Date()) - print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)") + let timestamp = dateFormatter.string(from: Date()) + let entry = "\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)" + let logger = osLogger(for: category) + os_log("%{public}@", log: logger, type: .default, entry) + +#if DEBUG + Swift.print(entry) +#endif } static func trackMemory( From 27b16a3d15a3f351a43d12c38f712844fdc824f5 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 15 Jan 2026 23:31:07 +0530 Subject: [PATCH 07/16] Update Third Party stuff --- .../xcshareddata/swiftpm/Package.resolved | 2 +- DynamicIsland/ContentView.swift | 103 +++++++- .../DynamicIslandViewCoordinator.swift | 63 ++++- DynamicIsland/Localizable.xcstrings | 8 + .../ExtensionLiveActivityViews.swift | 232 +++++++++++++++--- .../ExtensionRenderingHelpers.swift | 207 +++++++++++++--- .../Settings/ExtensionsSettings.swift | 31 +++ .../ExtensionLiveActivityManager.swift | 89 +++++-- 8 files changed, 636 insertions(+), 99 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2cf39bc..d232e97 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f" + "revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355" } }, { diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index f320769..fd633d8 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -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 { diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 66aec7e..873e996 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -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 } } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b1c9c9c..83d66c5 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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 diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 641e11f..ffd6a7f 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -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( - ExtensionCenterContentView( - descriptor: descriptor, - accent: accentColor, - width: layout.centerWidth, - style: resolvedCenterStyle - ) + 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 { - EmptyView() - } else { - ExtensionEdgeContentView( - content: descriptor.trailingContent, - accent: accentColor, - availableWidth: trailingWidth, - alignment: .trailing - ) + switch trailingRenderable { + case let .content(content): + if case .none = content { + EmptyView() + } else { + ExtensionEdgeContentView( + 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,23 +147,39 @@ 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 { - ExtensionCompositeIconView( - leading: iconDescriptor, - badge: badge, - accent: accent, - size: frameHeight - ) - } else { - ExtensionEdgeContentView( - content: content, - accent: accent, - availableWidth: frameWidth, - alignment: .leading - ) + Group { + switch content { + case let .icon(iconDescriptor): + ExtensionCompositeIconView( + leading: iconDescriptor, + badge: badge, + accent: accent, + size: frameHeight + ) + 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, + size: frameHeight + ) + } } + .frame(width: frameWidth, height: frameHeight) } } @@ -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) + } + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index 9bf6c80..e759128 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -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 + } + } +} diff --git a/DynamicIsland/components/Settings/ExtensionsSettings.swift b/DynamicIsland/components/Settings/ExtensionsSettings.swift index 4a051a1..3a8d2bc 100644 --- a/DynamicIsland/components/Settings/ExtensionsSettings.swift +++ b/DynamicIsland/components/Settings/ExtensionsSettings.swift @@ -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() } @@ -403,6 +408,32 @@ private struct ExtensionEntryRow: View { .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 { HStack { diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift index a51bcdb..6a71d7e 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -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,25 +51,35 @@ 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 + } - guard activeActivities.count < Defaults[maxCapacityKey] else { - logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") - throw ExtensionValidationError.exceedsCapacity + 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)") + 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() + + // 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 - 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 { 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 } - 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) } 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) From 88692a241a8e15a8678d652dd6e87a27c5218a29 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 15 Jan 2026 23:50:22 +0530 Subject: [PATCH 08/16] Sneak peek is a bit issue --- DynamicIsland/ContentView.swift | 17 ++------- .../ExtensionLiveActivityViews.swift | 38 ++++++++++++++++--- .../ExtensionRenderingHelpers.swift | 10 ++++- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index fd633d8..5fe4560 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -1271,13 +1271,7 @@ struct ContentView: View { let outerHeight = notchHeight let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12)) let leadingWidth = max(contentHeight, 44) - 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 centerWidth: CGFloat = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) let trailingWidth = ExtensionLayoutMetrics.trailingWidth( for: payload, baseWidth: leadingWidth, @@ -1297,13 +1291,8 @@ struct ContentView: View { 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 + let config = payload.descriptor.sneakPeekConfig ?? .default + return config.enabled } @MainActor diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index ffd6a7f..54b992a 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -344,12 +344,37 @@ struct ExtensionInlineSneakPeekView: View { @ViewBuilder private var trailingWing: some View { if let payload { - ExtensionMusicWingView( - payload: payload, - notchHeight: contentHeight, - trailingWidth: trailingWidth - ) + let descriptor = payload.descriptor + switch resolvedExtensionTrailingRenderable(for: descriptor) { + case let .content(content): + if case .none = content { + spectrumPlaceholder + } else { + VStack(alignment: .trailing, spacing: 6) { + ExtensionEdgeContentView( + content: content, + accent: resolvedAccent, + availableWidth: trailingWidth, + alignment: .trailing + ) + .frame(maxWidth: .infinity, alignment: .trailing) + + Spacer(minLength: 0) + } + .padding(.trailing, 8) + .padding(.vertical, 6) + } + case .indicator: + spectrumPlaceholder + } } else { + spectrumPlaceholder + } + } + + @ViewBuilder + private var spectrumPlaceholder: some View { + VStack { Rectangle() .fill(resolvedAccent.gradient) .mask { @@ -358,5 +383,8 @@ struct ExtensionInlineSneakPeekView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(.trailing, 8) + .padding(.vertical, 6) } } diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index e759128..82e4600 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -318,10 +318,13 @@ struct ExtensionProgressIndicatorView: View { .monospacedDigit() case let .countdown(font, color): let textColor = color?.swiftUIColor ?? accent - Text(countdownText) + let text = countdownText + Text(text) .font(font.swiftUIFont()) .foregroundStyle(textColor) .monospacedDigit() + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: text) case .lottie: Image(systemName: "sparkles") .font(.system(size: 14, weight: .medium)) @@ -418,10 +421,13 @@ struct ExtensionCountdownTextView: View { var body: some View { TimelineView(.periodic(from: .now, by: 1)) { context in - Text(formattedRemaining(since: context.date)) + let remainingText = formattedRemaining(since: context.date) + Text(remainingText) .font(font.swiftUIFont()) .foregroundStyle(customColor ?? accent) .monospacedDigit() + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: remainingText) } } From 675f60c9f270f0f43864005b5a7fcfea3a2e3e39 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 16 Jan 2026 00:00:02 +0530 Subject: [PATCH 09/16] Update stuff, but sneak peek still broken --- DynamicIsland/ContentView.swift | 4 +-- .../ExtensionLiveActivityViews.swift | 35 ++----------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 5fe4560..2c5f857 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -1290,9 +1290,7 @@ struct ContentView: View { } private func shouldSuppressExtensionCenter(for payload: ExtensionLiveActivityPayload) -> Bool { - guard vm.notchState == .closed else { return false } - let config = payload.descriptor.sneakPeekConfig ?? .default - return config.enabled + vm.notchState == .closed } @MainActor diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 54b992a..55fcbc8 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -266,12 +266,7 @@ struct ExtensionInlineSneakPeekView: View { } private var trailingWidth: CGFloat { - guard let payload else { return max(leadingWidth, 120) } - return ExtensionLayoutMetrics.trailingWidth( - for: payload, - baseWidth: leadingWidth, - maxWidth: leadingWidth + centerWidth * 0.6 - ) + max(leadingWidth * 0.9, 120) } private var marqueeFrameWidth: CGFloat { @@ -343,33 +338,7 @@ struct ExtensionInlineSneakPeekView: View { @ViewBuilder private var trailingWing: some View { - if let payload { - let descriptor = payload.descriptor - switch resolvedExtensionTrailingRenderable(for: descriptor) { - case let .content(content): - if case .none = content { - spectrumPlaceholder - } else { - VStack(alignment: .trailing, spacing: 6) { - ExtensionEdgeContentView( - content: content, - accent: resolvedAccent, - availableWidth: trailingWidth, - alignment: .trailing - ) - .frame(maxWidth: .infinity, alignment: .trailing) - - Spacer(minLength: 0) - } - .padding(.trailing, 8) - .padding(.vertical, 6) - } - case .indicator: - spectrumPlaceholder - } - } else { - spectrumPlaceholder - } + spectrumPlaceholder } @ViewBuilder From 1d6946134caa1526710444b5807562f816b0d926 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 16 Jan 2026 00:48:02 +0530 Subject: [PATCH 10/16] Fully working lock screen widget --- DynamicIsland.xcodeproj/project.pbxproj | 9 + .../xcshareddata/swiftpm/Package.resolved | 2 +- .../ExtensionLockScreenWidgetView.swift | 231 ++++++++++++++++-- ...ckScreenWidgetPresentationController.swift | 13 +- 4 files changed, 235 insertions(+), 20 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index a09045d..754dc7a 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -79,6 +79,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 7E07AFF12F181892003A8B1B /* Recovered References */ = { + isa = PBXGroup; + children = ( + 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { isa = PBXGroup; children = ( @@ -121,6 +129,7 @@ 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E82CD4987A00C7AFD9 /* Products */, + 7E07AFF12F181892003A8B1B /* Recovered References */, ); sourceTree = ""; }; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d232e97..80701d0 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355" + "revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186" } }, { diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index 10faf4d..e7722f0 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -1,26 +1,27 @@ import SwiftUI import Defaults import AtollExtensionKit +import WebKit +import AppKit struct ExtensionLockScreenWidgetView: View { let payload: ExtensionLockScreenWidgetPayload private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor } private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var appearance: AtollWidgetAppearanceOptions? { descriptor.appearance } var body: some View { + let shape = RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) ZStack { - backgroundView + backgroundLayer(shape: shape) contentView - .padding(.horizontal, descriptor.layoutStyle == .circular ? 10 : 16) - .padding(.vertical, descriptor.layoutStyle == .circular ? 10 : 12) + .padding(resolvedContentInsets) } .frame(width: descriptor.size.width, height: descriptor.size.height) - .clipShape(RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .stroke(Color.white.opacity(0.04), lineWidth: 1) - ) + .clipShape(shape) + .overlay(borderOverlay(shape: shape)) + .shadow(color: shadowColor, radius: shadowRadius, x: shadowOffset.width, y: shadowOffset.height) .onAppear { logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)") } @@ -92,6 +93,11 @@ struct ExtensionLockScreenWidgetView: View { 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) } } @@ -104,25 +110,124 @@ struct ExtensionLockScreenWidgetView: View { } } + private var resolvedContentInsets: EdgeInsets { + if let custom = appearance?.contentInsets { + return EdgeInsets(top: custom.top, leading: custom.leading, bottom: custom.bottom, trailing: custom.trailing) + } + switch descriptor.layoutStyle { + case .circular: + return EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) + default: + return EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16) + } + } + @ViewBuilder - private var backgroundView: some View { + private func backgroundLayer(shape: RoundedRectangle) -> some View { + if shouldUseGlassBackdrop { + if #available(macOS 26.0, *) { + ZStack { + shape + .fill(Color.clear) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: descriptor.cornerRadius) + ) + .allowsHitTesting(false) + .accessibilityHidden(true) + tintOverlay(shape: shape) + } + } else { + ZStack { + shape.fill(AnyShapeStyle(.regularMaterial)) + tintOverlay(shape: shape) + } + } + } else { + ZStack { + shape.fill(baseMaterialStyle) + tintOverlay(shape: shape) + } + } + } + + private var baseMaterialStyle: AnyShapeStyle { switch descriptor.material { case .frosted: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(.ultraThinMaterial) + return AnyShapeStyle(.ultraThinMaterial) case .liquid: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(.regularMaterial) + return AnyShapeStyle(.regularMaterial) case .solid: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(accentColor.opacity(0.9)) + return AnyShapeStyle(resolvedSolidFillColor.opacity(0.95)) case .semiTransparent: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(accentColor.opacity(0.35)) + return AnyShapeStyle(resolvedSolidFillColor.opacity(0.35)) case .clear: - Color.clear + return AnyShapeStyle(Color.clear) } } + + private var resolvedSolidFillColor: Color { + if let tint = appearance?.tintColor?.swiftUIColor { + return tint + } + return accentColor + } + + @ViewBuilder + private func tintOverlay(shape: RoundedRectangle) -> some View { + Group { + if shouldApplyTintOverlay, let overlay = tintedOverlayColor { + shape + .fill(overlay) + .allowsHitTesting(false) + } + } + } + + private var tintedOverlayColor: Color? { + guard let descriptor = appearance, let tint = descriptor.tintColor, descriptor.tintOpacity > 0 else { + return nil + } + return tint.swiftUIColor.opacity(descriptor.tintOpacity) + } + + private var shouldApplyTintOverlay: Bool { + guard appearance?.tintColor != nil else { return false } + switch descriptor.material { + case .solid, .semiTransparent: + return false + default: + return true + } + } + + private var shouldUseGlassBackdrop: Bool { + descriptor.material == .liquid || (appearance?.enableGlassHighlight ?? false) + } + + @ViewBuilder + private func borderOverlay(shape: RoundedRectangle) -> some View { + if let border = appearance?.border { + shape + .stroke(border.color.swiftUIColor.opacity(border.opacity), lineWidth: border.width) + } else { + shape + .stroke(Color.white.opacity(0.04), lineWidth: 1) + } + } + + private var shadowColor: Color { + guard let shadow = appearance?.shadow else { return .clear } + return shadow.color.swiftUIColor.opacity(shadow.opacity) + } + + private var shadowRadius: CGFloat { + appearance?.shadow?.radius ?? 0 + } + + private var shadowOffset: CGSize { + appearance?.shadow?.offset ?? .zero + } } private func logWidgetDiagnostics(_ message: String) { @@ -130,6 +235,96 @@ private func logWidgetDiagnostics(_ message: String) { Logger.log(message, category: .extensions) } +private struct ExtensionWebContentView: NSViewRepresentable { + let descriptor: AtollWidgetWebContentDescriptor + + func makeCoordinator() -> Coordinator { + Coordinator(descriptor: descriptor) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.suppressesIncrementalRendering = false + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + let webView = NonInteractiveWKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.wantsLayer = true + applyConfiguration(descriptor, to: webView) + context.coordinator.lastHTML = descriptor.html + webView.loadHTMLString(descriptor.html, baseURL: nil) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.descriptor = descriptor + webView.navigationDelegate = context.coordinator + applyConfiguration(descriptor, to: webView) + if context.coordinator.lastHTML != descriptor.html { + webView.loadHTMLString(descriptor.html, baseURL: nil) + context.coordinator.lastHTML = descriptor.html + } + } + + private func applyConfiguration(_ descriptor: AtollWidgetWebContentDescriptor, to webView: WKWebView) { + webView.allowsBackForwardNavigationGestures = false + webView.allowsLinkPreview = false + if descriptor.isTransparent { + webView.setValue(false, forKey: "drawsBackground") + webView.layer?.backgroundColor = NSColor.clear.cgColor + } else { + let fallbackColor = descriptor.backgroundColor?.nsColor ?? NSColor.windowBackgroundColor + webView.layer?.backgroundColor = fallbackColor.cgColor + } + } + + private final class NonInteractiveWKWebView: WKWebView { + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override var acceptsFirstResponder: Bool { + false + } + } + + final class Coordinator: NSObject, WKNavigationDelegate { + var descriptor: AtollWidgetWebContentDescriptor + var lastHTML: String? + + init(descriptor: AtollWidgetWebContentDescriptor) { + self.descriptor = descriptor + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + if isAllowed(url: url) { + decisionHandler(.allow) + } else { + logWidgetDiagnostics("Blocked external navigation to \(url.absoluteString)") + decisionHandler(.cancel) + } + } + + private func isAllowed(url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + if scheme == "about" || scheme == "data" { + return true + } + if (scheme == "http" || scheme == "https") { + guard descriptor.allowLocalhostRequests else { + return false + } + let host = url.host?.lowercased() + return host == "localhost" || host == "127.0.0.1" + } + return false + } + } +} + private struct ExtensionGraphView: View { let data: [Double] let color: Color diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift index a11ca09..7d55d9e 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift @@ -244,7 +244,7 @@ private final class ExtensionLockScreenWidgetWindowPool { private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect { let size = descriptor.size - let safeInsets = NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48) + let safeInsets = safeInsets(for: descriptor.position.clampMode) let screenFrame = screen.frame let minX = screenFrame.minX + safeInsets.left @@ -271,6 +271,17 @@ private final class ExtensionLockScreenWidgetWindowPool { return NSRect(x: originX, y: originY, width: size.width, height: size.height) } + private func safeInsets(for clampMode: AtollWidgetPosition.ClampMode) -> NSEdgeInsets { + switch clampMode { + case .safeRegion: + return NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48) + case .relaxed: + return NSEdgeInsets(top: 96, left: 24, bottom: 72, right: 24) + case .unconstrained: + return NSEdgeInsets(top: 16, left: 0, bottom: 32, right: 0) + } + } + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { guard lower <= upper else { return value } return min(max(value, lower), upper) From 9214c61544ae0443521e837838f343cbeb46a68e Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 18 Jan 2026 01:46:35 +0530 Subject: [PATCH 11/16] Update stuff --- .../xcshareddata/swiftpm/Package.resolved | 2 +- DynamicIsland/ContentView.swift | 97 +++++- .../DynamicIslandViewCoordinator.swift | 71 +++++ DynamicIsland/Localizable.xcstrings | 18 +- .../ExtensionLiveActivityViews.swift | 277 ++++++++++++++++-- .../ExtensionLockScreenWidgetView.swift | 122 ++++---- .../components/Notch/NotchHomeView.swift | 15 +- .../Settings/ExtensionsSettings.swift | 58 +++- .../components/Tabs/TabSelectionView.swift | 89 ++++-- DynamicIsland/enums/generic.swift | 1 + .../Extensions/ExtensionActivityModels.swift | 14 + .../ExtensionAuthorizationManager.swift | 14 +- .../ExtensionDescriptorValidator.swift | 11 + .../Extensions/ExtensionEventBridge.swift | 19 ++ .../ExtensionNotchExperienceManager.swift | 215 ++++++++++++++ DynamicIsland/models/Constants.swift | 39 ++- .../Extensions/ExtensionXPCService.swift | 35 +++ .../Extensions/ExtensionXPCServiceHost.swift | 6 + 18 files changed, 985 insertions(+), 118 deletions(-) create mode 100644 DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 80701d0..ab7b105 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186" + "revision" : "a1303629e532337594787b981b499560a99c6c93" } }, { diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 2c5f857..2490de2 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -36,6 +36,7 @@ struct ContentView: View { @ObservedObject var lockScreenManager = LockScreenManager.shared @ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared + @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -77,6 +78,19 @@ struct ContentView: View { let resolvedHeight = max(baseSize.height, preferredHeight) return CGSize(width: baseSize.width, height: resolvedHeight) } + + if coordinator.currentView == .extensionExperience { + if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) { + return CGSize(width: baseSize.width, height: preferredHeight) + } + return baseSize + } + + if enableMinimalisticUI, + coordinator.currentView == .home, + let preferredHeight = extensionMinimalisticPreferredHeight(baseSize: baseSize) { + return CGSize(width: baseSize.width, height: preferredHeight) + } guard coordinator.currentView == .stats else { return baseSize @@ -557,17 +571,17 @@ struct ContentView: View { isHovering: isHovering, gestureProgress: gestureProgress ) - .transition(.move(edge: .trailing).combined(with: .opacity)) + .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( coordinator.sneakPeek.type == .capsLock - ? .move(edge: .trailing).combined(with: .opacity) - : .opacity + ? AnyTransition.move(edge: .trailing).combined(with: .opacity) + : AnyTransition.opacity ) } else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress) - .transition(.move(edge: .trailing).combined(with: .opacity)) + .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) } else if canShowMusicDuringExpansion && musicPairingEligible { MusicLiveActivity(secondary: musicSecondary) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { @@ -596,8 +610,7 @@ struct ContentView: View { ExtensionLiveActivityStandaloneView( payload: extensionPayload, layout: layout, - isHovering: isHovering, - notchState: vm.notchState + isHovering: isHovering ) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) @@ -712,6 +725,12 @@ struct ContentView: View { NotchNotesView() case .clipboard: NotchNotesView() + case .extensionExperience: + if let payload = currentExtensionTabPayload() { + ExtensionNotchExperienceTabView(payload: payload) + } else { + NotchHomeView(albumArtNamespace: albumArtNamespace) + } } } .transition(.asymmetric( @@ -1467,6 +1486,72 @@ struct ContentView: View { if count == 0 { return 0 } 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 diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 873e996..c3ec73b 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -97,6 +97,7 @@ class DynamicIslandViewCoordinator: ObservableObject { private let statsSecondRowRevealDelay: TimeInterval = 0.5 private let statsSecondRowAnimationDuration: TimeInterval = 0.3 @Published var notesLayoutState: NotesLayoutState = .list + @Published var selectedExtensionExperienceID: String? @AppStorage("firstLaunch") var firstLaunch: Bool = true @@ -138,6 +139,7 @@ class DynamicIslandViewCoordinator: ObservableObject { @Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown" @Published var optionKeyPressed: Bool = true + private let extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared private init() { selectedScreen = preferredScreen @@ -161,6 +163,36 @@ class DynamicIslandViewCoordinator: ObservableObject { self?.handleMinimalisticModeChange(change.newValue) } .store(in: &cancellables) + + extensionNotchExperienceManager.$activeExperiences + .receive(on: DispatchQueue.main) + .sink { [weak self] experiences in + self?.handleExtensionExperienceSnapshot(experiences) + } + .store(in: &cancellables) + + Defaults.publisher(.enableThirdPartyExtensions) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + Defaults.publisher(.enableExtensionNotchExperiences) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + Defaults.publisher(.enableExtensionNotchTabs) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExtensionFeatureToggle() + } + .store(in: &cancellables) + + handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences) } private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { @@ -206,6 +238,45 @@ class DynamicIslandViewCoordinator: ObservableObject { } } } + + private func handleExtensionExperienceSnapshot(_ experiences: [ExtensionNotchExperiencePayload]) { + guard extensionTabsAllowed else { + selectedExtensionExperienceID = nil + resetExtensionViewIfNeeded() + return + } + + let tabCapablePayloads = experiences.filter { $0.descriptor.tab != nil } + guard !tabCapablePayloads.isEmpty else { + selectedExtensionExperienceID = nil + resetExtensionViewIfNeeded() + return + } + + if let currentID = selectedExtensionExperienceID, + tabCapablePayloads.contains(where: { $0.descriptor.id == currentID }) { + return + } + + selectedExtensionExperienceID = tabCapablePayloads.first?.descriptor.id + } + + private func handleExtensionFeatureToggle() { + handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences) + } + + private func resetExtensionViewIfNeeded() { + guard currentView == .extensionExperience else { return } + withAnimation(.smooth) { + currentView = .home + } + } + + private var extensionTabsAllowed: Bool { + Defaults[.enableThirdPartyExtensions] + && Defaults[.enableExtensionNotchExperiences] + && Defaults[.enableExtensionNotchTabs] + } func toggleSneakPeek( status: Bool, diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 83d66c5..4b14356 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -8777,6 +8777,10 @@ "comment" : "A label describing the expanded notch width setting.", "isCommentAutoGenerated" : true }, + "Extension tab unavailable" : { + "comment" : "A message displayed when the extension tab is not available.", + "isCommentAutoGenerated" : true + }, "Extensions" : { "localizations" : { "ar" : { @@ -14720,6 +14724,10 @@ } } }, + "Notch Experiences" : { + "comment" : "A toggle that enables or disables the \"Notch Experiences\" feature for a specific app.", + "isCommentAutoGenerated" : true + }, "Notch Height" : { "localizations" : { "ar" : { @@ -15973,7 +15981,7 @@ "comment" : "A button that removes an extension from the authorized list.", "isCommentAutoGenerated" : true }, - "Remove %@ from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app." : { + "Remove %@ from the authorized extensions list? This will dismiss all active live activities, lock screen widgets, and notch experiences from this app." : { "comment" : "A message presented when a user confirms they want to remove an extension from the authorized list. The argument is the name of the extension.", "isCommentAutoGenerated" : true }, @@ -16021,6 +16029,10 @@ "comment" : "A menu item that resets all lock screen widgets for a particular app.", "isCommentAutoGenerated" : true }, + "Reset Notch Experiences" : { + "comment" : "A menu item that resets all notch experiences for a particular app.", + "isCommentAutoGenerated" : true + }, "Reset Rate Limits" : { "comment" : "A button that resets the rate limits for a particular extension.", "isCommentAutoGenerated" : true @@ -18984,8 +18996,8 @@ "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : { }, - "Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below." : { - "comment" : "A description below the toggle to enable third-party extensions, explaining that third-party apps can display live activities and lock screen widgets, and that this view allows you to manage those permissions.", + "Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below." : { + "comment" : "A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view.", "isCommentAutoGenerated" : true }, "This duration powers the \"Custom\" option inside the timer popover for quick access." : { diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 55fcbc8..3789054 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -16,7 +16,6 @@ struct ExtensionLiveActivityStandaloneView: View { let payload: ExtensionLiveActivityPayload let layout: ExtensionStandaloneLayout let isHovering: Bool - let notchState: NotchState private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var contentHeight: CGFloat { layout.contentHeight } @@ -35,10 +34,6 @@ struct ExtensionLiveActivityStandaloneView: View { } } - private var shouldSuppressCenterText: Bool { - layout.suppressingCenterText - } - var body: some View { HStack(spacing: 0) { ExtensionLeadingContentView( @@ -56,7 +51,9 @@ struct ExtensionLiveActivityStandaloneView: View { .frame(width: layout.centerWidth, height: contentHeight) .overlay( Group { - if !shouldSuppressCenterText { + if layout.suppressingCenterText { + EmptyView() + } else { ExtensionCenterContentView( descriptor: descriptor, accent: accentColor, @@ -75,10 +72,12 @@ struct ExtensionLiveActivityStandaloneView: View { .frame(width: layout.trailingWidth, height: contentHeight) } .frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0)) - .transition(.asymmetric( - insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)), - removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9)) - )) + .transition( + .asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)), + removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9)) + ) + ) .animation(.smooth(duration: 0.25), value: payload.id) .onAppear { logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view") @@ -106,7 +105,7 @@ struct ExtensionMusicWingView: View { switch trailingRenderable { case let .content(content): if case .none = content { - EmptyView() + Spacer(minLength: 0) } else { ExtensionEdgeContentView( content: content, @@ -126,7 +125,7 @@ struct ExtensionMusicWingView: View { ) .frame(maxWidth: .infinity, alignment: .trailing) } - + Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) @@ -239,10 +238,152 @@ private func logExtensionDiagnostics(_ message: String) { Logger.log(message, category: .extensions) } +struct ExtensionNotchExperienceTabView: View { + let payload: ExtensionNotchExperiencePayload + + @Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled + + private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor } + private var tabConfiguration: AtollNotchExperienceDescriptor.TabConfiguration? { descriptor.tab } + private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var allowInteractiveWebViews: Bool { + interactiveWebViewsEnabled && (tabConfiguration?.allowWebInteraction ?? false) + } + + var body: some View { + Group { + if let tabConfiguration { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + header(for: tabConfiguration) + ForEach(Array(tabConfiguration.sections.enumerated()), id: \.offset) { index, section in + ExtensionNotchSectionView( + section: section, + accent: accentColor, + allowWebInteraction: allowInteractiveWebViews + ) + .accessibilityIdentifier("extension-notch-section-\(payload.descriptor.id)-\(index)") + } + if let webDescriptor = tabConfiguration.webContent { + ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowInteractiveWebViews) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + if let footnote = tabConfiguration.footnote { + Text(footnote) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.65)) + .lineLimit(2) + } + } + .padding(.vertical, 14) + .padding(.horizontal, 16) + } + } else { + Text("Extension tab unavailable") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(tabBackground) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + + @ViewBuilder + private func header(for configuration: AtollNotchExperienceDescriptor.TabConfiguration) -> some View { + HStack(spacing: 10) { + Group { + if let badgeIcon = configuration.badgeIcon { + ExtensionIconView( + descriptor: badgeIcon, + tint: accentColor, + size: CGSize(width: 32, height: 32), + cornerRadius: 10 + ) + } else { + Image(systemName: configuration.iconSymbolName ?? "puzzlepiece.extension") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(accentColor.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(configuration.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + } + Spacer(minLength: 0) + } + } + + private var tabBackground: some View { + AnyView( + LinearGradient( + colors: [Color.white.opacity(0.04), accentColor.opacity(0.08)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } +} + +struct ExtensionNotchSectionView: View { + let section: AtollNotchContentSection + let accent: Color + let allowWebInteraction: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ExtensionNotchSectionHeader(section: section) + layoutContent + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + @ViewBuilder + private var layoutContent: some View { + switch section.layout { + case .stack: + VStack(alignment: .leading, spacing: 10) { + elementViews + } + case .columns: + LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 12) { + elementViews + } + case .metrics: + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + elementViews + } + } + } + + @ViewBuilder + private var elementViews: some View { + ForEach(Array(section.elements.enumerated()), id: \.offset) { index, element in + ExtensionWidgetElementView( + element: element, + accent: accent, + allowWebInteraction: allowWebInteraction + ) + .accessibilityIdentifier("extension-notch-element-\(index)") + } + } +} + struct ExtensionInlineSneakPeekView: View { let payload: ExtensionLiveActivityPayload? let title: String - let subtitle: String + let subtitle: String? let accentColor: Color let notchHeight: CGFloat let closedNotchWidth: CGFloat @@ -251,7 +392,6 @@ struct ExtensionInlineSneakPeekView: View { private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor } private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor } - private var contentHeight: CGFloat { max(0, notchHeight - (isHovering ? 0 : 12)) } @@ -274,11 +414,19 @@ struct ExtensionInlineSneakPeekView: View { } private var resolvedTitle: String { - title.isEmpty ? "Extension" : title + let descriptorTitle = descriptor?.title ?? "Extension" + let preferred = title.isEmpty ? descriptorTitle : title + return preferred.isEmpty ? "Extension" : preferred } private var resolvedSubtitle: String { - subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + if let subtitle, !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + } + if let descriptorSubtitle = descriptor?.subtitle, !descriptorSubtitle.isEmpty { + return descriptorSubtitle + } + return "" } var body: some View { @@ -291,16 +439,14 @@ struct ExtensionInlineSneakPeekView: View { .frame(width: centerWidth, height: contentHeight) .overlay(alignment: .bottomLeading) { VStack(alignment: .leading, spacing: 2) { - if !resolvedTitle.isEmpty { - MarqueeText( - .constant(resolvedTitle), - font: .system(size: 12, weight: .semibold), - nsFont: .subheadline, - textColor: .white, - minDuration: 0.4, - frameWidth: marqueeFrameWidth - ) - } + MarqueeText( + .constant(resolvedTitle), + font: .system(size: 12, weight: .semibold), + nsFont: .subheadline, + textColor: .white, + minDuration: 0.4, + frameWidth: marqueeFrameWidth + ) if !resolvedSubtitle.isEmpty { Text(resolvedSubtitle) .font(.system(size: 11, weight: .regular)) @@ -338,11 +484,6 @@ struct ExtensionInlineSneakPeekView: View { @ViewBuilder private var trailingWing: some View { - spectrumPlaceholder - } - - @ViewBuilder - private var spectrumPlaceholder: some View { VStack { Rectangle() .fill(resolvedAccent.gradient) @@ -357,3 +498,77 @@ struct ExtensionInlineSneakPeekView: View { .padding(.vertical, 6) } } + +struct ExtensionNotchSectionHeader: View { + let section: AtollNotchContentSection + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let title = section.title { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + } + if let subtitle = section.subtitle { + Text(subtitle) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.7)) + } + } + } +} + +struct ExtensionMinimalisticExperienceView: View { + let payload: ExtensionNotchExperiencePayload + let albumArtNamespace: Namespace.ID + + @Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled + + private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor } + private var configuration: AtollNotchExperienceDescriptor.MinimalisticConfiguration? { descriptor.minimalistic } + private var accent: Color { descriptor.accentColor.swiftUIColor } + + var body: some View { + Group { + if let configuration { + let hasWebContent = configuration.webContent != nil + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if let headline = configuration.headline { + Text(headline) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + if let subtitle = configuration.subtitle { + Text(subtitle) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.75)) + } + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { index, section in + ExtensionNotchSectionView( + section: section, + accent: accent, + allowWebInteraction: interactiveWebViewsEnabled + ) + .accessibilityIdentifier("extension-minimalistic-section-\(payload.descriptor.id)-\(index)") + } + if let webDescriptor = configuration.webContent { + ExtensionWebContentView( + descriptor: webDescriptor, + allowInteraction: interactiveWebViewsEnabled + ) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, hasWebContent ? 0 : 10) + } + } else { + MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + } + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index e7722f0..c00f26b 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -52,53 +52,12 @@ struct ExtensionLockScreenWidgetView: View { } } - @ViewBuilder private func view(for element: AtollWidgetContentElement) -> some View { - switch element { - case let .text(text, font: font, color: color, alignment: _): - Text(text) - .font(font.swiftUIFont()) - .foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9)) - .lineLimit(2) - case let .icon(iconDescriptor, tint): - ExtensionIconView( - descriptor: iconDescriptor, - tint: (tint?.swiftUIColor) ?? accentColor, - size: CGSize(width: 28, height: 28), - cornerRadius: 8 - ) - case let .progress(indicator, value, color): - ExtensionProgressIndicatorView( - indicator: indicator, - progress: value, - accent: (color?.swiftUIColor) ?? accentColor, - estimatedDuration: nil - ) - case let .graph(data, color, size): - ExtensionGraphView(data: data, color: color.swiftUIColor, size: size) - .frame(width: size.width, height: size.height) - case let .gauge(value, minValue, maxValue, style, color): - ExtensionGaugeView( - value: value, - minValue: minValue, - maxValue: maxValue, - style: style, - accent: (color?.swiftUIColor) ?? accentColor - ) - .frame(maxWidth: .infinity) - case let .spacer(height): - Color.clear - .frame(height: height) - case let .divider(color, thickness): - Rectangle() - .fill(color.swiftUIColor.opacity(0.4)) - .frame(height: thickness) - case let .webView(webDescriptor): - ExtensionWebContentView(descriptor: webDescriptor) - .frame(height: webDescriptor.preferredHeight) - .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) - .allowsHitTesting(false) - } + ExtensionWidgetElementView( + element: element, + accent: accentColor, + allowWebInteraction: false + ) } private func alignment(for element: AtollWidgetContentElement) -> Alignment { @@ -235,8 +194,62 @@ private func logWidgetDiagnostics(_ message: String) { Logger.log(message, category: .extensions) } -private struct ExtensionWebContentView: NSViewRepresentable { +struct ExtensionWidgetElementView: View { + let element: AtollWidgetContentElement + let accent: Color + let allowWebInteraction: Bool + + var body: some View { + switch element { + case let .text(text, font: font, color: color, alignment: _): + Text(text) + .font(font.swiftUIFont()) + .foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9)) + .lineLimit(2) + case let .icon(iconDescriptor, tint): + ExtensionIconView( + descriptor: iconDescriptor, + tint: (tint?.swiftUIColor) ?? accent, + size: CGSize(width: 28, height: 28), + cornerRadius: 8 + ) + case let .progress(indicator, value, color): + ExtensionProgressIndicatorView( + indicator: indicator, + progress: value, + accent: (color?.swiftUIColor) ?? accent, + estimatedDuration: nil + ) + case let .graph(data, color, size): + ExtensionGraphView(data: data, color: color.swiftUIColor, size: size) + .frame(width: size.width, height: size.height) + case let .gauge(value, minValue, maxValue, style, color): + ExtensionGaugeView( + value: value, + minValue: minValue, + maxValue: maxValue, + style: style, + accent: (color?.swiftUIColor) ?? accent + ) + .frame(maxWidth: .infinity) + case let .spacer(height): + Color.clear + .frame(height: height) + case let .divider(color, thickness): + Rectangle() + .fill(color.swiftUIColor.opacity(0.4)) + .frame(height: thickness) + case let .webView(webDescriptor): + ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowWebInteraction) + .frame(height: webDescriptor.preferredHeight) + .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) + } + } +} + +struct ExtensionWebContentView: NSViewRepresentable { let descriptor: AtollWidgetWebContentDescriptor + let allowInteraction: Bool func makeCoordinator() -> Coordinator { Coordinator(descriptor: descriptor) @@ -246,7 +259,8 @@ private struct ExtensionWebContentView: NSViewRepresentable { let configuration = WKWebViewConfiguration() configuration.suppressesIncrementalRendering = false configuration.preferences.javaScriptCanOpenWindowsAutomatically = false - let webView = NonInteractiveWKWebView(frame: .zero, configuration: configuration) + let webView = ConfigurableWKWebView(frame: .zero, configuration: configuration) + webView.allowInteraction = allowInteraction webView.navigationDelegate = context.coordinator webView.wantsLayer = true applyConfiguration(descriptor, to: webView) @@ -259,6 +273,9 @@ private struct ExtensionWebContentView: NSViewRepresentable { context.coordinator.descriptor = descriptor webView.navigationDelegate = context.coordinator applyConfiguration(descriptor, to: webView) + if let configurableView = webView as? ConfigurableWKWebView { + configurableView.allowInteraction = allowInteraction + } if context.coordinator.lastHTML != descriptor.html { webView.loadHTMLString(descriptor.html, baseURL: nil) context.coordinator.lastHTML = descriptor.html @@ -277,13 +294,16 @@ private struct ExtensionWebContentView: NSViewRepresentable { } } - private final class NonInteractiveWKWebView: WKWebView { + private final class ConfigurableWKWebView: WKWebView { + var allowInteraction = false + override func hitTest(_ point: NSPoint) -> NSView? { - nil + guard allowInteraction else { return nil } + return super.hitTest(point) } override var acceptsFirstResponder: Bool { - false + allowInteraction } } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 4fa9b92..53ad2d4 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -378,6 +378,7 @@ struct NotchHomeView: View { @ObservedObject var webcamManager = WebcamManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared let albumArtNamespace: Namespace.ID var body: some View { @@ -392,8 +393,14 @@ struct NotchHomeView: View { private var mainContent: some View { HStack(alignment: .top, spacing: 20) { if Defaults[.enableMinimalisticUI] { - // Minimalistic mode: Only show compact music player - MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + if let overridePayload = minimalisticOverridePayload { + ExtensionMinimalisticExperienceView( + payload: overridePayload, + albumArtNamespace: albumArtNamespace + ) + } else { + MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) + } } else { // Normal mode: Show full music player with optional calendar and webcam MusicPlayerView(albumArtNamespace: albumArtNamespace) @@ -422,6 +429,10 @@ struct NotchHomeView: View { .combined(with: .move(edge: .top))) .blur(radius: vm.notchState == .closed ? 30 : 0) } + + private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? { + extensionNotchExperienceManager.minimalisticReplacementPayload() + } } struct MusicSliderView: View { diff --git a/DynamicIsland/components/Settings/ExtensionsSettings.swift b/DynamicIsland/components/Settings/ExtensionsSettings.swift index 3a8d2bc..ff4a79e 100644 --- a/DynamicIsland/components/Settings/ExtensionsSettings.swift +++ b/DynamicIsland/components/Settings/ExtensionsSettings.swift @@ -44,7 +44,7 @@ struct ExtensionsSettingsView: View { selectedEntry = nil } } message: { entry in - Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app.") + Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities, lock screen widgets, and notch experiences from this app.") } } @@ -59,6 +59,21 @@ struct ExtensionsSettingsView: View { Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets) .settingsHighlight(id: highlightID("Allow extension lock screen widgets")) + + Defaults.Toggle("Allow extension notch experiences", key: .enableExtensionNotchExperiences) + .settingsHighlight(id: highlightID("Allow extension notch experiences")) + + if Defaults[.enableExtensionNotchExperiences] { + VStack(alignment: .leading, spacing: 8) { + Defaults.Toggle("Show extension tabs", key: .enableExtensionNotchTabs) + .tint(.accentColor) + Defaults.Toggle("Allow minimalistic overrides", key: .enableExtensionNotchMinimalisticOverrides) + .tint(.accentColor) + Defaults.Toggle("Allow interactive web content", key: .enableExtensionNotchInteractiveWebViews) + .tint(.accentColor) + } + .padding(.leading, 4) + } Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled) .settingsHighlight(id: highlightID("Enable extension diagnostics logging")) @@ -67,7 +82,7 @@ struct ExtensionsSettingsView: View { Text("Global Settings") } footer: { if Defaults[.enableThirdPartyExtensions] { - Text("Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below.") + Text("Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below.") .font(.caption) .foregroundStyle(.secondary) } else { @@ -148,6 +163,7 @@ private struct ExtensionEntryRow: View { @ObservedObject private var authManager = ExtensionAuthorizationManager.shared @ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared + @ObservedObject private var notchExperienceManager = ExtensionNotchExperienceManager.shared let entry: ExtensionAuthorizationEntry let onRemove: () -> Void @@ -275,8 +291,8 @@ private struct ExtensionEntryRow: View { } // Rate limits info - if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), - !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty { + if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }), + !rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty || !rateLimitRecord.notchExperienceTimestamps.isEmpty { rateLimitInfo(record: rateLimitRecord) Divider() } @@ -324,6 +340,21 @@ private struct ExtensionEntryRow: View { )) .font(.caption) .disabled(!authManager.areLockScreenWidgetsEnabled) + + Toggle("Notch Experiences", isOn: Binding( + get: { entry.allowedScopes.contains(.notchExperiences) }, + set: { enabled in + var newScopes = entry.allowedScopes + if enabled { + newScopes.insert(.notchExperiences) + } else { + newScopes.remove(.notchExperiences) + } + authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes) + } + )) + .font(.caption) + .disabled(!authManager.areNotchExperiencesEnabled) } } @@ -353,6 +384,16 @@ private struct ExtensionEntryRow: View { .font(.caption.monospacedDigit()) } } + + if !record.notchExperienceTimestamps.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Notch Experiences") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(record.notchExperienceTimestamps.count)") + .font(.caption.monospacedDigit()) + } + } } Button("Reset Rate Limits") { @@ -420,6 +461,11 @@ private struct ExtensionEntryRow: View { widgetManager.dismissAll(for: entry.bundleIdentifier) } .disabled(!hasWidgets) + + Button("Reset Notch Experiences") { + notchExperienceManager.dismissAll(for: entry.bundleIdentifier) + } + .disabled(!hasNotchExperiences) } label: { Label("Reset", systemImage: "arrow.counterclockwise.circle") } @@ -434,6 +480,10 @@ private struct ExtensionEntryRow: View { private var hasWidgets: Bool { widgetManager.activeWidgets.contains { $0.bundleIdentifier == entry.bundleIdentifier } } + + private var hasNotchExperiences: Bool { + notchExperienceManager.activeExperiences.contains { $0.bundleIdentifier == entry.bundleIdentifier } + } private func infoRow(label: String, value: String) -> some View { HStack { diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 7916761..e88e826 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -4,23 +4,38 @@ // // Created by Hugo Persson on 2024-08-25. // Modified by Hariharan Mudaliar - +import AtollExtensionKit import SwiftUI import Defaults struct TabModel: Identifiable { - let id = UUID() + let id: String let label: String let icon: String let view: NotchViews + let experienceID: String? + let accentColor: Color? + + init(label: String, icon: String, view: NotchViews, experienceID: String? = nil, accentColor: Color? = nil) { + self.id = experienceID.map { "extension-\($0)" } ?? "system-\(view)-\(label)" + self.label = label + self.icon = icon + self.view = view + self.experienceID = experienceID + self.accentColor = accentColor + } } struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableStatsFeature) var enableStatsFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature @Default(.timerDisplayMode) var timerDisplayMode + @Default(.enableThirdPartyExtensions) private var enableThirdPartyExtensions + @Default(.enableExtensionNotchExperiences) private var enableExtensionNotchExperiences + @Default(.enableExtensionNotchTabs) private var enableExtensionNotchTabs @Namespace var animation private var tabs: [TabModel] { @@ -46,35 +61,73 @@ struct TabSelectionView: View { let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard" tabsArray.append(TabModel(label: label, icon: icon, view: .notes)) } + if extensionTabsEnabled { + for payload in extensionTabPayloads { + guard let tab = payload.descriptor.tab else { continue } + let accent = payload.descriptor.accentColor.swiftUIColor + let iconName = tab.iconSymbolName ?? "puzzlepiece.extension" + tabsArray.append( + TabModel( + label: tab.title, + icon: iconName, + view: .extensionExperience, + experienceID: payload.descriptor.id, + accentColor: accent + ) + ) + } + } return tabsArray } var body: some View { HStack(spacing: 0) { ForEach(tabs) { tab in - TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { - withAnimation(.smooth) { - coordinator.currentView = tab.view + let isSelected = isSelected(tab) + let activeAccent = tab.accentColor ?? .white + TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { + withAnimation(.smooth) { + if tab.view == .extensionExperience { + coordinator.selectedExtensionExperienceID = tab.experienceID } + coordinator.currentView = tab.view } - .frame(height: 26) - .foregroundStyle(tab.view == coordinator.currentView ? .white : .gray) - .background { - if tab.view == coordinator.currentView { - Capsule() - .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) - .matchedGeometryEffect(id: "capsule", in: animation) - } else { - Capsule() - .fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear) - .matchedGeometryEffect(id: "capsule", in: animation) - .hidden() - } + } + .frame(height: 26) + .foregroundStyle(isSelected ? activeAccent : .gray) + .background { + if isSelected { + Capsule() + .fill((tab.accentColor ?? Color(nsColor: .secondarySystemFill)).opacity(0.25)) + .shadow(color: (tab.accentColor ?? .clear).opacity(0.4), radius: 8) + .matchedGeometryEffect(id: "capsule", in: animation) + } else { + Capsule() + .fill(Color.clear) + .matchedGeometryEffect(id: "capsule", in: animation) + .hidden() } + } } } .clipShape(Capsule()) } + + private var extensionTabsEnabled: Bool { + enableThirdPartyExtensions && enableExtensionNotchExperiences && enableExtensionNotchTabs + } + + private var extensionTabPayloads: [ExtensionNotchExperiencePayload] { + extensionNotchExperienceManager.activeExperiences.filter { $0.descriptor.tab != nil } + } + + private func isSelected(_ tab: TabModel) -> Bool { + if tab.view == .extensionExperience { + return coordinator.currentView == .extensionExperience + && coordinator.selectedExtensionExperienceID == tab.experienceID + } + return coordinator.currentView == tab.view + } } #Preview { diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 9656bc1..ab33e05 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -33,6 +33,7 @@ public enum NotchViews { case colorPicker case notes case clipboard + case extensionExperience } enum NotesLayoutState: Equatable { diff --git a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift index 1ce692a..cb53836 100644 --- a/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift +++ b/DynamicIsland/managers/Extensions/ExtensionActivityModels.swift @@ -23,6 +23,20 @@ struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable { var priority: AtollLiveActivityPriority { descriptor.priority } } +struct ExtensionNotchExperiencePayload: Identifiable, Hashable, Codable { + let bundleIdentifier: String + let descriptor: AtollNotchExperienceDescriptor + let receivedAt: Date + + var id: String { descriptor.id } + + var priority: AtollLiveActivityPriority { descriptor.priority } + + var hasTabConfiguration: Bool { descriptor.tab != nil } + + var hasMinimalisticConfiguration: Bool { descriptor.minimalistic != nil } +} + enum ExtensionValidationError: LocalizedError, Equatable { case featureDisabled case unauthorized diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift index 6f852f1..43faf6f 100644 --- a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -22,13 +22,16 @@ final class ExtensionAuthorizationManager: ObservableObject { var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] } var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] } var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] } + var areNotchExperiencesEnabled: Bool { Defaults[.enableExtensionNotchExperiences] } func updateFeatureToggles(extensionsEnabled: Bool? = nil, liveActivitiesEnabled: Bool? = nil, - lockScreenWidgetsEnabled: Bool? = nil) { + lockScreenWidgetsEnabled: Bool? = nil, + notchExperiencesEnabled: Bool? = nil) { if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled } if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled } if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled } + if let notchExperiencesEnabled { Defaults[.enableExtensionNotchExperiences] = notchExperiencesEnabled } objectWillChange.send() } @@ -88,9 +91,12 @@ final class ExtensionAuthorizationManager: ObservableObject { record.activityTimestamps.append(now) case .lockScreenWidgets: record.widgetTimestamps.append(now) + case .notchExperiences: + record.notchExperienceTimestamps.append(now) } record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) + record.notchExperienceTimestamps = flushOldTimestamps(record.notchExperienceTimestamps) } } @@ -126,6 +132,12 @@ final class ExtensionAuthorizationManager: ObservableObject { return true } + func canProcessNotchExperienceRequest(from bundleIdentifier: String) -> Bool { + guard preflight(bundleIdentifier: bundleIdentifier, scope: .notchExperiences) else { return false } + guard areNotchExperiencesEnabled else { return false } + return true + } + func recordDeniedRequest(bundleIdentifier: String, reason: String) { updateEntry(bundleIdentifier: bundleIdentifier) { entry in entry.lastDeniedReason = reason diff --git a/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift index 293836f..5ae58ed 100644 --- a/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift +++ b/DynamicIsland/managers/Extensions/ExtensionDescriptorValidator.swift @@ -30,4 +30,15 @@ struct ExtensionDescriptorValidator { throw ExtensionValidationError.invalidDescriptor("Widget exceeds size limits") } } + + static func validate(_ descriptor: AtollNotchExperienceDescriptor) throws { + guard descriptor.isValid else { + throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields") + } + if let durationHint = descriptor.durationHint { + guard durationHint > 0, durationHint <= 21_600 else { + throw ExtensionValidationError.invalidDescriptor("Duration hint must be between 0 and 6 hours") + } + } + } } diff --git a/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift index caa226c..709a045 100644 --- a/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift +++ b/DynamicIsland/managers/Extensions/ExtensionEventBridge.swift @@ -23,6 +23,10 @@ final class ExtensionEventBridge { broadcast(payloads, channel: .lockScreenWidgets) } + func broadcastNotchExperienceSnapshot(_ payloads: [ExtensionNotchExperiencePayload]) { + broadcast(payloads, channel: .notchExperiences) + } + func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] { loadSnapshot(channel: .liveActivities) } @@ -31,6 +35,10 @@ final class ExtensionEventBridge { loadSnapshot(channel: .lockScreenWidgets) } + func loadPersistedNotchExperiences() -> [ExtensionNotchExperiencePayload] { + loadSnapshot(channel: .notchExperiences) + } + func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol { observe(channel: .liveActivities, handler: handler) } @@ -39,6 +47,10 @@ final class ExtensionEventBridge { observe(channel: .lockScreenWidgets, handler: handler) } + func observeNotchExperienceSnapshots(_ handler: @escaping ([ExtensionNotchExperiencePayload], Int32) -> Void) -> NSObjectProtocol { + observe(channel: .notchExperiences, handler: handler) + } + func removeObserver(_ observer: NSObjectProtocol) { notificationCenter.removeObserver(observer) } @@ -92,6 +104,8 @@ final class ExtensionEventBridge { return directory.appendingPathComponent("live_activities.json", isDirectory: false) case .lockScreenWidgets: return directory.appendingPathComponent("lock_screen_widgets.json", isDirectory: false) + case .notchExperiences: + return directory.appendingPathComponent("notch_experiences.json", isDirectory: false) } } @@ -120,6 +134,7 @@ final class ExtensionEventBridge { private enum Channel: CustomStringConvertible { case liveActivities case lockScreenWidgets + case notchExperiences var notificationName: Notification.Name { switch self { @@ -127,6 +142,8 @@ final class ExtensionEventBridge { return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot") case .lockScreenWidgets: return Notification.Name("com.ebullioscopic.Atoll.extensions.lockScreenWidgetSnapshot") + case .notchExperiences: + return Notification.Name("com.ebullioscopic.Atoll.extensions.notchExperienceSnapshot") } } @@ -136,6 +153,8 @@ final class ExtensionEventBridge { return "live-activities" case .lockScreenWidgets: return "lock-screen-widgets" + case .notchExperiences: + return "notch-experiences" } } } diff --git a/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift new file mode 100644 index 0000000..932faf3 --- /dev/null +++ b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift @@ -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 + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 16e58d2..85c2859 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -103,11 +103,13 @@ enum AnimationSource: Codable, Hashable, Equatable { enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable { case liveActivities case lockScreenWidgets + case notchExperiences var displayName: String { switch self { case .liveActivities: return "Live Activities" case .lockScreenWidgets: return "Lock Screen Widgets" + case .notchExperiences: return "Notch Experiences" } } } @@ -168,13 +170,43 @@ struct ExtensionRateLimitRecord: Codable, Defaults.Serializable, Hashable, Ident let bundleIdentifier: String var activityTimestamps: [Date] var widgetTimestamps: [Date] + var notchExperienceTimestamps: [Date] var id: String { bundleIdentifier } - init(bundleIdentifier: String, activityTimestamps: [Date] = [], widgetTimestamps: [Date] = []) { + init( + bundleIdentifier: String, + activityTimestamps: [Date] = [], + widgetTimestamps: [Date] = [], + notchExperienceTimestamps: [Date] = [] + ) { self.bundleIdentifier = bundleIdentifier self.activityTimestamps = activityTimestamps self.widgetTimestamps = widgetTimestamps + self.notchExperienceTimestamps = notchExperienceTimestamps + } + + private enum CodingKeys: String, CodingKey { + case bundleIdentifier + case activityTimestamps + case widgetTimestamps + case notchExperienceTimestamps + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + bundleIdentifier = try container.decode(String.self, forKey: .bundleIdentifier) + activityTimestamps = try container.decodeIfPresent([Date].self, forKey: .activityTimestamps) ?? [] + widgetTimestamps = try container.decodeIfPresent([Date].self, forKey: .widgetTimestamps) ?? [] + notchExperienceTimestamps = try container.decodeIfPresent([Date].self, forKey: .notchExperienceTimestamps) ?? [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(bundleIdentifier, forKey: .bundleIdentifier) + try container.encode(activityTimestamps, forKey: .activityTimestamps) + try container.encode(widgetTimestamps, forKey: .widgetTimestamps) + try container.encode(notchExperienceTimestamps, forKey: .notchExperienceTimestamps) } } @@ -800,11 +832,16 @@ extension Defaults.Keys { static let enableThirdPartyExtensions = Key("enableThirdPartyExtensions", default: true) static let enableExtensionLiveActivities = Key("enableExtensionLiveActivities", default: true) static let enableExtensionLockScreenWidgets = Key("enableExtensionLockScreenWidgets", default: true) + static let enableExtensionNotchExperiences = Key("enableExtensionNotchExperiences", default: true) + static let enableExtensionNotchTabs = Key("enableExtensionNotchTabs", default: true) + static let enableExtensionNotchMinimalisticOverrides = Key("enableExtensionNotchMinimalisticOverrides", default: true) + static let enableExtensionNotchInteractiveWebViews = Key("enableExtensionNotchInteractiveWebViews", default: true) static let extensionAuthorizationEntries = Key<[ExtensionAuthorizationEntry]>("extensionAuthorizationEntries", default: []) static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: []) static let extensionDiagnosticsLoggingEnabled = Key("extensionDiagnosticsLoggingEnabled", default: true) static let extensionLiveActivityCapacity = Key("extensionLiveActivityCapacity", default: 4) static let extensionLockScreenWidgetCapacity = Key("extensionLockScreenWidgetCapacity", default: 4) + static let extensionNotchExperienceCapacity = Key("extensionNotchExperienceCapacity", default: 2) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/services/Extensions/ExtensionXPCService.swift b/DynamicIsland/services/Extensions/ExtensionXPCService.swift index bd72334..8bf8c24 100644 --- a/DynamicIsland/services/Extensions/ExtensionXPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionXPCService.swift @@ -12,6 +12,7 @@ final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { private let authorizationManager = ExtensionAuthorizationManager.shared private let liveActivityManager = ExtensionLiveActivityManager.shared private let widgetManager = ExtensionLockScreenWidgetManager.shared + private let notchExperienceManager = ExtensionNotchExperienceManager.shared private let decoder = JSONDecoder() init(bundleIdentifier: String, host: ExtensionXPCServiceHost, connection: NSXPCConnection) { @@ -127,6 +128,40 @@ final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol { } } + // MARK: Notch Experiences + + func presentNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received notch experience payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue))") + try service.notchExperienceManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Notch experience \(descriptor.id) stored for \(service.bundleIdentifier); active experiences: \(service.notchExperienceManager.activeExperiences.count)") + } + } + + func updateNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) { + respond(reply: reply) { service in + let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + service.logDiagnostics("Received notch experience update from \(service.bundleIdentifier) (id: \(descriptor.id))") + try service.notchExperienceManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier) + service.logDiagnostics("Notch experience \(descriptor.id) updated for \(service.bundleIdentifier)") + } + } + + func dismissNotchExperience(experienceID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) { + Task { @MainActor [weak self] in + guard let self else { return } + guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return } + + self.logDiagnostics("Received notch experience dismissal from \(self.bundleIdentifier) (id: \(experienceID))") + self.notchExperienceManager.dismiss(experienceID: experienceID, bundleIdentifier: self.bundleIdentifier) + self.logDiagnostics("Notch experience \(experienceID) dismissed for \(self.bundleIdentifier)") + reply(true, nil) + } + } + // MARK: Diagnostics func getVersion(reply: @escaping (String) -> Void) { diff --git a/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift index 670699e..f0d329b 100644 --- a/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift +++ b/DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift @@ -91,6 +91,12 @@ final class ExtensionXPCServiceHost: NSObject, NSXPCListenerDelegate { } } + func notifyNotchExperienceDismiss(bundleIdentifier: String, experienceID: String) { + deliver(to: bundleIdentifier) { client in + client.notchExperienceDidDismiss(experienceID: experienceID) + } + } + private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? { let processIdentifier = connection.processIdentifier guard processIdentifier != 0 else { return nil } From a4e58b9e701eb45239e9112b74a9c11376dad549 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 18 Jan 2026 19:40:05 +0530 Subject: [PATCH 12/16] Update fix for Sneak Peek --- DynamicIsland/ContentView.swift | 64 +++-- .../ExtensionLiveActivityViews.swift | 252 +++++++----------- .../ExtensionRenderingHelpers.swift | 4 +- 3 files changed, 137 insertions(+), 183 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 2490de2..a6f416c 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -13,6 +13,7 @@ import Foundation import KeyboardShortcuts import SwiftUI import SwiftUIIntrospect +import AtollExtensionKit #if canImport(AppKit) import AppKit #elseif canImport(UIKit) @@ -563,13 +564,10 @@ struct ContentView: View { 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 + isHovering: isHovering ) .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) { @@ -600,12 +598,10 @@ 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, - suppressingCenterText: suppressingCenterText + isHovering: isHovering ) ExtensionLiveActivityStandaloneView( payload: extensionPayload, @@ -676,25 +672,31 @@ struct ContentView: View { } } // Extension live activity sneak peek - else if case .extensionLiveActivity = coordinator.sneakPeek.type { + else if case let .extensionLiveActivity(bundleID, activityID) = 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)" + let payload = extensionLiveActivityManager.payload(bundleIdentifier: bundleID, activityID: activityID) + let descriptor = payload?.descriptor + let accent = (descriptor?.accentColor.swiftUIColor ?? coordinator.sneakPeek.accentColor ?? .gray) + .ensureMinimumBrightness(factor: 0.7) + GeometryReader { geo in + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(accent) + .frame(width: 8, height: 12) MarqueeText( - .constant(combinedText), - textColor: coordinator.sneakPeek.accentColor ?? .gray, + .constant( + extensionSneakPeekText( + preferredTitle: coordinator.sneakPeek.title, + preferredSubtitle: coordinator.sneakPeek.subtitle, + descriptor: descriptor + ) + ), + textColor: accent, minDuration: 1, - frameWidth: geo.size.width + frameWidth: max(0, geo.size.width - 14) ) } } - .foregroundStyle(coordinator.sneakPeek.accentColor ?? .gray) .padding(.bottom, 10) } } @@ -776,6 +778,19 @@ struct ContentView: View { } } + private func extensionSneakPeekText(preferredTitle: String, preferredSubtitle: String?, descriptor: AtollLiveActivityDescriptor?) -> String { + let trimmedPreferredTitle = preferredTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let descriptorTitle = descriptor?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Extension" + let title = trimmedPreferredTitle.isEmpty ? descriptorTitle : trimmedPreferredTitle + + let trimmedPreferredSubtitle = preferredSubtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let descriptorSubtitle = descriptor?.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let subtitle = !trimmedPreferredSubtitle.isEmpty ? trimmedPreferredSubtitle : descriptorSubtitle + + guard !subtitle.isEmpty else { return title } + return "\(title) • \(subtitle)" + } + private let reminderTimeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .none @@ -1286,7 +1301,7 @@ struct ContentView: View { return payload } - private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool, suppressingCenterText: Bool) -> ExtensionStandaloneLayout { + private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout { let outerHeight = notchHeight let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12)) let leadingWidth = max(contentHeight, 44) @@ -1303,15 +1318,10 @@ struct ContentView: View { contentHeight: contentHeight, leadingWidth: leadingWidth, centerWidth: centerWidth, - trailingWidth: trailingWidth, - suppressingCenterText: suppressingCenterText + trailingWidth: trailingWidth ) } - private func shouldSuppressExtensionCenter(for payload: ExtensionLiveActivityPayload) -> Bool { - vm.notchState == .closed - } - @MainActor private final class ExtensionRoutingDiagnostics { static let shared = ExtensionRoutingDiagnostics() diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 3789054..6167aae 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -9,7 +9,6 @@ struct ExtensionStandaloneLayout { let leadingWidth: CGFloat let centerWidth: CGFloat let trailingWidth: CGFloat - let suppressingCenterText: Bool } struct ExtensionLiveActivityStandaloneView: View { @@ -23,17 +22,6 @@ struct ExtensionLiveActivityStandaloneView: View { private var resolvedLeadingContent: AtollTrailingContent { resolvedExtensionLeadingContent(for: descriptor) } - private var resolvedCenterStyle: ExtensionCenterContentView.Style { - switch descriptor.centerTextStyle { - case .inline: - return .inline - case .standard: - return .stacked - case .inheritUser: - return Defaults[.sneakPeekStyles] == .inline ? .inline : .stacked - } - } - var body: some View { HStack(spacing: 0) { ExtensionLeadingContentView( @@ -49,20 +37,7 @@ struct ExtensionLiveActivityStandaloneView: View { Rectangle() .fill(Color.black) .frame(width: layout.centerWidth, height: contentHeight) - .overlay( - Group { - if layout.suppressingCenterText { - EmptyView() - } else { - ExtensionCenterContentView( - descriptor: descriptor, - accent: accentColor, - width: layout.centerWidth, - style: resolvedCenterStyle - ) - } - } - ) + .overlay(EmptyView()) ExtensionMusicWingView( payload: payload, @@ -182,57 +157,6 @@ struct ExtensionLeadingContentView: View { } } -struct ExtensionCenterContentView: View { - enum Style { - case stacked - case inline - } - - let descriptor: AtollLiveActivityDescriptor - let accent: Color - let width: CGFloat - let style: Style - - var body: some View { - switch style { - case .stacked: - VStack(alignment: .leading, spacing: 2) { - Text(descriptor.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - if let subtitle = descriptor.subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.7)) - .lineLimit(1) - } - } - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, alignment: .leading) - case .inline: - HStack(alignment: .center, spacing: 8) { - MarqueeText( - .constant(descriptor.title), - font: .system(size: 13, weight: .semibold), - nsFont: .body, - textColor: .white, - minDuration: 0.4, - frameWidth: max(40, width * 0.55) - ) - Spacer(minLength: 4) - if let subtitle = descriptor.subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.75)) - .lineLimit(1) - } - } - .padding(.horizontal, 12) - } - } -} - private func logExtensionDiagnostics(_ message: String) { guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } Logger.log(message, category: .extensions) @@ -382,120 +306,140 @@ struct ExtensionNotchSectionView: View { 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 let wingPadding: CGFloat = 16 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 trailingRenderable: ExtensionTrailingRenderable? { + descriptor.map { resolvedExtensionTrailingRenderable(for: $0) } } - private var leadingWidth: CGFloat { - let base = max(contentHeight, 44) - return max(base * 0.85, base + gestureProgress / 2) + private var contentHeight: CGFloat { + max(0, notchHeight - (isHovering ? 0 : 12)) } private var centerWidth: CGFloat { max(closedNotchWidth + (isHovering ? 8 : 0), 120) } - private var trailingWidth: CGFloat { - max(leadingWidth * 0.9, 120) + private var iconDiameter: CGFloat { + max(contentHeight - 8, 28) } - private var marqueeFrameWidth: CGFloat { - max(48, centerWidth - 24) + private var leftWingWidth: CGFloat { + wingPadding + iconDiameter } - private var resolvedTitle: String { - let descriptorTitle = descriptor?.title ?? "Extension" - let preferred = title.isEmpty ? descriptorTitle : title - return preferred.isEmpty ? "Extension" : preferred + private var baseTrailingWidth: CGFloat { + max(iconDiameter, 56) } - private var resolvedSubtitle: String { - if let subtitle, !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return subtitle.trimmingCharacters(in: .whitespacesAndNewlines) - } - if let descriptorSubtitle = descriptor?.subtitle, !descriptorSubtitle.isEmpty { - return descriptorSubtitle - } - return "" + private var trailingContentWidth: CGFloat { + guard let payload else { return baseTrailingWidth } + return ExtensionLayoutMetrics.trailingWidth( + for: payload, + baseWidth: baseTrailingWidth, + maxWidth: baseTrailingWidth + centerWidth * 0.6 + ) + } + + private var rightWingWidth: CGFloat { + wingPadding + trailingContentWidth + } + + private var trailingContentAvailableWidth: CGFloat { + max(0, trailingContentWidth - wingPadding) } 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) { - 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) - } - + centerSection 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) - } + Color.clear + .frame(width: leftWingWidth, height: contentHeight) + .background(alignment: .leading) { + Group { + if let descriptor { + ExtensionLeadingContentView( + content: resolvedExtensionLeadingContent(for: descriptor), + badge: descriptor.badgeIcon, + accent: resolvedAccent, + frameWidth: iconDiameter, + frameHeight: iconDiameter, + defaultIcon: descriptor.leadingIcon + ) + } else { + RoundedRectangle(cornerRadius: iconDiameter * 0.25, style: .continuous) + .fill(resolvedAccent.gradient) + .frame(width: iconDiameter, height: iconDiameter) + } + } + .frame(width: iconDiameter, height: iconDiameter) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.leading, wingPadding / 2) + } + } + + private var centerSection: some View { + Rectangle() + .fill(Color.black) + .frame(width: centerWidth, height: contentHeight) } @ViewBuilder private var trailingWing: some View { - VStack { - Rectangle() - .fill(resolvedAccent.gradient) - .mask { - AudioSpectrumView(isPlaying: .constant(true)) - .frame(width: 18, height: 12) - .frame(maxWidth: .infinity, maxHeight: .infinity) + Color.clear + .frame(width: rightWingWidth, height: contentHeight) + .background(alignment: .trailing) { + if let descriptor, let trailingRenderable { + VStack(alignment: .trailing, spacing: 6) { + switch trailingRenderable { + case let .content(content): + if case .none = content { + Spacer(minLength: 0) + } else { + ExtensionEdgeContentView( + content: content, + accent: resolvedAccent, + availableWidth: trailingContentAvailableWidth, + alignment: .trailing + ) + .frame(maxWidth: trailingContentAvailableWidth, alignment: .trailing) + } + case let .indicator(indicator): + ExtensionProgressIndicatorView( + indicator: indicator, + progress: descriptor.progress, + accent: resolvedAccent, + estimatedDuration: descriptor.estimatedDuration, + maxVisualHeight: contentHeight + ) + } + + Spacer(minLength: 0) + } + .padding(.trailing, wingPadding / 2) + .padding(.vertical, 6) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(resolvedAccent.opacity(0.25)) + .frame(width: max(32, trailingContentAvailableWidth * 0.6), height: max(16, contentHeight * 0.4)) + .padding(.trailing, wingPadding / 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + .padding(.vertical, 6) } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(.trailing, 8) - .padding(.vertical, 6) + } } } diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index 82e4600..94d30ee 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -352,8 +352,8 @@ struct ExtensionProgressIndicatorView: View { private func resolvedRingDiameter(requested: CGFloat) -> CGFloat { guard let maxVisualHeight else { return requested } - let clamped = min(requested, max(maxVisualHeight - 6, 18)) - return max(18, clamped) + let target = max(min(maxVisualHeight - 12, 24), 16) + return min(requested, target) } } From 5b41f2c3644742fc68f835c23d885316a2d973dc Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 18 Jan 2026 21:05:15 +0530 Subject: [PATCH 13/16] Update stuff --- DynamicIsland/ContentView.swift | 28 +++++++++++++++++-- .../ExtensionLiveActivityViews.swift | 5 +++- .../ExtensionRenderingHelpers.swift | 16 +++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index a6f416c..9ee6c0e 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -834,7 +834,9 @@ struct ContentView: View { ) let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerBaseWidth let notchWidth = wingBaseWidth + effectiveCenterWidth + rightWingWidth - let badgeSize = max(16, notchContentHeight * 0.45) + let badgeBaseSize = max(13, notchContentHeight * 0.36) + let badgeDisplaySize = badgeDisplaySize(for: secondary, baseSize: badgeBaseSize) + let badgeOffset = badgeOverlayOffset(for: secondary, badgeSize: badgeDisplaySize) HStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { @@ -849,8 +851,8 @@ struct ContentView: View { .clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed)) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .albumArtFlip(angle: musicManager.flipAngle) - albumArtBadge(for: secondary, badgeSize: badgeSize) - .offset(x: badgeSize * 0.2, y: badgeSize * 0.25) + albumArtBadge(for: secondary, badgeSize: badgeDisplaySize) + .offset(x: badgeOffset.width, y: badgeOffset.height) .id(secondary?.id ?? "music-badge") .contentTransition(.symbolEffect(.replace)) } @@ -1066,6 +1068,26 @@ struct ContentView: View { } } + private func badgeDisplaySize(for secondary: MusicSecondaryLiveActivity?, baseSize: CGFloat) -> CGFloat { + guard let secondary else { return baseSize } + switch secondary { + case .extensionPayload: + return max(11, baseSize * 0.86) + default: + return baseSize + } + } + + private func badgeOverlayOffset(for secondary: MusicSecondaryLiveActivity?, badgeSize: CGFloat) -> CGSize { + guard let secondary else { return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25) } + switch secondary { + case .extensionPayload: + return CGSize(width: badgeSize * 0.14, height: badgeSize * 0.12) + default: + return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25) + } + } + @ViewBuilder private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat, trailingWidth: CGFloat) -> some View { switch secondary { diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 6167aae..0a45cfd 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -314,7 +314,10 @@ struct ExtensionInlineSneakPeekView: View { private let wingPadding: CGFloat = 16 private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor } - private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor } + private var resolvedAccent: Color { + guard let descriptor else { return accentColor } + return descriptor.accentColor.resolvedColor(fallback: accentColor) + } private var trailingRenderable: ExtensionTrailingRenderable? { descriptor.map { resolvedExtensionTrailingRenderable(for: $0) } } diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index 94d30ee..af5dcd8 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -21,6 +21,10 @@ extension AtollColorDescriptor { } return NSColor(red: red, green: green, blue: blue, alpha: alpha) } + + func resolvedColor(fallback accent: Color) -> Color { + isAccent ? accent : swiftUIColor + } } // MARK: - Font Conversion @@ -287,7 +291,7 @@ struct ExtensionProgressIndicatorView: View { var body: some View { switch indicator { case let .ring(diameter, strokeWidth, color): - let ringColor = color?.swiftUIColor ?? accent + let ringColor = color?.resolvedColor(fallback: accent) ?? accent let resolvedDiameter = resolvedRingDiameter(requested: diameter) ZStack { Circle() @@ -300,7 +304,7 @@ struct ExtensionProgressIndicatorView: View { } .frame(width: resolvedDiameter, height: resolvedDiameter) case let .bar(width, height, cornerRadius, color): - let barColor = color?.swiftUIColor ?? accent + let barColor = color?.resolvedColor(fallback: accent) ?? accent Capsule() .fill(Color.white.opacity(0.18)) .frame(width: width ?? 80, height: height) @@ -311,13 +315,13 @@ struct ExtensionProgressIndicatorView: View { .animation(.smooth(duration: 0.25), value: progress) } case let .percentage(font, color): - let textColor = color?.swiftUIColor ?? accent + let textColor = color?.resolvedColor(fallback: accent) ?? accent Text("\(Int(progress * 100))%") .font(font.swiftUIFont()) .foregroundStyle(textColor) .monospacedDigit() case let .countdown(font, color): - let textColor = color?.swiftUIColor ?? accent + let textColor = color?.resolvedColor(fallback: accent) ?? accent let text = countdownText Text(text) .font(font.swiftUIFont()) @@ -383,7 +387,7 @@ struct ExtensionEdgeContentView: View { frameWidth: max(24, availableWidth - 12) ) case let .countdownText(targetDate, font: font, color: color): - ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.swiftUIColor) + ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.resolvedColor(fallback: accent)) .frame(maxWidth: .infinity, alignment: alignment) case let .icon(descriptor): ExtensionIconView( @@ -409,7 +413,7 @@ struct ExtensionEdgeContentView: View { } private func resolvedColor(_ descriptorColor: AtollColorDescriptor?) -> Color { - descriptorColor?.swiftUIColor ?? accent + descriptorColor?.resolvedColor(fallback: accent) ?? accent } } From 8720de3cf77ee7a20f2b11ddc65802e3794e3149 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 22 Jan 2026 13:13:55 +0530 Subject: [PATCH 14/16] Update Lock Screen Customization --- DynamicIsland/Localizable.xcstrings | 62 ++- .../LockScreen/LockScreenMusicPanel.swift | 72 ++- .../LockScreen/LockScreenTimerWidget.swift | 49 +- .../components/Settings/SettingsView.swift | 443 +++++++++++++++++- DynamicIsland/enums/generic.swift | 18 + .../helpers/LiquidGlassBackground.swift | 134 ++++++ DynamicIsland/models/Constants.swift | 17 + 7 files changed, 749 insertions(+), 46 deletions(-) create mode 100644 DynamicIsland/helpers/LiquidGlassBackground.swift diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4b14356..6d116e6 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -5157,6 +5157,9 @@ } } } + }, + "Choose the global material mode for lock screen widgets. Custom Liquid unlocks per-widget variant sliders while Standard sticks to the classic frosted/liquid options." : { + }, "Choose which service to use when sharing files from the shelf. Drag files onto the shelf or click the shelf button to pick files." : { "comment" : "A footer description under the Quick Share section of the Shelf settings.", @@ -5292,6 +5295,10 @@ } } }, + "Classic mode keeps the original translucent black background." : { + "comment" : "A description of the \"Classic mode\" option in the Lock Screen Integration settings.", + "isCommentAutoGenerated" : true + }, "Clear" : { "extractionState" : "stale", "localizations" : { @@ -5811,6 +5818,9 @@ }, "Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." : { + }, + "Configure lock screen materials from the Appearance tab. Custom Liquid unlocks variant sliders for both widgets whenever Liquid Glass is selected." : { + }, "Configure presets in Settings" : { "comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.", @@ -5951,9 +5961,8 @@ "Controls how often system metrics refresh while monitoring is active." : { }, - "Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view." : { - "comment" : "A footer for the \"Timer Widget\" section in the Lock Screen Settings view.", - "isCommentAutoGenerated" : true + "Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting." : { + }, "Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes." : { @@ -6211,6 +6220,13 @@ } } } + }, + "Custom liquid glass already renders with Apple's liquid material, so this option is managed automatically." : { + "comment" : "A description of the custom liquid blur option in the media settings.", + "isCommentAutoGenerated" : true + }, + "Custom Liquid settings require the Liquid Glass material." : { + }, "Custom music live activity animation" : { "localizations" : { @@ -9555,6 +9571,9 @@ } } } + }, + "Glass mode" : { + }, "Global Settings" : { "comment" : "The header for the section that controls global settings related to extensions.", @@ -11137,6 +11156,12 @@ } } } + }, + "Lock Screen Glass" : { + + }, + "Lock screen glass mode" : { + }, "Lock Screen Integration" : { @@ -13244,6 +13269,9 @@ "Move Up" : { "comment" : "A button label that says \"Move Up\".", "isCommentAutoGenerated" : true + }, + "Music panel variant" : { + }, "Music Source" : { "localizations" : { @@ -15141,6 +15169,9 @@ }, "Pick Color" : { + }, + "Pick per-widget liquid-glass variants below. Changes mirror the Lock Screen tab." : { + }, "Picked %@" : { @@ -19166,6 +19197,14 @@ } } }, + "Timer glass material" : { + "comment" : "A label displayed above a picker for selecting the material of the timer glass.", + "isCommentAutoGenerated" : true + }, + "Timer liquid mode" : { + "comment" : "A segment control that lets the user choose between different customization modes for the timer glass.", + "isCommentAutoGenerated" : true + }, "Timer Presets" : { "comment" : "A section header in the timer settings view, describing timer presets.", "isCommentAutoGenerated" : true @@ -19180,12 +19219,19 @@ } } }, + "Timer surface" : { + "comment" : "A label displayed above the user's timer surface.", + "isCommentAutoGenerated" : true + }, "Timer tint" : { }, "Timer Widget" : { "comment" : "A section header for the timer widget settings.", "isCommentAutoGenerated" : true + }, + "Timer widget variant" : { + }, "Title" : { "comment" : "A label for the title input field in the note editor.", @@ -20126,6 +20172,9 @@ "Use the Media tab to configure sneak peek, lyrics, and floating media controls." : { "comment" : "A footer for the \"Media Live Activity\" section, explaining how to configure the media controls.", "isCommentAutoGenerated" : true + }, + "Use the sliders below to pick unique Apple liquid-glass variants for each widget." : { + }, "Used" : { "comment" : "A label describing the percentage of memory that is currently in use.", @@ -20153,6 +20202,10 @@ }, "Uses event-driven private API for real-time screen recording detection" : { + }, + "Uses the frosted blur treatment while glass mode is enabled." : { + "comment" : "A description of the frosted blur treatment when glass mode is enabled.", + "isCommentAutoGenerated" : true }, "Utilization" : { "comment" : "A label displayed alongside the utilization percentage in the GPU detail view.", @@ -20165,6 +20218,9 @@ } } } + }, + "v%lld" : { + }, "Version" : { "localizations" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 10a3f1f..61b6cf0 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -11,6 +11,8 @@ import Defaults struct LockScreenMusicPanel: View { private struct GlassLogSnapshot: Equatable { let style: LockScreenGlassStyle + let customizationMode: LockScreenGlassCustomizationMode + let variantRawValue: Int let usesLiquidGlass: Bool } @@ -32,6 +34,8 @@ struct LockScreenMusicPanel: View { @State private var isParallaxSuspended = false @State private var lastLoggedGlassSnapshot: GlassLogSnapshot? @Default(.lockScreenGlassStyle) var lockScreenGlassStyle + @Default(.lockScreenGlassCustomizationMode) private var glassCustomizationMode + @Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant @Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelUsesBlur) var enableBlur @@ -70,12 +74,21 @@ struct LockScreenMusicPanel: View { isExpanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius } - private var usesLiquidGlass: Bool { + private var usesCustomLiquidGlass: Bool { + glassCustomizationMode == .customLiquid + } + + private var usesStandardLiquidGlass: Bool { + guard glassCustomizationMode == .standard else { return false } if #available(macOS 26.0, *) { return lockScreenGlassStyle == .liquid } return false } + + private var usesLiquidGlass: Bool { + usesCustomLiquidGlass || usesStandardLiquidGlass + } var body: some View { if isActive { @@ -138,6 +151,14 @@ struct LockScreenMusicPanel: View { .onChange(of: lockScreenGlassStyle) { _, _ in logGlassState(reason: "Glass style updated") } + .onChange(of: glassCustomizationMode) { _, _ in + logGlassState(reason: "Glass mode updated") + } + .onChange(of: musicGlassVariant) { _, _ in + if usesCustomLiquidGlass { + logGlassState(reason: "Liquid variant updated") + } + } .scaleEffect(animator.isPresented ? 1 : 0.9, anchor: .center) .opacity(animator.isPresented ? 1 : 0) .animation(.spring(response: 0.52, dampingFraction: 0.8), value: animator.isPresented) @@ -723,8 +744,10 @@ struct LockScreenMusicPanel: View { @ViewBuilder private var panelBackgroundLayer: some View { - if usesLiquidGlass { - liquidPanelBackdrop + if usesCustomLiquidGlass { + customLiquidPanelBackdrop + } else if usesStandardLiquidGlass { + standardLiquidPanelBackdrop } else if shouldUseFrostedBlur { frostedPanelBackground } else { @@ -733,7 +756,7 @@ struct LockScreenMusicPanel: View { } @ViewBuilder - private var liquidPanelBackdrop: some View { + private var standardLiquidPanelBackdrop: some View { if #available(macOS 26.0, *) { GlassTextBackdrop(cornerRadius: panelCornerRadius) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -742,6 +765,16 @@ struct LockScreenMusicPanel: View { } } + @ViewBuilder + private var customLiquidPanelBackdrop: some View { + LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: panelCornerRadius) { + Color.white.opacity(0.04) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + private var frostedPanelBackground: some View { RoundedRectangle(cornerRadius: panelCornerRadius) .fill(.ultraThinMaterial) @@ -768,7 +801,9 @@ struct LockScreenMusicPanel: View { @ViewBuilder private func albumArtBackground(cornerRadius: CGFloat) -> some View { - if usesLiquidGlass { + if usesCustomLiquidGlass { + customLiquidAlbumArtBackground(cornerRadius: cornerRadius) + } else if usesStandardLiquidGlass { if #available(macOS 26.0, *) { clearLiquidGlassSurface(cornerRadius: cornerRadius) } @@ -809,8 +844,20 @@ struct LockScreenMusicPanel: View { ) } + @ViewBuilder + private func customLiquidAlbumArtBackground(cornerRadius: CGFloat) -> some View { + LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: cornerRadius) { + Color.clear + } + } + private func logGlassState(reason: String) { - let snapshot = GlassLogSnapshot(style: lockScreenGlassStyle, usesLiquidGlass: usesLiquidGlass) + let snapshot = GlassLogSnapshot( + style: lockScreenGlassStyle, + customizationMode: glassCustomizationMode, + variantRawValue: musicGlassVariant.rawValue, + usesLiquidGlass: usesLiquidGlass + ) guard snapshot != lastLoggedGlassSnapshot else { return } lastLoggedGlassSnapshot = snapshot @@ -831,9 +878,18 @@ struct LockScreenMusicPanel: View { return "\(entry.name)=\(mode)" }.joined(separator: ", ") - print("[LockScreenMusicPanel] \(reason) – style=\(lockScreenGlassStyle.rawValue), usesLiquidGlass=\(usesLiquidGlass), components[\(componentSummary)], macOS \(currentOSVersionDescription())") + let modeDescription: String + if usesCustomLiquidGlass { + modeDescription = "Custom Liquid (variant \(musicGlassVariant.rawValue))" + } else if usesStandardLiquidGlass { + modeDescription = "Standard Liquid" + } else { + modeDescription = lockScreenGlassStyle.rawValue + } - if lockScreenGlassStyle == .liquid && !usesLiquidGlass { + print("[LockScreenMusicPanel] \(reason) – customization=\(glassCustomizationMode.rawValue), mode=\(modeDescription), components[\(componentSummary)], macOS \(currentOSVersionDescription())") + + if glassCustomizationMode == .standard && lockScreenGlassStyle == .liquid && !usesStandardLiquidGlass { print("[LockScreenMusicPanel] Liquid Glass requested but unavailable on this macOS build. Falling back to frosted visuals.") } } diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index 21a9be6..54ee35e 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -7,8 +7,10 @@ struct LockScreenTimerWidget: View { @ObservedObject private var animator: LockScreenTimerWidgetAnimator @ObservedObject private var timerManager = TimerManager.shared - @Default(.lockScreenGlassStyle) private var glassStyle - @Default(.lockScreenTimerWidgetUsesBlur) private var enableTimerBlur + @Default(.lockScreenTimerGlassStyle) private var timerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var timerGlassCustomizationMode + @Default(.lockScreenTimerLiquidGlassVariant) private var timerGlassVariant + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.timerPresets) private var timerPresets @MainActor @@ -78,29 +80,45 @@ struct LockScreenTimerWidget: View { ) } - private var usesLiquidGlass: Bool { + private var timerSurfaceMode: LockScreenTimerSurfaceMode { + timerGlassModeIsGlass ? .glass : .classic + } + + private var usesGlassBackground: Bool { + timerSurfaceMode == .glass + } + + private var usesCustomLiquidGlass: Bool { + guard usesGlassBackground else { return false } + return timerGlassStyle == .liquid && timerGlassCustomizationMode == .customLiquid + } + + private var usesStandardLiquidGlass: Bool { + guard usesGlassBackground else { return false } + guard timerGlassStyle == .liquid else { return false } if #available(macOS 26.0, *) { - return glassStyle == .liquid + return timerGlassCustomizationMode == .standard } return false } @ViewBuilder private var widgetBackground: some View { - if enableTimerBlur { - if usesLiquidGlass { - liquidBackground + if usesGlassBackground { + if usesCustomLiquidGlass { + customLiquidBackground + } else if timerGlassStyle == .liquid { + standardLiquidBackground } else { frostedBackground } } else { - RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) - .fill(Color.black.opacity(0.65)) + classicBackground } } @ViewBuilder - private var liquidBackground: some View { + private var standardLiquidBackground: some View { if #available(macOS 26.0, *) { RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) .glassEffect( @@ -112,11 +130,22 @@ struct LockScreenTimerWidget: View { } } + private var customLiquidBackground: some View { + LiquidGlassBackground(variant: timerGlassVariant, cornerRadius: Self.cornerRadius) { + Color.black.opacity(0.12) + } + } + private var frostedBackground: some View { RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) .fill(.ultraThinMaterial) } + private var classicBackground: some View { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(Color.black.opacity(0.65)) + } + private var pauseIcon: String { timerManager.isPaused ? "play.fill" : "pause.fill" } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index cf04058..1b8acfc 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -178,26 +178,34 @@ private struct SettingsHighlightModifier: ViewModifier { func body(content: Content) -> some View { content .id(id) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.accentColor.opacity(0.08)) - .padding(.horizontal, -8) - .opacity(isActive ? 1 : 0) - .animation(.easeInOut(duration: 0.25), value: isActive) - ) - .overlay { + .background(highlightBackground) + .onChange(of: isActive) { _, active in + animatePulse = active + } + .onAppear { if isActive { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.accentColor.opacity(0.85), lineWidth: 2) - .padding(.vertical, -4) - .padding(.horizontal, -8) - .opacity(animatePulse ? 0.25 : 0.9) - .animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: animatePulse) - .onAppear { animatePulse = true } - .onDisappear { animatePulse = false } + animatePulse = true } } } + + private var highlightBackground: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke( + Color.accentColor.opacity(isActive ? (animatePulse ? 0.95 : 0.4) : 0), + lineWidth: 2 + ) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.accentColor.opacity(isActive ? 0.08 : 0)) + ) + .padding(-4) + .shadow(color: Color.accentColor.opacity(isActive ? 0.25 : 0), radius: animatePulse ? 8 : 2) + .animation( + isActive ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, + value: animatePulse + ) + } } extension View { @@ -679,7 +687,10 @@ struct SettingsView: View { SettingsSearchEntry(tab: .lockScreen, title: "Show panel border", keywords: ["panel border"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show panel border")), SettingsSearchEntry(tab: .lockScreen, title: "Enable media panel blur", keywords: ["blur", "media panel"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable media panel blur")), SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen timer", keywords: ["timer widget", "lock screen timer"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen timer")), - SettingsSearchEntry(tab: .lockScreen, title: "Enable timer blur", keywords: ["timer blur"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable timer blur")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer surface", keywords: ["timer glass", "classic", "blur"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer surface")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer glass material", keywords: ["frosted", "liquid", "timer material"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer glass material")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer liquid mode", keywords: ["timer", "standard", "custom"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer liquid mode")), + SettingsSearchEntry(tab: .lockScreen, title: "Timer widget variant", keywords: ["timer variant", "liquid"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer widget variant")), SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen weather", keywords: ["weather widget"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen weather")), SettingsSearchEntry(tab: .lockScreen, title: "Layout", keywords: ["inline", "circular", "weather layout"], highlightID: SettingsTab.lockScreen.highlightID(for: "Layout")), SettingsSearchEntry(tab: .lockScreen, title: "Weather data provider", keywords: ["wttr", "open meteo"], highlightID: SettingsTab.lockScreen.highlightID(for: "Weather data provider")), @@ -708,7 +719,10 @@ struct SettingsView: View { SettingsSearchEntry(tab: .timer, title: "Enable timer feature", keywords: ["timer", "enable"], highlightID: SettingsTab.timer.highlightID(for: "Enable timer feature")), SettingsSearchEntry(tab: .timer, title: "Mirror macOS Clock timers", keywords: ["system timer", "clock app"], highlightID: SettingsTab.timer.highlightID(for: "Mirror macOS Clock timers")), SettingsSearchEntry(tab: .timer, title: "Show lock screen timer widget", keywords: ["lock screen", "timer widget"], highlightID: SettingsTab.timer.highlightID(for: "Show lock screen timer widget")), - SettingsSearchEntry(tab: .timer, title: "Enable timer blur", keywords: ["timer blur", "lock screen"], highlightID: SettingsTab.timer.highlightID(for: "Enable timer blur")), + SettingsSearchEntry(tab: .timer, title: "Timer surface", keywords: ["timer glass", "classic", "blur"], highlightID: SettingsTab.timer.highlightID(for: "Timer surface")), + SettingsSearchEntry(tab: .timer, title: "Timer glass material", keywords: ["frosted", "liquid", "timer material"], highlightID: SettingsTab.timer.highlightID(for: "Timer glass material")), + SettingsSearchEntry(tab: .timer, title: "Timer liquid mode", keywords: ["timer", "standard", "custom"], highlightID: SettingsTab.timer.highlightID(for: "Timer liquid mode")), + SettingsSearchEntry(tab: .timer, title: "Timer widget variant", keywords: ["timer variant", "liquid"], highlightID: SettingsTab.timer.highlightID(for: "Timer widget variant")), SettingsSearchEntry(tab: .timer, title: "Timer tint", keywords: ["timer colour", "preset"], highlightID: SettingsTab.timer.highlightID(for: "Timer tint")), SettingsSearchEntry(tab: .timer, title: "Solid colour", keywords: ["timer colour", "custom"], highlightID: SettingsTab.timer.highlightID(for: "Solid colour")), SettingsSearchEntry(tab: .timer, title: "Progress style", keywords: ["progress", "bar", "ring"], highlightID: SettingsTab.timer.highlightID(for: "Progress style")), @@ -1936,6 +1950,7 @@ struct Media: View { @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget @Default(.showSneakPeekOnTrackChange) private var showSneakPeekOnTrackChange @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle + @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled private func highlightID(_ title: String) -> String { @@ -2079,7 +2094,11 @@ struct Media: View { .disabled(!enableLockScreenMediaWidget) Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder) .disabled(!enableLockScreenMediaWidget) - if lockScreenGlassStyle == .frosted { + if lockScreenGlassCustomizationMode == .customLiquid { + customLiquidBlurRow + .opacity(enableLockScreenMediaWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Enable media panel blur")) + } else if lockScreenGlassStyle == .frosted { Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur) .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Enable media panel blur")) @@ -2129,6 +2148,16 @@ struct Media: View { } .frame(maxWidth: .infinity, alignment: .leading) } + private var customLiquidBlurRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Enable media panel blur") + .foregroundStyle(.secondary) + Text("Custom liquid glass already renders with Apple's liquid material, so this option is managed automatically.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } } struct CalendarSettings: View { @@ -2762,6 +2791,15 @@ struct Appearance: View { @Default(.selectedVisualizer) var selectedVisualizer @Default(.openNotchWidth) var openNotchWidth @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode + @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle + @Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant + @Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant + @Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass + @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget + @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget let icons: [String] = ["logo2"] @State private var selectedIcon: String = "logo2" @State private var selectedListVisualizer: CustomVisualizer? = nil @@ -2778,6 +2816,37 @@ struct Appearance: View { SettingsTab.appearance.highlightID(for: title) } + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var appearanceMusicVariantBinding: Binding { + Binding( + get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenMusicLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var appearanceTimerVariantBinding: Binding { + Binding( + get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerSurfaceBinding: Binding { + Binding( + get: { timerGlassModeIsGlass ? .glass : .classic }, + set: { mode in timerGlassModeIsGlass = (mode == .glass) } + ) + } + var body: some View { Form { Section { @@ -2796,6 +2865,80 @@ struct Appearance: View { notchWidthControls() + Section { + if #available(macOS 26.0, *) { + Picker("Material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .settingsHighlight(id: highlightID("Lock screen material")) + } else { + Picker("Material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(true) + .settingsHighlight(id: highlightID("Lock screen material")) + Text("Liquid Glass requires macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if lockScreenGlassStyle == .liquid { + Picker("Lock screen glass mode", selection: $lockScreenGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Lock screen glass mode")) + + if lockScreenGlassCustomizationMode == .customLiquid { + Text("Pick per-widget liquid-glass variants below. Changes mirror the Lock Screen tab.") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Music panel variant") + Spacer() + Text("v\(lockScreenMusicLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: appearanceMusicVariantBinding, in: liquidVariantRange, step: 1) + } + .settingsHighlight(id: highlightID("Music panel variant (appearance)")) + .disabled(!enableLockScreenMediaWidget) + .opacity(enableLockScreenMediaWidget ? 1 : 0.4) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Timer widget variant") + Spacer() + Text("v\(lockScreenTimerLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: appearanceTimerVariantBinding, in: liquidVariantRange, step: 1) + } + .settingsHighlight(id: highlightID("Timer widget variant (appearance)")) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.4) + } + } else { + Text("Custom Liquid settings require the Liquid Glass material.") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Lock Screen Glass") + } footer: { + Text("Configure lock screen materials from the Appearance tab. Custom Liquid unlocks variant sliders for both widgets whenever Liquid Glass is selected.") + } + Section { Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) .settingsHighlight(id: highlightID("Enable colored spectrograms")) @@ -3047,6 +3190,9 @@ struct Appearance: View { } } } + .onAppear(perform: enforceLockScreenGlassConsistency) + .onChange(of: lockScreenGlassStyle) { _, _ in enforceLockScreenGlassConsistency() } + .onChange(of: lockScreenGlassCustomizationMode) { _, _ in enforceLockScreenGlassConsistency() } .navigationTitle("Appearance") } @@ -3112,10 +3258,25 @@ struct Appearance: View { } } } + + private func enforceLockScreenGlassConsistency() { + if lockScreenGlassStyle == .frosted && lockScreenGlassCustomizationMode != .standard { + lockScreenGlassCustomizationMode = .standard + } + if lockScreenGlassCustomizationMode == .customLiquid && lockScreenGlassStyle != .liquid { + lockScreenGlassStyle = .liquid + } + } } struct LockScreenSettings: View { @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle + @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode + @Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant + @Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant + @Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget @@ -3133,6 +3294,37 @@ struct LockScreenSettings: View { SettingsTab.lockScreen.highlightID(for: title) } + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var musicVariantBinding: Binding { + Binding( + get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenMusicLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerVariantBinding: Binding { + Binding( + get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerSurfaceBinding: Binding { + Binding( + get: { timerGlassModeIsGlass ? .glass : .classic }, + set: { mode in timerGlassModeIsGlass = (mode == .glass) } + ) + } + var body: some View { Form { Section { @@ -3166,6 +3358,33 @@ struct LockScreenSettings: View { .font(.caption) .foregroundStyle(.secondary) } + + if lockScreenGlassStyle == .liquid { + Picker("Glass mode", selection: $lockScreenGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .settingsHighlight(id: highlightID("Glass mode")) + + if lockScreenGlassCustomizationMode == .customLiquid { + Text("Use the sliders below to pick unique Apple liquid-glass variants for each widget.") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("Custom Liquid settings require the Liquid Glass material.") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Lock Screen Glass") + } footer: { + Text("Choose the global material mode for lock screen widgets. Custom Liquid unlocks per-widget variant sliders while Standard sticks to the classic frosted/liquid options.") + } + + Section { Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Show lock screen media panel")) Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon) @@ -3174,7 +3393,15 @@ struct LockScreenSettings: View { Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder) .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Show panel border")) - if lockScreenGlassStyle == .frosted { + if lockScreenGlassCustomizationMode == .customLiquid { + variantSlider( + title: "Music panel variant", + value: musicVariantBinding, + currentValue: lockScreenMusicLiquidGlassVariant.rawValue, + isEnabled: enableLockScreenMediaWidget, + highlight: highlightID("Music panel variant") + ) + } else if lockScreenGlassStyle == .frosted { Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur) .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Enable media panel blur")) @@ -3192,13 +3419,63 @@ struct LockScreenSettings: View { Section { Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget) .settingsHighlight(id: highlightID("Show lock screen timer")) - Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur) + Picker("Timer surface", selection: timerSurfaceBinding) { + ForEach(LockScreenTimerSurfaceMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer surface")) + + if timerGlassModeIsGlass { + Picker("Timer glass material", selection: $lockScreenTimerGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } .disabled(!enableLockScreenTimerWidget) - .settingsHighlight(id: highlightID("Enable timer blur")) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer glass material")) + + if lockScreenTimerGlassStyle == .liquid { + Picker("Timer liquid mode", selection: $lockScreenTimerGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer liquid mode")) + + if lockScreenTimerGlassCustomizationMode == .customLiquid { + variantSlider( + title: "Timer widget variant", + value: timerVariantBinding, + currentValue: lockScreenTimerLiquidGlassVariant.rawValue, + isEnabled: enableLockScreenTimerWidget, + highlight: highlightID("Timer widget variant") + ) + } + } else { + Text("Uses the frosted blur treatment while glass mode is enabled.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("Classic mode keeps the original translucent black background.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + } } header: { Text("Timer Widget") } footer: { - Text("Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view.") + Text("Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting.") } Section { @@ -3315,11 +3592,23 @@ struct LockScreenSettings: View { Text("Collect the latest crash report to share with the developer when reporting lock screen or overlay issues.") } } + .onAppear(perform: enforceLockScreenGlassConsistency) + .onChange(of: lockScreenGlassStyle) { _, _ in enforceLockScreenGlassConsistency() } + .onChange(of: lockScreenGlassCustomizationMode) { _, _ in enforceLockScreenGlassConsistency() } .navigationTitle("Lock Screen") } } extension LockScreenSettings { + private func enforceLockScreenGlassConsistency() { + if lockScreenGlassStyle == .frosted && lockScreenGlassCustomizationMode != .standard { + lockScreenGlassCustomizationMode = .standard + } + if lockScreenGlassCustomizationMode == .customLiquid && lockScreenGlassStyle != .liquid { + lockScreenGlassStyle = .liquid + } + } + private var blurSettingUnavailableRow: some View { VStack(alignment: .leading, spacing: 4) { Text("Enable media panel blur") @@ -3330,6 +3619,29 @@ extension LockScreenSettings { } .frame(maxWidth: .infinity, alignment: .leading) } + + @ViewBuilder + private func variantSlider( + title: String, + value: Binding, + currentValue: Int, + isEnabled: Bool, + highlight: String + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + Spacer() + Text("v\(currentValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: value, in: liquidVariantRange, step: 1) + } + .settingsHighlight(id: highlight) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.4) + } } private struct LockScreenPositioningControls: View { @@ -3954,6 +4266,10 @@ struct TimerSettings: View { @Default(.mirrorSystemTimer) private var mirrorSystemTimer @Default(.timerDisplayMode) private var timerDisplayMode @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget + @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass + @Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle + @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode + @Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 @State private var customHours: Int = 0 @State private var customMinutes: Int = 10 @@ -3963,6 +4279,27 @@ struct TimerSettings: View { private func highlightID(_ title: String) -> String { SettingsTab.timer.highlightID(for: title) } + + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var timerVariantBinding: Binding { + Binding( + get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + + private var timerSurfaceBinding: Binding { + Binding( + get: { timerGlassModeIsGlass ? .glass : .classic }, + set: { mode in timerGlassModeIsGlass = (mode == .glass) } + ) + } var body: some View { Form { @@ -4037,9 +4374,65 @@ struct TimerSettings: View { Section { Defaults.Toggle("Show lock screen timer widget", key: .enableLockScreenTimerWidget) .settingsHighlight(id: highlightID("Show lock screen timer widget")) - Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur) + Picker("Timer surface", selection: timerSurfaceBinding) { + ForEach(LockScreenTimerSurfaceMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer surface")) + + if timerGlassModeIsGlass { + Picker("Timer glass material", selection: $lockScreenTimerGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } .disabled(!enableLockScreenTimerWidget) - .settingsHighlight(id: highlightID("Enable timer blur")) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer glass material")) + + if lockScreenTimerGlassStyle == .liquid { + Picker("Timer liquid mode", selection: $lockScreenTimerGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + .settingsHighlight(id: highlightID("Timer liquid mode")) + + if lockScreenTimerGlassCustomizationMode == .customLiquid { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Timer widget variant") + Spacer() + Text("v\(lockScreenTimerLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: timerVariantBinding, in: liquidVariantRange, step: 1) + } + .settingsHighlight(id: highlightID("Timer widget variant")) + .disabled(!enableLockScreenTimerWidget) + .opacity(enableLockScreenTimerWidget ? 1 : 0.4) + } + } else { + Text("Uses the frosted blur treatment while glass mode is enabled.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("Classic mode keeps the original translucent black background.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(enableLockScreenTimerWidget ? 1 : 0.5) + } } header: { Text("Lock Screen Integration") } footer: { diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index ab33e05..c3ffeb7 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -100,6 +100,24 @@ enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifi var id: String { rawValue } } +enum LockScreenGlassCustomizationMode: String, CaseIterable, Defaults.Serializable, Identifiable { + case standard = "Standard" + case customLiquid = "Custom Liquid" + + var id: String { rawValue } + + var allowsVariantSelection: Bool { + self == .customLiquid + } +} + +enum LockScreenTimerSurfaceMode: String, CaseIterable, Defaults.Serializable, Identifiable { + case classic = "Classic" + case glass = "Glass" + + var id: String { rawValue } +} + enum LockScreenWeatherWidgetStyle: String, CaseIterable, Defaults.Serializable, Identifiable { case inline = "Inline" case circular = "Circular" diff --git a/DynamicIsland/helpers/LiquidGlassBackground.swift b/DynamicIsland/helpers/LiquidGlassBackground.swift new file mode 100644 index 0000000..b8d8f7f --- /dev/null +++ b/DynamicIsland/helpers/LiquidGlassBackground.swift @@ -0,0 +1,134 @@ +// LiquidGlassBackground.swift +// A SwiftUI wrapper around Apple’s private NSGlassEffectView. +// Provides easy access to every liquid‑glass variant (0–19). + + +import SwiftUI +import AppKit +import Defaults + +/// All 20 available liquid‑glass variants. +/// Apple does not publicly describe how each value looks so experiment and pick the one you like! +public enum LiquidGlassVariant: Int, CaseIterable, Identifiable, Defaults.Serializable, Sendable { + case v0 = 0, v1 = 1, v2 = 2, v3 = 3, v4 = 4 + case v5 = 5, v6 = 6, v7 = 7, v8 = 8, v9 = 9 + case v10 = 10, v11 = 11, v12 = 12, v13 = 13, v14 = 14 + case v15 = 15, v16 = 16, v17 = 17, v18 = 18, v19 = 19 + + public var id: Int { rawValue } + + public static let supportedRange = 0...19 + + public static var defaultVariant: LiquidGlassVariant { .v11 } + + public static func clamped(_ rawValue: Int) -> LiquidGlassVariant { + let clamped = min(max(rawValue, supportedRange.lowerBound), supportedRange.upperBound) + return LiquidGlassVariant(rawValue: clamped) ?? .defaultVariant + } +} + +/// A SwiftUI view that embeds its content inside Apple’s private liquid‑glass material. +/// +/// ```swift +/// GlassBackground(variant: .v11, cornerRadius: 12) { +/// VStack(spacing: 12) { +/// Image(systemName: "sparkles") +/// .font(.largeTitle) +/// Text("Hello, glass!") +/// .font(.title2) +/// } +/// .padding() +/// } +/// ``` +public struct LiquidGlassBackground: NSViewRepresentable { + private let content: Content + private let cornerRadius: CGFloat + private let variant: LiquidGlassVariant + + /// Creates a new liquid‑glass container. + /// - Parameters: + /// - variant: Any ``LiquidGlassVariant`` (0–19). Defaults to `.v11`, which is visually super pleasing + /// - cornerRadius: Corner radius in points. Defaults to `10`. + /// - content: Your SwiftUI hierarchy. + public init( + variant: LiquidGlassVariant = .defaultVariant, + cornerRadius: CGFloat = 10, + @ViewBuilder content: () -> Content + ) { + self.variant = variant + self.cornerRadius = cornerRadius + self.content = content() + } + + + @inline(__always) + private func setterSelector(for key: String, privateVariant: Bool = true) -> Selector? { + guard !key.isEmpty else { return nil } + let name: String + if privateVariant { + let cleaned = key.hasPrefix("_") ? key : "_" + key + name = "set" + cleaned + } else { + let first = String(key.prefix(1)).uppercased() + let rest = String(key.dropFirst()) + name = "set" + first + rest + } + return NSSelectorFromString(name + ":") + } + + private typealias VariantSetterIMP = @convention(c) (AnyObject, Selector, Int) -> Void + + private func callPrivateVariantSetter(on object: AnyObject, value: Int) { + guard + let sel = setterSelector(for: "variant", privateVariant: true), + let m = class_getInstanceMethod(object_getClass(object), sel) + else { + #if DEBUG + print("✗ LiquidGlassBackground: selector set_variant: not found. falling back to default") + #endif + return + } + let imp = method_getImplementation(m) + let f = unsafeBitCast(imp, to: VariantSetterIMP.self) + f(object, sel, value) + } + + + + public func makeNSView(context: Context) -> NSView { + // `NSGlassEffectView` is private. Look it up dynamically to avoid compile‑time coupling. + if let glassType = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let glass = glassType.init(frame: .zero) + glass.setValue(cornerRadius, forKey: "cornerRadius") + callPrivateVariantSetter(on: glass, value: variant.rawValue) + + let hosting = NSHostingView(rootView: content) + hosting.translatesAutoresizingMaskIntoConstraints = false + glass.setValue(hosting, forKey: "contentView") + return glass + } + + // Fallback for earlier macOS – use an ordinary blur. + let fallback = NSVisualEffectView() + fallback.material = .underWindowBackground + + let hosting = NSHostingView(rootView: content) + hosting.translatesAutoresizingMaskIntoConstraints = false + fallback.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.leadingAnchor.constraint(equalTo: fallback.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: fallback.trailingAnchor), + hosting.topAnchor.constraint(equalTo: fallback.topAnchor), + hosting.bottomAnchor.constraint(equalTo: fallback.bottomAnchor) + ]) + return fallback + } + + public func updateNSView(_ nsView: NSView, context: Context) { + if let hosting = nsView.value(forKey: "contentView") as? NSHostingView { + hosting.rootView = content + } + nsView.setValue(cornerRadius, forKey: "cornerRadius") + callPrivateVariantSetter(on: nsView, value: variant.rawValue) + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 85c2859..b1aaf96 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -703,6 +703,23 @@ extension Defaults.Keys { static let lockScreenMusicAlbumParallaxEnabled = Key("lockScreenMusicAlbumParallaxEnabled", default: false) static let lockScreenTimerVerticalOffset = Key("lockScreenTimerVerticalOffset", default: 0) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) + static let lockScreenGlassCustomizationMode = Key( + "lockScreenGlassCustomizationMode", + default: .standard + ) + static let lockScreenTimerGlassStyle = Key("lockScreenTimerGlassStyle", default: .frosted) + static let lockScreenTimerGlassCustomizationMode = Key( + "lockScreenTimerGlassCustomizationMode", + default: .standard + ) + static let lockScreenMusicLiquidGlassVariant = Key( + "lockScreenMusicLiquidGlassVariant", + default: .defaultVariant + ) + static let lockScreenTimerLiquidGlassVariant = Key( + "lockScreenTimerLiquidGlassVariant", + default: .defaultVariant + ) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: false) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) From 2d86127175a914b8dd936d5c5a01cdbfa494b8bc Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 22 Jan 2026 15:57:17 +0530 Subject: [PATCH 15/16] Update Liquid Glass for Lock Screen Widgets --- .../xcshareddata/swiftpm/Package.resolved | 2 +- DynamicIsland/ContentView.swift | 19 +-- .../ExtensionLiveActivityViews.swift | 142 ------------------ .../ExtensionLockScreenWidgetView.swift | 27 +++- .../ExtensionLiveActivityManager.swift | 3 +- 5 files changed, 31 insertions(+), 162 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ab7b105..37ec1ab 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "a1303629e532337594787b981b499560a99c6c93" + "revision" : "ebe35277572a349fad26abeb6fc552cb5251d584" } }, { diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 9ee6c0e..7504853 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -556,20 +556,6 @@ 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, - accentColor: coordinator.sneakPeek.accentColor ?? .gray, - notchHeight: vm.effectiveClosedNotchHeight, - closedNotchWidth: vm.closedNotchSize.width, - isHovering: isHovering - ) - .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( @@ -1956,7 +1942,10 @@ struct ContentView: View { } private func resolvedSneakPeekStyle() -> SneakPeekStyle { - coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles] + if case .extensionLiveActivity = coordinator.sneakPeek.type { + return .standard + } + return coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles] } } diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 0a45cfd..79ff2da 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -304,148 +304,6 @@ struct ExtensionNotchSectionView: View { } } -struct ExtensionInlineSneakPeekView: View { - let payload: ExtensionLiveActivityPayload? - let accentColor: Color - let notchHeight: CGFloat - let closedNotchWidth: CGFloat - let isHovering: Bool - - private let wingPadding: CGFloat = 16 - - private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor } - private var resolvedAccent: Color { - guard let descriptor else { return accentColor } - return descriptor.accentColor.resolvedColor(fallback: accentColor) - } - private var trailingRenderable: ExtensionTrailingRenderable? { - descriptor.map { resolvedExtensionTrailingRenderable(for: $0) } - } - - private var contentHeight: CGFloat { - max(0, notchHeight - (isHovering ? 0 : 12)) - } - - private var centerWidth: CGFloat { - max(closedNotchWidth + (isHovering ? 8 : 0), 120) - } - - private var iconDiameter: CGFloat { - max(contentHeight - 8, 28) - } - - private var leftWingWidth: CGFloat { - wingPadding + iconDiameter - } - - private var baseTrailingWidth: CGFloat { - max(iconDiameter, 56) - } - - private var trailingContentWidth: CGFloat { - guard let payload else { return baseTrailingWidth } - return ExtensionLayoutMetrics.trailingWidth( - for: payload, - baseWidth: baseTrailingWidth, - maxWidth: baseTrailingWidth + centerWidth * 0.6 - ) - } - - private var rightWingWidth: CGFloat { - wingPadding + trailingContentWidth - } - - private var trailingContentAvailableWidth: CGFloat { - max(0, trailingContentWidth - wingPadding) - } - - var body: some View { - HStack(spacing: 0) { - leadingWing - centerSection - trailingWing - } - .frame(height: notchHeight + (isHovering ? 8 : 0), alignment: .center) - } - - private var leadingWing: some View { - Color.clear - .frame(width: leftWingWidth, height: contentHeight) - .background(alignment: .leading) { - Group { - if let descriptor { - ExtensionLeadingContentView( - content: resolvedExtensionLeadingContent(for: descriptor), - badge: descriptor.badgeIcon, - accent: resolvedAccent, - frameWidth: iconDiameter, - frameHeight: iconDiameter, - defaultIcon: descriptor.leadingIcon - ) - } else { - RoundedRectangle(cornerRadius: iconDiameter * 0.25, style: .continuous) - .fill(resolvedAccent.gradient) - .frame(width: iconDiameter, height: iconDiameter) - } - } - .frame(width: iconDiameter, height: iconDiameter) - .frame(maxHeight: .infinity, alignment: .center) - .padding(.leading, wingPadding / 2) - } - } - - private var centerSection: some View { - Rectangle() - .fill(Color.black) - .frame(width: centerWidth, height: contentHeight) - } - - @ViewBuilder - private var trailingWing: some View { - Color.clear - .frame(width: rightWingWidth, height: contentHeight) - .background(alignment: .trailing) { - if let descriptor, let trailingRenderable { - VStack(alignment: .trailing, spacing: 6) { - switch trailingRenderable { - case let .content(content): - if case .none = content { - Spacer(minLength: 0) - } else { - ExtensionEdgeContentView( - content: content, - accent: resolvedAccent, - availableWidth: trailingContentAvailableWidth, - alignment: .trailing - ) - .frame(maxWidth: trailingContentAvailableWidth, alignment: .trailing) - } - case let .indicator(indicator): - ExtensionProgressIndicatorView( - indicator: indicator, - progress: descriptor.progress, - accent: resolvedAccent, - estimatedDuration: descriptor.estimatedDuration, - maxVisualHeight: contentHeight - ) - } - - Spacer(minLength: 0) - } - .padding(.trailing, wingPadding / 2) - .padding(.vertical, 6) - } else { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(resolvedAccent.opacity(0.25)) - .frame(width: max(32, trailingContentAvailableWidth * 0.6), height: max(16, contentHeight * 0.4)) - .padding(.trailing, wingPadding / 2) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) - .padding(.vertical, 6) - } - } - } -} - struct ExtensionNotchSectionHeader: View { let section: AtollNotchContentSection diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index c00f26b..f12625f 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -83,7 +83,9 @@ struct ExtensionLockScreenWidgetView: View { @ViewBuilder private func backgroundLayer(shape: RoundedRectangle) -> some View { - if shouldUseGlassBackdrop { + if descriptor.material == .liquid { + liquidGlassBackground(shape: shape) + } else if shouldUseGlassHighlight { if #available(macOS 26.0, *) { ZStack { shape @@ -110,6 +112,18 @@ struct ExtensionLockScreenWidgetView: View { } } + @ViewBuilder + private func liquidGlassBackground(shape: RoundedRectangle) -> some View { + LiquidGlassBackground(variant: requestedLiquidGlassVariant, cornerRadius: descriptor.cornerRadius) { + Color.black.opacity(0.08) + } + .overlay { + tintOverlay(shape: shape) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + private var baseMaterialStyle: AnyShapeStyle { switch descriptor.material { case .frosted: @@ -160,8 +174,15 @@ struct ExtensionLockScreenWidgetView: View { } } - private var shouldUseGlassBackdrop: Bool { - descriptor.material == .liquid || (appearance?.enableGlassHighlight ?? false) + private var shouldUseGlassHighlight: Bool { + (appearance?.enableGlassHighlight ?? false) && descriptor.material != .liquid + } + + private var requestedLiquidGlassVariant: LiquidGlassVariant { + guard let requested = appearance?.liquidGlassVariant?.rawValue else { + return LiquidGlassVariant.defaultVariant + } + return LiquidGlassVariant.clamped(requested) } @ViewBuilder diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift index 6a71d7e..2fc4cd5 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -172,7 +172,8 @@ final class ExtensionLiveActivityManager: ObservableObject { guard let requestedStyle = config.style else { return nil } switch requestedStyle { case .inline: - return .inline + logDiagnostics("Inline sneak peek requested for \(descriptor.id); host will show standard mode instead") + return .standard case .standard: return .standard } From dbe6ba3daf1a48de56009771eab9fc581ad26351 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 22 Jan 2026 16:15:35 +0530 Subject: [PATCH 16/16] Finally Fixed Sneak Peek --- DynamicIsland/ContentView.swift | 2 +- DynamicIsland/DynamicIslandViewCoordinator.swift | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 7504853..ac091fd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -556,7 +556,7 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - } 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.isExtensionPayload && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( coordinator.sneakPeek.type == .capsLock diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index c3ec73b..331a256 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -55,6 +55,15 @@ extension SneakContentType { } } +extension SneakContentType { + var isExtensionPayload: Bool { + if case .extensionLiveActivity = self { + return true + } + return false + } +} + struct sneakPeek { var show: Bool = false var type: SneakContentType = .music