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

- 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.
- SSH auth-failure alerts now point at the actual cause. The catch-all "Check your credentials or private key." string was wrong for the most common 2FA case (typing a wrong TOTP code), because the credentials were fine; only the verification code was wrong. `SSHTunnelError.authenticationFailed` now carries an `AuthFailureReason` (`password`, `verificationCode`, `privateKey`, `agentRejected`, `generic`), every throw site picks the right one, and the alert text matches: "Verification code rejected. Get a new code from your authenticator app and try again." for kbd-int + TOTP rejections, "SSH password rejected. Check the password and try again." for plain password rejections, and so on. Follow-up to #1005.
- TOTP codes are now fetched lazily from the `TOTPProvider` inside the kbd-int callback rather than once upfront before authentication starts. Fixes two related issues: (1) when the kbd-int handshake straddled a 30-second TOTP rotation boundary, the `AutoTOTPProvider` code that was valid at fetch time had expired by the time PAM validated it; (2) when the server retried after a wrong code (sshd defaults to 3 prompts per session), TablePro replayed the same wrong code instead of asking the user for a new one. `PromptTOTPProvider` now switches its NSAlert wording to "Verification Code Rejected. The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code." on retries, matching how OpenSSH re-prompts.
- SSH Auth Method = Password failed against servers that only advertise `keyboard-interactive` (the typical `pam_google_authenticator` setup, where `sshd_config` has `AuthenticationMethods keyboard-interactive`). The bare `userauth_password` request the server doesn't accept was the only attempt, so connection failed even when the user typed the right password. `buildAuthenticator` now always pairs `PasswordAuthenticator` with a `KeyboardInteractiveAuthenticator` that reuses the same password, matching OpenSSH and Sequel Ace's "save the password, the server may also prompt for a code" flow. Follow-up to #1005.
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ touch Secrets.xcconfig
brew install swiftlint swiftformat
```

If you're signing the Debug build with a non-official Apple Developer team (e.g. a free personal team), three things in the project file are tied to the official team and need a one-time local override in Xcode. Open `TablePro.xcodeproj`, select the `TablePro` target, then:

1. **Team**: Signing & Capabilities tab → click the **Debug** sub-tab at the top (so the change scopes to Debug only) → set Team to your personal team. If the build fails with a signing error on a different target (driver plugin, framework, mcp-server, tests), repeat for that target — codesign signs each target independently.
2. **Bundle Identifier**: same Debug sub-tab → change `com.TablePro` to something unique under your team (e.g. `com.<yourhandle>.TablePro`). The default `com.TablePro` is reserved for the official team in Apple's developer portal. Plugin sub-IDs (`com.TablePro.MySQLDriver` etc.) are auto-registered under your team and don't need renaming.
3. **Entitlements**: Build Settings tab → search "Code Signing Entitlements" → in the **Debug** row, change `TablePro/TablePro.entitlements` to `TablePro/TablePro.Debug.entitlements`. This second file already ships with the repo (you don't need to create it); it is identical to the default minus the iCloud keys, which free personal teams don't support. iCloud sync is automatically disabled at runtime when the entitlement is absent.

These changes will appear in `TablePro.xcodeproj/project.pbxproj`. **Don't commit them**, or you'll break the official Release signing. Either revert with `git checkout TablePro.xcodeproj/project.pbxproj` before every commit, or run `git update-index --skip-worktree TablePro.xcodeproj/project.pbxproj` once to make git ignore your local changes to that file. Release builds and official-team Debug builds keep using `com.TablePro` and `TablePro/TablePro.entitlements` unchanged.

Verify the setup by saving a database connection with a password, quitting and relaunching the app, then re-opening the connection: the password should still be there.

Build:

```bash
Expand Down
23 changes: 20 additions & 3 deletions TablePro/Core/Storage/KeychainHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,23 @@ final class KeychainHelper: Sendable {
static let passwordSyncEnabledKey = "com.TablePro.keychainPasswordSyncEnabled"

private let service = "com.TablePro"
private let accessGroup = "D7HJ5TFYCU.com.TablePro.shared"
private let accessGroup: String? = KeychainHelper.resolveAccessGroup()
private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainHelper")

private static let accessGroupSuffix = ".com.TablePro.shared"
private static let teamPrefixedGroupPattern = #"^[A-Z0-9]{10}\..+"#

private static func resolveAccessGroup() -> String? {
guard let task = SecTaskCreateFromSelf(nil),
let groups = SecTaskCopyValueForEntitlement(task, "keychain-access-groups" as CFString, nil) as? [String]
else { return nil }
let candidate = groups.first { $0.hasSuffix(accessGroupSuffix) } ?? groups.first
guard let candidate,
candidate.range(of: teamPrefixedGroupPattern, options: .regularExpression) != nil
else { return nil }
return candidate
}

private var isPasswordSyncEnabled: Bool {
UserDefaults.standard.bool(forKey: Self.passwordSyncEnabledKey)
}
Expand Down Expand Up @@ -139,13 +153,16 @@ final class KeychainHelper: Sendable {
// MARK: - Private

private func baseQuery(forKey key: String) -> [String: Any] {
[
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: accessGroup,
kSecUseDataProtectionKeychain as String: true
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}

private func accessibility(forSync synchronizable: Bool) -> CFString {
Expand Down
34 changes: 28 additions & 6 deletions TablePro/Core/Sync/CloudKitSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import CloudKit
import Foundation
import os
import Security

/// Result of a pull operation
struct PullResult: Sendable {
Expand All @@ -20,29 +21,48 @@ struct PullResult: Sendable {
actor CloudKitSyncEngine {
private static let logger = Logger(subsystem: "com.TablePro", category: "CloudKitSyncEngine")

private let container: CKContainer
private let database: CKDatabase
private let container: CKContainer?
private let database: CKDatabase?
let zoneID: CKRecordZone.ID

private static let containerIdentifier = "iCloud.com.TablePro"
private static let zoneName = "TableProSync"
private static let maxRetries = 3

static func hasICloudEntitlement() -> Bool {
guard let task = SecTaskCreateFromSelf(nil) else { return false }
return SecTaskCopyValueForEntitlement(task, "com.apple.developer.icloud-services" as CFString, nil) != nil
}

init() {
container = CKContainer(identifier: Self.containerIdentifier)
database = container.privateCloudDatabase
if Self.hasICloudEntitlement() {
let container = CKContainer(identifier: Self.containerIdentifier)
self.container = container
database = container.privateCloudDatabase
} else {
container = nil
database = nil
Self.logger.warning("iCloud entitlement missing: CloudKit sync disabled")
}
zoneID = CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)
}

// MARK: - Account Status

func checkAccountStatus() async throws -> CKAccountStatus {
try await container.accountStatus()
guard let container else { throw SyncError.accountUnavailable }
return try await container.accountStatus()
}

func currentAccountId() async throws -> String? {
guard let container else { return nil }
return try await container.userRecordID().recordName
}

// MARK: - Zone Management

func ensureZoneExists() async throws {
guard let database else { throw SyncError.accountUnavailable }
let zone = CKRecordZone(zoneID: zoneID)
_ = try await database.save(zone)
Self.logger.trace("Created or confirmed sync zone: \(Self.zoneName)")
Expand Down Expand Up @@ -79,6 +99,7 @@ actor CloudKitSyncEngine {
}

private func pushBatch(records: [CKRecord], deletions: [CKRecord.ID]) async throws {
guard let database else { throw SyncError.accountUnavailable }
try await withRetry {
let operation = CKModifyRecordsOperation(
recordsToSave: records,
Expand Down Expand Up @@ -106,7 +127,7 @@ actor CloudKitSyncEngine {
continuation.resume(throwing: error)
}
}
self.database.add(operation)
database.add(operation)
}
}
}
Expand All @@ -120,6 +141,7 @@ actor CloudKitSyncEngine {
}

private func performPull(since token: CKServerChangeToken?) async throws -> PullResult {
guard let database else { throw SyncError.accountUnavailable }
let configuration = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
configuration.previousServerChangeToken = token

Expand Down
4 changes: 1 addition & 3 deletions TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,7 @@ final class SyncCoordinator {
}

private func currentAccountId() async throws -> String? {
let container = CKContainer(identifier: "iCloud.com.TablePro")
let userRecordID = try await container.userRecordID()
return userRecordID.recordName
try await engine.currentAccountId()
}

// MARK: - Conflict Handling
Expand Down
14 changes: 14 additions & 0 deletions TablePro/TablePro.Debug.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.TablePro.shared</string>
</array>
</dict>
</plist>
6 changes: 1 addition & 5 deletions TablePro/TablePro.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>D7HJ5TFYCU.com.TablePro</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.TablePro</string>
Expand All @@ -14,15 +12,13 @@
</array>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.developer.team-identifier</key>
<string>D7HJ5TFYCU</string>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>D7HJ5TFYCU.com.TablePro.shared</string>
<string>$(AppIdentifierPrefix)com.TablePro.shared</string>
</array>
</dict>
</plist>
74 changes: 74 additions & 0 deletions TableProTests/Core/Sync/CloudKitSyncEngineTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// CloudKitSyncEngineTests.swift
// TableProTests
//
// Verifies the soft-dependency path: when the running process lacks the
// iCloud entitlement, every CloudKit-touching method throws
// SyncError.accountUnavailable instead of trapping. Tests skip themselves
// when the test host happens to be signed with the entitlement (otherwise
// they would hit the real CloudKit network or get unrelated errors).
//

import CloudKit
import Foundation
@testable import TablePro
import Testing

@Suite("CloudKitSyncEngine soft dependency")
struct CloudKitSyncEngineTests {
private func skipIfEntitled() throws {
try #require(!CloudKitSyncEngine.hasICloudEntitlement(), "Test host has the iCloud entitlement; skipping")
}

@Test("checkAccountStatus throws accountUnavailable without iCloud entitlement")
func checkAccountStatusThrows() async throws {
try skipIfEntitled()
let engine = CloudKitSyncEngine()
await #expect(throws: SyncError.accountUnavailable) {
_ = try await engine.checkAccountStatus()
}
}

@Test("ensureZoneExists throws accountUnavailable without iCloud entitlement")
func ensureZoneExistsThrows() async throws {
try skipIfEntitled()
let engine = CloudKitSyncEngine()
await #expect(throws: SyncError.accountUnavailable) {
try await engine.ensureZoneExists()
}
}

@Test("push with non-empty input throws accountUnavailable without iCloud entitlement")
func pushThrows() async throws {
try skipIfEntitled()
let engine = CloudKitSyncEngine()
let zoneID = await engine.zoneID
let record = CKRecord(recordType: "Test", recordID: CKRecord.ID(recordName: "test", zoneID: zoneID))
await #expect(throws: SyncError.accountUnavailable) {
try await engine.push(records: [record], deletions: [])
}
}

@Test("push short-circuits without throwing when both inputs are empty")
func pushEmptyShortCircuits() async throws {
let engine = CloudKitSyncEngine()
try await engine.push(records: [], deletions: [])
}

@Test("pull throws accountUnavailable without iCloud entitlement")
func pullThrows() async throws {
try skipIfEntitled()
let engine = CloudKitSyncEngine()
await #expect(throws: SyncError.accountUnavailable) {
_ = try await engine.pull(since: nil)
}
}

@Test("currentAccountId returns nil without iCloud entitlement")
func currentAccountIdReturnsNil() async throws {
try skipIfEntitled()
let engine = CloudKitSyncEngine()
let accountId = try await engine.currentAccountId()
#expect(accountId == nil)
}
}
Loading