Files
openclaw/apps/ios/Sources/Gateway/GatewayConnectionController.swift
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a60619722)
(cherry picked from commit 6f0c386567)
(cherry picked from commit 784a5857b7)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

1362 lines
58 KiB
Swift

import Foundation
import Network
import Observation
import OpenClawChatUI
import OpenClawKit
import SwiftUI
typealias GatewayTCPReachabilityProbe = @Sendable (String, Int, Double, String) async -> Bool
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 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)
}
@MainActor
@Observable
final class GatewayConnectionController {
enum DiscoveredGatewayConnectionAvailability: Equatable {
case available
case secureTransportRequired
var canConnect: Bool {
self == .available
}
var actionTitle: String {
switch self {
case .available:
String(localized: "Connect")
case .secureTransportRequired:
String(localized: "TLS required")
}
}
var guidanceText: String? {
switch self {
case .available:
nil
case .secureTransportRequired:
String(localized: """
Enable Gateway TLS, or enter your Tailscale Serve HTTPS host in Manual Setup. \
Use Unencrypted only with a trusted private-LAN address.
""")
}
}
}
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: [
GatewayStableIdentifier.Key: (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)
}
func discoveredGatewayConnectionAvailability(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> DiscoveredGatewayConnectionAvailability
{
if gateway.tlsEnabled || GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil {
return .available
}
return .secureTransportRequired
}
func preferredDiscoveredGateway() -> GatewayDiscoveryModel.DiscoveredGateway? {
self.gateways.first(where: {
self.discoveredGatewayConnectionAvailability($0).canConnect
}) ?? self.gateways.first
}
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
forceReconnect: Bool = false) async -> String?
{
let availability = self.discoveredGatewayConnectionAvailability(gateway)
guard availability.canConnect else { return availability.guidanceText }
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)
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: {
GatewayStableIdentifier.matches($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? {
guard let stableID = GatewayStableIdentifier.exact(stableID) else {
return "This paired gateway is no longer available."
}
guard let entry = GatewaySettingsStore.loadGatewayRegistry().entries.first(where: {
GatewayStableIdentifier.matches($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: {
GatewayStableIdentifier.matches($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 {
guard let stableID = GatewayStableIdentifier.exact(stableID),
let stableIDKey = GatewayStableIdentifier.key(stableID)
else { return false }
if self.pendingForgetCleanups[stableIDKey] != nil {
return true
}
guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else {
return false
}
if GatewayStableIdentifier.matches(self.pendingConnectionStableID, stableID) {
let cancellationLease = self.cancelPendingConnectionAttempts()
self.releaseAutoConnectSuppression(after: cancellationLease)
}
let wasConnected = GatewayStableIdentifier.matches(
self.appModel?.activeGatewayConnectConfig?.effectiveStableID,
stableID) || GatewayStableIdentifier.matches(self.appModel?.connectedGatewayID, stableID)
let shouldDisconnect = wasConnected
if shouldDisconnect {
let hasDifferentPendingTarget = self.pendingConnectionStableID.map {
!GatewayStableIdentifier.matches($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
if GatewayStableIdentifier.matches(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[stableIDKey] = (cleanupID, cleanupTask)
Task { @MainActor [weak self] in
await cleanupTask.value
guard self?.pendingForgetCleanups[stableIDKey]?.id == cleanupID else { return }
self?.pendingForgetCleanups[stableIDKey] = nil
}
return true
}
private func waitForPendingForgetCleanup(stableID: String) async {
guard let stableIDKey = GatewayStableIdentifier.key(stableID),
let pending = self.pendingForgetCleanups[stableIDKey]
else { return }
await pending.task.value
if self.pendingForgetCleanups[stableIDKey]?.id == pending.id {
self.pendingForgetCleanups[stableIDKey] = 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,
GatewayStableIdentifier.matches(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,
GatewayStableIdentifier.matches(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
}
}
extension GatewayConnectionController {
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: {
GatewayStableIdentifier.matches($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 = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.preferredStableID"))
let lastDiscoveredStableID = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.lastDiscoveredStableID"))
let candidates = [preferredStableID, lastDiscoveredStableID].compactMap(\.self)
if let targetStableID = candidates.first(where: { id in
self.gateways.contains(where: { GatewayStableIdentifier.matches($0.stableID, id) })
}) {
guard let target = self.gateways.first(where: {
GatewayStableIdentifier.matches($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 GatewayStableIdentifier.sortsBefore(rhs.stableID, lhs.stableID)
}
}
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
let defaults = UserDefaults.standard
let preferred = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.preferredStableID"))
let existingLast = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.lastDiscoveredStableID"))
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
guard preferred == nil, existingLast == nil 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,
let gatewayStableID = GatewayStableIdentifier.exact(gatewayStableID)
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 GatewayStableIdentifier.matches(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:
if host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")).hasSuffix(".ts.net") {
String(localized: """
Can't reach gateway at \(host):\(port). \
Verify Tailscale Serve is enabled and publishes this Gateway.
""")
} else {
String(localized: "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()
}
}
}
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?
}
#if DEBUG
extension GatewayConnectionController {
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_resolveManualPort(host: String, port: Int, useTLS _: Bool) -> Int? {
Self.resolvedManualPort(host: host, port: port)
}
}
#endif