mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 22:42:19 +08:00
* fix(mobile): clarify gateway connection setup * chore(i18n): sync native mobile strings * fix(mobile): align manual gateway previews * chore(i18n): refresh native source inventory * chore(android): remove unused connect tab strings * test(i18n): drop removed connect tab sentinel * fix(ios): brand connection security picker labels * fix(mobile): hide gateway endpoint previews * chore(i18n): sync native source inventory * fix(mobile): remove redundant TLS helper copy
1929 lines
82 KiB
Swift
1929 lines
82 KiB
Swift
import AVFoundation
|
|
import Contacts
|
|
import CoreLocation
|
|
import CoreMotion
|
|
import CryptoKit
|
|
import EventKit
|
|
import Foundation
|
|
import Network
|
|
import Observation
|
|
import OpenClawChatUI
|
|
import OpenClawKit
|
|
import os
|
|
import Photos
|
|
import ReplayKit
|
|
import Security
|
|
import Speech
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
enum GatewayTLSFingerprintProbeFailure: Equatable {
|
|
case endpointUnreachable
|
|
case tlsHandshakeTimeout
|
|
case tlsUnavailable
|
|
case certificateUnavailable
|
|
}
|
|
|
|
enum GatewayTLSFingerprintProbeResult: Equatable {
|
|
case fingerprint(String)
|
|
case failure(GatewayTLSFingerprintProbeFailure)
|
|
}
|
|
|
|
typealias GatewayTCPReachabilityProbe = @Sendable (String, Int, Double, String) async -> Bool
|
|
typealias GatewayTLSFingerprintProbeFunction = @Sendable (URL) async -> GatewayTLSFingerprintProbeResult
|
|
typealias GatewayServiceEndpointResolver = @Sendable (NWEndpoint) async -> (host: String, port: Int)?
|
|
typealias GatewayForceReconnectReset = @MainActor (NodeAppModel) async -> Void
|
|
typealias GatewayTLSFingerprintPersist = @Sendable (_ fingerprint: String, _ stableID: String) -> Bool
|
|
|
|
private enum GatewayTLSFingerprintProbeBudget {
|
|
static let tcpConnectTimeoutSeconds = 3.0
|
|
static let tlsHandshakeTimeoutSeconds = 10.0
|
|
}
|
|
|
|
private enum GatewaySetupRouteProbeBudget {
|
|
static let tcpConnectTimeoutSeconds = 2.0
|
|
}
|
|
|
|
private func defaultGatewayTCPReachabilityProbe(
|
|
host: String,
|
|
port: Int,
|
|
timeoutSeconds: Double,
|
|
queueLabel: String) async -> Bool
|
|
{
|
|
await TCPProbe.probe(host: host, port: port, timeoutSeconds: timeoutSeconds, queueLabel: queueLabel)
|
|
}
|
|
|
|
private func defaultGatewayTLSFingerprintProbe(url: URL) async -> GatewayTLSFingerprintProbeResult {
|
|
await withCheckedContinuation { continuation in
|
|
let probe = GatewayTLSFingerprintProbe(
|
|
url: url,
|
|
timeoutSeconds: GatewayTLSFingerprintProbeBudget.tlsHandshakeTimeoutSeconds)
|
|
{ result in
|
|
continuation.resume(returning: result)
|
|
}
|
|
probe.start()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class GatewayConnectionController {
|
|
static func resolvedManualPort(host: String, port: Int) -> Int? {
|
|
if port > 0 {
|
|
return port <= 65535 ? port : nil
|
|
}
|
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !trimmedHost.isEmpty else { return nil }
|
|
if trimmedHost.hasSuffix(".ts.net") || trimmedHost.hasSuffix(".ts.net.") {
|
|
return 443
|
|
}
|
|
return 18789
|
|
}
|
|
|
|
struct TrustPrompt: Identifiable, Equatable {
|
|
let stableID: String
|
|
let gatewayName: String
|
|
let host: String
|
|
let port: Int
|
|
let fingerprintSha256: String
|
|
let isManual: Bool
|
|
|
|
var id: String {
|
|
self.stableID
|
|
}
|
|
}
|
|
|
|
struct AutoConnectSuppressionLease {
|
|
fileprivate let generation: UInt64
|
|
fileprivate let previousAutoReconnectEnabled: Bool
|
|
fileprivate let restoresAutoReconnect: Bool
|
|
fileprivate let suspendedConfig: GatewayConnectConfig?
|
|
}
|
|
|
|
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
|
private(set) var discoveryStatusText: String = "Idle"
|
|
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
|
private(set) var pendingTrustPrompt: TrustPrompt?
|
|
|
|
private let discovery = GatewayDiscoveryModel()
|
|
private let discoveryEnabled: Bool
|
|
private weak var appModel: NodeAppModel?
|
|
private var localNetworkAccessRequested: Bool
|
|
private var currentScenePhase: ScenePhase = .inactive
|
|
private var didAutoConnect = false
|
|
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
|
private var pendingTrustConnect: GatewayPendingTrustConnect?
|
|
private var trustProbeGeneration: UInt64 = 0
|
|
private var connectAttemptGeneration: UInt64 = 0
|
|
private var autoConnectSuppressionGeneration: UInt64?
|
|
private var autoConnectSuppressionBaseline: (
|
|
autoReconnectEnabled: Bool,
|
|
restoresAutoReconnect: Bool,
|
|
suspendedConfig: GatewayConnectConfig?)?
|
|
@ObservationIgnored private var pendingAutoConnectTask: Task<Void, Never>?
|
|
@ObservationIgnored private var pendingAutoConnectGeneration: UInt64?
|
|
@ObservationIgnored private var pendingAutoConnectSuppressionGeneration: UInt64?
|
|
@ObservationIgnored private var pendingForgetCleanups: [
|
|
String: (id: UUID, task: Task<Void, Never>)
|
|
] = [:]
|
|
private var pendingConnectionStableID: String?
|
|
private let tcpReachabilityProbe: GatewayTCPReachabilityProbe
|
|
private let tlsFingerprintProbe: GatewayTLSFingerprintProbeFunction
|
|
private let serviceEndpointResolver: GatewayServiceEndpointResolver?
|
|
private let forceReconnectReset: GatewayForceReconnectReset
|
|
private let persistTLSFingerprint: GatewayTLSFingerprintPersist
|
|
|
|
init(
|
|
appModel: NodeAppModel,
|
|
startDiscovery: Bool = true,
|
|
deferDiscoveryUntilLocalNetworkRequest: Bool = false,
|
|
tcpReachabilityProbe: @escaping GatewayTCPReachabilityProbe = defaultGatewayTCPReachabilityProbe,
|
|
tlsFingerprintProbe: @escaping GatewayTLSFingerprintProbeFunction = defaultGatewayTLSFingerprintProbe,
|
|
serviceEndpointResolver: GatewayServiceEndpointResolver? = nil,
|
|
forceReconnectReset: @escaping GatewayForceReconnectReset = { appModel in
|
|
await appModel.resetGatewaySessionsForForcedReconnect()
|
|
},
|
|
persistTLSFingerprint: @escaping GatewayTLSFingerprintPersist = { fingerprint, stableID in
|
|
GatewayTLSStore.replaceFingerprint(fingerprint, stableID: stableID)
|
|
})
|
|
{
|
|
self.discoveryEnabled = startDiscovery
|
|
self.appModel = appModel
|
|
self.localNetworkAccessRequested = !deferDiscoveryUntilLocalNetworkRequest
|
|
self.tcpReachabilityProbe = tcpReachabilityProbe
|
|
self.tlsFingerprintProbe = tlsFingerprintProbe
|
|
self.serviceEndpointResolver = serviceEndpointResolver
|
|
self.forceReconnectReset = forceReconnectReset
|
|
self.persistTLSFingerprint = persistTLSFingerprint
|
|
|
|
GatewaySettingsStore.bootstrapPersistence()
|
|
Self.migrateLegacyDeviceAuth()
|
|
let defaults = UserDefaults.standard
|
|
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs"))
|
|
|
|
self.updateFromDiscovery()
|
|
self.observeDiscovery()
|
|
|
|
if self.discoveryEnabled, self.localNetworkAccessRequested {
|
|
self.discovery.start()
|
|
}
|
|
}
|
|
|
|
func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) {
|
|
self.discovery.setDebugLoggingEnabled(enabled)
|
|
}
|
|
|
|
func selectReachableSetupLink(_ link: GatewayConnectDeepLink) async -> GatewayConnectDeepLink {
|
|
let endpoints = link.connectionEndpoints
|
|
guard endpoints.count > 1 else { return link }
|
|
// Probe before persisting: a setup code may carry LAN and Tailnet routes,
|
|
// but only the route reachable from the phone should become its saved endpoint.
|
|
self.requestLocalNetworkAccess(reason: "setup_route_probe")
|
|
for (index, endpoint) in endpoints.enumerated() {
|
|
let reachable = await self.tcpReachabilityProbe(
|
|
endpoint.host,
|
|
endpoint.port,
|
|
GatewaySetupRouteProbeBudget.tcpConnectTimeoutSeconds,
|
|
"ai.openclaw.gateway.setup-route-\(index)")
|
|
if reachable {
|
|
return link.selectingEndpoint(endpoint)
|
|
}
|
|
}
|
|
return link
|
|
}
|
|
|
|
func requestLocalNetworkAccess(reason: String, allowAutoReconnect: Bool = true) {
|
|
guard self.discoveryEnabled else {
|
|
self.discovery.stop()
|
|
self.updateFromDiscovery(allowAutoConnect: allowAutoReconnect)
|
|
return
|
|
}
|
|
|
|
self.localNetworkAccessRequested = true
|
|
GatewayDiagnostics.log("local network access requested reason=\(reason)")
|
|
|
|
guard self.currentScenePhase != .background else { return }
|
|
self.discovery.start()
|
|
self.updateFromDiscovery(allowAutoConnect: allowAutoReconnect)
|
|
guard allowAutoReconnect else { return }
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
|
|
func setScenePhase(_ phase: ScenePhase) {
|
|
self.currentScenePhase = phase
|
|
guard self.discoveryEnabled else {
|
|
self.discovery.stop()
|
|
return
|
|
}
|
|
guard self.localNetworkAccessRequested else { return }
|
|
|
|
switch phase {
|
|
case .background:
|
|
self.discovery.stop()
|
|
case .active, .inactive:
|
|
self.discovery.start()
|
|
self.attemptAutoReconnectIfNeeded()
|
|
@unknown default:
|
|
self.discovery.start()
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
}
|
|
|
|
func restartDiscovery() {
|
|
guard self.discoveryEnabled else {
|
|
self.discovery.stop()
|
|
self.updateFromDiscovery()
|
|
return
|
|
}
|
|
guard self.localNetworkAccessRequested else {
|
|
self.requestLocalNetworkAccess(reason: "restart_discovery")
|
|
return
|
|
}
|
|
|
|
self.discovery.stop()
|
|
self.didAutoConnect = false
|
|
self.discovery.start()
|
|
self.updateFromDiscovery()
|
|
}
|
|
|
|
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
|
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
|
await self.connectDiscoveredGateway(gateway)
|
|
}
|
|
|
|
private func connectDiscoveredGateway(
|
|
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
|
forceReconnect: Bool = false) async -> String?
|
|
{
|
|
let connectAttempt = self.beginConnectAttempt()
|
|
self.pendingConnectionStableID = gateway.stableID
|
|
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
|
|
await self.waitForPendingForgetCleanup(stableID: gateway.stableID)
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
|
|
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway", allowAutoReconnect: false)
|
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if instanceId.isEmpty {
|
|
return "Missing instanceId (node.instanceId). Try restarting the app."
|
|
}
|
|
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
|
let target = if let serviceEndpointResolver {
|
|
await serviceEndpointResolver(gateway.endpoint)
|
|
} else {
|
|
await self.resolveServiceEndpoint(gateway.endpoint)
|
|
}
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
|
|
guard let target else {
|
|
return "Failed to resolve the discovered gateway endpoint."
|
|
}
|
|
|
|
let stableID = gateway.stableID
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
|
|
let tlsRequired = true
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
|
|
guard gateway.tlsEnabled || stored != nil else {
|
|
return "Discovered gateway is missing TLS and no trusted fingerprint is stored."
|
|
}
|
|
|
|
if tlsRequired, stored == nil {
|
|
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
|
|
else { return "Failed to build TLS URL for trust verification." }
|
|
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
|
|
guard let probeResult = await self.probeTLSFingerprint(
|
|
host: target.host,
|
|
port: target.port,
|
|
url: url,
|
|
queueLabel: "gateway.tls.discovered")
|
|
else { return nil }
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return nil }
|
|
switch probeResult {
|
|
case let .fingerprint(fp):
|
|
self.pendingTrustConnect = GatewayPendingTrustConnect(
|
|
url: url,
|
|
stableID: stableID,
|
|
isManual: false,
|
|
authOverride: nil,
|
|
allowStoredDeviceAuth: true,
|
|
suppressionLease: connectAttempt.suppressionLease,
|
|
gatewayGeneration: connectAttempt.gatewayGeneration)
|
|
self.pendingTrustPrompt = TrustPrompt(
|
|
stableID: stableID,
|
|
gatewayName: gateway.name,
|
|
host: target.host,
|
|
port: target.port,
|
|
fingerprintSha256: fp,
|
|
isManual: false)
|
|
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
|
return nil
|
|
case let .failure(failure):
|
|
let message = self.tlsProbeFailureMessage(
|
|
failure,
|
|
host: target.host,
|
|
port: target.port)
|
|
self.appModel?.gatewayStatusText = message
|
|
return message
|
|
}
|
|
}
|
|
|
|
let tlsParams = stored.map { fp in
|
|
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
|
}
|
|
|
|
guard let url = self.buildGatewayURL(
|
|
host: target.host,
|
|
port: target.port,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return "Failed to build discovered gateway URL." }
|
|
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
|
|
stableID: stableID,
|
|
kind: .discovered,
|
|
name: gateway.name,
|
|
host: nil,
|
|
port: nil,
|
|
useTLS: true,
|
|
lastConnectedAtMs: nil)
|
|
guard self.persistActiveGateway(registryEntry) else {
|
|
return "Could not save the paired gateway."
|
|
}
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: credentials.token,
|
|
bootstrapToken: credentials.bootstrapToken,
|
|
password: credentials.password,
|
|
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth,
|
|
forceReconnect: forceReconnect,
|
|
suppressionGeneration: connectAttempt.suppressionLease.generation,
|
|
expectedGeneration: connectAttempt.gatewayGeneration)
|
|
return nil
|
|
}
|
|
|
|
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
|
_ = await self.connectWithDiagnostics(gateway)
|
|
}
|
|
|
|
func connectManual(
|
|
host: String,
|
|
port: Int,
|
|
useTLS: Bool,
|
|
authOverride: ManualAuthOverride? = nil,
|
|
forceReconnect: Bool = false) async
|
|
{
|
|
let connectAttempt = self.beginConnectAttempt()
|
|
defer { self.finishConnectAttempt(connectAttempt.suppressionLease) }
|
|
self.requestLocalNetworkAccess(reason: "connect_manual", allowAutoReconnect: false)
|
|
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
|
guard let resolvedPort = Self.resolvedManualPort(host: host, port: port)
|
|
else { return }
|
|
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
|
self.pendingConnectionStableID = stableID
|
|
await self.waitForPendingForgetCleanup(stableID: stableID)
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
|
|
let instanceId = GatewaySettingsStore.currentInstanceID()
|
|
let storedCredentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
let token = authOverride.map(\.token) ?? storedCredentials.token
|
|
let bootstrapToken = authOverride.map(\.bootstrapToken) ?? storedCredentials.bootstrapToken
|
|
let password = authOverride.map(\.password) ?? storedCredentials.password
|
|
let suppressStoredDeviceAuth =
|
|
authOverride?.suppressStoredDeviceAuth ?? storedCredentials.suppressStoredDeviceAuth
|
|
let pendingAuthOverride = authOverride ?? (storedCredentials.hasCredentials
|
|
? ManualAuthOverride.explicit(
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
targetStableID: stableID,
|
|
suppressStoredDeviceAuth: suppressStoredDeviceAuth)
|
|
: nil)
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
if resolvedUseTLS, stored == nil {
|
|
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
|
self.appModel?.beginGatewayPreconnectVerification(statusText: "Verifying gateway TLS fingerprint…")
|
|
guard let probeResult = await self.probeTLSFingerprint(
|
|
host: host,
|
|
port: resolvedPort,
|
|
url: url,
|
|
queueLabel: "gateway.tls.manual")
|
|
else { return }
|
|
guard self.connectAttemptGeneration == connectAttempt.suppressionLease.generation else { return }
|
|
switch probeResult {
|
|
case let .fingerprint(fp):
|
|
self.pendingTrustConnect = GatewayPendingTrustConnect(
|
|
url: url,
|
|
stableID: stableID,
|
|
isManual: true,
|
|
authOverride: pendingAuthOverride,
|
|
allowStoredDeviceAuth: !suppressStoredDeviceAuth,
|
|
suppressionLease: connectAttempt.suppressionLease,
|
|
gatewayGeneration: connectAttempt.gatewayGeneration)
|
|
self.pendingTrustPrompt = TrustPrompt(
|
|
stableID: stableID,
|
|
gatewayName: "\(host):\(resolvedPort)",
|
|
host: host,
|
|
port: resolvedPort,
|
|
fingerprintSha256: fp,
|
|
isManual: true)
|
|
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
|
return
|
|
case let .failure(failure):
|
|
self.appModel?.gatewayStatusText = self.tlsProbeFailureMessage(
|
|
failure,
|
|
host: host,
|
|
port: resolvedPort)
|
|
return
|
|
}
|
|
}
|
|
|
|
let tlsParams = stored.map { fp in
|
|
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
|
}
|
|
guard let url = self.buildGatewayURL(
|
|
host: host,
|
|
port: resolvedPort,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
|
|
stableID: stableID,
|
|
kind: .manual,
|
|
name: "\(host):\(resolvedPort)",
|
|
host: host,
|
|
port: resolvedPort,
|
|
useTLS: resolvedUseTLS && tlsParams != nil,
|
|
lastConnectedAtMs: nil)
|
|
guard self.persistActiveGateway(registryEntry) else { return }
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
allowStoredDeviceAuth: !suppressStoredDeviceAuth,
|
|
forceReconnect: forceReconnect,
|
|
suppressionGeneration: connectAttempt.suppressionLease.generation,
|
|
expectedGeneration: connectAttempt.gatewayGeneration)
|
|
}
|
|
|
|
func connectActiveGateway() async {
|
|
self.requestLocalNetworkAccess(reason: "connect_active_gateway", allowAutoReconnect: false)
|
|
guard let active = GatewaySettingsStore.activeGatewayEntry() else { return }
|
|
switch active.kind {
|
|
case .manual:
|
|
guard let host = active.host, let port = active.port else { return }
|
|
await self.connectManual(host: host, port: port, useTLS: active.useTLS, forceReconnect: true)
|
|
case .discovered:
|
|
if let gateway = self.gateways.first(where: { $0.stableID == active.stableID }) {
|
|
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
|
|
return
|
|
}
|
|
guard let fallback = self.mostRecentlyConnectedManualGateway() else { return }
|
|
guard let host = fallback.host, let port = fallback.port else { return }
|
|
await self.connectManual(host: host, port: port, useTLS: fallback.useTLS, forceReconnect: true)
|
|
}
|
|
}
|
|
|
|
/// Returns `nil` after initiating a switch, or a user-facing discovery failure.
|
|
func switchToGateway(stableID: String) async -> String? {
|
|
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let entry = GatewaySettingsStore.loadGatewayRegistry().entries.first(where: {
|
|
$0.stableID == stableID
|
|
}) else {
|
|
return "This paired gateway is no longer available."
|
|
}
|
|
switch entry.kind {
|
|
case .manual:
|
|
guard let host = entry.host, let port = entry.port else {
|
|
return "This paired gateway has an invalid saved endpoint."
|
|
}
|
|
// Switching intentionally persists the user's selection at initiation, matching connect flows.
|
|
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
|
|
return "Could not save the active gateway selection."
|
|
}
|
|
await self.connectManual(
|
|
host: host,
|
|
port: port,
|
|
useTLS: entry.useTLS,
|
|
forceReconnect: true)
|
|
return nil
|
|
case .discovered:
|
|
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else {
|
|
return "\(entry.name) is not currently discoverable on this network."
|
|
}
|
|
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
|
|
return "Could not save the active gateway selection."
|
|
}
|
|
return await self.connectDiscoveredGateway(gateway, forceReconnect: true)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func forgetGateway(stableID: String) -> Bool {
|
|
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !stableID.isEmpty else { return false }
|
|
if self.pendingForgetCleanups[stableID] != nil {
|
|
return true
|
|
}
|
|
guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else {
|
|
return false
|
|
}
|
|
if self.pendingConnectionStableID == stableID {
|
|
let cancellationLease = self.cancelPendingConnectionAttempts()
|
|
self.releaseAutoConnectSuppression(after: cancellationLease)
|
|
}
|
|
let wasConnected = self.appModel?.activeGatewayConnectConfig?.effectiveStableID == stableID ||
|
|
self.appModel?.connectedGatewayID == stableID
|
|
let shouldDisconnect = wasConnected
|
|
if shouldDisconnect {
|
|
let hasDifferentPendingTarget = self.pendingConnectionStableID.map { $0 != stableID } ?? false
|
|
self.appModel?.disconnectForgottenGateway(
|
|
preservingPendingConnectAttempt: hasDifferentPendingTarget)
|
|
}
|
|
let instanceID = GatewaySettingsStore.currentInstanceID()
|
|
self.clearLegacyManualGatewayDefaults(matching: stableID)
|
|
GatewaySettingsStore.clearLegacyGatewaySelectors(stableID: stableID)
|
|
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID, stableID: stableID)
|
|
_ = GatewaySettingsStore.clearGatewayCustomHeaders(gatewayStableID: stableID)
|
|
_ = GatewayTLSStore.clearFingerprint(stableID: stableID)
|
|
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: stableID, clientId: nil)
|
|
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: nil)
|
|
let shareRelayGatewayID = ShareGatewayRelaySettings.loadConfig()?.gatewayStableID?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if shareRelayGatewayID == stableID {
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
}
|
|
|
|
Self.clearDeviceAuthTokens(gatewayID: stableID)
|
|
let cleanupID = UUID()
|
|
let cleanupTask = Task { @MainActor [weak appModel] in
|
|
if let appModel {
|
|
if shouldDisconnect {
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
// A handshake racing teardown can persist a token after the first cleanup.
|
|
Self.clearDeviceAuthTokens(gatewayID: stableID)
|
|
}
|
|
await appModel.purgeChatTranscriptCache(gatewayID: stableID)
|
|
} else if let databaseURL = NodeAppModel.chatTranscriptCacheDatabaseURL(gatewayID: stableID) {
|
|
OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL)
|
|
}
|
|
}
|
|
self.pendingForgetCleanups[stableID] = (cleanupID, cleanupTask)
|
|
Task { @MainActor [weak self] in
|
|
await cleanupTask.value
|
|
guard self?.pendingForgetCleanups[stableID]?.id == cleanupID else { return }
|
|
self?.pendingForgetCleanups[stableID] = nil
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func waitForPendingForgetCleanup(stableID: String) async {
|
|
guard let pending = self.pendingForgetCleanups[stableID] else { return }
|
|
await pending.task.value
|
|
if self.pendingForgetCleanups[stableID]?.id == pending.id {
|
|
self.pendingForgetCleanups[stableID] = nil
|
|
}
|
|
}
|
|
|
|
private func persistActiveGateway(_ entry: GatewaySettingsStore.GatewayRegistryEntry) -> Bool {
|
|
guard GatewaySettingsStore.upsertGatewayRegistryEntry(entry, activate: true) else {
|
|
self.appModel?.gatewayStatusText = "Could not save paired gateway"
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private static func clearDeviceAuthTokens(gatewayID: String) {
|
|
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
|
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "node", gatewayID: gatewayID)
|
|
DeviceAuthStore.clearToken(deviceId: primaryIdentity.deviceId, role: "operator", gatewayID: gatewayID)
|
|
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
|
DeviceAuthStore.clearToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "node",
|
|
gatewayID: gatewayID,
|
|
profile: .shareExtension)
|
|
DeviceAuthStore.clearToken(
|
|
deviceId: shareIdentity.deviceId,
|
|
role: "operator",
|
|
gatewayID: gatewayID,
|
|
profile: .shareExtension)
|
|
}
|
|
|
|
private func clearLegacyManualGatewayDefaults(matching stableID: String) {
|
|
let defaults = UserDefaults.standard
|
|
let host = defaults.string(forKey: "gateway.manual.host")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let port = Self.resolvedManualPort(
|
|
host: host,
|
|
port: defaults.integer(forKey: "gateway.manual.port"))
|
|
guard !host.isEmpty, let port, self.manualStableID(host: host, port: port) == stableID else { return }
|
|
defaults.set(false, forKey: "gateway.manual.enabled")
|
|
defaults.removeObject(forKey: "gateway.manual.host")
|
|
defaults.removeObject(forKey: "gateway.manual.port")
|
|
defaults.removeObject(forKey: "gateway.manual.tls")
|
|
}
|
|
|
|
/// Rebuild connect options from current local settings (caps/commands/permissions)
|
|
/// and re-apply the active gateway config so capability changes take effect immediately.
|
|
func refreshActiveGatewayRegistrationFromSettings() {
|
|
Task { [weak self] in
|
|
await self?.refreshActiveGatewayRegistrationFromSettingsAsync()
|
|
}
|
|
}
|
|
|
|
private func refreshActiveGatewayRegistrationFromSettingsAsync() async {
|
|
guard let appModel else { return }
|
|
guard let cfg = appModel.activeGatewayConnectConfig else { return }
|
|
guard appModel.gatewayAutoReconnectEnabled else { return }
|
|
let generation = appModel.gatewayConnectGeneration
|
|
|
|
let nodeOptions = await self.makeConnectOptions(
|
|
stableID: cfg.stableID,
|
|
deviceAuthGatewayID: cfg.nodeOptions.deviceAuthGatewayID,
|
|
allowStoredDeviceAuth: cfg.nodeOptions.allowStoredDeviceAuth)
|
|
let refreshedConfig = GatewayConnectConfig(
|
|
url: cfg.url,
|
|
stableID: cfg.stableID,
|
|
tls: cfg.tls,
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
nodeOptions: nodeOptions)
|
|
appModel.applyGatewayConnectConfig(refreshedConfig, expectedGeneration: generation)
|
|
}
|
|
|
|
func clearPendingTrustPrompt() {
|
|
// Invalidate an in-flight probe so its late result cannot restore a stale prompt.
|
|
self.trustProbeGeneration &+= 1
|
|
self.pendingTrustPrompt = nil
|
|
self.pendingTrustConnect = nil
|
|
self.pendingConnectionStableID = nil
|
|
}
|
|
|
|
@discardableResult
|
|
func cancelPendingConnectionAttempts(
|
|
suspendCurrentGateway: Bool = false) -> AutoConnectSuppressionLease
|
|
{
|
|
let lease = self.beginAutoConnectSuppression(restoresAutoReconnect: suspendCurrentGateway)
|
|
_ = self.reserveGatewayConnectAttempt()
|
|
if suspendCurrentGateway {
|
|
self.appModel?.suspendGatewayForTargetReview()
|
|
}
|
|
return lease
|
|
}
|
|
|
|
private func beginAutoConnectSuppression(restoresAutoReconnect: Bool) -> AutoConnectSuppressionLease {
|
|
let baseline = if self.autoConnectSuppressionGeneration != nil,
|
|
let baseline = self.autoConnectSuppressionBaseline
|
|
{
|
|
(
|
|
autoReconnectEnabled: baseline.autoReconnectEnabled,
|
|
restoresAutoReconnect: baseline.restoresAutoReconnect || restoresAutoReconnect,
|
|
suspendedConfig: baseline.suspendedConfig ??
|
|
(restoresAutoReconnect ? self.appModel?.activeGatewayConnectConfig : nil))
|
|
} else {
|
|
(
|
|
autoReconnectEnabled: self.appModel?.gatewayAutoReconnectEnabled ?? false,
|
|
restoresAutoReconnect: restoresAutoReconnect,
|
|
suspendedConfig: restoresAutoReconnect ? self.appModel?.activeGatewayConnectConfig : nil)
|
|
}
|
|
self.connectAttemptGeneration &+= 1
|
|
self.autoConnectSuppressionGeneration = self.connectAttemptGeneration
|
|
self.autoConnectSuppressionBaseline = baseline
|
|
self.clearPendingTrustPrompt()
|
|
return AutoConnectSuppressionLease(
|
|
generation: self.connectAttemptGeneration,
|
|
previousAutoReconnectEnabled: baseline.autoReconnectEnabled,
|
|
restoresAutoReconnect: baseline.restoresAutoReconnect,
|
|
suspendedConfig: baseline.suspendedConfig)
|
|
}
|
|
|
|
func resumeAutoConnect(after lease: AutoConnectSuppressionLease) {
|
|
// A dismissed older target must not release suppression owned by its replacement.
|
|
guard self.autoConnectSuppressionGeneration == lease.generation else { return }
|
|
self.clearAutoConnectSuppression(generation: lease.generation)
|
|
if lease.restoresAutoReconnect {
|
|
let currentPreference = UserDefaults.standard.bool(forKey: "gateway.autoconnect")
|
|
if lease.previousAutoReconnectEnabled,
|
|
currentPreference,
|
|
let suspendedConfig = lease.suspendedConfig
|
|
{
|
|
self.appModel?.resumeGatewayAfterTargetReview(suspendedConfig)
|
|
return
|
|
}
|
|
self.appModel?.gatewayAutoReconnectEnabled = lease.previousAutoReconnectEnabled && currentPreference
|
|
}
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
|
|
func releaseAutoConnectSuppression(after lease: AutoConnectSuppressionLease) {
|
|
self.clearAutoConnectSuppression(generation: lease.generation)
|
|
}
|
|
|
|
private func clearAutoConnectSuppression(generation: UInt64) {
|
|
guard self.autoConnectSuppressionGeneration == generation else { return }
|
|
self.autoConnectSuppressionGeneration = nil
|
|
self.autoConnectSuppressionBaseline = nil
|
|
}
|
|
|
|
func acceptPendingTrustPrompt() async {
|
|
guard let pending = self.pendingTrustConnect,
|
|
let prompt = self.pendingTrustPrompt,
|
|
pending.stableID == prompt.stableID
|
|
else { return }
|
|
|
|
guard self.persistTLSFingerprint(prompt.fingerprintSha256, pending.stableID) else {
|
|
self.appModel?.gatewayStatusText = "Could not save gateway certificate"
|
|
return
|
|
}
|
|
|
|
let instanceId = GatewaySettingsStore.currentInstanceID()
|
|
let registryEntry = GatewaySettingsStore.GatewayRegistryEntry(
|
|
stableID: pending.stableID,
|
|
kind: pending.isManual ? .manual : .discovered,
|
|
name: prompt.gatewayName,
|
|
host: pending.isManual ? prompt.host : nil,
|
|
port: pending.isManual ? prompt.port : nil,
|
|
useTLS: true,
|
|
lastConnectedAtMs: nil)
|
|
guard self.persistActiveGateway(registryEntry) else {
|
|
_ = GatewayTLSStore.clearFingerprint(stableID: pending.stableID)
|
|
return
|
|
}
|
|
self.clearPendingTrustPrompt()
|
|
let storedCredentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: pending.stableID)
|
|
let token = pending.authOverride.map(\.token) ?? storedCredentials.token
|
|
let bootstrapToken = pending.authOverride.map(\.bootstrapToken) ?? storedCredentials.bootstrapToken
|
|
let password = pending.authOverride.map(\.password) ?? storedCredentials.password
|
|
let suppressStoredDeviceAuth =
|
|
pending.authOverride?.suppressStoredDeviceAuth ?? storedCredentials.suppressStoredDeviceAuth
|
|
let tlsParams = GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: prompt.fingerprintSha256,
|
|
allowTOFU: false,
|
|
storeKey: pending.stableID)
|
|
|
|
self.didAutoConnect = true
|
|
let didStart = self.startAutoConnect(
|
|
url: pending.url,
|
|
gatewayStableID: pending.stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
allowStoredDeviceAuth: pending.allowStoredDeviceAuth && !suppressStoredDeviceAuth,
|
|
suppressionGeneration: pending.suppressionLease.generation,
|
|
expectedGeneration: pending.gatewayGeneration)
|
|
if !didStart {
|
|
self.clearAutoConnectSuppression(generation: pending.suppressionLease.generation)
|
|
}
|
|
}
|
|
|
|
func declinePendingTrustPrompt() {
|
|
let lease = self.pendingTrustConnect?.suppressionLease
|
|
self.clearPendingTrustPrompt()
|
|
self.appModel?.gatewayStatusText = "Offline"
|
|
if let lease {
|
|
self.resumeAutoConnect(after: lease)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem) async -> Bool {
|
|
guard problem.canTrustRotatedCertificate,
|
|
let stableID = problem.tlsStoreKey,
|
|
let fingerprint = problem.tlsObservedFingerprint
|
|
else {
|
|
self.appModel?.gatewayStatusText = "Certificate review required"
|
|
return false
|
|
}
|
|
|
|
guard self.persistTLSFingerprint(fingerprint, stableID) else {
|
|
self.appModel?.gatewayStatusText = "Could not update gateway certificate"
|
|
return false
|
|
}
|
|
|
|
GatewayDiagnostics.log(
|
|
"gateway tls pin replaced stableID=\(stableID) "
|
|
+ "old=\(problem.tlsExpectedFingerprint ?? "unknown") new=\(fingerprint)")
|
|
self.appModel?.gatewayStatusText = "Gateway certificate updated. Reconnecting…"
|
|
if let appModel = self.appModel, let cfg = appModel.activeGatewayConnectConfig {
|
|
let currentTLS = cfg.tls
|
|
let refreshedTLS = GatewayTLSParams(
|
|
required: currentTLS?.required ?? true,
|
|
expectedFingerprint: fingerprint,
|
|
allowTOFU: currentTLS?.allowTOFU ?? false,
|
|
storeKey: currentTLS?.storeKey ?? stableID)
|
|
let refreshedConfig = GatewayConnectConfig(
|
|
url: cfg.url,
|
|
stableID: cfg.stableID,
|
|
tls: refreshedTLS,
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
nodeOptions: cfg.nodeOptions)
|
|
appModel.applyGatewayConnectConfig(refreshedConfig)
|
|
} else {
|
|
await self.connectActiveGateway()
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func updateFromDiscovery(allowAutoConnect: Bool = true) {
|
|
let newGateways = self.discovery.gateways
|
|
self.gateways = newGateways
|
|
self.discoveryStatusText = self.discovery.statusText
|
|
self.discoveryDebugLog = self.discovery.debugLog
|
|
self.updateLastDiscoveredGateway(from: newGateways)
|
|
if allowAutoConnect {
|
|
self.maybeAutoConnect()
|
|
}
|
|
}
|
|
|
|
private func observeDiscovery() {
|
|
withObservationTracking {
|
|
_ = self.discovery.gateways
|
|
_ = self.discovery.statusText
|
|
_ = self.discovery.debugLog
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.updateFromDiscovery()
|
|
self.observeDiscovery()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func maybeAutoConnect() {
|
|
guard self.autoConnectSuppressionGeneration == nil else { return }
|
|
guard !self.didAutoConnect else { return }
|
|
guard let appModel = self.appModel else { return }
|
|
guard appModel.gatewayServerName == nil else { return }
|
|
|
|
let defaults = UserDefaults.standard
|
|
guard defaults.bool(forKey: "gateway.autoconnect") else { return }
|
|
|
|
let instanceId = defaults.string(forKey: "node.instanceId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !instanceId.isEmpty else { return }
|
|
|
|
if let active = GatewaySettingsStore.activeGatewayEntry() {
|
|
if self.startActiveGatewayAutoConnect(active, instanceId: instanceId) {
|
|
return
|
|
}
|
|
if active.kind == .discovered,
|
|
let target = self.gateways.first(where: { $0.stableID == active.stableID }),
|
|
GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil
|
|
{
|
|
self.didAutoConnect = true
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
_ = await self.connectDiscoveredGateway(target)
|
|
}
|
|
return
|
|
}
|
|
if active.kind == .discovered,
|
|
let fallback = self.mostRecentlyConnectedManualGateway(),
|
|
self.startActiveGatewayAutoConnect(fallback, instanceId: instanceId)
|
|
{
|
|
_ = GatewaySettingsStore.setActiveGateway(stableID: fallback.stableID)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
if defaults.bool(forKey: "gateway.manual.enabled") {
|
|
self.startConfiguredManualAutoConnect(defaults: defaults, instanceId: instanceId)
|
|
return
|
|
}
|
|
|
|
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
|
if let targetStableID = candidates.first(where: { id in
|
|
self.gateways.contains(where: { $0.stableID == id })
|
|
}) {
|
|
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
|
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
|
guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
|
|
|
|
self.didAutoConnect = true
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
_ = await self.connectDiscoveredGateway(target)
|
|
}
|
|
return
|
|
}
|
|
|
|
if self.gateways.count == 1, let gateway = self.gateways.first {
|
|
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
|
guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return }
|
|
|
|
self.didAutoConnect = true
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
_ = await self.connectDiscoveredGateway(gateway)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
private func startConfiguredManualAutoConnect(defaults: UserDefaults, instanceId: String) {
|
|
let host = defaults.string(forKey: "gateway.manual.host")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !host.isEmpty else { return }
|
|
|
|
let configuredPort = defaults.integer(forKey: "gateway.manual.port")
|
|
let configuredTLS = defaults.bool(forKey: "gateway.manual.tls")
|
|
let useTLS = self.resolveManualUseTLS(host: host, useTLS: configuredTLS)
|
|
guard let port = Self.resolvedManualPort(host: host, port: configuredPort) else { return }
|
|
|
|
let stableID = self.manualStableID(host: host, port: port)
|
|
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
|
// Manual TLS auto-connect cannot present first-use trust UI, so it requires an existing pin.
|
|
guard !useTLS || tlsParams?.expectedFingerprint != nil else { return }
|
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: credentials.token,
|
|
bootstrapToken: credentials.bootstrapToken,
|
|
password: credentials.password,
|
|
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth)
|
|
}
|
|
|
|
private func startActiveGatewayAutoConnect(
|
|
_ active: GatewaySettingsStore.GatewayRegistryEntry,
|
|
instanceId: String) -> Bool
|
|
{
|
|
switch active.kind {
|
|
case .manual:
|
|
guard let host = active.host, let port = active.port else { return false }
|
|
let stableID = active.stableID
|
|
let useTLS = active.useTLS
|
|
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
|
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: resolvedUseTLS)
|
|
guard !resolvedUseTLS || tlsParams?.expectedFingerprint != nil else { return false }
|
|
guard let url = self.buildGatewayURL(
|
|
host: host,
|
|
port: port,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return false }
|
|
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: stableID)
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: credentials.token,
|
|
bootstrapToken: credentials.bootstrapToken,
|
|
password: credentials.password,
|
|
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth)
|
|
return true
|
|
case .discovered:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func attemptAutoReconnectIfNeeded() {
|
|
guard let appModel = self.appModel else { return }
|
|
guard appModel.gatewayAutoReconnectEnabled else { return }
|
|
guard self.autoConnectSuppressionGeneration == nil else { return }
|
|
// Avoid starting duplicate connect loops while a prior config is active.
|
|
guard appModel.activeGatewayConnectConfig == nil else { return }
|
|
guard UserDefaults.standard.bool(forKey: "gateway.autoconnect") else { return }
|
|
self.didAutoConnect = false
|
|
self.maybeAutoConnect()
|
|
}
|
|
|
|
private func mostRecentlyConnectedManualGateway() -> GatewaySettingsStore.GatewayRegistryEntry? {
|
|
GatewaySettingsStore.loadGatewayRegistry().entries
|
|
.filter { $0.kind == .manual }
|
|
.max { lhs, rhs in
|
|
let lhsConnected = lhs.lastConnectedAtMs ?? Int.min
|
|
let rhsConnected = rhs.lastConnectedAtMs ?? Int.min
|
|
if lhsConnected != rhsConnected { return lhsConnected < rhsConnected }
|
|
return lhs.stableID > rhs.stableID
|
|
}
|
|
}
|
|
|
|
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
|
let defaults = UserDefaults.standard
|
|
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
|
|
guard preferred.isEmpty, existingLast.isEmpty else { return }
|
|
guard let first = gateways.first else { return }
|
|
|
|
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
|
|
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID)
|
|
}
|
|
|
|
@discardableResult
|
|
private func startAutoConnect(
|
|
url: URL,
|
|
gatewayStableID: String,
|
|
tls: GatewayTLSParams?,
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
allowStoredDeviceAuth: Bool = true,
|
|
forceReconnect: Bool = false,
|
|
suppressionGeneration: UInt64? = nil,
|
|
expectedGeneration: UInt64? = nil) -> Bool
|
|
{
|
|
guard let appModel else { return false }
|
|
if let expectedGeneration {
|
|
guard expectedGeneration == appModel.gatewayConnectGeneration else { return false }
|
|
}
|
|
let previousTask = self.pendingAutoConnectTask
|
|
previousTask?.cancel()
|
|
// Advancing again at handoff rejects work that captured the reservation generation while
|
|
// endpoint resolution or trust verification was suspended.
|
|
let generation = appModel.beginGatewayConnectAttempt()
|
|
self.pendingAutoConnectGeneration = generation
|
|
self.pendingConnectionStableID = gatewayStableID
|
|
// An explicit target owns suppression until its queued handoff exits. Otherwise a
|
|
// foreground reconnect can replace it while reset or permission work is suspended.
|
|
self.pendingAutoConnectSuppressionGeneration = suppressionGeneration
|
|
appModel.gatewayStatusText = "Connecting…"
|
|
let task = Task { [weak self, weak appModel] in
|
|
guard let self, let appModel else { return }
|
|
defer {
|
|
if self.pendingAutoConnectGeneration == generation {
|
|
self.pendingAutoConnectTask = nil
|
|
self.pendingAutoConnectGeneration = nil
|
|
self.pendingAutoConnectSuppressionGeneration = nil
|
|
if self.pendingConnectionStableID == gatewayStableID {
|
|
self.pendingConnectionStableID = nil
|
|
}
|
|
}
|
|
if let suppressionGeneration,
|
|
self.autoConnectSuppressionGeneration == suppressionGeneration
|
|
{
|
|
self.clearAutoConnectSuppression(generation: suppressionGeneration)
|
|
}
|
|
}
|
|
await previousTask?.value
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return }
|
|
if forceReconnect {
|
|
await self.forceReconnectReset(appModel)
|
|
guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return }
|
|
}
|
|
let nodeOptions = await self.makeConnectOptions(
|
|
stableID: gatewayStableID,
|
|
deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID(routeStableID: gatewayStableID),
|
|
allowStoredDeviceAuth: allowStoredDeviceAuth)
|
|
// Permission reads above can suspend long enough for a model-owned reconnect reset
|
|
// to start, so close the reset barrier again immediately before the synchronous apply.
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
guard !Task.isCancelled, generation == appModel.gatewayConnectGeneration else { return }
|
|
let cfg = GatewayConnectConfig(
|
|
url: url,
|
|
stableID: gatewayStableID,
|
|
tls: tls,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
nodeOptions: nodeOptions)
|
|
appModel.applyGatewayConnectConfig(
|
|
cfg,
|
|
forceReconnect: forceReconnect,
|
|
expectedGeneration: generation)
|
|
}
|
|
self.pendingAutoConnectTask = task
|
|
return true
|
|
}
|
|
|
|
private func resolveDiscoveredTLSParams(
|
|
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
|
{
|
|
let stableID = gateway.stableID
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
|
|
// Never let unauthenticated discovery (TXT) override a stored pin.
|
|
if let stored {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: stored,
|
|
allowTOFU: false,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: nil,
|
|
allowTOFU: false,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolveManualTLSParams(
|
|
stableID: String,
|
|
tlsEnabled: Bool) -> GatewayTLSParams?
|
|
{
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
if tlsEnabled || stored != nil {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: stored,
|
|
allowTOFU: false,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func probeTLSFingerprint(
|
|
host: String,
|
|
port: Int,
|
|
url: URL,
|
|
queueLabel: String) async -> GatewayTLSFingerprintProbeResult?
|
|
{
|
|
self.trustProbeGeneration &+= 1
|
|
let generation = self.trustProbeGeneration
|
|
self.pendingTrustConnect = nil
|
|
self.pendingTrustPrompt = nil
|
|
let reachable = await self.tcpReachabilityProbe(
|
|
host,
|
|
port,
|
|
GatewayTLSFingerprintProbeBudget.tcpConnectTimeoutSeconds,
|
|
queueLabel)
|
|
guard self.trustProbeGeneration == generation else { return nil }
|
|
guard reachable else {
|
|
return .failure(.endpointUnreachable)
|
|
}
|
|
let result = await self.tlsFingerprintProbe(url)
|
|
guard self.trustProbeGeneration == generation else { return nil }
|
|
return result
|
|
}
|
|
|
|
private func beginConnectAttempt()
|
|
-> (suppressionLease: AutoConnectSuppressionLease, gatewayGeneration: UInt64?)
|
|
{
|
|
let suppressionLease = self.beginAutoConnectSuppression(restoresAutoReconnect: false)
|
|
// Allocate both tokens before any resolution or trust work. A new explicit target must
|
|
// invalidate queued config construction from the previous target immediately.
|
|
let gatewayGeneration = self.reserveGatewayConnectAttempt()
|
|
return (suppressionLease, gatewayGeneration)
|
|
}
|
|
|
|
private func reserveGatewayConnectAttempt() -> UInt64? {
|
|
let previousTask = self.pendingAutoConnectTask
|
|
previousTask?.cancel()
|
|
self.pendingConnectionStableID = nil
|
|
guard let appModel else { return nil }
|
|
let generation = appModel.beginGatewayConnectAttempt()
|
|
let activeConfig = appModel.activeGatewayConnectConfig
|
|
let shouldRestoreActiveConfig = appModel.gatewayAutoReconnectEnabled &&
|
|
!appModel.gatewayPairingPaused &&
|
|
appModel.lastGatewayProblem?.pauseReconnect != true &&
|
|
(previousTask != nil || appModel.hasGatewaySessionResetInFlight)
|
|
self.pendingAutoConnectSuppressionGeneration = nil
|
|
self.pendingAutoConnectGeneration = generation
|
|
// The barrier owns any superseded teardown until it finishes. If the replacement never
|
|
// reaches handoff, restore the still-current route after that teardown completes.
|
|
let barrier = Task { [weak self, weak appModel] in
|
|
guard let self, let appModel else { return }
|
|
defer {
|
|
if self.pendingAutoConnectGeneration == generation {
|
|
self.pendingAutoConnectTask = nil
|
|
self.pendingAutoConnectGeneration = nil
|
|
}
|
|
}
|
|
await previousTask?.value
|
|
await appModel.waitForGatewaySessionResetIfNeeded()
|
|
guard !Task.isCancelled,
|
|
generation == appModel.gatewayConnectGeneration,
|
|
shouldRestoreActiveConfig,
|
|
let activeConfig,
|
|
appModel.gatewayAutoReconnectEnabled,
|
|
!appModel.gatewayPairingPaused,
|
|
appModel.lastGatewayProblem?.pauseReconnect != true,
|
|
appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: activeConfig) == true
|
|
else { return }
|
|
appModel.applyGatewayConnectConfig(activeConfig, expectedGeneration: generation)
|
|
}
|
|
self.pendingAutoConnectTask = barrier
|
|
return generation
|
|
}
|
|
|
|
private func finishConnectAttempt(_ lease: AutoConnectSuppressionLease) {
|
|
guard self.connectAttemptGeneration == lease.generation else { return }
|
|
guard self.pendingTrustPrompt == nil else { return }
|
|
guard self.pendingAutoConnectSuppressionGeneration != lease.generation else { return }
|
|
self.pendingConnectionStableID = nil
|
|
if lease.restoresAutoReconnect {
|
|
self.resumeAutoConnect(after: lease)
|
|
} else {
|
|
self.releaseAutoConnectSuppression(after: lease)
|
|
}
|
|
}
|
|
|
|
private func tlsProbeFailureMessage(
|
|
_ failure: GatewayTLSFingerprintProbeFailure,
|
|
host: String,
|
|
port: Int) -> String
|
|
{
|
|
switch failure {
|
|
case .endpointUnreachable:
|
|
"Can't reach gateway at \(host):\(port). Check Tailscale or LAN."
|
|
case .tlsHandshakeTimeout:
|
|
"TLS fingerprint verification timed out for \(host):\(port). "
|
|
+ "Secure endpoint was reached, but TLS did not finish in time."
|
|
case .tlsUnavailable:
|
|
"No secure gateway endpoint was detected at \(host):\(port). "
|
|
+ "Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected."
|
|
case .certificateUnavailable:
|
|
"Could not read the TLS certificate from \(host):\(port)."
|
|
}
|
|
}
|
|
|
|
private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
|
guard case let .service(name, type, domain, _) = endpoint else { return nil }
|
|
let key = "\(domain)|\(type)|\(name)"
|
|
return await withCheckedContinuation { continuation in
|
|
let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in
|
|
Task { @MainActor in
|
|
self?.pendingServiceResolvers[key] = nil
|
|
continuation.resume(returning: result)
|
|
}
|
|
}
|
|
self.pendingServiceResolvers[key] = resolver
|
|
resolver.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension GatewayConnectionController {
|
|
private static func migrateLegacyDeviceAuth() {
|
|
let migrationGatewayID = self.legacyDeviceAuthMigrationGatewayID()
|
|
let relay = ShareGatewayRelaySettings.loadConfig()
|
|
let instanceID = GatewaySettingsStore.currentInstanceID()
|
|
if let migrationGatewayID, let relay {
|
|
_ = GatewaySettingsStore.migrateProvenRelayCredentials(
|
|
instanceId: instanceID,
|
|
gatewayStableID: migrationGatewayID,
|
|
token: relay.token,
|
|
password: relay.password)
|
|
} else {
|
|
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
|
|
}
|
|
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
|
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
|
// The extension connects independently, so the host's last route cannot prove who
|
|
// issued its legacy token. Require one extension re-pair instead of guessing an owner.
|
|
DeviceAuthStore.discardUnscopedTokens(
|
|
deviceId: shareIdentity.deviceId,
|
|
profile: .shareExtension)
|
|
guard let migrationGatewayID else {
|
|
// No cross-gateway fallback: ambiguous legacy tokens require one explicit re-pair.
|
|
DeviceAuthStore.discardUnscopedTokens(deviceId: primaryIdentity.deviceId)
|
|
return
|
|
}
|
|
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceID,
|
|
gatewayStableID: migrationGatewayID)
|
|
let hasProvenOperatorCredentials = credentials.token != nil || credentials.password != nil
|
|
if hasProvenOperatorCredentials {
|
|
// Shared credentials recover the independently authenticated operator session.
|
|
// Without them, migrate neither role so reconnect enters the normal re-pair flow.
|
|
DeviceAuthStore.migrateUnscopedToken(
|
|
deviceId: primaryIdentity.deviceId,
|
|
role: "node",
|
|
toGatewayID: migrationGatewayID)
|
|
}
|
|
DeviceAuthStore.discardUnscopedTokens(deviceId: primaryIdentity.deviceId)
|
|
guard let relay else { return }
|
|
let relayStableID = relay.gatewayStableID?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard relayStableID.isEmpty else { return }
|
|
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
|
|
gatewayURLString: relay.gatewayURLString,
|
|
gatewayStableID: migrationGatewayID,
|
|
token: relay.token,
|
|
password: relay.password,
|
|
sessionKey: relay.sessionKey,
|
|
deliveryChannel: relay.deliveryChannel,
|
|
deliveryTo: relay.deliveryTo))
|
|
}
|
|
|
|
private static func legacyDeviceAuthMigrationGatewayID() -> String? {
|
|
guard let relay = ShareGatewayRelaySettings.loadConfig() else { return nil }
|
|
if let stableID = relay.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!stableID.isEmpty
|
|
{
|
|
return stableID
|
|
}
|
|
guard let active = GatewaySettingsStore.activeGatewayEntry(),
|
|
active.kind == .manual,
|
|
let host = active.host,
|
|
let port = active.port,
|
|
let relayURL = URL(string: relay.gatewayURLString),
|
|
relayURL.host?.caseInsensitiveCompare(host) == .orderedSame
|
|
else { return nil }
|
|
let relayPort = relayURL.port ?? (relayURL.scheme?.lowercased() == "wss" ? 443 : 80)
|
|
let relayUsesTLS = relayURL.scheme?.lowercased() == "wss"
|
|
guard relayPort == port, relayUsesTLS == active.useTLS else { return nil }
|
|
return active.stableID
|
|
}
|
|
|
|
struct ManualAuthOverride: Equatable {
|
|
struct SetupAuth {
|
|
let token: String
|
|
let bootstrapToken: String
|
|
let password: String
|
|
let targetStableID: String
|
|
|
|
var hasBootstrapToken: Bool {
|
|
!self.bootstrapToken.isEmpty
|
|
}
|
|
|
|
var manualAuthOverride: ManualAuthOverride {
|
|
// Setup-link credentials are endpoint-scoped. An explicit empty override prevents
|
|
// a new host from falling back to credentials stored for the previous gateway.
|
|
ManualAuthOverride.explicit(
|
|
token: self.token,
|
|
bootstrapToken: self.bootstrapToken,
|
|
password: self.password,
|
|
targetStableID: self.targetStableID,
|
|
suppressStoredDeviceAuth: true)
|
|
}
|
|
}
|
|
|
|
let token: String?
|
|
let bootstrapToken: String?
|
|
let password: String?
|
|
let targetStableID: String?
|
|
let suppressStoredDeviceAuth: Bool
|
|
|
|
static func explicit(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
targetStableID: String? = nil,
|
|
suppressStoredDeviceAuth: Bool) -> ManualAuthOverride
|
|
{
|
|
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return ManualAuthOverride(
|
|
token: trimmedToken.isEmpty ? nil : trimmedToken,
|
|
bootstrapToken: trimmedBootstrapToken.isEmpty ? nil : trimmedBootstrapToken,
|
|
password: trimmedPassword.isEmpty ? nil : trimmedPassword,
|
|
targetStableID: targetStableID,
|
|
suppressStoredDeviceAuth: suppressStoredDeviceAuth)
|
|
}
|
|
|
|
static func normalized(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?) -> ManualAuthOverride?
|
|
{
|
|
let override = ManualAuthOverride.explicit(
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
suppressStoredDeviceAuth: false)
|
|
guard override.token != nil || override.bootstrapToken != nil || override.password != nil
|
|
else { return nil }
|
|
return override
|
|
}
|
|
|
|
static func persisted(instanceId: String, targetStableID: String) -> ManualAuthOverride? {
|
|
let authenticationOwnerID = GatewaySettingsStore.authenticationOwnerID(
|
|
routeStableID: targetStableID)
|
|
guard let metadata = GatewaySettingsStore.loadGatewayCredentialMetadata(
|
|
instanceId: instanceId,
|
|
gatewayStableID: authenticationOwnerID)
|
|
else { return nil }
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: instanceId,
|
|
gatewayStableID: targetStableID)
|
|
return ManualAuthOverride.explicit(
|
|
token: credentials.token,
|
|
bootstrapToken: credentials.bootstrapToken,
|
|
password: credentials.password,
|
|
targetStableID: targetStableID,
|
|
suppressStoredDeviceAuth: metadata.suppressStoredDeviceAuth)
|
|
}
|
|
|
|
static func currentManualInput(
|
|
token: String?,
|
|
pendingOverride: ManualAuthOverride?,
|
|
password: String?,
|
|
targetStableID: String? = nil) -> ManualAuthOverride?
|
|
{
|
|
guard let pendingOverride else {
|
|
return ManualAuthOverride.normalized(token: token, bootstrapToken: nil, password: password)
|
|
}
|
|
if let pendingTarget = pendingOverride.targetStableID, pendingTarget != targetStableID {
|
|
let normalizedInput = ManualAuthOverride.explicit(
|
|
token: token,
|
|
bootstrapToken: nil,
|
|
password: password,
|
|
targetStableID: targetStableID,
|
|
suppressStoredDeviceAuth: true)
|
|
// Setup-link fields retain their source provenance. When the endpoint changes,
|
|
// carry only values the user replaced instead of forwarding source credentials.
|
|
return ManualAuthOverride.explicit(
|
|
token: normalizedInput.token == pendingOverride.token ? nil : normalizedInput.token,
|
|
bootstrapToken: nil,
|
|
password: normalizedInput.password == pendingOverride.password ? nil : normalizedInput.password,
|
|
targetStableID: targetStableID,
|
|
suppressStoredDeviceAuth: true)
|
|
}
|
|
return ManualAuthOverride.explicit(
|
|
token: token,
|
|
bootstrapToken: pendingOverride.bootstrapToken,
|
|
password: password,
|
|
targetStableID: pendingOverride.targetStableID,
|
|
suppressStoredDeviceAuth: pendingOverride.suppressStoredDeviceAuth)
|
|
}
|
|
|
|
static func manualStableID(host: String, port: Int) -> String {
|
|
"manual|\(host.lowercased())|\(port)"
|
|
}
|
|
|
|
static func setupAuth(from link: GatewayConnectDeepLink) -> SetupAuth {
|
|
SetupAuth(
|
|
token: link.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
|
|
bootstrapToken: link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
|
|
password: link.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "",
|
|
targetStableID: self.manualStableID(host: link.host, port: link.port))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct GatewayPendingTrustConnect {
|
|
let url: URL
|
|
let stableID: String
|
|
let isManual: Bool
|
|
let authOverride: GatewayConnectionController.ManualAuthOverride?
|
|
let allowStoredDeviceAuth: Bool
|
|
let suppressionLease: GatewayConnectionController.AutoConnectSuppressionLease
|
|
let gatewayGeneration: UInt64?
|
|
}
|
|
|
|
struct GatewayManualTransportPresentation: Equatable {
|
|
let requiresTLS: Bool
|
|
let effectiveTLS: Bool
|
|
let helperText: String?
|
|
}
|
|
|
|
extension GatewayConnectionController {
|
|
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
|
let scheme = useTLS ? "wss" : "ws"
|
|
var components = URLComponents()
|
|
components.scheme = scheme
|
|
components.host = host
|
|
components.port = port
|
|
return components.url
|
|
}
|
|
|
|
private func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
|
|
Self.manualTransportPresentation(
|
|
host: host,
|
|
requestedTLS: useTLS).effectiveTLS
|
|
}
|
|
|
|
static func manualTransportPresentation(
|
|
host: String,
|
|
requestedTLS: Bool) -> GatewayManualTransportPresentation
|
|
{
|
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let requiresTLS = !trimmedHost.isEmpty && !LoopbackHost.isLocalNetworkHost(trimmedHost)
|
|
let effectiveTLS = requestedTLS || requiresTLS
|
|
let helperText: String? = if requiresTLS {
|
|
"Secure connection is required for this host."
|
|
} else if effectiveTLS {
|
|
nil
|
|
} else {
|
|
"Use only on a trusted private network."
|
|
}
|
|
return GatewayManualTransportPresentation(
|
|
requiresTLS: requiresTLS,
|
|
effectiveTLS: effectiveTLS,
|
|
helperText: helperText)
|
|
}
|
|
|
|
private func manualStableID(host: String, port: Int) -> String {
|
|
ManualAuthOverride.manualStableID(host: host, port: port)
|
|
}
|
|
|
|
private func makeConnectOptions(
|
|
stableID: String?,
|
|
deviceAuthGatewayID: String?,
|
|
allowStoredDeviceAuth: Bool = true) async -> GatewayConnectOptions
|
|
{
|
|
let defaults = UserDefaults.standard
|
|
let displayName = self.resolvedDisplayName(defaults: defaults)
|
|
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
|
|
let permissions = await self.currentPermissions()
|
|
|
|
return GatewayConnectOptions(
|
|
role: "node",
|
|
scopes: [],
|
|
caps: self.currentCaps(),
|
|
commands: self.currentCommands(),
|
|
permissions: permissions,
|
|
clientId: resolvedClientId,
|
|
clientMode: "node",
|
|
clientDisplayName: displayName,
|
|
allowStoredDeviceAuth: allowStoredDeviceAuth,
|
|
deviceAuthGatewayID: deviceAuthGatewayID)
|
|
}
|
|
|
|
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
|
if let stableID,
|
|
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID)
|
|
{
|
|
return override
|
|
}
|
|
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if manualClientId?.isEmpty == false {
|
|
return manualClientId!
|
|
}
|
|
return "openclaw-ios"
|
|
}
|
|
|
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
|
let key = "node.displayName"
|
|
let existingRaw = defaults.string(forKey: key)
|
|
let resolved = NodeDisplayName.resolve(
|
|
existing: existingRaw,
|
|
deviceName: UIDevice.current.name,
|
|
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
|
|
let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if existing.isEmpty || NodeDisplayName.isGeneric(existing) {
|
|
defaults.set(resolved, forKey: key)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
private func currentCaps() -> [String] {
|
|
var caps = [
|
|
OpenClawCapability.canvas.rawValue,
|
|
OpenClawCapability.screen.rawValue,
|
|
]
|
|
|
|
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
|
let cameraEnabled =
|
|
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
|
? true
|
|
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
|
if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) }
|
|
|
|
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
|
if voiceWakeEnabled { caps.append(OpenClawCapability.voiceWake.rawValue) }
|
|
|
|
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
|
let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off
|
|
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
|
|
|
caps.append(OpenClawCapability.device.rawValue)
|
|
caps.append(OpenClawCapability.talk.rawValue)
|
|
if WatchMessagingService.isSupportedOnDevice() {
|
|
caps.append(OpenClawCapability.watch.rawValue)
|
|
}
|
|
caps.append(OpenClawCapability.photos.rawValue)
|
|
caps.append(OpenClawCapability.contacts.rawValue)
|
|
caps.append(OpenClawCapability.calendar.rawValue)
|
|
caps.append(OpenClawCapability.reminders.rawValue)
|
|
if Self.motionAvailable() {
|
|
caps.append(OpenClawCapability.motion.rawValue)
|
|
}
|
|
|
|
return caps
|
|
}
|
|
|
|
private func currentCommands() -> [String] {
|
|
var commands: [String] = [
|
|
OpenClawCanvasCommand.present.rawValue,
|
|
OpenClawCanvasCommand.hide.rawValue,
|
|
OpenClawCanvasCommand.navigate.rawValue,
|
|
OpenClawCanvasCommand.evalJS.rawValue,
|
|
OpenClawCanvasCommand.snapshot.rawValue,
|
|
OpenClawCanvasA2UICommand.push.rawValue,
|
|
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
|
OpenClawCanvasA2UICommand.reset.rawValue,
|
|
OpenClawScreenCommand.record.rawValue,
|
|
OpenClawSystemCommand.notify.rawValue,
|
|
OpenClawChatCommand.push.rawValue,
|
|
OpenClawTalkCommand.pttStart.rawValue,
|
|
OpenClawTalkCommand.pttStop.rawValue,
|
|
OpenClawTalkCommand.pttCancel.rawValue,
|
|
OpenClawTalkCommand.pttOnce.rawValue,
|
|
]
|
|
|
|
let caps = Set(self.currentCaps())
|
|
if caps.contains(OpenClawCapability.camera.rawValue) {
|
|
commands.append(OpenClawCameraCommand.list.rawValue)
|
|
commands.append(OpenClawCameraCommand.snap.rawValue)
|
|
commands.append(OpenClawCameraCommand.clip.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.location.rawValue) {
|
|
commands.append(OpenClawLocationCommand.get.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.device.rawValue) {
|
|
commands.append(OpenClawDeviceCommand.status.rawValue)
|
|
commands.append(OpenClawDeviceCommand.info.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.watch.rawValue) {
|
|
commands.append(OpenClawWatchCommand.status.rawValue)
|
|
commands.append(OpenClawWatchCommand.notify.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.photos.rawValue) {
|
|
commands.append(OpenClawPhotosCommand.latest.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.contacts.rawValue) {
|
|
commands.append(OpenClawContactsCommand.search.rawValue)
|
|
commands.append(OpenClawContactsCommand.add.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.calendar.rawValue) {
|
|
commands.append(OpenClawCalendarCommand.events.rawValue)
|
|
commands.append(OpenClawCalendarCommand.add.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.reminders.rawValue) {
|
|
commands.append(OpenClawRemindersCommand.list.rawValue)
|
|
commands.append(OpenClawRemindersCommand.add.rawValue)
|
|
}
|
|
if caps.contains(OpenClawCapability.motion.rawValue) {
|
|
commands.append(OpenClawMotionCommand.activity.rawValue)
|
|
commands.append(OpenClawMotionCommand.pedometer.rawValue)
|
|
}
|
|
|
|
return commands
|
|
}
|
|
|
|
private func currentPermissions() async -> [String: Bool] {
|
|
var permissions: [String: Bool] = [:]
|
|
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
|
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
|
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
|
let locationStatus = CLLocationManager().authorizationStatus
|
|
let locationServicesEnabled = await Self.locationServicesEnabled()
|
|
permissions["location"] = Self.isLocationAvailable(
|
|
servicesEnabled: locationServicesEnabled,
|
|
status: locationStatus)
|
|
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
|
|
|
|
permissions["photos"] = PhotoLibraryAccess.canRead(PhotoLibraryAccess.authorizationStatus())
|
|
let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
|
|
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
|
|
|
|
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
|
permissions["calendar"] = Self.hasEventKitAccess(calendarStatus)
|
|
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
|
permissions["reminders"] = Self.hasEventKitAccess(remindersStatus)
|
|
|
|
let motionStatus = CMMotionActivityManager.authorizationStatus()
|
|
let pedometerStatus = CMPedometer.authorizationStatus()
|
|
permissions["motion"] =
|
|
motionStatus == .authorized || pedometerStatus == .authorized
|
|
|
|
let watchStatus = WatchMessagingService.currentStatusSnapshot()
|
|
permissions["watchSupported"] = watchStatus.supported
|
|
permissions["watchPaired"] = watchStatus.paired
|
|
permissions["watchAppInstalled"] = watchStatus.appInstalled
|
|
permissions["watchReachable"] = watchStatus.reachable
|
|
|
|
return permissions
|
|
}
|
|
|
|
private static func locationServicesEnabled() async -> Bool {
|
|
await Task.detached(priority: .utility) {
|
|
CLLocationManager.locationServicesEnabled()
|
|
}.value
|
|
}
|
|
|
|
private static func isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
|
guard servicesEnabled else { return false }
|
|
switch status {
|
|
case .authorizedAlways, .authorizedWhenInUse:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func hasEventKitAccess(_ status: EKAuthorizationStatus) -> Bool {
|
|
status == .fullAccess || status == .writeOnly
|
|
}
|
|
|
|
private static func motionAvailable() -> Bool {
|
|
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
extension GatewayConnectionController {
|
|
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
|
|
self.resolvedDisplayName(defaults: defaults)
|
|
}
|
|
|
|
func _test_currentCaps() -> [String] {
|
|
self.currentCaps()
|
|
}
|
|
|
|
func _test_currentCommands() -> [String] {
|
|
self.currentCommands()
|
|
}
|
|
|
|
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
|
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
|
|
}
|
|
|
|
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
|
self.gateways = gateways
|
|
}
|
|
|
|
func _test_triggerAutoConnect() {
|
|
self.maybeAutoConnect()
|
|
}
|
|
|
|
func _test_triggerAutoReconnect() {
|
|
self.attemptAutoReconnectIfNeeded()
|
|
}
|
|
|
|
func _test_didAutoConnect() -> Bool {
|
|
self.didAutoConnect
|
|
}
|
|
|
|
func _test_isAutoConnectSuppressed() -> Bool {
|
|
self.autoConnectSuppressionGeneration != nil
|
|
}
|
|
|
|
func _test_resolveDiscoveredTLSParams(
|
|
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
|
{
|
|
self.resolveDiscoveredTLSParams(gateway: gateway)
|
|
}
|
|
|
|
func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {
|
|
self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
|
}
|
|
|
|
func _test_resolveManualPort(host: String, port: Int, useTLS _: Bool) -> Int? {
|
|
Self.resolvedManualPort(host: host, port: port)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, URLSessionTaskDelegate,
|
|
@unchecked Sendable
|
|
{
|
|
private struct ProbeState {
|
|
var didFinish = false
|
|
var session: URLSession?
|
|
var task: URLSessionWebSocketTask?
|
|
}
|
|
|
|
private let url: URL
|
|
private let timeoutSeconds: Double
|
|
private let onComplete: (GatewayTLSFingerprintProbeResult) -> Void
|
|
private let state = OSAllocatedUnfairLock(initialState: ProbeState())
|
|
|
|
init(
|
|
url: URL,
|
|
timeoutSeconds: Double,
|
|
onComplete: @escaping (GatewayTLSFingerprintProbeResult) -> Void)
|
|
{
|
|
self.url = url
|
|
self.timeoutSeconds = timeoutSeconds
|
|
self.onComplete = onComplete
|
|
}
|
|
|
|
func start() {
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.timeoutIntervalForRequest = self.timeoutSeconds
|
|
config.timeoutIntervalForResource = self.timeoutSeconds
|
|
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
|
let task = session.webSocketTask(with: self.url)
|
|
self.state.withLock { s in
|
|
s.session = session
|
|
s.task = task
|
|
}
|
|
task.resume()
|
|
|
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
|
|
self?.finish(.failure(.tlsHandshakeTimeout))
|
|
}
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: URLSession,
|
|
didReceive challenge: URLAuthenticationChallenge,
|
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
|
|
{
|
|
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
|
let trust = challenge.protectionSpace.serverTrust
|
|
else {
|
|
completionHandler(.performDefaultHandling, nil)
|
|
return
|
|
}
|
|
|
|
let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust)
|
|
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
if let fp {
|
|
self.finish(.fingerprint(fp))
|
|
} else {
|
|
self.finish(.failure(.certificateUnavailable))
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
guard let error else {
|
|
self.finish(.failure(.tlsUnavailable))
|
|
return
|
|
}
|
|
self.finish(.failure(Self.failure(for: error)))
|
|
}
|
|
|
|
private func finish(_ result: GatewayTLSFingerprintProbeResult) {
|
|
typealias FinishState = (Bool, URLSessionWebSocketTask?, URLSession?)
|
|
let (shouldComplete, taskToCancel, sessionToInvalidate) = self.state.withLock { s -> FinishState in
|
|
guard !s.didFinish else { return (false, nil, nil) }
|
|
s.didFinish = true
|
|
let task = s.task
|
|
let session = s.session
|
|
s.task = nil
|
|
s.session = nil
|
|
return (true, task, session)
|
|
}
|
|
guard shouldComplete else { return }
|
|
taskToCancel?.cancel(with: .goingAway, reason: nil)
|
|
sessionToInvalidate?.invalidateAndCancel()
|
|
self.onComplete(result)
|
|
}
|
|
|
|
private static func failure(for error: Error) -> GatewayTLSFingerprintProbeFailure {
|
|
let nsError = error as NSError
|
|
guard nsError.domain == URLError.errorDomain else {
|
|
return .tlsUnavailable
|
|
}
|
|
|
|
switch URLError.Code(rawValue: nsError.code) {
|
|
case .timedOut:
|
|
return .tlsHandshakeTimeout
|
|
case .cannotFindHost,
|
|
.dnsLookupFailed,
|
|
.cannotConnectToHost,
|
|
.notConnectedToInternet,
|
|
.internationalRoamingOff,
|
|
.callIsActive,
|
|
.dataNotAllowed:
|
|
return .endpointUnreachable
|
|
case .networkConnectionLost,
|
|
.secureConnectionFailed,
|
|
.cannotParseResponse,
|
|
.badServerResponse:
|
|
return .tlsUnavailable
|
|
default:
|
|
return .tlsUnavailable
|
|
}
|
|
}
|
|
|
|
private static func certificateFingerprint(_ trust: SecTrust) -> String? {
|
|
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
|
let cert = chain.first
|
|
else {
|
|
return nil
|
|
}
|
|
let data = SecCertificateCopyData(cert) as Data
|
|
let digest = SHA256.hash(data: data)
|
|
return digest.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|