Files
RouterOS/RouterOSAssistant/Features/Backup/BackupListView.swift
T
KayandClaude Sonnet 5 20f8d5e0bf M13: Backup-Wiederherstellung, mit Modell-Schutz und Login-Erhalt
Letzter offener Punkt aus HANDOFF.md: gespeicherte .rsc-Backups lassen
sich jetzt wieder einspielen. Weg: Backup-Datei per SFTP auf den Router
hochladen (Citadel, die bereits eingebundene SSH-Bibliothek, hat einen
SFTP-Client), dann RouterOS' offiziell dokumentierter Restore-Weg in
einem Rutsch: /system reset-configuration no-defaults=yes
run-after-reset=<datei> (kompletter Wipe + sofortiges Wiederanwenden,
sicherer als ein Re-Import auf eine bestehende, andere Config).

Vor dem Bestätigungsdialog wird das Routermodell abgeglichen
(/system routerboard prints "model"-Feld gegen die "# model = ..."-
Kopfzeile der Sicherung) und bei Mismatch komplett blockiert, um ein
Brick-Risiko durch falsches Modell zu vermeiden - der Dialog selbst warnt
zusaetzlich prominent davor.

Ein ernster Bug live gefunden und gefixt: der erste echte Restore-Test
sperrte den Router komplett aus (RouterOS exportiert nie Passwoerter,
no-defaults=yes loescht zusaetzlich den Werks-Admin-Account), nur per
Hardware-Reset behebbar. Fix: das aktuell verwendete App-Login wird jetzt
vorne ins Restore-Skript eingefuegt, noch vor dem eigentlichen
Sicherungsinhalt, da RouterOS den Import beim ersten Fehler irgendwo im
Skript komplett abbricht. Zwei Tests fuer die Escaping-Logik ergaenzt.

Nebenbei: BackupListView mit den neuen Restore-Dialogen liess sich nicht
mehr kompilieren (SwiftUI-Typpruefung timeoutete bei der langen
Modifier-Kette) - Restore-Dialoge in eine eigene @ViewBuilder-Property
ausgelagert.

HANDOFF.md/CHATLOG.md aktualisiert: M13, Bug 18+19, "Backup-
Wiederherstellung fehlt" aus den offenen Punkten entfernt, Hinweis auf
den zweiten (ungewollten) Werksreset waehrend der Session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
2026-09-14 15:21:05 +02:00

295 lines
15 KiB
Swift

