forked from kay/RouterOS
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>
100 lines
4.8 KiB
Swift
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)
|
|
}
|
|
}
|