M16: Verbinden-Tab zweisprachig (DE/EN)

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
This commit is contained in:
Kay
2026-09-15 16:18:02 +02:00
co-authored by Claude Sonnet 5
parent 7af550ae85
commit b6bb4bc289
6 changed files with 241 additions and 104 deletions
+18
View File
@@ -855,3 +855,21 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
Duplikate geprüft). 60 Tests grün, live bestätigt ("funktioniert"). Duplikate geprüft). 60 Tests grün, live bestätigt ("funktioniert").
Damit ist der komplette Experte-Tab zweisprachig. HANDOFF.md/ Damit ist der komplette Experte-Tab zweisprachig. HANDOFF.md/
README.md/Manual.md aktualisiert, Commit + Push. README.md/Manual.md aktualisiert, Commit + Push.
- "mach den Verbinden-Tab zweisprachig" → `ConnectView.swift`
durchgearbeitet: Verbindungsformular, Verbindungsstatus, Geräte-/
Routerboard-Infos, Software-/Firmware-Update-Bereich samt aller
Bestätigungsdialoge, Zertifikats-/SSH-Hostkey-Warnungen. Mehrere
Nachrichten mit eingebetteten Laufzeitwerten (Fingerabdruck, Version,
Datum) in übersetzte statische Textteile + unverändert eingefügten
Wert zerlegt, statt einen Dictionary-Key mit Platzhalter zu bauen. Die
vier `View`-Extension-Hilfsfunktionen für die Dialoge bekamen dafür
einen zusätzlichen `appLanguage`-Parameter (liegen außerhalb der
View-Struct, kein direkter `@AppStorage`-Zugriff). Dabei eine Lücke im
bereits als "komplett" gemeldeten Experte-Tab-Katalog gefunden und
geschlossen: Feld-Label "Passwort" (in fünf Schemas verwendet) hatte
keinen eigenen Übersetzungseintrag. `L10n.swift` jetzt 444 Einträge,
keine Duplikate (Python-Skript-Check). 60 Tests grün, live bestätigt
("sieht gut aus" / "sieht gut aus"). Bewusst nicht übersetzt:
Backend-/Laufzeit-Text von RouterOS oder app-eigenen Fehlerpfaden
(technische/englische Ausgaben, keine kuratierten UI-Strings).
HANDOFF.md/README.md/Manual.md aktualisiert, Commit + Push.
+34 -9
View File
@@ -1038,19 +1038,44 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und
zweiten Eintrag entfernt (dieselbe Übersetzung greift ohnehin für zweiten Eintrag entfernt (dieselbe Übersetzung greift ohnehin für
beide Stellen). Build + 60 Tests grün, **vom Nutzer live in Xcode beide Stellen). Build + 60 Tests grün, **vom Nutzer live in Xcode
bestätigt** ("funktioniert"). Damit ist der komplette Experte-Tab bestätigt** ("funktioniert"). Damit ist der komplette Experte-Tab
zweisprachig; offen bleiben die übrigen fünf Tabs (Verbinden/ zweisprachig. Direkt danach denselben Mechanismus auf den Verbinden-
Einrichten/Übersicht/Geräte/Sicherungen), die noch auf festen Tab (`ConnectView.swift`) ausgerollt: Verbindungsformular,
deutschen String-Literalen laufen. Verbindungsstatus (inkl. dynamischem "Verbunden (REST-API/SSH)"),
Geräte-/Routerboard-Infos, Software-/Firmware-Update-Bereich samt
aller Bestätigungsdialoge, Zertifikats-/SSH-Hostkey-TOFU-Warnungen.
Da `ConnectView` mehrere Nachrichten mit eingebetteten Laufzeitwerten
baut (Fingerabdruck, Router-Version, Datum der letzten Sicherung),
wurden diese in übersetzte statische Textteile + unverändert
eingefügten dynamischen Wert zerlegt statt als ein Dictionary-Key mit
Platzhalter (z.B. `L10n.t("Fingerabdruck:", appLanguage) +
" \(fingerprint)"`). Die vier `View`-Extension-Hilfsfunktionen
(`withUpdateDialogs`/`withSoftwareUpdateDialogs`/
`withFirmwareUpgradeDialogs`/`withRebootDialogs`) bekamen dafür einen
zusätzlichen `appLanguage: String`-Parameter, da sie außerhalb der
View-Struct liegen und nicht direkt auf `@AppStorage` zugreifen
können. Nebenbei eine Lücke im Experte-Tab-Katalog geschlossen: das
Feld-Label "Passwort" (in fünf verschiedenen Schemas verwendet) hatte
bisher keinen eigenen Übersetzungseintrag. `L10n.swift`s
`translations`-Dictionary jetzt 444 Einträge, auf Duplikate per
Python-Skript geprüft (keine gefunden). Build + 60 Tests grün, **vom
Nutzer live in Xcode bestätigt** ("sieht gut aus"). Bewusst nicht
übersetzt: Laufzeit-/Backend-Text, der direkt von RouterOS oder dem
App-eigenen Fehler-/Ergebnis-Pfad kommt (z.B. `update.status`,
`firmwareUpgradeResultMessage`, alle `viewModel.*Error`-Alerts) — das
sind technische/englische RouterOS-Ausgaben bzw. app-generierte
Fehlertexte, keine kuratierten UI-Strings; offen bleiben die übrigen
vier Tabs (Einrichten/Übersicht/Geräte/Sicherungen), die noch auf
festen deutschen String-Literalen laufen.
## Nächste Schritte ## Nächste Schritte
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~ 1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~
— erledigt, kompletter Experte-Tab (alle 9 Menü-Familien) jetzt und ~~Verbinden-Tab zweisprachig~~ — beides erledigt, live bestätigt.
zweisprachig, live bestätigt. Offen: dieselbe `L10n.t(...)`-Umstellung Offen: dieselbe `L10n.t(...)`-Umstellung auf die übrigen vier Tabs
auf die übrigen Tabs (Verbinden/Einrichten/Geräte/Sicherungen/ (Einrichten/Übersicht/Geräte/Sicherungen) ausrollen — dort läuft
Übersicht) ausrollen — dort läuft aktuell noch alles auf festen aktuell noch alles auf festen deutschen String-Literalen ohne
deutschen String-Literalen ohne Sprachumschaltung, mit dem Nutzer Sprachumschaltung, mit dem Nutzer vereinbart Tab für Tab
vereinbart Tab für Tab weiterzumachen. weiterzumachen.
2. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen. 2. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen.
2. Rest von M7: Fehlerzustände/Politur, REST-Schreibtest an einem Gerät 2. Rest von M7: Fehlerzustände/Politur, REST-Schreibtest an einem Gerät
mit aktivem `www-ssl`. mit aktivem `www-ssl`.
+6 -5
View File
@@ -43,8 +43,9 @@ automatisch gewählt, ohne dass du dich darum kümmern musst.
Ein DE/EN-Umschalt-Button in der Toolbar wechselt die Sprache der App Ein DE/EN-Umschalt-Button in der Toolbar wechselt die Sprache der App
(persistiert über Neustarts). Aktuell vollständig übersetzt: alle (persistiert über Neustarts). Aktuell vollständig übersetzt: alle
Tab-Namen sowie der komplette Experte-Tab (alle Menü-Bereiche, Buttons, Tab-Namen, der komplette Experte-Tab (alle Menü-Bereiche, Buttons,
Tooltips). Weitere Tabs folgen schrittweise. Tooltips) sowie der komplette Verbinden-Tab. Weitere Tabs folgen
schrittweise.
## 1. Verbinden ## 1. Verbinden
@@ -174,9 +175,9 @@ router over its REST API (preferred) or SSH (fallback) — chosen
automatically, no configuration needed. automatically, no configuration needed.
A DE/EN toggle button in the toolbar switches the app's language A DE/EN toggle button in the toolbar switches the app's language
(persists across restarts). Fully translated so far: every tab name and (persists across restarts). Fully translated so far: every tab name,
the entire Expert tab (all menu areas, buttons, tooltips). More tabs the entire Expert tab (all menu areas, buttons, tooltips), and the
will follow incrementally. entire Connect tab. More tabs will follow incrementally.
## 1. Connect ## 1. Connect
+2 -2
View File
@@ -53,8 +53,8 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
Modell-Abgleich als Brick-Schutz, wählbarer Speicherort, "Werks­ein­ Modell-Abgleich als Brick-Schutz, wählbarer Speicherort, "Werks­ein­
stellungen wiederherstellen"-Notfallschalter. stellungen wiederherstellen"-Notfallschalter.
- **Deutsch/Englisch** — Umschalt-Button in der Toolbar (persistiert über - **Deutsch/Englisch** — Umschalt-Button in der Toolbar (persistiert über
Neustarts). Kompletter Experte-Tab vollständig übersetzt (alle 9 Neustarts). Vollständig übersetzt: kompletter Experte-Tab (alle 9
Menü-Familien, Formular-Chrome/Tooltips), weitere Tabs folgen Menü-Familien) und der Verbinden-Tab; weitere Tabs folgen
schrittweise. schrittweise.
## Architektur ## Architektur
+86 -1
View File
@@ -528,6 +528,91 @@ enum L10n {
"Meist 587 (STARTTLS) oder 465 (SSL).": "Usually 587 (STARTTLS) or 465 (SSL).", "Meist 587 (STARTTLS) oder 465 (SSL).": "Usually 587 (STARTTLS) or 465 (SSL).",
"Absenderadresse": "Sender Address", "Absenderadresse": "Sender Address",
"Z.B. router@example.com.": "E.g. router@example.com.", "Z.B. router@example.com.": "E.g. router@example.com.",
"Zugangsdaten des Mailservers.": "Credentials for the mail server." "Zugangsdaten des Mailservers.": "Credentials for the mail server.",
"Passwort": "Password",
// MARK: - Connect tab (ConnectView.swift)
"Verbindung": "Connection",
"IP-Adresse oder Hostname": "IP Address or Hostname",
"Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.":
"Your router's address on the network. MikroTik's factory default is usually 192.168.88.1.",
"Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".":
"Your router's admin username. The factory default is usually \"admin\".",
"Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.":
"The password for this user. Often empty in an unchanged factory state.",
"Passwort merken": "Remember Password",
"Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.":
"Stores the password encrypted in macOS Keychain so you don't have to type it in every time.",
"Verbinde…": "Connecting…",
"Verbunden": "Connected",
"Trennen": "Disconnect",
"Beendet die Verbindung zum Router. Zugangsdaten bleiben erhalten (falls gemerkt).":
"Ends the connection to the router. Credentials are kept (if remembered).",
"Sichere…": "Backing up…",
"Jetzt sichern": "Back Up Now",
"Sichert die aktuelle Router-Konfiguration — sinnvoll direkt nach dem Verbinden, bevor du im Einrichten-Tab etwas änderst.":
"Backs up the current router configuration — useful right after connecting, before you change anything in the Setup tab.",
"Zuletzt gesichert:": "Last backed up:",
"Unbekanntes Zertifikat": "Unknown Certificate",
"Vertrauen und verbinden": "Trust and Connect",
"Der Router hat sich mit einem unbekannten Zertifikat gemeldet.": "The router presented an unknown certificate.",
"Fingerabdruck:": "Fingerprint:",
"Nur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist.":
"Only confirm if this is your own device on the local network.",
"Unbekannter SSH-Schlüssel": "Unknown SSH Key",
"Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.": "The router presented an unknown SSH key.",
"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.":
"Only confirm if this is your own device on the local network. If you've connected to this router before and the fingerprint has changed, that could indicate a tampered network — when in doubt, don't confirm.",
"Sicherung fehlgeschlagen": "Backup Failed",
"Gerät": "Device",
"Modell": "Model",
"RouterOS-Version": "RouterOS Version",
"Architektur": "Architecture",
"Laufzeit": "Uptime",
"Modell (RB-Code)": "Model (RB code)",
"Seriennummer": "Serial Number",
"Firmware-Typ": "Firmware Type",
"Firmware (aktuell)": "Firmware (current)",
"Firmware (minimal)": "Firmware (minimum)",
"Firmware (verfügbar)": "Firmware (available)",
"Firmware-Update verfügbar": "Firmware Update Available",
"Aktualisiere…": "Updating…",
"Firmware aktualisieren": "Update Firmware",
"Firmware aktuell": "Firmware Up to Date",
"Starte neu…": "Restarting…",
"Jetzt neu starten": "Restart Now",
"Nötig, damit die neu geschriebene Firmware aktiv wird — passiert nicht automatisch.":
"Needed for the newly written firmware to take effect — doesn't happen automatically.",
"Software-Update": "Software Update",
"Kanal": "Channel",
"Installiert": "Installed",
"Neueste Version": "Latest Version",
"Installiere…": "Installing…",
"Update installieren": "Install Update",
"Prüfe…": "Checking…",
"Jetzt prüfen": "Check Now",
"Prüft bei MikroTik, ob eine neuere RouterOS-Version verfügbar ist. Braucht Internetzugang auf dem Router.":
"Checks with MikroTik whether a newer RouterOS version is available. Needs internet access on the router.",
"Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.":
"Update command sent — the router will download, install, and then reboot automatically.",
"Verbinde dich mit deinem Router, um Geräteinformationen zu sehen.":
"Connect to your router to see device information.",
"RouterOS-Update installieren?": "Install RouterOS Update?",
"Installieren": "Install",
"Lädt RouterOS": "Downloading RouterOS",
"die neueste Version": "the latest version",
" herunter, installiert es und startet den Router danach automatisch neu — die Verbindung geht dabei verloren. Vorher am besten eine Sicherung erstellen (\"Jetzt sichern\" oben).":
" and installs it, then automatically reboots the router — the connection will be lost. It's best to create a backup first (\"Back Up Now\" above).",
"Update-Prüfung fehlgeschlagen": "Update Check Failed",
"Update-Installation fehlgeschlagen": "Update Installation Failed",
"Routerboard-Firmware aktualisieren?": "Update Routerboard Firmware?",
"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.":
"Writes the new bootloader firmware to the router. A manual reboot is then needed — it doesn't happen automatically. This command normally asks for confirmation in an interactive session; whether that's also the case over this app hasn't been conclusively tested (similarly interactive commands have gone smoothly so far). If the app hangs here unusually long without responding, please report it.",
"Firmware-Aktualisierung fehlgeschlagen": "Firmware Update Failed",
"Router jetzt neu starten?": "Restart Router Now?",
"Neu starten": "Restart",
"Startet den Router sofort neu. Die Verbindung geht dabei kurz verloren, danach im Tab \"Verbinden\" erneut verbinden.":
"Restarts the router immediately. The connection will briefly drop — reconnect in the \"Connect\" tab afterwards.",
"Neustart fehlgeschlagen": "Restart Failed"
] ]
} }
@@ -7,6 +7,7 @@ struct ConnectView: View {
@State private var showUpdateInstallConfirmation = false @State private var showUpdateInstallConfirmation = false
@State private var showFirmwareUpgradeConfirmation = false @State private var showFirmwareUpgradeConfirmation = false
@State private var showRebootConfirmation = false @State private var showRebootConfirmation = false
@AppStorage("appLanguage") private var appLanguage: String = "de"
init(connectionService: ConnectionService) { init(connectionService: ConnectionService) {
self.connectionService = connectionService self.connectionService = connectionService
@@ -15,8 +16,8 @@ struct ConnectView: View {
var body: some View { var body: some View {
mainContent mainContent
.withUpdateDialogs(viewModel: viewModel, connectionService: connectionService, showUpdateInstallConfirmation: $showUpdateInstallConfirmation, showFirmwareUpgradeConfirmation: $showFirmwareUpgradeConfirmation) .withUpdateDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, showUpdateInstallConfirmation: $showUpdateInstallConfirmation, showFirmwareUpgradeConfirmation: $showFirmwareUpgradeConfirmation)
.withRebootDialogs(viewModel: viewModel, connectionService: connectionService, isPresented: $showRebootConfirmation) .withRebootDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: $showRebootConfirmation)
} }
/// Split out of `body` same reasoning as `BackupListView.restoreAwareContent` (Bug 19 in /// Split out of `body` same reasoning as `BackupListView.restoreAwareContent` (Bug 19 in
@@ -25,19 +26,19 @@ struct ConnectView: View {
private var mainContent: some View { private var mainContent: some View {
NavigationSplitView { NavigationSplitView {
Form { Form {
Section("Verbindung") { Section(L10n.t("Verbindung", appLanguage)) {
TextField("IP-Adresse oder Hostname", text: $viewModel.host) TextField(L10n.t("IP-Adresse oder Hostname", appLanguage), text: $viewModel.host)
.help("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.") .help(L10n.t("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.", appLanguage))
TextField("Benutzername", text: $viewModel.username) TextField(L10n.t("Benutzername", appLanguage), text: $viewModel.username)
.help("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".") .help(L10n.t("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".", appLanguage))
SecureField("Passwort", text: $viewModel.password) SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.password)
.help("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.") .help(L10n.t("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.", appLanguage))
Toggle("Passwort merken", isOn: $viewModel.rememberPassword) Toggle(L10n.t("Passwort merken", appLanguage), isOn: $viewModel.rememberPassword)
.help("Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.") .help(L10n.t("Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.", appLanguage))
} }
Section { Section {
Button("Verbinden") { Button(L10n.t("Verbinden", appLanguage)) {
viewModel.connect() viewModel.connect()
} }
.disabled(viewModel.host.isEmpty || viewModel.username.isEmpty) .disabled(viewModel.host.isEmpty || viewModel.username.isEmpty)
@@ -55,38 +56,42 @@ struct ConnectView: View {
backupViewModel.load() backupViewModel.load()
} }
.alert( .alert(
"Unbekanntes Zertifikat", L10n.t("Unbekanntes Zertifikat", appLanguage),
isPresented: certificateAlertBinding, isPresented: certificateAlertBinding,
presenting: certificateFingerprint presenting: certificateFingerprint
) { fingerprint in ) { fingerprint in
Button("Vertrauen und verbinden") { Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
viewModel.trustAndRetry(fingerprint: fingerprint) viewModel.trustAndRetry(fingerprint: fingerprint)
} }
Button("Abbrechen", role: .cancel) {} Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { fingerprint in } message: { fingerprint in
Text("Der Router hat sich mit einem unbekannten Zertifikat gemeldet.\nFingerabdruck: \(fingerprint)\n\nNur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist.") 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( .alert(
"Unbekannter SSH-Schlüssel", L10n.t("Unbekannter SSH-Schlüssel", appLanguage),
isPresented: sshHostKeyAlertBinding, isPresented: sshHostKeyAlertBinding,
presenting: sshHostKeyFingerprint presenting: sshHostKeyFingerprint
) { fingerprint in ) { fingerprint in
Button("Vertrauen und verbinden") { Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint) viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
} }
Button("Abbrechen", role: .cancel) {} Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { fingerprint in } message: { fingerprint in
Text("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.\nFingerabdruck: \(fingerprint)\n\nNur 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.") 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( .alert(
"Sicherung fehlgeschlagen", L10n.t("Sicherung fehlgeschlagen", appLanguage),
isPresented: Binding( isPresented: Binding(
get: { backupViewModel.errorMessage != nil }, get: { backupViewModel.errorMessage != nil },
set: { _ in backupViewModel.errorMessage = nil } set: { _ in backupViewModel.errorMessage = nil }
), ),
presenting: backupViewModel.errorMessage presenting: backupViewModel.errorMessage
) { _ in ) { _ in
Button("OK") {} Button(L10n.t("OK", appLanguage)) {}
} message: { message in } message: { message in
Text(message) Text(message)
} }
@@ -129,18 +134,18 @@ struct ConnectView: View {
Section { Section {
HStack { HStack {
ProgressView() ProgressView()
Text("Verbinde…") Text(L10n.t("Verbinde…", appLanguage))
} }
} }
case .connected(let kind): case .connected(let kind):
Section { Section {
Label("Verbunden (\(kind == .rest ? "REST-API" : "SSH"))", systemImage: "checkmark.circle.fill") Label(L10n.t("Verbunden", appLanguage) + " (\(kind == .rest ? "REST-API" : "SSH"))", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
Button("Trennen", role: .destructive) { Button(L10n.t("Trennen", appLanguage), role: .destructive) {
Task { await connectionService.disconnect() } Task { await connectionService.disconnect() }
} }
.help("Beendet die Verbindung zum Router. Zugangsdaten bleiben erhalten (falls gemerkt).") .help(L10n.t("Beendet die Verbindung zum Router. Zugangsdaten bleiben erhalten (falls gemerkt).", appLanguage))
Button { Button {
if let credentials = connectionService.credentials { if let credentials = connectionService.credentials {
@@ -150,18 +155,18 @@ struct ConnectView: View {
if backupViewModel.isCreatingBackup { if backupViewModel.isCreatingBackup {
HStack { HStack {
ProgressView() ProgressView()
Text("Sichere…") Text(L10n.t("Sichere…", appLanguage))
} }
} else { } else {
Label("Jetzt sichern", systemImage: "square.and.arrow.down") Label(L10n.t("Jetzt sichern", appLanguage), systemImage: "square.and.arrow.down")
} }
} }
.disabled(backupViewModel.isCreatingBackup) .disabled(backupViewModel.isCreatingBackup)
.help("Sichert die aktuelle Router-Konfiguration — sinnvoll direkt nach dem Verbinden, bevor du im Einrichten-Tab etwas änderst.") .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 }) { if let lastBackup = backupViewModel.backups.first(where: { $0.host == connectionService.credentials?.host }) {
Label( Label(
"Zuletzt gesichert: \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))", L10n.t("Zuletzt gesichert:", appLanguage) + " \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))",
systemImage: "checkmark" systemImage: "checkmark"
) )
.font(.caption) .font(.caption)
@@ -180,40 +185,40 @@ struct ConnectView: View {
private var deviceDetail: some View { private var deviceDetail: some View {
if let info = connectionService.deviceInfo { if let info = connectionService.deviceInfo {
Form { Form {
Section("Gerät") { Section(L10n.t("Gerät", appLanguage)) {
LabeledContent("Modell", value: info.boardName) LabeledContent(L10n.t("Modell", appLanguage), value: info.boardName)
LabeledContent("RouterOS-Version", value: info.routerOSVersion) LabeledContent(L10n.t("RouterOS-Version", appLanguage), value: info.routerOSVersion)
LabeledContent("Architektur", value: info.architecture) LabeledContent(L10n.t("Architektur", appLanguage), value: info.architecture)
LabeledContent("Laufzeit", value: info.uptime) LabeledContent(L10n.t("Laufzeit", appLanguage), value: info.uptime)
} }
if let board = connectionService.routerBoardInfo { if let board = connectionService.routerBoardInfo {
Section("Routerboard") { Section("Routerboard") {
LabeledContent("Modell (RB-Code)", value: board.model) LabeledContent(L10n.t("Modell (RB-Code)", appLanguage), value: board.model)
LabeledContent("Revision", value: board.revision) LabeledContent(L10n.t("Revision", appLanguage), value: board.revision)
LabeledContent("Seriennummer", value: board.serialNumber) LabeledContent(L10n.t("Seriennummer", appLanguage), value: board.serialNumber)
.textSelection(.enabled) .textSelection(.enabled)
LabeledContent("Firmware-Typ", value: board.firmwareType) LabeledContent(L10n.t("Firmware-Typ", appLanguage), value: board.firmwareType)
LabeledContent("Firmware (aktuell)", value: board.currentFirmware) LabeledContent(L10n.t("Firmware (aktuell)", appLanguage), value: board.currentFirmware)
LabeledContent("Firmware (minimal)", value: board.minimumFirmware) LabeledContent(L10n.t("Firmware (minimal)", appLanguage), value: board.minimumFirmware)
LabeledContent("Firmware (verfügbar)", value: board.upgradeFirmware) LabeledContent(L10n.t("Firmware (verfügbar)", appLanguage), value: board.upgradeFirmware)
// Confirmed via docs: same value = up to date, different = a bootloader // Confirmed via docs: same value = up to date, different = a bootloader
// update is available (only ever becomes available after installing a // update is available (only ever becomes available after installing a
// newer RouterOS package first the firmware ships bundled inside it). // newer RouterOS package first the firmware ships bundled inside it).
if board.currentFirmware != board.upgradeFirmware { if board.currentFirmware != board.upgradeFirmware {
Label("Firmware-Update verfügbar", systemImage: "arrow.up.circle.fill") Label(L10n.t("Firmware-Update verfügbar", appLanguage), systemImage: "arrow.up.circle.fill")
.foregroundStyle(.orange) .foregroundStyle(.orange)
Button { Button {
showFirmwareUpgradeConfirmation = true showFirmwareUpgradeConfirmation = true
} label: { } label: {
if viewModel.isUpgradingFirmware { if viewModel.isUpgradingFirmware {
HStack { ProgressView(); Text("Aktualisiere…") } HStack { ProgressView(); Text(L10n.t("Aktualisiere…", appLanguage)) }
} else { } else {
Label("Firmware aktualisieren", systemImage: "arrow.up.circle") Label(L10n.t("Firmware aktualisieren", appLanguage), systemImage: "arrow.up.circle")
} }
} }
.disabled(viewModel.isUpgradingFirmware) .disabled(viewModel.isUpgradingFirmware)
} else { } else {
Label("Firmware aktuell", systemImage: "checkmark.circle") Label(L10n.t("Firmware aktuell", appLanguage), systemImage: "checkmark.circle")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -223,30 +228,30 @@ struct ConnectView: View {
showRebootConfirmation = true showRebootConfirmation = true
} label: { } label: {
if viewModel.isRebooting { if viewModel.isRebooting {
HStack { ProgressView(); Text("Starte neu…") } HStack { ProgressView(); Text(L10n.t("Starte neu…", appLanguage)) }
} else { } else {
Label("Jetzt neu starten", systemImage: "arrow.clockwise") Label(L10n.t("Jetzt neu starten", appLanguage), systemImage: "arrow.clockwise")
} }
} }
.disabled(viewModel.isRebooting) .disabled(viewModel.isRebooting)
.help("Nötig, damit die neu geschriebene Firmware aktiv wird — passiert nicht automatisch.") .help(L10n.t("Nötig, damit die neu geschriebene Firmware aktiv wird — passiert nicht automatisch.", appLanguage))
} }
} }
} }
Section("Software-Update") { Section(L10n.t("Software-Update", appLanguage)) {
if let update = viewModel.packageUpdateInfo { if let update = viewModel.packageUpdateInfo {
LabeledContent("Kanal", value: update.channel) LabeledContent(L10n.t("Kanal", appLanguage), value: update.channel)
LabeledContent("Installiert", value: update.installedVersion) LabeledContent(L10n.t("Installiert", appLanguage), value: update.installedVersion)
LabeledContent("Neueste Version", value: update.latestVersion) LabeledContent(L10n.t("Neueste Version", appLanguage), value: update.latestVersion)
LabeledContent("Status", value: update.status) LabeledContent(L10n.t("Status", appLanguage), value: update.status)
if update.status == "New version is available" { if update.status == "New version is available" {
Button { Button {
showUpdateInstallConfirmation = true showUpdateInstallConfirmation = true
} label: { } label: {
if viewModel.isInstallingUpdate { if viewModel.isInstallingUpdate {
HStack { ProgressView(); Text("Installiere…") } HStack { ProgressView(); Text(L10n.t("Installiere…", appLanguage)) }
} else { } else {
Label("Update installieren", systemImage: "arrow.down.circle") Label(L10n.t("Update installieren", appLanguage), systemImage: "arrow.down.circle")
} }
} }
.disabled(viewModel.isInstallingUpdate) .disabled(viewModel.isInstallingUpdate)
@@ -258,17 +263,17 @@ struct ConnectView: View {
} }
} label: { } label: {
if viewModel.isCheckingForUpdates { if viewModel.isCheckingForUpdates {
HStack { ProgressView(); Text("Prüfe…") } HStack { ProgressView(); Text(L10n.t("Prüfe…", appLanguage)) }
} else { } else {
Label("Jetzt prüfen", systemImage: "arrow.triangle.2.circlepath") Label(L10n.t("Jetzt prüfen", appLanguage), systemImage: "arrow.triangle.2.circlepath")
} }
} }
.disabled(viewModel.isCheckingForUpdates) .disabled(viewModel.isCheckingForUpdates)
.help("Prüft bei MikroTik, ob eine neuere RouterOS-Version verfügbar ist. Braucht Internetzugang auf dem Router.") .help(L10n.t("Prüft bei MikroTik, ob eine neuere RouterOS-Version verfügbar ist. Braucht Internetzugang auf dem Router.", appLanguage))
if viewModel.didSendUpdateInstall { if viewModel.didSendUpdateInstall {
Label( Label(
"Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.", L10n.t("Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.", appLanguage),
systemImage: "arrow.clockwise" systemImage: "arrow.clockwise"
) )
.font(.caption) .font(.caption)
@@ -291,9 +296,9 @@ struct ConnectView: View {
.formStyle(.grouped) .formStyle(.grouped)
} else { } else {
ContentUnavailableView( ContentUnavailableView(
"Nicht verbunden", LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
systemImage: "network.slash", systemImage: "network.slash",
description: Text("Verbinde dich mit deinem Router, um Geräteinformationen zu sehen.") description: Text(L10n.t("Verbinde dich mit deinem Router, um Geräteinformationen zu sehen.", appLanguage))
) )
} }
} }
@@ -306,29 +311,32 @@ private extension View {
func withUpdateDialogs( func withUpdateDialogs(
viewModel: ConnectViewModel, viewModel: ConnectViewModel,
connectionService: ConnectionService, connectionService: ConnectionService,
appLanguage: String,
showUpdateInstallConfirmation: Binding<Bool>, showUpdateInstallConfirmation: Binding<Bool>,
showFirmwareUpgradeConfirmation: Binding<Bool> showFirmwareUpgradeConfirmation: Binding<Bool>
) -> some View { ) -> some View {
self self
.withSoftwareUpdateDialogs(viewModel: viewModel, connectionService: connectionService, isPresented: showUpdateInstallConfirmation) .withSoftwareUpdateDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: showUpdateInstallConfirmation)
.withFirmwareUpgradeDialogs(viewModel: viewModel, connectionService: connectionService, isPresented: showFirmwareUpgradeConfirmation) .withFirmwareUpgradeDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: showFirmwareUpgradeConfirmation)
} }
func withSoftwareUpdateDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, isPresented: Binding<Bool>) -> some View { func withSoftwareUpdateDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
self self
.confirmationDialog( .confirmationDialog(
"RouterOS-Update installieren?", L10n.t("RouterOS-Update installieren?", appLanguage),
isPresented: isPresented, isPresented: isPresented,
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button("Installieren", role: .destructive) { Button(L10n.t("Installieren", appLanguage), role: .destructive) {
if let credentials = connectionService.credentials { if let credentials = connectionService.credentials {
viewModel.installUpdate(for: credentials) viewModel.installUpdate(for: credentials)
} }
} }
Button("Abbrechen", role: .cancel) {} Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { } message: {
Text("Lädt RouterOS \(viewModel.packageUpdateInfo?.latestVersion ?? "die neueste Version") herunter, installiert es und startet den Router danach automatisch neu — die Verbindung geht dabei verloren. Vorher am besten eine Sicherung erstellen (\"Jetzt sichern\" oben).") 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 .onChange(of: viewModel.didSendUpdateInstall) { _, didSend in
if didSend { if didSend {
@@ -336,76 +344,76 @@ private extension View {
} }
} }
.alert( .alert(
"Update-Prüfung fehlgeschlagen", L10n.t("Update-Prüfung fehlgeschlagen", appLanguage),
isPresented: Binding( isPresented: Binding(
get: { viewModel.updateCheckError != nil }, get: { viewModel.updateCheckError != nil },
set: { _ in viewModel.updateCheckError = nil } set: { _ in viewModel.updateCheckError = nil }
), ),
presenting: viewModel.updateCheckError presenting: viewModel.updateCheckError
) { _ in ) { _ in
Button("OK") {} Button(L10n.t("OK", appLanguage)) {}
} message: { message in } message: { message in
Text(message) Text(message)
} }
.alert( .alert(
"Update-Installation fehlgeschlagen", L10n.t("Update-Installation fehlgeschlagen", appLanguage),
isPresented: Binding( isPresented: Binding(
get: { viewModel.installUpdateError != nil }, get: { viewModel.installUpdateError != nil },
set: { _ in viewModel.installUpdateError = nil } set: { _ in viewModel.installUpdateError = nil }
), ),
presenting: viewModel.installUpdateError presenting: viewModel.installUpdateError
) { _ in ) { _ in
Button("OK") {} Button(L10n.t("OK", appLanguage)) {}
} message: { message in } message: { message in
Text(message) Text(message)
} }
} }
func withFirmwareUpgradeDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, isPresented: Binding<Bool>) -> some View { func withFirmwareUpgradeDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
self self
.confirmationDialog( .confirmationDialog(
"Routerboard-Firmware aktualisieren?", L10n.t("Routerboard-Firmware aktualisieren?", appLanguage),
isPresented: isPresented, isPresented: isPresented,
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button("Aktualisieren", role: .destructive) { Button(L10n.t("Aktualisieren", appLanguage), role: .destructive) {
if let credentials = connectionService.credentials { if let credentials = connectionService.credentials {
viewModel.upgradeRouterboardFirmware(for: credentials) viewModel.upgradeRouterboardFirmware(for: credentials)
} }
} }
Button("Abbrechen", role: .cancel) {} Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { } message: {
Text("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.") 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( .alert(
"Firmware-Aktualisierung fehlgeschlagen", L10n.t("Firmware-Aktualisierung fehlgeschlagen", appLanguage),
isPresented: Binding( isPresented: Binding(
get: { viewModel.firmwareUpgradeError != nil }, get: { viewModel.firmwareUpgradeError != nil },
set: { _ in viewModel.firmwareUpgradeError = nil } set: { _ in viewModel.firmwareUpgradeError = nil }
), ),
presenting: viewModel.firmwareUpgradeError presenting: viewModel.firmwareUpgradeError
) { _ in ) { _ in
Button("OK") {} Button(L10n.t("OK", appLanguage)) {}
} message: { message in } message: { message in
Text(message) Text(message)
} }
} }
func withRebootDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, isPresented: Binding<Bool>) -> some View { func withRebootDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
self self
.confirmationDialog( .confirmationDialog(
"Router jetzt neu starten?", L10n.t("Router jetzt neu starten?", appLanguage),
isPresented: isPresented, isPresented: isPresented,
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button("Neu starten", role: .destructive) { Button(L10n.t("Neu starten", appLanguage), role: .destructive) {
if let credentials = connectionService.credentials { if let credentials = connectionService.credentials {
viewModel.reboot(for: credentials) viewModel.reboot(for: credentials)
} }
} }
Button("Abbrechen", role: .cancel) {} Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { } message: {
Text("Startet den Router sofort neu. Die Verbindung geht dabei kurz verloren, danach im Tab \"Verbinden\" erneut verbinden.") 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 .onChange(of: viewModel.didSendReboot) { _, didSend in
if didSend { if didSend {
@@ -413,14 +421,14 @@ private extension View {
} }
} }
.alert( .alert(
"Neustart fehlgeschlagen", L10n.t("Neustart fehlgeschlagen", appLanguage),
isPresented: Binding( isPresented: Binding(
get: { viewModel.rebootError != nil }, get: { viewModel.rebootError != nil },
set: { _ in viewModel.rebootError = nil } set: { _ in viewModel.rebootError = nil }
), ),
presenting: viewModel.rebootError presenting: viewModel.rebootError
) { _ in ) { _ in
Button("OK") {} Button(L10n.t("OK", appLanguage)) {}
} message: { message in } message: { message in
Text(message) Text(message)
} }