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? @AppStorage("appLanguage") private var appLanguage: String = "de" 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( L10n.t("Sicherung wirklich wiederherstellen?", appLanguage), isPresented: $showRestoreConfirmation, titleVisibility: .visible ) { Button(L10n.t("Wiederherstellen", appLanguage), role: .destructive) { if let credentials = connectionService.credentials, let backup = pendingRestoreBackup { viewModel.restore(backup, for: credentials) } pendingRestoreBackup = nil } Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { pendingRestoreBackup = nil } } message: { Text(restoreWarningText) } .onChange(of: viewModel.didSendRestore) { _, didSend in if didSend { Task { await connectionService.disconnect() } } } .alert( L10n.t("Falsches Routermodell", appLanguage), isPresented: Binding( get: { modelMismatchMessage != nil }, set: { if !$0 { modelMismatchMessage = nil } } ), presenting: modelMismatchMessage ) { _ in Button(L10n.t("OK", appLanguage)) {} } message: { message in Text(message) } .alert( L10n.t("Wiederherstellung fehlgeschlagen", appLanguage), isPresented: Binding( get: { viewModel.restoreError != nil }, set: { _ in viewModel.restoreError = nil } ), presenting: viewModel.restoreError ) { _ in Button(L10n.t("OK", appLanguage)) {} } message: { message in Text(message) } } @ViewBuilder private var baseContent: some View { Group { if viewModel.backups.isEmpty { ContentUnavailableView( LocalizedStringKey(L10n.t("Keine Sicherungen", appLanguage)), systemImage: "clock.arrow.circlepath", description: Text(L10n.t("Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.", appLanguage)) ) } else { Form { Section(L10n.t("Sicherungen", appLanguage)) { ForEach(Array(viewModel.backups.enumerated()), id: \.element.id) { index, backup in HStack { VStack(alignment: .leading) { Text(backup.host).bold() Text(backup.createdAt.formatted(date: .abbreviated, time: .standard)) .appFont(.caption) .foregroundStyle(.secondary) if let model = BackupService.backupModel(from: backup.fileURL) { Text(model).appFont(.caption2).foregroundStyle(.secondary) } } Spacer() Button { beginRestore(backup) } label: { Label(L10n.t("Wiederherstellen", appLanguage), systemImage: "tray.and.arrow.up") } .buttonStyle(.borderless) .disabled(connectionService.credentials == nil || viewModel.isRestoring) .help(L10n.t("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.", appLanguage)) } .listRowBackground(TableZebra.color(for: index)) } } } .formStyle(.grouped) .scrollContentBackground(.hidden) } } .safeAreaInset(edge: .bottom) { VStack(alignment: .leading, spacing: 4) { Text(L10n.t("Speicherort", appLanguage)) .appFont(.caption) .foregroundStyle(.secondary) Text(viewModel.backupDirectoryPath) .appFont(.caption) .lineLimit(1) .truncationMode(.middle) .help(viewModel.backupDirectoryPath) HStack { Button(L10n.t("Ordner wählen…", appLanguage)) { showFolderPicker = true } .help(L10n.t("Wähle einen eigenen Ordner für neue Sicherungen, z.B. auf einer externen Festplatte oder in iCloud Drive.", appLanguage)) Button(L10n.t("Standard verwenden", appLanguage)) { viewModel.resetBackupDirectoryToDefault() } .help(L10n.t("Setzt den Speicherort zurück auf den App-eigenen Standardordner.", appLanguage)) } Divider() .padding(.vertical, 4) Text(L10n.t("Gefahrenzone", appLanguage)) .appFont(.caption, bold: true) .foregroundStyle(.red) Text(L10n.t("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.", appLanguage)) .appFont(.caption) .foregroundStyle(.secondary) Button(role: .destructive) { showFactoryResetConfirmation = true } label: { if viewModel.isResettingToFactoryDefaults { HStack { ProgressView() Text(L10n.t("Setze zurück…", appLanguage)) } } else { Label(L10n.t("Werkseinstellungen wiederherstellen", appLanguage), systemImage: "exclamationmark.triangle") } } .disabled(connectionService.credentials == nil || viewModel.isResettingToFactoryDefaults) .help(L10n.t("Stellt die vom Hersteller vorinstallierte Standardkonfiguration des Routers wieder her. Nur für den Notfall, falls etwas schiefgelaufen ist.", appLanguage)) if viewModel.didSendFactoryReset { Label( L10n.t("Befehl gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", appLanguage), systemImage: "arrow.clockwise" ) .appFont(.caption) } if viewModel.didSendRestore { Label( L10n.t("Wiederherstellung gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", appLanguage), systemImage: "arrow.clockwise" ) .appFont(.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( L10n.t("Werkseinstellungen wirklich wiederherstellen?", appLanguage), isPresented: $showFactoryResetConfirmation, titleVisibility: .visible ) { Button(L10n.t("Zurücksetzen", appLanguage), role: .destructive) { if let credentials = connectionService.credentials { viewModel.resetToFactoryDefaults(for: credentials) } } Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {} } message: { Text(L10n.t("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.", appLanguage)) } .onChange(of: viewModel.didSendFactoryReset) { _, didSend in if didSend { Task { await connectionService.disconnect() } } } .navigationTitle(LocalizedStringKey(L10n.t("Sicherungen", appLanguage))) .toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabBackup) } } .toolbar { ToolbarItem { Button { if let credentials = connectionService.credentials { viewModel.createBackup(for: credentials) } } label: { if viewModel.isCreatingBackup { ProgressView() } else { Label(L10n.t("Jetzt sichern", appLanguage), systemImage: "square.and.arrow.down") } } // Same precedent as LAN-Scanner's "Neu scannen"/Einrichten's "Abbrechen" — // a plain text toolbar button is easy to miss, applied app-wide now. .buttonStyle(.borderedProminent) .disabled(connectionService.credentials == nil || viewModel.isCreatingBackup) .help(connectionService.credentials == nil ? L10n.t("Zuerst im Tab 'Verbinden' mit dem Router verbinden.", appLanguage) : L10n.t("Sicherung jetzt erstellen", appLanguage)) } } .alert( L10n.t("Sicherung fehlgeschlagen", appLanguage), isPresented: Binding( get: { viewModel.errorMessage != nil }, set: { _ in viewModel.errorMessage = nil } ), presenting: viewModel.errorMessage ) { _ in Button(L10n.t("OK", appLanguage)) {} } message: { message in Text(message) } .alert( L10n.t("Zurücksetzen fehlgeschlagen", appLanguage), isPresented: Binding( get: { viewModel.resetError != nil }, set: { _ in viewModel.resetError = nil } ), presenting: viewModel.resetError ) { _ in Button(L10n.t("OK", appLanguage)) {} } 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 = L10n.t("Diese Sicherung stammt von einem", appLanguage) + " \(backupModel)" + L10n.t(", der verbundene Router meldet sich als", appLanguage) + " \(currentModel)" + L10n.t(". Wiederherstellen abgebrochen, um das Gerät nicht unbrauchbar zu machen (\"brick\").", appLanguage) 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) } ?? L10n.t("unbekannt", appLanguage) let currentModel = pendingRestoreCurrentModel ?? L10n.t("unbekannt", appLanguage) let modelLine = L10n.t("WICHTIG: Diese Sicherung darf nur auf genau das Routermodell zurückgespielt werden, von dem sie stammt — sonst kann der Router unbrauchbar werden (\"brick\"). Sicherung:", appLanguage) + " \(backupModel)" + L10n.t(". Verbundener Router:", appLanguage) + " \(currentModel)." let effectLine = L10n.t("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.", appLanguage) let failureLine = L10n.t("Falls die Wiederherstellung fehlschlägt, bleibt der Router leer stehen, nicht auf Werkseinstellungen zurückgefallen.", appLanguage) return [modelLine, effectLine, failureLine].joined(separator: " ") } } #Preview { BackupListView(connectionService: ConnectionService()) }