Merge pull request #256 from Ebullioscopic/feature/extensions

Feature/extensions
This commit is contained in:
Hariharan Mudaliar 2026-01-22 16:20:12 +05:30 • committed by GitHub
commit c42c4509d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 6012 additions and 121 deletions

View file

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7E61181E2E24071E0050006C /* SkyLightWindow */; };
7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7E288D112F1659EF0020C117 /* AtollExtensionKit */; };
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; };
7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; };
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; };
@ -26,6 +27,7 @@
7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -41,6 +43,7 @@
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = "<group>"; };
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtensionEventBridge.swift; path = DynamicIsland/managers/Extensions/ExtensionEventBridge.swift; sourceTree = SOURCE_ROOT; };
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -68,6 +71,7 @@
7E6118172E24071E0050006C /* Collections in Frameworks */,
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */,
7E6118152E24071E0050006C /* Sparkle in Frameworks */,
7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */,
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -75,6 +79,14 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
7E07AFF12F181892003A8B1B /* Recovered References */ = {
isa = PBXGroup;
children = (
7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
isa = PBXGroup;
children = (
@ -117,6 +129,7 @@
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
F54406E82CD4987A00C7AFD9 /* Products */,
7E07AFF12F181892003A8B1B /* Recovered References */,
);
sourceTree = "<group>";
};
@ -157,6 +170,7 @@
B194240A2CD10017003E5DC2 /* LottieUI */,
146BC4362D366E9B0047C66A /* MacroVisionKit */,
7E61181E2E24071E0050006C /* SkyLightWindow */,
7E288D112F1659EF0020C117 /* AtollExtensionKit */,
);
productName = DynamicIsland;
productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */;
@ -201,6 +215,7 @@
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
@ -235,6 +250,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -557,6 +573,14 @@
minimumVersion = 1.4.16;
};
};
7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Ebullioscopic/AtollExtensionKit";
requirement = {
branch = main;
kind = branch;
};
};
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Lakr233/SkyLightWindow";
@ -631,6 +655,11 @@
package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
7E288D112F1659EF0020C117 /* AtollExtensionKit */ = {
isa = XCSwiftPackageProductDependency;
package = 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */;
productName = AtollExtensionKit;
};
7E61181E2E24071E0050006C /* SkyLightWindow */ = {
isa = XCSwiftPackageProductDependency;
package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */;

View file

@ -1,6 +1,15 @@
{
"originHash" : "3b2c9100f16643012ece44ae6bfed9555c2d858d8ac2f98547c70e4c1738f0f6",
"originHash" : "6fbfa8cda0c383746edd66361426fdc2bd266fc082867294374a1818f02798f3",
"pins" : [
{
"identity" : "atollextensionkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
"state" : {
"branch" : "main",
"revision" : "ebe35277572a349fad26abeb6fc552cb5251d584"
}
},
{
"identity" : "defaults",
"kind" : "remoteSourceControl",

File diff suppressed because it is too large Load diff

View file

@ -91,6 +91,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection
let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel
let systemTimerBridge = SystemTimerBridge.shared
let extensionXPCServiceHost = ExtensionXPCServiceHost.shared
var closeNotchWorkItem: DispatchWorkItem?
private var previousScreens: [NSScreen]?
private var onboardingWindowController: NSWindowController?
@ -132,6 +133,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Cancel any pending window size updates
windowSizeUpdateWorkItem?.cancel()
NotificationCenter.default.removeObserver(self)
extensionXPCServiceHost.stop()
}
@objc func onScreenLocked(_: Notification) {
@ -340,6 +342,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm)
LockScreenManager.shared.configure(viewModel: vm)
extensionXPCServiceHost.start()
// Migrate legacy progress bar settings
Defaults.Keys.migrateProgressBarStyle()

View file

@ -9,7 +9,7 @@ import Combine
import Defaults
import SwiftUI
enum SneakContentType {
enum SneakContentType: Equatable {
case brightness
case volume
case backlight
@ -25,6 +25,43 @@ enum SneakContentType {
case privacy
case lockScreen
case capsLock
case extensionLiveActivity(bundleID: String, activityID: String)
}
extension SneakContentType {
static func == (lhs: SneakContentType, rhs: SneakContentType) -> Bool {
switch (lhs, rhs) {
case (.brightness, .brightness),
(.volume, .volume),
(.backlight, .backlight),
(.music, .music),
(.mic, .mic),
(.battery, .battery),
(.download, .download),
(.timer, .timer),
(.reminder, .reminder),
(.recording, .recording),
(.doNotDisturb, .doNotDisturb),
(.bluetoothAudio, .bluetoothAudio),
(.privacy, .privacy),
(.lockScreen, .lockScreen),
(.capsLock, .capsLock):
return true
case let (.extensionLiveActivity(lb, la), .extensionLiveActivity(rb, ra)):
return lb == rb && la == ra
default:
return false
}
}
}
extension SneakContentType {
var isExtensionPayload: Bool {
if case .extensionLiveActivity = self {
return true
}
return false
}
}
struct sneakPeek {
@ -32,6 +69,10 @@ struct sneakPeek {
var type: SneakContentType = .music
var value: CGFloat = 0
var icon: String = ""
var title: String = ""
var subtitle: String = ""
var accentColor: Color?
var styleOverride: SneakPeekStyle? = nil
}
enum BrowserType {
@ -65,6 +106,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
private let statsSecondRowRevealDelay: TimeInterval = 0.5
private let statsSecondRowAnimationDuration: TimeInterval = 0.3
@Published var notesLayoutState: NotesLayoutState = .list
@Published var selectedExtensionExperienceID: String?
@AppStorage("firstLaunch") var firstLaunch: Bool = true
@ -106,6 +148,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
@Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown"
@Published var optionKeyPressed: Bool = true
private let extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
private init() {
selectedScreen = preferredScreen
@ -129,6 +172,36 @@ class DynamicIslandViewCoordinator: ObservableObject {
self?.handleMinimalisticModeChange(change.newValue)
}
.store(in: &cancellables)
extensionNotchExperienceManager.$activeExperiences
.receive(on: DispatchQueue.main)
.sink { [weak self] experiences in
self?.handleExtensionExperienceSnapshot(experiences)
}
.store(in: &cancellables)
Defaults.publisher(.enableThirdPartyExtensions)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleExtensionFeatureToggle()
}
.store(in: &cancellables)
Defaults.publisher(.enableExtensionNotchExperiences)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleExtensionFeatureToggle()
}
.store(in: &cancellables)
Defaults.publisher(.enableExtensionNotchTabs)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleExtensionFeatureToggle()
}
.store(in: &cancellables)
handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences)
}
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
@ -174,20 +247,80 @@ class DynamicIslandViewCoordinator: ObservableObject {
}
}
}
private func handleExtensionExperienceSnapshot(_ experiences: [ExtensionNotchExperiencePayload]) {
guard extensionTabsAllowed else {
selectedExtensionExperienceID = nil
resetExtensionViewIfNeeded()
return
}
let tabCapablePayloads = experiences.filter { $0.descriptor.tab != nil }
guard !tabCapablePayloads.isEmpty else {
selectedExtensionExperienceID = nil
resetExtensionViewIfNeeded()
return
}
if let currentID = selectedExtensionExperienceID,
tabCapablePayloads.contains(where: { $0.descriptor.id == currentID }) {
return
}
selectedExtensionExperienceID = tabCapablePayloads.first?.descriptor.id
}
private func handleExtensionFeatureToggle() {
handleExtensionExperienceSnapshot(extensionNotchExperienceManager.activeExperiences)
}
private func resetExtensionViewIfNeeded() {
guard currentView == .extensionExperience else { return }
withAnimation(.smooth) {
currentView = .home
}
}
private var extensionTabsAllowed: Bool {
Defaults[.enableThirdPartyExtensions]
&& Defaults[.enableExtensionNotchExperiences]
&& Defaults[.enableExtensionNotchTabs]
}
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
func toggleSneakPeek(
status: Bool,
type: SneakContentType,
duration: TimeInterval = 1.5,
value: CGFloat = 0,
icon: String = "",
title: String = "",
subtitle: String = "",
accentColor: Color? = nil,
styleOverride: SneakPeekStyle? = nil
) {
let resolvedDuration: TimeInterval
switch type {
case .timer:
resolvedDuration = 10
case .reminder:
resolvedDuration = Defaults[.reminderSneakPeekDuration]
case .extensionLiveActivity:
resolvedDuration = duration
default:
resolvedDuration = duration
}
sneakPeekDuration = resolvedDuration
let bypassedTypes: [SneakContentType] = [.music, .timer, .reminder, .bluetoothAudio]
if !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] {
// Check if it's an extension type
let isExtensionType: Bool
if case .extensionLiveActivity = type {
isExtensionType = true
} else {
isExtensionType = false
}
if !isExtensionType && !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] {
return
}
DispatchQueue.main.async {
@ -196,6 +329,10 @@ class DynamicIslandViewCoordinator: ObservableObject {
self.sneakPeek.type = type
self.sneakPeek.value = value
self.sneakPeek.icon = icon
self.sneakPeek.title = title
self.sneakPeek.subtitle = subtitle
self.sneakPeek.accentColor = accentColor
self.sneakPeek.styleOverride = styleOverride
}
}
}

View file

