Files
RouterOS/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift
T
KayandClaude Sonnet 5 c9ecd3a0a9 M9/M10: Einfach/Experte-Modus + Experte-Tab (generischer RouterOS-Zugriff)
M9: Einrichten-Wizard bekommt einen Einfach/Experte-Modusschalter
(ModeStepView). Einfach überspringt VLAN, erlaubt nur ein LAN-Netzwerk
ohne Isolation, Firewall-Grundschutz fest an.

M10: neuer "Experte"-Tab mit generischem Motor (RouterOSMenuItem,
RouterOSCommand.remove, ConnectionService.fetchMenuItems, freies
"eigener Menüpfad"-Feld) plus kuratierten Formularen mit Tooltips
(RouterOSSchemaCatalog) für Firewall/NAT/Mangle/Raw/Adress-Listen,
Interfaces, IP, VPN, WLAN, Queues, System, Werkzeuge.

Live gegen einen hEX-Testrouter verifiziert (erst per SSH, dann vom
Nutzer selbst in der App), dabei 7 reale Bugs gefunden und gefixt —
der wichtigste: RouterOS' SSH-CLI gibt bei fehlgeschlagenen Befehlen
Exit-Code 0 zurück, wodurch apply() app-weit Fehler verschluckte statt
sie zu melden. Danach ergänzt: Bestätigungsdialog vor Anlegen/Ändern
+ Auto-Backup vor dem ersten Experte-Tab-Schreibvorgang je Sitzung
(Angleichung an den Wizard), sowie ein Dauer-Editor (Tage/Std/Min/Sek)
für Lease-/Ablaufzeit-Felder statt Freitext.

Details zu allen Bugs/Fixes: HANDOFF.md, CHATLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EW3r6rW1xCf6UT5jNvt6rn
2026-09-14 00:03:50 +02:00

227 lines
8.0 KiB
Swift

