Something in the way

This commit is contained in:
Hariharan Mudaliar 2026-01-13 14:35:23 +05:30
commit 9bd445986d
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
19 changed files with 2395 additions and 1 deletions

View file

@ -0,0 +1,96 @@
import SwiftUI
import AtollExtensionKit
struct ExtensionLiveActivityStandaloneView: View {
let payload: ExtensionLiveActivityPayload
let notchWidth: CGFloat
let notchHeight: CGFloat
let isHovering: Bool
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var contentHeight: CGFloat {
max(0, notchHeight - (isHovering ? 0 : 12))
}
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
var body: some View {
let baseWingWidth = max(contentHeight, 44)
let trailingWidth = ExtensionLayoutMetrics.trailingWidth(
for: payload,
baseWidth: baseWingWidth,
maxWidth: notchWidth * 0.42
)
let centerWidth = max(96, notchWidth - baseWingWidth - trailingWidth)
return HStack(spacing: 0) {
ExtensionCompositeIconView(
leading: descriptor.leadingIcon,
badge: descriptor.badgeIcon,
accent: accentColor,
size: contentHeight
)
.frame(width: baseWingWidth, height: contentHeight)
Rectangle()
.fill(Color.black)
.frame(width: centerWidth, height: contentHeight)
.overlay(centerContent)
ExtensionMusicWingView(payload: payload, notchHeight: contentHeight)
.frame(width: trailingWidth, height: contentHeight)
}
.frame(width: notchWidth, height: notchHeight + (isHovering ? 8 : 0))
.animation(.smooth(duration: 0.25), value: payload.id)
}
private var centerContent: some View {
VStack(alignment: .leading, spacing: 2) {
Text(descriptor.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
if let subtitle = descriptor.subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Color.white.opacity(0.7))
.lineLimit(1)
}
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct ExtensionMusicWingView: View {
let payload: ExtensionLiveActivityPayload
let notchHeight: CGFloat
private var descriptor: AtollLiveActivityDescriptor { payload.descriptor }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
var body: some View {
VStack(alignment: .trailing, spacing: 6) {
if case .none = descriptor.trailingContent {
EmptyView()
} else {
ExtensionTrailingContentView(content: descriptor.trailingContent, accent: accentColor)
.frame(maxWidth: .infinity, alignment: .trailing)
}
if let indicator = descriptor.progressIndicator, indicator != .none {
ExtensionProgressIndicatorView(
indicator: indicator,
progress: descriptor.progress,
accent: accentColor,
estimatedDuration: descriptor.estimatedDuration
)
.frame(maxWidth: .infinity, alignment: .trailing)
} else {
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(.trailing, 8)
.padding(.vertical, 6)
}
}

View file

@ -0,0 +1,204 @@
import SwiftUI
import AtollExtensionKit
struct ExtensionLockScreenWidgetView: View {
let payload: ExtensionLockScreenWidgetPayload
private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor }
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
var body: some View {
ZStack {
backgroundView
contentView
.padding(.horizontal, descriptor.layoutStyle == .circular ? 10 : 16)
.padding(.vertical, descriptor.layoutStyle == .circular ? 10 : 12)
}
.frame(width: descriptor.size.width, height: descriptor.size.height)
.clipShape(RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.04), lineWidth: 1)
)
}
@ViewBuilder
private var contentView: some View {
switch descriptor.layoutStyle {
case .inline, .card, .custom:
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in
view(for: element)
.frame(maxWidth: .infinity, alignment: alignment(for: element))
.accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)")
}
}
case .circular:
ZStack {
ForEach(Array(descriptor.content.enumerated()), id: \.offset) { index, element in
view(for: element)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.accessibilityIdentifier("extension-widget-element-\(payload.id)-\(index)")
}
}
}
}
@ViewBuilder
private func view(for element: AtollWidgetContentElement) -> some View {
switch element {
case let .text(text, font: font, color: color, alignment: _):
Text(text)
.font(font.swiftUIFont())
.foregroundStyle((color?.swiftUIColor) ?? Color.white.opacity(0.9))
.lineLimit(2)
case let .icon(iconDescriptor, tint):
ExtensionIconView(
descriptor: iconDescriptor,
tint: (tint?.swiftUIColor) ?? accentColor,
size: CGSize(width: 28, height: 28),
cornerRadius: 8
)
case let .progress(indicator, value, color):
ExtensionProgressIndicatorView(
indicator: indicator,
progress: value,
accent: (color?.swiftUIColor) ?? accentColor,
estimatedDuration: nil
)
case let .graph(data, color, size):
ExtensionGraphView(data: data, color: color.swiftUIColor, size: size)
.frame(width: size.width, height: size.height)
case let .gauge(value, minValue, maxValue, style, color):
ExtensionGaugeView(
value: value,
minValue: minValue,
maxValue: maxValue,
style: style,
accent: (color?.swiftUIColor) ?? accentColor
)
.frame(maxWidth: .infinity)
case let .spacer(height):
Color.clear
.frame(height: height)
case let .divider(color, thickness):
Rectangle()
.fill(color.swiftUIColor.opacity(0.4))
.frame(height: thickness)
}
}
private func alignment(for element: AtollWidgetContentElement) -> Alignment {
switch element {
case let .text(_, _, _, alignment):
return alignment.swiftUI
default:
return .leading
}
}
@ViewBuilder
private var backgroundView: some View {
switch descriptor.material {
case .frosted:
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
case .liquid:
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
.fill(.regularMaterial)
case .solid:
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
.fill(accentColor.opacity(0.9))
case .semiTransparent:
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
.fill(accentColor.opacity(0.35))
case .clear:
Color.clear
}
}
}
private struct ExtensionGraphView: View {
let data: [Double]
let color: Color
let size: CGSize
var body: some View {
GeometryReader { proxy in
let minValue = data.min() ?? 0
let maxValue = data.max() ?? 1
let range = max(maxValue - minValue, 0.0001)
let step = proxy.size.width / CGFloat(max(data.count - 1, 1))
Path { path in
for (index, value) in data.enumerated() {
let x = CGFloat(index) * step
let normalized = (value - minValue) / range
let y = proxy.size.height - (CGFloat(normalized) * proxy.size.height)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
}
.frame(width: size.width, height: size.height)
}
}
private struct ExtensionGaugeView: View {
let value: Double
let minValue: Double
let maxValue: Double
let style: AtollWidgetContentElement.GaugeStyle
let accent: Color
var body: some View {
switch style {
case .circular:
ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: 5)
Circle()
.trim(from: 0, to: CGFloat(normalizedValue))
.stroke(accent, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.3), value: normalizedValue)
Text("\(Int(normalizedValue * 100))%")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.monospacedDigit()
}
.frame(width: 54, height: 54)
case .linear:
GeometryReader { proxy in
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: 8)
.overlay(alignment: .leading) {
Capsule()
.fill(accent)
.frame(width: proxy.size.width * CGFloat(normalizedValue), height: 8)
.animation(.smooth(duration: 0.3), value: normalizedValue)
}
}
.frame(height: 8)
}
}
private var normalizedValue: Double {
guard maxValue > minValue else { return 0 }
return min(max((value - minValue) / (maxValue - minValue), 0), 1)
}
}
private extension AtollWidgetContentElement.TextAlignment {
var swiftUI: Alignment {
switch self {
case .leading: return .leading
case .center: return .center
case .trailing: return .trailing
}
}
}

