diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e75c7c61..78186367c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- iOS: connections to `.local` (Bonjour) hostnames and other local-network addresses (10.x, 192.168.x, 172.16-31.x, 169.254.x, IPv6 ULA / link-local) timed out silently. The bundle was missing `NSLocalNetworkUsageDescription` and `NSBonjourServices`, so iOS never prompted the user for Local Network access and quietly dropped every outbound `connect()` to a local-network address. Most visible variant: SSH Tunnel set to `Some-MacBook.local`, error surfaced as "MySQL connection failed: Lost connection to server at 'handshake: reading initial communication packet', system error: 60" (errno 60 = `ETIMEDOUT`). Both Info.plist keys are now declared (purpose string explains database/SSH access; Bonjour types `_ssh._tcp`, `_mysql._tcp`, `_postgresql._tcp`, `_redis._tcp`). A new `LocalNetworkPermission` actor starts an `NWBrowser` for `_ssh._tcp` the first time a connection targets a local-network host (the documented Apple pattern from the DTS "Local Network Privacy FAQ" since a bare `connect()` does not always trigger the consent prompt for `getaddrinfo`-based connections), watches the `NWBrowser.State` stream for `.ready` (granted) or `.waiting`/`.failed` (unavailable), and caches the resolution per process. On denial the gate throws `LocalNetworkPermissionError.unavailable` immediately on every subsequent attempt instead of waiting for the 10-second TCP timeout, so the error surfaces in under a second. Concurrent first-time gate calls share one in-flight resolution `Task` so a parallel SSH + DB connect does not double-prompt. Wired through `SSHTunnelFactory.create()` and `MySQLDriver` / `PostgreSQLDriver` / `RedisDriver` `connect()` (loopback-only and non-local hosts no-op the gate). `ErrorClassifier` matches the typed `LocalNetworkPermissionError` directly and falls back to detecting `ETIMEDOUT` on SSH-enabled or local-network connections; both paths show "Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again." instead of the previous generic "server is not responding" or misleading SSH-handshake copy. - Toolbar connection status keeps its left inset when no connection tag is shown. - The SQL editor jumped to the end of the document after committing a Chinese (or any IME-marked) word like "测试". `TextView.insertText(_:replacementRange:)` called `unmarkText()` first, which wiped the marked text via `replaceCharacters(_:with: "")`, then re-ran `replaceCharacters` with the same range AppKit had supplied. By then that range was stale. For mid-document marked text it ate the next characters; for end-of-document marked text the second range was out of bounds and the cursor landed at `documentLength` (the visible "scroll to end"). The implementation now resolves the effective range(s) before clearing marked-text bookkeeping and performs a single edit, with a multi-cursor IME path that replaces every marked range in one pass. While here, `TextSelectionManager.didReplaceCharacters` had a latent off-by-N in its delta calculation that the old unmark-then-insert flow happened to mask; same-length replaces now leave selections past the replace point unchanged. Fixes #1012. - External-contributor builds under any non-official Apple Developer team (free personal team or otherwise) failed in three correlated ways: provisioning rejected the iCloud capability, `KeychainHelper`'s hardcoded `D7HJ5TFYCU.com.TablePro.shared` access group caused every keychain write to fail with `errSecMissingEntitlement` (visible as "Test Connection: Access denied (using password: NO)"), and `CloudKitSyncEngine.init` trapped on launch with `EXC_BREAKPOINT` because `CKContainer(identifier:)` requires the iCloud entitlement. `TablePro.entitlements` no longer hardcodes the team prefix (uses `$(AppIdentifierPrefix)` for the keychain group; `application-identifier` and `team-identifier` are removed since codesign auto-injects them). `KeychainHelper` resolves the access group at runtime from the running process's `keychain-access-groups` entitlement and falls back to the default group when none is declared. `CloudKitSyncEngine` and `SyncCoordinator.currentAccountId` detect the iCloud entitlement at runtime; sync methods throw `SyncError.accountUnavailable` when absent and the existing UI surfaces the disabled state. A new `TablePro/TablePro.Debug.entitlements` (identical to the default minus the iCloud keys) ships in the repo so contributors on personal teams can point their Debug build at it; `CONTRIBUTING.md` documents the one-time Xcode UI setup (Team, Bundle Identifier, Code Signing Entitlements path). Release and official-team Debug builds use `com.TablePro` and `TablePro/TablePro.entitlements` unchanged. Fixes #1020. diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 7f3e7b5d0..020b47bc7 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -458,18 +458,14 @@ 5A87EEAD2F7F88F200D028D0 /* RegistryPluginDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistryPluginDetailView.swift; sourceTree = ""; }; 5A87EEAF2F7F88F200D028D0 /* AISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsView.swift; sourceTree = ""; }; 5A87EEB02F7F88F200D028D0 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; - 5A87EEB22F7F88F200D028D0 /* EditorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSettingsView.swift; sourceTree = ""; }; 5A87EEB32F7F88F200D028D0 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; - 5A87EEB52F7F88F200D028D0 /* KeyboardSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardSettingsView.swift; sourceTree = ""; }; 5A87EEB62F7F88F200D028D0 /* LicenseActivationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseActivationSheet.swift; sourceTree = ""; }; - 5A87EEB82F7F88F200D028D0 /* LinkedFoldersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedFoldersSection.swift; sourceTree = ""; }; 5A87EEB92F7F88F200D028D0 /* PluginsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsSettingsView.swift; sourceTree = ""; }; 5A87EEBA2F7F88F200D028D0 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5A87EEBB2F7F88F200D028D0 /* ShortcutRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorderView.swift; sourceTree = ""; }; - 5A87EEBD2F7F88F200D028D0 /* ThemePreviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreviewCard.swift; sourceTree = ""; }; 5A87EEBF2F7F88F200D028D0 /* DoubleClickDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleClickDetector.swift; sourceTree = ""; }; 5A87EEC02F7F88F200D028D0 /* FavoriteEditDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteEditDialog.swift; sourceTree = ""; }; @@ -1488,18 +1484,14 @@ 5A87EEAE2F7F88F200D028D0 /* Plugins */, 5A87EEAF2F7F88F200D028D0 /* AISettingsView.swift */, 5A87EEB02F7F88F200D028D0 /* AppearanceSettingsView.swift */, - 5A87EEB22F7F88F200D028D0 /* EditorSettingsView.swift */, 5A87EEB32F7F88F200D028D0 /* GeneralSettingsView.swift */, - 5A87EEB52F7F88F200D028D0 /* KeyboardSettingsView.swift */, 5A87EEB62F7F88F200D028D0 /* LicenseActivationSheet.swift */, - 5A87EEB82F7F88F200D028D0 /* LinkedFoldersSection.swift */, 5A87EEB92F7F88F200D028D0 /* PluginsSettingsView.swift */, 5A87EEBA2F7F88F200D028D0 /* SettingsView.swift */, 5A87EEBB2F7F88F200D028D0 /* ShortcutRecorderView.swift */, - 5A87EEBD2F7F88F200D028D0 /* ThemePreviewCard.swift */, ); path = Settings; diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index cad413052..349f56beb 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -38,6 +38,7 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Connection func connect() async throws { + try await LocalNetworkPermission.shared.ensureAccess(for: host) try await actor.connect(host: host, port: port, user: user, password: password, database: database, sslEnabled: sslEnabled) serverVersion = await actor.serverVersion() } diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 11564f49f..661bc9e3f 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -38,6 +38,7 @@ final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Connection func connect() async throws { + try await LocalNetworkPermission.shared.ensureAccess(for: host) try await actor.connect(host: host, port: port, user: user, password: password, database: database, sslEnabled: sslEnabled) serverVersion = await actor.serverVersion() } diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index db95b2604..b214fb59a 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -38,6 +38,7 @@ final class RedisDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Connection func connect() async throws { + try await LocalNetworkPermission.shared.ensureAccess(for: host) try await actor.connect(host: host, port: port, password: password, database: database, sslEnabled: sslEnabled) serverVersion = try? await actor.fetchServerVersion() } diff --git a/TableProMobile/TableProMobile/Helpers/AppError.swift b/TableProMobile/TableProMobile/Helpers/AppError.swift index e78163546..c98b9eca4 100644 --- a/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -54,10 +54,25 @@ enum ErrorClassifier { static func classify(_ error: Error, context: ErrorContext) -> AppError { let message = error.localizedDescription.lowercased() - // Log the error logger.error("[\(context.operation)] \(error.localizedDescription, privacy: .public)") - // SSH errors + if error is LocalNetworkPermissionError { + return AppError( + category: .network, + title: String(localized: "Local Network Access Required"), + message: error.localizedDescription, + recovery: String(localized: "Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again."), + underlying: error + ) + } + + let host = context.host ?? "" + let mayUseLocalNetwork = context.sshEnabled || LocalNetworkPermission.isLocalNetworkHost(host) + let timedOut = message.contains("timeout") || message.contains("timed out") || message.contains("operation timed out") || message.contains("system error: 60") + if mayUseLocalNetwork && timedOut { + return network(error, context: context) + } + if message.contains("ssh") || message.contains("tunnel") || message.contains("handshake") { return ssh(error, context: context) } @@ -142,11 +157,18 @@ enum ErrorClassifier { private static func network(_ error: Error, context: ErrorContext) -> AppError { let msg = error.localizedDescription + let lowered = msg.lowercased() let recovery: String - if msg.lowercased().contains("timeout") || msg.lowercased().contains("timed out") { + let isTimeout = lowered.contains("timeout") || lowered.contains("timed out") || lowered.contains("operation timed out") || lowered.contains("system error: 60") + let host = context.host ?? "" + let mayUseLocalNetwork = context.sshEnabled || LocalNetworkPermission.isLocalNetworkHost(host) + + if isTimeout && mayUseLocalNetwork { + recovery = String(localized: "Local Network access may be blocked. Open Settings > Privacy & Security > Local Network and turn TablePro on.") + } else if isTimeout { recovery = String(localized: "The server is not responding. Check the host and port.") - } else if msg.lowercased().contains("refused") { + } else if lowered.contains("refused") { recovery = String(localized: "Connection refused. The server may not be running or the port is incorrect.") } else { recovery = String(localized: "Check your network connection and server availability.") @@ -185,7 +207,7 @@ enum ErrorClassifier { } private static func config(_ error: Error, context: ErrorContext) -> AppError { - return AppError( + AppError( category: .config, title: String(localized: "Configuration Error"), message: error.localizedDescription, diff --git a/TableProMobile/TableProMobile/Info.plist b/TableProMobile/TableProMobile/Info.plist index 1d80a5e8f..e1dc13944 100644 --- a/TableProMobile/TableProMobile/Info.plist +++ b/TableProMobile/TableProMobile/Info.plist @@ -4,6 +4,15 @@ AnalyticsHMACSecret $(ANALYTICS_HMAC_SECRET) + NSLocalNetworkUsageDescription + TablePro connects to database servers and SSH tunnels running on your local network, including Bonjour (.local) hostnames. + NSBonjourServices + + _ssh._tcp + _mysql._tcp + _postgresql._tcp + _redis._tcp + NSUserActivityTypes com.TablePro.viewConnection diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index b9a9b8d15..607ebd7a0 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -85,6 +85,9 @@ } } } + }, + "%d row(s) affected" : { + }, "%lld" : { "localizations" : { @@ -2005,6 +2008,12 @@ } } } + }, + "Local Network access is required. Open Settings > Privacy & Security > Local Network and turn TablePro on." : { + + }, + "Local Network Access Required" : { + }, "Logic" : { "localizations" : { @@ -2518,6 +2527,9 @@ } } } + }, + "Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again." : { + }, "Opens a database connection in TablePro" : { "localizations" : { @@ -2967,6 +2979,9 @@ } } } + }, + "Run" : { + }, "Run a Query" : { "localizations" : { @@ -3432,6 +3447,9 @@ } } } + }, + "Stop" : { + }, "Switching..." : { "extractionState" : "stale", diff --git a/TableProMobile/TableProMobile/Platform/LocalNetworkPermission.swift b/TableProMobile/TableProMobile/Platform/LocalNetworkPermission.swift new file mode 100644 index 000000000..d8324d0f0 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/LocalNetworkPermission.swift @@ -0,0 +1,133 @@ +// +// LocalNetworkPermission.swift +// TableProMobile +// + +import Foundation +import Network +import os + +enum LocalNetworkPermissionError: Error, LocalizedError { + case unavailable + + var errorDescription: String? { + String(localized: "Local Network access is required. Open Settings > Privacy & Security > Local Network and turn TablePro on.") + } +} + +actor LocalNetworkPermission { + static let shared = LocalNetworkPermission() + + private static let logger = Logger(subsystem: "com.TablePro", category: "LocalNetworkPermission") + private static let promptTimeout: Duration = .seconds(5) + private static let triggerServiceType = "_ssh._tcp" + + enum Resolution: Sendable { + case unknown + case granted + case unavailable + } + + private var resolution: Resolution = .unknown + private var inFlight: Task? + + func ensureAccess(for host: String) async throws { + guard Self.isLocalNetworkHost(host) else { return } + + switch resolution { + case .granted: + return + case .unavailable: + throw LocalNetworkPermissionError.unavailable + case .unknown: + let result = await resolve() + if case .unavailable = result { + throw LocalNetworkPermissionError.unavailable + } + } + } + + private func resolve() async -> Resolution { + if let inFlight { + return await inFlight.value + } + + let task = Task { + await Self.runPrompt() + } + inFlight = task + + let result = await task.value + resolution = result + inFlight = nil + return result + } + + private static func runPrompt() async -> Resolution { + let browser = NWBrowser( + for: .bonjourWithTXTRecord(type: triggerServiceType, domain: nil), + using: NWParameters() + ) + let (stream, continuation) = AsyncStream.makeStream() + + browser.stateUpdateHandler = { state in + logger.info("NWBrowser state: \(String(describing: state), privacy: .public)") + continuation.yield(state) + switch state { + case .ready, .failed, .cancelled, .waiting: + continuation.finish() + default: + break + } + } + + browser.start(queue: .global(qos: .userInitiated)) + + let timeoutTask = Task { + try? await Task.sleep(for: promptTimeout) + continuation.finish() + } + + var resolved: Resolution = .unknown + for await state in stream { + switch state { + case .ready: + resolved = .granted + case .waiting, .failed: + resolved = .unavailable + case .cancelled: + break + default: + continue + } + } + + timeoutTask.cancel() + browser.cancel() + return resolved + } + + static func isLocalNetworkHost(_ host: String) -> Bool { + let lowered = host.lowercased() + if lowered.hasSuffix(".local") { return true } + if lowered == "localhost" { return false } + + if let bytes = IPv4Address(host)?.rawValue, bytes.count == 4 { + let octets = Array(bytes) + if octets[0] == 10 { return true } + if octets[0] == 172, (16...31).contains(octets[1]) { return true } + if octets[0] == 192, octets[1] == 168 { return true } + if octets[0] == 169, octets[1] == 254 { return true } + return false + } + + if let bytes = IPv6Address(host)?.rawValue, !bytes.isEmpty { + let octets = Array(bytes) + if (octets[0] & 0xfe) == 0xfc { return true } + if octets.count >= 2, octets[0] == 0xfe, (octets[1] & 0xc0) == 0x80 { return true } + return false + } + + return false + } +} diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index c50ff0ba0..bf8e6056f 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -5,8 +5,8 @@ // Stateless factory that creates fully-connected, authenticated SSH tunnels. // -import Foundation import CLibSSH2 +import Foundation import TableProModels enum SSHTunnelFactory { @@ -24,6 +24,8 @@ enum SSHTunnelFactory { ) async throws -> SSHTunnel { _ = initialized + try await LocalNetworkPermission.shared.ensureAccess(for: config.host) + let tunnel = SSHTunnel() try await tunnel.connect(host: config.host, port: config.port)