Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 0 additions & 8 deletions TableProMobile/TableProMobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -458,18 +458,14 @@
5A87EEAD2F7F88F200D028D0 /* RegistryPluginDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistryPluginDetailView.swift; sourceTree = "<group>"; };
5A87EEAF2F7F88F200D028D0 /* AISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsView.swift; sourceTree = "<group>"; };
5A87EEB02F7F88F200D028D0 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };

5A87EEB22F7F88F200D028D0 /* EditorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSettingsView.swift; sourceTree = "<group>"; };
5A87EEB32F7F88F200D028D0 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };

5A87EEB52F7F88F200D028D0 /* KeyboardSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardSettingsView.swift; sourceTree = "<group>"; };
5A87EEB62F7F88F200D028D0 /* LicenseActivationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseActivationSheet.swift; sourceTree = "<group>"; };

5A87EEB82F7F88F200D028D0 /* LinkedFoldersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedFoldersSection.swift; sourceTree = "<group>"; };
5A87EEB92F7F88F200D028D0 /* PluginsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsSettingsView.swift; sourceTree = "<group>"; };
5A87EEBA2F7F88F200D028D0 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5A87EEBB2F7F88F200D028D0 /* ShortcutRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorderView.swift; sourceTree = "<group>"; };

5A87EEBD2F7F88F200D028D0 /* ThemePreviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreviewCard.swift; sourceTree = "<group>"; };
5A87EEBF2F7F88F200D028D0 /* DoubleClickDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleClickDetector.swift; sourceTree = "<group>"; };
5A87EEC02F7F88F200D028D0 /* FavoriteEditDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteEditDialog.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions TableProMobile/TableProMobile/Drivers/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions TableProMobile/TableProMobile/Drivers/RedisDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
32 changes: 27 additions & 5 deletions TableProMobile/TableProMobile/Helpers/AppError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions TableProMobile/TableProMobile/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
<dict>
<key>AnalyticsHMACSecret</key>
<string>$(ANALYTICS_HMAC_SECRET)</string>
<key>NSLocalNetworkUsageDescription</key>
<string>TablePro connects to database servers and SSH tunnels running on your local network, including Bonjour (.local) hostnames.</string>
<key>NSBonjourServices</key>
<array>
<string>_ssh._tcp</string>
<string>_mysql._tcp</string>
<string>_postgresql._tcp</string>
<string>_redis._tcp</string>
</array>
<key>NSUserActivityTypes</key>
<array>
<string>com.TablePro.viewConnection</string>
Expand Down
18 changes: 18 additions & 0 deletions TableProMobile/TableProMobile/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
}
}
}
},
"%d row(s) affected" : {

},
"%lld" : {
"localizations" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -2518,6 +2527,9 @@
}
}
}
},
"Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again." : {

},
"Opens a database connection in TablePro" : {
"localizations" : {
Expand Down Expand Up @@ -2967,6 +2979,9 @@
}
}
}
},
"Run" : {

},
"Run a Query" : {
"localizations" : {
Expand Down Expand Up @@ -3432,6 +3447,9 @@
}
}
}
},
"Stop" : {

},
"Switching..." : {
"extractionState" : "stale",
Expand Down
133 changes: 133 additions & 0 deletions TableProMobile/TableProMobile/Platform/LocalNetworkPermission.swift
Original file line number Diff line number Diff line change
@@ -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<Resolution, Never>?

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<Resolution, Never> {
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<NWBrowser.State>.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
}
}
4 changes: 3 additions & 1 deletion TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// Stateless factory that creates fully-connected, authenticated SSH tunnels.
//

import Foundation
import CLibSSH2
import Foundation
import TableProModels

enum SSHTunnelFactory {
Expand All @@ -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)
Expand Down
Loading