Skip to content

Commit 1af0b44

Browse files
committed
Merge branch 'main' into fix/ssh-agent-tilde-expansion
2 parents 85fd896 + 0bfb2c2 commit 1af0b44

8 files changed

Lines changed: 288 additions & 280 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Sync status indicator in welcome window showing real-time sync state
1616
- Conflict resolution dialog for handling simultaneous edits across devices
1717

18+
### Fixed
19+
20+
- Keychain authorization prompt no longer appears on every table open
21+
1822
## [0.18.1] - 2026-03-14
1923

2024
### Fixed

TablePro/AppDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5252

5353
func applicationDidFinishLaunching(_ notification: Notification) {
5454
NSWindow.allowsAutomaticWindowTabbing = true
55+
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
5556
PluginManager.shared.loadPlugins()
5657

5758
Task { @MainActor in

TablePro/Core/Storage/AIKeyStorage.swift

Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,10 @@
77
//
88

99
import Foundation
10-
import os
11-
import Security
1210

1311
/// Singleton Keychain storage for AI provider API keys
1412
final class AIKeyStorage {
1513
static let shared = AIKeyStorage()
16-
private static let logger = Logger(subsystem: "com.TablePro", category: "AIKeyStorage")
1714

1815
private init() {}
1916

@@ -22,67 +19,18 @@ final class AIKeyStorage {
2219
/// Save an API key to Keychain for the given provider
2320
func saveAPIKey(_ apiKey: String, for providerID: UUID) {
2421
let key = "com.TablePro.aikey.\(providerID.uuidString)"
25-
26-
// Delete existing
27-
let deleteQuery: [String: Any] = [
28-
kSecClass as String: kSecClassGenericPassword,
29-
kSecAttrService as String: "com.TablePro",
30-
kSecAttrAccount as String: key,
31-
]
32-
SecItemDelete(deleteQuery as CFDictionary)
33-
34-
// Add new
35-
guard let data = apiKey.data(using: .utf8) else { return }
36-
37-
let addQuery: [String: Any] = [
38-
kSecClass as String: kSecClassGenericPassword,
39-
kSecAttrService as String: "com.TablePro",
40-
kSecAttrAccount as String: key,
41-
kSecValueData as String: data,
42-
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
43-
]
44-
45-
let status = SecItemAdd(addQuery as CFDictionary, nil)
46-
if status != errSecSuccess {
47-
Self.logger.error("Failed to save API key for provider \(providerID.uuidString): \(status)")
48-
}
22+
KeychainHelper.shared.saveString(apiKey, forKey: key)
4923
}
5024

5125
/// Load an API key from Keychain for the given provider
5226
func loadAPIKey(for providerID: UUID) -> String? {
5327
let key = "com.TablePro.aikey.\(providerID.uuidString)"
54-
55-
let query: [String: Any] = [
56-
kSecClass as String: kSecClassGenericPassword,
57-
kSecAttrService as String: "com.TablePro",
58-
kSecAttrAccount as String: key,
59-
kSecReturnData as String: true,
60-
kSecMatchLimit as String: kSecMatchLimitOne,
61-
]
62-
63-
var result: AnyObject?
64-
let status = SecItemCopyMatching(query as CFDictionary, &result)
65-
66-
guard status == errSecSuccess,
67-
let data = result as? Data,
68-
let apiKey = String(data: data, encoding: .utf8)
69-
else {
70-
return nil
71-
}
72-
73-
return apiKey
28+
return KeychainHelper.shared.loadString(forKey: key)
7429
}
7530

7631
/// Delete an API key from Keychain for the given provider
7732
func deleteAPIKey(for providerID: UUID) {
7833
let key = "com.TablePro.aikey.\(providerID.uuidString)"
79-
80-
let query: [String: Any] = [
81-
kSecClass as String: kSecClassGenericPassword,
82-
kSecAttrService as String: "com.TablePro",
83-
kSecAttrAccount as String: key,
84-
]
85-
86-
SecItemDelete(query as CFDictionary)
34+
KeychainHelper.shared.delete(key: key)
8735
}
8836
}

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 12 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import Foundation
99
import os
10-
import Security
1110

1211
/// Service for persisting database connections
1312
final class ConnectionStorage {
@@ -162,223 +161,70 @@ final class ConnectionStorage {
162161
// - ConnectionFormView — single-item lookup during form population (negligible latency)
163162
// No async wrapper is needed; adding one would add complexity without measurable benefit.
164163

165-
/// Upsert a value into the Keychain: tries SecItemAdd first, falls back to SecItemUpdate
166-
/// on duplicate. Returns true on success.
167-
@discardableResult
168-
private func keychainUpsert(key: String, data: Data) -> Bool {
169-
let baseQuery: [String: Any] = [
170-
kSecClass as String: kSecClassGenericPassword,
171-
kSecAttrService as String: "com.TablePro",
172-
kSecAttrAccount as String: key,
173-
]
174-
175-
let addQuery = baseQuery.merging([
176-
kSecValueData as String: data,
177-
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
178-
]) { _, new in new }
179-
180-
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
181-
182-
if addStatus == errSecDuplicateItem {
183-
// Item already exists — update it
184-
let updateAttrs: [String: Any] = [kSecValueData as String: data]
185-
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
186-
if updateStatus != errSecSuccess {
187-
Self.logger.error("Failed to update Keychain item '\(key)': OSStatus \(updateStatus)")
188-
return false
189-
}
190-
return true
191-
} else if addStatus != errSecSuccess {
192-
Self.logger.error("Failed to add Keychain item '\(key)': OSStatus \(addStatus)")
193-
return false
194-
}
195-
return true
196-
}
197-
198-
/// Save password to Keychain
199164
func savePassword(_ password: String, for connectionId: UUID) {
200165
let key = "com.TablePro.password.\(connectionId.uuidString)"
201-
guard let data = password.data(using: .utf8) else { return }
202-
keychainUpsert(key: key, data: data)
166+
KeychainHelper.shared.saveString(password, forKey: key)
203167
}
204168

205-
/// Load password from Keychain
206169
func loadPassword(for connectionId: UUID) -> String? {
207170
let key = "com.TablePro.password.\(connectionId.uuidString)"
208-
209-
let query: [String: Any] = [
210-
kSecClass as String: kSecClassGenericPassword,
211-
kSecAttrService as String: "com.TablePro",
212-
kSecAttrAccount as String: key,
213-
kSecReturnData as String: true,
214-
kSecMatchLimit as String: kSecMatchLimitOne,
215-
]
216-
217-
var result: AnyObject?
218-
let status = SecItemCopyMatching(query as CFDictionary, &result)
219-
220-
guard status == errSecSuccess,
221-
let data = result as? Data,
222-
let password = String(data: data, encoding: .utf8)
223-
else {
224-
return nil
225-
}
226-
227-
return password
171+
return KeychainHelper.shared.loadString(forKey: key)
228172
}
229173

230-
/// Delete password from Keychain
231174
func deletePassword(for connectionId: UUID) {
232175
let key = "com.TablePro.password.\(connectionId.uuidString)"
233-
234-
let query: [String: Any] = [
235-
kSecClass as String: kSecClassGenericPassword,
236-
kSecAttrService as String: "com.TablePro",
237-
kSecAttrAccount as String: key,
238-
]
239-
240-
SecItemDelete(query as CFDictionary)
176+
KeychainHelper.shared.delete(key: key)
241177
}
242178

243179
// MARK: - SSH Password Storage
244180

245-
/// Save SSH password to Keychain
246181
func saveSSHPassword(_ password: String, for connectionId: UUID) {
247182
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"
248-
guard let data = password.data(using: .utf8) else { return }
249-
keychainUpsert(key: key, data: data)
183+
KeychainHelper.shared.saveString(password, forKey: key)
250184
}
251185

252-
/// Load SSH password from Keychain
253186
func loadSSHPassword(for connectionId: UUID) -> String? {
254187
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"
255-
256-
let query: [String: Any] = [
257-
kSecClass as String: kSecClassGenericPassword,
258-
kSecAttrService as String: "com.TablePro",
259-
kSecAttrAccount as String: key,
260-
kSecReturnData as String: true,
261-
kSecMatchLimit as String: kSecMatchLimitOne,
262-
]
263-
264-
var result: AnyObject?
265-
let status = SecItemCopyMatching(query as CFDictionary, &result)
266-
267-
guard status == errSecSuccess,
268-
let data = result as? Data,
269-
let password = String(data: data, encoding: .utf8)
270-
else {
271-
return nil
272-
}
273-
274-
return password
188+
return KeychainHelper.shared.loadString(forKey: key)
275189
}
276190

277-
/// Delete SSH password from Keychain
278191
func deleteSSHPassword(for connectionId: UUID) {
279192
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"
280-
281-
let query: [String: Any] = [
282-
kSecClass as String: kSecClassGenericPassword,
283-
kSecAttrService as String: "com.TablePro",
284-
kSecAttrAccount as String: key,
285-
]
286-
287-
SecItemDelete(query as CFDictionary)
193+
KeychainHelper.shared.delete(key: key)
288194
}
289195

290196
// MARK: - Key Passphrase Storage
291197

292-
/// Save private key passphrase to Keychain
293198
func saveKeyPassphrase(_ passphrase: String, for connectionId: UUID) {
294199
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"
295-
guard let data = passphrase.data(using: .utf8) else { return }
296-
keychainUpsert(key: key, data: data)
200+
KeychainHelper.shared.saveString(passphrase, forKey: key)
297201
}
298202

299-
/// Load private key passphrase from Keychain
300203
func loadKeyPassphrase(for connectionId: UUID) -> String? {
301204
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"
302-
303-
let query: [String: Any] = [
304-
kSecClass as String: kSecClassGenericPassword,
305-
kSecAttrService as String: "com.TablePro",
306-
kSecAttrAccount as String: key,
307-
kSecReturnData as String: true,
308-
kSecMatchLimit as String: kSecMatchLimitOne,
309-
]
310-
311-
var result: AnyObject?
312-
let status = SecItemCopyMatching(query as CFDictionary, &result)
313-
314-
guard status == errSecSuccess,
315-
let data = result as? Data,
316-
let passphrase = String(data: data, encoding: .utf8)
317-
else {
318-
return nil
319-
}
320-
321-
return passphrase
205+
return KeychainHelper.shared.loadString(forKey: key)
322206
}
323207

324-
/// Delete private key passphrase from Keychain
325208
func deleteKeyPassphrase(for connectionId: UUID) {
326209
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"
327-
328-
let query: [String: Any] = [
329-
kSecClass as String: kSecClassGenericPassword,
330-
kSecAttrService as String: "com.TablePro",
331-
kSecAttrAccount as String: key,
332-
]
333-
334-
SecItemDelete(query as CFDictionary)
210+
KeychainHelper.shared.delete(key: key)
335211
}
336212

337213
// MARK: - TOTP Secret Storage
338214

339-
/// Save TOTP secret to Keychain
340215
func saveTOTPSecret(_ secret: String, for connectionId: UUID) {
341216
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
342-
guard let data = secret.data(using: .utf8) else { return }
343-
keychainUpsert(key: key, data: data)
217+
KeychainHelper.shared.saveString(secret, forKey: key)
344218
}
345219

346-
/// Load TOTP secret from Keychain
347220
func loadTOTPSecret(for connectionId: UUID) -> String? {
348221
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
349-
350-
let query: [String: Any] = [
351-
kSecClass as String: kSecClassGenericPassword,
352-
kSecAttrService as String: "com.TablePro",
353-
kSecAttrAccount as String: key,
354-
kSecReturnData as String: true,
355-
kSecMatchLimit as String: kSecMatchLimitOne,
356-
]
357-
358-
var result: AnyObject?
359-
let status = SecItemCopyMatching(query as CFDictionary, &result)
360-
361-
guard status == errSecSuccess,
362-
let data = result as? Data,
363-
let secret = String(data: data, encoding: .utf8)
364-
else {
365-
return nil
366-
}
367-
368-
return secret
222+
return KeychainHelper.shared.loadString(forKey: key)
369223
}
370224

371-
/// Delete TOTP secret from Keychain
372225
func deleteTOTPSecret(for connectionId: UUID) {
373226
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
374-
375-
let query: [String: Any] = [
376-
kSecClass as String: kSecClassGenericPassword,
377-
kSecAttrService as String: "com.TablePro",
378-
kSecAttrAccount as String: key,
379-
]
380-
381-
SecItemDelete(query as CFDictionary)
227+
KeychainHelper.shared.delete(key: key)
382228
}
383229
}
384230

0 commit comments

Comments
 (0)