Working stuff

This commit is contained in:
Hariharan Mudaliar 2026-01-14 15:11:57 +05:30
commit 94a612f327
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
13 changed files with 913 additions and 150 deletions

View file

@ -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 = "<group>"; };
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 */;

View file

@ -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",

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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<T: Codable>(_ 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<T: Codable>(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<T: Codable>(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"
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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(