ConnectView.swift auf L10n.t(...) umgestellt: Verbindungsformular, Status, Geräte-/Routerboard-Infos, Update-/Firmware-Dialoge, Zertifikats-/SSH- Warnungen. Dialog-Helper-Funktionen bekommen appLanguage-Parameter (liegen außerhalb der View-Struct). Nebenbei fehlenden "Passwort"-Label-Eintrag im Experte-Katalog nachgetragen. L10n.swift jetzt 444 Einträge, keine Duplikate. 60 Tests grün, live bestätigt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
441 lines
22 KiB
Swift
441 lines
22 KiB
Swift
import SwiftUI
|
|
|
|
struct ConnectView: View {
|
|
@ObservedObject private var connectionService: ConnectionService
|
|
@StateObject private var viewModel: ConnectViewModel
|
|
@StateObject private var backupViewModel = BackupViewModel()
|
|
@State private var showUpdateInstallConfirmation = false
|
|
@State private var showFirmwareUpgradeConfirmation = false
|
|
@State private var showRebootConfirmation = false
|
|
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
|
|
|
init(connectionService: ConnectionService) {
|
|
self.connectionService = connectionService
|
|
_viewModel = StateObject(wrappedValue: ConnectViewModel(connectionService: connectionService))
|
|
}
|
|
|
|
var body: some View {
|
|
mainContent
|
|
.withUpdateDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, showUpdateInstallConfirmation: $showUpdateInstallConfirmation, showFirmwareUpgradeConfirmation: $showFirmwareUpgradeConfirmation)
|
|
.withRebootDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: $showRebootConfirmation)
|
|
}
|
|
|
|
/// Split out of `body` — same reasoning as `BackupListView.restoreAwareContent` (Bug 19 in
|
|
/// HANDOFF.md): too many `.alert`/`.confirmationDialog` modifiers chained onto one view times
|
|
/// out the Swift type-checker with a misleading error location, not an actual logic issue.
|
|
private var mainContent: some View {
|
|
NavigationSplitView {
|
|
Form {
|
|
Section(L10n.t("Verbindung", appLanguage)) {
|
|
TextField(L10n.t("IP-Adresse oder Hostname", appLanguage), text: $viewModel.host)
|
|
.help(L10n.t("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.", appLanguage))
|
|
TextField(L10n.t("Benutzername", appLanguage), text: $viewModel.username)
|
|
.help(L10n.t("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".", appLanguage))
|
|
SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.password)
|
|
.help(L10n.t("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.", appLanguage))
|
|
Toggle(L10n.t("Passwort merken", appLanguage), isOn: $viewModel.rememberPassword)
|
|
.help(L10n.t("Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.", appLanguage))
|
|
}
|
|
|
|
Section {
|
|
Button(L10n.t("Verbinden", appLanguage)) {
|
|
viewModel.connect()
|
|
}
|
|
.disabled(viewModel.host.isEmpty || viewModel.username.isEmpty)
|
|
}
|
|
|
|
statusSection
|
|
}
|
|
.formStyle(.grouped)
|
|
.frame(minWidth: 320)
|
|
} detail: {
|
|
deviceDetail
|
|
}
|
|
.onAppear {
|
|
viewModel.onAppear()
|
|
backupViewModel.load()
|
|
}
|
|
.alert(
|
|
L10n.t("Unbekanntes Zertifikat", appLanguage),
|
|
isPresented: certificateAlertBinding,
|
|
presenting: certificateFingerprint
|
|
) { fingerprint in
|
|
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
|
|
viewModel.trustAndRetry(fingerprint: fingerprint)
|
|
}
|
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
|
} message: { fingerprint in
|
|
Text(L10n.t("Der Router hat sich mit einem unbekannten Zertifikat gemeldet.", appLanguage)
|
|
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
|
|
+ L10n.t("Nur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist.", appLanguage))
|
|
}
|
|
.alert(
|
|
L10n.t("Unbekannter SSH-Schlüssel", appLanguage),
|
|
isPresented: sshHostKeyAlertBinding,
|
|
presenting: sshHostKeyFingerprint
|
|
) { fingerprint in
|
|
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
|
|
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
|
|
}
|
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
|
} message: { fingerprint in
|
|
Text(L10n.t("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.", appLanguage)
|
|
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
|
|
+ L10n.t("Nur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist. Falls du diesen Router schon einmal verbunden hattest und sich der Fingerabdruck geändert hat, könnte das auf ein manipuliertes Netzwerk hindeuten — im Zweifel nicht bestätigen.", appLanguage))
|
|
}
|
|
.alert(
|
|
L10n.t("Sicherung fehlgeschlagen", appLanguage),
|
|
isPresented: Binding(
|
|
get: { backupViewModel.errorMessage != nil },
|
|
set: { _ in backupViewModel.errorMessage = nil }
|
|
),
|
|
presenting: backupViewModel.errorMessage
|
|
) { _ in
|
|
Button(L10n.t("OK", appLanguage)) {}
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
}
|
|
|
|
private var certificateFingerprint: String? {
|
|
if case .needsCertificateConfirmation(let fingerprint) = connectionService.state {
|
|
return fingerprint
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var certificateAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { certificateFingerprint != nil },
|
|
set: { _ in }
|
|
)
|
|
}
|
|
|
|
private var sshHostKeyFingerprint: String? {
|
|
if case .needsSSHHostKeyConfirmation(let fingerprint) = connectionService.state {
|
|
return fingerprint
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var sshHostKeyAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { sshHostKeyFingerprint != nil },
|
|
set: { _ in }
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusSection: some View {
|
|
switch connectionService.state {
|
|
case .idle, .needsCertificateConfirmation, .needsSSHHostKeyConfirmation:
|
|
EmptyView()
|
|
case .connecting:
|
|
Section {
|
|
HStack {
|
|
ProgressView()
|
|
Text(L10n.t("Verbinde…", appLanguage))
|
|
}
|
|
}
|
|
case .connected(let kind):
|
|
Section {
|
|
Label(L10n.t("Verbunden", appLanguage) + " (\(kind == .rest ? "REST-API" : "SSH"))", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
|
|
Button(L10n.t("Trennen", appLanguage), role: .destructive) {
|
|
Task { await connectionService.disconnect() }
|
|
}
|
|
.help(L10n.t("Beendet die Verbindung zum Router. Zugangsdaten bleiben erhalten (falls gemerkt).", appLanguage))
|
|
|
|
Button {
|
|
if let credentials = connectionService.credentials {
|
|
backupViewModel.createBackup(for: credentials)
|
|
}
|
|
} label: {
|
|
if backupViewModel.isCreatingBackup {
|
|
HStack {
|
|
ProgressView()
|
|
Text(L10n.t("Sichere…", appLanguage))
|
|
}
|
|
} else {
|
|
Label(L10n.t("Jetzt sichern", appLanguage), systemImage: "square.and.arrow.down")
|
|
}
|
|
}
|
|
.disabled(backupViewModel.isCreatingBackup)
|
|
.help(L10n.t("Sichert die aktuelle Router-Konfiguration — sinnvoll direkt nach dem Verbinden, bevor du im Einrichten-Tab etwas änderst.", appLanguage))
|
|
|
|
if let lastBackup = backupViewModel.backups.first(where: { $0.host == connectionService.credentials?.host }) {
|
|
Label(
|
|
L10n.t("Zuletzt gesichert:", appLanguage) + " \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))",
|
|
systemImage: "checkmark"
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
case .failed(let message):
|
|
Section {
|
|
Label(message, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var deviceDetail: some View {
|
|
if let info = connectionService.deviceInfo {
|
|
Form {
|
|
Section(L10n.t("Gerät", appLanguage)) {
|
|
LabeledContent(L10n.t("Modell", appLanguage), value: info.boardName)
|
|
LabeledContent(L10n.t("RouterOS-Version", appLanguage), value: info.routerOSVersion)
|
|
LabeledContent(L10n.t("Architektur", appLanguage), value: info.architecture)
|
|
LabeledContent(L10n.t("Laufzeit", appLanguage), value: info.uptime)
|
|
}
|
|
if let board = connectionService.routerBoardInfo {
|
|
Section("Routerboard") {
|
|
LabeledContent(L10n.t("Modell (RB-Code)", appLanguage), value: board.model)
|
|
LabeledContent(L10n.t("Revision", appLanguage), value: board.revision)
|
|
LabeledContent(L10n.t("Seriennummer", appLanguage), value: board.serialNumber)
|
|
.textSelection(.enabled)
|
|
LabeledContent(L10n.t("Firmware-Typ", appLanguage), value: board.firmwareType)
|
|
LabeledContent(L10n.t("Firmware (aktuell)", appLanguage), value: board.currentFirmware)
|
|
LabeledContent(L10n.t("Firmware (minimal)", appLanguage), value: board.minimumFirmware)
|
|
LabeledContent(L10n.t("Firmware (verfügbar)", appLanguage), value: board.upgradeFirmware)
|
|
// Confirmed via docs: same value = up to date, different = a bootloader
|
|
// update is available (only ever becomes available after installing a
|
|
// newer RouterOS package first — the firmware ships bundled inside it).
|
|
if board.currentFirmware != board.upgradeFirmware {
|
|
Label(L10n.t("Firmware-Update verfügbar", appLanguage), systemImage: "arrow.up.circle.fill")
|
|
.foregroundStyle(.orange)
|
|
Button {
|
|
showFirmwareUpgradeConfirmation = true
|
|
} label: {
|
|
if viewModel.isUpgradingFirmware {
|
|
HStack { ProgressView(); Text(L10n.t("Aktualisiere…", appLanguage)) }
|
|
} else {
|
|
Label(L10n.t("Firmware aktualisieren", appLanguage), systemImage: "arrow.up.circle")
|
|
}
|
|
}
|
|
.disabled(viewModel.isUpgradingFirmware)
|
|
} else {
|
|
Label(L10n.t("Firmware aktuell", appLanguage), systemImage: "checkmark.circle")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let message = viewModel.firmwareUpgradeResultMessage {
|
|
Text(message).font(.caption).foregroundStyle(.secondary)
|
|
Button {
|
|
showRebootConfirmation = true
|
|
} label: {
|
|
if viewModel.isRebooting {
|
|
HStack { ProgressView(); Text(L10n.t("Starte neu…", appLanguage)) }
|
|
} else {
|
|
Label(L10n.t("Jetzt neu starten", appLanguage), systemImage: "arrow.clockwise")
|
|
}
|
|
}
|
|
.disabled(viewModel.isRebooting)
|
|
.help(L10n.t("Nötig, damit die neu geschriebene Firmware aktiv wird — passiert nicht automatisch.", appLanguage))
|
|
}
|
|
}
|
|
}
|
|
Section(L10n.t("Software-Update", appLanguage)) {
|
|
if let update = viewModel.packageUpdateInfo {
|
|
LabeledContent(L10n.t("Kanal", appLanguage), value: update.channel)
|
|
LabeledContent(L10n.t("Installiert", appLanguage), value: update.installedVersion)
|
|
LabeledContent(L10n.t("Neueste Version", appLanguage), value: update.latestVersion)
|
|
LabeledContent(L10n.t("Status", appLanguage), value: update.status)
|
|
if update.status == "New version is available" {
|
|
Button {
|
|
showUpdateInstallConfirmation = true
|
|
} label: {
|
|
if viewModel.isInstallingUpdate {
|
|
HStack { ProgressView(); Text(L10n.t("Installiere…", appLanguage)) }
|
|
} else {
|
|
Label(L10n.t("Update installieren", appLanguage), systemImage: "arrow.down.circle")
|
|
}
|
|
}
|
|
.disabled(viewModel.isInstallingUpdate)
|
|
}
|
|
}
|
|
Button {
|
|
if let credentials = connectionService.credentials {
|
|
viewModel.checkForUpdates(for: credentials)
|
|
}
|
|
} label: {
|
|
if viewModel.isCheckingForUpdates {
|
|
HStack { ProgressView(); Text(L10n.t("Prüfe…", appLanguage)) }
|
|
} else {
|
|
Label(L10n.t("Jetzt prüfen", appLanguage), systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
}
|
|
.disabled(viewModel.isCheckingForUpdates)
|
|
.help(L10n.t("Prüft bei MikroTik, ob eine neuere RouterOS-Version verfügbar ist. Braucht Internetzugang auf dem Router.", appLanguage))
|
|
|
|
if viewModel.didSendUpdateInstall {
|
|
Label(
|
|
L10n.t("Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.", appLanguage),
|
|
systemImage: "arrow.clockwise"
|
|
)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
Section("Interfaces") {
|
|
ForEach(connectionService.interfaces) { interface in
|
|
HStack {
|
|
Image(systemName: interface.running ? "circle.fill" : "circle")
|
|
.foregroundStyle(interface.running ? .green : .secondary)
|
|
.font(.caption)
|
|
VStack(alignment: .leading) {
|
|
Text(interface.name).bold()
|
|
Text(interface.type).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
} else {
|
|
ContentUnavailableView(
|
|
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
|
|
systemImage: "network.slash",
|
|
description: Text(L10n.t("Verbinde dich mit deinem Router, um Geräteinformationen zu sehen.", appLanguage))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Split into two small functions (software-update dialogs, firmware-upgrade dialogs), not one
|
|
/// big one — same Bug 19 reasoning as `mainContent`'s own split: keep each modifier chain short
|
|
/// enough that the type-checker doesn't time out.
|
|
private extension View {
|
|
func withUpdateDialogs(
|
|
viewModel: ConnectViewModel,
|
|
connectionService: ConnectionService,
|
|
appLanguage: String,
|
|
showUpdateInstallConfirmation: Binding<Bool>,
|
|
showFirmwareUpgradeConfirmation: Binding<Bool>
|
|
) -> some View {
|
|
self
|
|
.withSoftwareUpdateDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: showUpdateInstallConfirmation)
|
|
.withFirmwareUpgradeDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: showFirmwareUpgradeConfirmation)
|
|
}
|
|
|
|
func withSoftwareUpdateDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
|
|
self
|
|
.confirmationDialog(
|
|
L10n.t("RouterOS-Update installieren?", appLanguage),
|
|
isPresented: isPresented,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(L10n.t("Installieren", appLanguage), role: .destructive) {
|
|
if let credentials = connectionService.credentials {
|
|
viewModel.installUpdate(for: credentials)
|
|
}
|
|
}
|
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
|
} message: {
|
|
Text(L10n.t("Lädt RouterOS", appLanguage)
|
|
+ " \(viewModel.packageUpdateInfo?.latestVersion ?? L10n.t("die neueste Version", appLanguage))"
|
|
+ L10n.t(" herunter, installiert es und startet den Router danach automatisch neu — die Verbindung geht dabei verloren. Vorher am besten eine Sicherung erstellen (\"Jetzt sichern\" oben).", appLanguage))
|
|
}
|
|
.onChange(of: viewModel.didSendUpdateInstall) { _, didSend in
|
|
if didSend {
|
|
Task { await connectionService.disconnect() }
|
|
}
|
|
}
|
|
.alert(
|
|
L10n.t("Update-Prüfung fehlgeschlagen", appLanguage),
|
|
isPresented: Binding(
|
|
get: { viewModel.updateCheckError != nil },
|
|
set: { _ in viewModel.updateCheckError = nil }
|
|
),
|
|
presenting: viewModel.updateCheckError
|
|
) { _ in
|
|
Button(L10n.t("OK", appLanguage)) {}
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
.alert(
|
|
L10n.t("Update-Installation fehlgeschlagen", appLanguage),
|
|
isPresented: Binding(
|
|
get: { viewModel.installUpdateError != nil },
|
|
set: { _ in viewModel.installUpdateError = nil }
|
|
),
|
|
presenting: viewModel.installUpdateError
|
|
) { _ in
|
|
Button(L10n.t("OK", appLanguage)) {}
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
}
|
|
|
|
func withFirmwareUpgradeDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
|
|
self
|
|
.confirmationDialog(
|
|
L10n.t("Routerboard-Firmware aktualisieren?", appLanguage),
|
|
isPresented: isPresented,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(L10n.t("Aktualisieren", appLanguage), role: .destructive) {
|
|
if let credentials = connectionService.credentials {
|
|
viewModel.upgradeRouterboardFirmware(for: credentials)
|
|
}
|
|
}
|
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
|
} message: {
|
|
Text(L10n.t("Schreibt die neue Bootloader-Firmware auf den Router. Danach ist ein manueller Neustart nötig — passiert nicht automatisch. Dieser Befehl fragt in einer interaktiven Sitzung normalerweise erst nach Bestätigung; ob das über diese App genauso abläuft, ist nicht abschließend getestet (ähnlich interaktive Befehle liefen bisher aber problemlos). Falls die App hier ungewöhnlich lange ohne Rückmeldung bleibt, bitte melden.", appLanguage))
|
|
}
|
|
.alert(
|
|
L10n.t("Firmware-Aktualisierung fehlgeschlagen", appLanguage),
|
|
isPresented: Binding(
|
|
get: { viewModel.firmwareUpgradeError != nil },
|
|
set: { _ in viewModel.firmwareUpgradeError = nil }
|
|
),
|
|
presenting: viewModel.firmwareUpgradeError
|
|
) { _ in
|
|
Button(L10n.t("OK", appLanguage)) {}
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
}
|
|
|
|
func withRebootDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
|
|
self
|
|
.confirmationDialog(
|
|
L10n.t("Router jetzt neu starten?", appLanguage),
|
|
isPresented: isPresented,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(L10n.t("Neu starten", appLanguage), role: .destructive) {
|
|
if let credentials = connectionService.credentials {
|
|
viewModel.reboot(for: credentials)
|
|
}
|
|
}
|
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
|
} message: {
|
|
Text(L10n.t("Startet den Router sofort neu. Die Verbindung geht dabei kurz verloren, danach im Tab \"Verbinden\" erneut verbinden.", appLanguage))
|
|
}
|
|
.onChange(of: viewModel.didSendReboot) { _, didSend in
|
|
if didSend {
|
|
Task { await connectionService.disconnect() }
|
|
}
|
|
}
|
|
.alert(
|
|
L10n.t("Neustart fehlgeschlagen", appLanguage),
|
|
isPresented: Binding(
|
|
get: { viewModel.rebootError != nil },
|
|
set: { _ in viewModel.rebootError = nil }
|
|
),
|
|
presenting: viewModel.rebootError
|
|
) { _ in
|
|
Button(L10n.t("OK", appLanguage)) {}
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ConnectView(connectionService: ConnectionService())
|
|
}
|