mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
iOS: allow A2UI actions from local canvas host
This commit is contained in:
@@ -214,7 +214,7 @@ actor CameraController {
|
||||
nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||
let v = ms ?? 3000
|
||||
// Keep clips short by default; avoid huge base64 payloads on the bridge.
|
||||
return min(15000, max(250, v))
|
||||
return min(60_000, max(250, v))
|
||||
}
|
||||
|
||||
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||
|
||||
@@ -196,7 +196,7 @@ final class ScreenController {
|
||||
forResource: "index",
|
||||
withExtension: "html")
|
||||
|
||||
fileprivate func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
||||
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
let std = url.standardizedFileURL
|
||||
if let expected = Self.canvasScaffoldURL,
|
||||
@@ -211,6 +211,64 @@ final class ScreenController {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" { return true }
|
||||
if host.hasSuffix(".local") { return true }
|
||||
if let ipv4 = Self.parseIPv4(host) {
|
||||
return Self.isLocalNetworkIPv4(ipv4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
let (a, b, _, _) = ip
|
||||
// 10.0.0.0/8
|
||||
if a == 10 { return true }
|
||||
// 172.16.0.0/12
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
// 192.168.0.0/16
|
||||
if a == 192, b == 168 { return true }
|
||||
// 127.0.0.0/8
|
||||
if a == 127 { return true }
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if a == 169, b == 254 { return true }
|
||||
// Tailscale: 100.64.0.0/10
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func parseA2UIActionBody(_ body: Any) -> [String: Any]? {
|
||||
if let dict = body as? [String: Any] { return dict.isEmpty ? nil : dict }
|
||||
if let str = body as? String,
|
||||
let data = str.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
{
|
||||
return json.isEmpty ? nil : json
|
||||
}
|
||||
if let dict = body as? [AnyHashable: Any] {
|
||||
let mapped = dict.reduce(into: [String: Any]()) { acc, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
acc[key] = pair.value
|
||||
}
|
||||
return mapped.isEmpty ? nil : mapped
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
@@ -259,20 +317,15 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
guard message.name == Self.messageName else { return }
|
||||
guard let controller else { return }
|
||||
|
||||
// Only accept actions from local bundled canvas/A2UI content (not arbitrary web pages).
|
||||
guard let url = message.webView?.url, controller.isTrustedCanvasUIURL(url) else { return }
|
||||
guard let url = message.webView?.url else { return }
|
||||
if url.isFileURL {
|
||||
guard controller.isTrustedCanvasUIURL(url) else { return }
|
||||
} else {
|
||||
// For security, only accept actions from local-network pages (e.g. the canvas host).
|
||||
guard controller.isLocalNetworkCanvasURL(url) else { return }
|
||||
}
|
||||
|
||||
let body: [String: Any] = {
|
||||
if let dict = message.body as? [String: Any] { return dict }
|
||||
if let dict = message.body as? [AnyHashable: Any] {
|
||||
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
acc[key] = pair.value
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}()
|
||||
guard !body.isEmpty else { return }
|
||||
guard let body = ScreenController.parseA2UIActionBody(message.body) else { return }
|
||||
|
||||
controller.onA2UIAction?(body)
|
||||
}
|
||||
|
||||
@@ -41,4 +41,21 @@ import WebKit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func localNetworkCanvasURLsAreAllowed() {
|
||||
let screen = ScreenController()
|
||||
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18793/")!) == true)
|
||||
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://clawd.local:18793/")!) == true)
|
||||
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18793/")!) == true)
|
||||
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18793/")!) == true)
|
||||
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18793/")!) == true) // Tailscale CGNAT
|
||||
#expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false)
|
||||
#expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false)
|
||||
}
|
||||
|
||||
@Test func parseA2UIActionBodyAcceptsJSONString() throws {
|
||||
let body = ScreenController.parseA2UIActionBody("{\"userAction\":{\"name\":\"hello\"}}")
|
||||
let userAction = try #require(body?["userAction"] as? [String: Any])
|
||||
#expect(userAction["name"] as? String == "hello")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user