import SwiftUI
import UniformTypeIdentifiers
struct BackupListView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel = BackupViewModel()
@State private var showFolderPicker = false
@State private var showFactoryResetConfirmation = false
/// Plain, independent Bool — same reasoning as the Geräte-Tab's confirmation dialogs
/// (`showStaticConfirmation` there): `.confirmationDialog`'s `isPresented` setter fires on
/// every dismissal including the confirming button, so it must never double as the payload.
@State private var showRestoreConfirmation = false
@State private var pendingRestoreBackup: BackupRecord?
/// Set instead of opening the confirmation dialog at all when the backup's own recorded
/// model doesn't match the connected router — restoring the wrong device's backup risks
/// bricking it (mismatched interface count/model-specific config), so this blocks the action
/// outright rather than just warning.
@State private var modelMismatchMessage: String?
/// The connected router's own `model` field (from `/system routerboard print`, fetched fresh
/// each time — not cached across the connection, matching this app's "state that can change
/// must be reloaded, not cached at connect time" lesson). Kept for the confirmation dialog's
/// text once a restore is allowed to proceed.
@State private var pendingRestoreCurrentModel: String?
var body: some View {
NavigationStack {
restoreAwareContent
}
.onAppear { viewModel.load() }
}
/// Split out of `body` because the combined modifier chain (backup list + Gefahrenzone +
/// factory-reset dialogs + restore dialogs, all on one view) made the Swift type-checker
/// time out ("unable to type-check this expression in reasonable time") — no logic issue,
/// purely a compiler-inference limit on very long SwiftUI modifier chains.
@ViewBuilder
private var restoreAwareContent: some View {
baseContent
.confirmationDialog(
"Sicherung wirklich wiederherstellen?",
isPresented: $showRestoreConfirmation,
titleVisibility: .visible
) {
Button("Wiederherstellen", role: .destructive) {
if let credentials = connectionService.credentials, let backup = pendingRestoreBackup {
viewModel.restore(backup, for: credentials)
}
pendingRestoreBackup = nil
}
Button("Abbrechen", role: .cancel) { pendingRestoreBackup = nil }
} message: {
Text(restoreWarningText)
}
.onChange(of: viewModel.didSendRestore) { _, didSend in
if didSend {
Task { await connectionService.disconnect() }
}
}
.alert(
"Falsches Routermodell",
isPresented: Binding(
get: { modelMismatchMessage != nil },
set: { if !$0 { modelMismatchMessage = nil } }
),
presenting: modelMismatchMessage
) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
.alert(
"Wiederherstellung fehlgeschlagen",
isPresented: Binding(
get: { viewModel.restoreError != nil },
set: { _ in viewModel.restoreError = nil }
),
presenting: viewModel.restoreError
) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
}
@ViewBuilder
private var baseContent: some View {
Group {
if viewModel.backups.isEmpty {
ContentUnavailableView(
"Keine Sicherungen",
systemImage: "clock.arrow.circlepath",
description: Text("Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.")
)
} else {
List(viewModel.backups) { backup in
HStack {
VStack(alignment: .leading) {
Text(backup.host).bold()
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.foregroundStyle(.secondary)
if let model = BackupService.backupModel(from: backup.fileURL) {
Text(model).font(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
Button {
beginRestore(backup)
} label: {
Label("Wiederherstellen", systemImage: "tray.and.arrow.up")
}
.buttonStyle(.borderless)
.disabled(connectionService.credentials == nil || viewModel.isRestoring)
.help("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.")
}
}
}
}
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
Text("Speicherort")
.font(.caption)
.foregroundStyle(.secondary)
Text(viewModel.backupDirectoryPath)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
.help(viewModel.backupDirectoryPath)
HStack {
Button("Ordner wählen…") { showFolderPicker = true }
.help("Wähle einen eigenen Ordner für neue Sicherungen, z.B. auf einer externen Festplatte oder in iCloud Drive.")
Button("Standard verwenden") { viewModel.resetBackupDirectoryToDefault() }
.help("Setzt den Speicherort zurück auf den App-eigenen Standardordner.")
}
Divider()
.padding(.vertical, 4)
Text("Gefahrenzone")
.font(.caption.bold())
.foregroundStyle(.red)
Text("Setzt den Router komplett auf die vom Hersteller mitgelieferte Standardkonfiguration zurück — alle bisherigen Änderungen (Internet, Heimnetz, VLANs, WLAN, Firewall) gehen verloren. Der Router startet danach neu.")
.font(.caption)
.foregroundStyle(.secondary)
Button(role: .destructive) {
showFactoryResetConfirmation = true
} label: {
if viewModel.isResettingToFactoryDefaults {
HStack {
ProgressView()
Text("Setze zurück…")
}
} else {
Label("Werkseinstellungen wiederherstellen", systemImage: "exclamationmark.triangle")
}
}
.disabled(connectionService.credentials == nil || viewModel.isResettingToFactoryDefaults)
.help("Stellt die vom Hersteller vorinstallierte Standardkonfiguration des Routers wieder her. Nur für den Notfall, falls etwas schiefgelaufen ist.")
if viewModel.didSendFactoryReset {
Label(
"Befehl gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.",
systemImage: "arrow.clockwise"
)
.font(.caption)
}
if viewModel.didSendRestore {
Label(
"Wiederherstellung gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.",
systemImage: "arrow.clockwise"
)
.font(.caption)
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [.folder]
) { result in
if case .success(let url) = result {
viewModel.setBackupDirectory(url)
}
}
.confirmationDialog(
"Werkseinstellungen wirklich wiederherstellen?",
isPresented: $showFactoryResetConfirmation,
titleVisibility: .visible
) {
Button("Zurücksetzen", role: .destructive) {
if let credentials = connectionService.credentials {
viewModel.resetToFactoryDefaults(for: credentials)
}
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Dies löscht ALLE bisherigen Einstellungen (Internet, Heimnetz, VLANs, WLAN, Firewall) und setzt den Router auf die Werks-Standardkonfiguration zurück. Der Router startet neu, diese App verliert danach die Verbindung. Vorher wird zusätzlich automatisch eine Sicherung erstellt.")
}
.onChange(of: viewModel.didSendFactoryReset) { _, didSend in
if didSend {
Task { await connectionService.disconnect() }
}
}
.navigationTitle("Sicherungen")
.toolbar {
ToolbarItem {
Button {
if let credentials = connectionService.credentials {
viewModel.createBackup(for: credentials)
}
} label: {
if viewModel.isCreatingBackup {
ProgressView()
} else {
Label("Jetzt sichern", systemImage: "square.and.arrow.down")
}
}
.disabled(connectionService.credentials == nil || viewModel.isCreatingBackup)
.help(connectionService.credentials == nil ? "Zuerst im Tab 'Verbinden' mit dem Router verbinden." : "Sicherung jetzt erstellen")
}
}
.alert(
"Sicherung fehlgeschlagen",
isPresented: Binding(
get: { viewModel.errorMessage != nil },
set: { _ in viewModel.errorMessage = nil }
),
presenting: viewModel.errorMessage
) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
.alert(
"Zurücksetzen fehlgeschlagen",
isPresented: Binding(
get: { viewModel.resetError != nil },
set: { _ in viewModel.resetError = nil }
),
presenting: viewModel.resetError
) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
}
/// Checks the backup's recorded model against the connected router's own `model` field
/// before ever showing the destructive confirmation dialog — blocks outright on a confirmed
/// mismatch (see `modelMismatchMessage`'s doc comment), proceeds otherwise (including when
/// either side's model is unknown, since refusing every restore just because an old backup
/// predates the "# model =" line would make the feature useless — the confirmation dialog's
/// own text still states plainly when a model couldn't be determined).
private func beginRestore(_ backup: BackupRecord) {
Task {
let backupModel = BackupService.backupModel(from: backup.fileURL)
let currentModel = await currentRouterboardModel()
pendingRestoreCurrentModel = currentModel
if let backupModel, let currentModel, backupModel != currentModel {
modelMismatchMessage = "Diese Sicherung stammt von einem \(backupModel), der verbundene Router meldet sich als \(currentModel). Wiederherstellen abgebrochen, um das Gerät nicht unbrauchbar zu machen (\"brick\")."
return
}
pendingRestoreBackup = backup
showRestoreConfirmation = true
}
}
/// `/system routerboard print`'s `model` field (e.g. "RB750Gr3") — confirmed via research
/// that this, not `ConnectionService.deviceInfo?.boardName` (`/system resource print`'s
/// `board-name`, often a marketing name like "hEX"), is what a backup's own "# model ="
/// header line actually corresponds to; comparing against `boardName` would have produced
/// false mismatches even on the exact same device.
private func currentRouterboardModel() async -> String? {
guard let items = try? await connectionService.fetchMenuItems(menuPath: "/system routerboard", restPath: "system/routerboard") else {
return nil
}
return items.first?.fields["model"]
}
private var restoreWarningText: String {
let backupModel = pendingRestoreBackup.flatMap { BackupService.backupModel(from: $0.fileURL) } ?? "unbekannt"
let currentModel = pendingRestoreCurrentModel ?? "unbekannt"
let modelLine: String = "WICHTIG: Diese Sicherung darf nur auf genau das Routermodell zurückgespielt werden, von dem sie stammt — sonst kann der Router unbrauchbar werden (\"brick\"). Sicherung: \(backupModel). Verbundener Router: \(currentModel)."
let effectLine: String = "Dies löscht ALLE aktuellen Einstellungen restlos (auch die Werks-Grundkonfiguration, nicht nur deine eigenen Änderungen) und ersetzt sie durch den Inhalt der Sicherung. Der Router startet neu, diese App verliert danach die Verbindung."
let failureLine: String = "Falls die Wiederherstellung fehlschlägt, bleibt der Router leer stehen, nicht auf Werkseinstellungen zurückgefallen."
return [modelLine, effectLine, failureLine].joined(separator: " ")
}
}
#Preview {
BackupListView(connectionService: ConnectionService())
}