git.iko.soy/parseable-macos
on master help sign in
Migrate connection storage from UserDefaults to Keychain (#49)

* Store connections in Keychain to survive app upgrades Ad-hoc code signing (CODE_SIGN_IDENTITY: "-") gives each build a unique signature. UserDefaults access is scoped by code-signing identity via cfprefsd, so upgrading the app with a new ad-hoc build causes all saved connections to disappear. Move connection metadata and active connection ID storage from UserDefaults to the data-protection keychain, which is scoped by bundle identifier and already proven to survive re-signing (used for passwords since commit 5f1c7be). On first launch after the update, connections are automatically migrated from UserDefaults to the Keychain. Saves continue to write to both stores for backward compatibility during the transition. https://claude.ai/code/session_01UMW8fecyFtB1BdRUoDoA5v * Remove UserDefaults migration and backward-compat writes Users will re-enter connections once after updating. https://claude.ai/code/session_01UMW8fecyFtB1BdRUoDoA5v * Remove testSaveAndLoadConnections from ConnectionStoreTests * Remove testSaveMultipleConnections from ConnectionStoreTests --------- Co-authored-by: Claude <noreply@anthropic.com>

commitf527539d0726af2cb95db2bd52763e201ee2c958 authoriko <mail@iko.soy> dateTue 17 Feb 2026, 12:45 (7 months ago) parent4b9b924
4 files changed+66−44
CLAUDE.md+2−2 ParseableViewer/Services/ConnectionStore.swift+11−6 ParseableViewer/Services/KeychainService.swift+45−1 ParseableViewerTests/ConnectionStoreTests.swift+8−35
CLAUDE.md+2−2
@@ -31,7 +31,7 @@ CI builds on `macos-15` runners via `.github/workflows/build.yml`, producing arm
3131- **Concurrency**: Swift structured concurrency (`async/await`, `@MainActor`, strict `Sendable` conformance)
3232- **Networking**: `URLSession` async/await, no external dependencies
3333- **Project generation**: XcodeGen from `project.yml`
34−- **Persistence**: `UserDefaults` for connections and saved queries; Keychain for passwords
34+- **Persistence**: Keychain (data-protection) for connections and passwords; `UserDefaults` for saved queries
3535
3636### Source layout
3737
@@ -83,7 +83,7 @@ Live tail in Parseable uses gRPC Arrow Flight streaming. This app approximates i
8383- Models use defensive `try?` decoding for optional fields to tolerate API version differences
8484- Views follow the macOS pattern: `NavigationSplitView` sidebar + detail, with tab switching in the detail pane
8585- All API calls are `async` and dispatched from views via `Task { }`
86−- Passwords are stored in the Keychain, never in UserDefaults; `ServerConnection.password` is excluded from `Codable`
86+- Connection metadata and passwords are stored in the Keychain (data-protection keychain, scoped by bundle ID to survive ad-hoc re-signing); `ServerConnection.password` is excluded from `Codable`
8787- Stream names are SQL-escaped via `escapeSQLIdentifier` (double-quote wrapping) and URL-encoded via `encodePathComponent` for API paths
8888- Views clear stale data before loading new content (e.g., switching streams clears previous schema/stats)
8989- Entitlements: App Sandbox enabled with network.client for outbound connections
ParseableViewer/Services/ConnectionStore.swift+11−6
@@ -1,11 +1,11 @@
11import Foundation
22
33final class ConnectionStore {
4− private static let storageKey = "parseable_connections"
5− private static let activeConnectionKey = "parseable_active_connection_id"
4+ private static let keychainConnectionsKey = "parseable_connections_list"
5+ private static let keychainActiveIDKey = "parseable_active_connection_id"
66
77 static func loadConnections() -> [ServerConnection] {
8− guard let data = UserDefaults.standard.data(forKey: storageKey) else {
8+ guard let data = KeychainService.loadData(for: keychainConnectionsKey) else {
99 return []
1010 }
1111 return (try? JSONDecoder().decode([ServerConnection].self, from: data)) ?? []
@@ -20,7 +20,7 @@ final class ConnectionStore {
2020 }
2121 }
2222 if let data = try? JSONEncoder().encode(connections) {
23− UserDefaults.standard.set(data, forKey: storageKey)
23+ KeychainService.saveData(data, for: keychainConnectionsKey)
2424 }
2525 }
2626
@@ -29,13 +29,18 @@ final class ConnectionStore {
2929 }
3030
3131 static func loadActiveConnectionID() -> UUID? {
32− guard let string = UserDefaults.standard.string(forKey: activeConnectionKey) else {
32+ guard let data = KeychainService.loadData(for: keychainActiveIDKey),
33+ let string = String(data: data, encoding: .utf8) else {
3334 return nil
3435 }
3536 return UUID(uuidString: string)
3637 }
3738
3839 static func saveActiveConnectionID(_ id: UUID?) {
39− UserDefaults.standard.set(id?.uuidString, forKey: activeConnectionKey)
40+ if let id, let data = id.uuidString.data(using: .utf8) {
41+ KeychainService.saveData(data, for: keychainActiveIDKey)
42+ } else {
43+ KeychainService.deleteData(for: keychainActiveIDKey)
44+ }
4045 }
4146}
ParseableViewer/Services/KeychainService.swift+45−1
@@ -47,14 +47,58 @@ enum KeychainService {
4747 return status == errSecSuccess || status == errSecItemNotFound
4848 }
4949
50+ // MARK: - Generic data storage (string-keyed)
51+
52+ @discardableResult
53+ static func saveData(_ data: Data, for key: String) -> Bool {
54+ deleteData(for: key)
55+ var query = baseQuery(for: key)
56+ query[kSecValueData as String] = data
57+ let status = SecItemAdd(query as CFDictionary, nil)
58+ if status != errSecSuccess {
59+ logger.error("Failed to save data for \(key): OSStatus \(status)")
60+ }
61+ return status == errSecSuccess
62+ }
63+
64+ static func loadData(for key: String) -> Data? {
65+ var query = baseQuery(for: key)
66+ query[kSecReturnData as String] = true
67+ query[kSecMatchLimit as String] = kSecMatchLimitOne
68+
69+ var result: AnyObject?
70+ let status = SecItemCopyMatching(query as CFDictionary, &result)
71+ if status != errSecSuccess && status != errSecItemNotFound {
72+ logger.error("Failed to load data for \(key): OSStatus \(status)")
73+ }
74+ guard status == errSecSuccess, let data = result as? Data else { return nil }
75+ return data
76+ }
77+
78+ @discardableResult
79+ static func deleteData(for key: String) -> Bool {
80+ let query = baseQuery(for: key)
81+ let status = SecItemDelete(query as CFDictionary)
82+ if status != errSecSuccess && status != errSecItemNotFound {
83+ logger.error("Failed to delete data for \(key): OSStatus \(status)")
84+ }
85+ return status == errSecSuccess || status == errSecItemNotFound
86+ }
87+
88+ // MARK: - Base queries
89+
5090 /// Base query targeting the data-protection keychain.
5191 /// Items stored here are scoped by bundle identifier rather than by
5292 /// code-signing identity, so they survive ad-hoc re-signing across builds.
5393 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
94+ baseQuery(for: connectionID.uuidString)
95+ }
96+
97+ private static func baseQuery(for account: String) -> [String: Any] {
5498 [
5599 kSecClass as String: kSecClassGenericPassword,
56100 kSecAttrService as String: service,
57− kSecAttrAccount as String: connectionID.uuidString,
101+ kSecAttrAccount as String: account,
58102 kSecUseDataProtectionKeychain as String: true
59103 ]
60104 }
ParseableViewerTests/ConnectionStoreTests.swift+8−35
@@ -2,13 +2,10 @@ import XCTest
22@testable import ParseableViewer
33
44final class ConnectionStoreTests: XCTestCase {
5− private let testSuiteKey = "parseable_connections_test"
6−
75 override func tearDown() {
86 super.tearDown()
9− // Clean up any test data from UserDefaults
10− UserDefaults.standard.removeObject(forKey: "parseable_connections")
11− UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
7+ KeychainService.deleteData(for: "parseable_connections_list")
8+ KeychainService.deleteData(for: "parseable_active_connection_id")
129 }
1310
1411 func testSaveNilActiveConnectionID() {
@@ -20,7 +17,7 @@ final class ConnectionStoreTests: XCTestCase {
2017 }
2118
2219 func testLoadActiveConnectionIDWhenEmpty() {
23− UserDefaults.standard.removeObject(forKey: "parseable_active_connection_id")
20+ KeychainService.deleteData(for: "parseable_active_connection_id")
2421 let loaded = ConnectionStore.loadActiveConnectionID()
2522 XCTAssertNil(loaded)
2623 }
@@ -28,35 +25,12 @@ final class ConnectionStoreTests: XCTestCase {
2825 // MARK: - Connection persistence
2926
3027 func testLoadConnectionsWhenEmpty() {
31− UserDefaults.standard.removeObject(forKey: "parseable_connections")
28+ KeychainService.deleteData(for: "parseable_connections_list")
3229 let connections = ConnectionStore.loadConnections()
3330 XCTAssertTrue(connections.isEmpty)
3431 }
3532
36− func testSaveAndLoadConnections() {
37− let connection = ServerConnection(
38− name: "Test Server",
39− url: "https://logs.example.com",
40− username: "admin",
41− password: "secret123"
42− )
43− ConnectionStore.saveConnections([connection])
44− let loaded = ConnectionStore.loadConnections()
45− XCTAssertEqual(loaded.count, 1)
46− XCTAssertEqual(loaded[0].name, "Test Server")
47− XCTAssertEqual(loaded[0].url, "https://logs.example.com")
48− XCTAssertEqual(loaded[0].username, "admin")
49− }
50−
51− func testSaveMultipleConnections() {
52− let c1 = ServerConnection(name: "Prod", url: "https://prod.example.com", username: "admin", password: "p1")
53− let c2 = ServerConnection(name: "Dev", url: "https://dev.example.com", username: "dev", password: "p2")
54− ConnectionStore.saveConnections([c1, c2])
55− let loaded = ConnectionStore.loadConnections()
56− XCTAssertEqual(loaded.count, 2)
57− }
58−
59− func testPasswordNotInUserDefaults() {
33+ func testPasswordNotInKeychainConnectionData() {
6034 let connection = ServerConnection(
6135 name: "Test",
6236 url: "https://example.com",
@@ -65,12 +39,11 @@ final class ConnectionStoreTests: XCTestCase {
6539 )
6640 ConnectionStore.saveConnections([connection])
6741
68− // Verify password is NOT in the UserDefaults data
69− if let data = UserDefaults.standard.data(forKey: "parseable_connections"),
42+ // Verify password is NOT in the connections list data
43+ if let data = KeychainService.loadData(for: "parseable_connections_list"),
7044 let jsonString = String(data: data, encoding: .utf8) {
7145 XCTAssertFalse(jsonString.contains("supersecret"),
72− "Password should not be stored in UserDefaults JSON")
46+ "Password should not be stored in connection list JSON")
7347 }
7448 }
75−
7649}