mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Something in the way
This commit is contained in:
parent
018050081d
commit
9bd445986d
19 changed files with 2395 additions and 1 deletions
|
|
@ -26,6 +26,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 */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -58,6 +59,7 @@
|
||||||
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 */,
|
||||||
|
|
@ -148,6 +150,7 @@
|
||||||
);
|
);
|
||||||
name = DynamicIsland;
|
name = DynamicIsland;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
B2C8B0022F11AABB00887766 /* AtollExtensionKit */,
|
||||||
14D032192C68F32E0096E6A1 /* LaunchAtLogin */,
|
14D032192C68F32E0096E6A1 /* LaunchAtLogin */,
|
||||||
14D0321C2C68F3350096E6A1 /* Sparkle */,
|
14D0321C2C68F3350096E6A1 /* Sparkle */,
|
||||||
B18654382C6F4990000B926A /* KeyboardShortcuts */,
|
B18654382C6F4990000B926A /* KeyboardShortcuts */,
|
||||||
|
|
@ -190,6 +193,7 @@
|
||||||
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" */,
|
||||||
|
|
@ -524,6 +528,13 @@
|
||||||
};
|
};
|
||||||
/* 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;
|
||||||
|
|
@ -661,6 +672,11 @@
|
||||||
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 */;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ struct ContentView: View {
|
||||||
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
||||||
@ObservedObject var lockScreenManager = LockScreenManager.shared
|
@ObservedObject var lockScreenManager = LockScreenManager.shared
|
||||||
@ObservedObject var capsLockManager = CapsLockManager.shared
|
@ObservedObject var capsLockManager = CapsLockManager.shared
|
||||||
|
@ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared
|
||||||
@State private var downloadManager = DownloadManager.shared
|
@State private var downloadManager = DownloadManager.shared
|
||||||
|
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
|
@ -60,6 +61,7 @@ struct ContentView: View {
|
||||||
@Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent
|
@Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent
|
||||||
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
||||||
@Default(.enableCapsLockIndicator) var enableCapsLockIndicator
|
@Default(.enableCapsLockIndicator) var enableCapsLockIndicator
|
||||||
|
@Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities
|
||||||
|
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
|
|
@ -484,6 +486,8 @@ struct ContentView: View {
|
||||||
&& !vm.hideOnClosed
|
&& !vm.hideOnClosed
|
||||||
&& !lockScreenManager.isLocked
|
&& !lockScreenManager.isLocked
|
||||||
let musicSecondary = resolveMusicSecondaryLiveActivity()
|
let musicSecondary = resolveMusicSecondaryLiveActivity()
|
||||||
|
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
|
||||||
|
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
|
||||||
let expansionMatchesSecondary: Bool = {
|
let expansionMatchesSecondary: Bool = {
|
||||||
guard let musicSecondary else { return false }
|
guard let musicSecondary else { return false }
|
||||||
switch musicSecondary {
|
switch musicSecondary {
|
||||||
|
|
@ -497,6 +501,8 @@ struct ContentView: View {
|
||||||
return coordinator.expandingView.type == .doNotDisturb
|
return coordinator.expandingView.type == .doNotDisturb
|
||||||
case .capsLock:
|
case .capsLock:
|
||||||
return false
|
return false
|
||||||
|
case .extensionPayload:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let canShowMusicDuringExpansion = !coordinator.expandingView.show
|
let canShowMusicDuringExpansion = !coordinator.expandingView.show
|
||||||
|
|
@ -554,6 +560,14 @@ struct ContentView: View {
|
||||||
LockScreenLiveActivity()
|
LockScreenLiveActivity()
|
||||||
} 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 {
|
||||||
|
ExtensionLiveActivityStandaloneView(
|
||||||
|
payload: extensionPayload,
|
||||||
|
notchWidth: extensionStandaloneNotchWidth(for: vm.effectiveClosedNotchHeight, isHovering: isHovering),
|
||||||
|
notchHeight: vm.effectiveClosedNotchHeight,
|
||||||
|
isHovering: isHovering
|
||||||
|
)
|
||||||
|
.transition(.opacity.combined(with: .scale))
|
||||||
} else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
|
} else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
|
||||||
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
|
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
|
||||||
} else if vm.notchState == .open {
|
} else if vm.notchState == .open {
|
||||||
|
|
@ -823,6 +837,10 @@ struct ContentView: View {
|
||||||
return .capsLock(showLabel: showCapsLockLabel)
|
return .capsLock(showLabel: showCapsLockLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let extensionPayload = resolvedExtensionMusicPayload() {
|
||||||
|
return .extensionPayload(extensionPayload)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -840,6 +858,9 @@ struct ContentView: View {
|
||||||
return focusRightWingWidth(baseWidth: baseWidth)
|
return focusRightWingWidth(baseWidth: baseWidth)
|
||||||
case .recording:
|
case .recording:
|
||||||
return recordingRightWingWidth(baseWidth: baseWidth)
|
return recordingRightWingWidth(baseWidth: baseWidth)
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
let maxWidth = baseWidth + centerBaseWidth * 0.6
|
||||||
|
return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -936,6 +957,17 @@ struct ContentView: View {
|
||||||
Image(systemName: "capslock.fill")
|
Image(systemName: "capslock.fill")
|
||||||
.font(.system(size: badgeSize * 0.5, weight: .semibold))
|
.font(.system(size: badgeSize * 0.5, weight: .semibold))
|
||||||
.foregroundStyle(capsLockTintMode.color)
|
.foregroundStyle(capsLockTintMode.color)
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
ExtensionCompositeIconView(
|
||||||
|
leading: payload.descriptor.leadingIcon,
|
||||||
|
badge: payload.descriptor.badgeIcon,
|
||||||
|
accent: payload.descriptor.accentColor.swiftUIColor,
|
||||||
|
size: badgeSize
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.85))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: badgeSize, height: badgeSize)
|
.frame(width: badgeSize, height: badgeSize)
|
||||||
|
|
@ -976,6 +1008,8 @@ struct ContentView: View {
|
||||||
spectrumView(forceSpectrum: true)
|
spectrumView(forceSpectrum: true)
|
||||||
case .recording:
|
case .recording:
|
||||||
spectrumView(forceSpectrum: true, trailingInset: 6)
|
spectrumView(forceSpectrum: true, trailingInset: 6)
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
ExtensionMusicWingView(payload: payload, notchHeight: notchHeight)
|
||||||
case .none:
|
case .none:
|
||||||
spectrumView(forceSpectrum: false)
|
spectrumView(forceSpectrum: false)
|
||||||
}
|
}
|
||||||
|
|
@ -1023,6 +1057,44 @@ struct ContentView: View {
|
||||||
let remaining = reminder.event.start.timeIntervalSince(now)
|
let remaining = reminder.event.start.timeIntervalSince(now)
|
||||||
return remaining > 0 && remaining <= window
|
return remaining > 0 && remaining <= window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func extensionSecondaryPayloadID(for secondary: MusicSecondaryLiveActivity?) -> String? {
|
||||||
|
guard case let .extensionPayload(payload) = secondary else { return nil }
|
||||||
|
return payload.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? {
|
||||||
|
guard enableExtensionLiveActivities,
|
||||||
|
vm.notchState == .closed,
|
||||||
|
!vm.hideOnClosed,
|
||||||
|
!lockScreenManager.isLocked,
|
||||||
|
coordinator.musicLiveActivityEnabled else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return extensionLiveActivityManager.sortedActivities(for: true).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? {
|
||||||
|
guard enableExtensionLiveActivities,
|
||||||
|
vm.notchState == .closed,
|
||||||
|
!vm.hideOnClosed,
|
||||||
|
!lockScreenManager.isLocked,
|
||||||
|
vm.effectiveClosedNotchHeight > 0,
|
||||||
|
!coordinator.expandingView.show else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensionLiveActivityManager
|
||||||
|
.sortedActivities()
|
||||||
|
.first(where: { $0.id != musicPayloadID })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extensionStandaloneNotchWidth(for height: CGFloat, isHovering: Bool) -> CGFloat {
|
||||||
|
let effectiveCenter = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96)
|
||||||
|
let wing = max(height - (isHovering ? 0 : 12), 0)
|
||||||
|
let total = effectiveCenter + (wing * 2)
|
||||||
|
return max(total, effectiveCenter)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var dragDetector: some View {
|
var dragDetector: some View {
|
||||||
|
|
@ -1518,6 +1590,7 @@ private enum MusicSecondaryLiveActivity: Equatable {
|
||||||
case recording
|
case recording
|
||||||
case focus(FocusModeType)
|
case focus(FocusModeType)
|
||||||
case capsLock(showLabel: Bool)
|
case capsLock(showLabel: Bool)
|
||||||
|
case extensionPayload(ExtensionLiveActivityPayload)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -1531,6 +1604,8 @@ private enum MusicSecondaryLiveActivity: Equatable {
|
||||||
return "focus-\(mode.rawValue)"
|
return "focus-\(mode.rawValue)"
|
||||||
case .capsLock(let showLabel):
|
case .capsLock(let showLabel):
|
||||||
return showLabel ? "caps-lock-label" : "caps-lock-icon"
|
return showLabel ? "caps-lock-label" : "caps-lock-icon"
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
return "extension-\(payload.id)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection
|
let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection
|
||||||
let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel
|
let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel
|
||||||
let systemTimerBridge = SystemTimerBridge.shared
|
let systemTimerBridge = SystemTimerBridge.shared
|
||||||
|
let extensionXPCServiceHost = ExtensionXPCServiceHost.shared
|
||||||
var closeNotchWorkItem: DispatchWorkItem?
|
var closeNotchWorkItem: DispatchWorkItem?
|
||||||
private var previousScreens: [NSScreen]?
|
private var previousScreens: [NSScreen]?
|
||||||
private var onboardingWindowController: NSWindowController?
|
private var onboardingWindowController: NSWindowController?
|
||||||
|
|
@ -132,6 +133,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Cancel any pending window size updates
|
// Cancel any pending window size updates
|
||||||
windowSizeUpdateWorkItem?.cancel()
|
windowSizeUpdateWorkItem?.cancel()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
extensionXPCServiceHost.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func onScreenLocked(_: Notification) {
|
@objc func onScreenLocked(_: Notification) {
|
||||||
|
|
@ -340,6 +342,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm)
|
LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm)
|
||||||
LockScreenManager.shared.configure(viewModel: vm)
|
LockScreenManager.shared.configure(viewModel: vm)
|
||||||
|
extensionXPCServiceHost.start()
|
||||||
|
|
||||||
// Migrate legacy progress bar settings
|
// Migrate legacy progress bar settings
|
||||||
Defaults.Keys.migrateProgressBarStyle()
|
Defaults.Keys.migrateProgressBarStyle()
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,10 @@
|
||||||
"comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.",
|
"comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%@:" : {
|
||||||
|
"comment" : "A row of text with a label and its corresponding value. The argument is the label text, and the second is the value text.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%@°" : {
|
"%@°" : {
|
||||||
"comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).",
|
"comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -872,6 +876,18 @@
|
||||||
"%lld" : {
|
"%lld" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
"%lld %@" : {
|
||||||
|
"comment" : "A text label showing the number of apps with which the current app has granted permissions. The argument is the count of such apps.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%lld animations" : {
|
"%lld animations" : {
|
||||||
"comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.",
|
"comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -2853,6 +2869,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Allowed Features" : {
|
||||||
|
"comment" : "A label describing the allowed features for an app extension.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"ALPHA" : {
|
"ALPHA" : {
|
||||||
"comment" : "A label indicating a feature that is still in development.",
|
"comment" : "A label indicating a feature that is still in development.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -3200,6 +3220,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"App Permissions" : {
|
||||||
|
"comment" : "A section header in the settings view that lists the permissions of installed apps.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Appearance" : {
|
"Appearance" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -3302,6 +3326,10 @@
|
||||||
},
|
},
|
||||||
"Applies the notch-style parallax effect to the lock screen media widget album art." : {
|
"Applies the notch-style parallax effect to the lock screen media widget album art." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Apps using AtollExtensionKit will appear here once they request permission" : {
|
||||||
|
"comment" : "A description below the placeholder text in the \"Search extensions...\" text field, explaining that apps using AtollExtensionKit will be listed here once they grant permission to use the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Are you sure you want to delete \"%@\"?" : {
|
"Are you sure you want to delete \"%@\"?" : {
|
||||||
"comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.",
|
"comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.",
|
||||||
|
|
@ -3354,6 +3382,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Authorize" : {
|
||||||
|
"comment" : "A button that authorizes an app extension.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Authorized" : {
|
||||||
|
"comment" : "A label indicating that an app has been granted permission to use AtollExtensionKit.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Auto-scroll to next event" : {
|
"Auto-scroll to next event" : {
|
||||||
"comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.",
|
"comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -6782,6 +6818,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Denied/Revoked" : {
|
||||||
|
"comment" : "A label describing an extension with denied or revoked permissions.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Deny" : {
|
||||||
|
"comment" : "A button that denies an app extension's access.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Description" : {
|
"Description" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -7926,6 +7970,10 @@
|
||||||
"comment" : "A toggle that allows users to enable or disable customizable controls for media playback.",
|
"comment" : "A toggle that allows users to enable or disable customizable controls for media playback.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll." : {
|
||||||
|
"comment" : "A footer text that explains the purpose of enabling extensions.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enable global keyboard shortcuts above to customize your shortcuts." : {
|
"Enable global keyboard shortcuts above to customize your shortcuts." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -8730,7 +8778,6 @@
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Extensions" : {
|
"Extensions" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -9505,6 +9552,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Global Settings" : {
|
||||||
|
"comment" : "The header for the section that controls global settings related to extensions.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Got it!" : {
|
"Got it!" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -10859,6 +10910,10 @@
|
||||||
"comment" : "A help text explaining why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.",
|
"comment" : "A help text explaining why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Last Denied Reason:" : {
|
||||||
|
"comment" : "A label for the \"Last Denied Reason\" section in the extensions settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Last Updated" : {
|
"Last Updated" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -11101,6 +11156,10 @@
|
||||||
"comment" : "A section header for the lock screen reminder widget settings.",
|
"comment" : "A section header for the lock screen reminder widget settings.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Lock Screen Widgets" : {
|
||||||
|
"comment" : "A toggle that enables or disables the use of lock screen widgets by the extension.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Loop Mode" : {
|
"Loop Mode" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -14193,6 +14252,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No extensions yet" : {
|
||||||
|
"comment" : "A message displayed when there are no extensions that have requested permission.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No favorites" : {
|
"No favorites" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -15045,6 +15108,10 @@
|
||||||
},
|
},
|
||||||
"Pause" : {
|
"Pause" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Pending" : {
|
||||||
|
"comment" : "A label indicating that a user's permission to access a feature is being checked.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Percentage" : {
|
"Percentage" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
@ -15057,6 +15124,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Permission States:" : {
|
||||||
|
"comment" : "A label describing the different permission states displayed in the footer of the extensions settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Permissions" : {
|
"Permissions" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -15602,6 +15673,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Re-authorize" : {
|
||||||
|
"comment" : "A button that re-authorizes an app when pressed.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Read" : {
|
"Read" : {
|
||||||
"comment" : "A heading displayed for the user's disk read speed.",
|
"comment" : "A heading displayed for the user's disk read speed.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -15634,6 +15709,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Recent Activity (last 5 minutes)" : {
|
||||||
|
"comment" : "A label describing the recent activity of a particular extension.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Recent Colors" : {
|
"Recent Colors" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -15889,6 +15968,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Remove" : {
|
||||||
|
"comment" : "A button that removes an extension from the authorized list.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remove %@ from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app." : {
|
||||||
|
"comment" : "A message presented when a user confirms they want to remove an extension from the authorized list. The argument is the name of the extension.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remove Extension" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Remove from shelf after dragging" : {
|
"Remove from shelf after dragging" : {
|
||||||
|
|
||||||
|
|
@ -15923,6 +16013,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Reset Rate Limits" : {
|
||||||
|
"comment" : "A button that resets the rate limits for a particular extension.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Reset to Default" : {
|
"Reset to Default" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -16074,6 +16168,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Revoke Access" : {
|
||||||
|
"comment" : "A button that revokes access to an app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Right" : {
|
"Right" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -16319,6 +16417,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Search extensions..." : {
|
||||||
|
"comment" : "A placeholder text for a text field used to filter through a list of extensions.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Search notes..." : {
|
"Search notes..." : {
|
||||||
"comment" : "A placeholder text for a search bar in the note list view.",
|
"comment" : "A placeholder text for a search bar in the note list view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -18450,6 +18552,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Status:" : {
|
||||||
|
"comment" : "A label for the status of an extension.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Stop" : {
|
"Stop" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -18869,6 +18975,10 @@
|
||||||
},
|
},
|
||||||
"These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : {
|
"These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below." : {
|
||||||
|
"comment" : "A description below the toggle to enable third-party extensions, explaining that third-party apps can display live activities and lock screen widgets, and that this view allows you to manage those permissions.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"This duration powers the \"Custom\" option inside the timer popover for quick access." : {
|
"This duration powers the \"Custom\" option inside the timer popover for quick access." : {
|
||||||
"comment" : "A footer description for the custom timer settings, explaining their purpose.",
|
"comment" : "A footer description for the custom timer settings, explaining their purpose.",
|
||||||
|
|
@ -20610,6 +20720,10 @@
|
||||||
},
|
},
|
||||||
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Widget Updates" : {
|
||||||
|
"comment" : "A label describing the number of times the extension has updated its widget.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Width:" : {
|
"Width:" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import SwiftUI
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
struct ExtensionLiveActivityStandaloneView: View {
|
||||||
|
let payload: ExtensionLiveActivityPayload
|
||||||
|
let notchWidth: CGFloat
|
||||||
|
let notchHeight: CGFloat
|
||||||
|
let isHovering: Bool
|
||||||
|
|
||||||
|
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
|
||||||
|
private var contentHeight: CGFloat {
|
||||||
|
max(0, notchHeight - (isHovering ? 0 : 12))
|
||||||
|
}
|
||||||
|
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let baseWingWidth = max(contentHeight, 44)
|
||||||
|
let trailingWidth = ExtensionLayoutMetrics.trailingWidth(
|
||||||
|
for: payload,
|
||||||
|
baseWidth: baseWingWidth,
|
||||||
|
maxWidth: notchWidth * 0.42
|
||||||
|
)
|
||||||
|
let centerWidth = max(96, notchWidth - baseWingWidth - trailingWidth)
|
||||||
|
|
||||||
|
return HStack(spacing: 0) {
|
||||||
|
ExtensionCompositeIconView(
|
||||||
|
leading: descriptor.leadingIcon,
|
||||||
|
badge: descriptor.badgeIcon,
|
||||||
|
accent: accentColor,
|
||||||
|
size: contentHeight
|
||||||
|
)
|
||||||
|
.frame(width: baseWingWidth, height: contentHeight)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.black)
|
||||||
|
.frame(width: centerWidth, height: contentHeight)
|
||||||
|
.overlay(centerContent)
|
||||||
|
|
||||||
|
ExtensionMusicWingView(payload: payload, notchHeight: contentHeight)
|
||||||
|
.frame(width: trailingWidth, height: contentHeight)
|
||||||
|
}
|
||||||
|
.frame(width: notchWidth, height: notchHeight + (isHovering ? 8 : 0))
|
||||||
|
.animation(.smooth(duration: 0.25), value: payload.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var centerContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(descriptor.title)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let subtitle = descriptor.subtitle, !subtitle.isEmpty {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.7))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExtensionMusicWingView: View {
|
||||||
|
let payload: ExtensionLiveActivityPayload
|
||||||
|
let notchHeight: CGFloat
|
||||||
|
|
||||||
|
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
|
||||||
|
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
|
if case .none = descriptor.trailingContent {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
ExtensionTrailingContentView(content: descriptor.trailingContent, accent: accentColor)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let indicator = descriptor.progressIndicator, indicator != .none {
|
||||||
|
ExtensionProgressIndicatorView(
|
||||||
|
indicator: indicator,
|
||||||
|
progress: descriptor.progress,
|
||||||
|
accent: accentColor,
|
||||||
|
estimatedDuration: descriptor.estimatedDuration
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
} else {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import SwiftUI
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
struct ExtensionLockScreenWidgetView: View {
|
||||||
|
let payload: ExtensionLockScreenWidgetPayload
|
||||||
|
|
||||||
|
private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor }
|
||||||
|
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
backgroundView
|
||||||
|
contentView
|
||||||
|
.padding(.horizontal, descriptor.layoutStyle == .circular ? 10 : 16)
|
||||||
|
.padding(.vertical, descriptor.layoutStyle == .circular ? 10 : 12)
|
||||||
|
}
|
||||||
|
.frame(width: descriptor.size.width, height: descriptor.size.height)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
||||||
|
.stroke(Color.white.opacity(0.04), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
switch descriptor.layoutStyle {
|
||||||
|
case .inline, .card, .custom:
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in
|
||||||
|
view(for: element)
|
||||||
|
.frame(maxWidth: .infinity, alignment: alignment(for: element))
|
||||||
|
.accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .circular:
|
||||||
|
ZStack {
|
||||||
|
ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in
|
||||||
|
view(for: element)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
.accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func view(for element: AtollWidgetContentElement) -> some View {
|
||||||
|
switch element {
|
||||||
|
case let .text(text, font: font, color: color, alignment: _):
|
||||||
|
Text(text)
|
||||||
|
.font(font.swiftUIFont())
|
||||||
|
.foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9))
|
||||||
|
.lineLimit(2)
|
||||||
|
case let .icon(iconDescriptor, tint):
|
||||||
|
ExtensionIconView(
|
||||||
|
descriptor: iconDescriptor,
|
||||||
|
tint: (tint?.swiftUIColor) ?? accentColor,
|
||||||
|
size: CGSize(width: 28, height: 28),
|
||||||
|
cornerRadius: 8
|
||||||
|
)
|
||||||
|
case let .progress(indicator, value, color):
|
||||||
|
ExtensionProgressIndicatorView(
|
||||||
|
indicator: indicator,
|
||||||
|
progress: value,
|
||||||
|
accent: (color?.swiftUIColor) ?? accentColor,
|
||||||
|
estimatedDuration: nil
|
||||||
|
)
|
||||||
|
case let .graph(data, color, size):
|
||||||
|
ExtensionGraphView(data: data, color: color.swiftUIColor, size: size)
|
||||||
|
.frame(width: size.width, height: size.height)
|
||||||
|
case let .gauge(value, minValue, maxValue, style, color):
|
||||||
|
ExtensionGaugeView(
|
||||||
|
value: value,
|
||||||
|
minValue: minValue,
|
||||||
|
maxValue: maxValue,
|
||||||
|
style: style,
|
||||||
|
accent: (color?.swiftUIColor) ?? accentColor
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
case let .spacer(height):
|
||||||
|
Color.clear
|
||||||
|
.frame(height: height)
|
||||||
|
case let .divider(color, thickness):
|
||||||
|
Rectangle()
|
||||||
|
.fill(color.swiftUIColor.opacity(0.4))
|
||||||
|
.frame(height: thickness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func alignment(for element: AtollWidgetContentElement) -> Alignment {
|
||||||
|
switch element {
|
||||||
|
case let .text(_, _, _, alignment):
|
||||||
|
return alignment.swiftUI
|
||||||
|
default:
|
||||||
|
return .leading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var backgroundView: some View {
|
||||||
|
switch descriptor.material {
|
||||||
|
case .frosted:
|
||||||
|
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
case .liquid:
|
||||||
|
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
case .solid:
|
||||||
|
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
||||||
|
.fill(accentColor.opacity(0.9))
|
||||||
|
case .semiTransparent:
|
||||||
|
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
||||||
|
.fill(accentColor.opacity(0.35))
|
||||||
|
case .clear:
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExtensionGraphView: View {
|
||||||
|
let data: [Double]
|
||||||
|
let color: Color
|
||||||
|
let size: CGSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let minValue = data.min() ?? 0
|
||||||
|
let maxValue = data.max() ?? 1
|
||||||
|
let range = max(maxValue - minValue, 0.0001)
|
||||||
|
let step = proxy.size.width / CGFloat(max(data.count - 1, 1))
|
||||||
|
Path { path in
|
||||||
|
for (index, value) in data.enumerated() {
|
||||||
|
let x = CGFloat(index) * step
|
||||||
|
let normalized = (value - minValue) / range
|
||||||
|
let y = proxy.size.height - (CGFloat(normalized) * proxy.size.height)
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: CGPoint(x: x, y: y))
|
||||||
|
} else {
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||||
|
}
|
||||||
|
.frame(width: size.width, height: size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExtensionGaugeView: View {
|
||||||
|
let value: Double
|
||||||
|
let minValue: Double
|
||||||
|
let maxValue: Double
|
||||||
|
let style: AtollWidgetContentElement.GaugeStyle
|
||||||
|
let accent: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch style {
|
||||||
|
case .circular:
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.15), lineWidth: 5)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(normalizedValue))
|
||||||
|
.stroke(accent, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.smooth(duration: 0.3), value: normalizedValue)
|
||||||
|
Text("\(Int(normalizedValue * 100))%")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.frame(width: 54, height: 54)
|
||||||
|
case .linear:
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.white.opacity(0.2))
|
||||||
|
.frame(height: 8)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(accent)
|
||||||
|
.frame(width: proxy.size.width * CGFloat(normalizedValue), height: 8)
|
||||||
|
.animation(.smooth(duration: 0.3), value: normalizedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var normalizedValue: Double {
|
||||||
|
guard maxValue > minValue else { return 0 }
|
||||||
|
return min(max((value - minValue) / (maxValue - minValue), 0), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AtollWidgetContentElement.TextAlignment {
|
||||||
|
var swiftUI: Alignment {
|
||||||
|
switch self {
|
||||||
|
case .leading: return .leading
|
||||||
|
case .center: return .center
|
||||||
|
case .trailing: return .trailing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
// MARK: - Color Conversion
|
||||||
|
|
||||||
|
extension AtollColorDescriptor {
|
||||||
|
var swiftUIColor: Color {
|
||||||
|
if isAccent {
|
||||||
|
return .accentColor
|
||||||
|
}
|
||||||
|
return Color(red: red, green: green, blue: blue, opacity: alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsColor: NSColor {
|
||||||
|
if isAccent {
|
||||||
|
return NSColor.controlAccentColor
|
||||||
|
}
|
||||||
|
return NSColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Font Conversion
|
||||||
|
|
||||||
|
extension AtollFontDescriptor {
|
||||||
|
func swiftUIFont() -> Font {
|
||||||
|
let design = self.design.swiftUI
|
||||||
|
let weight = self.weight.swiftUI
|
||||||
|
var font = Font.system(size: size, weight: weight, design: design)
|
||||||
|
if isMonospacedDigit {
|
||||||
|
font = font.monospacedDigit()
|
||||||
|
}
|
||||||
|
return font
|
||||||
|
}
|
||||||
|
|
||||||
|
func nsFont() -> NSFont {
|
||||||
|
let weight = weight.nsFont
|
||||||
|
let font: NSFont
|
||||||
|
switch design {
|
||||||
|
case .serif:
|
||||||
|
font = NSFont.userFont(ofSize: size) ?? NSFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
case .rounded:
|
||||||
|
if let descriptor = NSFont.systemFont(ofSize: size, weight: weight).fontDescriptor.withDesign(.rounded) {
|
||||||
|
font = NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
} else {
|
||||||
|
font = NSFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
}
|
||||||
|
case .monospaced:
|
||||||
|
font = NSFont.monospacedSystemFont(ofSize: size, weight: weight)
|
||||||
|
case .default:
|
||||||
|
font = NSFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
}
|
||||||
|
return font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AtollFontWeight {
|
||||||
|
var swiftUI: Font.Weight {
|
||||||
|
switch self {
|
||||||
|
case .ultraLight: return .ultraLight
|
||||||
|
case .thin: return .thin
|
||||||
|
case .light: return .light
|
||||||
|
case .regular: return .regular
|
||||||
|
case .medium: return .medium
|
||||||
|
case .semibold: return .semibold
|
||||||
|
case .bold: return .bold
|
||||||
|
case .heavy: return .heavy
|
||||||
|
case .black: return .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsFont: NSFont.Weight {
|
||||||
|
switch self {
|
||||||
|
case .ultraLight: return .ultraLight
|
||||||
|
case .thin: return .thin
|
||||||
|
case .light: return .light
|
||||||
|
case .regular: return .regular
|
||||||
|
case .medium: return .medium
|
||||||
|
case .semibold: return .semibold
|
||||||
|
case .bold: return .bold
|
||||||
|
case .heavy: return .heavy
|
||||||
|
case .black: return .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AtollFontDesign {
|
||||||
|
var swiftUI: Font.Design {
|
||||||
|
switch self {
|
||||||
|
case .default: return .default
|
||||||
|
case .serif: return .serif
|
||||||
|
case .rounded: return .rounded
|
||||||
|
case .monospaced: return .monospaced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Icon Rendering
|
||||||
|
|
||||||
|
struct ExtensionIconView: View {
|
||||||
|
let descriptor: AtollIconDescriptor
|
||||||
|
let tint: Color
|
||||||
|
let size: CGSize
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch descriptor {
|
||||||
|
case let .symbol(name, glyphSize, weight):
|
||||||
|
Image(systemName: name)
|
||||||
|
.font(.system(size: glyphSize, weight: weight.swiftUI))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: size.width, height: size.height)
|
||||||
|
case let .image(data, targetSize, radius):
|
||||||
|
if let image = NSImage(data: data) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: targetSize.width, height: targetSize.height)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
|
} else {
|
||||||
|
fallbackSymbol
|
||||||
|
}
|
||||||
|
case let .appIcon(bundleIdentifier, targetSize, radius):
|
||||||
|
if let icon = AppIconAsNSImage(for: bundleIdentifier) {
|
||||||
|
Image(nsImage: icon)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: targetSize.width, height: targetSize.height)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
|
} else {
|
||||||
|
fallbackSymbol
|
||||||
|
}
|
||||||
|
case .lottie, .none:
|
||||||
|
fallbackSymbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fallbackSymbol: some View {
|
||||||
|
Image(systemName: "app.dashed")
|
||||||
|
.font(.system(size: size.width * 0.6, weight: .medium))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: size.width, height: size.height)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous)
|
||||||
|
.fill(tint.opacity(0.15))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cornerRadius(for descriptor: AtollIconDescriptor) -> CGFloat {
|
||||||
|
switch descriptor {
|
||||||
|
case .image(_, _, let radius): return radius
|
||||||
|
case .appIcon(_, _, let radius): return radius
|
||||||
|
default: return cornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExtensionCompositeIconView: View {
|
||||||
|
let leading: AtollIconDescriptor
|
||||||
|
let badge: AtollIconDescriptor?
|
||||||
|
let accent: Color
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
ExtensionIconView(
|
||||||
|
descriptor: leading,
|
||||||
|
tint: accent,
|
||||||
|
size: CGSize(width: size, height: size),
|
||||||
|
cornerRadius: size * 0.18
|
||||||
|
)
|
||||||
|
if let badge {
|
||||||
|
ExtensionIconView(
|
||||||
|
descriptor: badge,
|
||||||
|
tint: .white,
|
||||||
|
size: CGSize(width: max(size * 0.35, 12), height: max(size * 0.35, 12)),
|
||||||
|
cornerRadius: size * 0.12
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.7))
|
||||||
|
.frame(width: size * 0.4, height: size * 0.4)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Rendering
|
||||||
|
|
||||||
|
struct ExtensionProgressIndicatorView: View {
|
||||||
|
let indicator: AtollProgressIndicator
|
||||||
|
let progress: Double
|
||||||
|
let accent: Color
|
||||||
|
let estimatedDuration: TimeInterval?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch indicator {
|
||||||
|
case let .ring(diameter, strokeWidth):
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.15), lineWidth: strokeWidth)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(max(0, min(progress, 1))))
|
||||||
|
.stroke(accent, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.smooth(duration: 0.25), value: progress)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
case let .bar(width, height, cornerRadius):
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.white.opacity(0.18))
|
||||||
|
.frame(width: width ?? 80, height: height)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(accent)
|
||||||
|
.frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height)
|
||||||
|
.animation(.smooth(duration: 0.25), value: progress)
|
||||||
|
}
|
||||||
|
case let .percentage(font):
|
||||||
|
Text("\(Int(progress * 100))%")
|
||||||
|
.font(font.swiftUIFont())
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
.monospacedDigit()
|
||||||
|
case let .countdown(font):
|
||||||
|
Text(countdownText)
|
||||||
|
.font(font.swiftUIFont())
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
.monospacedDigit()
|
||||||
|
case .lottie:
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
case .none:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var countdownText: String {
|
||||||
|
guard let estimatedDuration else { return formatPercent }
|
||||||
|
let remaining = max(estimatedDuration * (1 - progress), 0)
|
||||||
|
let hours = Int(remaining) / 3600
|
||||||
|
let minutes = (Int(remaining) % 3600) / 60
|
||||||
|
let seconds = Int(remaining) % 60
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formatPercent: String {
|
||||||
|
"\(Int(progress * 100))%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trailing Content Rendering
|
||||||
|
|
||||||
|
struct ExtensionTrailingContentView: View {
|
||||||
|
let content: AtollTrailingContent
|
||||||
|
let accent: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch content {
|
||||||
|
case let .text(value, font: font):
|
||||||
|
Text(value)
|
||||||
|
.font(font.swiftUIFont())
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
.lineLimit(1)
|
||||||
|
case let .icon(descriptor):
|
||||||
|
ExtensionIconView(
|
||||||
|
descriptor: descriptor,
|
||||||
|
tint: accent,
|
||||||
|
size: CGSize(width: 26, height: 26),
|
||||||
|
cornerRadius: 6
|
||||||
|
)
|
||||||
|
case let .spectrum(color: colorDescriptor):
|
||||||
|
Rectangle()
|
||||||
|
.fill((colorDescriptor.isAccent ? accent : colorDescriptor.swiftUIColor).gradient)
|
||||||
|
.frame(width: 48, height: 14)
|
||||||
|
.mask {
|
||||||
|
AudioSpectrumView(isPlaying: .constant(true))
|
||||||
|
.frame(width: 16, height: 12)
|
||||||
|
}
|
||||||
|
case .animation:
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
case .none:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout Metrics
|
||||||
|
|
||||||
|
enum ExtensionLayoutMetrics {
|
||||||
|
static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
|
||||||
|
var width = baseWidth
|
||||||
|
width = max(width, widthForTrailing(content: payload.descriptor.trailingContent, baseWidth: baseWidth))
|
||||||
|
if let indicator = payload.descriptor.progressIndicator {
|
||||||
|
width = max(width, widthForProgress(indicator))
|
||||||
|
}
|
||||||
|
if let maxWidth {
|
||||||
|
width = min(width, maxWidth)
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func widthForTrailing(content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat {
|
||||||
|
switch content {
|
||||||
|
case let .text(text, font: font):
|
||||||
|
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
|
||||||
|
return max(baseWidth, measured + 32)
|
||||||
|
case .icon:
|
||||||
|
return max(baseWidth, 52)
|
||||||
|
case .spectrum:
|
||||||
|
return max(baseWidth, 56)
|
||||||
|
case let .animation(data: _, size: size):
|
||||||
|
return max(baseWidth, size.width + 16)
|
||||||
|
case .none:
|
||||||
|
return baseWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat {
|
||||||
|
switch indicator {
|
||||||
|
case let .ring(diameter, _):
|
||||||
|
return diameter + 20
|
||||||
|
case let .bar(width, _, _):
|
||||||
|
return (width ?? 72) + 12
|
||||||
|
case .percentage:
|
||||||
|
return 60
|
||||||
|
case .countdown:
|
||||||
|
return 74
|
||||||
|
case let .lottie(_, size):
|
||||||
|
return size.width + 16
|
||||||
|
case .none:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExtensionTextMeasurer {
|
||||||
|
static func width(for text: String, font: NSFont) -> CGFloat {
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
||||||
|
return max(1, text.size(withAttributes: attributes).width)
|
||||||
|
}
|
||||||
|
}
|
||||||
426
DynamicIsland/components/Settings/ExtensionsSettings.swift
Normal file
426
DynamicIsland/components/Settings/ExtensionsSettings.swift
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
//
|
||||||
|
// ExtensionsSettings.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created on 13/01/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
struct ExtensionsSettingsView: View {
|
||||||
|
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var selectedEntry: ExtensionAuthorizationEntry?
|
||||||
|
@State private var showingRemoveConfirmation = false
|
||||||
|
|
||||||
|
private func highlightID(_ title: String) -> String {
|
||||||
|
"extensions-\(title)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredEntries: [ExtensionAuthorizationEntry] {
|
||||||
|
guard !searchText.isEmpty else { return authManager.entries }
|
||||||
|
let query = searchText.lowercased()
|
||||||
|
return authManager.entries.filter {
|
||||||
|
$0.bundleIdentifier.lowercased().contains(query) ||
|
||||||
|
$0.appName.lowercased().contains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
globalTogglesSection
|
||||||
|
|
||||||
|
if authManager.isExtensionsFeatureEnabled {
|
||||||
|
authorizedAppsSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Extensions")
|
||||||
|
.alert("Remove Extension", isPresented: $showingRemoveConfirmation, presenting: selectedEntry) { entry in
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Remove", role: .destructive) {
|
||||||
|
authManager.removeEntry(bundleIdentifier: entry.bundleIdentifier)
|
||||||
|
selectedEntry = nil
|
||||||
|
}
|
||||||
|
} message: { entry in
|
||||||
|
Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var globalTogglesSection: some View {
|
||||||
|
Section {
|
||||||
|
Defaults.Toggle("Enable third-party extensions", key: .enableThirdPartyExtensions)
|
||||||
|
.settingsHighlight(id: highlightID("Enable third-party extensions"))
|
||||||
|
|
||||||
|
if Defaults[.enableThirdPartyExtensions] {
|
||||||
|
Defaults.Toggle("Allow extension live activities", key: .enableExtensionLiveActivities)
|
||||||
|
.settingsHighlight(id: highlightID("Allow extension live activities"))
|
||||||
|
|
||||||
|
Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets)
|
||||||
|
.settingsHighlight(id: highlightID("Allow extension lock screen widgets"))
|
||||||
|
|
||||||
|
Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled)
|
||||||
|
.settingsHighlight(id: highlightID("Enable extension diagnostics logging"))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Global Settings")
|
||||||
|
} footer: {
|
||||||
|
if Defaults[.enableThirdPartyExtensions] {
|
||||||
|
Text("Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var authorizedAppsSection: some View {
|
||||||
|
Section {
|
||||||
|
if authManager.entries.isEmpty {
|
||||||
|
VStack(alignment: .center, spacing: 12) {
|
||||||
|
Image(systemName: "puzzlepiece.extension")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.5))
|
||||||
|
|
||||||
|
Text("No extensions yet")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Apps using AtollExtensionKit will appear here once they request permission")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
} else {
|
||||||
|
if authManager.entries.count > 3 {
|
||||||
|
TextField("Search extensions...", text: $searchText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(filteredEntries) { entry in
|
||||||
|
ExtensionEntryRow(entry: entry, onRemove: {
|
||||||
|
selectedEntry = entry
|
||||||
|
showingRemoveConfirmation = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("App Permissions")
|
||||||
|
Spacer()
|
||||||
|
if !authManager.entries.isEmpty {
|
||||||
|
Text("\(authManager.entries.count) \(authManager.entries.count == 1 ? "app" : "apps")")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settingsHighlight(id: highlightID("App permissions list"))
|
||||||
|
} footer: {
|
||||||
|
if !authManager.entries.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Permission States:")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Label("Authorized", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Label("Pending", systemImage: "clock.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Label("Denied/Revoked", systemImage: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExtensionEntryRow: View {
|
||||||
|
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared
|
||||||
|
let entry: ExtensionAuthorizationEntry
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Main row
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Status indicator
|
||||||
|
statusIndicator
|
||||||
|
|
||||||
|
// App info
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(entry.appName)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
Text(entry.bundleIdentifier)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Expand button
|
||||||
|
Button {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.snappy(duration: 0.2)) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded details
|
||||||
|
if isExpanded {
|
||||||
|
expandedDetails
|
||||||
|
.padding(.top, 12)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIndicator: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(statusColor.opacity(0.15))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch entry.status {
|
||||||
|
case .authorized: return .green
|
||||||
|
case .pending: return .orange
|
||||||
|
case .denied, .revoked: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch entry.status {
|
||||||
|
case .authorized: return "checkmark.circle.fill"
|
||||||
|
case .pending: return "clock.fill"
|
||||||
|
case .denied, .revoked: return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var expandedDetails: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
// Status info
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text("Status:")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(entry.status.rawValue.capitalized)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(statusColor.opacity(0.15))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
if let grantedAt = entry.grantedAt {
|
||||||
|
infoRow(label: "Granted", value: formatDate(grantedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastActivity = entry.lastActivityAt {
|
||||||
|
infoRow(label: "Last Activity", value: formatDate(lastActivity))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let deniedReason = entry.lastDeniedReason {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("Last Denied Reason:")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(deniedReason)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red.opacity(0.9))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Scopes section
|
||||||
|
if entry.status == .authorized {
|
||||||
|
scopeToggles
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limits info
|
||||||
|
if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }),
|
||||||
|
!rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty {
|
||||||
|
rateLimitInfo(record: rateLimitRecord)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
actionButtons
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.05))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scopeToggles: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Allowed Features")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Toggle("Live Activities", isOn: Binding(
|
||||||
|
get: { entry.allowedScopes.contains(.liveActivities) },
|
||||||
|
set: { enabled in
|
||||||
|
var newScopes = entry.allowedScopes
|
||||||
|
if enabled {
|
||||||
|
newScopes.insert(.liveActivities)
|
||||||
|
} else {
|
||||||
|
newScopes.remove(.liveActivities)
|
||||||
|
}
|
||||||
|
authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.font(.caption)
|
||||||
|
.disabled(!authManager.areLiveActivitiesEnabled)
|
||||||
|
|
||||||
|
Toggle("Lock Screen Widgets", isOn: Binding(
|
||||||
|
get: { entry.allowedScopes.contains(.lockScreenWidgets) },
|
||||||
|
set: { enabled in
|
||||||
|
var newScopes = entry.allowedScopes
|
||||||
|
if enabled {
|
||||||
|
newScopes.insert(.lockScreenWidgets)
|
||||||
|
} else {
|
||||||
|
newScopes.remove(.lockScreenWidgets)
|
||||||
|
}
|
||||||
|
authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.font(.caption)
|
||||||
|
.disabled(!authManager.areLockScreenWidgetsEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rateLimitInfo(record: ExtensionRateLimitRecord) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Recent Activity (last 5 minutes)")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
if !record.activityTimestamps.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Live Activities")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(record.activityTimestamps.count)")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !record.widgetTimestamps.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Widget Updates")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(record.widgetTimestamps.count)")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Reset Rate Limits") {
|
||||||
|
authManager.resetRateLimits(for: entry.bundleIdentifier)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionButtons: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
switch entry.status {
|
||||||
|
case .pending:
|
||||||
|
Button("Authorize") {
|
||||||
|
authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Button("Deny") {
|
||||||
|
authManager.deny(bundleIdentifier: entry.bundleIdentifier, reason: "Denied by user")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
case .authorized:
|
||||||
|
Button("Revoke Access") {
|
||||||
|
authManager.revoke(bundleIdentifier: entry.bundleIdentifier, reason: "Revoked by user")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.orange)
|
||||||
|
|
||||||
|
case .denied, .revoked:
|
||||||
|
Button("Re-authorize") {
|
||||||
|
authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Remove") {
|
||||||
|
onRemove()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func infoRow(label: String, value: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text("\(label):")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .short
|
||||||
|
return formatter.localizedString(for: date, relativeTo: .now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ExtensionsSettingsView()
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case lockScreen
|
case lockScreen
|
||||||
case media
|
case media
|
||||||
case devices
|
case devices
|
||||||
|
case extensions
|
||||||
case timer
|
case timer
|
||||||
case calendar
|
case calendar
|
||||||
case hudAndOSD
|
case hudAndOSD
|
||||||
|
|
@ -48,6 +49,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .lockScreen: return "Lock Screen"
|
case .lockScreen: return "Lock Screen"
|
||||||
case .media: return "Media"
|
case .media: return "Media"
|
||||||
case .devices: return "Devices"
|
case .devices: return "Devices"
|
||||||
|
case .extensions: return "Extensions"
|
||||||
case .timer: return "Timer"
|
case .timer: return "Timer"
|
||||||
case .calendar: return "Calendar"
|
case .calendar: return "Calendar"
|
||||||
case .hudAndOSD: return "Controls"
|
case .hudAndOSD: return "Controls"
|
||||||
|
|
@ -72,6 +74,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .lockScreen: return "lock.laptopcomputer"
|
case .lockScreen: return "lock.laptopcomputer"
|
||||||
case .media: return "play.laptopcomputer"
|
case .media: return "play.laptopcomputer"
|
||||||
case .devices: return "headphones"
|
case .devices: return "headphones"
|
||||||
|
case .extensions: return "puzzlepiece.extension"
|
||||||
case .timer: return "timer"
|
case .timer: return "timer"
|
||||||
case .calendar: return "calendar"
|
case .calendar: return "calendar"
|
||||||
case .hudAndOSD: return "dial.medium.fill"
|
case .hudAndOSD: return "dial.medium.fill"
|
||||||
|
|
@ -96,6 +99,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
|
||||||
case .lockScreen: return .orange
|
case .lockScreen: return .orange
|
||||||
case .media: return .green
|
case .media: return .green
|
||||||
case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12)
|
case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12)
|
||||||
|
case .extensions: return Color(red: 0.557, green: 0.353, blue: 0.957)
|
||||||
case .timer: return .red
|
case .timer: return .red
|
||||||
case .calendar: return .cyan
|
case .calendar: return .cyan
|
||||||
case .hudAndOSD: return .indigo
|
case .hudAndOSD: return .indigo
|
||||||
|
|
@ -386,6 +390,7 @@ struct SettingsView: View {
|
||||||
.lockScreen,
|
.lockScreen,
|
||||||
.media,
|
.media,
|
||||||
.devices,
|
.devices,
|
||||||
|
.extensions,
|
||||||
.timer,
|
.timer,
|
||||||
.calendar,
|
.calendar,
|
||||||
.hudAndOSD,
|
.hudAndOSD,
|
||||||
|
|
@ -689,6 +694,13 @@ struct SettingsView: View {
|
||||||
SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")),
|
SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")),
|
||||||
SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")),
|
SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")),
|
||||||
|
|
||||||
|
// Extensions
|
||||||
|
SettingsSearchEntry(tab: .extensions, title: "Enable third-party extensions", keywords: ["extensions", "authorization", "third party"], highlightID: SettingsTab.extensions.highlightID(for: "Enable third-party extensions")),
|
||||||
|
SettingsSearchEntry(tab: .extensions, title: "Allow extension live activities", keywords: ["extensions", "live activities", "permissions"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension live activities")),
|
||||||
|
SettingsSearchEntry(tab: .extensions, title: "Allow extension lock screen widgets", keywords: ["extensions", "lock screen", "widgets"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension lock screen widgets")),
|
||||||
|
SettingsSearchEntry(tab: .extensions, title: "Enable extension diagnostics logging", keywords: ["extensions", "diagnostics", "logging"], highlightID: SettingsTab.extensions.highlightID(for: "Enable extension diagnostics logging")),
|
||||||
|
SettingsSearchEntry(tab: .extensions, title: "Manage app permissions", keywords: ["extensions", "permissions", "apps"], highlightID: SettingsTab.extensions.highlightID(for: "App permissions list")),
|
||||||
|
|
||||||
// Shortcuts
|
// Shortcuts
|
||||||
SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"], highlightID: SettingsTab.shortcuts.highlightID(for: "Enable global keyboard shortcuts")),
|
SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"], highlightID: SettingsTab.shortcuts.highlightID(for: "Enable global keyboard shortcuts")),
|
||||||
|
|
||||||
|
|
@ -766,6 +778,10 @@ struct SettingsView: View {
|
||||||
SettingsForm(tab: .devices) {
|
SettingsForm(tab: .devices) {
|
||||||
DevicesSettingsView()
|
DevicesSettingsView()
|
||||||
}
|
}
|
||||||
|
case .extensions:
|
||||||
|
SettingsForm(tab: .extensions) {
|
||||||
|
ExtensionsSettingsView()
|
||||||
|
}
|
||||||
case .timer:
|
case .timer:
|
||||||
SettingsForm(tab: .timer) {
|
SettingsForm(tab: .timer) {
|
||||||
TimerSettings()
|
TimerSettings()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import Foundation
|
||||||
|
import Defaults
|
||||||
|
import AtollExtensionKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ExtensionLiveActivityPayload: Identifiable, Hashable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
let descriptor: AtollLiveActivityDescriptor
|
||||||
|
let receivedAt: Date
|
||||||
|
|
||||||
|
var id: String { descriptor.id }
|
||||||
|
|
||||||
|
var priority: AtollLiveActivityPriority { descriptor.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
let descriptor: AtollLockScreenWidgetDescriptor
|
||||||
|
let receivedAt: Date
|
||||||
|
|
||||||
|
var id: String { descriptor.id }
|
||||||
|
|
||||||
|
var priority: AtollLiveActivityPriority { descriptor.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExtensionValidationError: LocalizedError, Equatable {
|
||||||
|
case featureDisabled
|
||||||
|
case unauthorized
|
||||||
|
case invalidDescriptor(String)
|
||||||
|
case rateLimited
|
||||||
|
case exceedsCapacity
|
||||||
|
case duplicateIdentifier
|
||||||
|
case unsupportedContent
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .featureDisabled:
|
||||||
|
return "Extensions feature is currently disabled in Atoll."
|
||||||
|
case .unauthorized:
|
||||||
|
return "Your app is not authorized to post this content."
|
||||||
|
case .invalidDescriptor(let reason):
|
||||||
|
return "Descriptor validation failed: \(reason)."
|
||||||
|
case .rateLimited:
|
||||||
|
return "Too many requests in a short period. Please slow down."
|
||||||
|
case .exceedsCapacity:
|
||||||
|
return "Atoll reached its limit for simultaneous extension content."
|
||||||
|
case .duplicateIdentifier:
|
||||||
|
return "An item with the same identifier already exists."
|
||||||
|
case .unsupportedContent:
|
||||||
|
return "Descriptor includes unsupported content for this surface."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
import Foundation
|
||||||
|
import Defaults
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExtensionAuthorizationManager: ObservableObject {
|
||||||
|
static let shared = ExtensionAuthorizationManager()
|
||||||
|
|
||||||
|
@Published private(set) var entries: [ExtensionAuthorizationEntry]
|
||||||
|
@Published private(set) var rateLimitRecords: [ExtensionRateLimitRecord]
|
||||||
|
|
||||||
|
private let persistenceQueue = DispatchQueue(label: "com.atoll.extensions.authorization", qos: .utility)
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.entries = Defaults[.extensionAuthorizationEntries]
|
||||||
|
self.rateLimitRecords = Defaults[.extensionRateLimitRecords]
|
||||||
|
normalizeState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
var isExtensionsFeatureEnabled: Bool { Defaults[.enableThirdPartyExtensions] }
|
||||||
|
var areLiveActivitiesEnabled: Bool { Defaults[.enableExtensionLiveActivities] }
|
||||||
|
var areLockScreenWidgetsEnabled: Bool { Defaults[.enableExtensionLockScreenWidgets] }
|
||||||
|
|
||||||
|
func updateFeatureToggles(extensionsEnabled: Bool? = nil,
|
||||||
|
liveActivitiesEnabled: Bool? = nil,
|
||||||
|
lockScreenWidgetsEnabled: Bool? = nil) {
|
||||||
|
if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled }
|
||||||
|
if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled }
|
||||||
|
if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled }
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationEntry(for bundleIdentifier: String) -> ExtensionAuthorizationEntry? {
|
||||||
|
entries.first { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureEntryExists(bundleIdentifier: String, appName: String?) -> ExtensionAuthorizationEntry {
|
||||||
|
if let existing = authorizationEntry(for: bundleIdentifier) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let resolvedName = appName ?? bundleIdentifier
|
||||||
|
let entry = ExtensionAuthorizationEntry(
|
||||||
|
bundleIdentifier: bundleIdentifier,
|
||||||
|
appName: resolvedName,
|
||||||
|
status: .pending,
|
||||||
|
allowedScopes: defaultScopes()
|
||||||
|
)
|
||||||
|
entries.append(entry)
|
||||||
|
persistEntries()
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorize(bundleIdentifier: String, appName: String?, scopes: Set<ExtensionPermissionScope>? = nil) {
|
||||||
|
updateEntry(bundleIdentifier: bundleIdentifier) { entry in
|
||||||
|
entry.appName = appName ?? entry.appName
|
||||||
|
entry.status = .authorized
|
||||||
|
entry.allowedScopes = scopes ?? entry.allowedScopes
|
||||||
|
entry.grantedAt = .now
|
||||||
|
entry.lastDeniedReason = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deny(bundleIdentifier: String, reason: String?) {
|
||||||
|
updateEntry(bundleIdentifier: bundleIdentifier) { entry in
|
||||||
|
entry.status = .denied
|
||||||
|
entry.lastDeniedReason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func revoke(bundleIdentifier: String, reason: String?) {
|
||||||
|
updateEntry(bundleIdentifier: bundleIdentifier) { entry in
|
||||||
|
entry.status = .revoked
|
||||||
|
entry.lastDeniedReason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordActivity(for bundleIdentifier: String, scope: ExtensionPermissionScope) {
|
||||||
|
updateEntry(bundleIdentifier: bundleIdentifier) { entry in
|
||||||
|
entry.lastActivityAt = .now
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRateLimitRecord(bundleIdentifier: bundleIdentifier) { record in
|
||||||
|
let now = Date()
|
||||||
|
switch scope {
|
||||||
|
case .liveActivities:
|
||||||
|
record.activityTimestamps.append(now)
|
||||||
|
case .lockScreenWidgets:
|
||||||
|
record.widgetTimestamps.append(now)
|
||||||
|
}
|
||||||
|
record.activityTimestamps = flushOldTimestamps(record.activityTimestamps)
|
||||||
|
record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllowedScopes(bundleIdentifier: String, allowedScopes: Set<ExtensionPermissionScope>) {
|
||||||
|
updateEntry(bundleIdentifier: bundleIdentifier) { entry in
|
||||||
|
entry.allowedScopes = allowedScopes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetRateLimits(for bundleIdentifier: String) {
|
||||||
|
rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
persistRateLimitRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeEntry(bundleIdentifier: String) {
|
||||||
|
entries.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
rateLimitRecords.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
persistEntries()
|
||||||
|
persistRateLimitRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation Helpers
|
||||||
|
|
||||||
|
func canProcessLiveActivityRequest(from bundleIdentifier: String) -> Bool {
|
||||||
|
guard preflight(bundleIdentifier: bundleIdentifier, scope: .liveActivities) else { return false }
|
||||||
|
guard areLiveActivitiesEnabled else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func canProcessLockScreenRequest(from bundleIdentifier: String) -> Bool {
|
||||||
|
guard preflight(bundleIdentifier: bundleIdentifier, scope: .lockScreenWidgets) else { return false }
|
||||||
|
guard areLockScreenWidgetsEnabled else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDeniedRequest(bundleIdentifier: String, reason: String) {
|
||||||
|
updateEntry(bundleIdentifier: bundleIdentifier) { entry in
|
||||||
|
entry.lastDeniedReason = reason
|
||||||
|
entry.status = entry.status == .authorized ? .revoked : entry.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal State Updates
|
||||||
|
|
||||||
|
private func updateEntry(bundleIdentifier: String, mutate: (inout ExtensionAuthorizationEntry) -> Void) {
|
||||||
|
guard let index = entries.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) else {
|
||||||
|
var entry = ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: bundleIdentifier)
|
||||||
|
mutate(&entry)
|
||||||
|
entries.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
entries.append(entry)
|
||||||
|
persistEntries()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var entry = entries[index]
|
||||||
|
mutate(&entry)
|
||||||
|
entries[index] = entry
|
||||||
|
persistEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRateLimitRecord(bundleIdentifier: String, mutate: (inout ExtensionRateLimitRecord) -> Void) {
|
||||||
|
if let index = rateLimitRecords.firstIndex(where: { $0.bundleIdentifier == bundleIdentifier }) {
|
||||||
|
var record = rateLimitRecords[index]
|
||||||
|
mutate(&record)
|
||||||
|
rateLimitRecords[index] = record
|
||||||
|
persistRateLimitRecords()
|
||||||
|
} else {
|
||||||
|
var newRecord = ExtensionRateLimitRecord(bundleIdentifier: bundleIdentifier)
|
||||||
|
mutate(&newRecord)
|
||||||
|
rateLimitRecords.append(newRecord)
|
||||||
|
persistRateLimitRecords()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preflight(bundleIdentifier: String, scope: ExtensionPermissionScope) -> Bool {
|
||||||
|
guard isExtensionsFeatureEnabled else { return false }
|
||||||
|
guard let entry = authorizationEntry(for: bundleIdentifier) else { return false }
|
||||||
|
guard entry.isAuthorized else { return false }
|
||||||
|
guard entry.allowedScopes.contains(scope) else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flushOldTimestamps(_ timestamps: [Date]) -> [Date] {
|
||||||
|
let threshold = Date().addingTimeInterval(-60 * 5)
|
||||||
|
return timestamps.filter { $0 >= threshold }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func defaultScopes() -> Set<ExtensionPermissionScope> {
|
||||||
|
Set(ExtensionPermissionScope.allCases)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistEntries() {
|
||||||
|
let entriesSnapshot = entries
|
||||||
|
persistenceQueue.async {
|
||||||
|
Defaults[.extensionAuthorizationEntries] = entriesSnapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistRateLimitRecords() {
|
||||||
|
let recordsSnapshot = rateLimitRecords
|
||||||
|
persistenceQueue.async {
|
||||||
|
Defaults[.extensionRateLimitRecords] = recordsSnapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizeState() {
|
||||||
|
entries = entries.filter { !$0.bundleIdentifier.isEmpty }
|
||||||
|
rateLimitRecords = rateLimitRecords.filter { !$0.bundleIdentifier.isEmpty }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import Foundation
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
struct ExtensionDescriptorValidator {
|
||||||
|
static func validate(_ descriptor: AtollLiveActivityDescriptor) throws {
|
||||||
|
guard descriptor.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields")
|
||||||
|
}
|
||||||
|
guard descriptor.leadingIcon.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Invalid leading icon data")
|
||||||
|
}
|
||||||
|
if let badge = descriptor.badgeIcon {
|
||||||
|
guard badge.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Invalid badge icon data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if descriptor.metadata.count > 32 {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Metadata keys must be ≤ 32")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validate(_ descriptor: AtollLockScreenWidgetDescriptor) throws {
|
||||||
|
guard descriptor.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields")
|
||||||
|
}
|
||||||
|
guard descriptor.content.count <= 12 else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Too many content elements")
|
||||||
|
}
|
||||||
|
guard descriptor.size.width <= 480, descriptor.size.height <= 280 else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Widget exceeds size limits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import Foundation
|
||||||
|
import Defaults
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExtensionLiveActivityManager: ObservableObject {
|
||||||
|
static let shared = ExtensionLiveActivityManager()
|
||||||
|
|
||||||
|
@Published private(set) var activeActivities: [ExtensionLiveActivityPayload] = []
|
||||||
|
|
||||||
|
private let authorizationManager = ExtensionAuthorizationManager.shared
|
||||||
|
private let maxCapacityKey = Defaults.Keys.extensionLiveActivityCapacity
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
|
||||||
|
guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else {
|
||||||
|
throw ExtensionValidationError.unauthorized
|
||||||
|
}
|
||||||
|
guard descriptor.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
||||||
|
}
|
||||||
|
guard activeActivities.count < Defaults[maxCapacityKey] else {
|
||||||
|
throw ExtensionValidationError.exceedsCapacity
|
||||||
|
}
|
||||||
|
guard activeActivities.contains(where: { $0.descriptor.id == descriptor.id }) == false else {
|
||||||
|
throw ExtensionValidationError.duplicateIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = ExtensionLiveActivityPayload(
|
||||||
|
bundleIdentifier: bundleIdentifier,
|
||||||
|
descriptor: descriptor,
|
||||||
|
receivedAt: .now
|
||||||
|
)
|
||||||
|
activeActivities.append(payload)
|
||||||
|
sortActivities()
|
||||||
|
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
|
||||||
|
guard descriptor.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
||||||
|
}
|
||||||
|
guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Missing existing activity")
|
||||||
|
}
|
||||||
|
let payload = ExtensionLiveActivityPayload(
|
||||||
|
bundleIdentifier: bundleIdentifier,
|
||||||
|
descriptor: descriptor,
|
||||||
|
receivedAt: activeActivities[index].receivedAt
|
||||||
|
)
|
||||||
|
activeActivities[index] = payload
|
||||||
|
sortActivities()
|
||||||
|
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss(activityID: String, bundleIdentifier: String) {
|
||||||
|
let previousCount = activeActivities.count
|
||||||
|
activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
if previousCount != activeActivities.count {
|
||||||
|
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
|
||||||
|
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissAll(for bundleIdentifier: String) {
|
||||||
|
let ids = activeActivities
|
||||||
|
.filter { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
.map { $0.descriptor.id }
|
||||||
|
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] {
|
||||||
|
activeActivities
|
||||||
|
.filter { coexistence ? $0.descriptor.allowsMusicCoexistence : true }
|
||||||
|
.sorted(by: descriptorComparator)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool {
|
||||||
|
if lhs.descriptor.priority == rhs.descriptor.priority {
|
||||||
|
return lhs.receivedAt < rhs.receivedAt
|
||||||
|
}
|
||||||
|
return lhs.descriptor.priority > rhs.descriptor.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortActivities() {
|
||||||
|
activeActivities.sort(by: descriptorComparator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import Foundation
|
||||||
|
import Defaults
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExtensionLockScreenWidgetManager: ObservableObject {
|
||||||
|
static let shared = ExtensionLockScreenWidgetManager()
|
||||||
|
|
||||||
|
@Published private(set) var activeWidgets: [ExtensionLockScreenWidgetPayload] = []
|
||||||
|
|
||||||
|
private let authorizationManager = ExtensionAuthorizationManager.shared
|
||||||
|
private let maxCapacityKey = Defaults.Keys.extensionLockScreenWidgetCapacity
|
||||||
|
private var presentationController: ExtensionLockScreenWidgetPresentationController!
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
presentationController = ExtensionLockScreenWidgetPresentationController(manager: self)
|
||||||
|
presentationController.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
|
||||||
|
guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else {
|
||||||
|
throw ExtensionValidationError.unauthorized
|
||||||
|
}
|
||||||
|
guard descriptor.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
||||||
|
}
|
||||||
|
guard activeWidgets.count < Defaults[maxCapacityKey] else {
|
||||||
|
throw ExtensionValidationError.exceedsCapacity
|
||||||
|
}
|
||||||
|
guard activeWidgets.contains(where: { $0.descriptor.id == descriptor.id }) == false else {
|
||||||
|
throw ExtensionValidationError.duplicateIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = ExtensionLockScreenWidgetPayload(
|
||||||
|
bundleIdentifier: bundleIdentifier,
|
||||||
|
descriptor: descriptor,
|
||||||
|
receivedAt: .now
|
||||||
|
)
|
||||||
|
activeWidgets.append(payload)
|
||||||
|
sortWidgets()
|
||||||
|
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
|
||||||
|
guard descriptor.isValid else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
|
||||||
|
}
|
||||||
|
guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
|
||||||
|
throw ExtensionValidationError.invalidDescriptor("Missing existing widget")
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = ExtensionLockScreenWidgetPayload(
|
||||||
|
bundleIdentifier: bundleIdentifier,
|
||||||
|
descriptor: descriptor,
|
||||||
|
receivedAt: activeWidgets[index].receivedAt
|
||||||
|
)
|
||||||
|
activeWidgets[index] = payload
|
||||||
|
sortWidgets()
|
||||||
|
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss(widgetID: String, bundleIdentifier: String) {
|
||||||
|
let previousCount = activeWidgets.count
|
||||||
|
activeWidgets.removeAll { $0.descriptor.id == widgetID && $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
if previousCount != activeWidgets.count {
|
||||||
|
Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions)
|
||||||
|
ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissAll(for bundleIdentifier: String) {
|
||||||
|
let ids = activeWidgets
|
||||||
|
.filter { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
.map { $0.descriptor.id }
|
||||||
|
activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier }
|
||||||
|
ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortWidgets() {
|
||||||
|
activeWidgets.sort { lhs, rhs in
|
||||||
|
if lhs.priority == rhs.priority {
|
||||||
|
return lhs.receivedAt < rhs.receivedAt
|
||||||
|
}
|
||||||
|
return lhs.priority > rhs.priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import Defaults
|
||||||
|
import AppKit
|
||||||
|
import SkyLightWindow
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExtensionLockScreenWidgetPresentationController {
|
||||||
|
private unowned let manager: ExtensionLockScreenWidgetManager
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
private let windowPool = ExtensionLockScreenWidgetWindowPool()
|
||||||
|
private var cachedPayloads: [ExtensionLockScreenWidgetPayload] = []
|
||||||
|
private var isLocked: Bool = false
|
||||||
|
|
||||||
|
init(manager: ExtensionLockScreenWidgetManager) {
|
||||||
|
self.manager = manager
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
observeLockState()
|
||||||
|
observePayloads()
|
||||||
|
observeDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeLockState() {
|
||||||
|
LockScreenManager.shared.$isLocked
|
||||||
|
.removeDuplicates()
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] locked in
|
||||||
|
guard let self else { return }
|
||||||
|
self.isLocked = locked
|
||||||
|
self.refreshPresentation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observePayloads() {
|
||||||
|
manager.$activeWidgets
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] payloads in
|
||||||
|
guard let self else { return }
|
||||||
|
self.cachedPayloads = payloads
|
||||||
|
self.refreshPresentation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeDefaults() {
|
||||||
|
Defaults.publisher(.enableExtensionLockScreenWidgets, options: [])
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] change in
|
||||||
|
guard let self else { return }
|
||||||
|
if change.newValue {
|
||||||
|
self.refreshPresentation()
|
||||||
|
} else {
|
||||||
|
self.windowPool.hideAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPresentation() {
|
||||||
|
guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else {
|
||||||
|
windowPool.hideAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard cachedPayloads.isEmpty == false else {
|
||||||
|
windowPool.hideAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
windowPool.sync(with: cachedPayloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class ExtensionLockScreenWidgetWindowPool {
|
||||||
|
private struct WindowRecord {
|
||||||
|
let window: NSWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
private var windows: [String: WindowRecord] = [:]
|
||||||
|
private var payloadsByID: [String: ExtensionLockScreenWidgetPayload] = [:]
|
||||||
|
private var screenChangeObserver: NSObjectProtocol?
|
||||||
|
private var workspaceObservers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
registerScreenChangeObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let screenChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(screenChangeObserver)
|
||||||
|
}
|
||||||
|
let workspaceCenter = NSWorkspace.shared.notificationCenter
|
||||||
|
workspaceObservers.forEach { workspaceCenter.removeObserver($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func sync(with payloads: [ExtensionLockScreenWidgetPayload]) {
|
||||||
|
payloadsByID = Dictionary(uniqueKeysWithValues: payloads.map { ($0.id, $0) })
|
||||||
|
let activeIDs = Set(payloadsByID.keys)
|
||||||
|
let staleIDs = Set(windows.keys).subtracting(activeIDs)
|
||||||
|
staleIDs.forEach { hideWindow(withID: $0) }
|
||||||
|
payloads.forEach { renderWindow(for: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideAll() {
|
||||||
|
payloadsByID.removeAll()
|
||||||
|
windows.values.forEach { record in
|
||||||
|
record.window.orderOut(nil)
|
||||||
|
record.window.contentView = nil
|
||||||
|
}
|
||||||
|
windows.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) {
|
||||||
|
guard let screen = currentScreen() else { return }
|
||||||
|
let descriptor = payload.descriptor
|
||||||
|
let window = ensureWindow(for: payload.id)
|
||||||
|
let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload))
|
||||||
|
hosting.frame = NSRect(origin: .zero, size: descriptor.size)
|
||||||
|
hosting.wantsLayer = true
|
||||||
|
hosting.layer?.masksToBounds = true
|
||||||
|
hosting.layer?.cornerRadius = descriptor.cornerRadius
|
||||||
|
window.contentView = hosting
|
||||||
|
window.setFrame(frame(for: descriptor, on: screen), display: true)
|
||||||
|
window.alphaValue = 1
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureWindow(for id: String) -> NSWindow {
|
||||||
|
if let record = windows[id] {
|
||||||
|
return record.window
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(origin: .zero, size: CGSize(width: 200, height: 80)),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.isOpaque = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.hasShadow = false
|
||||||
|
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||||
|
window.ignoresMouseEvents = true
|
||||||
|
window.isMovable = false
|
||||||
|
|
||||||
|
ScreenCaptureVisibilityManager.shared.register(window, scope: .entireInterface)
|
||||||
|
SkyLightOperator.shared.delegateWindow(window)
|
||||||
|
|
||||||
|
let record = WindowRecord(window: window)
|
||||||
|
windows[id] = record
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideWindow(withID id: String) {
|
||||||
|
guard let record = windows[id] else { return }
|
||||||
|
record.window.orderOut(nil)
|
||||||
|
record.window.contentView = nil
|
||||||
|
windows.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerScreenChangeObservers() {
|
||||||
|
screenChangeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: NSApplication.didChangeScreenParametersNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.handleScreenGeometryChange(reason: "screen-parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspaceCenter = NSWorkspace.shared.notificationCenter
|
||||||
|
let wakeObserver = workspaceCenter.addObserver(
|
||||||
|
forName: NSWorkspace.screensDidWakeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.handleScreenGeometryChange(reason: "screens-did-wake")
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceObservers = [wakeObserver]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScreenGeometryChange(reason: String) {
|
||||||
|
guard windows.isEmpty == false else { return }
|
||||||
|
repositionAll(animated: false)
|
||||||
|
print("ExtensionLockScreenWidgetWindowPool: realigned windows due to \(reason)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func repositionAll(animated: Bool) {
|
||||||
|
guard let screen = currentScreen() else { return }
|
||||||
|
for (id, record) in windows {
|
||||||
|
guard let payload = payloadsByID[id] else { continue }
|
||||||
|
let targetFrame = frame(for: payload.descriptor, on: screen)
|
||||||
|
if animated {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.22
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
record.window.animator().setFrame(targetFrame, display: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
record.window.setFrame(targetFrame, display: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect {
|
||||||
|
let size = descriptor.size
|
||||||
|
let safeInsets = NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48)
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
|
||||||
|
let minX = screenFrame.minX + safeInsets.left
|
||||||
|
let maxX = screenFrame.maxX - safeInsets.right - size.width
|
||||||
|
let minY = screenFrame.minY + safeInsets.bottom
|
||||||
|
let maxY = screenFrame.maxY - safeInsets.top - size.height
|
||||||
|
|
||||||
|
var originX: CGFloat
|
||||||
|
switch descriptor.position.alignment {
|
||||||
|
case .leading:
|
||||||
|
originX = minX
|
||||||
|
case .center:
|
||||||
|
originX = screenFrame.midX - (size.width / 2)
|
||||||
|
case .trailing:
|
||||||
|
originX = maxX
|
||||||
|
}
|
||||||
|
|
||||||
|
originX += descriptor.position.horizontalOffset
|
||||||
|
originX = clamp(originX, lower: min(minX, maxX), upper: max(minX, maxX))
|
||||||
|
|
||||||
|
var originY = screenFrame.midY - (size.height / 2) + descriptor.position.verticalOffset
|
||||||
|
originY = clamp(originY, lower: min(minY, maxY), upper: max(minY, maxY))
|
||||||
|
|
||||||
|
return NSRect(x: originX, y: originY, width: size.width, height: size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
|
||||||
|
guard lower <= upper else { return value }
|
||||||
|
return min(max(value, lower), upper)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentScreen() -> NSScreen? {
|
||||||
|
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
import Lottie
|
import Lottie
|
||||||
|
import Foundation
|
||||||
|
|
||||||
private let availableDirectories = FileManager
|
private let availableDirectories = FileManager
|
||||||
.default
|
.default
|
||||||
|
|
@ -97,6 +98,86 @@ enum AnimationSource: Codable, Hashable, Equatable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Extension Authorization Models
|
||||||
|
|
||||||
|
enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable {
|
||||||
|
case liveActivities
|
||||||
|
case lockScreenWidgets
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .liveActivities: return "Live Activities"
|
||||||
|
case .lockScreenWidgets: return "Lock Screen Widgets"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExtensionAuthorizationStatus: String, CaseIterable, Codable, Defaults.Serializable {
|
||||||
|
case pending
|
||||||
|
case authorized
|
||||||
|
case denied
|
||||||
|
case revoked
|
||||||
|
|
||||||
|
var isActive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .authorized: return true
|
||||||
|
case .pending, .denied, .revoked: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExtensionAuthorizationEntry: Codable, Defaults.Serializable, Identifiable, Hashable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
var appName: String
|
||||||
|
var status: ExtensionAuthorizationStatus
|
||||||
|
var allowedScopes: Set<ExtensionPermissionScope>
|
||||||
|
var requestedAt: Date
|
||||||
|
var grantedAt: Date?
|
||||||
|
var lastActivityAt: Date?
|
||||||
|
var lastDeniedReason: String?
|
||||||
|
var notes: String?
|
||||||
|
|
||||||
|
var id: String { bundleIdentifier }
|
||||||
|
|
||||||
|
init(
|
||||||
|
bundleIdentifier: String,
|
||||||
|
appName: String,
|
||||||
|
status: ExtensionAuthorizationStatus,
|
||||||
|
allowedScopes: Set<ExtensionPermissionScope> = Set(ExtensionPermissionScope.allCases),
|
||||||
|
requestedAt: Date = .now,
|
||||||
|
grantedAt: Date? = nil,
|
||||||
|
lastActivityAt: Date? = nil,
|
||||||
|
lastDeniedReason: String? = nil,
|
||||||
|
notes: String? = nil
|
||||||
|
) {
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.appName = appName
|
||||||
|
self.status = status
|
||||||
|
self.allowedScopes = allowedScopes
|
||||||
|
self.requestedAt = requestedAt
|
||||||
|
self.grantedAt = grantedAt
|
||||||
|
self.lastActivityAt = lastActivityAt
|
||||||
|
self.lastDeniedReason = lastDeniedReason
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAuthorized: Bool { status.isActive }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExtensionRateLimitRecord: Codable, Defaults.Serializable, Hashable, Identifiable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
var activityTimestamps: [Date]
|
||||||
|
var widgetTimestamps: [Date]
|
||||||
|
|
||||||
|
var id: String { bundleIdentifier }
|
||||||
|
|
||||||
|
init(bundleIdentifier: String, activityTimestamps: [Date] = [], widgetTimestamps: [Date] = []) {
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.activityTimestamps = activityTimestamps
|
||||||
|
self.widgetTimestamps = widgetTimestamps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum CalendarSelectionState: Codable, Defaults.Serializable {
|
enum CalendarSelectionState: Codable, Defaults.Serializable {
|
||||||
case all
|
case all
|
||||||
case selected(Set<String>)
|
case selected(Set<String>)
|
||||||
|
|
@ -714,6 +795,16 @@ extension Defaults.Keys {
|
||||||
static let selectedAIModel = Key<AIModel?>("selectedAIModel", default: nil)
|
static let selectedAIModel = Key<AIModel?>("selectedAIModel", default: nil)
|
||||||
static let enableThinkingMode = Key<Bool>("enableThinkingMode", default: false)
|
static let enableThinkingMode = Key<Bool>("enableThinkingMode", default: false)
|
||||||
static let localModelEndpoint = Key<String>("localModelEndpoint", default: "http://localhost:11434")
|
static let localModelEndpoint = Key<String>("localModelEndpoint", default: "http://localhost:11434")
|
||||||
|
|
||||||
|
// MARK: Third-Party Extensions
|
||||||
|
static let enableThirdPartyExtensions = Key<Bool>("enableThirdPartyExtensions", default: true)
|
||||||
|
static let enableExtensionLiveActivities = Key<Bool>("enableExtensionLiveActivities", default: true)
|
||||||
|
static let enableExtensionLockScreenWidgets = Key<Bool>("enableExtensionLockScreenWidgets", default: true)
|
||||||
|
static let extensionAuthorizationEntries = Key<[ExtensionAuthorizationEntry]>("extensionAuthorizationEntries", default: [])
|
||||||
|
static let extensionRateLimitRecords = Key<[ExtensionRateLimitRecord]>("extensionRateLimitRecords", default: [])
|
||||||
|
static let extensionDiagnosticsLoggingEnabled = Key<Bool>("extensionDiagnosticsLoggingEnabled", default: true)
|
||||||
|
static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4)
|
||||||
|
static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4)
|
||||||
|
|
||||||
// MARK: Keyboard Shortcuts
|
// MARK: Keyboard Shortcuts
|
||||||
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)
|
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)
|
||||||
|
|
|
||||||
159
DynamicIsland/services/Extensions/ExtensionXPCService.swift
Normal file
159
DynamicIsland/services/Extensions/ExtensionXPCService.swift
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Defaults
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExtensionXPCService: NSObject, AtollXPCServiceProtocol {
|
||||||
|
private let bundleIdentifier: String
|
||||||
|
private weak var host: ExtensionXPCServiceHost?
|
||||||
|
private weak var connection: NSXPCConnection?
|
||||||
|
|
||||||
|
private let authorizationManager = ExtensionAuthorizationManager.shared
|
||||||
|
private let liveActivityManager = ExtensionLiveActivityManager.shared
|
||||||
|
private let widgetManager = ExtensionLockScreenWidgetManager.shared
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
init(bundleIdentifier: String, host: ExtensionXPCServiceHost, connection: NSXPCConnection) {
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.host = host
|
||||||
|
self.connection = connection
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Authorization
|
||||||
|
|
||||||
|
func requestAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
|
guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return }
|
||||||
|
|
||||||
|
guard authorizationManager.isExtensionsFeatureEnabled else {
|
||||||
|
reply(false, ExtensionValidationError.featureDisabled.asNSError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = authorizationManager.ensureEntryExists(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName())
|
||||||
|
|
||||||
|
if entry.status == .pending {
|
||||||
|
authorizationManager.authorize(bundleIdentifier: bundleIdentifier, appName: resolvedApplicationName())
|
||||||
|
host?.notifyAuthorizationChange(bundleIdentifier: bundleIdentifier, isAuthorized: true)
|
||||||
|
reply(true, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool) -> Void) {
|
||||||
|
guard providedBundleIdentifier == bundleIdentifier else {
|
||||||
|
reply(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAuthorized = authorizationManager.authorizationEntry(for: bundleIdentifier)?.isAuthorized ?? false
|
||||||
|
reply(isAuthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Live Activities
|
||||||
|
|
||||||
|
func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
|
respond(reply: reply) {
|
||||||
|
let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
|
||||||
|
try ExtensionDescriptorValidator.validate(descriptor)
|
||||||
|
try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
|
respond(reply: reply) {
|
||||||
|
let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
|
||||||
|
try ExtensionDescriptorValidator.validate(descriptor)
|
||||||
|
try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissLiveActivity(activityID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
|
guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return }
|
||||||
|
|
||||||
|
liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: bundleIdentifier)
|
||||||
|
reply(true, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Lock Screen Widgets
|
||||||
|
|
||||||
|
func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
|
respond(reply: reply) {
|
||||||
|
let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
|
||||||
|
try ExtensionDescriptorValidator.validate(descriptor)
|
||||||
|
try widgetManager.present(descriptor: descriptor, bundleIdentifier: bundleIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
|
respond(reply: reply) {
|
||||||
|
let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
|
||||||
|
try ExtensionDescriptorValidator.validate(descriptor)
|
||||||
|
try widgetManager.update(descriptor: descriptor, bundleIdentifier: bundleIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissLockScreenWidget(widgetID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) {
|
||||||
|
guard validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return }
|
||||||
|
|
||||||
|
widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bundleIdentifier)
|
||||||
|
reply(true, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Diagnostics
|
||||||
|
|
||||||
|
func getVersion(reply: @escaping (String) -> Void) {
|
||||||
|
reply(appVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func respond(reply: @escaping (Bool, Error?) -> Void, operation: () throws -> Void) {
|
||||||
|
do {
|
||||||
|
try operation()
|
||||||
|
reply(true, nil)
|
||||||
|
} catch {
|
||||||
|
if Defaults[.extensionDiagnosticsLoggingEnabled] {
|
||||||
|
Logger.log("Extension XPC request failed: \(error)", category: .extensions)
|
||||||
|
}
|
||||||
|
reply(false, error.asNSError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validate(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) -> Bool {
|
||||||
|
guard providedBundleIdentifier == bundleIdentifier else {
|
||||||
|
let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier)
|
||||||
|
reply(false, error.asNSError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedApplicationName() -> String {
|
||||||
|
guard let processIdentifier = connection?.processIdentifier,
|
||||||
|
processIdentifier != 0,
|
||||||
|
let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)),
|
||||||
|
let name = app.localizedName else {
|
||||||
|
return bundleIdentifier
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ExtensionXPCServiceError: LocalizedError {
|
||||||
|
case bundleMismatch(expected: String, received: String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case let .bundleMismatch(expected, received):
|
||||||
|
return "Bundle identifier mismatch. Expected \(expected) but received \(received)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Error {
|
||||||
|
var asNSError: NSError { self as NSError }
|
||||||
|
}
|
||||||
131
DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift
Normal file
131
DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import AtollExtensionKit
|
||||||
|
|
||||||
|
/// Shared constants for the Atoll extension XPC service.
|
||||||
|
enum ExtensionXPCServiceConstants {
|
||||||
|
static let machServiceName = "com.ebullioscopic.Atoll.xpc"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExtensionXPCServiceHost: NSObject, NSXPCListenerDelegate {
|
||||||
|
static let shared = ExtensionXPCServiceHost()
|
||||||
|
|
||||||
|
private final class ClientContext {
|
||||||
|
weak var connection: NSXPCConnection?
|
||||||
|
let bundleIdentifier: String
|
||||||
|
|
||||||
|
init(connection: NSXPCConnection, bundleIdentifier: String) {
|
||||||
|
self.connection = connection
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var listener: NSXPCListener?
|
||||||
|
private var clientContexts: [ObjectIdentifier: ClientContext] = [:]
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard listener == nil else { return }
|
||||||
|
|
||||||
|
let listener = NSXPCListener(machServiceName: ExtensionXPCServiceConstants.machServiceName)
|
||||||
|
listener.delegate = self
|
||||||
|
self.listener = listener
|
||||||
|
listener.resume()
|
||||||
|
|
||||||
|
Logger.log("Started Atoll XPC listener", category: .extensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
listener?.invalidate()
|
||||||
|
listener = nil
|
||||||
|
clientContexts.removeAll()
|
||||||
|
Logger.log("Stopped Atoll XPC listener", category: .extensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
|
||||||
|
guard let bundleIdentifier = resolveBundleIdentifier(for: connection) else {
|
||||||
|
Logger.log("Rejected XPC connection without bundle identifier", category: .extensions)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = ExtensionXPCService(bundleIdentifier: bundleIdentifier, host: self, connection: connection)
|
||||||
|
connection.exportedInterface = NSXPCInterface(with: AtollXPCServiceProtocol.self)
|
||||||
|
connection.exportedObject = service
|
||||||
|
connection.remoteObjectInterface = NSXPCInterface(with: AtollXPCClientProtocol.self)
|
||||||
|
|
||||||
|
connection.invalidationHandler = { [weak self, weak connection] in
|
||||||
|
guard let connection else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.removeConnection(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.interruptionHandler = { [weak self, weak connection] in
|
||||||
|
guard let connection else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.removeConnection(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.resume()
|
||||||
|
clientContexts[ObjectIdentifier(connection)] = ClientContext(connection: connection, bundleIdentifier: bundleIdentifier)
|
||||||
|
Logger.log("Accepted XPC connection from \(bundleIdentifier)", category: .extensions)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyAuthorizationChange(bundleIdentifier: String, isAuthorized: Bool) {
|
||||||
|
deliver(to: bundleIdentifier) { client in
|
||||||
|
client.authorizationDidChange(isAuthorized: isAuthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyActivityDismiss(bundleIdentifier: String, activityID: String) {
|
||||||
|
deliver(to: bundleIdentifier) { client in
|
||||||
|
client.activityDidDismiss(activityID: activityID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyWidgetDismiss(bundleIdentifier: String, widgetID: String) {
|
||||||
|
deliver(to: bundleIdentifier) { client in
|
||||||
|
client.widgetDidDismiss(widgetID: widgetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? {
|
||||||
|
let processIdentifier = connection.processIdentifier
|
||||||
|
guard processIdentifier != 0 else { return nil }
|
||||||
|
|
||||||
|
if let app = NSRunningApplication(processIdentifier: pid_t(processIdentifier)),
|
||||||
|
let bundleIdentifier = app.bundleIdentifier {
|
||||||
|
return bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deliver(to bundleIdentifier: String, send block: (AtollXPCClientProtocol) -> Void) {
|
||||||
|
var staleIdentifiers: [ObjectIdentifier] = []
|
||||||
|
|
||||||
|
for (identifier, context) in clientContexts where context.bundleIdentifier == bundleIdentifier {
|
||||||
|
guard let connection = context.connection else {
|
||||||
|
staleIdentifiers.append(identifier)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let client = connection.remoteObjectProxyWithErrorHandler({ error in
|
||||||
|
Logger.log("Failed to deliver XPC callback to \(bundleIdentifier): \(error)", category: .extensions)
|
||||||
|
}) as? AtollXPCClientProtocol else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
block(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
staleIdentifiers.forEach { clientContexts.removeValue(forKey: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeConnection(_ connection: NSXPCConnection) {
|
||||||
|
clientContexts.removeValue(forKey: ObjectIdentifier(connection))
|
||||||
|
Logger.log("Removed XPC connection", category: .extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ enum LogCategory: String {
|
||||||
case warning = "⚠️"
|
case warning = "⚠️"
|
||||||
case success = "✅"
|
case success = "✅"
|
||||||
case debug = "🔍"
|
case debug = "🔍"
|
||||||
|
case extensions = "🧩"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Logger {
|
struct Logger {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue