import Foundation /// Persists the Verbinden-Tab's "known routers" list (host/username/editable name/last-connected) /// as JSON in UserDefaults — small, structured data, no reason for its own file the way backups /// get a directory. Passwords themselves stay exactly where they already did: the macOS Keychain /// via `KeychainService`, keyed by "username@host", untouched by this store. struct SavedRoutersStore { private static let key = "RouterOSAssistant.SavedRouters" private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { self.defaults = defaults } /// Most recently used first. func load() -> [SavedRouter] { sortedByRecency(loadRaw()) } private func loadRaw() -> [SavedRouter] { guard let data = defaults.data(forKey: Self.key), let routers = try? JSONDecoder().decode([SavedRouter].self, from: data) else { return [] } return routers } private func sortedByRecency(_ routers: [SavedRouter]) -> [SavedRouter] { routers.sorted { $0.lastConnectedAt > $1.lastConnectedAt } } /// Persists in whatever order given, then returns the recency-sorted view — every public /// method's return value has the same "most recently used first" order as `load()`, so a /// caller can always assign it straight to a displayed list without a separate re-sort. @discardableResult private func save(_ routers: [SavedRouter]) -> [SavedRouter] { if let data = try? JSONEncoder().encode(routers) { defaults.set(data, forKey: Self.key) } return sortedByRecency(routers) } /// Called after a successful connection: adds a new entry (name defaulting to `defaultName`) /// if this host/username/serial combination isn't known yet, or just bumps `lastConnectedAt` /// on the existing one. An existing entry's `name` is never touched here — that's what /// preserves a user's own rename across later reconnects. /// /// `serialNumber` disambiguates two physically different routers that happen to share the /// same host/username (e.g. both left at MikroTik's factory default 192.168.88.1/admin) — /// without it, connecting to a second such device would silently rename the first one's /// existing entry in place instead of creating a new one, live-confirmed 2026-09-16. Matching /// order: (1) an entry with the exact same serial wins outright; (2) failing that, exactly one /// same-host/username entry with no recorded serial yet is treated as the same device seen for /// the first time since this field existed, and gets its serial filled in now; (3) otherwise /// this is a genuinely new device (or an ambiguous multi-match this app won't guess at) and /// gets its own new entry. @discardableResult func recordSuccessfulConnection(host: String, username: String, defaultName: String, serialNumber: String? = nil) -> [SavedRouter] { var routers = loadRaw() let candidateIndices = routers.indices.filter { routers[$0].host == host && routers[$0].username == username } if let serialNumber, let index = candidateIndices.first(where: { routers[$0].serialNumber == serialNumber }) { routers[index].lastConnectedAt = Date() return save(routers) } let unrecordedSerialIndices = candidateIndices.filter { routers[$0].serialNumber == nil } if unrecordedSerialIndices.count == 1, let index = unrecordedSerialIndices.first { routers[index].lastConnectedAt = Date() routers[index].serialNumber = serialNumber return save(routers) } routers.append(SavedRouter(host: host, username: username, name: defaultName, serialNumber: serialNumber)) return save(routers) } @discardableResult func rename(_ id: SavedRouter.ID, to newName: String) -> [SavedRouter] { var routers = loadRaw() guard let index = routers.firstIndex(where: { $0.id == id }) else { return sortedByRecency(routers) } routers[index].name = newName return save(routers) } /// Free-text location/purpose (e.g. "Keller" or "1. OG") — kept as its own method rather than /// folded into `rename` since a caller may want to update just one of the two, and it mirrors /// `rename`'s exact shape. @discardableResult func updateLocation(_ id: SavedRouter.ID, to newLocation: String) -> [SavedRouter] { var routers = loadRaw() guard let index = routers.firstIndex(where: { $0.id == id }) else { return sortedByRecency(routers) } routers[index].location = newLocation return save(routers) } @discardableResult func remove(_ id: SavedRouter.ID) -> [SavedRouter] { var routers = loadRaw() routers.removeAll { $0.id == id } return save(routers) } }