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 @@ -14,6 +14,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.
- Data grid column headers now use the same 4pt horizontal inset as result cells, including the right-aligned `#` row-number header.
- 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
4 changes: 4 additions & 0 deletions TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ struct DataGridView: NSViewRepresentable {
rowNumberColumn.maxWidth = 60
rowNumberColumn.isEditable = false
rowNumberColumn.resizingMask = []
let rowNumberHeaderCell = SortableHeaderCell(textCell: "#")
rowNumberHeaderCell.font = rowNumberColumn.headerCell.font
rowNumberHeaderCell.alignment = .right
rowNumberColumn.headerCell = rowNumberHeaderCell
rowNumberColumn.headerCell.setAccessibilityLabel(String(localized: "Row number"))
tableView.addTableColumn(rowNumberColumn)
rowNumberColumn.isHidden = !configuration.showRowNumbers
Expand Down
27 changes: 20 additions & 7 deletions TablePro/Views/Results/SortableHeaderCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ final class SortableHeaderCell: NSTableHeaderCell {

override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
guard let direction = sortDirection else {
super.drawInterior(withFrame: cellFrame, in: controlView)
drawTitle(in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: false))
return
}

Expand All @@ -49,7 +49,7 @@ final class SortableHeaderCell: NSTableHeaderCell {
width: max(0, cellFrame.width - reservedWidth),
height: cellFrame.height
)
drawSortedTitle(in: titleFrame)
drawTitle(in: titleRect(forBounds: titleFrame), font: titleFont(isSorted: true))

let indicatorOriginX = cellFrame.maxX - Self.indicatorPadding - indicatorSize.width
let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2
Expand All @@ -73,26 +73,39 @@ final class SortableHeaderCell: NSTableHeaderCell {
}
}

private func drawSortedTitle(in rect: NSRect) {
override func titleRect(forBounds rect: NSRect) -> NSRect {
let inset = min(Self.titleHorizontalPadding, rect.width / 2)
return NSRect(
x: rect.minX + inset,
y: rect.minY,
width: max(0, rect.width - inset * 2),
height: rect.height
)
}

private func titleFont(isSorted: Bool) -> NSFont {
let baseFont = font ?? NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
let boldFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask)
guard isSorted else { return baseFont }
return NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask)
}

private func drawTitle(in rect: NSRect, font titleFont: NSFont) {
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
paragraph.lineBreakMode = .byTruncatingTail

let attributes: [NSAttributedString.Key: Any] = [
.font: boldFont,
.font: titleFont,
.foregroundColor: NSColor.headerTextColor,
.paragraphStyle: paragraph
]

let title = NSAttributedString(string: stringValue, attributes: attributes)
let textHeight = title.size().height
let drawRect = NSRect(
x: rect.minX + Self.titleHorizontalPadding,
x: rect.minX,
y: rect.midY - textHeight / 2,
width: max(0, rect.width - Self.titleHorizontalPadding),
width: rect.width,
height: textHeight
)
title.draw(in: drawRect)
Expand Down
25 changes: 25 additions & 0 deletions TableProTests/Views/Results/SortableHeaderCellTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import AppKit
@testable import TablePro
import Testing

@MainActor
@Suite("SortableHeaderCell")
struct SortableHeaderCellTests {
@Test("Title rect uses data cell horizontal padding")
func titleRectUsesDataCellHorizontalPadding() {
let cell = SortableHeaderCell(textCell: "id")
let titleRect = cell.titleRect(forBounds: NSRect(x: 10, y: 0, width: 100, height: 24))

#expect(titleRect.minX == 14)
#expect(titleRect.width == 92)
}

@Test("Narrow title rect does not produce negative width")
func narrowTitleRectDoesNotProduceNegativeWidth() {
let cell = SortableHeaderCell(textCell: "id")
let titleRect = cell.titleRect(forBounds: NSRect(x: 0, y: 0, width: 6, height: 24))

#expect(titleRect.minX == 3)
#expect(titleRect.width == 0)
}
}
Loading