View file

@ -0,0 +1,349 @@
import SwiftUI
import AppKit
import AtollExtensionKit
// MARK: - Color Conversion
extension AtollColorDescriptor {
var swiftUIColor: Color {
if isAccent {
return .accentColor
}
return Color(red: red, green: green, blue: blue, opacity: alpha)
}
var nsColor: NSColor {
if isAccent {
return NSColor.controlAccentColor
}
return NSColor(red: red, green: green, blue: blue, alpha: alpha)
}
}
// MARK: - Font Conversion
extension AtollFontDescriptor {
func swiftUIFont() -> Font {
let design = self.design.swiftUI
let weight = self.weight.swiftUI
var font = Font.system(size: size, weight: weight, design: design)
if isMonospacedDigit {
font = font.monospacedDigit()
}
return font
}
func nsFont() -> NSFont {
let weight = weight.nsFont
let font: NSFont
switch design {
case .serif:
font = NSFont.userFont(ofSize: size) ?? NSFont.systemFont(ofSize: size, weight: weight)
case .rounded:
if let descriptor = NSFont.systemFont(ofSize: size, weight: weight).fontDescriptor.withDesign(.rounded) {
font = NSFont(descriptor: descriptor, size: size) ?? NSFont.systemFont(ofSize: size, weight: weight)
} else {
font = NSFont.systemFont(ofSize: size, weight: weight)
}
case .monospaced:
font = NSFont.monospacedSystemFont(ofSize: size, weight: weight)
case .default:
font = NSFont.systemFont(ofSize: size, weight: weight)
}
return font
}
}
private extension AtollFontWeight {
var swiftUI: Font.Weight {
switch self {
case .ultraLight: return .ultraLight
case .thin: return .thin
case .light: return .light
case .regular: return .regular
case .medium: return .medium
case .semibold: return .semibold
case .bold: return .bold
case .heavy: return .heavy
case .black: return .black
}
}
var nsFont: NSFont.Weight {
switch self {
case .ultraLight: return .ultraLight
case .thin: return .thin
case .light: return .light
case .regular: return .regular
case .medium: return .medium
case .semibold: return .semibold
case .bold: return .bold
case .heavy: return .heavy
case .black: return .black
}
}
}
private extension AtollFontDesign {
var swiftUI: Font.Design {
switch self {
case .default: return .default
case .serif: return .serif
case .rounded: return .rounded
case .monospaced: return .monospaced
}
}
}
// MARK: - Icon Rendering
struct ExtensionIconView: View {
let descriptor: AtollIconDescriptor
let tint: Color
let size: CGSize
let cornerRadius: CGFloat
var body: some View {
switch descriptor {
case let .symbol(name, glyphSize, weight):
Image(systemName: name)
.font(.system(size: glyphSize, weight: weight.swiftUI))
.foregroundStyle(tint)
.frame(width: size.width, height: size.height)
case let .image(data, targetSize, radius):
if let image = NSImage(data: data) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: targetSize.width, height: targetSize.height)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
} else {
fallbackSymbol
}
case let .appIcon(bundleIdentifier, targetSize, radius):
if let icon = AppIconAsNSImage(for: bundleIdentifier) {
Image(nsImage: icon)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: targetSize.width, height: targetSize.height)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
} else {
fallbackSymbol
}
case .lottie, .none:
fallbackSymbol
}
}
private var fallbackSymbol: some View {
Image(systemName: "app.dashed")
.font(.system(size: size.width * 0.6, weight: .medium))
.foregroundStyle(tint)
.frame(width: size.width, height: size.height)
.background(
RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous)
.fill(tint.opacity(0.15))
)
}
private func cornerRadius(for descriptor: AtollIconDescriptor) -> CGFloat {
switch descriptor {
case .image(_, _, let radius): return radius
case .appIcon(_, _, let radius): return radius
default: return cornerRadius
}
}
}
struct ExtensionCompositeIconView: View {
let leading: AtollIconDescriptor
let badge: AtollIconDescriptor?
let accent: Color
let size: CGFloat
var body: some View {
ZStack(alignment: .bottomTrailing) {
ExtensionIconView(
descriptor: leading,
tint: accent,
size: CGSize(width: size, height: size),
cornerRadius: size * 0.18
)
if let badge {
ExtensionIconView(
descriptor: badge,
tint: .white,
size: CGSize(width: max(size * 0.35, 12), height: max(size * 0.35, 12)),
cornerRadius: size * 0.12
)
.background(
Circle()
.fill(Color.black.opacity(0.7))
.frame(width: size * 0.4, height: size * 0.4)
)
}
}
.frame(width: size, height: size)
}
}
// MARK: - Progress Rendering
struct ExtensionProgressIndicatorView: View {
let indicator: AtollProgressIndicator
let progress: Double
let accent: Color
let estimatedDuration: TimeInterval?
var body: some View {
switch indicator {
case let .ring(diameter, strokeWidth):
ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: strokeWidth)
Circle()
.trim(from: 0, to: CGFloat(max(0, min(progress, 1))))
.stroke(accent, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: progress)
}
.frame(width: diameter, height: diameter)
case let .bar(width, height, cornerRadius):
Capsule()
.fill(Color.white.opacity(0.18))
.frame(width: width ?? 80, height: height)
.overlay(alignment: .leading) {
Capsule()
.fill(accent)
.frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height)
.animation(.smooth(duration: 0.25), value: progress)
}
case let .percentage(font):
Text("\(Int(progress * 100))%")
.font(font.swiftUIFont())
.foregroundStyle(accent)
.monospacedDigit()
case let .countdown(font):
Text(countdownText)
.font(font.swiftUIFont())
.foregroundStyle(accent)
.monospacedDigit()
case .lottie:
Image(systemName: "sparkles")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(accent)
case .none:
EmptyView()
}
}
private var countdownText: String {
guard let estimatedDuration else { return formatPercent }
let remaining = max(estimatedDuration * (1 - progress), 0)
let hours = Int(remaining) / 3600
let minutes = (Int(remaining) % 3600) / 60
let seconds = Int(remaining) % 60
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
private var formatPercent: String {
"\(Int(progress * 100))%"
}
}
// MARK: - Trailing Content Rendering
struct ExtensionTrailingContentView: View {
let content: AtollTrailingContent
let accent: Color
var body: some View {
switch content {
case let .text(value, font: font):
Text(value)
.font(font.swiftUIFont())
.foregroundStyle(accent)
.lineLimit(1)
case let .icon(descriptor):
ExtensionIconView(
descriptor: descriptor,
tint: accent,
size: CGSize(width: 26, height: 26),
cornerRadius: 6
)
case let .spectrum(color: colorDescriptor):
Rectangle()
.fill((colorDescriptor.isAccent ? accent : colorDescriptor.swiftUIColor).gradient)
.frame(width: 48, height: 14)
.mask {
AudioSpectrumView(isPlaying: .constant(true))
.frame(width: 16, height: 12)
}
case .animation:
Image(systemName: "sparkles")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(accent)
case .none:
EmptyView()
}
}
}
// MARK: - Layout Metrics
enum ExtensionLayoutMetrics {
static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat {
var width = baseWidth
width = max(width, widthForTrailing(content: payload.descriptor.trailingContent, baseWidth: baseWidth))
if let indicator = payload.descriptor.progressIndicator {
width = max(width, widthForProgress(indicator))
}
if let maxWidth {
width = min(width, maxWidth)
}
return width
}
private static func widthForTrailing(content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat {
switch content {
case let .text(text, font: font):
let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont())
return max(baseWidth, measured + 32)
case .icon:
return max(baseWidth, 52)
case .spectrum:
return max(baseWidth, 56)
case let .animation(data: _, size: size):
return max(baseWidth, size.width + 16)
case .none:
return baseWidth
}
}
private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat {
switch indicator {
case let .ring(diameter, _):
return diameter + 20
case let .bar(width, _, _):
return (width ?? 72) + 12
case .percentage:
return 60
case .countdown:
return 74
case let .lottie(_, size):
return size.width + 16
case .none:
return 0
}
}
}
enum ExtensionTextMeasurer {
static func width(for text: String, font: NSFont) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: font]
return max(1, text.size(withAttributes: attributes).width)
}
}

