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 apple/CloudKit/production-schema.ckdb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ DEFINE SCHEMA
sshConfigJson BYTES QUERYABLE SORTABLE,
sshEnabled INT64 QUERYABLE SORTABLE,
sshProfileId STRING QUERYABLE SEARCHABLE SORTABLE,
sshRouteJson BYTES QUERYABLE SORTABLE,
sslConfigJson BYTES QUERYABLE SORTABLE,
sslEnabled INT64 QUERYABLE SORTABLE,
startupCommands STRING QUERYABLE SEARCHABLE SORTABLE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,31 @@ public struct PublishedSSHRoute: Codable, Hashable, Sendable {
version == Self.currentVersion && basis == Self.basis(of: input)
}

// Aliases, and the hop ids derived from them, are each Mac's own names for the hosts a device dials.
public func leadsToSameHosts(as other: PublishedSSHRoute) -> Bool {
version == other.version && basis == other.basis && hops == other.hops && target == other.target
version == other.version && basis == other.basis
&& hops.map(Endpoint.init) == other.hops.map(Endpoint.init)
&& Endpoint(target) == Endpoint(other.target)
}

private struct Endpoint: Equatable {
let host: String
let port: Int
let username: String

init(_ hop: Hop) {
self.init(host: hop.host, port: hop.port, username: hop.username)
}

init(_ target: Target) {
self.init(host: target.host, port: target.port, username: target.username)
}

init(host: String, port: Int, username: String) {
self.host = host
self.port = port
self.username = username
}
}

// True when dialing the input as typed reaches the same hosts, so there is nothing to publish.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ public enum SyncRecordMapper {
connection.connectTimeoutSeconds = validConnectTimeout(legacyConnectTimeout)
connection.adoptConfigurationSwitches()
connection.sshProfileId = (fields[.sshProfileId] as? String).flatMap { UUID(uuidString: $0) }
// Only the Mac writes the route, from its ~/.ssh/config.
connection.sshRoute = (fields[.sshRouteJson] as? Data).flatMap(PublishedSSHRoute.decoded(from:))
return connection
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public enum ConnectionSyncField: String, SyncSchemaField {
case redisDatabase
case startupCommands
case sshProfileId
case sshRouteJson
case isFavorite
case modifiedAtLocal
case schemaVersion
Expand All @@ -39,6 +40,12 @@ public enum ConnectionSyncField: String, SyncSchemaField {
.isReadOnly, .sshEnabled, .sslEnabled, .queryTimeoutSeconds,
.sshConfigJson, .sslConfigJson, .additionalFieldsJson,
.aiPolicy, .aiRules, .aiAlwaysAllowedTools, .redisDatabase, .startupCommands,
.sshProfileId, .isFavorite, .modifiedAtLocal, .schemaVersion
.sshProfileId, .sshRouteJson, .isFavorite, .modifiedAtLocal, .schemaVersion
]
}

public extension ConnectionSyncField {
// In each app's readable content, so the first pull on this build fetches everything once and
// reads the routes an older build cached without reading them.
static let sshRouteContentKind = "\(SyncRecordType.connection.rawValue)/\(Self.sshRouteJson.rawValue).v1"
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,16 @@ struct PublishedSSHRouteTests {
second.target.host = "10.0.0.6"
#expect(!first.leadsToSameHosts(as: second))
}

@Test("Two Macs naming the same jump host differently reach the same hosts")
func aliasesDoNotMatter() {
let first = Self.route()
var second = Self.route()
second.hops[0].alias = "jump-host"
second.hops[0].id = PublishedSSHRoute.derivedHopId(alias: "jump-host", occurrence: 0)
second.target.alias = "database"
#expect(first.leadsToSameHosts(as: second))
second.hops[0].port = 2_222
#expect(!first.leadsToSameHosts(as: second))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct SyncFieldParityTests {
]

// Derived on the Mac from a file only the Mac has, so only the Mac writes it; both apps read it.
private static let connectionMacPublished: Set<String> = []
private static let connectionMacPublished: Set<String> = ["sshRouteJson"]

private static let connectionIosOnly: Set<String> = [
"sshEnabled", "sslEnabled",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,55 @@ struct SyncRecordMapperSSHRouteTests {
#expect(pulled.sshProfileId == profileId)
}

private static let routeKey = ConnectionSyncField.sshRouteJson.key

@Test("A pulled record brings the route a Mac dialed, and the tunnel dials it")
func pullReadsRoute() throws {
let connection = tunnelled()
let route = try route(for: connection)
let record = SyncRecordMapper.toRecord(connection, zoneID: zoneID)
record[Self.routeKey] = try #require(route.encoded()) as CKRecordValue

let pulled = try #require(SyncRecordMapper.toConnection(record))

#expect(pulled.sshRoute == route)
#expect(pulled.sshConfigurationForDialing?.host == "10.0.0.5")
#expect(pulled.sshConfigurationForDialing?.jumpHosts.map { $0.host } == ["b.example"])
}

@Test("A route this build cannot read is ignored and the connection still arrives")
func unreadableRouteIsIgnored() throws {
let record = SyncRecordMapper.toRecord(tunnelled(), zoneID: zoneID)
record[Self.routeKey] = Data(#"{"version":2,"legs":[]}"#.utf8) as CKRecordValue

let pulled = try #require(SyncRecordMapper.toConnection(record))

#expect(pulled.sshRoute == nil)
#expect(pulled.sshConfigurationForDialing?.host == "db")
}

@Test("This device never writes the route")
func iosNeverWritesRoute() throws {
var connection = tunnelled()
connection.sshRoute = try route(for: connection)

let fresh = SyncRecordMapper.toRecord(connection, zoneID: zoneID)
#expect(fresh["sshRouteJson"] == nil)
#expect(!fresh.changedKeys().contains("sshRouteJson"))
#expect(fresh[Self.routeKey] == nil)
#expect(!fresh.changedKeys().contains(Self.routeKey))
}

@Test("An edit here leaves the Mac's route on the record")
func editKeepsTheMacRoute() throws {
let base = SyncRecordMapper.toRecord(tunnelled(), zoneID: zoneID)
let routeData = try #require(try route(for: tunnelled()).encoded())
base[Self.routeKey] = routeData as CKRecordValue
var edited = try #require(SyncRecordMapper.toConnection(base))
edited.name = "Renamed"
edited.sshRoute = nil

SyncRecordMapper.updateRecord(base, with: edited)

#expect(base[Self.routeKey] as? Data == routeData)
}

@Test("A record built from scratch keeps the SSH profile link this copy knows of")
Expand Down
6 changes: 4 additions & 2 deletions apple/TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ final class ConnectionStorage {
return true
}

/// A replace keeps the stored row's device-local state, except a credential profile the import
/// created and a tunnel command the user agreed to import. A replace whose row is gone is not written.
// A replace keeps the stored row's device-local state, except a credential profile the import
// created and a tunnel command the user agreed to import. A replace whose row is gone is not written.
func applyImport(adding: [DatabaseConnection], replacing: [DatabaseConnection]) -> ConnectionImportWrite? {
var connections = loadConnections()
guard !lastLoadFailed else {
Expand All @@ -185,6 +185,8 @@ final class ConnectionStorage {
if incoming.isTunnelCommandEnabled {
updated.tunnelCommandMode = incoming.tunnelCommandMode
}
// A file carries no route, and readers drop the stored one once it no longer describes the tunnel.
updated.sshRoute = stored.sshRoute
updated.sortOrder = stored.groupId == updated.groupId
? stored.sortOrder
: Self.nextSortOrder(in: connections, groupId: updated.groupId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,20 @@ extension SyncCoordinator {
if boundary.includes(.connection) {
let storage = services.connectionStorage
let profiles = services.sshProfileStorage
let routePublisher = storage.sshRoutePublisherId
await collectRecords(of: .connection, snapshot: snapshot, boundary: boundary, into: &batch, zoneID: zoneID) {
let connections = storage.loadConnections()
return storage.lastLoadFailed ? nil : connections
} disposition: { (connection: DatabaseConnection) in
guard connection.participatesInSync else { return .drop }
let recordID = SyncRecordMapper.recordID(type: .connection, id: connection.id.uuidString, in: zoneID)
return .push(SyncRecordMapper.toCKRecord(
Self.syncedTunnel(of: connection, profiles: profiles),
in: zoneID,
base: recordCache.record(for: recordID)
))
let base = recordCache.record(for: recordID)
let synced = SyncRecordMapper.keepingForeignRoute(
of: Self.syncedTunnel(of: connection, profiles: profiles),
in: base,
publisher: routePublisher
)
return .push(SyncRecordMapper.toCKRecord(synced, in: zoneID, base: base))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,16 @@ extension SyncCoordinator {
static let formatterContentKind = "\(SyncRecordType.settings.rawValue)/\(AppSettingsCategory.formatter).v1"

static var readableContent: Set<String> {
Set(SyncRecordType.verifiedInProduction.map(\.rawValue)).union([Self.themeContentKind, Self.formatterContentKind])
Set(SyncRecordType.verifiedInProduction.map(\.rawValue))
.union([Self.themeContentKind, Self.formatterContentKind, ConnectionSyncField.sshRouteContentKind])
}

// Only what this Mac applies: a pull skips a category that is off and still moves the token past it.
static func readableContent(for settings: SyncSettings) -> Set<String> {
var content = Set(SyncRecordType.verifiedInProduction.filter { settings.syncs($0) }.map(\.rawValue))
if settings.syncs(.connection) {
content.insert(ConnectionSyncField.sshRouteContentKind)
}
if settings.syncs(.settings) {
content.insert(Self.themeContentKind)
content.insert(Self.formatterContentKind)
Expand Down
12 changes: 10 additions & 2 deletions apple/TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -907,12 +907,20 @@ final class SyncCoordinator: ObservableObject {
@discardableResult
private func mergeLocalEdits(into remoteRecord: CKRecord, localConnection: DatabaseConnection) -> DatabaseConnection? {
guard let base = recordCache.record(for: remoteRecord.recordID) else { return nil }
let zoneID = remoteRecord.recordID.zoneID

let localRecord = SyncRecordMapper.toCKRecord(localConnection, in: remoteRecord.recordID.zoneID)
let local = SyncRecordMapper.keepingForeignRoute(
of: localConnection,
in: base,
publisher: services.connectionStorage.sshRoutePublisherId
)
let localRecord = SyncRecordMapper.toCKRecord(local, in: zoneID)
// Written again the way this Mac writes, so iOS's spelling of a value this Mac never changed is not an edit.
let baseAsWritten = (try? SyncRecordMapper.toConnection(base)).map { SyncRecordMapper.toCKRecord($0, in: zoneID) } ?? base
guard let merged = remoteRecord.copy() as? CKRecord else { return nil }

let localFields = localRecord.fields(ConnectionSyncField.self)
let baseFields = base.fields(ConnectionSyncField.self)
let baseFields = baseAsWritten.fields(ConnectionSyncField.self)
let mergedFields = merged.fields(ConnectionSyncField.self)
for field in ConnectionSyncField.allCases where field != .modifiedAtLocal {
guard !CKRecord.isEqualRecordValue(localFields[field], baseFields[field]) else { continue }
Expand Down
50 changes: 37 additions & 13 deletions apple/TablePro/Core/Sync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ enum SyncDecodeError: Error, LocalizedError {
/// Pure-function mapper between local models and CKRecord
struct SyncRecordMapper {
private static let logger = Logger(subsystem: "com.TablePro", category: "SyncRecordMapper")
private static let encoder = JSONEncoder()
// Unsorted keys come out in another order on each encode, and a merge compares fields by their bytes.
private static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
return encoder
}()
private static let decoder = JSONDecoder()

/// Current schema version stamped on every record
Expand Down Expand Up @@ -85,11 +90,8 @@ struct SyncRecordMapper {

// MARK: - Connection

/// Writes the optional fields whether or not they hold anything, for the reason
/// `toCKRecord(sqlFavorite:in:)` gives: a connection moved out of its group, stripped of its
/// tags or of its AI rules pushed a record that said nothing about them, and the next pull put
/// them back. On the server's own record a nil names the key only where the server still has a
/// value; a record built fresh has nothing to compare against, so it names every empty key.
// Optional fields are written even when empty: under `.changedKeys` a key the record never names
// keeps the server's value, so an emptied group, tag or AI rule came back on the next pull.
static func toCKRecord(
_ connection: DatabaseConnection,
in zone: CKRecordZone.ID,
Expand All @@ -108,10 +110,7 @@ struct SyncRecordMapper {
fields[.color] = connection.color.rawValue
fields[.iconName] = LibrarySymbolCatalog.normalizedName(connection.iconName)
fields[.safeModeLevel] = connection.preferredSafeModeLevel.rawValue
/// `safeModeLevel` superseded `isReadOnly`, but both are still on the wire and this mapper
/// still reads the old one when the new one is absent. Writing only the new one left the
/// old one holding whatever it last held, so a connection taken out of read-only on a Mac
/// stayed read-only for anything reading the legacy field.
// Older readers still read `isReadOnly` when `safeModeLevel` is absent.
fields[.isReadOnly] = Int64(connection.preferredSafeModeLevel == .readOnly ? 1 : 0)
fields[.modifiedAtLocal] = Date()
fields[.schemaVersion] = schemaVersion
Expand All @@ -121,7 +120,7 @@ struct SyncRecordMapper {

let tagIdStrings = connection.tagIds.map(\.uuidString)
fields[.tagIds] = tagIdStrings.isEmpty ? nil : tagIdStrings
/// Cleared with `tagIds`, because a record with no `tagIds` is read through this one.
// Cleared with `tagIds`, because a record with no `tagIds` is read through this one.
fields[.tagId] = tagIdStrings.first
fields[.groupId] = connection.groupId?.uuidString
fields[.aiPolicy] = connection.aiPolicy?.rawValue ?? unrecognizedAIPolicy(in: fields)
Expand All @@ -132,6 +131,11 @@ struct SyncRecordMapper {
fields[.redisDatabase] = connection.redisDatabase.map { Int64($0) }
fields[.startupCommands] = connection.startupCommands.flatMap { $0.isEmpty ? nil : $0 }
fields[.sshProfileId] = connection.sshProfileId?.uuidString
// Only a change is written: a route another build wrote stays, and a record built without the
// server's copy names no route, so a push after the cache was dropped cannot clear another Mac's.
if connection.sshRoute != Self.sshRoute(in: fields) {
fields[.sshRouteJson] = connection.sshRoute?.encoded()
}

// Encode complex structs as JSON Data — contract device-local paths
// to portable ~/… form so they resolve correctly on other devices.
Expand Down Expand Up @@ -189,8 +193,7 @@ struct SyncRecordMapper {
let port = (fields[.port] as? Int64).map { Int($0) } ?? 0
let database = fields[.database] as? String ?? ""
let username = fields[.username] as? String ?? ""
/// `colorTag` is where iOS wrote a connection's colour as hex before the two platforms
/// converged on this enum, so a colour set on an iPhone shows up here rather than nowhere.
// Older iOS builds wrote the colour as hex to `colorTag`.
let colorRaw = fields[.color] as? String
?? fields[.colorTag] as? String
?? ConnectionColor.none.rawValue
Expand Down Expand Up @@ -283,9 +286,30 @@ struct SyncRecordMapper {
if let queryTimeoutSeconds = validQueryTimeout(queryTimeoutSeconds ?? legacyQueryTimeout) {
connection.queryTimeoutSeconds = queryTimeoutSeconds
}
connection.sshRoute = sshRoute(in: fields)
return connection
}

private static func sshRoute(in fields: SyncRecordFields<ConnectionSyncField>) -> PublishedSSHRoute? {
(fields[.sshRouteJson] as? Data).flatMap(PublishedSSHRoute.decoded(from:))
}

// Only the Mac that published a route clears it, so a copy cached before this build read the
// field does not wipe the route another Mac published.
static func keepingForeignRoute(
of connection: DatabaseConnection,
in base: CKRecord?,
publisher: UUID
) -> DatabaseConnection {
guard connection.sshRoute == nil,
let route = base.flatMap({ sshRoute(in: $0.fields(ConnectionSyncField.self)) }),
route.publisher != publisher
else { return connection }
var kept = connection
kept.sshRoute = route
return kept
}

/// A policy a newer release added reads as nil on this Mac. Writing that nil back would clear
/// the other device's choice the next time this Mac saves any other change to the connection.
private static func unrecognizedAIPolicy(in fields: SyncRecordFields<ConnectionSyncField>) -> String? {
Expand Down
2 changes: 0 additions & 2 deletions apple/TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -505,8 +505,6 @@ internal extension DatabaseConnection {
adopted.cloudSQLProxyMode = local.cloudSQLProxyMode
adopted.socksProxyMode = local.socksProxyMode
adopted.tunnelCommandMode = local.tunnelCommandMode
// The Connection record has no route field yet, so the route this Mac dialed stays on it.
adopted.sshRoute = local.sshRoute
return adopted
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ nonisolated enum ConnectionLibraryEditing {
copy.id = newId
copy.name = name
copy.isFavorite = false
// Only a Mac writes the route, so the copy's record never carries it and the next pull would drop it.
copy.sshRoute = nil

let groupId = effectiveGroupId(of: source, validGroupIds: validGroupIds)
let siblings = LibrarySorting.sorted(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ struct IOSImportLibraryStore: ImportLibraryStore {
from: stored.sshConfiguration,
into: updated.sshConfiguration
)
// A file carries no route, and the stored one is dialed only while it still describes the tunnel.
updated.sshRoute = stored.sshRoute
// A Mac SSH profile's password goes to whatever server this connection's tunnel names, so the link
// survives only when the file names the same one.
if stored.sshProfileId != nil {
Expand Down
Loading
Loading