diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 6f35aaa20..2928a622a 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1423,6 +1423,24 @@ final class SyncService: ObservableObject { .first { !$0.isEmpty } } + private func profileHasTailnetRoute(_ profile: HostConnectionProfile) -> Bool { + if profile.tailscaleAddress != nil { return true } + if let last = profile.lastSuccessfulAddress, syncIsTailscaleRoute(last) { return true } + return profile.savedAddressCandidates.contains(where: syncIsTailscaleRoute) + } + + private func shouldPreferProfile(_ candidate: HostConnectionProfile, over existing: HostConnectionProfile) -> Bool { + if candidate.updatedAt != existing.updatedAt { + return candidate.updatedAt > existing.updatedAt + } + let candidateTailnet = profileHasTailnetRoute(candidate) + let existingTailnet = profileHasTailnetRoute(existing) + if candidateTailnet != existingTailnet { + return candidateTailnet + } + return candidate.lastSuccessfulAddress != nil && existing.lastSuccessfulAddress == nil + } + private func loadSavedProfilesRaw() -> [String: HostConnectionProfile] { guard let data = UserDefaults.standard.data(forKey: profilesKey) else { return [:] @@ -1434,6 +1452,9 @@ final class SyncService: ObservableObject { var migrated: [String: HostConnectionProfile] = [:] for profile in legacyArray { guard let key = profileStorageKey(profile) else { continue } + if let existing = migrated[key], !shouldPreferProfile(profile, over: existing) { + continue + } migrated[key] = profile } syncConnectLog.warning("Migrated \(legacyArray.count, privacy: .public) legacy array-format host profiles to dict format (\(migrated.count, privacy: .public) keyed)") @@ -1453,15 +1474,59 @@ final class SyncService: ObservableObject { } private func saveSavedProfiles(_ profiles: [String: HostConnectionProfile]) { - if profiles.isEmpty { + let normalized = deduplicatedProfiles(profiles) + if normalized.isEmpty { UserDefaults.standard.removeObject(forKey: profilesKey) return } - if let data = try? encoder.encode(profiles) { + if let data = try? encoder.encode(normalized) { UserDefaults.standard.set(data, forKey: profilesKey) } } + private func profileIdentityKeys(_ profile: HostConnectionProfile) -> [String] { + var keys: [String] = [] + if let id = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty { + keys.append("identity:\(id)") + } + if let device = profile.lastHostDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !device.isEmpty { + keys.append("device:\(device)") + } + if let name = profile.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + keys.append("name:\(name.lowercased()):\(profile.port)") + } + if let last = profile.lastSuccessfulAddress?.trimmingCharacters(in: .whitespacesAndNewlines), !last.isEmpty { + keys.append("addr:\(last):\(profile.port)") + } + return keys + } + + private func deduplicatedProfiles(_ profiles: [String: HostConnectionProfile]) -> [String: HostConnectionProfile] { + var canonicalProfiles: [HostConnectionProfile] = [] + var keyToIndex: [String: Int] = [:] + for profile in profiles.values { + let identityKeys = profileIdentityKeys(profile) + let matchedIndex = identityKeys.lazy.compactMap { keyToIndex[$0] }.first + if let index = matchedIndex { + if shouldPreferProfile(profile, over: canonicalProfiles[index]) { + canonicalProfiles[index] = profile + } + for key in identityKeys { keyToIndex[key] = index } + } else { + let index = canonicalProfiles.count + canonicalProfiles.append(profile) + for key in identityKeys { keyToIndex[key] = index } + } + } + var byKey: [String: HostConnectionProfile] = [:] + byKey.reserveCapacity(canonicalProfiles.count) + for profile in canonicalProfiles { + guard let key = profileStorageKey(profile) else { continue } + byKey[key] = profile + } + return byKey + } + private func migrateTokenIfNeeded(for profile: HostConnectionProfile) { guard let key = profileStorageKey(profile), keychain.loadToken(hostKey: key) == nil, @@ -1639,37 +1704,6 @@ final class SyncService: ObservableObject { lastError = nil } - func reconnectToSavedHost(_ host: DiscoveredSyncHost, preferTailnet: Bool = false) async { - guard let profile = profile(forSavedHost: host), let token = tokenForProfile(profile) else { - lastError = "That saved host is missing pairing credentials. Pair it again from Settings." - connectionState = .error - return - } - keychain.saveToken(token) - saveProfile(profile) - await reconnectIfPossible(userInitiated: true, preferTailnet: preferTailnet || host.tailscaleAddress != nil) - } - - private func profile(forSavedHost host: DiscoveredSyncHost) -> HostConnectionProfile? { - let normalizedHostId = host.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let hostAddresses = Set((host.addresses + (host.tailscaleAddress.map { [$0] } ?? [])).map(syncNormalizedRouteHost)) - return loadSavedProfiles().values.first { profile in - if let normalizedHostId, - let profileIdentity = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - profileIdentity == normalizedHostId { - return true - } - let profileAddresses = Set( - (profile.savedAddressCandidates - + profile.discoveredLanAddresses - + (profile.lastSuccessfulAddress.map { [$0] } ?? []) - + (profile.tailscaleAddress.map { [$0] } ?? [])) - .map(syncNormalizedRouteHost) - ) - return !profileAddresses.isDisjoint(with: hostAddresses) - } - } - private func shouldPreferTailnetForUserReconnect(_ profile: HostConnectionProfile) -> Bool { guard let snapshot = lastNetworkPathSnapshot else { return false } return syncShouldRoamToTailnet(