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)") } } } } @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) case let .webView(webDescriptor): ExtensionWebContentView(descriptor: webDescriptor) .frame(height: webDescriptor.preferredHeight) .frame(maxWidth: webDescriptor.maximumContentWidth ?? .infinity) .allowsHitTesting(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 shouldUseGlassBackdrop { 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) } } } 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 shouldUseGlassBackdrop: Bool { descriptor.material == .liquid || (appearance?.enableGlassHighlight ?? false) } @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) } private struct ExtensionWebContentView: NSViewRepresentable { let descriptor: AtollWidgetWebContentDescriptor func makeCoordinator() -> Coordinator { Coordinator(descriptor: descriptor) } func makeNSView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.suppressesIncrementalRendering = false configuration.preferences.javaScriptCanOpenWindowsAutomatically = false let webView = NonInteractiveWKWebView(frame: .zero, configuration: configuration) 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 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 NonInteractiveWKWebView: WKWebView { override func hitTest(_ point: NSPoint) -> NSView? { nil } override var acceptsFirstResponder: Bool { false } } 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 } } }