@ -765,6 +765,10 @@
"comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.",
"isCommentAutoGenerated" : true
},
"%@:" : {
"comment" : "A row of text with a label and its corresponding value. The argument is the label text, and the second is the value text.",
"isCommentAutoGenerated" : true
},
"%@°" : {
"comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).",
"isCommentAutoGenerated" : true
@ -872,6 +876,18 @@
"%lld" : {
"shouldTranslate" : false
},
"%lld %@" : {
"comment" : "A text label showing the number of apps with which the current app has granted permissions. The argument is the count of such apps.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld %2$@"
}
}
}
},
"%lld animations" : {
"comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.",
"isCommentAutoGenerated" : true,
@ -2853,6 +2869,10 @@
}
}
},
"Allowed Features" : {
"comment" : "A label describing the allowed features for an app extension.",
"isCommentAutoGenerated" : true
},
"ALPHA" : {
"comment" : "A label indicating a feature that is still in development.",
"isCommentAutoGenerated" : true
@ -3200,6 +3220,10 @@
}
}
},
"App Permissions" : {
"comment" : "A section header in the settings view that lists the permissions of installed apps.",
"isCommentAutoGenerated" : true
},
"Appearance" : {
"localizations" : {
"ar" : {
@ -3302,6 +3326,10 @@
},
"Applies the notch-style parallax effect to the lock screen media widget album art." : {
},
"Apps using AtollExtensionKit will appear here once they request permission" : {
"comment" : "A description below the placeholder text in the \"Search extensions...\" text field, explaining that apps using AtollExtensionKit will be listed here once they grant permission to use the app.",
"isCommentAutoGenerated" : true
},
"Are you sure you want to delete \"%@\"?" : {
"comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.",
@ -3354,6 +3382,14 @@
}
}
},
"Authorize" : {
"comment" : "A button that authorizes an app extension.",
"isCommentAutoGenerated" : true
},
"Authorized" : {
"comment" : "A label indicating that an app has been granted permission to use AtollExtensionKit.",
"isCommentAutoGenerated" : true
},
"Auto-scroll to next event" : {
"comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.",
"isCommentAutoGenerated" : true
@ -5121,6 +5157,9 @@
}
}
}
},
"Choose the global material mode for lock screen widgets. Custom Liquid unlocks per-widget variant sliders while Standard sticks to the classic frosted/liquid options." : {
},
"Choose which service to use when sharing files from the shelf. Drag files onto the shelf or click the shelf button to pick files." : {
"comment" : "A footer description under the Quick Share section of the Shelf settings.",
@ -5256,6 +5295,10 @@
}
}
},
"Classic mode keeps the original translucent black background." : {
"comment" : "A description of the \"Classic mode\" option in the Lock Screen Integration settings.",
"isCommentAutoGenerated" : true
},
"Clear" : {
"extractionState" : "stale",
"localizations" : {
@ -5775,6 +5818,9 @@
},
"Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." : {
},
"Configure lock screen materials from the Appearance tab. Custom Liquid unlocks variant sliders for both widgets whenever Liquid Glass is selected." : {
},
"Configure presets in Settings" : {
"comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.",
@ -5915,9 +5961,8 @@
"Controls how often system metrics refresh while monitoring is active." : {
},
"Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view." : {
"comment" : "A footer for the \"Timer Widget\" section in the Lock Screen Settings view.",
"isCommentAutoGenerated" : true
"Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting." : {
},
"Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes." : {
@ -6175,6 +6220,13 @@
}
}
}
},
"Custom liquid glass already renders with Apple's liquid material, so this option is managed automatically." : {
"comment" : "A description of the custom liquid blur option in the media settings.",
"isCommentAutoGenerated" : true
},
"Custom Liquid settings require the Liquid Glass material." : {
},
"Custom music live activity animation" : {
"localizations" : {
@ -6782,6 +6834,14 @@
}
}
},
"Denied/Revoked" : {
"comment" : "A label describing an extension with denied or revoked permissions.",
"isCommentAutoGenerated" : true
},
"Deny" : {
"comment" : "A button that denies an app extension's access.",
"isCommentAutoGenerated" : true
},
"Description" : {
"extractionState" : "stale",
"localizations" : {
@ -7926,6 +7986,10 @@
"comment" : "A toggle that allows users to enable or disable customizable controls for media playback.",
"isCommentAutoGenerated" : true
},
"Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll." : {
"comment" : "A footer text that explains the purpose of enabling extensions.",
"isCommentAutoGenerated" : true
},
"Enable global keyboard shortcuts above to customize your shortcuts." : {
},
@ -8729,8 +8793,11 @@
"comment" : "A label describing the expanded notch width setting.",
"isCommentAutoGenerated" : true
},
"Extension tab unavailable" : {
"comment" : "A message displayed when the extension tab is not available.",
"isCommentAutoGenerated" : true
},
"Extensions" : {
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@ -9505,6 +9572,13 @@
}
}
},
"Glass mode" : {
},
"Global Settings" : {
"comment" : "The header for the section that controls global settings related to extensions.",
"isCommentAutoGenerated" : true
},
"Got it!" : {
"localizations" : {
"ar" : {
@ -10859,6 +10933,10 @@
"comment" : "A help text explaining why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.",
"isCommentAutoGenerated" : true
},
"Last Denied Reason:" : {
"comment" : "A label for the \"Last Denied Reason\" section in the extensions settings view.",
"isCommentAutoGenerated" : true
},
"Last Updated" : {
"localizations" : {
"fr" : {
@ -11078,6 +11156,12 @@
}
}
}
},
"Lock Screen Glass" : {
},
"Lock screen glass mode" : {
},
"Lock Screen Integration" : {
@ -11101,6 +11185,10 @@
"comment" : "A section header for the lock screen reminder widget settings.",
"isCommentAutoGenerated" : true
},
"Lock Screen Widgets" : {
"comment" : "A toggle that enables or disables the use of lock screen widgets by the extension.",
"isCommentAutoGenerated" : true
},
"Loop Mode" : {
},
@ -13181,6 +13269,9 @@
"Move Up" : {
"comment" : "A button label that says \"Move Up\".",
"isCommentAutoGenerated" : true
},
"Music panel variant" : {
},
"Music Source" : {
"localizations" : {
@ -14193,6 +14284,10 @@
}
}
},
"No extensions yet" : {
"comment" : "A message displayed when there are no extensions that have requested permission.",
"isCommentAutoGenerated" : true
},
"No favorites" : {
"localizations" : {
"fr" : {
@ -14657,6 +14752,10 @@
}
}
},
"Notch Experiences" : {
"comment" : "A toggle that enables or disables the \"Notch Experiences\" feature for a specific app.",
"isCommentAutoGenerated" : true
},
"Notch Height" : {
"localizations" : {
"ar" : {
@ -15045,6 +15144,10 @@
},
"Pause" : {
},
"Pending" : {
"comment" : "A label indicating that a user's permission to access a feature is being checked.",
"isCommentAutoGenerated" : true
},
"Percentage" : {
"extractionState" : "stale",
@ -15057,11 +15160,18 @@
}
}
},
"Permission States:" : {
"comment" : "A label describing the different permission states displayed in the footer of the extensions settings view.",
"isCommentAutoGenerated" : true
},
"Permissions" : {
},
"Pick Color" : {
},
"Pick per-widget liquid-glass variants below. Changes mirror the Lock Screen tab." : {
},
"Picked %@" : {
@ -15602,6 +15712,10 @@
}
}
},
"Re-authorize" : {
"comment" : "A button that re-authorizes an app when pressed.",
"isCommentAutoGenerated" : true
},
"Read" : {
"comment" : "A heading displayed for the user's disk read speed.",
"isCommentAutoGenerated" : true,
@ -15634,6 +15748,10 @@
}
}
},
"Recent Activity (last 5 minutes)" : {
"comment" : "A label describing the recent activity of a particular extension.",
"isCommentAutoGenerated" : true
},
"Recent Colors" : {
"localizations" : {
"fr" : {
@ -15889,6 +16007,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, lock screen widgets, and notch experiences from this app." : {
"comment" : "A message presented when a user confirms they want to remove an extension from the authorized list. The argument is the name of the extension.",
"isCommentAutoGenerated" : true
},
"Remove Extension" : {
},
"Remove from shelf after dragging" : {
@ -15923,6 +16052,22 @@
}
}
},
"Reset Live Activities" : {
"comment" : "A menu item that resets live activities for a particular app.",
"isCommentAutoGenerated" : true
},
"Reset Lock Screen Widgets" : {
"comment" : "A menu item that resets all lock screen widgets for a particular app.",
"isCommentAutoGenerated" : true
},
"Reset Notch Experiences" : {
"comment" : "A menu item that resets all notch experiences for a particular app.",
"isCommentAutoGenerated" : true
},
"Reset Rate Limits" : {
"comment" : "A button that resets the rate limits for a particular extension.",
"isCommentAutoGenerated" : true
},
"Reset to Default" : {
"localizations" : {
"fr" : {
@ -16074,6 +16219,10 @@
}
}
},
"Revoke Access" : {
"comment" : "A button that revokes access to an app.",
"isCommentAutoGenerated" : true
},
"Right" : {
},
@ -16319,6 +16468,10 @@
}
}
},
"Search extensions..." : {
"comment" : "A placeholder text for a text field used to filter through a list of extensions.",
"isCommentAutoGenerated" : true
},
"Search notes..." : {
"comment" : "A placeholder text for a search bar in the note list view.",
"isCommentAutoGenerated" : true
@ -18450,6 +18603,10 @@
}
}
},
"Status:" : {
"comment" : "A label for the status of an extension.",
"isCommentAutoGenerated" : true
},
"Stop" : {
"localizations" : {
"fr" : {
@ -18869,6 +19026,10 @@
},
"These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : {
},
"Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below." : {
"comment" : "A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view.",
"isCommentAutoGenerated" : true
},
"This duration powers the \"Custom\" option inside the timer popover for quick access." : {
"comment" : "A footer description for the custom timer settings, explaining their purpose.",
@ -19036,6 +19197,14 @@
}
}
},
"Timer glass material" : {
"comment" : "A label displayed above a picker for selecting the material of the timer glass.",
"isCommentAutoGenerated" : true
},
"Timer liquid mode" : {
"comment" : "A segment control that lets the user choose between different customization modes for the timer glass.",
"isCommentAutoGenerated" : true
},
"Timer Presets" : {
"comment" : "A section header in the timer settings view, describing timer presets.",
"isCommentAutoGenerated" : true
@ -19050,12 +19219,19 @@
}
}
},
"Timer surface" : {
"comment" : "A label displayed above the user's timer surface.",
"isCommentAutoGenerated" : true
},
"Timer tint" : {
},
"Timer Widget" : {
"comment" : "A section header for the timer widget settings.",
"isCommentAutoGenerated" : true
},
"Timer widget variant" : {
},
"Title" : {
"comment" : "A label for the title input field in the note editor.",
@ -19996,6 +20172,9 @@
"Use the Media tab to configure sneak peek, lyrics, and floating media controls." : {
"comment" : "A footer for the \"Media Live Activity\" section, explaining how to configure the media controls.",
"isCommentAutoGenerated" : true
},
"Use the sliders below to pick unique Apple liquid-glass variants for each widget." : {
},
"Used" : {
"comment" : "A label describing the percentage of memory that is currently in use.",
@ -20023,6 +20202,10 @@
},
"Uses event-driven private API for real-time screen recording detection" : {
},
"Uses the frosted blur treatment while glass mode is enabled." : {
"comment" : "A description of the frosted blur treatment when glass mode is enabled.",
"isCommentAutoGenerated" : true
},
"Utilization" : {
"comment" : "A label displayed alongside the utilization percentage in the GPU detail view.",
@ -20035,6 +20218,9 @@
}
}
}
},
"v%lld" : {
},
"Version" : {
"localizations" : {
@ -20610,6 +20796,10 @@
},
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
},
"Widget Updates" : {
"comment" : "A label describing the number of times the extension has updated its widget.",
"isCommentAutoGenerated" : true
},
"Width:" : {
"extractionState" : "stale",

View file

@ -0,0 +1,379 @@
import SwiftUI
import Defaults
import AtollExtensionKit
struct ExtensionStandaloneLayout {
let totalWidth: CGFloat
let outerHeight: CGFloat
let contentHeight: CGFloat
let leadingWidth: CGFloat
let centerWidth: CGFloat
let trailingWidth: CGFloat
}
struct ExtensionLiveActivityStandaloneView: View {
let payload: ExtensionLiveActivityPayload
let layout: ExtensionStandaloneLayout
let isHovering: Bool
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var contentHeight: CGFloat { layout.contentHeight }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var resolvedLeadingContent: AtollTrailingContent {
resolvedExtensionLeadingContent(for: descriptor)
}
var body: some View {
HStack(spacing: 0) {
ExtensionLeadingContentView(
content: resolvedLeadingContent,
badge: descriptor.badgeIcon,
accent: accentColor,
frameWidth: layout.leadingWidth,
frameHeight: contentHeight,
defaultIcon: descriptor.leadingIcon
)
.frame(width: layout.leadingWidth, height: contentHeight)
Rectangle()
.fill(Color.black)
.frame(width: layout.centerWidth, height: contentHeight)
.overlay(EmptyView())
ExtensionMusicWingView(
payload: payload,
notchHeight: contentHeight,
trailingWidth: layout.trailingWidth
)
.frame(width: layout.trailingWidth, height: contentHeight)
}
.frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0))
.transition(
.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)),
removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9))
)
)
.animation(.smooth(duration: 0.25), value: payload.id)
.onAppear {
logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view")
}
.onDisappear {
logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) standalone view")
}
}
}
struct ExtensionMusicWingView: View {
let payload: ExtensionLiveActivityPayload
let notchHeight: CGFloat
let trailingWidth: CGFloat
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var trailingRenderable: ExtensionTrailingRenderable {
resolvedExtensionTrailingRenderable(for: descriptor)
}
var body: some View {
VStack(alignment: .trailing, spacing: 6) {
switch trailingRenderable {
case let .content(content):
if case .none = content {
Spacer(minLength: 0)
} else {
ExtensionEdgeContentView(
content: content,
accent: accentColor,
availableWidth: trailingWidth,
alignment: .trailing
)
.frame(maxWidth: .infinity, alignment: .trailing)
}
case let .indicator(indicator):
ExtensionProgressIndicatorView(
indicator: indicator,
progress: descriptor.progress,
accent: accentColor,
estimatedDuration: descriptor.estimatedDuration,
maxVisualHeight: notchHeight
)
.frame(maxWidth: .infinity, alignment: .trailing)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(.trailing, 8)
.padding(.vertical, 6)
.onAppear {
logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) within music wing")
}
.onDisappear {
logExtensionDiagnostics("Hid extension live activity \(payload.descriptor.id) from music wing")
}
}
}
struct ExtensionLeadingContentView: View {
let content: AtollTrailingContent
let badge: AtollIconDescriptor?
let accent: Color
let frameWidth: CGFloat
let frameHeight: CGFloat
let defaultIcon: AtollIconDescriptor
var body: some View {
Group {
switch content {
case let .icon(iconDescriptor):
ExtensionCompositeIconView(
leading: iconDescriptor,
badge: badge,
accent: accent,
size: frameHeight
)
case let .animation(data, size):
let resolvedSize = CGSize(
width: min(size.width, frameHeight),
height: min(size.height, frameHeight)
)
ExtensionLottieView(data: data, size: resolvedSize)
.frame(width: frameHeight, height: frameHeight)
.background(
RoundedRectangle(cornerRadius: frameHeight * 0.18, style: .continuous)
.fill(Color.white.opacity(0.08))
)
default:
ExtensionCompositeIconView(
leading: defaultIcon,
badge: badge,
accent: accent,
size: frameHeight
)
}
}
.frame(width: frameWidth, height: frameHeight)
}
}
private func logExtensionDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
struct ExtensionNotchExperienceTabView: View {
let payload: ExtensionNotchExperiencePayload
@Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled
private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor }
private var tabConfiguration: AtollNotchExperienceDescriptor.TabConfiguration? { descriptor.tab }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var allowInteractiveWebViews: Bool {
interactiveWebViewsEnabled && (tabConfiguration?.allowWebInteraction ?? false)
}
var body: some View {
Group {
if let tabConfiguration {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
header(for: tabConfiguration)
ForEach(Array(tabConfiguration.sections.enumerated()), id: \.offset) { index, section in
ExtensionNotchSectionView(
section: section,
accent: accentColor,
allowWebInteraction: allowInteractiveWebViews
)
.accessibilityIdentifier("extension-notch-section-\(payload.descriptor.id)-\(index)")
}
if let webDescriptor = tabConfiguration.webContent {
ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowInteractiveWebViews)
.frame(height: webDescriptor.preferredHeight)
.frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
if let footnote = tabConfiguration.footnote {
Text(footnote)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(Color.white.opacity(0.65))
.lineLimit(2)
}
}
.padding(.vertical, 14)
.padding(.horizontal, 16)
}
} else {
Text("Extension tab unavailable")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(tabBackground)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
}
@ViewBuilder
private func header(for configuration: AtollNotchExperienceDescriptor.TabConfiguration) -> some View {
HStack(spacing: 10) {
Group {
if let badgeIcon = configuration.badgeIcon {
ExtensionIconView(
descriptor: badgeIcon,
tint: accentColor,
size: CGSize(width: 32, height: 32),
cornerRadius: 10
)
} else {
Image(systemName: configuration.iconSymbolName ?? "puzzlepiece.extension")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(accentColor.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
VStack(alignment: .leading, spacing: 2) {
Text(configuration.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
private var tabBackground: some View {
AnyView(
LinearGradient(
colors: [Color.white.opacity(0.04), accentColor.opacity(0.08)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
struct ExtensionNotchSectionView: View {
let section: AtollNotchContentSection
let accent: Color
let allowWebInteraction: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ExtensionNotchSectionHeader(section: section)
layoutContent
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ViewBuilder
private var layoutContent: some View {
switch section.layout {
case .stack:
VStack(alignment: .leading, spacing: 10) {
elementViews
}
case .columns:
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 12) {
elementViews
}
case .metrics:
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
elementViews
}
}
}
@ViewBuilder
private var elementViews: some View {
ForEach(Array(section.elements.enumerated()), id: \.offset) { index, element in
ExtensionWidgetElementView(
element: element,
accent: accent,
allowWebInteraction: allowWebInteraction
)
.accessibilityIdentifier("extension-notch-element-\(index)")
}
}
}
struct ExtensionNotchSectionHeader: View {
let section: AtollNotchContentSection
var body: some View {
VStack(alignment: .leading, spacing: 2) {
if let title = section.title {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
}
if let subtitle = section.subtitle {
Text(subtitle)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(Color.white.opacity(0.7))
}
}
}
}
struct ExtensionMinimalisticExperienceView: View {
let payload: ExtensionNotchExperiencePayload
let albumArtNamespace: Namespace.ID
@Default(.enableExtensionNotchInteractiveWebViews) private var interactiveWebViewsEnabled
private var descriptor: AtollNotchExperienceDescriptor { payload.descriptor }
private var configuration: AtollNotchExperienceDescriptor.MinimalisticConfiguration? { descriptor.minimalistic }
private var accent: Color { descriptor.accentColor.swiftUIColor }
var body: some View {
Group {
if let configuration {
let hasWebContent = configuration.webContent != nil
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
if let headline = configuration.headline {
Text(headline)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
if let subtitle = configuration.subtitle {
Text(subtitle)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(Color.white.opacity(0.75))
}
ForEach(Array(configuration.sections.enumerated()), id: \.offset) { index, section in
ExtensionNotchSectionView(
section: section,
accent: accent,
allowWebInteraction: interactiveWebViewsEnabled
)
.accessibilityIdentifier("extension-minimalistic-section-\(payload.descriptor.id)-\(index)")
}
if let webDescriptor = configuration.webContent {
ExtensionWebContentView(
descriptor: webDescriptor,
allowInteraction: interactiveWebViewsEnabled
)
.frame(height: webDescriptor.preferredHeight)
.frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, hasWebContent ? 0 : 10)
}
} else {
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
}
}
}
}

View file

@ -0,0 +1,452 @@
import SwiftUI
import Defaults
import AtollExtensionKit
import WebKit
import AppKit
struct ExtensionLockScreenWidgetView: View {
let payload: ExtensionLockScreenWidgetPayload
private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
private var appearance: AtollWidgetAppearanceOptions? { descriptor.appearance }
var body: some View {
let shape = RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
ZStack {
backgroundLayer(shape: shape)
contentView
.padding(resolvedContentInsets)
}
.frame(width: descriptor.size.width, height: descriptor.size.height)
.clipShape(shape)
.overlay(borderOverlay(shape: shape))
.shadow(color: shadowColor, radius: shadowRadius, x: shadowOffset.width, y: shadowOffset.height)
.onAppear {
logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)")
}
.onDisappear {
logWidgetDiagnostics("Lock screen widget \(payload.descriptor.id) removed")
}
}
@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)")
}
}
}
}
private func view(for element: AtollWidgetContentElement) -> some View {
ExtensionWidgetElementView(
element: element,
accent: accentColor,
allowWebInteraction: false
)
}
private func alignment(for element: AtollWidgetContentElement) -> Alignment {
switch element {
case let .text(_, _, _, alignment):
return alignment.swiftUI
default:
return .leading
}
}
private var resolvedContentInsets: EdgeInsets {
if let custom = appearance?.contentInsets {
return EdgeInsets(top: custom.top, leading: custom.leading, bottom: custom.bottom, trailing: custom.trailing)
}
switch descriptor.layoutStyle {
case .circular:
return EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
default:
return EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
}
}
@ViewBuilder
private func backgroundLayer(shape: RoundedRectangle) -> some View {
if descriptor.material == .liquid {
liquidGlassBackground(shape: shape)
} else if shouldUseGlassHighlight {
if #available(macOS 26.0, *) {
ZStack {
shape
.fill(Color.clear)
.glassEffect(
.clear.interactive(),
in: .rect(cornerRadius: descriptor.cornerRadius)
)
.allowsHitTesting(false)
.accessibilityHidden(true)
tintOverlay(shape: shape)
}
} else {
ZStack {
shape.fill(AnyShapeStyle(.regularMaterial))
tintOverlay(shape: shape)
}
}
} else {
ZStack {
shape.fill(baseMaterialStyle)
tintOverlay(shape: shape)
}
}
}
@ViewBuilder
private func liquidGlassBackground(shape: RoundedRectangle) -> some View {
LiquidGlassBackground(variant: requestedLiquidGlassVariant, cornerRadius: descriptor.cornerRadius) {
Color.black.opacity(0.08)
}
.overlay {
tintOverlay(shape: shape)
}
.allowsHitTesting(false)
.accessibilityHidden(true)
}
private var baseMaterialStyle: AnyShapeStyle {
switch descriptor.material {
case .frosted:
return AnyShapeStyle(.ultraThinMaterial)
case .liquid:
return AnyShapeStyle(.regularMaterial)
case .solid:
return AnyShapeStyle(resolvedSolidFillColor.opacity(0.95))
case .semiTransparent:
return AnyShapeStyle(resolvedSolidFillColor.opacity(0.35))
case .clear:
return AnyShapeStyle(Color.clear)
}
}
private var resolvedSolidFillColor: Color {
if let tint = appearance?.tintColor?.swiftUIColor {
return tint
}
return accentColor
}
@ViewBuilder
private func tintOverlay(shape: RoundedRectangle) -> some View {
Group {
if shouldApplyTintOverlay, let overlay = tintedOverlayColor {
shape
.fill(overlay)
.allowsHitTesting(false)
}
}
}
private var tintedOverlayColor: Color? {
guard let descriptor = appearance, let tint = descriptor.tintColor, descriptor.tintOpacity > 0 else {
return nil
}
return tint.swiftUIColor.opacity(descriptor.tintOpacity)
}
private var shouldApplyTintOverlay: Bool {
guard appearance?.tintColor != nil else { return false }
switch descriptor.material {
case .solid, .semiTransparent:
return false
default:
return true
}
}
private var shouldUseGlassHighlight: Bool {
(appearance?.enableGlassHighlight ?? false) && descriptor.material != .liquid
}
private var requestedLiquidGlassVariant: LiquidGlassVariant {
guard let requested = appearance?.liquidGlassVariant?.rawValue else {
return LiquidGlassVariant.defaultVariant
}
return LiquidGlassVariant.clamped(requested)
}
@ViewBuilder
private func borderOverlay(shape: RoundedRectangle) -> some View {
if let border = appearance?.border {
shape
.stroke(border.color.swiftUIColor.opacity(border.opacity), lineWidth: border.width)
} else {
shape
.stroke(Color.white.opacity(0.04), lineWidth: 1)
}
}
private var shadowColor: Color {
guard let shadow = appearance?.shadow else { return .clear }
return shadow.color.swiftUIColor.opacity(shadow.opacity)
}
private var shadowRadius: CGFloat {
appearance?.shadow?.radius ?? 0
}
private var shadowOffset: CGSize {
appearance?.shadow?.offset ?? .zero
}
}
private func logWidgetDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
struct ExtensionWidgetElementView: View {
let element: AtollWidgetContentElement
let accent: Color
let allowWebInteraction: Bool
var body: some View {
switch element {
case let .text(text, font: font, color: color, alignment: _):
Text(text)
.font(font.swiftUIFont())
.foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9))
.lineLimit(2)
case let .icon(iconDescriptor, tint):
ExtensionIconView(
descriptor: iconDescriptor,
tint: (tint?.swiftUIColor) ?? accent,
size: CGSize(width: 28, height: 28),
cornerRadius: 8
)
case let .progress(indicator, value, color):
ExtensionProgressIndicatorView(
indicator: indicator,
progress: value,
accent: (color?.swiftUIColor) ?? accent,
estimatedDuration: nil
)
case let .graph(data, color, size):
ExtensionGraphView(data: data, color: color.swiftUIColor, size: size)
.frame(width: size.width, height: size.height)
case let .gauge(value, minValue, maxValue, style, color):
ExtensionGaugeView(
value: value,
minValue: minValue,
maxValue: maxValue,
style: style,
accent: (color?.swiftUIColor) ?? accent
)
.frame(maxWidth: .infinity)
case let .spacer(height):
Color.clear
.frame(height: height)
case let .divider(color, thickness):
Rectangle()
.fill(color.swiftUIColor.opacity(0.4))
.frame(height: thickness)
case let .webView(webDescriptor):
ExtensionWebContentView(descriptor: webDescriptor, allowInteraction: allowWebInteraction)
.frame(height: webDescriptor.preferredHeight)
.frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity)
}
}
}
struct ExtensionWebContentView: NSViewRepresentable {
let descriptor: AtollWidgetWebContentDescriptor
let allowInteraction: Bool
func makeCoordinator() -> Coordinator {
Coordinator(descriptor: descriptor)
}
func makeNSView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.suppressesIncrementalRendering = false
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
let webView = ConfigurableWKWebView(frame: .zero, configuration: configuration)
webView.allowInteraction = allowInteraction
webView.navigationDelegate = context.coordinator
webView.wantsLayer = true
applyConfiguration(descriptor, to: webView)
context.coordinator.lastHTML = descriptor.html
webView.loadHTMLString(descriptor.html, baseURL: nil)
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
context.coordinator.descriptor = descriptor
webView.navigationDelegate = context.coordinator
applyConfiguration(descriptor, to: webView)
if let configurableView = webView as? ConfigurableWKWebView {
configurableView.allowInteraction = allowInteraction
}
if context.coordinator.lastHTML != descriptor.html {
webView.loadHTMLString(descriptor.html, baseURL: nil)
context.coordinator.lastHTML = descriptor.html
}
}
private func applyConfiguration(_ descriptor: AtollWidgetWebContentDescriptor, to webView: WKWebView) {
webView.allowsBackForwardNavigationGestures = false
webView.allowsLinkPreview = false
if descriptor.isTransparent {
webView.setValue(false, forKey: "drawsBackground")
webView.layer?.backgroundColor = NSColor.clear.cgColor
} else {
let fallbackColor = descriptor.backgroundColor?.nsColor ?? NSColor.windowBackgroundColor
webView.layer?.backgroundColor = fallbackColor.cgColor
}
}
private final class ConfigurableWKWebView: WKWebView {
var allowInteraction = false
override func hitTest(_ point: NSPoint) -> NSView? {
guard allowInteraction else { return nil }
return super.hitTest(point)
}
override var acceptsFirstResponder: Bool {
allowInteraction
}
}
final class Coordinator: NSObject, WKNavigationDelegate {
var descriptor: AtollWidgetWebContentDescriptor
var lastHTML: String?
init(descriptor: AtollWidgetWebContentDescriptor) {
self.descriptor = descriptor
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if isAllowed(url: url) {
decisionHandler(.allow)
} else {
logWidgetDiagnostics("Blocked external navigation to \(url.absoluteString)")
decisionHandler(.cancel)
}
}
private func isAllowed(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if scheme == "about" || scheme == "data" {
return true
}
if (scheme == "http" || scheme == "https") {
guard descriptor.allowLocalhostRequests else {
return false
}
let host = url.host?.lowercased()
return host == "localhost" || host == "127.0.0.1"
}
return false
}
}
}
private struct ExtensionGraphView: View {
let data: [Double]
let color: Color
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
}
}
}