import Foundation
enum SetupStep: Int, CaseIterable {
case mode
case wan
case lan
case vlan
case wifi
case firewall
case review
}
/// Simple restricts the wizard to basic setup (single LAN, fixed firewall baseline, no VLANs,
/// no per-port DHCP/isolation) for users who just want a working router. Expert exposes every
/// RouterOS feature the wizard supports.
enum SetupMode {
case simple
case expert
}
@MainActor
final class SetupViewModel: ObservableObject {
@Published var step: SetupStep = .mode
@Published var mode: SetupMode = .simple
@Published var wanConfig = WanConfig(interfaceName: "ether1")
@Published var lanConfigs: [LanDhcpConfig] = [LanDhcpConfig()]
@Published private(set) var vlanSectionEnabled = false
@Published var vlans: [VlanEntry] = []
@Published var wifiNetworks: [WifiNetworkConfig] = []
@Published private(set) var unsupportedWifiInterfaces: [NetworkInterface] = []
@Published private(set) var firewallSectionEnabled = false
@Published private(set) var existingFirewallRuleCounts: FirewallRuleCounts?
@Published private(set) var isLoadingFirewallRuleCounts = false
@Published private(set) var firewallRuleCountsError: String?
@Published private(set) var isApplying = false
@Published private(set) var applyLog: [String] = []
@Published private(set) var applyError: String?
@Published private(set) var didApplySuccessfully = false
private let connectionService: ConnectionService
private let backupService: BackupService
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
self.connectionService = connectionService
self.backupService = backupService
prepareDefaults(from: connectionService.interfaces)
}
var plannedCommands: [RouterOSCommand] {
wanConfig.buildCommands()
+ lanConfigs.flatMap { $0.buildCommands() }
+ vlans.flatMap { $0.buildCommands() }
+ wifiNetworks.flatMap { $0.buildCommands() }
+ (firewallSectionEnabled
? FirewallConfig(wanInterface: wanConfig.interfaceName, networks: networkSegments).buildCommands()
: [])
}
/// All configured LAN + VLAN networks, for the firewall isolation rules.
private var networkSegments: [FirewallConfig.NetworkSegment] {
lanConfigs.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
+ vlans.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
}
/// Interface names of networks marked isolated, for display on the Firewall step.
var isolatedNetworkNames: [String] {
networkSegments.filter(\.isolated).map(\.interfaceName)
}
func setFirewallSectionEnabled(_ enabled: Bool) {
firewallSectionEnabled = enabled
if enabled {
loadExistingFirewallRuleCounts()
} else {
existingFirewallRuleCounts = nil
firewallRuleCountsError = nil
}
}
func loadExistingFirewallRuleCounts() {
guard firewallSectionEnabled, existingFirewallRuleCounts == nil, !isLoadingFirewallRuleCounts else { return }
isLoadingFirewallRuleCounts = true
firewallRuleCountsError = nil
Task {
do {
existingFirewallRuleCounts = try await connectionService.fetchFirewallRuleCounts()
} catch {
firewallRuleCountsError = error.localizedDescription
}
isLoadingFirewallRuleCounts = false
}
}
func setVlanSectionEnabled(_ enabled: Bool) {
vlanSectionEnabled = enabled
if !enabled { vlans = [] }
}
func addVlan() {
let usedIDs = Set(vlans.map(\.vlanID))
var nextID = 20
while usedIDs.contains(nextID) { nextID += 1 }
vlans.append(VlanEntry(vlanID: nextID, parentInterface: lanConfigs.first?.interfaceName ?? "bridge"))
}
func removeVlan(_ id: VlanEntry.ID) {
vlans.removeAll { $0.id == id }
}
func addLan() {
let usedNames = Set([wanConfig.interfaceName] + lanConfigs.map(\.interfaceName))
let nextInterface = connectionService.interfaces.first { !usedNames.contains($0.name) }
lanConfigs.append(LanDhcpConfig(interfaceName: nextInterface?.name ?? "bridge"))
}
func removeLan(_ id: LanDhcpConfig.ID) {
guard lanConfigs.count > 1 else { return }
lanConfigs.removeAll { $0.id == id }
}
/// Replaces the placeholder WAN/LAN interface names ("ether1"/"bridge") with real ones
/// from the connected device, whenever the current value isn't actually one of its
/// interfaces — otherwise the Picker selections don't match any of their tags.
func prepareDefaults(from interfaces: [NetworkInterface]) {
guard !interfaces.isEmpty else { return }
let names = Set(interfaces.map(\.name))
if !names.contains(wanConfig.interfaceName) {
if let firstEthernet = interfaces.first(where: { $0.type.lowercased().contains("ether") }) {
wanConfig.interfaceName = firstEthernet.name
} else {
wanConfig.interfaceName = interfaces[0].name
}
}
if !lanConfigs.isEmpty, !names.contains(lanConfigs[0].interfaceName) {
if let bridge = interfaces.first(where: { $0.type.lowercased().contains("bridge") }) {
lanConfigs[0].interfaceName = bridge.name
} else if let fallback = interfaces.first(where: { $0.name != wanConfig.interfaceName }) {
lanConfigs[0].interfaceName = fallback.name
} else {
lanConfigs[0].interfaceName = interfaces[0].name
}
}
if wifiNetworks.isEmpty {
let legacyWireless = interfaces.filter { $0.type.lowercased() == "wlan" }
wifiNetworks = legacyWireless.map { WifiNetworkConfig(interfaceName: $0.name) }
}
unsupportedWifiInterfaces = interfaces.filter { $0.type.lowercased() == "wifi" }
}
func goNext() {
switch step {
case .mode:
if mode == .simple { applySimpleModeConstraints() }
step = .wan
case .wan:
step = .lan
case .lan:
step = (mode == .expert) ? .vlan : .wifi
case .vlan:
step = .wifi
case .wifi:
step = .firewall
case .firewall:
step = .review
case .review:
break
}
}
func goBack() {
switch step {
case .mode:
break
case .wan:
step = .mode
case .lan:
step = .wan
case .vlan:
step = .lan
case .wifi:
step = (mode == .expert) ? .vlan : .lan
case .firewall:
step = .wifi
case .review:
step = .firewall
}
}
/// Simple mode allows only one LAN network, no isolation, no VLANs, and a fixed firewall
/// baseline — strip anything an earlier Expert-mode choice may have left configured.
private func applySimpleModeConstraints() {
vlanSectionEnabled = false
vlans = []
if lanConfigs.count > 1 { lanConfigs = Array(lanConfigs.prefix(1)) }
if !lanConfigs.isEmpty { lanConfigs[0].isolated = false }
setFirewallSectionEnabled(true)
}
func apply(credentials: RouterOSCredentials) {
isApplying = true
applyError = nil
applyLog = []
didApplySuccessfully = false
Task {
do {
applyLog.append("Sichere aktuelle Konfiguration…")
_ = try await backupService.createBackup(for: credentials)
for command in plannedCommands {
applyLog.append(command.summary)
try await connectionService.apply(command)
}
didApplySuccessfully = true
} catch {
applyError = error.localizedDescription
}
isApplying = false
}
}
}