diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index a09045d..754dc7a 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -79,6 +79,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 7E07AFF12F181892003A8B1B /* Recovered References */ = { + isa = PBXGroup; + children = ( + 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { isa = PBXGroup; children = ( @@ -121,6 +129,7 @@ 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E82CD4987A00C7AFD9 /* Products */, + 7E07AFF12F181892003A8B1B /* Recovered References */, ); sourceTree = ""; }; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d232e97..80701d0 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355" + "revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186" } }, { diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index 10faf4d..e7722f0 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -1,26 +1,27 @@ 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 { - backgroundView + backgroundLayer(shape: shape) contentView - .padding(.horizontal, descriptor.layoutStyle == .circular ? 10 : 16) - .padding(.vertical, descriptor.layoutStyle == .circular ? 10 : 12) + .padding(resolvedContentInsets) } .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) - ) + .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)") } @@ -92,6 +93,11 @@ struct ExtensionLockScreenWidgetView: View { 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) } } @@ -104,25 +110,124 @@ struct ExtensionLockScreenWidgetView: View { } } + 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 var backgroundView: some View { + 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: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(.ultraThinMaterial) + return AnyShapeStyle(.ultraThinMaterial) case .liquid: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(.regularMaterial) + return AnyShapeStyle(.regularMaterial) case .solid: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(accentColor.opacity(0.9)) + return AnyShapeStyle(resolvedSolidFillColor.opacity(0.95)) case .semiTransparent: - RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous) - .fill(accentColor.opacity(0.35)) + return AnyShapeStyle(resolvedSolidFillColor.opacity(0.35)) case .clear: - Color.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) { @@ -130,6 +235,96 @@ private func logWidgetDiagnostics(_ message: String) { 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 diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift index a11ca09..7d55d9e 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetPresentationController.swift @@ -244,7 +244,7 @@ private final class ExtensionLockScreenWidgetWindowPool { private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect { let size = descriptor.size - let safeInsets = NSEdgeInsets(top: 140, left: 48, bottom: 100, right: 48) + let safeInsets = safeInsets(for: descriptor.position.clampMode) let screenFrame = screen.frame let minX = screenFrame.minX + safeInsets.left @@ -271,6 +271,17 @@ private final class ExtensionLockScreenWidgetWindowPool { 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)