View file

@ -0,0 +1,561 @@
import SwiftUI
import AppKit
import AtollExtensionKit
import Lottie
import LottieUI
import CryptoKit
// 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)
}
func resolvedColor(fallback accent: Color) -> Color {
isAccent ? accent : swiftUIColor
}
}
// 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 let .lottie(animationData, targetSize):
let resolvedSize = CGSize(width: min(targetSize.width, size.width), height: min(targetSize.height, size.height))
ExtensionLottieView(data: animationData, size: resolvedSize)
.frame(width: size.width, height: size.height)
.background(
RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous)
.fill(Color.white.opacity(0.08))
)
case .none:
fallbackSymbol
}
}
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: - Lottie Rendering
struct ExtensionLottieView: View {
let data: Data
let size: CGSize
var loopMode: LottieLoopMode = .loop
@State private var cachedURL: URL?
var body: some View {
Group {
if let cachedURL {
LottieView(
state: LUStateData(
type: .loadedFrom(cachedURL),
speed: 1.0,
loopMode: loopMode
)
)
} else {
ProgressView()
.controlSize(.small)
.tint(.white.opacity(0.7))
}
}
.frame(width: size.width, height: size.height)
.task {
if cachedURL == nil {
cachedURL = ExtensionLottieCache.shared.url(for: data)
}
}
}
}
private final class ExtensionLottieCache {
static let shared = ExtensionLottieCache()
private let queue = DispatchQueue(label: "com.atoll.extension-lottie-cache")
private var cache: [String: URL] = [:]
func url(for data: Data) -> URL? {
queue.sync {
let key = Self.hash(data: data)
if let existing = cache[key], FileManager.default.fileExists(atPath: existing.path) {
return existing
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent("extension-lottie-\(key).json", conformingTo: .json)
do {
try data.write(to: url, options: .atomic)
cache[key] = url
return url
} catch {
cache.removeValue(forKey: key)
return nil
}
}
}
private static func hash(data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Progress Rendering
struct ExtensionProgressIndicatorView: View {
let indicator: AtollProgressIndicator
let progress: Double
let accent: Color
let estimatedDuration: TimeInterval?
let maxVisualHeight: CGFloat?
init(
indicator: AtollProgressIndicator,
progress: Double,
accent: Color,
estimatedDuration: TimeInterval?,
maxVisualHeight: CGFloat? = nil
) {
self.indicator = indicator
self.progress = progress
self.accent = accent
self.estimatedDuration = estimatedDuration
self.maxVisualHeight = maxVisualHeight
}
var body: some View {
switch indicator {
case let .ring(diameter, strokeWidth, color):
let ringColor = color?.resolvedColor(fallback: accent) ?? accent
let resolvedDiameter = resolvedRingDiameter(requested: diameter)
ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: strokeWidth)
Circle()
.trim(from: 0, to: CGFloat(max(0, min(progress, 1))))
.stroke(ringColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: progress)
}
.frame(width: resolvedDiameter, height: resolvedDiameter)
case let .bar(width, height, cornerRadius, color):
let barColor = color?.resolvedColor(fallback: accent) ?? accent
Capsule()
.fill(Color.white.opacity(0.18))
.frame(width: width ?? 80, height: height)
.overlay(alignment: .leading) {
Capsule()
.fill(barColor)
.frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height)
.animation(.smooth(duration: 0.25), value: progress)
}
case let .percentage(font, color):
let textColor = color?.resolvedColor(fallback: accent) ?? accent
Text("\(Int(progress * 100))%")
.font(font.swiftUIFont())
.foregroundStyle(textColor)
.monospacedDigit()
case let .countdown(font, color):
let textColor = color?.resolvedColor(fallback: accent) ?? accent
let text = countdownText
Text(text)
.font(font.swiftUIFont())
.foregroundStyle(textColor)
.monospacedDigit()
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: text)
case .lottie:
Image(systemName: "sparkles")
.font(.system(size: 14, weight: .medium))
.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))%"
}
private func resolvedRingDiameter(requested: CGFloat) -> CGFloat {
guard let maxVisualHeight else { return requested }
let target = max(min(maxVisualHeight - 12, 24), 16)
return min(requested, target)
}
}
// MARK: - Edge Content Rendering
struct ExtensionEdgeContentView: View {
let content: AtollTrailingContent
let accent: Color
let availableWidth: CGFloat
let alignment: Alignment
var body: some View {
switch content {
case let .text(value, font: font, color: color):
Text(value)
.font(font.swiftUIFont())
.foregroundStyle(resolvedColor(color))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: alignment)
case let .marquee(value, font: font, minDuration: duration, color: color):
MarqueeText(
.constant(value),
font: font.swiftUIFont(),
nsFont: .body,
textColor: resolvedColor(color),
minDuration: duration,
frameWidth: max(24, availableWidth - 12)
)
case let .countdownText(targetDate, font: font, color: color):
ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.resolvedColor(fallback: accent))
.frame(maxWidth: .infinity, alignment: alignment)
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 let .animation(data, size):
let resolvedWidth = min(size.width, availableWidth)
ExtensionLottieView(data: data, size: CGSize(width: resolvedWidth, height: size.height))
case .none:
EmptyView()
}
}
private func resolvedColor(_ descriptorColor: AtollColorDescriptor?) -> Color {
descriptorColor?.resolvedColor(fallback: accent) ?? accent
}
}
struct ExtensionCountdownTextView: View {
let targetDate: Date
let font: AtollFontDescriptor
let accent: Color
let customColor: Color?
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let remainingText = formattedRemaining(since: context.date)
Text(remainingText)
.font(font.swiftUIFont())
.foregroundStyle(customColor ?? accent)
.monospacedDigit()
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: remainingText)
}
}
private func formattedRemaining(since date: Date) -> String {
let remaining = max(Int(targetDate.timeIntervalSince(date)), 0)
let hours = remaining / 3600
let minutes = (remaining % 3600) / 60
let seconds = remaining % 60
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
}
// MARK: - Layout Metrics
enum ExtensionLayoutMetrics {
static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
let renderable = resolvedExtensionTrailingRenderable(for: payload.descriptor)
var width: CGFloat
switch renderable {
case let .content(content):
width = edgeWidth(for: content, baseWidth: baseWidth, maxWidth: maxWidth)
case let .indicator(indicator):
width = edgeWidth(for: .none, baseWidth: baseWidth, maxWidth: maxWidth)
width = max(width, widthForProgress(indicator))
}
if let maxWidth {
width = min(width, maxWidth)
}
return width
}
static func edgeWidth(for content: AtollTrailingContent, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
var width = widthForContent(content, baseWidth: baseWidth)
if let maxWidth {
width = min(width, maxWidth)
}
return width
}
private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat {
switch content {
case let .text(text, font: font, color: _):
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
return max(baseWidth, measured + 32)
case let .marquee(text, font: font, minDuration: _, color: _):
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
return max(baseWidth, min(measured + 40, baseWidth * 2))
case let .countdownText(targetDate, font: font, color: _):
let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00"
let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont())
return max(baseWidth, measured + 24)
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)
}
}
enum ExtensionTrailingRenderable {
case content(AtollTrailingContent)
case indicator(AtollProgressIndicator)
}
func resolvedExtensionLeadingContent(for descriptor: AtollLiveActivityDescriptor) -> AtollTrailingContent {
guard let override = descriptor.leadingContent else {
return .icon(descriptor.leadingIcon)
}
switch override {
case .icon, .animation:
return override
default:
return .icon(descriptor.leadingIcon)
}
}
func resolvedExtensionTrailingRenderable(for descriptor: AtollLiveActivityDescriptor) -> ExtensionTrailingRenderable {
if let indicator = descriptor.progressIndicator,
indicator.isRenderable,
descriptor.trailingContent == .none {
return .indicator(indicator)
}
return .content(descriptor.trailingContent)
}
private extension AtollProgressIndicator {
var isRenderable: Bool {
switch self {
case .none:
return false
default:
return true
}
}
}

View file

