mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 23:54:32 +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 */
|
/* Begin PBXBuildFile section */
|
||||||
7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7E61181E2E24071E0050006C /* SkyLightWindow */; };
|
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 */; };
|
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; };
|
||||||
7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; };
|
7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; };
|
||||||
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; };
|
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; };
|
||||||
|
|
@ -26,7 +27,7 @@
|
||||||
7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
|
7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
|
||||||
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
||||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -42,6 +43,7 @@
|
||||||
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = "<group>"; };
|
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; };
|
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; };
|
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; };
|
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; };
|
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
@ -59,7 +61,6 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
B2C8B0032F11AABB00887766 /* AtollExtensionKit in Frameworks */,
|
|
||||||
7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */,
|
7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */,
|
||||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */,
|
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */,
|
||||||
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */,
|
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */,
|
||||||
|
|
@ -70,6 +71,7 @@
|
||||||
7E6118172E24071E0050006C /* Collections in Frameworks */,
|
7E6118172E24071E0050006C /* Collections in Frameworks */,
|
||||||
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */,
|
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */,
|
||||||
7E6118152E24071E0050006C /* Sparkle in Frameworks */,
|
7E6118152E24071E0050006C /* Sparkle in Frameworks */,
|
||||||
|
7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */,
|
||||||
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */,
|
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
@ -150,7 +152,6 @@
|
||||||
);
|
);
|
||||||
name = DynamicIsland;
|
name = DynamicIsland;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
B2C8B0022F11AABB00887766 /* AtollExtensionKit */,
|
|
||||||
14D032192C68F32E0096E6A1 /* LaunchAtLogin */,
|
14D032192C68F32E0096E6A1 /* LaunchAtLogin */,
|
||||||
14D0321C2C68F3350096E6A1 /* Sparkle */,
|
14D0321C2C68F3350096E6A1 /* Sparkle */,
|
||||||
B18654382C6F4990000B926A /* KeyboardShortcuts */,
|
B18654382C6F4990000B926A /* KeyboardShortcuts */,
|
||||||
|
|
@ -160,6 +161,7 @@
|
||||||
B194240A2CD10017003E5DC2 /* LottieUI */,
|
B194240A2CD10017003E5DC2 /* LottieUI */,
|
||||||
146BC4362D366E9B0047C66A /* MacroVisionKit */,
|
146BC4362D366E9B0047C66A /* MacroVisionKit */,
|
||||||
7E61181E2E24071E0050006C /* SkyLightWindow */,
|
7E61181E2E24071E0050006C /* SkyLightWindow */,
|
||||||
|
7E288D112F1659EF0020C117 /* AtollExtensionKit */,
|
||||||
);
|
);
|
||||||
productName = DynamicIsland;
|
productName = DynamicIsland;
|
||||||
productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */;
|
productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */;
|
||||||
|
|
@ -193,7 +195,6 @@
|
||||||
mainGroup = F54406DE2CD4987A00C7AFD9;
|
mainGroup = F54406DE2CD4987A00C7AFD9;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */,
|
|
||||||
F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */,
|
F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */,
|
||||||
14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */,
|
14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */,
|
||||||
14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||||
|
|
@ -205,6 +206,7 @@
|
||||||
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
|
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
|
||||||
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
|
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
|
||||||
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
|
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
|
||||||
|
7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
|
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
|
||||||
|
|
@ -239,6 +241,7 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -528,13 +531,6 @@
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
|
||||||
B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */ = {
|
|
||||||
isa = XCLocalSwiftPackageReference;
|
|
||||||
relativePath = ../AtollExtensionKit;
|
|
||||||
};
|
|
||||||
/* End XCLocalSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = {
|
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
|
@ -568,6 +564,14 @@
|
||||||
minimumVersion = 1.4.16;
|
minimumVersion = 1.4.16;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/Ebullioscopic/AtollExtensionKit";
|
||||||
|
requirement = {
|
||||||
|
branch = main;
|
||||||
|
kind = branch;
|
||||||
|
};
|
||||||
|
};
|
||||||
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = {
|
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/Lakr233/SkyLightWindow";
|
repositoryURL = "https://github.com/Lakr233/SkyLightWindow";
|
||||||
|
|
@ -642,6 +646,11 @@
|
||||||
package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||||
productName = Sparkle;
|
productName = Sparkle;
|
||||||
};
|
};
|
||||||
|
7E288D112F1659EF0020C117 /* AtollExtensionKit */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */;
|
||||||
|
productName = AtollExtensionKit;
|
||||||
|
};
|
||||||
7E61181E2E24071E0050006C /* SkyLightWindow */ = {
|
7E61181E2E24071E0050006C /* SkyLightWindow */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */;
|
package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */;
|
||||||
|
|
@ -672,11 +681,6 @@
|
||||||
package = B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */;
|
package = B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */;
|
||||||
productName = LottieUI;
|
productName = LottieUI;
|
||||||
};
|
};
|
||||||
B2C8B0022F11AABB00887766 /* AtollExtensionKit */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = B2C8B0012F11AABB00887766 /* XCLocalSwiftPackageReference "../AtollExtensionKit" */;
|
|
||||||
productName = AtollExtensionKit;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = F54406DF2CD4987A00C7AFD9 /* Project object */;
|
rootObject = F54406DF2CD4987A00C7AFD9 /* Project object */;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
{
|
{
|
||||||
"originHash" : "3b2c9100f16643012ece44ae6bfed9555c2d858d8ac2f98547c70e4c1738f0f6",
|
"originHash" : "6fbfa8cda0c383746edd66361426fdc2bd266fc082867294374a1818f02798f3",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "atollextensionkit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "defaults",
|
"identity" : "defaults",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Combine
|
import Combine
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import Foundation
|
||||||
import KeyboardShortcuts
|
import KeyboardShortcuts
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftUIIntrospect
|
import SwiftUIIntrospect
|
||||||
|
|
@ -480,12 +481,18 @@ struct ContentView: View {
|
||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
Spacer()
|
Spacer()
|
||||||
} else {
|
} 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
|
let musicPairingEligible = vm.notchState == .closed
|
||||||
&& (musicManager.isPlaying || !musicManager.isPlayerIdle)
|
&& hasActiveMusicSnapshot
|
||||||
&& coordinator.musicLiveActivityEnabled
|
&& coordinator.musicLiveActivityEnabled
|
||||||
&& !vm.hideOnClosed
|
&& !vm.hideOnClosed
|
||||||
&& !lockScreenManager.isLocked
|
&& !lockScreenManager.isLocked
|
||||||
let musicSecondary = resolveMusicSecondaryLiveActivity()
|
let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible)
|
||||||
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
|
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
|
||||||
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
|
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
|
||||||
let expansionMatchesSecondary: Bool = {
|
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 {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
|
||||||
PrivacyLiveActivity()
|
PrivacyLiveActivity()
|
||||||
} else if let extensionPayload = extensionStandalonePayload {
|
} else if let extensionPayload = extensionStandalonePayload {
|
||||||
|
let layout = extensionStandaloneLayout(
|
||||||
|
for: extensionPayload,
|
||||||
|
notchHeight: vm.effectiveClosedNotchHeight,
|
||||||
|
isHovering: isHovering
|
||||||
|
)
|
||||||
ExtensionLiveActivityStandaloneView(
|
ExtensionLiveActivityStandaloneView(
|
||||||
payload: extensionPayload,
|
payload: extensionPayload,
|
||||||
notchWidth: extensionStandaloneNotchWidth(for: vm.effectiveClosedNotchHeight, isHovering: isHovering),
|
layout: layout,
|
||||||
notchHeight: vm.effectiveClosedNotchHeight,
|
|
||||||
isHovering: isHovering
|
isHovering: isHovering
|
||||||
)
|
)
|
||||||
.transition(.opacity.combined(with: .scale))
|
.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)
|
.frame(width: rightWingWidth, height: notchContentHeight, alignment: .center)
|
||||||
.id(secondary?.id ?? "music-spectrum")
|
.id(secondary?.id ?? "music-spectrum")
|
||||||
.contentTransition(.symbolEffect(.replace))
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
|
@ -815,7 +826,7 @@ struct ContentView: View {
|
||||||
.animation(.smooth(duration: 0.25), value: secondary?.id)
|
.animation(.smooth(duration: 0.25), value: secondary?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveMusicSecondaryLiveActivity() -> MusicSecondaryLiveActivity? {
|
private func resolveMusicSecondaryLiveActivity(isMusicPairingEligible: Bool = true) -> MusicSecondaryLiveActivity? {
|
||||||
if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive {
|
if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive {
|
||||||
return .timer
|
return .timer
|
||||||
}
|
}
|
||||||
|
|
@ -837,7 +848,7 @@ struct ContentView: View {
|
||||||
return .capsLock(showLabel: showCapsLockLabel)
|
return .capsLock(showLabel: showCapsLockLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let extensionPayload = resolvedExtensionMusicPayload() {
|
if isMusicPairingEligible, let extensionPayload = resolvedExtensionMusicPayload() {
|
||||||
return .extensionPayload(extensionPayload)
|
return .extensionPayload(extensionPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -979,7 +990,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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 {
|
switch secondary {
|
||||||
case .timer:
|
case .timer:
|
||||||
MusicTimerSupplementView(
|
MusicTimerSupplementView(
|
||||||
|
|
@ -1004,12 +1015,12 @@ struct ContentView: View {
|
||||||
} else {
|
} else {
|
||||||
spectrumView(forceSpectrum: true)
|
spectrumView(forceSpectrum: true)
|
||||||
}
|
}
|
||||||
case .focus(let mode):
|
case .focus:
|
||||||
spectrumView(forceSpectrum: true)
|
spectrumView(forceSpectrum: true)
|
||||||
case .recording:
|
case .recording:
|
||||||
spectrumView(forceSpectrum: true, trailingInset: 6)
|
spectrumView(forceSpectrum: true, trailingInset: 6)
|
||||||
case .extensionPayload(let payload):
|
case .extensionPayload(let payload):
|
||||||
ExtensionMusicWingView(payload: payload, notchHeight: notchHeight)
|
ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth)
|
||||||
case .none:
|
case .none:
|
||||||
spectrumView(forceSpectrum: false)
|
spectrumView(forceSpectrum: false)
|
||||||
}
|
}
|
||||||
|
|
@ -1064,36 +1075,214 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? {
|
private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? {
|
||||||
guard enableExtensionLiveActivities,
|
let candidates = extensionLiveActivityManager.sortedActivities(for: true)
|
||||||
vm.notchState == .closed,
|
guard let payload = candidates.first else {
|
||||||
!vm.hideOnClosed,
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
!lockScreenManager.isLocked,
|
.music,
|
||||||
coordinator.musicLiveActivityEnabled else {
|
reason: "no eligible coexistence payloads",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
ExtensionRoutingDiagnostics.shared.reset(.music)
|
||||||
return nil
|
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? {
|
private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? {
|
||||||
guard enableExtensionLiveActivities,
|
let baseCandidates = extensionLiveActivityManager.sortedActivities()
|
||||||
vm.notchState == .closed,
|
guard !baseCandidates.isEmpty else {
|
||||||
!vm.hideOnClosed,
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
!lockScreenManager.isLocked,
|
.standalone,
|
||||||
vm.effectiveClosedNotchHeight > 0,
|
reason: "no active extension payloads",
|
||||||
!coordinator.expandingView.show else {
|
pendingCount: 0
|
||||||
|
)
|
||||||
|
ExtensionRoutingDiagnostics.shared.reset(.standalone)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return extensionLiveActivityManager
|
let candidates = baseCandidates.filter { $0.id != musicPayloadID }
|
||||||
.sortedActivities()
|
guard let payload = candidates.first else {
|
||||||
.first(where: { $0.id != musicPayloadID })
|
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 {
|
private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout {
|
||||||
let effectiveCenter = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96)
|
let outerHeight = notchHeight
|
||||||
let wing = max(height - (isHovering ? 0 : 12), 0)
|
let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12))
|
||||||
let total = effectiveCenter + (wing * 2)
|
let leadingWidth = max(contentHeight, 44)
|
||||||
return max(total, effectiveCenter)
|
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
|
@ViewBuilder
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,84 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
import AtollExtensionKit
|
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 {
|
struct ExtensionLiveActivityStandaloneView: View {
|
||||||
let payload: ExtensionLiveActivityPayload
|
let payload: ExtensionLiveActivityPayload
|
||||||
let notchWidth: CGFloat
|
let layout: ExtensionStandaloneLayout
|
||||||
let notchHeight: CGFloat
|
|
||||||
let isHovering: Bool
|
let isHovering: Bool
|
||||||
|
|
||||||
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
|
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
|
||||||
private var contentHeight: CGFloat {
|
private var contentHeight: CGFloat { layout.contentHeight }
|
||||||
max(0, notchHeight - (isHovering ? 0 : 12))
|
|
||||||
}
|
|
||||||
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
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 {
|
var body: some View {
|
||||||
let baseWingWidth = max(contentHeight, 44)
|
HStack(spacing: 0) {
|
||||||
let trailingWidth = ExtensionLayoutMetrics.trailingWidth(
|
ExtensionLeadingContentView(
|
||||||
for: payload,
|
content: resolvedLeadingContent,
|
||||||
baseWidth: baseWingWidth,
|
|
||||||
maxWidth: notchWidth * 0.42
|
|
||||||
)
|
|
||||||
let centerWidth = max(96, notchWidth - baseWingWidth - trailingWidth)
|
|
||||||
|
|
||||||
return HStack(spacing: 0) {
|
|
||||||
ExtensionCompositeIconView(
|
|
||||||
leading: descriptor.leadingIcon,
|
|
||||||
badge: descriptor.badgeIcon,
|
badge: descriptor.badgeIcon,
|
||||||
accent: accentColor,
|
accent: accentColor,
|
||||||
size: contentHeight
|
frameWidth: layout.leadingWidth,
|
||||||
|
frameHeight: contentHeight
|
||||||
)
|
)
|
||||||
.frame(width: baseWingWidth, height: contentHeight)
|
.frame(width: layout.leadingWidth, height: contentHeight)
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.black)
|
.fill(Color.black)
|
||||||
.frame(width: centerWidth, height: contentHeight)
|
.frame(width: layout.centerWidth, height: contentHeight)
|
||||||
.overlay(centerContent)
|
.overlay(
|
||||||
|
ExtensionCenterContentView(
|
||||||
|
descriptor: descriptor,
|
||||||
|
accent: accentColor,
|
||||||
|
width: layout.centerWidth,
|
||||||
|
style: resolvedCenterStyle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
ExtensionMusicWingView(payload: payload, notchHeight: contentHeight)
|
ExtensionMusicWingView(
|
||||||
.frame(width: trailingWidth, height: contentHeight)
|
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)
|
.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 {
|
struct ExtensionMusicWingView: View {
|
||||||
let payload: ExtensionLiveActivityPayload
|
let payload: ExtensionLiveActivityPayload
|
||||||
let notchHeight: CGFloat
|
let notchHeight: CGFloat
|
||||||
|
let trailingWidth: CGFloat
|
||||||
|
|
||||||
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
|
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
|
||||||
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
||||||
|
|
@ -73,7 +88,12 @@ struct ExtensionMusicWingView: View {
|
||||||
if case .none = descriptor.trailingContent {
|
if case .none = descriptor.trailingContent {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
ExtensionTrailingContentView(content: descriptor.trailingContent, accent: accentColor)
|
ExtensionEdgeContentView(
|
||||||
|
content: descriptor.trailingContent,
|
||||||
|
accent: accentColor,
|
||||||
|
availableWidth: trailingWidth,
|
||||||
|
alignment: .trailing
|
||||||
|
)
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,5 +112,93 @@ struct ExtensionMusicWingView: View {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
.padding(.vertical, 6)
|
.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 SwiftUI
|
||||||
|
import Defaults
|
||||||
import AtollExtensionKit
|
import AtollExtensionKit
|
||||||
|
|
||||||
struct ExtensionLockScreenWidgetView: View {
|
struct ExtensionLockScreenWidgetView: View {
|
||||||
|
|
@ -20,6 +21,12 @@ struct ExtensionLockScreenWidgetView: View {
|
||||||
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
||||||
.stroke(Color.white.opacity(0.04), lineWidth: 1)
|
.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
|
@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 {
|
private struct ExtensionGraphView: View {
|
||||||
let data: [Double]
|
let data: [Double]
|
||||||
let color: Color
|
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 content: AtollTrailingContent
|
||||||
let accent: Color
|
let accent: Color
|
||||||
|
let availableWidth: CGFloat
|
||||||
|
let alignment: Alignment
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch content {
|
switch content {
|
||||||
|
|
@ -267,6 +269,19 @@ struct ExtensionTrailingContentView: View {
|
||||||
.font(font.swiftUIFont())
|
.font(font.swiftUIFont())
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
.lineLimit(1)
|
.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):
|
case let .icon(descriptor):
|
||||||
ExtensionIconView(
|
ExtensionIconView(
|
||||||
descriptor: descriptor,
|
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
|
// MARK: - Layout Metrics
|
||||||
|
|
||||||
enum ExtensionLayoutMetrics {
|
enum ExtensionLayoutMetrics {
|
||||||
static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
|
static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
|
||||||
var width = baseWidth
|
var width = edgeWidth(for: payload.descriptor.trailingContent, baseWidth: baseWidth, maxWidth: maxWidth)
|
||||||
width = max(width, widthForTrailing(content: payload.descriptor.trailingContent, baseWidth: baseWidth))
|
|
||||||
if let indicator = payload.descriptor.progressIndicator {
|
if let indicator = payload.descriptor.progressIndicator {
|
||||||
width = max(width, widthForProgress(indicator))
|
width = max(width, widthForProgress(indicator))
|
||||||
}
|
}
|
||||||
|
|
@ -307,11 +347,26 @@ enum ExtensionLayoutMetrics {
|
||||||
return width
|
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 {
|
switch content {
|
||||||
case let .text(text, font: font):
|
case let .text(text, font: font):
|
||||||
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
|
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
|
||||||
return max(baseWidth, measured + 32)
|
return max(baseWidth, measured + 32)
|
||||||
|
case let .marquee(text, font: font, _):
|
||||||
|
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:
|
case .icon:
|
||||||
return max(baseWidth, 52)
|
return max(baseWidth, 52)
|
||||||
case .spectrum:
|
case .spectrum:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import Defaults
|
||||||
import AtollExtensionKit
|
import AtollExtensionKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ExtensionLiveActivityPayload: Identifiable, Hashable {
|
struct ExtensionLiveActivityPayload: Identifiable, Hashable, Codable {
|
||||||
let bundleIdentifier: String
|
let bundleIdentifier: String
|
||||||
let descriptor: AtollLiveActivityDescriptor
|
let descriptor: AtollLiveActivityDescriptor
|
||||||
let receivedAt: Date
|
let receivedAt: Date
|
||||||
|
|
@ -13,7 +13,7 @@ struct ExtensionLiveActivityPayload: Identifiable, Hashable {
|
||||||
var priority: AtollLiveActivityPriority { descriptor.priority }
|
var priority: AtollLiveActivityPriority { descriptor.priority }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable {
|
struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable {
|
||||||
let bundleIdentifier: String
|
let bundleIdentifier: String
|
||||||
let descriptor: AtollLockScreenWidgetDescriptor
|
let descriptor: AtollLockScreenWidgetDescriptor
|
||||||
let receivedAt: Date
|
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 authorizationManager = ExtensionAuthorizationManager.shared
|
||||||
private let maxCapacityKey = Defaults.Keys.extensionLiveActivityCapacity
|
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 {
|
func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
|
||||||
guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else {
|
guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else {
|
||||||
|
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized")
|
||||||
throw ExtensionValidationError.unauthorized
|
throw ExtensionValidationError.unauthorized
|
||||||
}
|
}
|
||||||
guard descriptor.isValid else {
|
guard descriptor.isValid else {
|
||||||
|
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
|
||||||
throw ExtensionValidationError.invalidDescriptor("Structure 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(
|
let payload = ExtensionLiveActivityPayload(
|
||||||
|
|
@ -35,10 +66,13 @@ final class ExtensionLiveActivityManager: ObservableObject {
|
||||||
activeActivities.append(payload)
|
activeActivities.append(payload)
|
||||||
sortActivities()
|
sortActivities()
|
||||||
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
|
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 {
|
func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
|
||||||
guard descriptor.isValid else {
|
guard descriptor.isValid else {
|
||||||
|
logDiagnostics("Rejected live activity update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
|
||||||
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
||||||
}
|
}
|
||||||
guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
|
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
|
activeActivities[index] = payload
|
||||||
sortActivities()
|
sortActivities()
|
||||||
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
|
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
|
||||||
|
logDiagnostics("Updated live activity \(descriptor.id) for \(bundleIdentifier)")
|
||||||
|
broadcastSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismiss(activityID: String, bundleIdentifier: String) {
|
func dismiss(activityID: String, bundleIdentifier: String) {
|
||||||
|
|
@ -60,6 +96,8 @@ final class ExtensionLiveActivityManager: ObservableObject {
|
||||||
if previousCount != activeActivities.count {
|
if previousCount != activeActivities.count {
|
||||||
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
|
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
|
||||||
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
|
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
|
||||||
|
logDiagnostics("Removed live activity \(activityID) for \(bundleIdentifier); remaining: \(activeActivities.count)")
|
||||||
|
broadcastSnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +107,10 @@ final class ExtensionLiveActivityManager: ObservableObject {
|
||||||
.map { $0.descriptor.id }
|
.map { $0.descriptor.id }
|
||||||
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
|
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
|
||||||
|
if !ids.isEmpty {
|
||||||
|
logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
|
||||||
|
broadcastSnapshot()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] {
|
func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] {
|
||||||
|
|
@ -87,4 +129,23 @@ final class ExtensionLiveActivityManager: ObservableObject {
|
||||||
private func sortActivities() {
|
private func sortActivities() {
|
||||||
activeActivities.sort(by: descriptorComparator)
|
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 authorizationManager = ExtensionAuthorizationManager.shared
|
||||||
private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity
|
private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity
|
||||||
private var presentationController: ExtensionLockScreenWidgetPresentationController!
|
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() {
|
private init() {
|
||||||
|
activeWidgets = eventBridge.loadPersistedLockScreenWidgets()
|
||||||
|
sortWidgets()
|
||||||
presentationController = ExtensionLockScreenWidgetPresentationController(manager: self)
|
presentationController = ExtensionLockScreenWidgetPresentationController(manager: self)
|
||||||
presentationController.activate()
|
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 {
|
func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
|
||||||
guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else {
|
guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else {
|
||||||
|
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized")
|
||||||
throw ExtensionValidationError.unauthorized
|
throw ExtensionValidationError.unauthorized
|
||||||
}
|
}
|
||||||
guard descriptor.isValid else {
|
guard descriptor.isValid else {
|
||||||
|
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
|
||||||
throw ExtensionValidationError.invalidDescriptor("Structure 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(
|
let payload = ExtensionLockScreenWidgetPayload(
|
||||||
|
|
@ -39,10 +69,13 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
|
||||||
activeWidgets.append(payload)
|
activeWidgets.append(payload)
|
||||||
sortWidgets()
|
sortWidgets()
|
||||||
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
|
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 {
|
func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
|
||||||
guard descriptor.isValid else {
|
guard descriptor.isValid else {
|
||||||
|
logDiagnostics("Rejected lock screen widget update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
|
||||||
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
||||||
}
|
}
|
||||||
guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
|
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
|
activeWidgets[index] = payload
|
||||||
sortWidgets()
|
sortWidgets()
|
||||||
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
|
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
|
||||||
|
logDiagnostics("Updated lock screen widget \(descriptor.id) for \(bundleIdentifier)")
|
||||||
|
broadcastSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismiss(widgetID: String, bundleIdentifier: String) {
|
func dismiss(widgetID: String, bundleIdentifier: String) {
|
||||||
|
|
@ -65,6 +100,8 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
|
||||||
if previousCount != activeWidgets.count {
|
if previousCount != activeWidgets.count {
|
||||||
Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions)
|
Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions)
|
||||||
ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID)
|
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 }
|
.map { $0.descriptor.id }
|
||||||
activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) }
|
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() {
|
private func sortWidgets() {
|
||||||
|
|
@ -84,4 +125,28 @@ final class ExtensionLockScreenWidgetManager: ObservableObject {
|
||||||
return lhs.priority > rhs.priority
|
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 let windowPool = ExtensionLockScreenWidgetWindowPool()
|
||||||
private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = []
|
private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = []
|
||||||
private var isLocked: Bool = false
|
private var isLocked: Bool = false
|
||||||
|
private var lastVisibilityState: VisibilityState?
|
||||||
|
|
||||||
init(manager: ExtensionLockScreenWidgetManager) {
|
init(manager: ExtensionLockScreenWidgetManager) {
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
|
|
@ -55,6 +56,7 @@ final class ExtensionLockScreenWidgetPresentationController {
|
||||||
self.refreshPresentation()
|
self.refreshPresentation()
|
||||||
} else {
|
} else {
|
||||||
self.windowPool.hideAll()
|
self.windowPool.hideAll()
|
||||||
|
self.updateVisibilityLog(.hidden(reason: "feature-disabled"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
@ -63,13 +65,37 @@ final class ExtensionLockScreenWidgetPresentationController {
|
||||||
private func refreshPresentation() {
|
private func refreshPresentation() {
|
||||||
guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else {
|
guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else {
|
||||||
windowPool.hideAll()
|
windowPool.hideAll()
|
||||||
|
updateVisibilityLog(.hidden(reason: "lock-state"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard cachedPayloads.isEmpty == false else {
|
guard cachedPayloads.isEmpty == false else {
|
||||||
windowPool.hideAll()
|
windowPool.hideAll()
|
||||||
|
updateVisibilityLog(.hidden(reason: "no-payloads"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
windowPool.sync(with: cachedPayloads)
|
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
|
record.window.contentView = nil
|
||||||
}
|
}
|
||||||
windows.removeAll()
|
windows.removeAll()
|
||||||
|
logDiagnostics("Cleared all lock screen widget windows")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) {
|
private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) {
|
||||||
guard let screen = currentScreen() else { return }
|
guard let screen = currentScreen() else { return }
|
||||||
let descriptor = payload.descriptor
|
let descriptor = payload.descriptor
|
||||||
|
let isNewWindow = windows[payload.id] == nil
|
||||||
let window = ensureWindow(for: payload.id)
|
let window = ensureWindow(for: payload.id)
|
||||||
let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload))
|
let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload))
|
||||||
hosting.frame = NSRect(origin: .zero, size: descriptor.size)
|
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.setFrame(frame(for: descriptor, on: screen), display: true)
|
||||||
window.alphaValue = 1
|
window.alphaValue = 1
|
||||||
window.orderFrontRegardless()
|
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 {
|
private func ensureWindow(for id: String) -> NSWindow {
|
||||||
|
|
@ -162,6 +195,7 @@ private final class ExtensionLockScreenWidgetWindowPool {
|
||||||
record.window.orderOut(nil)
|
record.window.orderOut(nil)
|
||||||
record.window.contentView = nil
|
record.window.contentView = nil
|
||||||
windows.removeValue(forKey: id)
|
windows.removeValue(forKey: id)
|
||||||
|
logDiagnostics("Removed lock screen widget window id=\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func registerScreenChangeObservers() {
|
private func registerScreenChangeObservers() {
|
||||||
|
|
@ -188,7 +222,7 @@ private final class ExtensionLockScreenWidgetWindowPool {
|
||||||
private func handleScreenGeometryChange(reason: String) {
|
private func handleScreenGeometryChange(reason: String) {
|
||||||
guard windows.isEmpty == false else { return }
|
guard windows.isEmpty == false else { return }
|
||||||
repositionAll(animated: false)
|
repositionAll(animated: false)
|
||||||
print("ExtensionLockScreenWidgetWindowPool: realigned windows due to \(reason)")
|
logDiagnostics("Realigned lock screen widget windows due to \(reason)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func repositionAll(animated: Bool) {
|
private func repositionAll(animated: Bool) {
|
||||||
|
|
@ -246,3 +280,8 @@ private final class ExtensionLockScreenWidgetWindowPool {
|
||||||
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
|
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
|
// MARK: Authorization
|
||||||
|
|
||||||
func requestAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) {
|
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 {
|
guard self.authorizationManager.isExtensionsFeatureEnabled else {
|
||||||
reply(false, ExtensionValidationError.featureDisabled.asNSError)
|
reply(false, ExtensionValidationError.featureDisabled.asNSError)
|
||||||
return
|
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) {
|
func checkAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool) -> Void) {
|
||||||
guard providedBundleIdentifier == bundleIdentifier else {
|
Task { @MainActor [weak self] in
|
||||||
reply(false)
|
guard let self else { return }
|
||||||
return
|
guard providedBundleIdentifier == self.bundleIdentifier else {
|
||||||
}
|
reply(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let isAuthorized = authorizationManager.authorizationEntry(for: bundleIdentifier)?.isAuthorized ?? false
|
let isAuthorized = self.authorizationManager.authorizationEntry(for: self.bundleIdentifier)?.isAuthorized ?? false
|
||||||
reply(isAuthorized)
|
reply(isAuthorized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Live Activities
|
// MARK: Live Activities
|
||||||
|
|
||||||
func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
respond(reply: reply) {
|
respond(reply: reply) { service in
|
||||||
let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
|
let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
|
||||||
try ExtensionDescriptorValidator.validate(descriptor)
|
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) {
|
func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
respond(reply: reply) {
|
respond(reply: reply) { service in
|
||||||
let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
|
let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
|
||||||
try ExtensionDescriptorValidator.validate(descriptor)
|
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) {
|
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)
|
self.logDiagnostics("Received live activity dismissal from \(self.bundleIdentifier) (id: \(activityID))")
|
||||||
reply(true, nil)
|
self.liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: self.bundleIdentifier)
|
||||||
|
self.logDiagnostics("Live activity \(activityID) dismissed for \(self.bundleIdentifier)")
|
||||||
|
reply(true, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Lock Screen Widgets
|
// MARK: Lock Screen Widgets
|
||||||
|
|
||||||
func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
respond(reply: reply) {
|
respond(reply: reply) { service in
|
||||||
let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
|
let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
|
||||||
try ExtensionDescriptorValidator.validate(descriptor)
|
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) {
|
func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
respond(reply: reply) {
|
respond(reply: reply) { service in
|
||||||
let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
|
let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
|
||||||
try ExtensionDescriptorValidator.validate(descriptor)
|
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) {
|
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)
|
self.logDiagnostics("Received lock screen widget dismissal from \(self.bundleIdentifier) (id: \(widgetID))")
|
||||||
reply(true, nil)
|
self.widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: self.bundleIdentifier)
|
||||||
|
self.logDiagnostics("Lock screen widget \(widgetID) dismissed for \(self.bundleIdentifier)")
|
||||||
|
reply(true, nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Diagnostics
|
// MARK: Diagnostics
|
||||||
|
|
||||||
func getVersion(reply: @escaping (String) -> Void) {
|
func getVersion(reply: @escaping (String) -> Void) {
|
||||||
reply(appVersion)
|
Task { @MainActor in
|
||||||
|
reply(appVersion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Helpers
|
// MARK: Helpers
|
||||||
|
|
||||||
private func respond(reply: @escaping (Bool, Error?) -> Void, operation: () throws -> Void) {
|
private func respond(reply: @escaping (Bool, Error?) -> Void, operation: @escaping (ExtensionXPCService) throws -> Void) {
|
||||||
do {
|
Task { @MainActor [weak self] in
|
||||||
try operation()
|
guard let self else { return }
|
||||||
reply(true, nil)
|
do {
|
||||||
} catch {
|
try operation(self)
|
||||||
if Defaults[.extensionDiagnosticsLoggingEnabled] {
|
reply(true, nil)
|
||||||
Logger.log("Extension XPC request failed: \(error)", category: .extensions)
|
} 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 {
|
private func validate(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) -> Bool {
|
||||||
guard providedBundleIdentifier == bundleIdentifier else {
|
guard providedBundleIdentifier == bundleIdentifier else {
|
||||||
let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier)
|
let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier)
|
||||||
|
logDiagnostics("Rejected XPC request due to bundle mismatch. Expected \(bundleIdentifier) received \(providedBundleIdentifier)")
|
||||||
reply(false, error.asNSError)
|
reply(false, error.asNSError)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func logDiagnostics(_ message: String) {
|
||||||
|
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
|
||||||
|
Logger.log(message, category: .extensions)
|
||||||
|
}
|
||||||
|
|
||||||
private func resolvedApplicationName() -> String {
|
private func resolvedApplicationName() -> String {
|
||||||
guard let processIdentifier = connection?.processIdentifier,
|
guard let processIdentifier = connection?.processIdentifier,
|
||||||
processIdentifier != 0,
|
processIdentifier != 0,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum LogCategory: String {
|
enum LogCategory: String {
|
||||||
|
|
@ -12,9 +13,41 @@ enum LogCategory: String {
|
||||||
case success = "✅"
|
case success = "✅"
|
||||||
case debug = "🔍"
|
case debug = "🔍"
|
||||||
case extensions = "🧩"
|
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 {
|
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(
|
static func log(
|
||||||
_ message: String,
|
_ message: String,
|
||||||
category: LogCategory,
|
category: LogCategory,
|
||||||
|
|
@ -23,8 +56,14 @@ struct Logger {
|
||||||
line: Int = #line
|
line: Int = #line
|
||||||
) {
|
) {
|
||||||
let fileName = (file as NSString).lastPathComponent
|
let fileName = (file as NSString).lastPathComponent
|
||||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
let timestamp = dateFormatter.string(from: Date())
|
||||||
print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)")
|
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(
|
static func trackMemory(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue