From db63bb51bbe5c0b9e14880bd0f3008248b4cef65 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 20:34:01 +0700 Subject: [PATCH 1/4] feat(connections): name the Local Network permission when it blocks a connection --- .../Core/Database/ConnectionTunnelError.swift | 4 + .../DatabaseManager+LocalNetwork.swift | 29 +++ .../Database/DatabaseManager+Queries.swift | 34 ++-- .../Database/DatabaseManager+Sessions.swift | 10 +- .../Core/Database/DatabaseManager.swift | 5 +- .../Network/LocalNetworkAccessDiagnosis.swift | 145 ++++++++++++++ .../Network/LocalNetworkAccessProbe.swift | 112 +++++++++++ .../Core/SOCKS/SOCKSProxyManager.swift | 11 ++ .../Core/SSH/LibSSH2TunnelFactory+TCP.swift | 8 +- .../Core/SSH/LibSSH2TunnelFactory.swift | 15 +- .../TablePro/Core/SSH/SSHTunnelManager.swift | 12 ++ .../ConnectionRecoveryPerformer.swift | 10 +- .../ConnectionFailureClassifier.swift | 11 ++ .../Connection/ConnectionRecoveryAction.swift | 14 +- .../Connection/ConnectionWindowPhase.swift | 19 +- .../DatabaseConnection+Tunnel.swift | 17 +- .../TablePro/Resources/Localizable.xcstrings | 18 ++ .../ConnectionUnavailableView.swift | 26 ++- .../LocalNetworkConnectFailureTests.swift | 130 +++++++++++++ .../LocalNetworkAccessDiagnosisTests.swift | 183 ++++++++++++++++++ .../LocalNetworkAccessProbeTests.swift | 62 ++++++ .../ConnectionFailureClassifierTests.swift | 24 +++ .../ConnectionRecoveryPresentationTests.swift | 18 +- 23 files changed, 871 insertions(+), 46 deletions(-) create mode 100644 apple/TablePro/Core/Database/DatabaseManager+LocalNetwork.swift create mode 100644 apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift create mode 100644 apple/TablePro/Core/Network/LocalNetworkAccessProbe.swift create mode 100644 apple/TableProTests/Core/Database/LocalNetworkConnectFailureTests.swift create mode 100644 apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift create mode 100644 apple/TableProTests/Core/Network/LocalNetworkAccessProbeTests.swift diff --git a/apple/TablePro/Core/Database/ConnectionTunnelError.swift b/apple/TablePro/Core/Database/ConnectionTunnelError.swift index e1f4fa26f3..8609228b59 100644 --- a/apple/TablePro/Core/Database/ConnectionTunnelError.swift +++ b/apple/TablePro/Core/Database/ConnectionTunnelError.swift @@ -28,3 +28,7 @@ enum ConnectionTunnelError: Error, LocalizedError, Equatable { } } } + +extension ConnectionTunnelError: ConnectionPreflightReporting { + var failedBeforeDialing: Bool { true } +} diff --git a/apple/TablePro/Core/Database/DatabaseManager+LocalNetwork.swift b/apple/TablePro/Core/Database/DatabaseManager+LocalNetwork.swift new file mode 100644 index 0000000000..b63dfc94f6 --- /dev/null +++ b/apple/TablePro/Core/Database/DatabaseManager+LocalNetwork.swift @@ -0,0 +1,29 @@ +// +// DatabaseManager+LocalNetwork.swift +// TablePro +// + +import Foundation +import os +import TableProSSHConfig + +extension DatabaseManager { + /// macOS 15 refuses a socket to a local-network host when TablePro lacks the Local Network + /// permission, and the driver reports it as an unreachable host. Probing the hop it dialed tells + /// the two apart. + internal func diagnosedConnectFailure(_ error: Error, for connection: DatabaseConnection) async -> Error { + guard !Task.isCancelled, LocalNetworkAccessDiagnosis.canExplain(error, fields: connection.additionalFields) else { + return error + } + let dialed = SSHProfileStorage.shared.refreshingLinkedProfile(connection) + let hops = LocalNetworkAccessDiagnosis.hops( + for: dialed, + sshDocument: await SSHConfigCache.shared.current() + ) + guard let hop = await LocalNetworkAccessDiagnosis.blockedHop(among: hops, probe: localNetworkProbe) else { + return error + } + Self.logger.notice("Local Network access is off, a connect to \(hop.host, privacy: .private) was refused") + return LocalNetworkAccessError(hop: hop, underlying: error) + } +} diff --git a/apple/TablePro/Core/Database/DatabaseManager+Queries.swift b/apple/TablePro/Core/Database/DatabaseManager+Queries.swift index 5e185703bd..16d495e9d6 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Queries.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Queries.swift @@ -47,23 +47,31 @@ extension DatabaseManager { // A remote file answers the only question this button asks without moving the file. if connection.activeTunnelKind == .remoteFile { - let reached = try await testRemoteDatabaseFile( - connection, - sshPassword: sshPassword, - deadline: ConnectionDeadline(configuredSeconds: connection.connectTimeoutSeconds) - ) - return ConnectionTestResult(succeeded: reached) + do { + let reached = try await testRemoteDatabaseFile( + connection, + sshPassword: sshPassword, + deadline: ConnectionDeadline(configuredSeconds: connection.connectTimeoutSeconds) + ) + return ConnectionTestResult(succeeded: reached) + } catch { + throw await diagnosedConnectFailure(error, for: connection) + } } let preparedConfiguration = try await DatabaseDriverFactory.prepareConfiguration(for: connection) let deadline = ConnectionDeadline(configuredSeconds: connection.connectTimeoutSeconds) - // Build effective connection (creates SSH tunnel if needed) - let testConnection = try await buildEffectiveConnection( - for: connection, - sshPasswordOverride: sshPassword, - deadline: deadline - ) + let testConnection: DatabaseConnection + do { + testConnection = try await buildEffectiveConnection( + for: connection, + sshPasswordOverride: sshPassword, + deadline: deadline + ) + } catch { + throw await diagnosedConnectFailure(error, for: connection) + } let tunnelManager = activeTunnelManager(for: connection) @@ -91,7 +99,7 @@ extension DatabaseManager { Self.logger.warning("Tunnel cleanup failed for \(connection.name): \(error.localizedDescription)") } } - throw reportedError + throw await diagnosedConnectFailure(reportedError, for: connection) } if let tunnelManager { diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index b23ecd556d..89f575716b 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -86,13 +86,16 @@ extension DatabaseManager { deadline: deadline ) } catch { + let reportedError = isAttemptCancelled(attempt, for: connection.id) + ? error + : await diagnosedConnectFailure(error, for: resolvedConnection) finalizeConnectionFailure( for: connection.id, cancelled: isAttemptCancelled(attempt, for: connection.id), - error: error, + error: reportedError, attempt: attempt ) - throw error + throw reportedError } let driver: DatabaseDriver @@ -194,11 +197,12 @@ extension DatabaseManager { reportedError = attributed } closeActiveTunnel(for: connection) + reportedError = await diagnosedConnectFailure(reportedError, for: resolvedConnection) } finalizeConnectionFailure( for: connection.id, - cancelled: cancelled, + cancelled: cancelled || isAttemptCancelled(attempt, for: connection.id), error: reportedError, attempt: attempt ) diff --git a/apple/TablePro/Core/Database/DatabaseManager.swift b/apple/TablePro/Core/Database/DatabaseManager.swift index 2f4c403c06..c13fa79aa1 100644 --- a/apple/TablePro/Core/Database/DatabaseManager.swift +++ b/apple/TablePro/Core/Database/DatabaseManager.swift @@ -21,6 +21,7 @@ final class DatabaseManager: ObservableObject { internal let pluginManager: PluginManager internal let aiAccessApprovals: AIAccessApprovals internal let preConnectScriptConsent: PreConnectScriptConsent + internal let localNetworkProbe: any LocalNetworkAccessProbing internal var historyRecorder: QueryHistoryRecording = QueryHistoryManager.shared /// Passwords the user has been asked for this launch, keyed by whatever answers for them: the @@ -179,13 +180,15 @@ final class DatabaseManager: ObservableObject { appSettingsStorage: AppSettingsStorage = .shared, pluginManager: PluginManager = .shared, aiAccessApprovals: AIAccessApprovals = .shared, - preConnectScriptConsent: PreConnectScriptConsent = PreConnectScriptConsent() + preConnectScriptConsent: PreConnectScriptConsent = PreConnectScriptConsent(), + localNetworkProbe: any LocalNetworkAccessProbing = NetworkFrameworkLocalNetworkProbe() ) { self.connectionStorage = connectionStorage self.appSettingsStorage = appSettingsStorage self.pluginManager = pluginManager self.aiAccessApprovals = aiAccessApprovals self.preConnectScriptConsent = preConnectScriptConsent + self.localNetworkProbe = localNetworkProbe observeConnectionUpdates() observeHealthCheckSetting() } diff --git a/apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift b/apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift new file mode 100644 index 0000000000..ca9be164fc --- /dev/null +++ b/apple/TablePro/Core/Network/LocalNetworkAccessDiagnosis.swift @@ -0,0 +1,145 @@ +// +// LocalNetworkAccessDiagnosis.swift +// TablePro +// + +import Foundation +import Network +import TableProPluginKit +import TableProSSHConfig + +/// An error that knows whether it was raised before any socket opened. One that was cannot be a +/// network refusal, whatever a probe of the host says. +internal protocol ConnectionPreflightReporting { + var failedBeforeDialing: Bool { get } +} + +internal struct LocalNetworkHop: Equatable, Sendable { + internal enum Role: Equatable, Sendable { + case database + case sshServer + case sshJumpHost + case socksProxy + } + + internal let role: Role + internal let host: String +} + +/// A connect the Local Network permission refused. The driver only saw an unreachable host, so its +/// error is kept for Copy Details while this one names the cause. +internal struct LocalNetworkAccessError: Error, LocalizedError { + internal let hop: LocalNetworkHop + internal let underlying: any Error + + internal var errorDescription: String? { + let format: String + switch hop.role { + case .database: + format = String(localized: "TablePro does not have Local Network access, so it cannot reach the database at %@.") + case .sshServer: + format = String(localized: "TablePro does not have Local Network access, so it cannot reach the SSH server at %@.") + case .sshJumpHost: + format = String(localized: "TablePro does not have Local Network access, so it cannot reach the SSH jump host at %@.") + case .socksProxy: + format = String(localized: "TablePro does not have Local Network access, so it cannot reach the SOCKS proxy at %@.") + } + return String(format: format, hop.host) + } + + internal var recoverySuggestion: String? { + String(localized: "Turn on TablePro in System Settings > Privacy & Security > Local Network, then try again.") + } +} + +internal enum LocalNetworkAccessDiagnosis { + /// A failure that already names its fix, or that the user chose, gains nothing from a probe. + /// `fields` is the connection's `additionalFields`, which decide whether a sign-in claims the error. + internal static func canExplain(_ error: Error, fields: [String: String]) -> Bool { + if error is LocalNetworkAccessError || error is SSLHandshakeError { return false } + if let preflight = error as? ConnectionPreflightReporting, preflight.failedBeforeDialing { return false } + if ConnectionFailureClassifier.isUserCancelled(error) || DatabaseCancellationDiagnosis.isCancellation(error) { + return false + } + if ConnectionSignInRegistry.provider(for: error, fields: fields) != nil { return false } + return ConnectionFailureClassifier.recoveryAction(for: error) == nil + } + + /// The hosts this Mac opens a socket to. Behind a tunnel or a proxy that is the first hop only, + /// since the server behind it is dialed from the other end. + internal static func hops(for connection: DatabaseConnection, sshDocument: SSHConfigDocument) -> [LocalNetworkHop] { + let kinds = connection.enabledTunnelKinds + guard kinds.count <= 1 else { return [] } + + let candidates: [LocalNetworkHop] + switch kinds.first { + case nil: + candidates = directHosts(of: connection).map { LocalNetworkHop(role: .database, host: $0) } + case .ssh, .remoteFile, .remoteDatabaseSession: + guard let first = LibSSH2TunnelFactory.firstHop(of: connection.resolvedSSHConfig, document: sshDocument) else { + return [] + } + candidates = [LocalNetworkHop(role: first.isJumpHost ? .sshJumpHost : .sshServer, host: first.host)] + case .socksProxy: + candidates = connection.resolvedSOCKSProxyConfig.map { [LocalNetworkHop(role: .socksProxy, host: $0.host)] } ?? [] + case .cloudflare, .cloudSQLProxy, .tunnelCommand: + // A helper process dials a cloud endpoint, or whatever the user's command names. + candidates = [] + } + + var seen: Set = [] + return candidates.compactMap { hop in + let host = normalized(hop.host) + guard !host.isEmpty, !host.hasPrefix("/"), seen.insert(host.lowercased()).inserted else { return nil } + return LocalNetworkHop(role: hop.role, host: host) + } + } + + /// The hop to name, only when the permission refused every host the connect could have used. + /// A host list with one reachable server may have failed on that server for its own reason. + internal static func blockedHop( + among hops: [LocalNetworkHop], + probe: any LocalNetworkAccessProbing + ) async -> LocalNetworkHop? { + guard let first = hops.first, !hops.contains(where: { isLoopback($0.host) }) else { return nil } + let everyHopRefused = await withTaskGroup(of: LocalNetworkAccess.self) { group in + for hop in hops { + group.addTask { await probe.access(to: hop.host) } + } + for await access in group where access != .denied { + group.cancelAll() + return false + } + return true + } + return everyHopRefused ? first : nil + } + + /// Loopback is the one destination the system never treats as local network. + internal static func isLoopback(_ host: String) -> Bool { + let lowered = host.lowercased() + if lowered == "localhost" || lowered.hasSuffix(".localhost") { return true } + if let address = IPv4Address(lowered) { + return Array(address.rawValue).first == 127 + } + guard let address = IPv6Address(lowered) else { return false } + let bytes = Array(address.rawValue) + guard bytes.count == 16 else { return false } + if bytes == Array(repeating: 0, count: 15) + [1] { return true } + guard bytes[0..<10].allSatisfy({ $0 == 0 }), bytes[10] == 0xff, bytes[11] == 0xff else { return false } + return bytes[12] == 127 + } + + private static func directHosts(of connection: DatabaseConnection) -> [String] { + let mode = PluginMetadataRegistry.shared.snapshot(for: connection.type)?.connectionMode ?? .network + guard mode == .network, connection.localSocketPath == nil else { return [] } + let listed = connection.hostListEndpoints.map(\.host) + return listed.isEmpty ? [connection.host] : listed + } + + private static func normalized(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("["), trimmed.hasSuffix("]") else { return trimmed } + return String(trimmed.dropFirst().dropLast()) + } +} diff --git a/apple/TablePro/Core/Network/LocalNetworkAccessProbe.swift b/apple/TablePro/Core/Network/LocalNetworkAccessProbe.swift new file mode 100644 index 0000000000..e59f58cb26 --- /dev/null +++ b/apple/TablePro/Core/Network/LocalNetworkAccessProbe.swift @@ -0,0 +1,112 @@ +// +// LocalNetworkAccessProbe.swift +// TablePro +// + +import dnssd +import Foundation +import Network + +internal enum LocalNetworkAccess: Equatable, Sendable { + case denied + case granted + case undetermined +} + +internal protocol LocalNetworkAccessProbing: Sendable { + func access(to host: String) async -> LocalNetworkAccess +} + +/// What a probe connection reported, reduced to whether the Local Network permission refused it. +internal enum LocalNetworkProbeSignal: Sendable { + case path(status: NWPath.Status, reason: NWPath.UnsatisfiedReason) + case ready + case waiting(NWError, status: NWPath.Status?, reason: NWPath.UnsatisfiedReason?) + case ended + case deadline + + /// Nil while the probe should keep listening. + internal var verdict: LocalNetworkAccess? { + switch self { + case .path(let status, let reason): + return Self.isDenied(status: status, reason: reason) ? .denied : nil + case .ready: + return .granted + case .waiting(let error, let status, let reason): + if Self.isPolicyDenied(error) { return .denied } + if let status, let reason, Self.isDenied(status: status, reason: reason) { return .denied } + return .undetermined + case .ended, .deadline: + return .undetermined + } + } + + // A satisfied path still reports `.notAvailable` as its reason, so the reason only counts on an unsatisfied one. + private static func isDenied(status: NWPath.Status, reason: NWPath.UnsatisfiedReason) -> Bool { + status == .unsatisfied && reason == .localNetworkDenied + } + + // A `.local` name is refused during mDNS resolution, before there is a path to read. + private static func isPolicyDenied(_ error: NWError) -> Bool { + guard case .dns(let code) = error else { return false } + return Int(code) == Int(kDNSServiceErr_PolicyDenied) + } +} + +/// No API reports the Local Network permission. The system answers on a connection, so this opens a +/// UDP connection to the host and reads the answer off its path. Connecting a UDP socket sends nothing. +internal struct NetworkFrameworkLocalNetworkProbe: LocalNetworkAccessProbing { + private static let discardPort: NWEndpoint.Port = 9 + private static let queue = DispatchQueue(label: "com.TablePro.LocalNetworkAccessProbe", qos: .userInitiated) + + private let deadline: Duration + + internal init(deadline: Duration = .seconds(2)) { + self.deadline = deadline + } + + internal func access(to host: String) async -> LocalNetworkAccess { + // The permission arrived in macOS 15, so nothing earlier refuses a local address. + guard #available(macOS 15, *) else { return .granted } + + let connection = NWConnection(host: NWEndpoint.Host(host), port: Self.discardPort, using: .udp) + let (signals, continuation) = AsyncStream.makeStream() + + connection.pathUpdateHandler = { path in + continuation.yield(.path(status: path.status, reason: path.unsatisfiedReason)) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + continuation.yield(.ready) + case .waiting(let error): + let path = connection.currentPath + continuation.yield(.waiting(error, status: path?.status, reason: path?.unsatisfiedReason)) + case .failed, .cancelled: + continuation.yield(.ended) + default: + break + } + } + + let deadline = deadline + let timer = Task { + try? await Task.sleep(for: deadline) + continuation.yield(.deadline) + } + connection.start(queue: Self.queue) + + defer { + timer.cancel() + connection.stateUpdateHandler = nil + connection.pathUpdateHandler = nil + connection.cancel() + continuation.finish() + } + + for await signal in signals { + if let verdict = signal.verdict { return verdict } + } + return .undetermined + } +} diff --git a/apple/TablePro/Core/SOCKS/SOCKSProxyManager.swift b/apple/TablePro/Core/SOCKS/SOCKSProxyManager.swift index 1a108342ae..4c2ed607ef 100644 --- a/apple/TablePro/Core/SOCKS/SOCKSProxyManager.swift +++ b/apple/TablePro/Core/SOCKS/SOCKSProxyManager.swift @@ -36,6 +36,17 @@ enum SOCKSProxyError: Error, LocalizedError, Equatable { } } +extension SOCKSProxyError: ConnectionPreflightReporting { + var failedBeforeDialing: Bool { + switch self { + case .invalidConfiguration, .unsupportedOnThisSystem, .listenerFailed: + return true + case .connectTimedOut, .connectFailed: + return false + } + } +} + actor SOCKSProxyManager: TunnelManaging { static let shared = SOCKSProxyManager() private static let logger = Logger(subsystem: "com.TablePro", category: "SOCKSProxyManager") diff --git a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory+TCP.swift b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory+TCP.swift index da04dae1de..bce36ec2c7 100644 --- a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory+TCP.swift +++ b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory+TCP.swift @@ -46,10 +46,11 @@ extension LibSSH2TunnelFactory { } } - if connectResult != 0, errno != EINPROGRESS { + let connectErrno = errno + if connectResult != 0, connectErrno != EINPROGRESS { attempt.unregisterTransportInterrupt(interruptId) Darwin.close(descriptor) - lastError = "Connection to \(host):\(port) failed" + lastError = "Connection to \(host):\(port) failed: \(String(cString: strerror(connectErrno)))" continue } @@ -60,6 +61,7 @@ extension LibSSH2TunnelFactory { 1, Int32(clamping: deadline.remainingMilliseconds) ) + let pollErrno = errno if pollResult <= 0 { attempt.unregisterTransportInterrupt(interruptId) @@ -68,7 +70,7 @@ extension LibSSH2TunnelFactory { if pollResult == 0 { throw deadline.timeoutError(for: endpoint) } - lastError = "Connection to \(host):\(port) failed: \(String(cString: strerror(errno)))" + lastError = "Connection to \(host):\(port) failed: \(String(cString: strerror(pollErrno)))" continue } diff --git a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index 1145f46caa..0318564db5 100644 --- a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -216,7 +216,7 @@ internal enum LibSSH2TunnelFactory { let resolvedPrimary = resolved.primary let resolvedJumps = resolved.jumps - let firstHop = resolvedJumps.first ?? resolvedPrimary + let firstHop = resolved.firstHop let firstEndpoint = timeoutEndpoint(host: firstHop.host, port: firstHop.port) let socketFD = try await connectTCP( host: firstHop.host, @@ -393,6 +393,19 @@ internal enum LibSSH2TunnelFactory { let primary: ResolvedSSHTarget let jumps: [ResolvedSSHTarget] let formJumps: [SSHJumpHost] + + var firstHop: ResolvedSSHTarget { + jumps.first ?? primary + } + } + + /// The host this Mac opens its TCP connection to; every later hop is dialed from the one before it. + internal static func firstHop( + of config: SSHConfiguration, + document: SSHConfigDocument + ) -> (host: String, isJumpHost: Bool)? { + guard let resolved = try? resolveChainConfiguration(config: config, document: document) else { return nil } + return (resolved.firstHop.host, !resolved.jumps.isEmpty) } private static func resolveChainConfiguration( diff --git a/apple/TablePro/Core/SSH/SSHTunnelManager.swift b/apple/TablePro/Core/SSH/SSHTunnelManager.swift index 56bb5fdd10..f9acafe503 100644 --- a/apple/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/apple/TablePro/Core/SSH/SSHTunnelManager.swift @@ -217,6 +217,18 @@ extension SSHTunnelError { } } +extension SSHTunnelError: ConnectionPreflightReporting { + var failedBeforeDialing: Bool { + switch self { + case .configExpansionFailed, .tunnelAlreadyExists, .noAvailablePort, .usernameMissing: + return true + case .tunnelCreationFailed, .authenticationFailed, .hostKeyVerificationFailed, .channelOpenFailed, + .socketForwardingRefused, .forwardRefused, .forwardTimedOut: + return false + } + } +} + /// Manages SSH tunnels for database connections using libssh2 actor SSHTunnelManager: TunnelManaging { static let shared = SSHTunnelManager() diff --git a/apple/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift b/apple/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift index dfbfaae2a5..11be701b3c 100644 --- a/apple/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift +++ b/apple/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift @@ -3,7 +3,7 @@ // TablePro // -import Foundation +import AppKit internal struct PendingConnectionRecovery { let action: ConnectionRecoveryAction @@ -12,6 +12,11 @@ internal struct PendingConnectionRecovery { @MainActor internal enum ConnectionRecoveryPerformer { + // macOS 27 ignores the Local Network anchor and opens Privacy & Security, one row above it. + private static let localNetworkSettingsURL = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork" + ) + internal static func canEdit(_ connection: DatabaseConnection) -> Bool { ConnectionStorage.shared.loadConnections().contains { $0.id == connection.id } } @@ -28,6 +33,9 @@ internal enum ConnectionRecoveryPerformer { WindowOpener.shared.openSettings(tab: .plugins) case .editConnection: WindowOpener.shared.openConnectionForm(editing: connection.id) + case .openLocalNetworkSettings: + guard let url = localNetworkSettingsURL else { return } + NSWorkspace.shared.open(url) } } } diff --git a/apple/TablePro/Models/Connection/ConnectionFailureClassifier.swift b/apple/TablePro/Models/Connection/ConnectionFailureClassifier.swift index 2240df2398..42e94b68fa 100644 --- a/apple/TablePro/Models/Connection/ConnectionFailureClassifier.swift +++ b/apple/TablePro/Models/Connection/ConnectionFailureClassifier.swift @@ -49,6 +49,9 @@ internal enum ConnectionFailureClassifier { if error is LoadableExtensionError { return canEditConnection ? .editConnection : nil } + if error is LocalNetworkAccessError { + return .openLocalNetworkSettings + } guard let pluginError = error as? PluginError else { return nil } switch pluginError { case .pluginNotInstalled: @@ -76,6 +79,14 @@ internal enum ConnectionFailureClassifier { if error is SSLHandshakeError { return ConnectionFailureInfo(message: SSLHandshakeError.formatted(error)) } + if let refusal = error as? LocalNetworkAccessError { + return ConnectionFailureInfo( + title: String(localized: "Local Network Access Required"), + message: refusal.errorDescription ?? "", + recoverySuggestion: refusal.recoverySuggestion, + details: info(for: refusal.underlying).lines.joined(separator: "\n") + ) + } let nsError = error as NSError return ConnectionFailureInfo( message: error.localizedDescription, diff --git a/apple/TablePro/Models/Connection/ConnectionRecoveryAction.swift b/apple/TablePro/Models/Connection/ConnectionRecoveryAction.swift index d5da260dc2..35ad95ce2f 100644 --- a/apple/TablePro/Models/Connection/ConnectionRecoveryAction.swift +++ b/apple/TablePro/Models/Connection/ConnectionRecoveryAction.swift @@ -9,6 +9,7 @@ internal enum ConnectionRecoveryAction: Equatable, Sendable { case installPlugin case openPluginSettings(pluginId: String?) case editConnection + case openLocalNetworkSettings internal var title: String { switch self { @@ -18,6 +19,8 @@ internal enum ConnectionRecoveryAction: Equatable, Sendable { return String(localized: "Open Plugin Settings") case .editConnection: return String(localized: "Edit Connection…") + case .openLocalNetworkSettings: + return String(localized: "Open System Settings") } } @@ -27,11 +30,18 @@ internal enum ConnectionRecoveryAction: Equatable, Sendable { return "puzzlepiece.extension" case .editConnection: return "exclamationmark.triangle" + case .openLocalNetworkSettings: + return "network.badge.shield.half.filled" } } + // A fix made in Settings leaves the connect itself to the user. internal var offersRetry: Bool { - if case .openPluginSettings = self { return true } - return false + switch self { + case .openPluginSettings, .openLocalNetworkSettings: + return true + case .installPlugin, .editConnection: + return false + } } } diff --git a/apple/TablePro/Models/Connection/ConnectionWindowPhase.swift b/apple/TablePro/Models/Connection/ConnectionWindowPhase.swift index 82b52face8..49097437b2 100644 --- a/apple/TablePro/Models/Connection/ConnectionWindowPhase.swift +++ b/apple/TablePro/Models/Connection/ConnectionWindowPhase.swift @@ -6,14 +6,31 @@ import Foundation internal struct ConnectionFailureInfo: Equatable, Sendable { + internal let title: String? internal let message: String internal let failureReason: String? internal let recoverySuggestion: String? + /// Kept for Copy Details only, such as the driver's own words under a cause TablePro named. + internal let details: String? - internal init(message: String, failureReason: String? = nil, recoverySuggestion: String? = nil) { + internal init( + title: String? = nil, + message: String, + failureReason: String? = nil, + recoverySuggestion: String? = nil, + details: String? = nil + ) { + self.title = title self.message = message self.failureReason = failureReason self.recoverySuggestion = recoverySuggestion + self.details = details + } + + internal var lines: [String] { + [message, failureReason, recoverySuggestion] + .compactMap { $0 } + .filter { !$0.isEmpty } } } diff --git a/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift b/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift index f21c13a26e..6ebd19c12c 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection+Tunnel.swift @@ -81,14 +81,19 @@ extension DatabaseConnection { firstHostListEntry.map { HostListEndpoint.usesHTTPS($0.text) } ?? false } + var hostListEndpoints: [HostListEndpoint] { + hostListEntries.map(\.endpoint) + } + private var firstHostListEntry: (text: String, endpoint: HostListEndpoint)? { - for fieldId in activeHostListFieldIds { - for text in (additionalFields[fieldId] ?? "").split(separator: ",").map(String.init) { - if let endpoint = HostListEndpoint.parse(text, defaultPort: port) { - return (text, endpoint) - } + hostListEntries.first + } + + private var hostListEntries: [(text: String, endpoint: HostListEndpoint)] { + activeHostListFieldIds.flatMap { fieldId in + (additionalFields[fieldId] ?? "").split(separator: ",").map(String.init).compactMap { text in + HostListEndpoint.parse(text, defaultPort: port).map { (text, $0) } } } - return nil } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index cbf357f20c..b34b1dc845 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -103354,6 +103354,9 @@ } } } + }, + "Local Network Access Required" : { + }, "Local Secondary Indexes" : { "extractionState" : "manual", @@ -176593,6 +176596,18 @@ } } } + }, + "TablePro does not have Local Network access, so it cannot reach the SOCKS proxy at %@." : { + + }, + "TablePro does not have Local Network access, so it cannot reach the SSH jump host at %@." : { + + }, + "TablePro does not have Local Network access, so it cannot reach the SSH server at %@." : { + + }, + "TablePro does not have Local Network access, so it cannot reach the database at %@." : { + }, "TablePro does not run a pre-connect script on its own." : { @@ -200093,6 +200108,9 @@ } } } + }, + "Turn on TablePro in System Settings > Privacy & Security > Local Network, then try again." : { + }, "Turn one table's structure into a runnable checklist of data quality queries" : { "localizations" : { diff --git a/apple/TablePro/Views/Connection/ConnectionUnavailableView.swift b/apple/TablePro/Views/Connection/ConnectionUnavailableView.swift index f5ef1cbc3d..7a8a712b6e 100644 --- a/apple/TablePro/Views/Connection/ConnectionUnavailableView.swift +++ b/apple/TablePro/Views/Connection/ConnectionUnavailableView.swift @@ -84,14 +84,7 @@ internal struct ConnectionUnavailableView: View { } private var headline: String { - switch reason { - case .notConnected, .cancelled: - return String(format: String(localized: "Not connected to %@"), connection.name) - case .disconnected, .disconnectedByUser: - return String(format: String(localized: "Disconnected from %@"), connection.name) - case .failed, .actionRequired: - return String(format: String(localized: "Could not connect to %@"), connection.name) - } + Self.headline(for: reason, connectionName: connection.name) } private var detailLines: [String] { @@ -99,7 +92,7 @@ internal struct ConnectionUnavailableView: View { if case .disconnected = reason { return [String(localized: "The connection was closed.")] } return [] } - return lines(from: failureInfo) + return failureInfo.lines } private var failureInfo: ConnectionFailureInfo? { @@ -117,15 +110,20 @@ internal struct ConnectionUnavailableView: View { /// clipboard verbatim alongside enough context to identify the connection. private var copyableDetails: String? { guard let failureInfo else { return nil } - return ([headline, connection.connectionSubtitle] + lines(from: failureInfo)) + return ([headline, connection.connectionSubtitle] + failureInfo.lines + [failureInfo.details ?? ""]) .filter { !$0.isEmpty } .joined(separator: "\n") } - private func lines(from info: ConnectionFailureInfo) -> [String] { - [info.message, info.failureReason, info.recoverySuggestion] - .compactMap { $0 } - .filter { !$0.isEmpty } + internal static func headline(for reason: ConnectionUnavailableReason, connectionName: String) -> String { + switch reason { + case .notConnected, .cancelled: + return String(format: String(localized: "Not connected to %@"), connectionName) + case .disconnected, .disconnectedByUser: + return String(format: String(localized: "Disconnected from %@"), connectionName) + case .failed(let info), .actionRequired(let info, _): + return info.title ?? String(format: String(localized: "Could not connect to %@"), connectionName) + } } internal static func primaryActionTitle(for reason: ConnectionUnavailableReason) -> String { diff --git a/apple/TableProTests/Core/Database/LocalNetworkConnectFailureTests.swift b/apple/TableProTests/Core/Database/LocalNetworkConnectFailureTests.swift new file mode 100644 index 0000000000..6ecce61832 --- /dev/null +++ b/apple/TableProTests/Core/Database/LocalNetworkConnectFailureTests.swift @@ -0,0 +1,130 @@ +// +// LocalNetworkConnectFailureTests.swift +// TableProTests +// +// A driver refused by the Local Network permission only reports an unreachable host. The connect +// path probes the host it dialed and, on a refusal, records the fix that opens System Settings. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +struct LocalNetworkConnectFailureTests { + private struct StubProbe: LocalNetworkAccessProbing { + let answer: LocalNetworkAccess + + func access(to host: String) async -> LocalNetworkAccess { answer } + } + + private struct HeldProbe: LocalNetworkAccessProbing { + let gate: AsyncGate + + func access(to host: String) async -> LocalNetworkAccess { + await gate.wait() + return .denied + } + } + + private struct RouteRefusal: LocalizedError { + var errorDescription: String? { "No route to host" } + } + + private struct Fixture { + let manager: DatabaseManager + let connection: DatabaseConnection + let cleanUp: () -> Void + } + + private func makeFixture(probe: any LocalNetworkAccessProbing) throws -> Fixture { + FakeMSSQLPluginRegistration.registerIfNeeded() + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-local-network-\(UUID().uuidString).json") + let suite = try EphemeralDefaults() + let storage = ConnectionStorage(fileURL: fileURL, userDefaults: suite.defaults, keychain: InMemoryKeychain()) + let manager = DatabaseManager(connectionStorage: storage, localNetworkProbe: probe) + + var connection = TestFixtures.makeConnection(name: "LAN Server", type: .mssql) + connection.host = "192.168.77.20" + storage.addConnection(connection) + FakeMSSQLPlugin.failConnect(for: connection.id, with: RouteRefusal()) + + let connectionId = connection.id + return Fixture( + manager: manager, + connection: connection, + cleanUp: { + manager.removeSession(for: connectionId) + FakeMSSQLPlugin.clearConnectFailure(for: connectionId) + suite.defaults.removePersistentDomain(forName: suite.suiteName) + try? FileManager.default.removeItem(at: fileURL) + } + ) + } + + @Test("A connect the permission refused names it and records the fix that opens System Settings") + func refusedConnectOffersSystemSettings() async throws { + let fixture = try makeFixture(probe: StubProbe(answer: .denied)) + defer { fixture.cleanUp() } + + await #expect(throws: LocalNetworkAccessError.self) { + try await fixture.manager.ensureConnected(fixture.connection) + } + + guard case .connectFailed(let info, let action)? = fixture.manager.disconnectReason(for: fixture.connection.id) else { + Issue.record("Expected a failed connect to be recorded") + return + } + #expect(action == .openLocalNetworkSettings) + #expect(info.message.contains("192.168.77.20")) + #expect(info.details == "No route to host") + } + + @Test("A host the permission allows keeps the driver's own error") + func allowedHostKeepsTheDriverError() async throws { + let fixture = try makeFixture(probe: StubProbe(answer: .granted)) + defer { fixture.cleanUp() } + + await #expect(throws: RouteRefusal.self) { + try await fixture.manager.ensureConnected(fixture.connection) + } + + guard case .connectFailed(let info, .none)? = fixture.manager.disconnectReason(for: fixture.connection.id) else { + Issue.record("Expected a failed connect with no fix") + return + } + #expect(info.message == "No route to host") + } + + @Test("A proxy setting rejected before any dial keeps its own error under a refusing probe") + func preflightErrorSurvivesARefusal() async throws { + let fixture = try makeFixture(probe: StubProbe(answer: .denied)) + defer { fixture.cleanUp() } + var connection = fixture.connection + connection.socksProxyMode = .inline(SOCKSProxyConfiguration(host: "192.168.1.30", port: 0)) + + await #expect(throws: SOCKSProxyError.invalidConfiguration) { + try await fixture.manager.ensureConnected(connection) + } + } + + @Test("A connect cancelled while the probe runs records nothing") + func cancelDuringProbeRecordsNothing() async throws { + let gate = AsyncGate() + let fixture = try makeFixture(probe: HeldProbe(gate: gate)) + defer { fixture.cleanUp() } + + let manager = fixture.manager + let connection = fixture.connection + let attempt = Task { try await manager.ensureConnected(connection) } + await gate.arrived(1) + manager.invalidateConnectionAttempt(connection.id) + await gate.open() + _ = await attempt.result + + #expect(manager.disconnectReason(for: connection.id) == nil) + } +} diff --git a/apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift b/apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift new file mode 100644 index 0000000000..6d05e3b46f --- /dev/null +++ b/apple/TableProTests/Core/Network/LocalNetworkAccessDiagnosisTests.swift @@ -0,0 +1,183 @@ +// +// LocalNetworkAccessDiagnosisTests.swift +// TableProTests +// +// The permission is asked of the host this Mac dials: the database for a direct connection, the +// first SSH hop or the SOCKS proxy behind a transport, never the server those reach for it. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +@testable import TableProSSHConfig +import Testing + +struct LocalNetworkAccessDiagnosisTests { + private struct StubProbe: LocalNetworkAccessProbing { + let denied: Set + + func access(to host: String) async -> LocalNetworkAccess { + denied.contains(host) ? .denied : .granted + } + } + + private struct RouteRefusal: LocalizedError { + var errorDescription: String? { "could not connect to server: No route to host" } + } + + private func hops(_ connection: DatabaseConnection, sshConfig: String = "") -> [LocalNetworkHop] { + LocalNetworkAccessDiagnosis.hops(for: connection, sshDocument: SSHConfigParser.parseDocumentContent(sshConfig)) + } + + private func sshConnection(_ config: SSHConfiguration) -> DatabaseConnection { + var connection = DatabaseConnection(name: "Behind SSH", host: "10.20.0.4", port: 5_432, type: .postgresql) + connection.sshTunnelMode = .inline(config) + return connection + } + + @Test("A direct connection asks about the database host") + func directConnectionProbesTheDatabase() { + let connection = DatabaseConnection(name: "LAN", host: "192.168.1.20", port: 5_432, type: .postgresql) + + #expect(hops(connection) == [LocalNetworkHop(role: .database, host: "192.168.1.20")]) + } + + @Test("A loopback host is never named, whatever the probe answers") + func loopbackIsNeverBlocked() async { + for host in ["localhost", "127.0.0.1", "[::1]", "::ffff:127.0.0.1"] { + let connection = DatabaseConnection(name: "Local", host: host, port: 5_432, type: .postgresql) + let blocked = await LocalNetworkAccessDiagnosis.blockedHop( + among: hops(connection), + probe: StubProbe(denied: [host, "::1", "::ffff:127.0.0.1"]) + ) + #expect(blocked == nil, "\(host)") + } + } + + @Test("An empty host or a socket path dials nothing") + func emptyHostIsSkipped() { + for host in ["", " ", "/tmp/.s.PGSQL.5432"] { + let connection = DatabaseConnection(name: "Local", host: host, port: 5_432, type: .postgresql) + #expect(hops(connection).isEmpty, "\(host)") + } + } + + @Test("A file database dials no host") + func fileDatabaseIsSkipped() { + let connection = DatabaseConnection(name: "File", host: "192.168.1.20", port: 0, type: .sqlite) + + #expect(hops(connection).isEmpty) + } + + @Test("Behind SSH the server is asked about, never the database it forwards to") + func sshProbesTheServer() { + let connection = sshConnection(SSHConfiguration(enabled: true, host: "192.168.1.9", username: "deploy")) + + #expect(hops(connection) == [LocalNetworkHop(role: .sshServer, host: "192.168.1.9")]) + } + + @Test("An SSH alias is asked about by the HostName the tunnel dials") + func sshAliasResolvesToItsHostName() { + let connection = sshConnection(SSHConfiguration(enabled: true, host: "db-gateway", username: "deploy")) + + let resolved = hops(connection, sshConfig: "Host db-gateway\n HostName 192.168.1.9\n") + + #expect(resolved == [LocalNetworkHop(role: .sshServer, host: "192.168.1.9")]) + } + + @Test("With jump hosts only the first one is dialed from this Mac") + func jumpHostIsTheFirstHop() { + let jump = SSHJumpHost(host: "10.0.0.5", username: "hop") + let connection = sshConnection( + SSHConfiguration(enabled: true, host: "203.0.113.7", username: "deploy", jumpHosts: [jump]) + ) + + #expect(hops(connection) == [LocalNetworkHop(role: .sshJumpHost, host: "10.0.0.5")]) + } + + @Test("Behind a SOCKS proxy the proxy is asked about") + func socksProbesTheProxy() { + var connection = DatabaseConnection(name: "Proxied", host: "10.20.0.4", port: 5_432, type: .postgresql) + connection.socksProxyMode = .inline(SOCKSProxyConfiguration(host: "192.168.1.30")) + + #expect(hops(connection) == [LocalNetworkHop(role: .socksProxy, host: "192.168.1.30")]) + } + + @Test("A transport run by a helper process names no hop") + func helperProcessTransportsAreSkipped() { + var connection = DatabaseConnection(name: "Cloudflare", host: "192.168.1.20", port: 5_432, type: .postgresql) + connection.cloudflareTunnelMode = .inline(CloudflareConfiguration(accessHostname: "db.example.com")) + + #expect(hops(connection).isEmpty) + } + + @Test("A refused hop is named") + func refusedHopIsNamed() async { + let hops = [LocalNetworkHop(role: .socksProxy, host: "192.168.1.30")] + + let blocked = await LocalNetworkAccessDiagnosis.blockedHop(among: hops, probe: StubProbe(denied: ["192.168.1.30"])) + + #expect(blocked == LocalNetworkHop(role: .socksProxy, host: "192.168.1.30")) + } + + @Test("A host list with one reachable server names nothing, since that server may have failed on its own") + func reachableAlternativeKeepsTheDriverError() async { + let hops = [ + LocalNetworkHop(role: .database, host: "203.0.113.10"), + LocalNetworkHop(role: .database, host: "192.168.1.20") + ] + + #expect(await LocalNetworkAccessDiagnosis.blockedHop(among: hops, probe: StubProbe(denied: ["192.168.1.20"])) == nil) + } + + @Test("When every host is refused the first in order is named") + func firstRefusedHopWins() async { + let hops = [ + LocalNetworkHop(role: .database, host: "192.168.1.20"), + LocalNetworkHop(role: .database, host: "192.168.1.21") + ] + + let blocked = await LocalNetworkAccessDiagnosis.blockedHop( + among: hops, + probe: StubProbe(denied: ["192.168.1.20", "192.168.1.21"]) + ) + + #expect(blocked?.host == "192.168.1.20") + } + + @Test("A granted hop names nothing") + func grantedHopIsNotBlocked() async { + let hops = [LocalNetworkHop(role: .sshServer, host: "192.168.1.9")] + + #expect(await LocalNetworkAccessDiagnosis.blockedHop(among: hops, probe: StubProbe(denied: [])) == nil) + } + + @Test("Only a failure with no named cause is worth a probe") + func onlyUnexplainedFailuresAreProbed() { + #expect(LocalNetworkAccessDiagnosis.canExplain(RouteRefusal(), fields: [:])) + #expect(!LocalNetworkAccessDiagnosis.canExplain(CancellationError(), fields: [:])) + #expect(!LocalNetworkAccessDiagnosis.canExplain(PluginError.pluginNotInstalled("SQL Server"), fields: [:])) + #expect(!LocalNetworkAccessDiagnosis.canExplain(SSLHandshakeError.unknown(serverMessage: "handshake failed"), fields: [:])) + #expect(!LocalNetworkAccessDiagnosis.canExplain( + LocalNetworkAccessError(hop: LocalNetworkHop(role: .database, host: "192.168.1.20"), underlying: RouteRefusal()), + fields: [:] + )) + } + + @Test("A failure a browser sign-in fixes keeps its sign-in offer") + func signInFailuresAreNotProbed() { + #expect(!LocalNetworkAccessDiagnosis.canExplain(EntraOAuthError.signInRequired, fields: [:])) + } + + @Test("A setting rejected before any socket opened is never blamed on the permission") + func preflightFailuresAreNotProbed() { + #expect(!LocalNetworkAccessDiagnosis.canExplain(SOCKSProxyError.invalidConfiguration, fields: [:])) + #expect(!LocalNetworkAccessDiagnosis.canExplain(SSHTunnelError.usernameMissing(host: "bastion"), fields: [:])) + #expect(!LocalNetworkAccessDiagnosis.canExplain(ConnectionTunnelError.remoteFilePathMissing, fields: [:])) + #expect(LocalNetworkAccessDiagnosis.canExplain(SOCKSProxyError.connectTimedOut(proxyHost: "192.168.1.30", proxyPort: 1_080), fields: [:])) + #expect(LocalNetworkAccessDiagnosis.canExplain( + SSHTunnelError.tunnelCreationFailed("Connection to 192.168.1.9:22 failed: No route to host"), + fields: [:] + )) + } +} diff --git a/apple/TableProTests/Core/Network/LocalNetworkAccessProbeTests.swift b/apple/TableProTests/Core/Network/LocalNetworkAccessProbeTests.swift new file mode 100644 index 0000000000..393db2ab09 --- /dev/null +++ b/apple/TableProTests/Core/Network/LocalNetworkAccessProbeTests.swift @@ -0,0 +1,62 @@ +// +// LocalNetworkAccessProbeTests.swift +// TableProTests +// +// The probe's answer comes from what Network framework reports on a UDP connection. Measured on +// macOS 27: a satisfied path to a LAN, public or loopback address goes ready in under 10 ms and +// carries `unsatisfiedReason == .notAvailable`; an unknown name waits with NoSuchRecord. +// + +import dnssd +import Foundation +import Network +@testable import TablePro +import Testing + +struct LocalNetworkAccessProbeTests { + @Test("A path the Local Network permission refused is a denial") + func refusedPathIsDenied() { + #expect(LocalNetworkProbeSignal.path(status: .unsatisfied, reason: .localNetworkDenied).verdict == .denied) + } + + @Test("A path's reason counts only when the path is unsatisfied") + func satisfiedPathKeepsListening() { + #expect(LocalNetworkProbeSignal.path(status: .satisfied, reason: .notAvailable).verdict == nil) + #expect(LocalNetworkProbeSignal.path(status: .satisfied, reason: .localNetworkDenied).verdict == nil) + #expect(LocalNetworkProbeSignal.path(status: .unsatisfied, reason: .notAvailable).verdict == nil) + } + + @Test("A .local name refused during resolution is a denial") + func mdnsPolicyRefusalIsDenied() { + let error = NWError.dns(DNSServiceErrorType(kDNSServiceErr_PolicyDenied)) + + #expect(LocalNetworkProbeSignal.waiting(error, status: .satisfied, reason: .notAvailable).verdict == .denied) + } + + @Test("Waiting on a refused path is a denial") + func waitingOnRefusedPathIsDenied() { + let signal = LocalNetworkProbeSignal.waiting(.posix(.EHOSTUNREACH), status: .unsatisfied, reason: .localNetworkDenied) + + #expect(signal.verdict == .denied) + } + + @Test("A name that does not resolve says nothing about the permission") + func unresolvedNameIsUndetermined() { + let error = NWError.dns(DNSServiceErrorType(kDNSServiceErr_NoSuchRecord)) + + #expect(LocalNetworkProbeSignal.waiting(error, status: .satisfied, reason: .notAvailable).verdict == .undetermined) + #expect(LocalNetworkProbeSignal.waiting(error, status: nil, reason: nil).verdict == .undetermined) + } + + @Test("A ready connection is granted, and one that ended or stayed silent is undetermined") + func readyEndedAndSilent() { + #expect(LocalNetworkProbeSignal.ready.verdict == .granted) + #expect(LocalNetworkProbeSignal.ended.verdict == .undetermined) + #expect(LocalNetworkProbeSignal.deadline.verdict == .undetermined) + } + + @Test("Loopback never needs the permission") + func loopbackProbeIsGranted() async { + #expect(await NetworkFrameworkLocalNetworkProbe().access(to: "127.0.0.1") == .granted) + } +} diff --git a/apple/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift b/apple/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift index c4b332ccf1..01e828927e 100644 --- a/apple/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift +++ b/apple/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift @@ -210,4 +210,28 @@ struct ConnectionFailureClassifierTests { #expect(!info.message.contains("hunter2")) } + + @Test("A Local Network refusal names the permission and the host, and keeps the driver's words for Copy Details") + func localNetworkRefusalOffersSystemSettings() { + let driverError = NSError( + domain: "com.TablePro.test", + code: Int(EHOSTUNREACH), + userInfo: [NSLocalizedDescriptionKey: "could not connect to server: No route to host"] + ) + let error = LocalNetworkAccessError( + hop: LocalNetworkHop(role: .sshServer, host: "192.168.1.9"), + underlying: driverError + ) + + guard case .actionRequired(let info, let action) = ConnectionFailureClassifier.outcome(for: error) else { + Issue.record("Expected the pane to offer a fix") + return + } + #expect(action == .openLocalNetworkSettings) + #expect(info.title == String(localized: "Local Network Access Required")) + #expect(info.message.contains("192.168.1.9")) + #expect(info.recoverySuggestion?.contains("Local Network") == true) + #expect(!info.lines.contains { $0.contains("No route to host") }) + #expect(info.details == "could not connect to server: No route to host") + } } diff --git a/apple/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift b/apple/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift index 768d85d815..3cc19cd334 100644 --- a/apple/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift +++ b/apple/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift @@ -19,7 +19,8 @@ struct ConnectionRecoveryPresentationTests { let expected: [(ConnectionRecoveryAction, String)] = [ (.installPlugin, String(localized: "Install Plugin…")), (.openPluginSettings(pluginId: nil), String(localized: "Open Plugin Settings")), - (.editConnection, String(localized: "Edit Connection…")) + (.editConnection, String(localized: "Edit Connection…")), + (.openLocalNetworkSettings, String(localized: "Open System Settings")) ] for (action, title) in expected { @@ -37,10 +38,25 @@ struct ConnectionRecoveryPresentationTests { @Test("Only a fix made in Settings offers a separate retry") func onlySettingsOffersRetry() { #expect(ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .openPluginSettings(pluginId: nil)))) + #expect(ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .openLocalNetworkSettings))) #expect(!ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .installPlugin))) #expect(!ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .editConnection))) } + @Test("A failure that names its cause heads the pane with it") + func namedCauseIsTheHeadline() { + let named = ConnectionFailureInfo(title: "Local Network Access Required", message: "Cannot reach 192.168.1.9.") + + #expect( + ConnectionUnavailableView.headline(for: .actionRequired(named, .openLocalNetworkSettings), connectionName: "Prod") + == "Local Network Access Required" + ) + #expect( + ConnectionUnavailableView.headline(for: .failed(Self.failure), connectionName: "Prod") + == String(format: String(localized: "Could not connect to %@"), "Prod") + ) + } + @Test("A request to show a plugin is answered once") func navigationRequestIsConsumedOnce() { let navigation = PluginsSettingsNavigation() From 7202416b683bfcded9030cb6b256a4c398129478 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 20:34:44 +0700 Subject: [PATCH 2/4] docs(changelog): add the Local Network permission fragment --- apple/changelog.d/3699.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3699.added.md diff --git a/apple/changelog.d/3699.added.md b/apple/changelog.d/3699.added.md new file mode 100644 index 0000000000..f22b259509 --- /dev/null +++ b/apple/changelog.d/3699.added.md @@ -0,0 +1 @@ +A connection blocked by the macOS Local Network permission now says so and offers to open System Settings. From 5e8ceacfd94132150acea12cd7c8fae8ece82af5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 21:48:14 +0700 Subject: [PATCH 3/4] fix(connections): stop the failure pane truncating its button titles --- .../Components/UnavailableStateView.swift | 22 ++++++----- .../UnavailableStateViewLayoutTests.swift | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 apple/TableProTests/Views/Components/UnavailableStateViewLayoutTests.swift diff --git a/apple/TablePro/Views/Components/UnavailableStateView.swift b/apple/TablePro/Views/Components/UnavailableStateView.swift index faed90014d..8d0c046838 100644 --- a/apple/TablePro/Views/Components/UnavailableStateView.swift +++ b/apple/TablePro/Views/Components/UnavailableStateView.swift @@ -24,22 +24,26 @@ internal struct UnavailableStateView= 560) + } + + @Test("The description still wraps at the text column") + func descriptionStaysInTheTextColumn() { + let view = UnavailableStateView { + Text("Title") + } description: { + Text(String(repeating: "word ", count: 80)) + } + + #expect(NSHostingView(rootView: view).fittingSize.width <= 420) + } +} From 647da1fb7275776c0149762620d02817bbb60be0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 21:48:30 +0700 Subject: [PATCH 4/4] docs(changelog): add the failure pane button fragment --- apple/changelog.d/3699.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3699.fixed.md diff --git a/apple/changelog.d/3699.fixed.md b/apple/changelog.d/3699.fixed.md new file mode 100644 index 0000000000..e3f8c474cd --- /dev/null +++ b/apple/changelog.d/3699.fixed.md @@ -0,0 +1 @@ +The buttons under a failed connection cut off their titles, such as Manage Connections.