mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 18:21:23 +00:00
* 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
868 lines
31 KiB
Swift
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
|