mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 21:31:26 +00:00
* fix(ios): restrict a2ui bridge trust * test(ios): cover fragment-strip trust and document raw-string equality * fix(ios): normalize capability URL before trust comparison in canvas commands * fix(ios): trim canvas.navigate url before trust comparison * chore: add changelog for iOS A2UI trust boundary --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
108 lines
4.3 KiB
Swift
108 lines
4.3 KiB
Swift
import Foundation
|
|
import Network
|
|
import OpenClawKit
|
|
|
|
enum A2UIReadyState {
|
|
case ready(String)
|
|
case hostNotConfigured
|
|
case hostUnavailable
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
func resolveCanvasHostURL() async -> String? {
|
|
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
|
if let host = base.host, LoopbackHost.isLoopback(host) {
|
|
return nil
|
|
}
|
|
return base.appendingPathComponent("__openclaw__/canvas/").absoluteString
|
|
}
|
|
|
|
func _test_resolveA2UIHostURL() async -> String? {
|
|
await self.resolveA2UIHostURL()
|
|
}
|
|
|
|
func resolveA2UIHostURL() async -> String? {
|
|
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
|
if let host = base.host, LoopbackHost.isLoopback(host) {
|
|
return nil
|
|
}
|
|
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
|
|
}
|
|
|
|
/// Normalize a URL string for trust comparison: lowercase scheme/host and strip fragment.
|
|
/// This matches the normalization applied by ScreenController.isTrustedCanvasUIURL so that
|
|
/// SPA hash-routing fragments and scheme/host casing do not silently prevent trust being set.
|
|
static func normalizeURLForTrustComparison(_ raw: String) -> String {
|
|
guard let url = URL(string: raw),
|
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
else { return raw }
|
|
components.fragment = nil
|
|
components.scheme = components.scheme?.lowercased()
|
|
components.host = components.host?.lowercased()
|
|
return components.url?.absoluteString ?? raw
|
|
}
|
|
|
|
func showA2UIOnConnectIfNeeded() async {
|
|
await MainActor.run {
|
|
// Keep the bundled home canvas as the default connected view.
|
|
// Agents can still explicitly present a remote or local canvas later.
|
|
self.lastAutoA2uiURL = nil
|
|
self.screen.showDefaultCanvas()
|
|
}
|
|
}
|
|
|
|
func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState {
|
|
guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else {
|
|
return .hostNotConfigured
|
|
}
|
|
self.screen.navigate(to: initialUrl, trustA2UIActions: true)
|
|
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
|
|
return .ready(initialUrl)
|
|
}
|
|
|
|
// First render can fail when scoped capability rotates between reconnects.
|
|
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable }
|
|
guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable }
|
|
self.screen.navigate(to: refreshedUrl, trustA2UIActions: true)
|
|
if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) {
|
|
return .ready(refreshedUrl)
|
|
}
|
|
return .hostUnavailable
|
|
}
|
|
|
|
func showLocalCanvasOnDisconnect() {
|
|
self.lastAutoA2uiURL = nil
|
|
self.screen.showDefaultCanvas()
|
|
}
|
|
|
|
private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? {
|
|
if let url = await self.resolveA2UIHostURL() {
|
|
return url
|
|
}
|
|
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
|
|
return await self.resolveA2UIHostURL()
|
|
}
|
|
|
|
private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? {
|
|
if let url = await self.resolveCanvasHostURL() {
|
|
return url
|
|
}
|
|
guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil }
|
|
return await self.resolveCanvasHostURL()
|
|
}
|
|
|
|
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
|
|
guard let host = url.host, !host.isEmpty else { return false }
|
|
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
|
|
return await TCPProbe.probe(
|
|
host: host,
|
|
port: portInt,
|
|
timeoutSeconds: timeoutSeconds,
|
|
queueLabel: "a2ui.preflight")
|
|
}
|
|
}
|