From eff1fe22c669f5c260e1b69eea5a69ad2e06101b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 27 May 2026 18:42:27 +0700 Subject: [PATCH] fix(ios): keep connection Safe Mode across iCloud sync --- CHANGELOG.md | 1 + Packages/TableProCore/Package.swift | 5 ++ .../TableProSync/SyncRecordMapper.swift | 14 ++++ .../SyncRecordMapperTests.swift | 81 +++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 51cec5c83..3b1628910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Opening a saved connection that fails now shows the detailed troubleshooting dialog with suggested fixes, the same one Test Connection shows, instead of a generic error alert. (#1425, #483) - Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483) - AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291) +- iOS: a connection's Safe Mode setting now survives relaunch. iCloud sync no longer drops the value, so a connection set to Confirm Writes or Read-Only no longer reverts to Off after reopening the app. ## [0.45.0] - 2026-05-26 diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index ac0d7ae13..99a90ddaf 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -77,6 +77,11 @@ let package = Package( name: "TableProMSSQLCoreTests", dependencies: ["TableProMSSQLCore"], path: "Tests/TableProMSSQLCoreTests" + ), + .testTarget( + name: "TableProSyncTests", + dependencies: ["TableProSync", "TableProModels"], + path: "Tests/TableProSyncTests" ) ] ) diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index 2d39850f2..e6db88efd 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -38,6 +38,7 @@ public enum SyncRecordMapper { record["type"] = connection.type.rawValue as CKRecordValue record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue record["isReadOnly"] = Int64(connection.isReadOnly ? 1 : 0) as CKRecordValue + record["safeModeLevel"] = connection.safeModeLevel.rawValue as CKRecordValue record["sshEnabled"] = Int64(connection.sshEnabled ? 1 : 0) as CKRecordValue record["sslEnabled"] = Int64(connection.sslEnabled ? 1 : 0) as CKRecordValue @@ -111,6 +112,7 @@ public enum SyncRecordMapper { let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) } let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0 + let safeModeLevel = safeModeLevel(fromWire: record["safeModeLevel"] as? String, isReadOnly: isReadOnly) let queryTimeout = (record["queryTimeoutSeconds"] as? Int64).map { Int($0) } var sshConfig: SSHConfiguration? if let sshData = record["sshConfigJson"] as? Data { @@ -151,6 +153,7 @@ public enum SyncRecordMapper { database: database, colorTag: colorTag, isReadOnly: isReadOnly, + safeModeLevel: safeModeLevel, queryTimeoutSeconds: queryTimeout, additionalFields: additionalFields, sshEnabled: sshEnabled, @@ -163,6 +166,16 @@ public enum SyncRecordMapper { ) } + private static func safeModeLevel(fromWire raw: String?, isReadOnly: Bool) -> SafeModeLevel { + guard let raw else { return isReadOnly ? .readOnly : .off } + if let level = SafeModeLevel(rawValue: raw) { return level } + switch raw { + case "silent": return .off + case "alert", "alertFull", "safeMode", "safeModeFull": return .confirmWrites + default: return isReadOnly ? .readOnly : .off + } + } + // MARK: - Update Existing CKRecord (preserves macOS-only fields) public static func updateRecord(_ record: CKRecord, with connection: DatabaseConnection) { @@ -175,6 +188,7 @@ public enum SyncRecordMapper { record["type"] = connection.type.rawValue as CKRecordValue record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue record["isReadOnly"] = Int64(connection.isReadOnly ? 1 : 0) as CKRecordValue + record["safeModeLevel"] = connection.safeModeLevel.rawValue as CKRecordValue record["sshEnabled"] = Int64(connection.sshEnabled ? 1 : 0) as CKRecordValue record["sslEnabled"] = Int64(connection.sslEnabled ? 1 : 0) as CKRecordValue diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift new file mode 100644 index 000000000..5ebc43993 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift @@ -0,0 +1,81 @@ +import CloudKit +import Foundation +import Testing + +@testable import TableProModels +@testable import TableProSync + +@Suite("SyncRecordMapper safe mode") +struct SyncRecordMapperTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + + private func makeConnection(safeModeLevel: SafeModeLevel, isReadOnly: Bool = false) -> DatabaseConnection { + DatabaseConnection( + name: "Test", + type: .postgresql, + host: "db.example.com", + port: 5432, + username: "admin", + database: "app", + isReadOnly: isReadOnly, + safeModeLevel: safeModeLevel + ) + } + + private func makeRawRecord(safeModeLevelRaw: String?, isReadOnly: Bool? = nil) -> CKRecord { + let id = SyncRecordMapper.recordID(type: .connection, id: UUID().uuidString, in: zoneID) + let record = CKRecord(recordType: SyncRecordType.connection.rawValue, recordID: id) + record["connectionId"] = UUID().uuidString as CKRecordValue + record["name"] = "Test" as CKRecordValue + record["type"] = DatabaseType.postgresql.rawValue as CKRecordValue + if let safeModeLevelRaw { + record["safeModeLevel"] = safeModeLevelRaw as CKRecordValue + } + if let isReadOnly { + record["isReadOnly"] = Int64(isReadOnly ? 1 : 0) as CKRecordValue + } + return record + } + + @Test("toRecord then toConnection preserves every safe mode level", arguments: SafeModeLevel.allCases) + func roundTripsEachLevel(_ level: SafeModeLevel) throws { + let record = SyncRecordMapper.toRecord(makeConnection(safeModeLevel: level), zoneID: zoneID) + let decoded = try #require(SyncRecordMapper.toConnection(record)) + #expect(decoded.safeModeLevel == level) + } + + @Test("updateRecord carries the new safe mode level") + func updateRecordPreservesLevel() throws { + let record = SyncRecordMapper.toRecord(makeConnection(safeModeLevel: .off), zoneID: zoneID) + SyncRecordMapper.updateRecord(record, with: makeConnection(safeModeLevel: .confirmWrites)) + let decoded = try #require(SyncRecordMapper.toConnection(record)) + #expect(decoded.safeModeLevel == .confirmWrites) + } + + @Test("legacy record without safeModeLevel falls back to isReadOnly") + func legacyFallback() throws { + let readOnlyRecord = makeRawRecord(safeModeLevelRaw: nil, isReadOnly: true) + let readOnly = try #require(SyncRecordMapper.toConnection(readOnlyRecord)) + #expect(readOnly.safeModeLevel == .readOnly) + + let writableRecord = makeRawRecord(safeModeLevelRaw: nil, isReadOnly: false) + let writable = try #require(SyncRecordMapper.toConnection(writableRecord)) + #expect(writable.safeModeLevel == .off) + } + + @Test( + "macOS wire values map to the nearest iOS level", + arguments: [ + ("silent", SafeModeLevel.off), + ("alert", SafeModeLevel.confirmWrites), + ("alertFull", SafeModeLevel.confirmWrites), + ("safeMode", SafeModeLevel.confirmWrites), + ("safeModeFull", SafeModeLevel.confirmWrites), + ("readOnly", SafeModeLevel.readOnly) + ] + ) + func decodesMacOSWireValues(_ raw: String, _ expected: SafeModeLevel) throws { + let decoded = try #require(SyncRecordMapper.toConnection(makeRawRecord(safeModeLevelRaw: raw))) + #expect(decoded.safeModeLevel == expected) + } +}