@ -11,6 +11,8 @@ import Defaults
struct LockScreenMusicPanel: View {
private struct GlassLogSnapshot: Equatable {
let style: LockScreenGlassStyle
let customizationMode: LockScreenGlassCustomizationMode
let variantRawValue: Int
let usesLiquidGlass: Bool
}
@ -32,6 +34,8 @@ struct LockScreenMusicPanel: View {
@State private var isParallaxSuspended = false
@State private var lastLoggedGlassSnapshot: GlassLogSnapshot?
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
@Default(.lockScreenGlassCustomizationMode) private var glassCustomizationMode
@Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant
@Default(.lockScreenShowAppIcon) var showAppIcon
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur
@ -70,12 +74,21 @@ struct LockScreenMusicPanel: View {
isExpanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
}
private var usesLiquidGlass: Bool {
private var usesCustomLiquidGlass: Bool {
glassCustomizationMode == .customLiquid
}
private var usesStandardLiquidGlass: Bool {
guard glassCustomizationMode == .standard else { return false }
if #available(macOS 26.0, *) {
return lockScreenGlassStyle == .liquid
}
return false
}
private var usesLiquidGlass: Bool {
usesCustomLiquidGlass || usesStandardLiquidGlass
}
var body: some View {
if isActive {
@ -138,6 +151,14 @@ struct LockScreenMusicPanel: View {
.onChange(of: lockScreenGlassStyle) { _, _ in
logGlassState(reason: "Glass style updated")
}
.onChange(of: glassCustomizationMode) { _, _ in
logGlassState(reason: "Glass mode updated")
}
.onChange(of: musicGlassVariant) { _, _ in
if usesCustomLiquidGlass {
logGlassState(reason: "Liquid variant updated")
}
}
.scaleEffect(animator.isPresented ? 1 : 0.9, anchor: .center)
.opacity(animator.isPresented ? 1 : 0)
.animation(.spring(response: 0.52, dampingFraction: 0.8), value: animator.isPresented)
@ -723,8 +744,10 @@ struct LockScreenMusicPanel: View {
@ViewBuilder
private var panelBackgroundLayer: some View {
if usesLiquidGlass {
liquidPanelBackdrop
if usesCustomLiquidGlass {
customLiquidPanelBackdrop
} else if usesStandardLiquidGlass {
standardLiquidPanelBackdrop
} else if shouldUseFrostedBlur {
frostedPanelBackground
} else {
@ -733,7 +756,7 @@ struct LockScreenMusicPanel: View {
}
@ViewBuilder
private var liquidPanelBackdrop: some View {
private var standardLiquidPanelBackdrop: some View {
if #available(macOS 26.0, *) {
GlassTextBackdrop(cornerRadius: panelCornerRadius)
.frame(maxWidth: .infinity, maxHeight: .infinity)
@ -742,6 +765,16 @@ struct LockScreenMusicPanel: View {
}
}
@ViewBuilder
private var customLiquidPanelBackdrop: some View {
LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: panelCornerRadius) {
Color.white.opacity(0.04)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
private var frostedPanelBackground: some View {
RoundedRectangle(cornerRadius: panelCornerRadius)
.fill(.ultraThinMaterial)
@ -768,7 +801,9 @@ struct LockScreenMusicPanel: View {
@ViewBuilder
private func albumArtBackground(cornerRadius: CGFloat) -> some View {
if usesLiquidGlass {
if usesCustomLiquidGlass {
customLiquidAlbumArtBackground(cornerRadius: cornerRadius)
} else if usesStandardLiquidGlass {
if #available(macOS 26.0, *) {
clearLiquidGlassSurface(cornerRadius: cornerRadius)
}
@ -809,8 +844,20 @@ struct LockScreenMusicPanel: View {
)
}
@ViewBuilder
private func customLiquidAlbumArtBackground(cornerRadius: CGFloat) -> some View {
LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: cornerRadius) {
Color.clear
}
}
private func logGlassState(reason: String) {
let snapshot = GlassLogSnapshot(style: lockScreenGlassStyle, usesLiquidGlass: usesLiquidGlass)
let snapshot = GlassLogSnapshot(
style: lockScreenGlassStyle,
customizationMode: glassCustomizationMode,
variantRawValue: musicGlassVariant.rawValue,
usesLiquidGlass: usesLiquidGlass
)
guard snapshot != lastLoggedGlassSnapshot else { return }
lastLoggedGlassSnapshot = snapshot
@ -831,9 +878,18 @@ struct LockScreenMusicPanel: View {
return "\(entry.name)=\(mode)"
}.joined(separator: ", ")
print("[LockScreenMusicPanel] \(reason) – style=\(lockScreenGlassStyle.rawValue), usesLiquidGlass=\(usesLiquidGlass), components[\(componentSummary)], macOS \(currentOSVersionDescription())")
let modeDescription: String
if usesCustomLiquidGlass {
modeDescription = "Custom Liquid (variant \(musicGlassVariant.rawValue))"
} else if usesStandardLiquidGlass {
modeDescription = "Standard Liquid"
} else {
modeDescription = lockScreenGlassStyle.rawValue
}
if lockScreenGlassStyle == .liquid && !usesLiquidGlass {
print("[LockScreenMusicPanel] \(reason) – customization=\(glassCustomizationMode.rawValue), mode=\(modeDescription), components[\(componentSummary)], macOS \(currentOSVersionDescription())")
if glassCustomizationMode == .standard && lockScreenGlassStyle == .liquid && !usesStandardLiquidGlass {
print("[LockScreenMusicPanel] Liquid Glass requested but unavailable on this macOS build. Falling back to frosted visuals.")
}
}

View file

@ -7,8 +7,10 @@ struct LockScreenTimerWidget: View {
@ObservedObject private var animator: LockScreenTimerWidgetAnimator
@ObservedObject private var timerManager = TimerManager.shared
@Default(.lockScreenGlassStyle) private var glassStyle
@Default(.lockScreenTimerWidgetUsesBlur) private var enableTimerBlur
@Default(.lockScreenTimerGlassStyle) private var timerGlassStyle
@Default(.lockScreenTimerGlassCustomizationMode) private var timerGlassCustomizationMode
@Default(.lockScreenTimerLiquidGlassVariant) private var timerGlassVariant
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
@Default(.timerPresets) private var timerPresets
@MainActor
@ -78,29 +80,45 @@ struct LockScreenTimerWidget: View {
)
}
private var usesLiquidGlass: Bool {
private var timerSurfaceMode: LockScreenTimerSurfaceMode {
timerGlassModeIsGlass ? .glass : .classic
}
private var usesGlassBackground: Bool {
timerSurfaceMode == .glass
}
private var usesCustomLiquidGlass: Bool {
guard usesGlassBackground else { return false }
return timerGlassStyle == .liquid && timerGlassCustomizationMode == .customLiquid
}
private var usesStandardLiquidGlass: Bool {
guard usesGlassBackground else { return false }
guard timerGlassStyle == .liquid else { return false }
if #available(macOS 26.0, *) {
return glassStyle == .liquid
return timerGlassCustomizationMode == .standard
}
return false
}
@ViewBuilder
private var widgetBackground: some View {
if enableTimerBlur {
if usesLiquidGlass {
liquidBackground
if usesGlassBackground {
if usesCustomLiquidGlass {
customLiquidBackground
} else if timerGlassStyle == .liquid {
standardLiquidBackground
} else {
frostedBackground
}
} else {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.fill(Color.black.opacity(0.65))
classicBackground
}
}
@ViewBuilder
private var liquidBackground: some View {
private var standardLiquidBackground: some View {
if #available(macOS 26.0, *) {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.glassEffect(
@ -112,11 +130,22 @@ struct LockScreenTimerWidget: View {
}
}
private var customLiquidBackground: some View {
LiquidGlassBackground(variant: timerGlassVariant, cornerRadius: Self.cornerRadius) {
Color.black.opacity(0.12)
}
}
private var frostedBackground: some View {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
}
private var classicBackground: some View {
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
.fill(Color.black.opacity(0.65))
}
private var pauseIcon: String {
timerManager.isPaused ? "play.fill" : "pause.fill"
}

View file

@ -378,6 +378,7 @@ struct NotchHomeView: View {
@ObservedObject var webcamManager = WebcamManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
let albumArtNamespace: Namespace.ID
var body: some View {
@ -392,8 +393,14 @@ struct NotchHomeView: View {
private var mainContent: some View {
HStack(alignment: .top, spacing: 20) {
if Defaults[.enableMinimalisticUI] {
// Minimalistic mode: Only show compact music player
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
if let overridePayload = minimalisticOverridePayload {
ExtensionMinimalisticExperienceView(
payload: overridePayload,
albumArtNamespace: albumArtNamespace
)
} else {
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
}
} else {
// Normal mode: Show full music player with optional calendar and webcam
MusicPlayerView(albumArtNamespace: albumArtNamespace)
@ -422,6 +429,10 @@ struct NotchHomeView: View {
.combined(with: .move(edge: .top)))
.blur(radius: vm.notchState == .closed ? 30 : 0)
}
private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? {
extensionNotchExperienceManager.minimalisticReplacementPayload()
}
}
struct MusicSliderView: View {

View file

@ -0,0 +1,507 @@
//
// 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, lock screen widgets, and notch experiences 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("Allow extension notch experiences", key: .enableExtensionNotchExperiences)
.settingsHighlight(id: highlightID("Allow extension notch experiences"))
if Defaults[.enableExtensionNotchExperiences] {
VStack(alignment: .leading, spacing: 8) {
Defaults.Toggle("Show extension tabs", key: .enableExtensionNotchTabs)
.tint(.accentColor)
Defaults.Toggle("Allow minimalistic overrides", key: .enableExtensionNotchMinimalisticOverrides)
.tint(.accentColor)
Defaults.Toggle("Allow interactive web content", key: .enableExtensionNotchInteractiveWebViews)
.tint(.accentColor)
}
.padding(.leading, 4)
}
Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled)
.settingsHighlight(id: highlightID("Enable extension diagnostics logging"))
}
} header: {
Text("Global Settings")
} footer: {
if Defaults[.enableThirdPartyExtensions] {
Text("Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
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)
}
}
}
}
@MainActor
private struct ExtensionEntryRow: View {
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared
@ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared
@ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared
@ObservedObject private var notchExperienceManager = ExtensionNotchExperienceManager.shared
let entry: ExtensionAuthorizationEntry
let onRemove: () -> Void
@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 || !rateLimitRecord.notchExperienceTimestamps.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)
Toggle("Notch Experiences", isOn: Binding(
get: { entry.allowedScopes.contains(.notchExperiences) },
set: { enabled in
var newScopes = entry.allowedScopes
if enabled {
newScopes.insert(.notchExperiences)
} else {
newScopes.remove(.notchExperiences)
}
authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes)
}
))
.font(.caption)
.disabled(!authManager.areNotchExperiencesEnabled)
}
}
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())
}
}
if !record.notchExperienceTimestamps.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("Notch Experiences")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(record.notchExperienceTimestamps.count)")
.font(.caption.monospacedDigit())
}
}
}
Button("Reset Rate Limits") {
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()
resetMenu
Button("Remove") {
onRemove()
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
}
}
private var resetMenu: some View {
Menu {
Button("Reset Live Activities") {
liveActivityManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasLiveActivities)
Button("Reset Lock Screen Widgets") {
widgetManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasWidgets)
Button("Reset Notch Experiences") {
notchExperienceManager.dismissAll(for: entry.bundleIdentifier)
}
.disabled(!hasNotchExperiences)
} label: {
Label("Reset", systemImage: "arrow.counterclockwise.circle")
}
.menuStyle(.borderlessButton)
.controlSize(.small)
}
private var hasLiveActivities: Bool {
liveActivityManager.activeActivities.contains { $0.bundleIdentifier == entry.bundleIdentifier }
}
private var hasWidgets: Bool {
widgetManager.activeWidgets.contains { $0.bundleIdentifier == entry.bundleIdentifier }
}
private var hasNotchExperiences: Bool {
notchExperienceManager.activeExperiences.contains { $0.bundleIdentifier == entry.bundleIdentifier }
}
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()
}

View file

@ -24,6 +24,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case lockScreen
case media
case devices
case extensions
case timer
case calendar
case hudAndOSD
@ -48,6 +49,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .lockScreen: return "Lock Screen"
case .media: return "Media"
case .devices: return "Devices"
case .extensions: return "Extensions"
case .timer: return "Timer"
case .calendar: return "Calendar"
case .hudAndOSD: return "Controls"
@ -72,6 +74,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .lockScreen: return "lock.laptopcomputer"
case .media: return "play.laptopcomputer"
case .devices: return "headphones"
case .extensions: return "puzzlepiece.extension"
case .timer: return "timer"
case .calendar: return "calendar"
case .hudAndOSD: return "dial.medium.fill"
@ -96,6 +99,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .lockScreen: return .orange
case .media: return .green
case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12)
case .extensions: return Color(red: 0.557, green: 0.353, blue: 0.957)
case .timer: return .red
case .calendar: return .cyan
case .hudAndOSD: return .indigo
@ -174,26 +178,34 @@ private struct SettingsHighlightModifier: ViewModifier {
func body(content: Content) -> some View {
content
.id(id)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.accentColor.opacity(0.08))
.padding(.horizontal, -8)
.opacity(isActive ? 1 : 0)
.animation(.easeInOut(duration: 0.25), value: isActive)
)
.overlay {
.background(highlightBackground)
.onChange(of: isActive) { _, active in
animatePulse = active
}
.onAppear {
if isActive {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.accentColor.opacity(0.85), lineWidth: 2)
.padding(.vertical, -4)
.padding(.horizontal, -8)
.opacity(animatePulse ? 0.25 : 0.9)
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: animatePulse)
.onAppear { animatePulse = true }
.onDisappear { animatePulse = false }
animatePulse = true
}
}
}
private var highlightBackground: some View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(
Color.accentColor.opacity(isActive ? (animatePulse ? 0.95 : 0.4) : 0),
lineWidth: 2
)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.accentColor.opacity(isActive ? 0.08 : 0))
)
.padding(-4)
.shadow(color: Color.accentColor.opacity(isActive ? 0.25 : 0), radius: animatePulse ? 8 : 2)
.animation(
isActive ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default,
value: animatePulse
)
}
}
extension View {
@ -386,6 +398,7 @@ struct SettingsView: View {
.lockScreen,
.media,
.devices,
.extensions,
.timer,
.calendar,
.hudAndOSD,
@ -674,7 +687,10 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .lockScreen, title: "Show panel border", keywords: ["panel border"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show panel border")),
SettingsSearchEntry(tab: .lockScreen, title: "Enable media panel blur", keywords: ["blur", "media panel"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable media panel blur")),
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen timer", keywords: ["timer widget", "lock screen timer"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen timer")),
SettingsSearchEntry(tab: .lockScreen, title: "Enable timer blur", keywords: ["timer blur"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable timer blur")),
SettingsSearchEntry(tab: .lockScreen, title: "Timer surface", keywords: ["timer glass", "classic", "blur"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer surface")),
SettingsSearchEntry(tab: .lockScreen, title: "Timer glass material", keywords: ["frosted", "liquid", "timer material"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer glass material")),
SettingsSearchEntry(tab: .lockScreen, title: "Timer liquid mode", keywords: ["timer", "standard", "custom"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer liquid mode")),
SettingsSearchEntry(tab: .lockScreen, title: "Timer widget variant", keywords: ["timer variant", "liquid"], highlightID: SettingsTab.lockScreen.highlightID(for: "Timer widget variant")),
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen weather", keywords: ["weather widget"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen weather")),
SettingsSearchEntry(tab: .lockScreen, title: "Layout", keywords: ["inline", "circular", "weather layout"], highlightID: SettingsTab.lockScreen.highlightID(for: "Layout")),
SettingsSearchEntry(tab: .lockScreen, title: "Weather data provider", keywords: ["wttr", "open meteo"], highlightID: SettingsTab.lockScreen.highlightID(for: "Weather data provider")),
@ -689,6 +705,13 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")),
SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")),
// Extensions
SettingsSearchEntry(tab: .extensions, title: "Enable third-party extensions", keywords: ["extensions", "authorization", "third party"], highlightID: SettingsTab.extensions.highlightID(for: "Enable third-party extensions")),
SettingsSearchEntry(tab: .extensions, title: "Allow extension live activities", keywords: ["extensions", "live activities", "permissions"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension live activities")),
SettingsSearchEntry(tab: .extensions, title: "Allow extension lock screen widgets", keywords: ["extensions", "lock screen", "widgets"], highlightID: SettingsTab.extensions.highlightID(for: "Allow extension lock screen widgets")),
SettingsSearchEntry(tab: .extensions, title: "Enable extension diagnostics logging", keywords: ["extensions", "diagnostics", "logging"], highlightID: SettingsTab.extensions.highlightID(for: "Enable extension diagnostics logging")),
SettingsSearchEntry(tab: .extensions, title: "Manage app permissions", keywords: ["extensions", "permissions", "apps"], highlightID: SettingsTab.extensions.highlightID(for: "App permissions list")),
// Shortcuts
SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"], highlightID: SettingsTab.shortcuts.highlightID(for: "Enable global keyboard shortcuts")),
@ -696,7 +719,10 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .timer, title: "Enable timer feature", keywords: ["timer", "enable"], highlightID: SettingsTab.timer.highlightID(for: "Enable timer feature")),
SettingsSearchEntry(tab: .timer, title: "Mirror macOS Clock timers", keywords: ["system timer", "clock app"], highlightID: SettingsTab.timer.highlightID(for: "Mirror macOS Clock timers")),
SettingsSearchEntry(tab: .timer, title: "Show lock screen timer widget", keywords: ["lock screen", "timer widget"], highlightID: SettingsTab.timer.highlightID(for: "Show lock screen timer widget")),
SettingsSearchEntry(tab: .timer, title: "Enable timer blur", keywords: ["timer blur", "lock screen"], highlightID: SettingsTab.timer.highlightID(for: "Enable timer blur")),
SettingsSearchEntry(tab: .timer, title: "Timer surface", keywords: ["timer glass", "classic", "blur"], highlightID: SettingsTab.timer.highlightID(for: "Timer surface")),
SettingsSearchEntry(tab: .timer, title: "Timer glass material", keywords: ["frosted", "liquid", "timer material"], highlightID: SettingsTab.timer.highlightID(for: "Timer glass material")),
SettingsSearchEntry(tab: .timer, title: "Timer liquid mode", keywords: ["timer", "standard", "custom"], highlightID: SettingsTab.timer.highlightID(for: "Timer liquid mode")),
SettingsSearchEntry(tab: .timer, title: "Timer widget variant", keywords: ["timer variant", "liquid"], highlightID: SettingsTab.timer.highlightID(for: "Timer widget variant")),
SettingsSearchEntry(tab: .timer, title: "Timer tint", keywords: ["timer colour", "preset"], highlightID: SettingsTab.timer.highlightID(for: "Timer tint")),
SettingsSearchEntry(tab: .timer, title: "Solid colour", keywords: ["timer colour", "custom"], highlightID: SettingsTab.timer.highlightID(for: "Solid colour")),
SettingsSearchEntry(tab: .timer, title: "Progress style", keywords: ["progress", "bar", "ring"], highlightID: SettingsTab.timer.highlightID(for: "Progress style")),
@ -766,6 +792,10 @@ struct SettingsView: View {
SettingsForm(tab: .devices) {
DevicesSettingsView()
}
case .extensions:
SettingsForm(tab: .extensions) {
ExtensionsSettingsView()
}
case .timer:
SettingsForm(tab: .timer) {
TimerSettings()
@ -1920,6 +1950,7 @@ struct Media: View {
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@Default(.showSneakPeekOnTrackChange) private var showSneakPeekOnTrackChange
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
private func highlightID(_ title: String) -> String {
@ -2063,7 +2094,11 @@ struct Media: View {
.disabled(!enableLockScreenMediaWidget)
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
.disabled(!enableLockScreenMediaWidget)
if lockScreenGlassStyle == .frosted {
if lockScreenGlassCustomizationMode == .customLiquid {
customLiquidBlurRow
.opacity(enableLockScreenMediaWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Enable media panel blur"))
} else if lockScreenGlassStyle == .frosted {
Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur)
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Enable media panel blur"))
@ -2113,6 +2148,16 @@ struct Media: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var customLiquidBlurRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Enable media panel blur")
.foregroundStyle(.secondary)
Text("Custom liquid glass already renders with Apple's liquid material, so this option is managed automatically.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct CalendarSettings: View {
@ -2746,6 +2791,15 @@ struct Appearance: View {
@Default(.selectedVisualizer) var selectedVisualizer
@Default(.openNotchWidth) var openNotchWidth
@Default(.enableMinimalisticUI) var enableMinimalisticUI
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
@Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant
@Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant
@Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle
@Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
let icons: [String] = ["logo2"]
@State private var selectedIcon: String = "logo2"
@State private var selectedListVisualizer: CustomVisualizer? = nil
@ -2762,6 +2816,37 @@ struct Appearance: View {
SettingsTab.appearance.highlightID(for: title)
}
private var liquidVariantRange: ClosedRange<Double> {
Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound)
}
private var appearanceMusicVariantBinding: Binding<Double> {
Binding(
get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) },
set: { newValue in
let raw = Int(newValue.rounded())
lockScreenMusicLiquidGlassVariant = LiquidGlassVariant.clamped(raw)
}
)
}
private var appearanceTimerVariantBinding: Binding<Double> {
Binding(
get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) },
set: { newValue in
let raw = Int(newValue.rounded())
lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw)
}
)
}
private var timerSurfaceBinding: Binding<LockScreenTimerSurfaceMode> {
Binding(
get: { timerGlassModeIsGlass ? .glass : .classic },
set: { mode in timerGlassModeIsGlass = (mode == .glass) }
)
}
var body: some View {
Form {
Section {
@ -2780,6 +2865,80 @@ struct Appearance: View {
notchWidthControls()
Section {
if #available(macOS 26.0, *) {
Picker("Material", selection: $lockScreenGlassStyle) {
ForEach(LockScreenGlassStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.settingsHighlight(id: highlightID("Lock screen material"))
} else {
Picker("Material", selection: $lockScreenGlassStyle) {
ForEach(LockScreenGlassStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.disabled(true)
.settingsHighlight(id: highlightID("Lock screen material"))
Text("Liquid Glass requires macOS 26 or later.")
.font(.caption)
.foregroundStyle(.secondary)
}
if lockScreenGlassStyle == .liquid {
Picker("Lock screen glass mode", selection: $lockScreenGlassCustomizationMode) {
ForEach(LockScreenGlassCustomizationMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.settingsHighlight(id: highlightID("Lock screen glass mode"))
if lockScreenGlassCustomizationMode == .customLiquid {
Text("Pick per-widget liquid-glass variants below. Changes mirror the Lock Screen tab.")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Music panel variant")
Spacer()
Text("v\(lockScreenMusicLiquidGlassVariant.rawValue)")
.font(.caption)
.foregroundStyle(.secondary)
}
Slider(value: appearanceMusicVariantBinding, in: liquidVariantRange, step: 1)
}
.settingsHighlight(id: highlightID("Music panel variant (appearance)"))
.disabled(!enableLockScreenMediaWidget)
.opacity(enableLockScreenMediaWidget ? 1 : 0.4)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Timer widget variant")
Spacer()
Text("v\(lockScreenTimerLiquidGlassVariant.rawValue)")
.font(.caption)
.foregroundStyle(.secondary)
}
Slider(value: appearanceTimerVariantBinding, in: liquidVariantRange, step: 1)
}
.settingsHighlight(id: highlightID("Timer widget variant (appearance)"))
.disabled(!enableLockScreenTimerWidget)
.opacity(enableLockScreenTimerWidget ? 1 : 0.4)
}
} else {
Text("Custom Liquid settings require the Liquid Glass material.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Lock Screen Glass")
} footer: {
Text("Configure lock screen materials from the Appearance tab. Custom Liquid unlocks variant sliders for both widgets whenever Liquid Glass is selected.")
}
Section {
Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram)
.settingsHighlight(id: highlightID("Enable colored spectrograms"))
@ -3031,6 +3190,9 @@ struct Appearance: View {
}
}
}
.onAppear(perform: enforceLockScreenGlassConsistency)
.onChange(of: lockScreenGlassStyle) { _, _ in enforceLockScreenGlassConsistency() }
.onChange(of: lockScreenGlassCustomizationMode) { _, _ in enforceLockScreenGlassConsistency() }
.navigationTitle("Appearance")
}
@ -3096,10 +3258,25 @@ struct Appearance: View {
}
}
}
private func enforceLockScreenGlassConsistency() {
if lockScreenGlassStyle == .frosted && lockScreenGlassCustomizationMode != .standard {
lockScreenGlassCustomizationMode = .standard
}
if lockScreenGlassCustomizationMode == .customLiquid && lockScreenGlassStyle != .liquid {
lockScreenGlassStyle = .liquid
}
}
}
struct LockScreenSettings: View {
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
@Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant
@Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant
@Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle
@Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
@ -3117,6 +3294,37 @@ struct LockScreenSettings: View {
SettingsTab.lockScreen.highlightID(for: title)
}
private var liquidVariantRange: ClosedRange<Double> {
Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound)
}
private var musicVariantBinding: Binding<Double> {
Binding(
get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) },
set: { newValue in
let raw = Int(newValue.rounded())
lockScreenMusicLiquidGlassVariant = LiquidGlassVariant.clamped(raw)
}
)
}
private var timerVariantBinding: Binding<Double> {
Binding(
get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) },
set: { newValue in
let raw = Int(newValue.rounded())
lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw)
}
)
}
private var timerSurfaceBinding: Binding<LockScreenTimerSurfaceMode> {
Binding(
get: { timerGlassModeIsGlass ? .glass : .classic },
set: { mode in timerGlassModeIsGlass = (mode == .glass) }
)
}
var body: some View {
Form {
Section {
@ -3150,6 +3358,33 @@ struct LockScreenSettings: View {
.font(.caption)
.foregroundStyle(.secondary)
}
if lockScreenGlassStyle == .liquid {
Picker("Glass mode", selection: $lockScreenGlassCustomizationMode) {
ForEach(LockScreenGlassCustomizationMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.settingsHighlight(id: highlightID("Glass mode"))
if lockScreenGlassCustomizationMode == .customLiquid {
Text("Use the sliders below to pick unique Apple liquid-glass variants for each widget.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("Custom Liquid settings require the Liquid Glass material.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Lock Screen Glass")
} footer: {
Text("Choose the global material mode for lock screen widgets. Custom Liquid unlocks per-widget variant sliders while Standard sticks to the classic frosted/liquid options.")
}
Section {
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show lock screen media panel"))
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
@ -3158,7 +3393,15 @@ struct LockScreenSettings: View {
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show panel border"))
if lockScreenGlassStyle == .frosted {
if lockScreenGlassCustomizationMode == .customLiquid {
variantSlider(
title: "Music panel variant",
value: musicVariantBinding,
currentValue: lockScreenMusicLiquidGlassVariant.rawValue,
isEnabled: enableLockScreenMediaWidget,
highlight: highlightID("Music panel variant")
)
} else if lockScreenGlassStyle == .frosted {
Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur)
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Enable media panel blur"))
@ -3176,13 +3419,63 @@ struct LockScreenSettings: View {
Section {
Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget)
.settingsHighlight(id: highlightID("Show lock screen timer"))
Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur)
Picker("Timer surface", selection: timerSurfaceBinding) {
ForEach(LockScreenTimerSurfaceMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(!enableLockScreenTimerWidget)
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Timer surface"))
if timerGlassModeIsGlass {
Picker("Timer glass material", selection: $lockScreenTimerGlassStyle) {
ForEach(LockScreenGlassStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.disabled(!enableLockScreenTimerWidget)
.settingsHighlight(id: highlightID("Enable timer blur"))
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Timer glass material"))
if lockScreenTimerGlassStyle == .liquid {
Picker("Timer liquid mode", selection: $lockScreenTimerGlassCustomizationMode) {
ForEach(LockScreenGlassCustomizationMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(!enableLockScreenTimerWidget)
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Timer liquid mode"))
if lockScreenTimerGlassCustomizationMode == .customLiquid {
variantSlider(
title: "Timer widget variant",
value: timerVariantBinding,
currentValue: lockScreenTimerLiquidGlassVariant.rawValue,
isEnabled: enableLockScreenTimerWidget,
highlight: highlightID("Timer widget variant")
)
}
} else {
Text("Uses the frosted blur treatment while glass mode is enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
Text("Classic mode keeps the original translucent black background.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
}
} header: {
Text("Timer Widget")
} footer: {
Text("Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view.")
Text("Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting.")
}
Section {
@ -3299,11 +3592,23 @@ struct LockScreenSettings: View {
Text("Collect the latest crash report to share with the developer when reporting lock screen or overlay issues.")
}
}
.onAppear(perform: enforceLockScreenGlassConsistency)
.onChange(of: lockScreenGlassStyle) { _, _ in enforceLockScreenGlassConsistency() }
.onChange(of: lockScreenGlassCustomizationMode) { _, _ in enforceLockScreenGlassConsistency() }
.navigationTitle("Lock Screen")
}
}
extension LockScreenSettings {
private func enforceLockScreenGlassConsistency() {
if lockScreenGlassStyle == .frosted && lockScreenGlassCustomizationMode != .standard {
lockScreenGlassCustomizationMode = .standard
}
if lockScreenGlassCustomizationMode == .customLiquid && lockScreenGlassStyle != .liquid {
lockScreenGlassStyle = .liquid
}
}
private var blurSettingUnavailableRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Enable media panel blur")
@ -3314,6 +3619,29 @@ extension LockScreenSettings {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func variantSlider(
title: String,
value: Binding<Double>,
currentValue: Int,
isEnabled: Bool,
highlight: String
) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title)
Spacer()
Text("v\(currentValue)")
.font(.caption)
.foregroundStyle(.secondary)
}
Slider(value: value, in: liquidVariantRange, step: 1)
}
.settingsHighlight(id: highlight)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.4)
}
}
private struct LockScreenPositioningControls: View {
@ -3938,6 +4266,10 @@ struct TimerSettings: View {
@Default(.mirrorSystemTimer) private var mirrorSystemTimer
@Default(.timerDisplayMode) private var timerDisplayMode
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
@Default(.lockScreenTimerGlassStyle) private var lockScreenTimerGlassStyle
@Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode
@Default(.lockScreenTimerLiquidGlassVariant) private var lockScreenTimerLiquidGlassVariant
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
@State private var customHours: Int = 0
@State private var customMinutes: Int = 10
@ -3947,6 +4279,27 @@ struct TimerSettings: View {
private func highlightID(_ title: String) -> String {
SettingsTab.timer.highlightID(for: title)
}
private var liquidVariantRange: ClosedRange<Double> {
Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound)
}
private var timerVariantBinding: Binding<Double> {
Binding(
get: { Double(lockScreenTimerLiquidGlassVariant.rawValue) },
set: { newValue in
let raw = Int(newValue.rounded())
lockScreenTimerLiquidGlassVariant = LiquidGlassVariant.clamped(raw)
}
)
}
private var timerSurfaceBinding: Binding<LockScreenTimerSurfaceMode> {
Binding(
get: { timerGlassModeIsGlass ? .glass : .classic },
set: { mode in timerGlassModeIsGlass = (mode == .glass) }
)
}
var body: some View {
Form {
@ -4021,9 +4374,65 @@ struct TimerSettings: View {
Section {
Defaults.Toggle("Show lock screen timer widget", key: .enableLockScreenTimerWidget)
.settingsHighlight(id: highlightID("Show lock screen timer widget"))
Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur)
Picker("Timer surface", selection: timerSurfaceBinding) {
ForEach(LockScreenTimerSurfaceMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(!enableLockScreenTimerWidget)
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Timer surface"))
if timerGlassModeIsGlass {
Picker("Timer glass material", selection: $lockScreenTimerGlassStyle) {
ForEach(LockScreenGlassStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.disabled(!enableLockScreenTimerWidget)
.settingsHighlight(id: highlightID("Enable timer blur"))
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Timer glass material"))
if lockScreenTimerGlassStyle == .liquid {
Picker("Timer liquid mode", selection: $lockScreenTimerGlassCustomizationMode) {
ForEach(LockScreenGlassCustomizationMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(!enableLockScreenTimerWidget)
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Timer liquid mode"))
if lockScreenTimerGlassCustomizationMode == .customLiquid {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Timer widget variant")
Spacer()
Text("v\(lockScreenTimerLiquidGlassVariant.rawValue)")
.font(.caption)
.foregroundStyle(.secondary)
}
Slider(value: timerVariantBinding, in: liquidVariantRange, step: 1)
}
.settingsHighlight(id: highlightID("Timer widget variant"))
.disabled(!enableLockScreenTimerWidget)
.opacity(enableLockScreenTimerWidget ? 1 : 0.4)
}
} else {
Text("Uses the frosted blur treatment while glass mode is enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
Text("Classic mode keeps the original translucent black background.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(enableLockScreenTimerWidget ? 1 : 0.5)
}
} header: {
Text("Lock Screen Integration")
} footer: {

View file

@ -4,23 +4,38 @@
//
// Created by Hugo Persson on 2024-08-25.
// Modified by Hariharan Mudaliar
import AtollExtensionKit
import SwiftUI
import Defaults
struct TabModel: Identifiable {
let id = UUID()
let id: String
let label: String
let icon: String
let view: NotchViews
let experienceID: String?
let accentColor: Color?
init(label: String, icon: String, view: NotchViews, experienceID: String? = nil, accentColor: Color? = nil) {
self.id = experienceID.map { "extension-\($0)" } ?? "system-\(view)-\(label)"
self.label = label
self.icon = icon
self.view = view
self.experienceID = experienceID
self.accentColor = accentColor
}
}
struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@Default(.timerDisplayMode) var timerDisplayMode
@Default(.enableThirdPartyExtensions) private var enableThirdPartyExtensions
@Default(.enableExtensionNotchExperiences) private var enableExtensionNotchExperiences
@Default(.enableExtensionNotchTabs) private var enableExtensionNotchTabs
@Namespace var animation
private var tabs: [TabModel] {
@ -46,35 +61,73 @@ struct TabSelectionView: View {
let icon = Defaults[.enableNotes] ? "note.text" : "doc.on.clipboard"
tabsArray.append(TabModel(label: label, icon: icon, view: .notes))
}
if extensionTabsEnabled {
for payload in extensionTabPayloads {
guard let tab = payload.descriptor.tab else { continue }
let accent = payload.descriptor.accentColor.swiftUIColor
let iconName = tab.iconSymbolName ?? "puzzlepiece.extension"
tabsArray.append(
TabModel(
label: tab.title,
icon: iconName,
view: .extensionExperience,
experienceID: payload.descriptor.id,
accentColor: accent
)
)
}
}
return tabsArray
}
var body: some View {
HStack(spacing: 0) {
ForEach(tabs) { tab in
TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) {
withAnimation(.smooth) {
coordinator.currentView = tab.view
let isSelected = isSelected(tab)
let activeAccent = tab.accentColor ?? .white
TabButton(label: tab.label, icon: tab.icon, selected: isSelected) {
withAnimation(.smooth) {
if tab.view == .extensionExperience {
coordinator.selectedExtensionExperienceID = tab.experienceID
}
coordinator.currentView = tab.view
}
.frame(height: 26)
.foregroundStyle(tab.view == coordinator.currentView ? .white : .gray)
.background {
if tab.view == coordinator.currentView {
Capsule()
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear)
.matchedGeometryEffect(id: "capsule", in: animation)
} else {
Capsule()
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear)
.matchedGeometryEffect(id: "capsule", in: animation)
.hidden()
}
}
.frame(height: 26)
.foregroundStyle(isSelected ? activeAccent : .gray)
.background {
if isSelected {
Capsule()
.fill((tab.accentColor ?? Color(nsColor: .secondarySystemFill)).opacity(0.25))
.shadow(color: (tab.accentColor ?? .clear).opacity(0.4), radius: 8)
.matchedGeometryEffect(id: "capsule", in: animation)
} else {
Capsule()
.fill(Color.clear)
.matchedGeometryEffect(id: "capsule", in: animation)
.hidden()
}
}
}
}
.clipShape(Capsule())
}
private var extensionTabsEnabled: Bool {
enableThirdPartyExtensions && enableExtensionNotchExperiences && enableExtensionNotchTabs
}
private var extensionTabPayloads: [ExtensionNotchExperiencePayload] {
extensionNotchExperienceManager.activeExperiences.filter { $0.descriptor.tab != nil }
}
private func isSelected(_ tab: TabModel) -> Bool {
if tab.view == .extensionExperience {
return coordinator.currentView == .extensionExperience
&& coordinator.selectedExtensionExperienceID == tab.experienceID
}
return coordinator.currentView == tab.view
}
}
#Preview {

View file

@ -33,6 +33,7 @@ public enum NotchViews {
case colorPicker
case notes
case clipboard
case extensionExperience
}
enum NotesLayoutState: Equatable {
@ -99,6 +100,24 @@ enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifi
var id: String { rawValue }
}
enum LockScreenGlassCustomizationMode: String, CaseIterable, Defaults.Serializable, Identifiable {
case standard = "Standard"
case customLiquid = "Custom Liquid"
var id: String { rawValue }
var allowsVariantSelection: Bool {
self == .customLiquid
}
}
enum LockScreenTimerSurfaceMode: String, CaseIterable, Defaults.Serializable, Identifiable {
case classic = "Classic"
case glass = "Glass"
var id: String { rawValue }
}
enum LockScreenWeatherWidgetStyle: String, CaseIterable, Defaults.Serializable, Identifiable {
case inline = "Inline"
case circular = "Circular"

View file

@ -0,0 +1,134 @@
// LiquidGlassBackground.swift
// A SwiftUI wrapper around Apple’s private NSGlassEffectView.
// Provides easy access to every liquid‑glass variant (0–19).
import SwiftUI
import AppKit
import Defaults
/// All 20 available liquid‑glass variants.
/// Apple does not publicly describe how each value looks so experiment and pick the one you like!
public enum LiquidGlassVariant: Int, CaseIterable, Identifiable, Defaults.Serializable, Sendable {
case v0 = 0, v1 = 1, v2 = 2, v3 = 3, v4 = 4
case v5 = 5, v6 = 6, v7 = 7, v8 = 8, v9 = 9
case v10 = 10, v11 = 11, v12 = 12, v13 = 13, v14 = 14
case v15 = 15, v16 = 16, v17 = 17, v18 = 18, v19 = 19
public var id: Int { rawValue }
public static let supportedRange = 0...19
public static var defaultVariant: LiquidGlassVariant { .v11 }
public static func clamped(_ rawValue: Int) -> LiquidGlassVariant {
let clamped = min(max(rawValue, supportedRange.lowerBound), supportedRange.upperBound)
return LiquidGlassVariant(rawValue: clamped) ?? .defaultVariant
}
}
/// A SwiftUI view that embeds its content inside Apple’s private liquid‑glass material.
///
/// ```swift
/// GlassBackground(variant: .v11, cornerRadius: 12) {
/// VStack(spacing: 12) {
/// Image(systemName: "sparkles")
/// .font(.largeTitle)
/// Text("Hello, glass!")
/// .font(.title2)
/// }
/// .padding()
/// }
/// ```
public struct LiquidGlassBackground<Content: View>: NSViewRepresentable {
private let content: Content
private let cornerRadius: CGFloat
private let variant: LiquidGlassVariant
/// Creates a new liquid‑glass container.
/// - Parameters:
/// - variant: Any ``LiquidGlassVariant`` (0–19). Defaults to `.v11`, which is visually super pleasing
/// - cornerRadius: Corner radius in points. Defaults to `10`.
/// - content: Your SwiftUI hierarchy.
public init(
variant: LiquidGlassVariant = .defaultVariant,
cornerRadius: CGFloat = 10,
@ViewBuilder content: () -> Content
) {
self.variant = variant
self.cornerRadius = cornerRadius
self.content = content()
}
@inline(__always)
private func setterSelector(for key: String, privateVariant: Bool = true) -> Selector? {
guard !key.isEmpty else { return nil }
let name: String
if privateVariant {
let cleaned = key.hasPrefix("_") ? key : "_" + key
name = "set" + cleaned
} else {
let first = String(key.prefix(1)).uppercased()
let rest = String(key.dropFirst())
name = "set" + first + rest
}
return NSSelectorFromString(name + ":")
}
private typealias VariantSetterIMP = @convention(c) (AnyObject, Selector, Int) -> Void
private func callPrivateVariantSetter(on object: AnyObject, value: Int) {
guard
let sel = setterSelector(for: "variant", privateVariant: true),
let m = class_getInstanceMethod(object_getClass(object), sel)
else {
#if DEBUG
print("✗ LiquidGlassBackground: selector set_variant: not found. falling back to default")
#endif
return
}
let imp = method_getImplementation(m)
let f = unsafeBitCast(imp, to: VariantSetterIMP.self)
f(object, sel, value)
}
public func makeNSView(context: Context) -> NSView {
// `NSGlassEffectView` is private. Look it up dynamically to avoid compile‑time coupling.
if let glassType = NSClassFromString("NSGlassEffectView") as? NSView.Type {
let glass = glassType.init(frame: .zero)
glass.setValue(cornerRadius, forKey: "cornerRadius")
callPrivateVariantSetter(on: glass, value: variant.rawValue)
let hosting = NSHostingView(rootView: content)
hosting.translatesAutoresizingMaskIntoConstraints = false
glass.setValue(hosting, forKey: "contentView")
return glass
}
// Fallback for earlier macOS – use an ordinary blur.
let fallback = NSVisualEffectView()
fallback.material = .underWindowBackground
let hosting = NSHostingView(rootView: content)
hosting.translatesAutoresizingMaskIntoConstraints = false
fallback.addSubview(hosting)
NSLayoutConstraint.activate([
hosting.leadingAnchor.constraint(equalTo: fallback.leadingAnchor),
hosting.trailingAnchor.constraint(equalTo: fallback.trailingAnchor),
hosting.topAnchor.constraint(equalTo: fallback.topAnchor),
hosting.bottomAnchor.constraint(equalTo: fallback.bottomAnchor)
])
return fallback
}
public func updateNSView(_ nsView: NSView, context: Context) {
if let hosting = nsView.value(forKey: "contentView") as? NSHostingView<Content> {
hosting.rootView = content
}
nsView.setValue(cornerRadius, forKey: "cornerRadius")
callPrivateVariantSetter(on: nsView, value: variant.rawValue)
}
}

View file

@ -0,0 +1,67 @@
import Foundation
import Defaults
import AtollExtensionKit
import SwiftUI
struct ExtensionLiveActivityPayload: Identifiable, Hashable, Codable {
let bundleIdentifier: String
let descriptor: AtollLiveActivityDescriptor
let receivedAt: Date
var id: String { descriptor.id }
var priority: AtollLiveActivityPriority { descriptor.priority }
}
struct ExtensionLockScreenWidgetPayload: Identifiable, Hashable, Codable {
let bundleIdentifier: String
let descriptor: AtollLockScreenWidgetDescriptor
let receivedAt: Date
var id: String { descriptor.id }
var priority: AtollLiveActivityPriority { descriptor.priority }
}
struct ExtensionNotchExperiencePayload: Identifiable, Hashable, Codable {
let bundleIdentifier: String
let descriptor: AtollNotchExperienceDescriptor
let receivedAt: Date
var id: String { descriptor.id }
var priority: AtollLiveActivityPriority { descriptor.priority }
var hasTabConfiguration: Bool { descriptor.tab != nil }
var hasMinimalisticConfiguration: Bool { descriptor.minimalistic != nil }
}
enum ExtensionValidationError: LocalizedError, Equatable {
case featureDisabled
case unauthorized
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."
}
}
}

View file

@ -0,0 +1,214 @@
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] }
var areNotchExperiencesEnabled: Bool { Defaults[.enableExtensionNotchExperiences] }
func updateFeatureToggles(extensionsEnabled: Bool? = nil,
liveActivitiesEnabled: Bool? = nil,
lockScreenWidgetsEnabled: Bool? = nil,
notchExperiencesEnabled: Bool? = nil) {
if let extensionsEnabled { Defaults[.enableThirdPartyExtensions] = extensionsEnabled }
if let liveActivitiesEnabled { Defaults[.enableExtensionLiveActivities] = liveActivitiesEnabled }
if let lockScreenWidgetsEnabled { Defaults[.enableExtensionLockScreenWidgets] = lockScreenWidgetsEnabled }
if let notchExperiencesEnabled { Defaults[.enableExtensionNotchExperiences] = notchExperiencesEnabled }
objectWillChange.send()
}
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)
case .notchExperiences:
record.notchExperienceTimestamps.append(now)
}
record.activityTimestamps = flushOldTimestamps(record.activityTimestamps)
record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps)
record.notchExperienceTimestamps = flushOldTimestamps(record.notchExperienceTimestamps)
}
}
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 canProcessNotchExperienceRequest(from bundleIdentifier: String) -> Bool {
guard preflight(bundleIdentifier: bundleIdentifier, scope: .notchExperiences) else { return false }
guard areNotchExperiencesEnabled else { return false }
return true
}
func recordDeniedRequest(bundleIdentifier: String, reason: String) {
updateEntry(bundleIdentifier: bundleIdentifier) { entry in
entry.lastDeniedReason = reason
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 }
}
}

View file

@ -0,0 +1,44 @@
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")
}
}
static func validate(_ descriptor: AtollNotchExperienceDescriptor) throws {
guard descriptor.isValid else {
throw ExtensionValidationError.invalidDescriptor("Missing mandatory fields")
}
if let durationHint = descriptor.durationHint {
guard durationHint > 0, durationHint <= 21_600 else {
throw ExtensionValidationError.invalidDescriptor("Duration hint must be between 0 and 6 hours")
}
}
}
}

View file

@ -0,0 +1,166 @@
import Foundation
/// Bridges extension payload updates between Atoll processes (main app, helpers, XPC services).
/// Stores the latest snapshot on disk and delivers change notifications through DistributedNotificationCenter.
final class ExtensionEventBridge {
static let shared = ExtensionEventBridge()
private let notificationCenter = DistributedNotificationCenter.default()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let processIdentifier = ProcessInfo.processInfo.processIdentifier
private let ioQueue = DispatchQueue(label: "com.ebullioscopic.Atoll.extensions.bridge", qos: .utility)
private init() {}
// MARK: - Public API
func broadcastLiveActivitySnapshot(_ payloads: [ExtensionLiveActivityPayload]) {
broadcast(payloads, channel: .liveActivities)
}
func broadcastLockScreenWidgetSnapshot(_ payloads: [ExtensionLockScreenWidgetPayload]) {
broadcast(payloads, channel: .lockScreenWidgets)
}
func broadcastNotchExperienceSnapshot(_ payloads: [ExtensionNotchExperiencePayload]) {
broadcast(payloads, channel: .notchExperiences)
}
func loadPersistedLiveActivities() -> [ExtensionLiveActivityPayload] {
loadSnapshot(channel: .liveActivities)
}
func loadPersistedLockScreenWidgets() -> [ExtensionLockScreenWidgetPayload] {
loadSnapshot(channel: .lockScreenWidgets)
}
func loadPersistedNotchExperiences() -> [ExtensionNotchExperiencePayload] {
loadSnapshot(channel: .notchExperiences)
}
func observeLiveActivitySnapshots(_ handler: @escaping ([ExtensionLiveActivityPayload], Int32) -> Void) -> NSObjectProtocol {
observe(channel: .liveActivities, handler: handler)
}
func observeLockScreenWidgetSnapshots(_ handler: @escaping ([ExtensionLockScreenWidgetPayload], Int32) -> Void) -> NSObjectProtocol {
observe(channel: .lockScreenWidgets, handler: handler)
}
func observeNotchExperienceSnapshots(_ handler: @escaping ([ExtensionNotchExperiencePayload], Int32) -> Void) -> NSObjectProtocol {
observe(channel: .notchExperiences, handler: handler)
}
func removeObserver(_ observer: NSObjectProtocol) {
notificationCenter.removeObserver(observer)
}
// MARK: - Snapshot Storage
private func broadcast<T: Codable>(_ payloads: [T], channel: Channel) {
ioQueue.async { [weak self] in
guard let self else { return }
guard let data = try? self.encoder.encode(payloads) else { return }
self.persistSnapshot(data, channel: channel)
let userInfo: [String: Any] = [
UserInfoKey.payloads: data,
UserInfoKey.sourcePID: NSNumber(value: self.processIdentifier)
]
self.notificationCenter.postNotificationName(
channel.notificationName,
object: nil,
userInfo: userInfo,
deliverImmediately: true
)
}
}
private func loadSnapshot<T: Codable>(channel: Channel) -> [T] {
let url = snapshotURL(for: channel)
guard FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else {
return []
}
return (try? decoder.decode([T].self, from: data)) ?? []
}
private func persistSnapshot(_ data: Data, channel: Channel) {
let url = snapshotURL(for: channel)
do {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: .atomic)
} catch {
Logger.log("Failed to persist extension snapshot for \(channel): \(error.localizedDescription)", category: .extensions)
}
}
private func snapshotURL(for channel: Channel) -> URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
let directory = appSupport.appendingPathComponent("AtollExtensions", isDirectory: true)
switch channel {
case .liveActivities:
return directory.appendingPathComponent("live_activities.json", isDirectory: false)
case .lockScreenWidgets:
return directory.appendingPathComponent("lock_screen_widgets.json", isDirectory: false)
case .notchExperiences:
return directory.appendingPathComponent("notch_experiences.json", isDirectory: false)
}
}
// MARK: - Observation
private func observe<T: Codable>(channel: Channel,
handler: @escaping ([T], Int32) -> Void) -> NSObjectProtocol {
notificationCenter.addObserver(forName: channel.notificationName, object: nil, queue: .main) { [weak self] notification in
guard let self else { return }
let sourcePID = (notification.userInfo?[UserInfoKey.sourcePID] as? NSNumber)?.int32Value ?? 0
guard sourcePID != self.processIdentifier else { return }
if let data = notification.userInfo?[UserInfoKey.payloads] as? Data,
let payloads = try? self.decoder.decode([T].self, from: data) {
handler(payloads, sourcePID)
return
}
let snapshot: [T] = self.loadSnapshot(channel: channel)
handler(snapshot, sourcePID)
}
}
// MARK: - Helpers
private enum Channel: CustomStringConvertible {
case liveActivities
case lockScreenWidgets
case notchExperiences
var notificationName: Notification.Name {
switch self {
case .liveActivities:
return Notification.Name("com.ebullioscopic.Atoll.extensions.liveActivitySnapshot")
case .lockScreenWidgets:
return Notification.Name("com.ebullioscopic.Atoll.extensions.lockScreenWidgetSnapshot")
case .notchExperiences:
return Notification.Name("com.ebullioscopic.Atoll.extensions.notchExperienceSnapshot")
}
}
var description: String {
switch self {
case .liveActivities:
return "live-activities"
case .lockScreenWidgets:
return "lock-screen-widgets"
case .notchExperiences:
return "notch-experiences"
}
}
}
private enum UserInfoKey {
static let payloads = "payloads"
static let sourcePID = "sourcePID"
}
}

View file

@ -0,0 +1,205 @@
import Foundation
import Defaults
import AtollExtensionKit
import SwiftUI
@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 let eventBridge = ExtensionEventBridge.shared
private var liveActivityObserver: NSObjectProtocol?
private var suppressBroadcast = false
private let currentProcessID = ProcessInfo.processInfo.processIdentifier
private init() {
activeActivities = eventBridge.loadPersistedLiveActivities()
sortActivities()
liveActivityObserver = eventBridge.observeLiveActivitySnapshots { [weak self] payloads, sourcePID in
self?.applySnapshot(payloads, sourcePID: sourcePID)
}
}
deinit {
if let token = liveActivityObserver {
eventBridge.removeObserver(token)
}
}
func present(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
guard authorizationManager.canProcessLiveActivityRequest(from: bundleIdentifier) else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized")
throw ExtensionValidationError.unauthorized
}
guard descriptor.isValid else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
let isUpdate: Bool
if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) {
let payload = ExtensionLiveActivityPayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: activeActivities[index].receivedAt
)
activeActivities[index] = payload
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions)
isUpdate = true
} else {
guard activeActivities.count < Defaults[maxCapacityKey] else {
logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached")
throw ExtensionValidationError.exceedsCapacity
}
let payload = ExtensionLiveActivityPayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: .now
)
activeActivities.append(payload)
sortActivities()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities)
logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)")
isUpdate = false
}
broadcastSnapshot()
// Trigger sneak peek (defaulting to enabled for legacy descriptors)
let resolvedConfig = descriptor.sneakPeekConfig ?? .default
if resolvedConfig.enabled {
let shouldShow = !isUpdate || resolvedConfig.showOnUpdate
if shouldShow {
triggerSneakPeek(for: descriptor, bundleIdentifier: bundleIdentifier, config: resolvedConfig)
}
}
}
func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws {
guard descriptor.isValid else {
logDiagnostics("Rejected live activity update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
guard let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
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)
logDiagnostics("Updated live activity \(descriptor.id) for \(bundleIdentifier)")
broadcastSnapshot()
}
func dismiss(activityID: String, bundleIdentifier: String) {
let previousCount = activeActivities.count
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier }
}
if previousCount != activeActivities.count {
Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID)
logDiagnostics("Removed live activity \(activityID) for \(bundleIdentifier); remaining: \(activeActivities.count)")
broadcastSnapshot()
}
}
func dismissAll(for bundleIdentifier: String) {
let ids = activeActivities
.filter { $0.bundleIdentifier == bundleIdentifier }
.map { $0.descriptor.id }
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier }
}
ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) }
if !ids.isEmpty {
logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()
}
}
func sortedActivities(for coexistence: Bool = false) -> [ExtensionLiveActivityPayload] {
activeActivities
.filter { coexistence ? $0.descriptor.allowsMusicCoexistence : true }
.sorted(by: descriptorComparator)
}
func payload(bundleIdentifier: String, activityID: String) -> ExtensionLiveActivityPayload? {
activeActivities.first { $0.bundleIdentifier == bundleIdentifier && $0.descriptor.id == activityID }
}
private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool {
if lhs.descriptor.priority == rhs.descriptor.priority {
return lhs.receivedAt < rhs.receivedAt
}
return lhs.descriptor.priority > rhs.descriptor.priority
}
private func sortActivities() {
activeActivities.sort(by: descriptorComparator)
}
private func broadcastSnapshot() {
guard !suppressBroadcast else { return }
eventBridge.broadcastLiveActivitySnapshot(activeActivities)
logDiagnostics("Broadcasted live activity snapshot (count: \(activeActivities.count))")
}
private func applySnapshot(_ payloads: [ExtensionLiveActivityPayload], sourcePID: Int32) {
guard sourcePID != currentProcessID else { return }
suppressBroadcast = true
activeActivities = payloads.sorted(by: descriptorComparator)
suppressBroadcast = false
logDiagnostics("Applied external live activity snapshot from PID \(sourcePID) (count: \(payloads.count))")
}
private func triggerSneakPeek(for descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String, config: AtollSneakPeekConfig) {
let coordinator = DynamicIslandViewCoordinator.shared
let duration = config.duration ?? 2.5
let accentColor = descriptor.accentColor.swiftUIColor
let styleOverride: SneakPeekStyle? = {
guard let requestedStyle = config.style else { return nil }
switch requestedStyle {
case .inline:
logDiagnostics("Inline sneak peek requested for \(descriptor.id); host will show standard mode instead")
return .standard
case .standard:
return .standard
}
}()
let resolvedTitle = descriptor.sneakPeekTitle?.isEmpty == false ? descriptor.sneakPeekTitle! : descriptor.title
let resolvedSubtitle = descriptor.sneakPeekSubtitle ?? descriptor.subtitle ?? ""
// Pass the activity's id and bundleID to the sneak peek so the specialized view can look up details if needed
coordinator.toggleSneakPeek(
status: true,
type: .extensionLiveActivity(bundleID: bundleIdentifier, activityID: descriptor.id),
duration: duration,
value: 0,
icon: "",
title: resolvedTitle,
subtitle: resolvedSubtitle,
accentColor: accentColor,
styleOverride: styleOverride
)
logDiagnostics("Triggered sneak peek for \(descriptor.id) from \(bundleIdentifier) with duration \(duration)s")
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
}

View file

@ -0,0 +1,152 @@
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 let eventBridge = ExtensionEventBridge.shared
private var widgetObserver: NSObjectProtocol?
private var suppressBroadcast = false
private let currentProcessID = ProcessInfo.processInfo.processIdentifier
private init() {
activeWidgets = eventBridge.loadPersistedLockScreenWidgets()
sortWidgets()
presentationController = ExtensionLockScreenWidgetPresentationController(manager: self)
presentationController.activate()
widgetObserver = eventBridge.observeLockScreenWidgetSnapshots { [weak self] payloads, sourcePID in
self?.applySnapshot(payloads, sourcePID: sourcePID)
}
}
deinit {
if let token = widgetObserver {
eventBridge.removeObserver(token)
}
}
func present(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
guard authorizationManager.canProcessLockScreenRequest(from: bundleIdentifier) else {
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized")
throw ExtensionValidationError.unauthorized
}
guard descriptor.isValid else {
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
if let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) {
let payload = ExtensionLockScreenWidgetPayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: activeWidgets[index].receivedAt
)
activeWidgets[index] = payload
sortWidgets()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
Logger.log("Replaced extension widget \(descriptor.id) for \(bundleIdentifier)", category: .extensions)
broadcastSnapshot()
return
}
guard activeWidgets.count < Defaults[maxCapacityKey] else {
logDiagnostics("Rejected lock screen widget \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached")
throw ExtensionValidationError.exceedsCapacity
}
let payload = ExtensionLockScreenWidgetPayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: .now
)
activeWidgets.append(payload)
sortWidgets()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .lockScreenWidgets)
logDiagnostics("Queued lock screen widget \(descriptor.id) for \(bundleIdentifier); total widgets: \(activeWidgets.count)")
broadcastSnapshot()
}
func update(descriptor: AtollLockScreenWidgetDescriptor, bundleIdentifier: String) throws {
guard descriptor.isValid else {
logDiagnostics("Rejected lock screen widget update \(descriptor.id) from \(bundleIdentifier): descriptor validation failed")
throw ExtensionValidationError.invalidDescriptor("Structure validation failed")
}
guard let index = activeWidgets.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
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)
logDiagnostics("Updated lock screen widget \(descriptor.id) for \(bundleIdentifier)")
broadcastSnapshot()
}
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)
logDiagnostics("Removed lock screen widget \(widgetID) for \(bundleIdentifier); remaining: \(activeWidgets.count)")
broadcastSnapshot()
}
}
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) }
if !ids.isEmpty {
logDiagnostics("Removed all lock screen widgets for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()
}
}
private func sortWidgets() {
activeWidgets.sort { lhs, rhs in
if lhs.priority == rhs.priority {
return lhs.receivedAt < rhs.receivedAt
}
return lhs.priority > rhs.priority
}
}
private func broadcastSnapshot() {
guard !suppressBroadcast else { return }
eventBridge.broadcastLockScreenWidgetSnapshot(activeWidgets)
logDiagnostics("Broadcasted lock screen widget snapshot (count: \(activeWidgets.count))")
}
private func applySnapshot(_ payloads: [ExtensionLockScreenWidgetPayload], sourcePID: Int32) {
guard sourcePID != currentProcessID else { return }
suppressBroadcast = true
activeWidgets = payloads.sorted { lhs, rhs in
if lhs.priority == rhs.priority {
return lhs.receivedAt < rhs.receivedAt
}
return lhs.priority > rhs.priority
}
suppressBroadcast = false
logDiagnostics("Applied external lock screen widget snapshot from PID \(sourcePID) (count: \(payloads.count))")
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
}

