Skip to content

Commit 098dfec

Browse files
committed
feat: add iCloud sync for SSH profiles and documentation
- Add SyncRecordType.sshProfile with CKRecord mapping - Add syncSSHProfiles toggle to SyncSettings - Add sync tracking (markDirty/markDeleted) in SSHProfileStorage - Add saveProfilesWithoutSync for applying remote changes - Update SyncCoordinator: push, pull, delete, conflict handling - Handle .sshProfile in ConflictResolutionView - Add SSH Profiles feature docs (EN/VI/ZH) - Update SSH Tunneling docs with link to profiles - Update docs.json navigation
1 parent 07ff075 commit 098dfec

13 files changed

Lines changed: 513 additions & 6 deletions

File tree

TablePro/Core/Storage/SSHProfileStorage.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ final class SSHProfileStorage {
4242
Self.logger.warning("Refusing to save SSH profiles: previous load failed (would overwrite existing data)")
4343
return
4444
}
45+
do {
46+
let data = try encoder.encode(profiles)
47+
defaults.set(data, forKey: profilesKey)
48+
SyncChangeTracker.shared.markDirty(.sshProfile, ids: profiles.map { $0.id.uuidString })
49+
} catch {
50+
Self.logger.error("Failed to save SSH profiles: \(error)")
51+
}
52+
}
53+
54+
func saveProfilesWithoutSync(_ profiles: [SSHProfile]) {
55+
guard !lastLoadFailed else { return }
4556
do {
4657
let data = try encoder.encode(profiles)
4758
defaults.set(data, forKey: profilesKey)
@@ -67,6 +78,7 @@ final class SSHProfileStorage {
6778
}
6879

6980
func deleteProfile(_ profile: SSHProfile) {
81+
SyncChangeTracker.shared.markDeleted(.sshProfile, id: profile.id.uuidString)
7082
var profiles = loadProfiles()
7183
guard !lastLoadFailed else { return }
7284
profiles.removeAll { $0.id == profile.id }

TablePro/Core/Sync/SyncCoordinator.swift

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,17 @@ final class SyncCoordinator {
146146
changeTracker.markDirty(.tag, id: tag.id.uuidString)
147147
}
148148

149+
let sshProfiles = SSHProfileStorage.shared.loadProfiles()
150+
for profile in sshProfiles {
151+
changeTracker.markDirty(.sshProfile, id: profile.id.uuidString)
152+
}
153+
149154
// Mark all settings categories as dirty
150155
for category in ["general", "appearance", "editor", "dataGrid", "history", "tabs", "keyboard", "ai"] {
151156
changeTracker.markDirty(.settings, id: category)
152157
}
153158

154-
Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, 8 settings categories")
159+
Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, \(sshProfiles.count) SSH profiles, 8 settings categories")
155160
}
156161

157162
/// Called when user disables sync in settings
@@ -254,6 +259,11 @@ final class SyncCoordinator {
254259
collectDirtyTags(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID)
255260
}
256261

262+
// Collect dirty SSH profiles
263+
if settings.syncSSHProfiles {
264+
collectDirtySSHProfiles(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID)
265+
}
266+
257267
// Collect unsynced query history
258268
if settings.syncQueryHistory {
259269
let limit = settings.historySyncLimit.limit ?? Int.max
@@ -301,6 +311,9 @@ final class SyncCoordinator {
301311
changeTracker.clearAllDirty(.group)
302312
changeTracker.clearAllDirty(.tag)
303313
}
314+
if settings.syncSSHProfiles {
315+
changeTracker.clearAllDirty(.sshProfile)
316+
}
304317
if settings.syncSettings {
305318
changeTracker.clearAllDirty(.settings)
306319
}
@@ -322,6 +335,11 @@ final class SyncCoordinator {
322335
metadataStorage.removeTombstone(type: .tag, id: tombstone.id)
323336
}
324337
}
338+
if settings.syncSSHProfiles {
339+
for tombstone in metadataStorage.tombstones(for: .sshProfile) {
340+
metadataStorage.removeTombstone(type: .sshProfile, id: tombstone.id)
341+
}
342+
}
325343
if settings.syncSettings {
326344
for tombstone in metadataStorage.tombstones(for: .settings) {
327345
metadataStorage.removeTombstone(type: .settings, id: tombstone.id)
@@ -416,6 +434,8 @@ final class SyncCoordinator {
416434
case SyncRecordType.tag.rawValue where settings.syncGroupsAndTags:
417435
applyRemoteTag(record)
418436
groupsOrTagsChanged = true
437+
case SyncRecordType.sshProfile.rawValue where settings.syncSSHProfiles:
438+
applyRemoteSSHProfile(record)
419439
case SyncRecordType.settings.rawValue where settings.syncSettings:
420440
applyRemoteSettings(record)
421441
case SyncRecordType.queryHistory.rawValue where settings.syncQueryHistory:
@@ -494,6 +514,18 @@ final class SyncCoordinator {
494514
TagStorage.shared.saveTags(tags)
495515
}
496516

517+
private func applyRemoteSSHProfile(_ record: CKRecord) {
518+
guard let remoteProfile = SyncRecordMapper.toSSHProfile(record) else { return }
519+
520+
var profiles = SSHProfileStorage.shared.loadProfiles()
521+
if let index = profiles.firstIndex(where: { $0.id == remoteProfile.id }) {
522+
profiles[index] = remoteProfile
523+
} else {
524+
profiles.append(remoteProfile)
525+
}
526+
SSHProfileStorage.shared.saveProfilesWithoutSync(profiles)
527+
}
528+
497529
private func applyRemoteSettings(_ record: CKRecord) {
498530
guard let category = SyncRecordMapper.settingsCategory(from: record),
499531
let data = SyncRecordMapper.settingsData(from: record)
@@ -561,6 +593,15 @@ final class SyncCoordinator {
561593
TagStorage.shared.saveTags(tags)
562594
}
563595
}
596+
597+
if recordName.hasPrefix("SSHProfile_") {
598+
let uuidString = String(recordName.dropFirst("SSHProfile_".count))
599+
if let uuid = UUID(uuidString: uuidString) {
600+
var profiles = SSHProfileStorage.shared.loadProfiles()
601+
profiles.removeAll { $0.id == uuid }
602+
SSHProfileStorage.shared.saveProfilesWithoutSync(profiles)
603+
}
604+
}
564605
}
565606

566607
// MARK: - Observers
@@ -654,6 +695,7 @@ final class SyncCoordinator {
654695
case SyncRecordType.tag.rawValue: syncRecordType = .tag
655696
case SyncRecordType.settings.rawValue: syncRecordType = .settings
656697
case SyncRecordType.queryHistory.rawValue: syncRecordType = .queryHistory
698+
case SyncRecordType.sshProfile.rawValue: syncRecordType = .sshProfile
657699
default: continue
658700
}
659701

@@ -786,4 +828,26 @@ final class SyncCoordinator {
786828
)
787829
}
788830
}
831+
832+
private func collectDirtySSHProfiles(
833+
into records: inout [CKRecord],
834+
deletions: inout [CKRecord.ID],
835+
zoneID: CKRecordZone.ID
836+
) {
837+
let dirtyProfileIds = changeTracker.dirtyRecords(for: .sshProfile)
838+
if !dirtyProfileIds.isEmpty {
839+
let profiles = SSHProfileStorage.shared.loadProfiles()
840+
for id in dirtyProfileIds {
841+
if let profile = profiles.first(where: { $0.id.uuidString == id }) {
842+
records.append(SyncRecordMapper.toCKRecord(profile, in: zoneID))
843+
}
844+
}
845+
}
846+
847+
for tombstone in metadataStorage.tombstones(for: .sshProfile) {
848+
deletions.append(
849+
SyncRecordMapper.recordID(type: .sshProfile, id: tombstone.id, in: zoneID)
850+
)
851+
}
852+
}
789853
}

TablePro/Core/Sync/SyncRecordMapper.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ enum SyncRecordType: String, CaseIterable {
1818
case queryHistory = "QueryHistory"
1919
case favorite = "SQLFavorite"
2020
case favoriteFolder = "SQLFavoriteFolder"
21+
case sshProfile = "SSHProfile"
2122
}
2223

2324
/// Pure-function mapper between local models and CKRecord
@@ -44,6 +45,7 @@ struct SyncRecordMapper {
4445
case .queryHistory: recordName = "History_\(id)"
4546
case .favorite: recordName = "Favorite_\(id)"
4647
case .favoriteFolder: recordName = "FavoriteFolder_\(id)"
48+
case .sshProfile: recordName = "SSHProfile_\(id)"
4749
}
4850
return CKRecord.ID(recordName: recordName, zoneID: zone)
4951
}
@@ -311,4 +313,82 @@ struct SyncRecordMapper {
311313

312314
return record
313315
}
316+
317+
// MARK: - SSH Profile
318+
319+
static func toCKRecord(_ profile: SSHProfile, in zone: CKRecordZone.ID) -> CKRecord {
320+
let recordID = recordID(type: .sshProfile, id: profile.id.uuidString, in: zone)
321+
let record = CKRecord(recordType: SyncRecordType.sshProfile.rawValue, recordID: recordID)
322+
323+
record["profileId"] = profile.id.uuidString as CKRecordValue
324+
record["name"] = profile.name as CKRecordValue
325+
record["host"] = profile.host as CKRecordValue
326+
record["port"] = Int64(profile.port) as CKRecordValue
327+
record["username"] = profile.username as CKRecordValue
328+
record["authMethod"] = profile.authMethod.rawValue as CKRecordValue
329+
record["privateKeyPath"] = profile.privateKeyPath as CKRecordValue
330+
record["useSSHConfig"] = Int64(profile.useSSHConfig ? 1 : 0) as CKRecordValue
331+
record["agentSocketPath"] = profile.agentSocketPath as CKRecordValue
332+
record["totpMode"] = profile.totpMode.rawValue as CKRecordValue
333+
record["totpAlgorithm"] = profile.totpAlgorithm.rawValue as CKRecordValue
334+
record["totpDigits"] = Int64(profile.totpDigits) as CKRecordValue
335+
record["totpPeriod"] = Int64(profile.totpPeriod) as CKRecordValue
336+
record["modifiedAtLocal"] = Date() as CKRecordValue
337+
record["schemaVersion"] = schemaVersion as CKRecordValue
338+
339+
if !profile.jumpHosts.isEmpty {
340+
do {
341+
let jumpHostsData = try encoder.encode(profile.jumpHosts)
342+
record["jumpHostsJson"] = jumpHostsData as CKRecordValue
343+
} catch {
344+
logger.warning("Failed to encode jump hosts for sync: \(error.localizedDescription)")
345+
}
346+
}
347+
348+
return record
349+
}
350+
351+
static func toSSHProfile(_ record: CKRecord) -> SSHProfile? {
352+
guard let profileIdString = record["profileId"] as? String,
353+
let profileId = UUID(uuidString: profileIdString),
354+
let name = record["name"] as? String
355+
else {
356+
logger.warning("Failed to decode SSH profile from CKRecord: missing required fields")
357+
return nil
358+
}
359+
360+
let host = record["host"] as? String ?? ""
361+
let port = (record["port"] as? Int64).map { Int($0) } ?? 22
362+
let username = record["username"] as? String ?? ""
363+
let authMethodRaw = record["authMethod"] as? String ?? SSHAuthMethod.password.rawValue
364+
let privateKeyPath = record["privateKeyPath"] as? String ?? ""
365+
let useSSHConfig = (record["useSSHConfig"] as? Int64 ?? 1) != 0
366+
let agentSocketPath = record["agentSocketPath"] as? String ?? ""
367+
let totpModeRaw = record["totpMode"] as? String ?? TOTPMode.none.rawValue
368+
let totpAlgorithmRaw = record["totpAlgorithm"] as? String ?? TOTPAlgorithm.sha1.rawValue
369+
let totpDigits = (record["totpDigits"] as? Int64).map { Int($0) } ?? 6
370+
let totpPeriod = (record["totpPeriod"] as? Int64).map { Int($0) } ?? 30
371+
372+
var jumpHosts: [SSHJumpHost] = []
373+
if let jumpHostsData = record["jumpHostsJson"] as? Data {
374+
jumpHosts = (try? decoder.decode([SSHJumpHost].self, from: jumpHostsData)) ?? []
375+
}
376+
377+
return SSHProfile(
378+
id: profileId,
379+
name: name,
380+
host: host,
381+
port: port,
382+
username: username,
383+
authMethod: SSHAuthMethod(rawValue: authMethodRaw) ?? .password,
384+
privateKeyPath: privateKeyPath,
385+
useSSHConfig: useSSHConfig,
386+
agentSocketPath: agentSocketPath,
387+
jumpHosts: jumpHosts,
388+
totpMode: TOTPMode(rawValue: totpModeRaw) ?? .none,
389+
totpAlgorithm: TOTPAlgorithm(rawValue: totpAlgorithmRaw) ?? .sha1,
390+
totpDigits: totpDigits,
391+
totpPeriod: totpPeriod
392+
)
393+
}
314394
}

