mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Fully working lock screen widget
This commit is contained in:
parent
675f60c9f2
commit
1d6946134c
4 changed files with 235 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue