Files
RouterOS/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift
T
KayandClaude Sonnet 5 221b914b05 M29: App-Einstellungen (Settings-Fenster ⌘,)
Neues natives macOS-Settings-Fenster mit Sprache, Auto-Update-Check,
Farbschema (Standard/Kontrastreich), echter Textgrößen-Skalierung,
Bedienelement-Größe und LAN-Scanner-Refreshraten/Sparkline-Einstellungen.

- AppPreferences.swift: zentrale AppStorage-Keys, ColorTheme/AppTextSize/
  UIDensity
- SettingsView.swift: 3-Tab-Settings-Scene
- .environment(\.dynamicTypeSize) erwies sich auf macOS als wirkungslos
  (per ImageRenderer-Snapshot bewiesen) — durch eigenen appFontScale-
  Mechanismus ersetzt, ~110 .font(...)-Aufrufe app-weit umgestellt
- Farbschema auf Wunsch auch auf Experte-Tab-Sidebar ausgeweitet
- Docs aktualisiert: README/HANDOFF/CHATLOG/Manual.md+PDF, found.md

Live bestätigt nach mehreren Nachbesserungsrunden ("passt, lassen wir so").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:38:22 +02:00

209 lines
8.6 KiB
Swift

import Foundation
@MainActor
final class ConnectViewModel: ObservableObject {
@Published var host: String = "192.168.88.1"
@Published var username: String = "admin"
@Published var password: String = ""
@Published var rememberPassword: Bool = true
@Published private(set) var savedRouters: [SavedRouter] = []
@Published private(set) var packageUpdateInfo: PackageUpdateInfo?
@Published private(set) var isCheckingForUpdates = false
@Published var updateCheckError: String?
@Published private(set) var isInstallingUpdate = false
@Published var installUpdateError: String?
@Published private(set) var didSendUpdateInstall = false
@Published private(set) var isUpgradingFirmware = false
@Published var firmwareUpgradeError: String?
@Published var firmwareUpgradeResultMessage: String?
@Published private(set) var isRebooting = false
@Published var rebootError: String?
@Published private(set) var didSendReboot = false
@Published private(set) var interfaceTraffic: [String: InterfaceTraffic] = [:]
let connectionService: ConnectionService
private let keychain = KeychainService()
private let updateService = UpdateService()
private let savedRoutersStore = SavedRoutersStore()
private let trafficMonitor = InterfaceTrafficMonitor()
private var trafficPollingTask: Task<Void, Never>?
init(connectionService: ConnectionService) {
self.connectionService = connectionService
}
func onAppear() {
savedRouters = savedRoutersStore.load()
if let saved = keychain.loadPassword(forHost: host, username: username) {
password = saved
}
}
/// Fills in a known router's host/username (and its remembered password, if any) without
/// connecting yet — the user still reviews/confirms via the normal "Verbinden" button.
func selectSavedRouter(_ router: SavedRouter) {
host = router.host
username = router.username
password = keychain.loadPassword(forHost: router.host, username: router.username, serialNumber: router.serialNumber) ?? ""
}
func renameSavedRouter(_ id: SavedRouter.ID, to newName: String) {
savedRouters = savedRoutersStore.rename(id, to: newName)
}
func updateSavedRouterLocation(_ id: SavedRouter.ID, to newLocation: String) {
savedRouters = savedRoutersStore.updateLocation(id, to: newLocation)
}
func removeSavedRouter(_ id: SavedRouter.ID) {
savedRouters = savedRoutersStore.remove(id)
}
func connect() {
let credentials = RouterOSCredentials(host: host, username: username, password: password)
Task {
await connectionService.connect(with: credentials)
if case .connected = connectionService.state {
let defaultName = connectionService.deviceInfo?.boardName ?? host
// "unbekannt" is RouterOSModels' own fallback when RouterOS reports the
// routerboard menu but leaves this one field out — treated as "no real serial"
// here too, or two such devices would falsely look like the same one.
let rawSerial = connectionService.routerBoardInfo?.serialNumber
let serialNumber = (rawSerial?.isEmpty == false && rawSerial != "unbekannt") ? rawSerial : nil
// Saved only now, not before attempting the connection — the serial (needed to
// keep two same-host/username devices' passwords apart, see `KeychainService`'s
// doc comment) isn't known until the connection actually succeeds. Confirmed
// live (2026-09-16): saving unqualified beforehand made both of two same-model
// routers, left at MikroTik's factory default, silently share one Keychain
// entry — reconnecting to either one always loaded whichever password was saved
// most recently, regardless of which device the user actually selected.
if rememberPassword {
keychain.save(password: password, forHost: host, username: username, serialNumber: serialNumber)
}
savedRouters = savedRoutersStore.recordSuccessfulConnection(
host: host, username: username, defaultName: defaultName, serialNumber: serialNumber
)
// Settings toggle (default off) — reuses the same manual "Nach Updates suchen"
// path the button in this tab triggers, just fired automatically once a
// connection succeeds instead of waiting for a click.
if UserDefaults.standard.bool(forKey: AppPreferences.autoCheckUpdatesOnConnectKey) {
checkForUpdates(for: credentials)
}
}
}
}
/// Starts (or restarts, if already running) periodic traffic polling for whatever interfaces
/// `connectionService.interfaces` currently lists — re-read every tick so newly-appearing
/// interfaces (e.g. a VLAN added via the Setup wizard) get picked up without restarting the
/// poll. 3s cadence: fast enough to feel "live" for a status dot, not so fast it noticeably
/// loads a home router's CPU with a constant stream of SSH round-trips.
func startTrafficPolling(credentials: RouterOSCredentials) {
stopTrafficPolling()
trafficPollingTask = Task {
while !Task.isCancelled {
let names = connectionService.interfaces.map(\.name)
if !names.isEmpty {
let traffic = await trafficMonitor.fetchTraffic(interfaceNames: names, for: credentials)
if !Task.isCancelled {
interfaceTraffic = traffic
}
}
try? await Task.sleep(for: .seconds(3))
}
}
}
func stopTrafficPolling() {
trafficPollingTask?.cancel()
trafficPollingTask = nil
interfaceTraffic = [:]
Task { await trafficMonitor.disconnect() }
}
func trustAndRetry(fingerprint: String) {
Task {
await connectionService.trustCurrentCertificateAndRetry(fingerprint: fingerprint)
}
}
func trustSSHHostKeyAndRetry(fingerprint: String) {
// Two distinct sources share this one confirmation alert (see `ConnectionService.
// pendingSSHTrustFingerprint`'s doc comment): a blocked initial SSH-fallback connect
// needs a full reconnect, a REST connection's background trust probe doesn't.
if case .needsSSHHostKeyConfirmation = connectionService.state {
Task {
await connectionService.trustCurrentSSHHostKeyAndRetry(fingerprint: fingerprint)
}
} else {
connectionService.trustPendingSSHHostKeyAndRetry()
}
}
func dismissSSHHostKeyPrompt() {
connectionService.dismissPendingSSHTrust()
}
func checkForUpdates(for credentials: RouterOSCredentials) {
isCheckingForUpdates = true
updateCheckError = nil
Task {
do {
packageUpdateInfo = try await updateService.checkForUpdates(for: credentials)
} catch {
updateCheckError = error.localizedDescription
}
isCheckingForUpdates = false
}
}
func installUpdate(for credentials: RouterOSCredentials) {
isInstallingUpdate = true
installUpdateError = nil
didSendUpdateInstall = false
Task {
do {
try await updateService.installUpdate(for: credentials)
didSendUpdateInstall = true
} catch {
installUpdateError = error.localizedDescription
}
isInstallingUpdate = false
}
}
func upgradeRouterboardFirmware(for credentials: RouterOSCredentials) {
isUpgradingFirmware = true
firmwareUpgradeError = nil
firmwareUpgradeResultMessage = nil
Task {
do {
firmwareUpgradeResultMessage = try await updateService.upgradeRouterboardFirmware(for: credentials)
} catch {
firmwareUpgradeError = error.localizedDescription
}
isUpgradingFirmware = false
}
}
func reboot(for credentials: RouterOSCredentials) {
isRebooting = true
rebootError = nil
didSendReboot = false
Task {
do {
try await updateService.reboot(for: credentials)
didSendReboot = true
} catch {
rebootError = error.localizedDescription
}
isRebooting = false
}
}
}