iOS: port onboarding + QR pairing flow stability (#18162)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a87eadea19
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-16 16:22:51 +00:00
committed by GitHub
parent df6d0ee92b
commit 130e59a9c0
15 changed files with 1668 additions and 37 deletions

View File

@@ -2,6 +2,56 @@ import Foundation
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
case gateway(GatewayConnectDeepLink)
}
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
public let token: String?
public let password: String?
public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
self.host = host
self.port = port
self.tls = tls
self.token = token
self.password = password
}
public var websocketURL: URL? {
let scheme = self.tls ? "wss" : "ws"
return URL(string: "\(scheme)://\(self.host):\(self.port)")
}
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
guard let urlString = json["url"] as? String,
let parsed = URLComponents(string: urlString),
let hostname = parsed.host, !hostname.isEmpty
else { return nil }
let scheme = (parsed.scheme ?? "ws").lowercased()
let tls = scheme == "wss"
let port = parsed.port ?? (tls ? 443 : 18789)
let token = json["token"] as? String
let password = json["password"] as? String
return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
}
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)
}
}
public struct AgentDeepLink: Codable, Sendable, Equatable {
@@ -69,6 +119,23 @@ public enum DeepLinkParser {
channel: query["channel"],
timeoutSeconds: timeoutSeconds,
key: query["key"]))
case "gateway":
guard let hostParam = query["host"],
!hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return nil
}
let port = query["port"].flatMap { Int($0) } ?? 18789
let tls = (query["tls"] as NSString?)?.boolValue ?? false
return .gateway(
.init(
host: hostParam,
port: port,
tls: tls,
token: query["token"],
password: query["password"]))
default:
return nil
}

View File

@@ -133,10 +133,16 @@ public actor GatewayChannelActor {
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 3.0
// Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event,
// and we must include the nonce once the gateway requires v2 signing.
private let connectTimeoutSeconds: Double = 12
private let connectChallengeTimeoutSeconds: Double = 6.0
// Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client,
// but NATs/proxies often require outbound traffic to keep the connection alive.
private let keepaliveIntervalSeconds: Double = 15.0
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
@@ -175,6 +181,9 @@ public actor GatewayChannelActor {
self.tickTask?.cancel()
self.tickTask = nil
self.keepaliveTask?.cancel()
self.keepaliveTask = nil
self.task?.cancel(with: .goingAway, reason: nil)
self.task = nil
@@ -257,6 +266,7 @@ public actor GatewayChannelActor {
self.connected = true
self.backoffMs = 500
self.lastSeq = nil
self.startKeepalive()
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
@@ -265,6 +275,29 @@ public actor GatewayChannelActor {
}
}
private func startKeepalive() {
self.keepaliveTask?.cancel()
self.keepaliveTask = Task { [weak self] in
guard let self else { return }
await self.keepaliveLoop()
}
}
private func keepaliveLoop() async {
while self.shouldReconnect {
try? await Task.sleep(nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000))
guard self.shouldReconnect else { return }
guard self.connected else { continue }
// Best-effort outbound message to keep intermediate NAT/proxy state alive.
// We intentionally ignore the response.
do {
try await self.send(method: "health", params: nil)
} catch {
// Avoid spamming logs; the reconnect paths will surface meaningful errors.
}
}
}
private func sendConnect() async throws {
let platform = InstanceIdentity.platformString
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
@@ -458,6 +491,8 @@ public actor GatewayChannelActor {
let wrapped = self.wrap(err, context: "gateway receive")
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
self.connected = false
self.keepaliveTask?.cancel()
self.keepaliveTask = nil
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
await self.failPending(wrapped)
await self.scheduleReconnect()