"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und springen per Textanker direkt zur passenden Manual-Stelle. build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES- Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs- Dictionary wiederverwendet wird (714 Einträge geparst) statt einer zweiten, separat gepflegten Übersetzung. Live bestätigt (DE und EN). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
308 lines
17 KiB
Swift
308 lines
17 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?
|
|
@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())
|
|
}
|