Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatInlineWidgetView.swift
Peter Steinberger 649e94dd21 feat(apple): copy or save rendered widgets as images from the chat transcript (#110987)
* feat(apple): copy or save rendered widgets as images from the chat transcript

* chore(i18n): regenerate native locale artifacts for widget export strings

* chore(i18n): resync native locale artifacts after main i18n advance
2026-07-18 21:53:40 -07:00

868 lines
31 KiB
Swift

import CryptoKit
import Foundation
import OpenClawKit
import SwiftUI
#if canImport(WebKit) && (os(iOS) || os(macOS))
import Security
import WebKit
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
import UniformTypeIdentifiers
#endif
#endif
enum OpenClawChatWidgetSurfaceRole: Sendable, Hashable {
case node
case operatorSurface
case legacy
}
public struct OpenClawChatWidgetResource: Sendable, Equatable {
public let url: URL
public let tlsFingerprintSHA256: String?
let surfaceRole: OpenClawChatWidgetSurfaceRole
let attemptedSurfaceRoles: Set<OpenClawChatWidgetSurfaceRole>
public init(url: URL, tlsFingerprintSHA256: String? = nil) {
self.url = url
self.tlsFingerprintSHA256 = tlsFingerprintSHA256
self.surfaceRole = .legacy
self.attemptedSurfaceRoles = []
}
init(
url: URL,
tlsFingerprintSHA256: String?,
surfaceRole: OpenClawChatWidgetSurfaceRole,
attemptedSurfaceRoles: Set<OpenClawChatWidgetSurfaceRole>)
{
self.url = url
self.tlsFingerprintSHA256 = tlsFingerprintSHA256
self.surfaceRole = surfaceRole
self.attemptedSurfaceRoles = attemptedSurfaceRoles
}
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.url == rhs.url && lhs.tlsFingerprintSHA256 == rhs.tlsFingerprintSHA256
}
}
public enum OpenClawChatWidgetURLResolver {
private static let documentsPath = "/__openclaw__/canvas/documents"
public static func resolve(surfaceURL rawSurfaceURL: String?, target rawTarget: String) -> URL? {
guard let target = self.relativeWidgetTarget(rawTarget),
var surface = self.capabilitySurface(rawSurfaceURL)
else { return nil }
var surfacePath = surface.percentEncodedPath
while surfacePath.hasSuffix("/") {
surfacePath.removeLast()
}
surface.percentEncodedPath = surfacePath + target.percentEncodedPath
surface.percentEncodedQuery = target.percentEncodedQuery
surface.fragment = target.fragment
return surface.url
}
public static func supportsTarget(_ rawTarget: String) -> Bool {
self.relativeWidgetTarget(rawTarget) != nil
}
public static func resolveResource(
target: String,
replacing failedResource: OpenClawChatWidgetResource?,
currentSurfaceRoutes: @Sendable () async -> (
node: GatewayCanvasHostRoute?,
operatorSurface: GatewayCanvasHostRoute?),
refreshNodeSurfaceRoute: @Sendable (GatewayCanvasHostRoute?) async -> GatewayCanvasHostRoute?,
refreshOperatorSurfaceRoute: @Sendable (GatewayCanvasHostRoute?) async -> GatewayCanvasHostRoute?) async
-> OpenClawChatWidgetResource?
{
let observed = await currentSurfaceRoutes()
guard let failedResource else {
return self.resolvePreferred(
surfaces: observed,
target: target,
excluding: nil,
blockedRoles: [],
attemptedRoles: [])
}
let blockedRoles = failedResource.attemptedSurfaceRoles
if failedResource.surfaceRole == .legacy,
blockedRoles.contains(.legacy)
{
return nil
}
let attemptedRoles = blockedRoles.union([failedResource.surfaceRole])
if !blockedRoles.contains(.node),
let nodeSurface = observed.node,
let currentNode = self.resolve(
surface: nodeSurface,
role: .node,
target: target,
attemptedRoles: attemptedRoles),
self.isReplacement(currentNode, for: failedResource)
{
return currentNode
}
if !blockedRoles.contains(.node),
let refreshedSurface = await refreshNodeSurfaceRoute(observed.node),
let refreshed = resolve(
surface: refreshedSurface,
role: .node,
target: target,
attemptedRoles: attemptedRoles),
self.isReplacement(refreshed, for: failedResource)
{
return refreshed
}
// A nil refresh can mean its route lease lost a reconnect race. Re-read
// both roles so a replacement connection and its TLS pin win together.
let afterNodeRefresh = await currentSurfaceRoutes()
if let replacement = self.resolvePreferred(
surfaces: afterNodeRefresh,
target: target,
excluding: failedResource,
blockedRoles: blockedRoles,
attemptedRoles: attemptedRoles)
{
return replacement
}
if !blockedRoles.contains(.operatorSurface),
let refreshedSurface = await refreshOperatorSurfaceRoute(afterNodeRefresh.operatorSurface),
let refreshed = resolve(
surface: refreshedSurface,
role: .operatorSurface,
target: target,
attemptedRoles: attemptedRoles),
self.isReplacement(refreshed, for: failedResource)
{
return refreshed
}
return await self.resolvePreferred(
surfaces: currentSurfaceRoutes(),
target: target,
excluding: failedResource,
blockedRoles: blockedRoles,
attemptedRoles: attemptedRoles)
}
private static func relativeWidgetTarget(_ rawTarget: String) -> URLComponents? {
let target = rawTarget.trimmingCharacters(in: .whitespacesAndNewlines)
guard target.hasPrefix("/"),
let components = URLComponents(string: target),
components.scheme == nil,
components.host == nil,
components.user == nil,
components.password == nil,
self.isCanonicalPath(components.percentEncodedPath),
components.percentEncodedPath.hasPrefix("\(self.documentsPath)/")
else { return nil }
return components
}
private static func capabilitySurface(_ rawSurfaceURL: String?) -> URLComponents? {
let raw = rawSurfaceURL?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !raw.isEmpty,
let components = URLComponents(string: raw),
self.isWebURL(components),
components.user == nil,
components.password == nil,
components.percentEncodedQuery == nil,
components.fragment == nil
else { return nil }
let segments = components.percentEncodedPath.split(separator: "/", omittingEmptySubsequences: true)
guard segments.count >= 3,
segments[segments.count - 3] == "__openclaw__",
segments[segments.count - 2] == "cap",
let capability = String(segments[segments.count - 1]).removingPercentEncoding,
!capability.isEmpty
else { return nil }
return components
}
private static func resolve(
surface: GatewayCanvasHostRoute,
role: OpenClawChatWidgetSurfaceRole,
target: String,
attemptedRoles: Set<OpenClawChatWidgetSurfaceRole>) -> OpenClawChatWidgetResource?
{
guard let url = resolve(surfaceURL: surface.url, target: target) else { return nil }
let resource = OpenClawChatWidgetResource(
url: url,
tlsFingerprintSHA256: surface.tlsFingerprintSHA256,
surfaceRole: role,
attemptedSurfaceRoles: attemptedRoles)
return resource.hasValidTLSBinding ? resource : nil
}
private static func resolvePreferred(
surfaces: (node: GatewayCanvasHostRoute?, operatorSurface: GatewayCanvasHostRoute?),
target: String,
excluding failedResource: OpenClawChatWidgetResource?,
blockedRoles: Set<OpenClawChatWidgetSurfaceRole>,
attemptedRoles: Set<OpenClawChatWidgetSurfaceRole>) -> OpenClawChatWidgetResource?
{
[
(role: OpenClawChatWidgetSurfaceRole.node, surface: surfaces.node),
(role: OpenClawChatWidgetSurfaceRole.operatorSurface, surface: surfaces.operatorSurface),
]
.lazy
.filter { !blockedRoles.contains($0.role) }
.compactMap { candidate in
candidate.surface.flatMap {
self.resolve(
surface: $0,
role: candidate.role,
target: target,
attemptedRoles: attemptedRoles)
}
}
.first { self.isReplacement($0, for: failedResource) }
}
private static func isReplacement(
_ candidate: OpenClawChatWidgetResource,
for failedResource: OpenClawChatWidgetResource?) -> Bool
{
guard let failedResource else { return true }
// Legacy URL-only callers cannot express trust identity, so retain
// their URL-only exclusion while resource-aware callers compare both.
if failedResource.surfaceRole == .legacy,
failedResource.tlsFingerprintSHA256 == nil
{
return candidate.url != failedResource.url
}
return candidate.url != failedResource.url ||
candidate.tlsFingerprintSHA256 != failedResource.tlsFingerprintSHA256
}
private static func isWebURL(_ components: URLComponents) -> Bool {
let scheme = components.scheme?.lowercased()
return (scheme == "http" || scheme == "https") && components.host?.isEmpty == false
}
private static func isCanonicalPath(_ path: String) -> Bool {
let segments = path.split(separator: "/", omittingEmptySubsequences: false)
guard segments.first?.isEmpty == true else { return false }
for (index, encodedSegment) in segments.enumerated() {
if index == 0 || (index == segments.count - 1 && encodedSegment.isEmpty) {
continue
}
guard !encodedSegment.isEmpty else { return false }
guard let segment = self.decodeRepeatedly(String(encodedSegment)) else { return false }
if segment == "." || segment == ".." || segment.contains("/") || segment.contains("\\") {
return false
}
}
return true
}
private static func decodeRepeatedly(_ encoded: String) -> String? {
var value = encoded
for _ in 0..<8 {
guard let decoded = value.removingPercentEncoding else { return nil }
if decoded == value { return decoded }
value = decoded
}
return nil
}
}
enum ChatInlineWidgetExport {
static func filename(title: String?) -> String {
var name = title ?? ""
name.removeAll { character in
character == "/" ||
character == "\\" ||
character.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
}
name = name.trimmingCharacters(in: .whitespacesAndNewlines)
return "\(name.isEmpty ? "widget" : name).png"
}
}
@MainActor
struct ChatInlineWidgetView: View {
let preview: OpenClawChatCanvasPreview
let resolverReady: Bool
let resolveResource: @MainActor @Sendable (
String,
OpenClawChatWidgetResource?) async -> OpenClawChatWidgetResource?
@State private var resolvedResource: OpenClawChatWidgetResource?
@State private var recoveryAttempts = 0
@State private var refreshInFlight = false
@State private var unavailable = false
@State private var activePath: String?
/// A reset can keep the same path while installing a new connection route.
/// Its generation prevents older resolver completions from restoring stale trust state.
@State private var loadGeneration = UUID()
#if canImport(WebKit) && (os(iOS) || os(macOS))
@State private var snapshotRequest: ChatInlineWidgetSnapshotRequest?
@State private var exportErrorMessage: String?
#if os(iOS)
@State private var sharedImage: ChatInlineWidgetSharedImage?
#endif
private var isPresentingExportError: Binding<Bool> {
Binding(
get: { self.exportErrorMessage != nil },
set: { if !$0 { self.exportErrorMessage = nil } })
}
#endif
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if let title = self.preview.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
Text(title)
.font(OpenClawChatTypography.footnote)
.fontWeight(.semibold)
.foregroundStyle(OpenClawChatTheme.muted)
}
#if canImport(WebKit) && (os(iOS) || os(macOS))
if let resolvedResource {
self.renderedWidget(resource: resolvedResource)
} else if self.unavailable {
Text("Widget unavailable")
.font(OpenClawChatTypography.footnote)
.foregroundStyle(OpenClawChatTheme.muted)
} else {
ProgressView()
.controlSize(.small)
.frame(maxWidth: .infinity, minHeight: 44)
}
#else
Text("Widget unavailable")
.font(OpenClawChatTypography.footnote)
.foregroundStyle(OpenClawChatTheme.muted)
#endif
}
.task(id: LoadID(path: self.preview.inlineWidgetPath, resolverReady: self.resolverReady)) {
let path = self.preview.inlineWidgetPath
if self.activePath != path {
self.reset(path: path)
}
guard self.resolverReady else { return }
self.reset(path: path)
guard let path else {
self.unavailable = true
return
}
await self.load(path: path, replacing: nil, generation: self.loadGeneration)
}
#if canImport(WebKit) && (os(iOS) || os(macOS))
.alert("Widget export failed", isPresented: self.isPresentingExportError) {
Button(role: .cancel) {
self.exportErrorMessage = nil
} label: {
Text("OK")
.font(OpenClawChatTypography.body)
}
} message: {
if let exportErrorMessage {
Text(exportErrorMessage)
.font(OpenClawChatTypography.body)
}
}
#if os(iOS)
.sheet(item: self.$sharedImage) { item in
ChatInlineWidgetShareSheet(image: item.image)
}
#endif
#endif
}
#if canImport(WebKit) && (os(iOS) || os(macOS))
private func renderedWidget(resource: OpenClawChatWidgetResource) -> some View {
ChatInlineWidgetWebView(
resource: resource,
allowsScripts: self.preview.sandbox == "scripts",
snapshotRequest: self.snapshotRequest,
onFailure: { self.handleLoadFailure(resource: resource) },
onSnapshot: self.handleSnapshot)
.id([
resource.url.absoluteString,
resource.tlsFingerprintSHA256 ?? "",
self.preview.sandbox ?? "",
].joined(separator: "\u{0}"))
.frame(height: self.preview.inlineWidgetHeight)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(OpenClawChatTheme.muted.opacity(0.24), lineWidth: 1)
}
.contentShape(Rectangle())
.contextMenu {
Button {
self.requestSnapshot(for: .copy)
} label: {
Text("Copy image")
.font(OpenClawChatTypography.body)
}
Button {
self.requestSnapshot(for: .save)
} label: {
#if os(macOS)
Text("Save image…")
.font(OpenClawChatTypography.body)
#else
Text("Save image")
.font(OpenClawChatTypography.body)
#endif
}
}
}
private func requestSnapshot(for action: ChatInlineWidgetSnapshotRequest.Action) {
self.snapshotRequest = ChatInlineWidgetSnapshotRequest(action: action)
}
private func handleSnapshot(_ outcome: ChatInlineWidgetSnapshotOutcome) {
switch outcome {
case let .failure(request):
self.clearSnapshotRequest(ifMatching: request)
self.exportErrorMessage = String(localized: "The widget image could not be captured.")
case let .success(request, image):
self.clearSnapshotRequest(ifMatching: request)
switch request.action {
case .copy:
self.copySnapshot(image)
case .save:
self.saveSnapshot(image)
}
}
}
private func clearSnapshotRequest(ifMatching completedRequest: ChatInlineWidgetSnapshotRequest) {
guard self.snapshotRequest?.id == completedRequest.id else { return }
self.snapshotRequest = nil
}
#if os(iOS)
private func copySnapshot(_ image: ChatInlineWidgetSnapshotImage) {
UIPasteboard.general.image = image
}
private func saveSnapshot(_ image: ChatInlineWidgetSnapshotImage) {
self.sharedImage = ChatInlineWidgetSharedImage(image: image)
}
#elseif os(macOS)
private func copySnapshot(_ image: ChatInlineWidgetSnapshotImage) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
guard pasteboard.writeObjects([image]) else {
self.exportErrorMessage = String(localized: "The widget image could not be copied.")
return
}
}
private func saveSnapshot(_ image: ChatInlineWidgetSnapshotImage) {
guard let pngData = image.chatInlineWidgetPNGData else {
self.exportErrorMessage = String(localized: "The widget image could not be encoded as PNG.")
return
}
let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
panel.nameFieldStringValue = ChatInlineWidgetExport.filename(title: self.preview.title)
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
do {
try pngData.write(to: url, options: .atomic)
} catch {
self.exportErrorMessage = String(localized: "The widget image could not be saved.")
}
}
}
#endif
#endif
private struct LoadID: Hashable {
let path: String?
let resolverReady: Bool
}
private func reset(path: String?) {
self.loadGeneration = UUID()
self.activePath = path
self.resolvedResource = nil
self.recoveryAttempts = 0
self.refreshInFlight = false
self.unavailable = false
}
private func load(
path: String,
replacing failedResource: OpenClawChatWidgetResource?,
generation: UUID) async
{
let candidate = await self.resolveResource(path, failedResource)
guard !Task.isCancelled,
self.activePath == path,
self.loadGeneration == generation
else { return }
let resource = candidate?.hasValidTLSBinding == true ? candidate : nil
self.resolvedResource = resource
self.unavailable = resource == nil
}
private func handleLoadFailure(resource: OpenClawChatWidgetResource) {
guard self.resolvedResource == resource,
let path = self.activePath,
!self.refreshInFlight
else { return }
guard self.recoveryAttempts < 3 else {
self.resolvedResource = nil
self.unavailable = true
return
}
self.recoveryAttempts += 1
self.refreshInFlight = true
let generation = self.loadGeneration
Task { @MainActor in
await self.load(path: path, replacing: resource, generation: generation)
guard self.activePath == path, self.loadGeneration == generation else { return }
self.refreshInFlight = false
}
}
}
extension OpenClawChatWidgetResource {
fileprivate var hasValidTLSBinding: Bool {
self.tlsFingerprintSHA256 == nil || self.url.scheme?.lowercased() == "https"
}
}
#if canImport(WebKit) && (os(iOS) || os(macOS))
#if os(iOS)
private typealias ChatInlineWidgetSnapshotImage = UIImage
#elseif os(macOS)
private typealias ChatInlineWidgetSnapshotImage = NSImage
#endif
private struct ChatInlineWidgetSnapshotRequest: Equatable {
enum Action: Equatable {
case copy
case save
}
let id = UUID()
let action: Action
}
private enum ChatInlineWidgetSnapshotOutcome {
case success(ChatInlineWidgetSnapshotRequest, ChatInlineWidgetSnapshotImage)
case failure(ChatInlineWidgetSnapshotRequest)
}
enum ChatInlineWidgetTLSPin {
static func normalize(_ raw: String) -> String? {
let stripped = raw.replacingOccurrences(
of: #"(?i)^sha-?256\s*:?\s*"#,
with: "",
options: .regularExpression)
let normalized = stripped.lowercased().filter(\.isHexDigit)
return normalized.count == 64 ? normalized : nil
}
static func fingerprint(certificateData: Data) -> String {
SHA256.hash(data: certificateData).map { String(format: "%02x", $0) }.joined()
}
static func matches(_ expected: String, trust: SecTrust) -> Bool {
guard let expected = normalize(expected),
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let certificate = chain.first
else { return false }
return self.fingerprint(certificateData: SecCertificateCopyData(certificate) as Data) == expected
}
}
struct ChatInlineWidgetContentProcessRecovery {
enum Action: Equatable {
case reload
case fail
}
private var didReload = false
mutating func nextAction() -> Action {
guard !self.didReload else { return .fail }
self.didReload = true
return .reload
}
mutating func reset() {
self.didReload = false
}
}
@MainActor
private final class ChatInlineWidgetNavigationDelegate: NSObject, WKNavigationDelegate {
var resource: OpenClawChatWidgetResource {
didSet {
if self.resource != oldValue {
self.contentProcessRecovery.reset()
}
}
}
let onFailure: @MainActor @Sendable () -> Void
var onSnapshot: @MainActor @Sendable (ChatInlineWidgetSnapshotOutcome) -> Void
private var contentProcessRecovery = ChatInlineWidgetContentProcessRecovery()
private var lastSnapshotRequestID: UUID?
init(
resource: OpenClawChatWidgetResource,
onFailure: @escaping @MainActor @Sendable () -> Void,
onSnapshot: @escaping @MainActor @Sendable (ChatInlineWidgetSnapshotOutcome) -> Void)
{
self.resource = resource
self.onFailure = onFailure
self.onSnapshot = onSnapshot
}
func captureSnapshot(_ request: ChatInlineWidgetSnapshotRequest?, from webView: WKWebView) {
guard let request, request.id != self.lastSnapshotRequestID else { return }
self.lastSnapshotRequestID = request.id
let configuration = WKSnapshotConfiguration()
webView.takeSnapshot(with: configuration) { [weak self] image, _ in
guard let self else { return }
if let image {
self.onSnapshot(.success(request, image))
} else {
self.onSnapshot(.failure(request))
}
}
}
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
if navigationAction.targetFrame?.isMainFrame == false {
decisionHandler(.cancel)
return
}
guard navigationAction.request.httpMethod?.caseInsensitiveCompare("GET") == .orderedSame,
let url = navigationAction.request.url,
self.matchesExpectedDocument(url)
else {
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
func webView(
_: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void)
{
if navigationResponse.isForMainFrame,
let response = navigationResponse.response as? HTTPURLResponse,
response.statusCode >= 400
{
self.onFailure()
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation?, withError _: any Error) {
self.onFailure()
}
func webView(_: WKWebView, didFail _: WKNavigation?, withError _: any Error) {
self.onFailure()
}
func webView(
_: WKWebView,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping @MainActor @Sendable (
URLSession.AuthChallengeDisposition,
URLCredential?) -> Void)
{
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let expectedFingerprint = resource.tlsFingerprintSHA256
else {
completionHandler(.performDefaultHandling, nil)
return
}
guard self.matchesExpectedProtectionSpace(challenge.protectionSpace),
let trust = challenge.protectionSpace.serverTrust,
ChatInlineWidgetTLSPin.matches(expectedFingerprint, trust: trust)
else {
completionHandler(.cancelAuthenticationChallenge, nil)
self.onFailure()
return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
// One same-document recovery handles incidental process loss. A second
// termination enters the view's bounded capability-refresh/failure path.
switch self.contentProcessRecovery.nextAction() {
case .reload:
webView.load(URLRequest(url: self.resource.url, cachePolicy: .reloadIgnoringLocalCacheData))
case .fail:
self.onFailure()
}
}
private func matchesExpectedDocument(_ candidate: URL) -> Bool {
guard var expected = URLComponents(url: self.resource.url, resolvingAgainstBaseURL: false),
var candidate = URLComponents(url: candidate, resolvingAgainstBaseURL: false)
else { return false }
expected.fragment = nil
candidate.fragment = nil
return expected == candidate
}
private func matchesExpectedProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Bool {
guard let expectedHost = self.resource.url.host,
protectionSpace.host.caseInsensitiveCompare(expectedHost) == .orderedSame
else { return false }
let expectedPort = self.resource.url.port ?? (self.resource.url.scheme?.lowercased() == "https" ? 443 : 80)
return protectionSpace.port == expectedPort
}
}
@MainActor
private func makeChatInlineWidgetWebView(
resource: OpenClawChatWidgetResource,
allowsScripts: Bool,
coordinator: ChatInlineWidgetNavigationDelegate) -> WKWebView
{
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
configuration.defaultWebpagePreferences.allowsContentJavaScript = allowsScripts
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = coordinator
webView.allowsLinkPreview = false
webView.load(URLRequest(url: resource.url, cachePolicy: .reloadIgnoringLocalCacheData))
return webView
}
#if os(iOS)
private struct ChatInlineWidgetWebView: UIViewRepresentable {
let resource: OpenClawChatWidgetResource
let allowsScripts: Bool
let snapshotRequest: ChatInlineWidgetSnapshotRequest?
let onFailure: @MainActor @Sendable () -> Void
let onSnapshot: @MainActor @Sendable (ChatInlineWidgetSnapshotOutcome) -> Void
func makeCoordinator() -> ChatInlineWidgetNavigationDelegate {
ChatInlineWidgetNavigationDelegate(
resource: self.resource,
onFailure: self.onFailure,
onSnapshot: self.onSnapshot)
}
func makeUIView(context: Context) -> WKWebView {
makeChatInlineWidgetWebView(
resource: self.resource,
allowsScripts: self.allowsScripts,
coordinator: context.coordinator)
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.onSnapshot = self.onSnapshot
if context.coordinator.resource != self.resource {
context.coordinator.resource = self.resource
webView.load(URLRequest(url: self.resource.url, cachePolicy: .reloadIgnoringLocalCacheData))
}
context.coordinator.captureSnapshot(self.snapshotRequest, from: webView)
}
static func dismantleUIView(_ webView: WKWebView, coordinator: ChatInlineWidgetNavigationDelegate) {
webView.stopLoading()
webView.navigationDelegate = nil
}
}
#elseif os(macOS)
private struct ChatInlineWidgetWebView: NSViewRepresentable {
let resource: OpenClawChatWidgetResource
let allowsScripts: Bool
let snapshotRequest: ChatInlineWidgetSnapshotRequest?
let onFailure: @MainActor @Sendable () -> Void
let onSnapshot: @MainActor @Sendable (ChatInlineWidgetSnapshotOutcome) -> Void
func makeCoordinator() -> ChatInlineWidgetNavigationDelegate {
ChatInlineWidgetNavigationDelegate(
resource: self.resource,
onFailure: self.onFailure,
onSnapshot: self.onSnapshot)
}
func makeNSView(context: Context) -> WKWebView {
makeChatInlineWidgetWebView(
resource: self.resource,
allowsScripts: self.allowsScripts,
coordinator: context.coordinator)
}
func updateNSView(_ webView: WKWebView, context: Context) {
context.coordinator.onSnapshot = self.onSnapshot
if context.coordinator.resource != self.resource {
context.coordinator.resource = self.resource
webView.load(URLRequest(url: self.resource.url, cachePolicy: .reloadIgnoringLocalCacheData))
}
context.coordinator.captureSnapshot(self.snapshotRequest, from: webView)
}
static func dismantleNSView(_ webView: WKWebView, coordinator: ChatInlineWidgetNavigationDelegate) {
webView.stopLoading()
webView.navigationDelegate = nil
}
}
#endif
#if os(iOS)
private struct ChatInlineWidgetSharedImage: Identifiable {
let id = UUID()
let image: UIImage
}
private struct ChatInlineWidgetShareSheet: UIViewControllerRepresentable {
let image: UIImage
func makeUIViewController(context _: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [self.image], applicationActivities: nil)
}
func updateUIViewController(_: UIActivityViewController, context _: Context) {}
}
#elseif os(macOS)
extension NSImage {
fileprivate var chatInlineWidgetPNGData: Data? {
guard let tiffRepresentation,
let representation = NSBitmapImageRep(data: tiffRepresentation)
else { return nil }
return representation.representation(using: .png, properties: [:])
}
}
#endif
#endif