From c15d78bcdb8ff1f88e81f44d5bdda9a9a3c8e842 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:34:32 -0400 Subject: [PATCH 1/2] fix(ios): restore profile dedup logic + remove dead reconnect path PR #209's rebase resolution kept the dict-based saved-profile API but dropped the recency-aware dedup that pre-existed on main. Restore it: - shouldPreferProfile picks the newer/Tailscale-preferring/has-last- successful-address profile when two map to the same storage key. - loadSavedProfilesRaw uses it during legacy [HostConnectionProfile] array migration so collisions don't fall to last-write-wins. - saveSavedProfiles runs a paranoia dedup pass before encoding. - reconnectToSavedHost (and its only helper profile(forSavedHost:)) removed; both were unreachable post-rebase, flagged by Greptile. --- apps/ios/ADE/Services/SyncService.swift | 62 ++++++++++++------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 6f35aaa20..f5289c385 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1423,6 +1423,16 @@ final class SyncService: ObservableObject { .first { !$0.isEmpty } } + private func shouldPreferProfile(_ candidate: HostConnectionProfile, over existing: HostConnectionProfile) -> Bool { + if candidate.updatedAt != existing.updatedAt { + return candidate.updatedAt > existing.updatedAt + } + if candidate.tailscaleAddress != nil && existing.tailscaleAddress == nil { + return true + } + return candidate.lastSuccessfulAddress != nil && existing.lastSuccessfulAddress == nil + } + private func loadSavedProfilesRaw() -> [String: HostConnectionProfile] { guard let data = UserDefaults.standard.data(forKey: profilesKey) else { return [:] @@ -1434,6 +1444,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 +1466,29 @@ 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 deduplicatedProfiles(_ profiles: [String: HostConnectionProfile]) -> [String: HostConnectionProfile] { + var byKey: [String: HostConnectionProfile] = [:] + byKey.reserveCapacity(profiles.count) + for profile in profiles.values { + guard let key = profileStorageKey(profile) else { continue } + if let existing = byKey[key], !shouldPreferProfile(profile, over: existing) { + continue + } + byKey[key] = profile + } + return byKey + } + private func migrateTokenIfNeeded(for profile: HostConnectionProfile) { guard let key = profileStorageKey(profile), keychain.loadToken(hostKey: key) == nil, @@ -1639,37 +1666,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( From 6eabc94d629e81499cf57106eaddca88db8dcd2f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:44:44 -0400 Subject: [PATCH 2/2] fix(ios): broader tailnet detection + cross-key profile dedup Addresses two CodeRabbit findings on #210: - profileHasTailnetRoute checks tailscaleAddress, lastSuccessfulAddress (via syncIsTailscaleRoute), and savedAddressCandidates so the shouldPreferProfile tie-break treats tailnet routes encoded in any field as equivalent to a populated tailscaleAddress. - deduplicatedProfiles now matches on hostIdentity, lastHostDeviceId, hostName:port, and lastSuccessfulAddress:port simultaneously, so profiles whose storage key evolved (e.g., older entry written before hostIdentity was learned) collapse with the newer stronger-keyed entry instead of leaving both in savedReconnectHosts. --- apps/ios/ADE/Services/SyncService.swift | 52 +++++++++++++++++++++---- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index f5289c385..2928a622a 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1423,12 +1423,20 @@ 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 } - if candidate.tailscaleAddress != nil && existing.tailscaleAddress == nil { - return true + let candidateTailnet = profileHasTailnetRoute(candidate) + let existingTailnet = profileHasTailnetRoute(existing) + if candidateTailnet != existingTailnet { + return candidateTailnet } return candidate.lastSuccessfulAddress != nil && existing.lastSuccessfulAddress == nil } @@ -1476,14 +1484,44 @@ final class SyncService: ObservableObject { } } + 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 byKey: [String: HostConnectionProfile] = [:] - byKey.reserveCapacity(profiles.count) + var canonicalProfiles: [HostConnectionProfile] = [] + var keyToIndex: [String: Int] = [:] for profile in profiles.values { - guard let key = profileStorageKey(profile) else { continue } - if let existing = byKey[key], !shouldPreferProfile(profile, over: existing) { - continue + 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