View file

@ -0,0 +1,298 @@
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
private var lastVisibilityState: VisibilityState?
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()
self.updateVisibilityLog(.hidden(reason: "feature-disabled"))
}
}
.store(in: &cancellables)
}
private func refreshPresentation() {
guard isLocked, LockScreenManager.shared.currentLockStatus, Defaults[.enableExtensionLockScreenWidgets] else {
windowPool.hideAll()
updateVisibilityLog(.hidden(reason: "lock-state"))
return
}
guard cachedPayloads.isEmpty == false else {
windowPool.hideAll()
updateVisibilityLog(.hidden(reason: "no-payloads"))
return
}
windowPool.sync(with: cachedPayloads)
updateVisibilityLog(.showing(count: cachedPayloads.count))
}
private func updateVisibilityLog(_ state: VisibilityState) {
guard state != lastVisibilityState else { return }
lastVisibilityState = state
switch state {
case let .hidden(reason):
logDiagnostics("Lock screen widgets hidden (reason: \(reason))")
case let .showing(count):
logDiagnostics("Presenting \(count) extension lock screen widget(s)")
}
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
private enum VisibilityState: Equatable {
case hidden(reason: String)
case showing(count: Int)
}
}
@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()
logDiagnostics("Cleared all lock screen widget windows")
}
private func renderWindow(for payload: ExtensionLockScreenWidgetPayload) {
guard let screen = currentScreen() else { return }
let descriptor = payload.descriptor
let isNewWindow = windows[payload.id] == nil
let window = ensureWindow(for: payload.id)
let hosting = NSHostingView(rootView: ExtensionLockScreenWidgetView(payload: payload))
hosting.frame = NSRect(origin: .zero, size: descriptor.size)
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()
if isNewWindow {
logDiagnostics("Created lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id) style=\(descriptor.layoutStyle)")
} else {
logDiagnostics("Updated lock screen widget window for \(payload.bundleIdentifier) id=\(payload.id)")
}
}
private func ensureWindow(for id: String) -> NSWindow {
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)
logDiagnostics("Removed lock screen widget window id=\(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)
logDiagnostics("Realigned lock screen widget 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 = safeInsets(for: descriptor.position.clampMode)
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 safeInsets(for clampMode: AtollWidgetPosition.ClampMode) -> NSEdgeInsets {
switch clampMode {
case .safeRegion:
return NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48)
case .relaxed:
return NSEdgeInsets(top: 96, left: 24, bottom: 72, right: 24)
case .unconstrained:
return NSEdgeInsets(top: 16, left: 0, bottom: 32, right: 0)
}
}
private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
guard lower <= upper else { return value }
return min(max(value, lower), upper)
}
private func currentScreen() -> NSScreen? {
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
}
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}