TablePro/Models/Settings/SyncSettings.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct SyncSettings: Codable, Equatable {
1616
var syncQueryHistory: Bool
1717
var historySyncLimit: HistorySyncLimit
1818
var syncPasswords: Bool
19+
var syncSSHProfiles: Bool
1920

2021
init(
2122
enabled: Bool,
@@ -24,7 +25,8 @@ struct SyncSettings: Codable, Equatable {
2425
syncSettings: Bool,
2526
syncQueryHistory: Bool,
2627
historySyncLimit: HistorySyncLimit,
27-
syncPasswords: Bool = false
28+
syncPasswords: Bool = false,
29+
syncSSHProfiles: Bool = true
2830
) {
2931
self.enabled = enabled
3032
self.syncConnections = syncConnections
@@ -33,6 +35,7 @@ struct SyncSettings: Codable, Equatable {
3335
self.syncQueryHistory = syncQueryHistory
3436
self.historySyncLimit = historySyncLimit
3537
self.syncPasswords = syncPasswords
38+
self.syncSSHProfiles = syncSSHProfiles
3639
}
3740

3841
init(from decoder: Decoder) throws {
@@ -44,6 +47,7 @@ struct SyncSettings: Codable, Equatable {
4447
syncQueryHistory = try container.decode(Bool.self, forKey: .syncQueryHistory)
4548
historySyncLimit = try container.decode(HistorySyncLimit.self, forKey: .historySyncLimit)
4649
syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false
50+
syncSSHProfiles = try container.decodeIfPresent(Bool.self, forKey: .syncSSHProfiles) ?? true
4751
}
4852

4953
static let `default` = SyncSettings(
@@ -53,7 +57,8 @@ struct SyncSettings: Codable, Equatable {
5357
syncSettings: true,
5458
syncQueryHistory: true,
5559
historySyncLimit: .entries500,
56-
syncPasswords: false
60+
syncPasswords: false,
61+
syncSSHProfiles: true
5762
)
5863
}
5964

TablePro/Views/Components/ConflictResolutionView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ struct ConflictResolutionView: View {
142142
if let name = record["name"] as? String {
143143
fieldRow(label: String(localized: "Name"), value: name)
144144
}
145+
case .sshProfile:
146+
if let name = record["name"] as? String {
147+
fieldRow(label: String(localized: "Name"), value: name)
148+
}
149+
if let host = record["host"] as? String {
150+
fieldRow(label: "Host", value: host)
151+
}
145152
}
146153
}
147154

TablePro/Views/Settings/SyncSettingsView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ struct SyncSettingsView: View {
131131
Toggle("Groups & Tags:", isOn: $syncSettings.syncGroupsAndTags)
132132
.onChange(of: syncSettings.syncGroupsAndTags) { _, _ in persistSettings() }
133133

134+
Toggle("SSH Profiles:", isOn: $syncSettings.syncSSHProfiles)
135+
.onChange(of: syncSettings.syncSSHProfiles) { _, _ in persistSettings() }
136+
134137
Toggle("Settings:", isOn: $syncSettings.syncSettings)
135138
.onChange(of: syncSettings.syncSettings) { _, _ in persistSettings() }
136139

docs/databases/ssh-tunneling.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ description: Route database connections through an encrypted SSH tunnel to reach
77

88
SSH tunneling routes your database connection through an encrypted tunnel to reach servers that aren't directly accessible from your Mac. TablePro manages the tunnel lifecycle, including keep-alive and auto-reconnect.
99

10+
<Tip>
11+
If you connect to multiple databases through the same SSH server, you can save your SSH configuration as a reusable profile. See [SSH Profiles](/features/ssh-profiles).
12+
</Tip>
13+
1014
## How SSH Tunneling Works
1115

1216
```mermaid

docs/docs.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@
6565
"features/keyboard-shortcuts",
6666
"features/deep-links",
6767
"features/safe-mode",
68-
"features/icloud-sync"
68+
"features/icloud-sync",
69+
"features/ssh-profiles"
6970
]
7071
},
7172
{
@@ -161,7 +162,8 @@
161162
"vi/features/keyboard-shortcuts",
162163
"vi/features/deep-links",
163164
"vi/features/safe-mode",
164-
"vi/features/icloud-sync"
165+
"vi/features/icloud-sync",
166+
"vi/features/ssh-profiles"
165167
]
166168
},
167169
{
@@ -262,7 +264,8 @@
262264
"zh/features/keyboard-shortcuts",
263265
"zh/features/deep-links",
264266
"zh/features/safe-mode",
265-
"zh/features/icloud-sync"
267+
"zh/features/icloud-sync",
268+
"zh/features/ssh-profiles"
266269
]
267270
},
268271
{

0 commit comments

Comments
 (0)