mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 12:01:34 +00:00
* fix(ios): defer QR pairing after scanner dismissal * fix(ios): process QR pairing after scanner dismissal * fix(ios): harden QR scanner handoff * fix(ios): give QR scanner dismissal more time * fix(ios): keep onboarding open for QR trust prompt * fix(ios): keep QR trust prompt owned by onboarding * fix(ios): recover operator pairing after QR bootstrap * fix(ios): cancel stale QR scanner handoffs Co-authored-by: PollyBot13 <pollybot13@gmail.com> * fix(ios): defer QR setup until onboarding closes * fix(ios): keep QR setup links with visible settings * fix(ios): consume setup links during onboarding * fix(ios): handle setup links during onboarding launch * fix(ios): route setup links through active onboarding * fix(ios): harden QR gateway handoff * fix(ios): cancel superseded gateway attempts * fix(ios): serialize scanner result delivery * fix(ios): prevent stale gateway reconnects * fix(ios): serialize gateway target handoff * fix(ios): disable stale gateway relaunch route * fix(ios): await staged bootstrap reset * test(ios): bound gateway reset handoff * fix(ios): preserve explicit gateway handoff * fix(ios): harden gateway lifecycle ownership * chore(ios): sync native i18n inventory * test(ios): align gateway ownership assertions * refactor(ios): remove superseded gateway helpers * fix(ios): keep gateway auth route scoped * fix(ios): restore gateway target review state * fix(protocol): refresh Swift plugin approval model * test(ios): isolate state directory overrides * fix(ios): preserve watch alerts across gateway switches * fix(ios): bind deferred work to gateway ownership * docs(changelog): credit iOS gateway handoff fix * chore(i18n): sync native app inventory * test(ios): remove unused Watch approval hooks --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
402 lines
14 KiB
Swift
402 lines
14 KiB
Swift
import Foundation
|
|
|
|
private func defaultGatewayPort(tls: Bool) -> Int {
|
|
tls ? 443 : 18789
|
|
}
|
|
|
|
public enum DeepLinkRoute: Sendable, Equatable {
|
|
case agent(AgentDeepLink)
|
|
case gateway(GatewayConnectDeepLink)
|
|
case dashboard
|
|
}
|
|
|
|
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
|
private static let maximumSetupEndpoints = 8
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case host
|
|
case port
|
|
case tls
|
|
case bootstrapToken
|
|
case token
|
|
case password
|
|
case fallbackEndpoints
|
|
}
|
|
|
|
private struct SetupPayload: Decodable {
|
|
let url: String?
|
|
let urls: [String]?
|
|
let host: String?
|
|
let port: Int?
|
|
let tls: Bool?
|
|
let bootstrapToken: String?
|
|
let token: String?
|
|
let password: String?
|
|
}
|
|
|
|
public let host: String
|
|
public let port: Int
|
|
public let tls: Bool
|
|
public let bootstrapToken: String?
|
|
public let token: String?
|
|
public let password: String?
|
|
public let fallbackEndpoints: [GatewayConnectEndpoint]
|
|
|
|
public init(
|
|
host: String,
|
|
port: Int,
|
|
tls: Bool,
|
|
bootstrapToken: String?,
|
|
token: String?,
|
|
password: String?,
|
|
fallbackEndpoints: [GatewayConnectEndpoint] = [])
|
|
{
|
|
self.host = host
|
|
self.port = port
|
|
self.tls = tls
|
|
self.bootstrapToken = bootstrapToken
|
|
self.token = token
|
|
self.password = password
|
|
self.fallbackEndpoints = fallbackEndpoints
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.host = try container.decode(String.self, forKey: .host)
|
|
self.port = try container.decode(Int.self, forKey: .port)
|
|
self.tls = try container.decode(Bool.self, forKey: .tls)
|
|
self.bootstrapToken = try container.decodeIfPresent(String.self, forKey: .bootstrapToken)
|
|
self.token = try container.decodeIfPresent(String.self, forKey: .token)
|
|
self.password = try container.decodeIfPresent(String.self, forKey: .password)
|
|
self.fallbackEndpoints = try container.decodeIfPresent(
|
|
[GatewayConnectEndpoint].self,
|
|
forKey: .fallbackEndpoints) ?? []
|
|
}
|
|
|
|
public var websocketURL: URL? {
|
|
guard (1...65535).contains(self.port) else { return nil }
|
|
var components = URLComponents()
|
|
components.scheme = self.tls ? "wss" : "ws"
|
|
components.host = self.host
|
|
components.port = self.port
|
|
return components.url
|
|
}
|
|
|
|
public var isValidEndpoint: Bool {
|
|
guard (1...65535).contains(self.port), self.websocketURL?.host != nil else { return false }
|
|
return self.tls || LoopbackHost.isLocalNetworkHost(self.host)
|
|
}
|
|
|
|
public var connectionEndpoints: [GatewayConnectEndpoint] {
|
|
[.init(host: self.host, port: self.port, tls: self.tls)] + self.fallbackEndpoints
|
|
}
|
|
|
|
public func selectingEndpoint(_ endpoint: GatewayConnectEndpoint) -> GatewayConnectDeepLink {
|
|
.init(
|
|
host: endpoint.host,
|
|
port: endpoint.port,
|
|
tls: endpoint.tls,
|
|
bootstrapToken: self.bootstrapToken,
|
|
token: self.token,
|
|
password: self.password)
|
|
}
|
|
|
|
/// Parse a gateway setup input from the QR/scanner/manual entry surfaces.
|
|
///
|
|
/// Accepted inputs are:
|
|
/// - device-pair setup code (base64url-encoded JSON)
|
|
/// - raw setup JSON
|
|
/// - a copied message containing a `Setup code:` line
|
|
/// - an `openclaw://gateway?...` deep link
|
|
/// - a raw `ws://` or `wss://` gateway URL
|
|
public static func fromSetupInput(_ input: String) -> GatewayConnectDeepLink? {
|
|
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
if let link = fromSetupCode(trimmed) {
|
|
return link
|
|
}
|
|
if let url = URL(string: trimmed),
|
|
let route = DeepLinkParser.parse(url),
|
|
case let .gateway(link) = route
|
|
{
|
|
return link
|
|
}
|
|
return self.fromGatewayURLString(
|
|
trimmed,
|
|
bootstrapToken: nil,
|
|
token: nil,
|
|
password: nil)
|
|
}
|
|
|
|
/// Parse a gateway setup payload from a device-pair setup code or copied setup text.
|
|
///
|
|
/// Accepted inputs are:
|
|
/// - base64url-encoded setup JSON
|
|
/// - raw setup JSON
|
|
/// - copied text/message content containing one or more extractable setup-code candidates
|
|
///
|
|
/// Accepted payload shapes are:
|
|
/// - `{url, urls?, bootstrapToken?, token?, password?}`
|
|
/// - `{host, port?, tls?, bootstrapToken?, token?, password?}`
|
|
///
|
|
/// URL-based payloads provide the primary gateway WebSocket URL via `url`, with optional
|
|
/// ordered candidates in `urls`. Host-based payloads provide `host` plus optional `port`
|
|
/// and `tls`. In both cases, the optional `bootstrapToken`, `token`, and `password` fields
|
|
/// are also supported.
|
|
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
|
|
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
if let link = decodeSetupPayload(from: Data(trimmed.utf8)) {
|
|
return link
|
|
}
|
|
if let data = decodeBase64Url(trimmed),
|
|
let link = decodeSetupPayload(from: data)
|
|
{
|
|
return link
|
|
}
|
|
for candidate in self.setupCodeCandidates(in: trimmed) where candidate != trimmed {
|
|
if let data = decodeBase64Url(candidate),
|
|
let link = decodeSetupPayload(from: data)
|
|
{
|
|
return link
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func decodeSetupPayload(from data: Data) -> GatewayConnectDeepLink? {
|
|
guard let payload = try? JSONDecoder().decode(SetupPayload.self, from: data) else { return nil }
|
|
var urlCandidates = payload.url.map { [$0] } ?? []
|
|
for candidate in payload.urls ?? [] {
|
|
guard urlCandidates.count < self.maximumSetupEndpoints else { break }
|
|
if !urlCandidates.contains(candidate) {
|
|
urlCandidates.append(candidate)
|
|
}
|
|
}
|
|
var seenURLs = Set<String>()
|
|
let links = urlCandidates.compactMap { rawURL -> GatewayConnectDeepLink? in
|
|
let url = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !url.isEmpty, seenURLs.insert(url).inserted else { return nil }
|
|
return self.fromGatewayURLString(
|
|
url,
|
|
bootstrapToken: payload.bootstrapToken,
|
|
token: payload.token,
|
|
password: payload.password)
|
|
}
|
|
if let primary = links.first {
|
|
let fallbacks = links.dropFirst().map {
|
|
GatewayConnectEndpoint(host: $0.host, port: $0.port, tls: $0.tls)
|
|
}
|
|
return GatewayConnectDeepLink(
|
|
host: primary.host,
|
|
port: primary.port,
|
|
tls: primary.tls,
|
|
bootstrapToken: primary.bootstrapToken,
|
|
token: primary.token,
|
|
password: primary.password,
|
|
fallbackEndpoints: fallbacks)
|
|
}
|
|
guard let host = payload.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!host.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
let tls = payload.tls ?? true
|
|
if !tls, !LoopbackHost.isLocalNetworkHost(host) {
|
|
return nil
|
|
}
|
|
return GatewayConnectDeepLink.validated(
|
|
host: host,
|
|
port: payload.port ?? defaultGatewayPort(tls: tls),
|
|
tls: tls,
|
|
bootstrapToken: payload.bootstrapToken,
|
|
token: payload.token,
|
|
password: payload.password)
|
|
}
|
|
|
|
private static func fromGatewayURLString(
|
|
_ urlString: String,
|
|
bootstrapToken: String?,
|
|
token: String?,
|
|
password: String?) -> GatewayConnectDeepLink?
|
|
{
|
|
guard let parsed = URLComponents(string: urlString),
|
|
let hostname = parsed.host, !hostname.isEmpty
|
|
else { return nil }
|
|
|
|
let scheme = (parsed.scheme ?? "ws").lowercased()
|
|
guard scheme == "ws" || scheme == "wss" || scheme == "http" || scheme == "https" else {
|
|
return nil
|
|
}
|
|
let tls = scheme == "wss" || scheme == "https"
|
|
if !tls, !LoopbackHost.isLocalNetworkHost(hostname) {
|
|
return nil
|
|
}
|
|
return GatewayConnectDeepLink.validated(
|
|
host: hostname,
|
|
port: parsed.port ?? defaultGatewayPort(tls: tls),
|
|
tls: tls,
|
|
bootstrapToken: bootstrapToken,
|
|
token: token,
|
|
password: password)
|
|
}
|
|
|
|
fileprivate static func validated(
|
|
host: String,
|
|
port: Int,
|
|
tls: Bool,
|
|
bootstrapToken: String?,
|
|
token: String?,
|
|
password: String?) -> GatewayConnectDeepLink?
|
|
{
|
|
let link = GatewayConnectDeepLink(
|
|
host: host,
|
|
port: port,
|
|
tls: tls,
|
|
bootstrapToken: bootstrapToken,
|
|
token: token,
|
|
password: password)
|
|
return link.isValidEndpoint ? link : nil
|
|
}
|
|
|
|
private static func decodeBase64Url(_ input: String) -> Data? {
|
|
var base64 = input
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
let remainder = base64.count % 4
|
|
if remainder > 0 {
|
|
base64.append(contentsOf: String(repeating: "=", count: 4 - remainder))
|
|
}
|
|
return Data(base64Encoded: base64)
|
|
}
|
|
|
|
private static func setupCodeCandidates(in input: String) -> [String] {
|
|
let surroundingPunctuation = CharacterSet(charactersIn: "`'\"“”‘’()[]{}<>.,;:")
|
|
return input
|
|
.components(separatedBy: .whitespacesAndNewlines)
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines.union(surroundingPunctuation)) }
|
|
.filter { candidate in
|
|
guard candidate.count >= 24 else { return false }
|
|
return candidate.allSatisfy { ch in
|
|
ch.isLetter || ch.isNumber || ch == "-" || ch == "_" || ch == "="
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct GatewayConnectEndpoint: Codable, Sendable, Equatable {
|
|
public let host: String
|
|
public let port: Int
|
|
public let tls: Bool
|
|
|
|
public init(host: String, port: Int, tls: Bool) {
|
|
self.host = host
|
|
self.port = port
|
|
self.tls = tls
|
|
}
|
|
}
|
|
|
|
public struct AgentDeepLink: Codable, Sendable, Equatable {
|
|
public let message: String
|
|
public let sessionKey: String?
|
|
public let thinking: String?
|
|
public let deliver: Bool
|
|
public let to: String?
|
|
public let channel: String?
|
|
public let timeoutSeconds: Int?
|
|
public let key: String?
|
|
|
|
public init(
|
|
message: String,
|
|
sessionKey: String?,
|
|
thinking: String?,
|
|
deliver: Bool,
|
|
to: String?,
|
|
channel: String?,
|
|
timeoutSeconds: Int?,
|
|
key: String?)
|
|
{
|
|
self.message = message
|
|
self.sessionKey = sessionKey
|
|
self.thinking = thinking
|
|
self.deliver = deliver
|
|
self.to = to
|
|
self.channel = channel
|
|
self.timeoutSeconds = timeoutSeconds
|
|
self.key = key
|
|
}
|
|
}
|
|
|
|
public enum DeepLinkParser {
|
|
public static func parse(_ url: URL) -> DeepLinkRoute? {
|
|
guard let scheme = url.scheme?.lowercased(),
|
|
scheme == "openclaw" || scheme == "openclaw-debug"
|
|
else {
|
|
return nil
|
|
}
|
|
guard let host = url.host?.lowercased(), !host.isEmpty else { return nil }
|
|
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
|
|
|
|
let query = (comps.queryItems ?? []).reduce(into: [String: String]()) { dict, item in
|
|
guard let value = item.value else { return }
|
|
dict[item.name] = value
|
|
}
|
|
|
|
switch host {
|
|
case "agent":
|
|
guard let message = query["message"],
|
|
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
let deliver = (query["deliver"] as NSString?)?.boolValue ?? false
|
|
let timeoutSeconds = query["timeoutSeconds"].flatMap { Int($0) }.flatMap { $0 >= 0 ? $0 : nil }
|
|
return .agent(
|
|
.init(
|
|
message: message,
|
|
sessionKey: query["sessionKey"],
|
|
thinking: query["thinking"],
|
|
deliver: deliver,
|
|
to: query["to"],
|
|
channel: query["channel"],
|
|
timeoutSeconds: timeoutSeconds,
|
|
key: query["key"]))
|
|
|
|
case "gateway":
|
|
guard let hostParam = query["host"],
|
|
!hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
let tls = (query["tls"] as NSString?)?.boolValue ?? false
|
|
let port: Int
|
|
if let rawPort = query["port"] {
|
|
guard let parsedPort = Int(rawPort) else { return nil }
|
|
port = parsedPort
|
|
} else {
|
|
port = defaultGatewayPort(tls: tls)
|
|
}
|
|
if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) {
|
|
return nil
|
|
}
|
|
guard let link = GatewayConnectDeepLink.validated(
|
|
host: hostParam,
|
|
port: port,
|
|
tls: tls,
|
|
bootstrapToken: nil,
|
|
token: query["token"],
|
|
password: query["password"])
|
|
else {
|
|
return nil
|
|
}
|
|
return .gateway(link)
|
|
|
|
case "dashboard":
|
|
return .dashboard
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|