View file

@ -0,0 +1,215 @@
import Foundation
import Defaults
import AtollExtensionKit
import SwiftUI
@MainActor
final class ExtensionNotchExperienceManager: ObservableObject {
static let shared = ExtensionNotchExperienceManager()
@Published private(set) var activeExperiences: [ExtensionNotchExperiencePayload] = []
private let authorizationManager = ExtensionAuthorizationManager.shared
private let maxCapacityKey = Defaults.Keys.extensionNotchExperienceCapacity
private let eventBridge = ExtensionEventBridge.shared
private var observerToken: NSObjectProtocol?
private var suppressBroadcast = false
private let currentProcessID = ProcessInfo.processInfo.processIdentifier
private init() {
activeExperiences = eventBridge.loadPersistedNotchExperiences()
sortExperiences()
observerToken = eventBridge.observeNotchExperienceSnapshots { [weak self] payloads, sourcePID in
self?.applySnapshot(payloads, sourcePID: sourcePID)
}
}
deinit {
if let observerToken {
eventBridge.removeObserver(observerToken)
}
}
// MARK: - Presentation Lifecycle
func present(descriptor: AtollNotchExperienceDescriptor, bundleIdentifier: String) throws {
guard authorizationManager.canProcessNotchExperienceRequest(from: bundleIdentifier) else {
logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): scope disabled or bundle unauthorized")
throw ExtensionValidationError.unauthorized
}
guard descriptor.bundleIdentifier == bundleIdentifier else {
logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): bundle mismatch (descriptor: \(descriptor.bundleIdentifier))")
throw ExtensionValidationError.invalidDescriptor("Bundle identifier mismatch")
}
try ExtensionDescriptorValidator.validate(descriptor)
try ensureWebContentSupport(for: descriptor)
let isUpdate: Bool
if let index = activeExperiences.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) {
let payload = ExtensionNotchExperiencePayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: activeExperiences[index].receivedAt
)
activeExperiences[index] = payload
sortExperiences()
isUpdate = true
Logger.log("Replaced notch experience \(descriptor.id) for \(bundleIdentifier)", category: .extensions)
} else {
guard activeExperiences.count < Defaults[maxCapacityKey] else {
logDiagnostics("Rejected notch experience \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached")
throw ExtensionValidationError.exceedsCapacity
}
let payload = ExtensionNotchExperiencePayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: .now
)
activeExperiences.append(payload)
sortExperiences()
isUpdate = false
logDiagnostics("Queued notch experience \(descriptor.id) for \(bundleIdentifier); total experiences: \(activeExperiences.count)")
}
authorizationManager.recordActivity(for: bundleIdentifier, scope: .notchExperiences)
broadcastSnapshot()
if let tabConfig = descriptor.tab, Defaults[.enableExtensionNotchTabs] {
Logger.log("Notch experience tab ready (title: \(tabConfig.title))", category: .extensions)
}
if descriptor.minimalistic != nil && Defaults[.enableExtensionNotchMinimalisticOverrides] {
Logger.log("Notch experience minimalistic override available", category: .extensions)
}
if !isUpdate {
logDiagnostics("Stored notch experience \(descriptor.id) for \(bundleIdentifier); priority \(descriptor.priority.rawValue)")
}
}
func update(descriptor: AtollNotchExperienceDescriptor, bundleIdentifier: String) throws {
try ExtensionDescriptorValidator.validate(descriptor)
guard descriptor.bundleIdentifier == bundleIdentifier else {
logDiagnostics("Rejected notch experience update \(descriptor.id) from \(bundleIdentifier): bundle mismatch (descriptor: \(descriptor.bundleIdentifier))")
throw ExtensionValidationError.invalidDescriptor("Bundle identifier mismatch")
}
guard let index = activeExperiences.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) else {
throw ExtensionValidationError.invalidDescriptor("Missing existing notch experience")
}
try ensureWebContentSupport(for: descriptor)
let payload = ExtensionNotchExperiencePayload(
bundleIdentifier: bundleIdentifier,
descriptor: descriptor,
receivedAt: activeExperiences[index].receivedAt
)
activeExperiences[index] = payload
sortExperiences()
authorizationManager.recordActivity(for: bundleIdentifier, scope: .notchExperiences)
logDiagnostics("Updated notch experience \(descriptor.id) for \(bundleIdentifier)")
broadcastSnapshot()
}
func dismiss(experienceID: String, bundleIdentifier: String) {
let previousCount = activeExperiences.count
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeExperiences.removeAll { $0.descriptor.id == experienceID && $0.bundleIdentifier == bundleIdentifier }
}
guard previousCount != activeExperiences.count else { return }
Logger.log("Dismissed notch experience \(experienceID) from \(bundleIdentifier)", category: .extensions)
ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID)
logDiagnostics("Removed notch experience \(experienceID) for \(bundleIdentifier); remaining: \(activeExperiences.count)")
broadcastSnapshot()
}
func dismissAll(for bundleIdentifier: String) {
let ids = activeExperiences
.filter { $0.bundleIdentifier == bundleIdentifier }
.map { $0.descriptor.id }
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
activeExperiences.removeAll { $0.bundleIdentifier == bundleIdentifier }
}
ids.forEach { ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) }
if !ids.isEmpty {
logDiagnostics("Removed all notch experiences for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))")
broadcastSnapshot()
}
}
// MARK: - Presentation Resolution
func highestPriorityTabPayload() -> ExtensionNotchExperiencePayload? {
guard Defaults[.enableThirdPartyExtensions],
Defaults[.enableExtensionNotchExperiences],
Defaults[.enableExtensionNotchTabs] else {
return nil
}
return activeExperiences.first(where: { $0.descriptor.tab != nil })
}
func minimalisticReplacementPayload() -> ExtensionNotchExperiencePayload? {
guard Defaults[.enableThirdPartyExtensions],
Defaults[.enableExtensionNotchExperiences],
Defaults[.enableExtensionNotchMinimalisticOverrides] else {
return nil
}
return activeExperiences.first(where: { $0.descriptor.minimalistic != nil })
}
func payload(bundleIdentifier: String, experienceID: String) -> ExtensionNotchExperiencePayload? {
activeExperiences.first { $0.bundleIdentifier == bundleIdentifier && $0.descriptor.id == experienceID }
}
func payload(experienceID: String) -> ExtensionNotchExperiencePayload? {
activeExperiences.first { $0.descriptor.id == experienceID }
}
// MARK: - Snapshot Sync
private func sortExperiences() {
activeExperiences.sort(by: descriptorComparator)
}
private func descriptorComparator(lhs: ExtensionNotchExperiencePayload, rhs: ExtensionNotchExperiencePayload) -> Bool {
if lhs.priority == rhs.priority {
return lhs.receivedAt < rhs.receivedAt
}
return lhs.priority > rhs.priority
}
private func broadcastSnapshot() {
guard !suppressBroadcast else { return }
eventBridge.broadcastNotchExperienceSnapshot(activeExperiences)
logDiagnostics("Broadcasted notch experience snapshot (count: \(activeExperiences.count))")
}
private func applySnapshot(_ payloads: [ExtensionNotchExperiencePayload], sourcePID: Int32) {
guard sourcePID != currentProcessID else { return }
suppressBroadcast = true
activeExperiences = payloads.sorted(by: descriptorComparator)
suppressBroadcast = false
logDiagnostics("Applied external notch experience snapshot from PID \(sourcePID) (count: \(payloads.count))")
}
private func ensureWebContentSupport(for descriptor: AtollNotchExperienceDescriptor) throws {
guard descriptor.hasWebContent else { return }
guard Defaults[.enableExtensionNotchInteractiveWebViews] else {
logDiagnostics("Rejected notch experience \(descriptor.id) due to web content while interactive web views are disabled")
throw ExtensionValidationError.unsupportedContent
}
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
}
private extension AtollNotchExperienceDescriptor {
var hasWebContent: Bool {
if tab?.webContent != nil { return true }
if minimalistic?.webContent != nil { return true }
return false
}
}

View file

@ -8,6 +8,7 @@
import SwiftUI
import Defaults
import Lottie
import Foundation
private let availableDirectories = FileManager
.default
@ -97,6 +98,118 @@ enum AnimationSource: Codable, Hashable, Equatable {
}
}
// MARK: - Extension Authorization Models
enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializable {
case liveActivities
case lockScreenWidgets
case notchExperiences
var displayName: String {
switch self {
case .liveActivities: return "Live Activities"
case .lockScreenWidgets: return "Lock Screen Widgets"
case .notchExperiences: return "Notch Experiences"
}
}
}
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 notchExperienceTimestamps: [Date]
var id: String { bundleIdentifier }
init(
bundleIdentifier: String,
activityTimestamps: [Date] = [],
widgetTimestamps: [Date] = [],
notchExperienceTimestamps: [Date] = []
) {
self.bundleIdentifier = bundleIdentifier
self.activityTimestamps = activityTimestamps
self.widgetTimestamps = widgetTimestamps
self.notchExperienceTimestamps = notchExperienceTimestamps
}
private enum CodingKeys: String, CodingKey {
case bundleIdentifier
case activityTimestamps
case widgetTimestamps
case notchExperienceTimestamps
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
bundleIdentifier = try container.decode(String.self, forKey: .bundleIdentifier)
activityTimestamps = try container.decodeIfPresent([Date].self, forKey: .activityTimestamps) ?? []
widgetTimestamps = try container.decodeIfPresent([Date].self, forKey: .widgetTimestamps) ?? []
notchExperienceTimestamps = try container.decodeIfPresent([Date].self, forKey: .notchExperienceTimestamps) ?? []
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(bundleIdentifier, forKey: .bundleIdentifier)
try container.encode(activityTimestamps, forKey: .activityTimestamps)
try container.encode(widgetTimestamps, forKey: .widgetTimestamps)
try container.encode(notchExperienceTimestamps, forKey: .notchExperienceTimestamps)
}
}
enum CalendarSelectionState: Codable, Defaults.Serializable {
case all
case selected(Set<String>)
@ -590,6 +703,23 @@ extension Defaults.Keys {
static let lockScreenMusicAlbumParallaxEnabled = Key<Bool>("lockScreenMusicAlbumParallaxEnabled", default: false)
static let lockScreenTimerVerticalOffset = Key<Double>("lockScreenTimerVerticalOffset", default: 0)
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
static let lockScreenGlassCustomizationMode = Key<LockScreenGlassCustomizationMode>(
"lockScreenGlassCustomizationMode",
default: .standard
)
static let lockScreenTimerGlassStyle = Key<LockScreenGlassStyle>("lockScreenTimerGlassStyle", default: .frosted)
static let lockScreenTimerGlassCustomizationMode = Key<LockScreenGlassCustomizationMode>(
"lockScreenTimerGlassCustomizationMode",
default: .standard
)
static let lockScreenMusicLiquidGlassVariant = Key<LiquidGlassVariant>(
"lockScreenMusicLiquidGlassVariant",
default: .defaultVariant
)
static let lockScreenTimerLiquidGlassVariant = Key<LiquidGlassVariant>(
"lockScreenTimerLiquidGlassVariant",
default: .defaultVariant
)
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
@ -714,6 +844,21 @@ extension Defaults.Keys {
static let selectedAIModel = Key<AIModel?>("selectedAIModel", default: nil)
static let enableThinkingMode = Key<Bool>("enableThinkingMode", default: false)
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 enableExtensionNotchExperiences = Key<Bool>("enableExtensionNotchExperiences", default: true)
static let enableExtensionNotchTabs = Key<Bool>("enableExtensionNotchTabs", default: true)
static let enableExtensionNotchMinimalisticOverrides = Key<Bool>("enableExtensionNotchMinimalisticOverrides", default: true)
static let enableExtensionNotchInteractiveWebViews = Key<Bool>("enableExtensionNotchInteractiveWebViews", 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)
static let extensionNotchExperienceCapacity = Key<Int>("extensionNotchExperienceCapacity", default: 2)
// MARK: Keyboard Shortcuts
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)

View file

@ -0,0 +1,229 @@
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 notchExperienceManager = ExtensionNotchExperienceManager.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) {
Task { @MainActor [weak self] in
guard let self else { return }
guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return }
guard self.authorizationManager.isExtensionsFeatureEnabled else {
reply(false, ExtensionValidationError.featureDisabled.asNSError)
return
}
let entry = self.authorizationManager.ensureEntryExists(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName())
if entry.status == .pending {
self.authorizationManager.authorize(bundleIdentifier: self.bundleIdentifier, appName: self.resolvedApplicationName())
self.host?.notifyAuthorizationChange(bundleIdentifier: self.bundleIdentifier, isAuthorized: true)
reply(true, nil)
return
}
reply(entry.isAuthorized, entry.isAuthorized ? nil : ExtensionValidationError.unauthorized.asNSError)
}
}
func checkAuthorization(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool) -> Void) {
Task { @MainActor [weak self] in
guard let self else { return }
guard providedBundleIdentifier == self.bundleIdentifier else {
reply(false)
return
}
let isAuthorized = self.authorizationManager.authorizationEntry(for: self.bundleIdentifier)?.isAuthorized ?? false
reply(isAuthorized)
}
}
// MARK: Live Activities
func presentLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
respond(reply: reply) { service in
let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
try ExtensionDescriptorValidator.validate(descriptor)
service.logDiagnostics("Received live activity payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue), coexistence: \(descriptor.allowsMusicCoexistence))")
try service.liveActivityManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier)
service.logDiagnostics("Live activity \(descriptor.id) stored for \(service.bundleIdentifier); active activities: \(service.liveActivityManager.activeActivities.count)")
}
}
func updateLiveActivity(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
respond(reply: reply) { service in
let descriptor = try service.decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData)
try ExtensionDescriptorValidator.validate(descriptor)
service.logDiagnostics("Received live activity update from \(service.bundleIdentifier) (id: \(descriptor.id))")
try service.liveActivityManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier)
service.logDiagnostics("Live activity \(descriptor.id) updated for \(service.bundleIdentifier)")
}
}
func dismissLiveActivity(activityID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) {
Task { @MainActor [weak self] in
guard let self else { return }
guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return }
self.logDiagnostics("Received live activity dismissal from \(self.bundleIdentifier) (id: \(activityID))")
self.liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: self.bundleIdentifier)
self.logDiagnostics("Live activity \(activityID) dismissed for \(self.bundleIdentifier)")
reply(true, nil)
}
}
// MARK: Lock Screen Widgets
func presentLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
respond(reply: reply) { service in
let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
try ExtensionDescriptorValidator.validate(descriptor)
service.logDiagnostics("Received lock screen widget payload from \(service.bundleIdentifier) (id: \(descriptor.id), style: \(descriptor.layoutStyle))")
try service.widgetManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier)
service.logDiagnostics("Lock screen widget \(descriptor.id) stored for \(service.bundleIdentifier); active widgets: \(service.widgetManager.activeWidgets.count)")
}
}
func updateLockScreenWidget(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
respond(reply: reply) { service in
let descriptor = try service.decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData)
try ExtensionDescriptorValidator.validate(descriptor)
service.logDiagnostics("Received lock screen widget update from \(service.bundleIdentifier) (id: \(descriptor.id))")
try service.widgetManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier)
service.logDiagnostics("Lock screen widget \(descriptor.id) updated for \(service.bundleIdentifier)")
}
}
func dismissLockScreenWidget(widgetID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) {
Task { @MainActor [weak self] in
guard let self else { return }
guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return }
self.logDiagnostics("Received lock screen widget dismissal from \(self.bundleIdentifier) (id: \(widgetID))")
self.widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: self.bundleIdentifier)
self.logDiagnostics("Lock screen widget \(widgetID) dismissed for \(self.bundleIdentifier)")
reply(true, nil)
}
}
// MARK: Notch Experiences
func presentNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
respond(reply: reply) { service in
let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData)
try ExtensionDescriptorValidator.validate(descriptor)
service.logDiagnostics("Received notch experience payload from \(service.bundleIdentifier) (id: \(descriptor.id), priority: \(descriptor.priority.rawValue))")
try service.notchExperienceManager.present(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier)
service.logDiagnostics("Notch experience \(descriptor.id) stored for \(service.bundleIdentifier); active experiences: \(service.notchExperienceManager.activeExperiences.count)")
}
}
func updateNotchExperience(descriptorData: Data, reply: @escaping (Bool, Error?) -> Void) {
respond(reply: reply) { service in
let descriptor = try service.decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData)
try ExtensionDescriptorValidator.validate(descriptor)
service.logDiagnostics("Received notch experience update from \(service.bundleIdentifier) (id: \(descriptor.id))")
try service.notchExperienceManager.update(descriptor: descriptor, bundleIdentifier: service.bundleIdentifier)
service.logDiagnostics("Notch experience \(descriptor.id) updated for \(service.bundleIdentifier)")
}
}
func dismissNotchExperience(experienceID: String, bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) {
Task { @MainActor [weak self] in
guard let self else { return }
guard self.validate(bundleIdentifier: providedBundleIdentifier, reply: reply) else { return }
self.logDiagnostics("Received notch experience dismissal from \(self.bundleIdentifier) (id: \(experienceID))")
self.notchExperienceManager.dismiss(experienceID: experienceID, bundleIdentifier: self.bundleIdentifier)
self.logDiagnostics("Notch experience \(experienceID) dismissed for \(self.bundleIdentifier)")
reply(true, nil)
}
}
// MARK: Diagnostics
func getVersion(reply: @escaping (String) -> Void) {
Task { @MainActor in
reply(appVersion)
}
}
// MARK: Helpers
private func respond(reply: @escaping (Bool, Error?) -> Void, operation: @escaping (ExtensionXPCService) throws -> Void) {
Task { @MainActor [weak self] in
guard let self else { return }
do {
try operation(self)
reply(true, nil)
} catch {
if Defaults[.extensionDiagnosticsLoggingEnabled] {
Logger.log("Extension XPC request failed: \(error)", category: .extensions)
}
reply(false, error.asNSError)
}
}
}
private func validate(bundleIdentifier providedBundleIdentifier: String, reply: @escaping (Bool, Error?) -> Void) -> Bool {
guard providedBundleIdentifier == bundleIdentifier else {
let error = ExtensionXPCServiceError.bundleMismatch(expected: bundleIdentifier, received: providedBundleIdentifier)
logDiagnostics("Rejected XPC request due to bundle mismatch. Expected \(bundleIdentifier) received \(providedBundleIdentifier)")
reply(false, error.asNSError)
return false
}
return true
}
private func logDiagnostics(_ message: String) {
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
Logger.log(message, category: .extensions)
}
private func resolvedApplicationName() -> String {
guard let processIdentifier = connection?.processIdentifier,
processIdentifier != 0,
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 }
}

