Files
RouterOS/RouterOSAssistant/Core/Services/SavedRoutersStore.swift
T
KayandClaude Sonnet 5 9de9311bee M26+M27: Seriennummer bei Bekannte Router, Schlüsselbund-Trennung, terse-Fallback verallgemeinert
M26: SavedRouter bekommt ein optionales serialNumber-Feld. Zwei
physisch verschiedene Router mit identischem Host+Benutzername
(Werks-Adresse) blieben bisher ein gemeinsamer "Bekannte Router"-
Eintrag; recordSuccessfulConnection matcht jetzt zusätzlich nach
Seriennummer, mit sauberer Migration bestehender Einträge ohne
Seriennummer. Drei neue Tests.

M27, beim Live-Test von M26 gefunden:
- Passwort-Anzeige-Button (Augen-Symbol) im Verbinden-Tab
- Bug 35: KeychainService speicherte Passwörter nur nach
  "username@host" — beide Router teilten sich denselben
  Schlüsselbund-Eintrag trotz getrennter SavedRouter-Einträge. Fix:
  optionaler serialNumber-Parameter qualifiziert den Account-Key,
  mit Fallback auf den alten Key für bereits gespeicherte Passwörter.
- Bug 36: neuer Router zeigte keine Routerboard-Infos/Seriennummer —
  derselbe Root Cause wie der zuvor gemeldete "Jetzt prüfen"-Fehler:
  /system routerboard und /system package update scheitern auf diesem
  Router mit "expected end of command" statt dem bisher einzig
  abgefangenen "bad parameter terse". SSHTransport.fetchMenuItems
  erkennt jetzt beide Formulierungen.

Alles live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:02:27 +02:00

100 lines
4.8 KiB
Swift

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)
}
}