View file

@ -0,0 +1,426 @@
//
// ExtensionsSettings.swift
// DynamicIsland
//
// Created on 13/01/2026.
//
import SwiftUI
import Defaults
import AtollExtensionKit
struct ExtensionsSettingsView: View {
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared
@State private var searchText = ""
@State private var selectedEntry: ExtensionAuthorizationEntry?
@State private var showingRemoveConfirmation = false
private func highlightID(_ title: String) -> String {
"extensions-\(title)"
}
private var filteredEntries: [ExtensionAuthorizationEntry] {
guard !searchText.isEmpty else { return authManager.entries }
let query = searchText.lowercased()
return authManager.entries.filter {
$0.bundleIdentifier.lowercased().contains(query) ||
$0.appName.lowercased().contains(query)
}
}
var body: some View {
Form {
globalTogglesSection
if authManager.isExtensionsFeatureEnabled {
authorizedAppsSection
}
}
.navigationTitle("Extensions")
.alert("Remove Extension", isPresented: $showingRemoveConfirmation, presenting: selectedEntry) { entry in
Button("Cancel", role: .cancel) { }
Button("Remove", role: .destructive) {
authManager.removeEntry(bundleIdentifier: entry.bundleIdentifier)
selectedEntry = nil
}
} message: { entry in
Text("Remove \(entry.appName) from the authorized extensions list? This will dismiss all active live activities and lock screen widgets from this app.")
}
}
private var globalTogglesSection: some View {
Section {
Defaults.Toggle("Enable third-party extensions", key: .enableThirdPartyExtensions)
.settingsHighlight(id: highlightID("Enable third-party extensions"))
if Defaults[.enableThirdPartyExtensions] {
Defaults.Toggle("Allow extension live activities", key: .enableExtensionLiveActivities)
.settingsHighlight(id: highlightID("Allow extension live activities"))
Defaults.Toggle("Allow extension lock screen widgets", key: .enableExtensionLockScreenWidgets)
.settingsHighlight(id: highlightID("Allow extension lock screen widgets"))
Defaults.Toggle("Enable extension diagnostics logging", key: .extensionDiagnosticsLoggingEnabled)
.settingsHighlight(id: highlightID("Enable extension diagnostics logging"))
}
} header: {
Text("Global Settings")
} footer: {
if Defaults[.enableThirdPartyExtensions] {
Text("Third-party apps using AtollExtensionKit can display live activities and lock screen widgets. You can manage individual app permissions below.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Enable extensions to allow third-party apps to display live activities and lock screen widgets in Atoll.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var authorizedAppsSection: some View {
Section {
if authManager.entries.isEmpty {
VStack(alignment: .center, spacing: 12) {
Image(systemName: "puzzlepiece.extension")
.font(.system(size: 48))
.foregroundStyle(.secondary.opacity(0.5))
Text("No extensions yet")
.font(.headline)
.foregroundStyle(.secondary)
Text("Apps using AtollExtensionKit will appear here once they request permission")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else {
if authManager.entries.count > 3 {
TextField("Search extensions...", text: $searchText)
.textFieldStyle(.roundedBorder)
}
ForEach(filteredEntries) { entry in
ExtensionEntryRow(entry: entry, onRemove: {
selectedEntry = entry
showingRemoveConfirmation = true
})
}
}
} header: {
HStack {
Text("App Permissions")
Spacer()
if !authManager.entries.isEmpty {
Text("\(authManager.entries.count) \(authManager.entries.count == 1 ? "app" : "apps")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.settingsHighlight(id: highlightID("App permissions list"))
} footer: {
if !authManager.entries.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Permission States:")
.font(.caption.weight(.semibold))
HStack(spacing: 16) {
Label("Authorized", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Label("Pending", systemImage: "clock.fill")
.foregroundStyle(.orange)
Label("Denied/Revoked", systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
}
.font(.caption2)
}
.foregroundStyle(.secondary)
}
}
}
}
private struct ExtensionEntryRow: View {
@ObservedObject private var authManager = ExtensionAuthorizationManager.shared
let entry: ExtensionAuthorizationEntry
let onRemove: () -> Void
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Main row
HStack(spacing: 12) {
// Status indicator
statusIndicator
// App info
VStack(alignment: .leading, spacing: 3) {
Text(entry.appName)
.font(.system(size: 13, weight: .medium))
Text(entry.bundleIdentifier)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer()
// Expand button
Button {
withAnimation(.snappy(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.buttonStyle(.plain)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.snappy(duration: 0.2)) {
isExpanded.toggle()
}
}
// Expanded details
if isExpanded {
expandedDetails
.padding(.top, 12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.vertical, 8)
}
private var statusIndicator: some View {
ZStack {
Circle()
.fill(statusColor.opacity(0.15))
.frame(width: 32, height: 32)
Image(systemName: statusIcon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(statusColor)
}
}
private var statusColor: Color {
switch entry.status {
case .authorized: return .green
case .pending: return .orange
case .denied, .revoked: return .red
}
}
private var statusIcon: String {
switch entry.status {
case .authorized: return "checkmark.circle.fill"
case .pending: return "clock.fill"
case .denied, .revoked: return "xmark.circle.fill"
}
}
private var expandedDetails: some View {
VStack(alignment: .leading, spacing: 14) {
// Status info
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Status:")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(entry.status.rawValue.capitalized)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(statusColor.opacity(0.15))
.foregroundStyle(statusColor)
.clipShape(Capsule())
}
if let grantedAt = entry.grantedAt {
infoRow(label: "Granted", value: formatDate(grantedAt))
}
if let lastActivity = entry.lastActivityAt {
infoRow(label: "Last Activity", value: formatDate(lastActivity))
}
if let deniedReason = entry.lastDeniedReason {
VStack(alignment: .leading, spacing: 3) {
Text("Last Denied Reason:")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(deniedReason)
.font(.caption)
.foregroundStyle(.red.opacity(0.9))
}
}
}
Divider()
// Scopes section
if entry.status == .authorized {
scopeToggles
Divider()
}
// Rate limits info
if let rateLimitRecord = authManager.rateLimitRecords.first(where: { $0.bundleIdentifier == entry.bundleIdentifier }),
!rateLimitRecord.activityTimestamps.isEmpty || !rateLimitRecord.widgetTimestamps.isEmpty {
rateLimitInfo(record: rateLimitRecord)
Divider()
}
// Actions
actionButtons
}
.padding(12)
.background(Color.secondary.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private var scopeToggles: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Allowed Features")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Toggle("Live Activities", isOn: Binding(
get: { entry.allowedScopes.contains(.liveActivities) },
set: { enabled in
var newScopes = entry.allowedScopes
if enabled {
newScopes.insert(.liveActivities)
} else {
newScopes.remove(.liveActivities)
}
authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes)
}
))
.font(.caption)
.disabled(!authManager.areLiveActivitiesEnabled)
Toggle("Lock Screen Widgets", isOn: Binding(
get: { entry.allowedScopes.contains(.lockScreenWidgets) },
set: { enabled in
var newScopes = entry.allowedScopes
if enabled {
newScopes.insert(.lockScreenWidgets)
} else {
newScopes.remove(.lockScreenWidgets)
}
authManager.updateAllowedScopes(bundleIdentifier: entry.bundleIdentifier, allowedScopes: newScopes)
}
))
.font(.caption)
.disabled(!authManager.areLockScreenWidgetsEnabled)
}
}
private func rateLimitInfo(record: ExtensionRateLimitRecord) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Recent Activity (last 5 minutes)")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
HStack(spacing: 20) {
if !record.activityTimestamps.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("Live Activities")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(record.activityTimestamps.count)")
.font(.caption.monospacedDigit())
}
}
if !record.widgetTimestamps.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("Widget Updates")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(record.widgetTimestamps.count)")
.font(.caption.monospacedDigit())
}
}
}
Button("Reset Rate Limits") {
authManager.resetRateLimits(for: entry.bundleIdentifier)
}
.font(.caption)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
private var actionButtons: some View {
HStack(spacing: 8) {
switch entry.status {
case .pending:
Button("Authorize") {
authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Deny") {
authManager.deny(bundleIdentifier: entry.bundleIdentifier, reason: "Denied by user")
}
.buttonStyle(.bordered)
.controlSize(.small)
case .authorized:
Button("Revoke Access") {
authManager.revoke(bundleIdentifier: entry.bundleIdentifier, reason: "Revoked by user")
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.orange)
case .denied, .revoked:
Button("Re-authorize") {
authManager.authorize(bundleIdentifier: entry.bundleIdentifier, appName: entry.appName)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
Spacer()
Button("Remove") {
onRemove()
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
}
}
private func infoRow(label: String, value: String) -> some View {
HStack {
Text("\(label):")
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.caption)
}
}
private func formatDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: .now)
}
}
#Preview {
ExtensionsSettingsView()
}

View file

@ -24,6 +24,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case lockScreen
case media
case devices
case extensions
case timer
case calendar
case hudAndOSD
@ -48,6 +49,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .lockScreen: return "Lock Screen"
case .media: return "Media"
case .devices: return "Devices"
case .extensions: return "Extensions"
case .timer: return "Timer"
case .calendar: return "Calendar"
case .hudAndOSD: return "Controls"
@ -72,6 +74,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .lockScreen: return "lock.laptopcomputer"
case .media: return "play.laptopcomputer"
case .devices: return "headphones"
case .extensions: return "puzzlepiece.extension"
case .timer: return "timer"
case .calendar: return "calendar"
case .hudAndOSD: return "dial.medium.fill"
@ -96,6 +99,7 @@ private enum SettingsTab: String, CaseIterable, Identifiable {
case .lockScreen: return .orange
case .media: return .green
case .devices: return Color(red: 0.1, green: 0.11, blue: 0.12)
case .extensions: return Color(red: 0.557, green: 0.353, blue: 0.957)
case .timer: return .red
case .calendar: return .cyan
case .hudAndOSD: return .indigo
@ -386,6 +390,7 @@ struct SettingsView: View {
.lockScreen,
.media,
.devices,
.extensions,
.timer,
.calendar,
.hudAndOSD,
@ -689,6 +694,13 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")),
SettingsSearchEntry(tab: .lockScreen, title: "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")),
@ -766,6 +778,10 @@ struct SettingsView: View {
SettingsForm(tab: .devices) {
DevicesSettingsView()
}
case .extensions:
SettingsForm(tab: .extensions) {
ExtensionsSettingsView()
}
case .timer:
SettingsForm(tab: .timer) {
TimerSettings()