mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working stuff
This commit is contained in:
parent
9bd445986d
commit
94a612f327
13 changed files with 913 additions and 150 deletions
|
|
@ -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 */;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
147
DynamicIsland/managers/Extensions/ExtensionEventBridge.swift
Normal file
147
DynamicIsland/managers/Extensions/ExtensionEventBridge.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue