mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-29 08:04:32 +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
|
|
@ -79,6 +79,14 @@
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
7E07AFF12F181892003A8B1B /* Recovered References */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */,
|
||||||
|
);
|
||||||
|
name = "Recovered References";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
|
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -121,6 +129,7 @@
|
||||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||||
F54406E82CD4987A00C7AFD9 /* Products */,
|
F54406E82CD4987A00C7AFD9 /* Products */,
|
||||||
|
7E07AFF12F181892003A8B1B /* Recovered References */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
|
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355"
|
"revision" : "0967e3ec3c54f765ed038c26bcaf1e126423f186"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,27 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
import AtollExtensionKit
|
import AtollExtensionKit
|
||||||
|
import WebKit
|
||||||
|
import AppKit
|
||||||
|
|
||||||
struct ExtensionLockScreenWidgetView: View {
|
struct ExtensionLockScreenWidgetView: View {
|
||||||
let payload: ExtensionLockScreenWidgetPayload
|
let payload: ExtensionLockScreenWidgetPayload
|
||||||
|
|
||||||
private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor }
|
private var descriptor: AtollLockScreenWidgetDescriptor { payload.descriptor }
|
||||||
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
private var accentColor: Color { descriptor.accentColor.swiftUIColor }
|
||||||
|
private var appearance: AtollWidgetAppearanceOptions? { descriptor.appearance }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
||||||
ZStack {
|
ZStack {
|
||||||
backgroundView
|
backgroundLayer(shape: shape)
|
||||||
contentView
|
contentView
|
||||||
.padding(.horizontal, descriptor.layoutStyle == .circular ? 10 : 16)
|
.padding(resolvedContentInsets)
|
||||||
.padding(.vertical, descriptor.layoutStyle == .circular ? 10 : 12)
|
|
||||||
}
|
}
|
||||||
.frame(width: descriptor.size.width, height: descriptor.size.height)
|
.frame(width: descriptor.size.width, height: descriptor.size.height)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous))
|
.clipShape(shape)
|
||||||
.overlay(
|
.overlay(borderOverlay(shape: shape))
|
||||||
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
.shadow(color: shadowColor, radius: shadowRadius, x: shadowOffset.width, y: shadowOffset.height)
|
||||||
.stroke(Color.white.opacity(0.04), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)")
|
logWidgetDiagnostics("Rendering extension lock screen widget \(payload.descriptor.id) for \(payload.bundleIdentifier)")
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +93,11 @@ struct ExtensionLockScreenWidgetView: View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(color.swiftUIColor.opacity(0.4))
|
.fill(color.swiftUIColor.opacity(0.4))
|
||||||
.frame(height: thickness)
|
.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
|
@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 {
|
switch descriptor.material {
|
||||||
case .frosted:
|
case .frosted:
|
||||||
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
return AnyShapeStyle(.ultraThinMaterial)
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
case .liquid:
|
case .liquid:
|
||||||
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
return AnyShapeStyle(.regularMaterial)
|
||||||
.fill(.regularMaterial)
|
|
||||||
case .solid:
|
case .solid:
|
||||||
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
return AnyShapeStyle(resolvedSolidFillColor.opacity(0.95))
|
||||||
.fill(accentColor.opacity(0.9))
|
|
||||||
case .semiTransparent:
|
case .semiTransparent:
|
||||||
RoundedRectangle(cornerRadius: descriptor.cornerRadius, style: .continuous)
|
return AnyShapeStyle(resolvedSolidFillColor.opacity(0.35))
|
||||||
.fill(accentColor.opacity(0.35))
|
|
||||||
case .clear:
|
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) {
|
private func logWidgetDiagnostics(_ message: String) {
|
||||||
|
|
@ -130,6 +235,96 @@ private func logWidgetDiagnostics(_ message: String) {
|
||||||
Logger.log(message, category: .extensions)
|
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 {
|
private struct ExtensionGraphView: View {
|
||||||
let data: [Double]
|
let data: [Double]
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ private final class ExtensionLockScreenWidgetWindowPool {
|
||||||
|
|
||||||
private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect {
|
private func frame(for descriptor: AtollLockScreenWidgetDescriptor, on screen: NSScreen) -> NSRect {
|
||||||
let size = descriptor.size
|
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 screenFrame = screen.frame
|
||||||
|
|
||||||
let minX = screenFrame.minX + safeInsets.left
|
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)
|
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 {
|
private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
|
||||||
guard lower <= upper else { return value }
|
guard lower <= upper else { return value }
|
||||||
return min(max(value, lower), upper)
|
return min(max(value, lower), upper)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue