mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #256 from Ebullioscopic/feature/extensions
Feature/extensions
This commit is contained in:
commit
c42c4509d7
29 changed files with 6012 additions and 121 deletions
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
/* Begin PBXBuildFile section */
|
||||
7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7E61181E2E24071E0050006C /* SkyLightWindow */; };
|
||||
7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7E288D112F1659EF0020C117 /* AtollExtensionKit */; };
|
||||
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; };
|
||||
7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; };
|
||||
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; };
|
||||
|
|
@ -26,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" */;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
507
DynamicIsland/components/Settings/ExtensionsSettings.swift
Normal file
507
DynamicIsland/components/Settings/ExtensionsSettings.swift
Normal 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()
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
134
DynamicIsland/helpers/LiquidGlassBackground.swift
Normal file
134
DynamicIsland/helpers/LiquidGlassBackground.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
DynamicIsland/managers/Extensions/ExtensionEventBridge.swift
Normal file
166
DynamicIsland/managers/Extensions/ExtensionEventBridge.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
229
DynamicIsland/services/Extensions/ExtensionXPCService.swift
Normal file
229
DynamicIsland/services/Extensions/ExtensionXPCService.swift
Normal 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 }
|
||||
}
|
||||
137
DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift
Normal file
137
DynamicIsland/services/Extensions/ExtensionXPCServiceHost.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue