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: app crashed with `EXC_BREAKPOINT` "Not enough bits to represent the passed value" when opening some MySQL tables (TestFlight report on a 100k-record table). `MySQLActor.execute` did `Int(mysql_affected_rows(mysql))`, but libmariadb is documented to return `~(my_ulonglong)0` (= `UInt64.max`) as an error sentinel ("for a SELECT, mysql_affected_rows() was called prior to mysql_store_result()") and the unchecked Int conversion trapped on the sentinel. The same shape applied to per-cell `mysql_fetch_lengths` values, which on arm64 are `unsigned long` (`UInt64`); a length above `Int.max` would trap rather than fail the read recoverably. Both paths now use `Int(clamping:)` and the affected-rows sites explicitly map the `~0` sentinel to `0`. Same hardening applied to the macOS MySQL plugin's two cell-length conversion sites in `MariaDBPluginConnection.swift` (default-fetch and streaming) which had identical exposure but no reported crash.
- 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.
Expand Down
6 changes: 2 additions & 4 deletions Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,7 @@ final class MariaDBPluginConnection: @unchecked Sendable {

for i in 0..<numFields {
if let fieldPtr = rowPtr[i] {
let lengthValue: UInt = lengths?[i] ?? 0
let length = Int(lengthValue)
let length = Int(clamping: lengths?[i] ?? 0)
let bufferPtr = UnsafeRawBufferPointer(start: fieldPtr, count: length)

if columnTypes[i] == 255 {
Expand Down Expand Up @@ -914,8 +913,7 @@ final class MariaDBPluginConnection: @unchecked Sendable {

for i in 0..<numFields {
if let fieldPtr = rowPtr[i] {
let lengthValue: UInt = lengths?[i] ?? 0
let length = Int(lengthValue)
let length = Int(clamping: lengths?[i] ?? 0)
let bufferPtr = UnsafeRawBufferPointer(start: fieldPtr, count: length)

if columnTypes[i] == 255 {
Expand Down
13 changes: 10 additions & 3 deletions TableProMobile/TableProMobile/Drivers/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ private actor MySQLActor {
if mysql_field_count(mysql) != 0 {
throw MySQLError.queryFailed(String(cString: mysql_error(mysql)))
}
let affected = Int(mysql_affected_rows(mysql))
let raw = mysql_affected_rows(mysql)
let affected = raw == .max ? 0 : Int(clamping: raw)
return RawMySQLResult(
columns: [], columnTypes: [], rows: [],
rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false
Expand Down Expand Up @@ -327,7 +328,7 @@ private actor MySQLActor {
var rowData: [String?] = []
for i in 0..<fieldCount {
if let value = row[i] {
let len = Int(lengths?[i] ?? 0)
let len = Int(clamping: lengths?[i] ?? 0)
let data = Data(bytes: value, count: len)
rowData.append(String(data: data, encoding: .utf8) ?? String(cString: value))
} else {
Expand All @@ -338,7 +339,13 @@ private actor MySQLActor {
}

let isTruncated = rows.count >= maxRows
let affected = columns.isEmpty ? Int(mysql_affected_rows(mysql)) : 0
let affected: Int
if columns.isEmpty {
let raw = mysql_affected_rows(mysql)
affected = raw == .max ? 0 : Int(clamping: raw)
} else {
affected = 0
}
return RawMySQLResult(
columns: columns, columnTypes: columnTypes, rows: rows,
rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: isTruncated
Expand Down
Loading