View file

@ -0,0 +1,137 @@
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)
}
}
func notifyNotchExperienceDismiss(bundleIdentifier: String, experienceID: String) {
deliver(to: bundleIdentifier) { client in
client.notchExperienceDidDismiss(experienceID: experienceID)
}
}
private func resolveBundleIdentifier(for connection: NSXPCConnection) -> String? {
let processIdentifier = connection.processIdentifier
guard processIdentifier != 0 else { return nil }
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)
}
}

View file

@ -1,4 +1,5 @@
import Foundation
import OSLog
import SwiftUI
enum LogCategory: String {
@ -11,9 +12,42 @@ enum LogCategory: String {
case warning = "⚠️"
case success = "✅"
case debug = "🔍"
case extensions = "🧩"
var osCategoryName: String {
switch self {
case .lifecycle: return "lifecycle"
case .memory: return "memory"
case .performance: return "performance"
case .ui: return "ui"
case .network: return "network"
case .error: return "error"
case .warning: return "warning"
case .success: return "success"
case .debug: return "debug"
case .extensions: return "extensions"
}
}
}
struct Logger {
private static let subsystem = "com.ebullioscopic.Atoll"
private static let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static var osLoggerCache: [LogCategory: OSLog] = [:]
private static func osLogger(for category: LogCategory) -> OSLog {
if let cached = osLoggerCache[category] {
return cached
}
let logger = OSLog(subsystem: subsystem, category: category.osCategoryName)
osLoggerCache[category] = logger
return logger
}
static func log(
_ message: String,
category: LogCategory,
@ -22,8 +56,14 @@ struct Logger {
line: Int = #line
) {
let fileName = (file as NSString).lastPathComponent
let timestamp = ISO8601DateFormatter().string(from: Date())
print("\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)")
let timestamp = dateFormatter.string(from: Date())
let entry = "\(category.rawValue) [\(timestamp)] [\(fileName):\(line)] \(function) - \(message)"
let logger = osLogger(for: category)
os_log("%{public}@", log: logger, type: .default, entry)
#if DEBUG
Swift.print(entry)
#endif
}
static func trackMemory(