forked from kay/RouterOS
M21: Zweisprachigkeit (DE/EN) auf alle fünf Tabs ausgerollt
Einrichten-Wizard (alle sieben Schritte), Übersicht, LAN-Scanner, Sicherungen laufen jetzt über L10n.t statt fester deutscher String-Literale — Experte- und Verbinden-Tab waren bereits vorher umgestellt (M16/M17). Model-Layer-Strings werden am Verwendungsort gewrappt, ViewModel-generierte Laufzeitstrings (Fehlermeldungen, CLI-Zeilen, Log) bleiben bewusst unübersetzt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+26
-7
@@ -1259,6 +1259,27 @@ grün, wartet auf Test durch den Nutzer in Xcode):
|
||||
Funktionen aufgeteilt (`withStaticAssignmentDialogs`/
|
||||
`withDeviceDetailSheets`).
|
||||
|
||||
- ✅ M21: `L10n.t(...)`-Zweisprachigkeit (Bug-19-Muster: M16 begann im
|
||||
Experte-Tab, siehe oben) auf die restlichen vier Tabs ausgerollt —
|
||||
Einrichten (alle sieben Wizard-Schritte:
|
||||
Mode/Wan/Lan/Vlan/Wifi/Firewall/Review, inkl. `PortConflictWarningView`
|
||||
als eigener Sub-Struct mit durchgereichtem `appLanguage`), Übersicht
|
||||
(inkl. `EdgeTooltipView`/`EdgeDetailView`/`NodeDetailView`/`LegendView`),
|
||||
LAN-Scanner, Sicherungen. `L10n.swift` um einen entsprechenden
|
||||
Übersetzungsblock erweitert; Model-Layer-Strings (z.B.
|
||||
`WanConnectionMode.label`, `OverviewNode.Category.rawValue`,
|
||||
`OverviewEdgeKind.rawValue`) werden wie schon bei `RouterOSMenuCategory`
|
||||
am Verwendungsort gewrappt, nicht in der Model-Datei selbst geändert.
|
||||
Etablierte Konvention beibehalten: nur statische View-Strings werden
|
||||
übersetzt, ViewModel-generierte Laufzeitstrings (Fehlermeldungen,
|
||||
`command.summary`/`cliLine`, `applyLog`-Zeilen, Router-Live-Daten)
|
||||
bleiben unübersetzt — dieselbe Grenze, die schon der Experte-Tab zieht.
|
||||
Bug 19 (Modifier-Ketten-Typecheck-Timeout) trat dabei ein drittes Mal
|
||||
in `DevicesView.swift` auf (zwei lange Confirmation-Dialog-Texte), Fix
|
||||
wie gehabt: in eigene Funktionen (`staticAssignmentMessage`/
|
||||
`staticRemovalMessage`) ausgelagert statt inline verkettet. Live
|
||||
bestätigt ("funktioniert").
|
||||
|
||||
**Nutzerfrage, keine Umsetzung** (2026-09-15): ob es Sinn macht, weitere
|
||||
Sprachen "per Plugin" zu importieren. Antwort: für 1-2 weitere feste
|
||||
Sprachen passt das bestehende `L10n.swift`-Dictionary-Muster gut; ein
|
||||
@@ -1271,13 +1292,11 @@ Claude-Memory `future-language-support.md` für Details.
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~
|
||||
und ~~Verbinden-Tab zweisprachig~~ — beides erledigt, live bestätigt.
|
||||
Offen: dieselbe `L10n.t(...)`-Umstellung auf die übrigen vier Tabs
|
||||
(Einrichten/Übersicht/Geräte/Sicherungen) ausrollen — dort läuft
|
||||
aktuell noch alles auf festen deutschen String-Literalen ohne
|
||||
Sprachumschaltung, mit dem Nutzer vereinbart Tab für Tab
|
||||
weiterzumachen.
|
||||
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~,
|
||||
~~Verbinden-Tab zweisprachig~~ und ~~`L10n.t(...)`-Umstellung auf die
|
||||
übrigen vier Tabs (Einrichten/Übersicht/LAN-Scanner/Sicherungen)~~ —
|
||||
alles erledigt, live bestätigt ("funktioniert"). Damit sind alle
|
||||
fünf Tabs vollständig zweisprachig (DE/EN).
|
||||
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
|
||||
mit aktivem `www-ssl`.
|
||||
|
||||
@@ -41,11 +41,11 @@ Einrichten, Übersicht, LAN-Scanner, Experte, Sicherungen. Die App spricht mit
|
||||
dem Router per REST-API (bevorzugt) oder SSH (Fallback) — beides wird
|
||||
automatisch gewählt, ohne dass du dich darum kümmern musst.
|
||||
|
||||
Ein DE/EN-Umschalt-Button in der Toolbar wechselt die Sprache der App
|
||||
(persistiert über Neustarts). Aktuell vollständig übersetzt: alle
|
||||
Tab-Namen, der komplette Experte-Tab (alle Menü-Bereiche, Buttons,
|
||||
Tooltips) sowie der komplette Verbinden-Tab. Weitere Tabs folgen
|
||||
schrittweise.
|
||||
Ein DE/EN-Umschalt-Button (Landesflagge) in der Toolbar wechselt die
|
||||
Sprache der App (persistiert über Neustarts). Alle fünf Tabs sind
|
||||
vollständig übersetzt (statische UI-Texte, Buttons, Tooltips). Werte, die
|
||||
direkt vom Router kommen (Fehlermeldungen, CLI-Befehlszeilen, Live-Log),
|
||||
bleiben unübersetzt.
|
||||
|
||||
## 1. Verbinden
|
||||
|
||||
@@ -206,10 +206,10 @@ Connect, Setup, Topology, LAN Scanner, Expert, Backups. The app talks to the
|
||||
router over its REST API (preferred) or SSH (fallback) — chosen
|
||||
automatically, no configuration needed.
|
||||
|
||||
A DE/EN toggle button in the toolbar switches the app's language
|
||||
(persists across restarts). Fully translated so far: every tab name,
|
||||
the entire Expert tab (all menu areas, buttons, tooltips), and the
|
||||
entire Connect tab. More tabs will follow incrementally.
|
||||
A DE/EN toggle button (country flag) in the toolbar switches the app's
|
||||
language (persists across restarts). All five tabs are fully translated
|
||||
(static UI text, buttons, tooltips). Values that come straight from the
|
||||
router (error messages, CLI command lines, live log) stay untranslated.
|
||||
|
||||
## 1. Connect
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
|
||||
| M18 | Live-Traffic-Anzeige an Interfaces (Verbinden-Tab) | ✅ live verifiziert |
|
||||
| M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ live verifiziert |
|
||||
| M20 | LAN-Scanner: Umbenennung, Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan) | ✅ live verifiziert |
|
||||
| M21 | Zweisprachigkeit (DE/EN) auf alle fünf Tabs ausgerollt (Einrichten/Übersicht/LAN-Scanner/Sicherungen) | ✅ live verifiziert |
|
||||
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
|
||||
|
||||
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
|
||||
|
||||
@@ -621,6 +621,311 @@ enum L10n {
|
||||
"Standort (optional, z.B. Keller, 1. OG)": "Location (optional, e.g. Basement, 1st Floor)",
|
||||
"Entfernen": "Remove",
|
||||
"Fertig": "Done",
|
||||
"Name": "Name"
|
||||
"Name": "Name",
|
||||
|
||||
// MARK: - Setup wizard (Einrichten tab)
|
||||
"Einfach": "Simple",
|
||||
"Grundeinrichtung: Internetverbindung (WAN), ein Heimnetzwerk (LAN) mit DHCP, WLAN und ein fest aktivierter Firewall-Grundschutz.":
|
||||
"Basic setup: internet connection (WAN), one home network (LAN) with DHCP, Wi-Fi, and a fixed, always-on firewall baseline.",
|
||||
"Kein VLAN, keine mehreren getrennten Netzwerke, keine Netzwerk-Isolation — für später jederzeit über den Expertenmodus nachrüstbar.":
|
||||
"No VLAN, no multiple separate networks, no network isolation — can be added later anytime via expert mode.",
|
||||
"Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.":
|
||||
"Full access: multiple LAN interfaces each with its own DHCP server, VLANs, network isolation between networks, and all firewall options.",
|
||||
"Weiter": "Next",
|
||||
"Zurück": "Back",
|
||||
"Modus wählen": "Choose Mode",
|
||||
|
||||
"Internet-Anschluss": "Internet Connection",
|
||||
"Anschluss (Port)": "Port",
|
||||
"Der Netzwerkport, über den dein Router mit dem Internet verbunden ist (z.B. das Kabel zu deinem Modem oder deiner Anschlussdose).":
|
||||
"The network port your router uses to connect to the internet (e.g. the cable to your modem or wall socket).",
|
||||
"Verbindungsart": "Connection Type",
|
||||
"Wie sich der Router beim Internetanbieter anmeldet. \"Automatisch (DHCP)\" passt für die meisten Kabel-/Glasfaseranschlüsse.":
|
||||
"How the router signs in with your internet provider. \"Automatic (DHCP)\" fits most cable/fiber connections.",
|
||||
"Automatisch (DHCP)": "Automatic (DHCP)",
|
||||
"Statische IP-Adresse": "Static IP Address",
|
||||
"PPPoE (DSL-Zugangsdaten)": "PPPoE (DSL credentials)",
|
||||
"Der Router bezieht seine Internetadresse automatisch von deinem Anbieter. Für die meisten Kabel-/Glasfaser-Anschlüsse die richtige Wahl.":
|
||||
"The router gets its internet address automatically from your provider. The right choice for most cable/fiber connections.",
|
||||
"Statische Adresse": "Static Address",
|
||||
"IP-Adresse (z.B. 203.0.113.5/24)": "IP Address (e.g. 203.0.113.5/24)",
|
||||
"Die feste IP-Adresse, die dir dein Anbieter zugewiesen hat, inklusive Netzmaske (die Zahl nach dem /, z.B. /24).":
|
||||
"The fixed IP address assigned to you by your provider, including the subnet mask (the number after the /, e.g. /24).",
|
||||
"Gateway (z.B. 203.0.113.1)": "Gateway (e.g. 203.0.113.1)",
|
||||
"Die Adresse des nächsten Geräts Richtung Internet — steht meist in den Unterlagen deines Anbieters.":
|
||||
"The address of the next device towards the internet — usually found in your provider's documentation.",
|
||||
"PPPoE-Zugangsdaten": "PPPoE Credentials",
|
||||
"Zugangsdaten von deinem Internetanbieter für die Einwahl (z.B. bei DSL-Anschlüssen).":
|
||||
"Credentials from your internet provider for dial-in (e.g. for DSL connections).",
|
||||
"Das zum Benutzernamen gehörende Passwort von deinem Internetanbieter.":
|
||||
"The password from your internet provider that goes with the username.",
|
||||
"Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).":
|
||||
"You get these credentials from your internet provider (e.g. Telekom, Vodafone).",
|
||||
"Internet einrichten": "Set Up Internet",
|
||||
|
||||
"Netzwerk auf": "Network on",
|
||||
"Anschluss/Bridge": "Port/Bridge",
|
||||
"Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).":
|
||||
"The internal network port your devices connect to (your local network, LAN).",
|
||||
"Prüfe, ob der Port frei ist…": "Checking whether the port is free…",
|
||||
"Router-Adresse (z.B. 192.168.88.1/24)": "Router Address (e.g. 192.168.88.1/24)",
|
||||
"Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.":
|
||||
"The address the router itself is reachable at on this network.",
|
||||
"Netzwerk (z.B. 192.168.88.0/24)": "Network (e.g. 192.168.88.0/24)",
|
||||
"Der komplette Adressbereich dieses Netzwerks (z.B. /24 erlaubt bis zu 254 Geräte).":
|
||||
"The complete address range of this network (e.g. /24 allows up to 254 devices).",
|
||||
"Automatische Adressvergabe von": "Automatic Address Assignment From",
|
||||
"Ab welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.":
|
||||
"The address from which the router automatically hands out addresses to devices on this network.",
|
||||
"bis": "to",
|
||||
"Bis zu welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.":
|
||||
"The address up to which the router automatically hands out addresses to devices on this network.",
|
||||
"Adresse behalten für": "Keep address for",
|
||||
"Stunden": "hours",
|
||||
"Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.":
|
||||
"How long a device keeps its assigned address before it needs to be renewed.",
|
||||
"Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.":
|
||||
"Which server translates internet addresses into names for devices on this network (e.g. www.google.de). Usually the router itself.",
|
||||
"Von anderen Netzwerken isolieren": "Isolate From Other Networks",
|
||||
"Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.":
|
||||
"Prevents traffic between this and all other configured LAN/VLAN networks. Internet access is kept. Implemented in the Firewall step.",
|
||||
"Netzwerk entfernen": "Remove Network",
|
||||
"Weiteres LAN-Netzwerk hinzufügen": "Add Another LAN Network",
|
||||
"Heimnetzwerk einrichten": "Set Up Home Network",
|
||||
|
||||
"wird beim Anwenden freigemacht": "will be freed up on apply",
|
||||
"ist nicht frei": "is not free",
|
||||
"Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.":
|
||||
"Choose a different, free port above — or free this one up now. Its existing configuration will be removed in the process.",
|
||||
"Port jetzt freimachen…": "Free Up Port Now…",
|
||||
"freimachen?": "free up?",
|
||||
"Fortfahren": "Continue",
|
||||
"Wirklich sicher?": "Really sure?",
|
||||
"Ja, endgültig freimachen": "Yes, free it up for good",
|
||||
"Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.":
|
||||
"This is only actually carried out with \"Apply Now\" at the end of the wizard — until then you can undo it by choosing a different port above.",
|
||||
"Folgendes wird entfernt, bevor": "The following will be removed before",
|
||||
"als neues Netzwerk eingerichtet wird:": "is set up as a new network:",
|
||||
"Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen.":
|
||||
"Existing traffic over this port (e.g. an active internet connection or devices on the previous network) will be interrupted as a result.",
|
||||
|
||||
"Netzwerk": "Network",
|
||||
"Zusätzliches Netzwerk einrichten (VLAN, optional)": "Set Up Additional Network (VLAN, optional)",
|
||||
"Erstellt ein zusätzliches Netzwerk mit eigenem Adressbereich und eigenem DHCP-Server.":
|
||||
"Creates an additional network with its own address range and its own DHCP server.",
|
||||
"Ein VLAN ist ein zusätzliches Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Ob es vom Hauptnetzwerk abgeschottet ist, legst du unten pro Netzwerk über \"Von anderen Netzwerken isolieren\" fest. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.":
|
||||
"A VLAN is an additional network with its own address range — e.g. for guests or smart devices. Whether it's shielded from the main network is set below, per network, via \"Isolate From Other Networks\". If you're unsure whether you need this, just skip this step.",
|
||||
"Ein Name zur Wiedererkennung, z.B. \"Gäste\" oder \"Smart Home\".": "A name for your own reference, e.g. \"Guests\" or \"Smart Home\".",
|
||||
"Eine eindeutige Nummer zur technischen Unterscheidung dieses Netzwerks. Muss nur innerhalb deines Routers einmalig sein.":
|
||||
"A unique number to technically distinguish this network. Only needs to be unique within your router.",
|
||||
"Basis-Anschluss": "Base Port",
|
||||
"Der physische Anschluss, auf dem dieses zusätzliche Netzwerk aufbaut.": "The physical port this additional network is built on.",
|
||||
"Router-Adresse (z.B. 192.168.20.1/24)": "Router Address (e.g. 192.168.20.1/24)",
|
||||
"Die Adresse des Routers innerhalb dieses zusätzlichen Netzwerks.": "The router's address within this additional network.",
|
||||
"Netzwerk (z.B. 192.168.20.0/24)": "Network (e.g. 192.168.20.0/24)",
|
||||
"Der komplette Adressbereich dieses zusätzlichen Netzwerks.": "The complete address range of this additional network.",
|
||||
"Ab welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.":
|
||||
"The address from which devices on this network automatically get an address.",
|
||||
"Bis zu welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.":
|
||||
"The address up to which devices on this network automatically get an address.",
|
||||
"Weiteres Netzwerk hinzufügen": "Add Another Network",
|
||||
"Zusätzliche Netzwerke (VLAN)": "Additional Networks (VLAN)",
|
||||
|
||||
"An diesem Gerät wurde kein WLAN erkannt. Dieser Schritt wird übersprungen.":
|
||||
"No Wi-Fi was detected on this device. This step is skipped.",
|
||||
"Dein Gerät nutzt den neueren RouterOS-WiFi-Treiber (": "Your device uses the newer RouterOS Wi-Fi driver (",
|
||||
"), den diese App noch nicht unterstützt. WLAN muss dafür vorerst manuell über Winbox eingerichtet werden.":
|
||||
"), which this app doesn't support yet. Wi-Fi must be set up manually via Winbox for now.",
|
||||
"WLAN auf": "Wi-Fi on",
|
||||
"Aktivieren": "Enable",
|
||||
"Schaltet WLAN auf diesem Funkmodul ein und vergibt Netzwerkname und Passwort.":
|
||||
"Turns on Wi-Fi on this radio module and sets the network name and password.",
|
||||
"Der Name, den Geräte in ihrer WLAN-Liste sehen und mit dem sie sich verbinden.":
|
||||
"The name devices see in their Wi-Fi list and use to connect.",
|
||||
"Passwort (mind. 8 Zeichen)": "Password (at least 8 characters)",
|
||||
"Das WLAN-Passwort (WPA2). Muss mindestens 8 Zeichen lang sein.": "The Wi-Fi password (WPA2). Must be at least 8 characters long.",
|
||||
"WLAN (falls vorhanden)": "Wi-Fi (if available)",
|
||||
|
||||
"Firewall-Grundschutz einrichten (empfohlen)": "Set Up Firewall Baseline (recommended)",
|
||||
"Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).":
|
||||
"Protects your router and your devices from unsolicited access from the internet and gives your devices internet access (NAT).",
|
||||
"Firewall-Grundschutz ist im einfachen Modus immer aktiv.": "The firewall baseline is always active in simple mode.",
|
||||
"Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router und deine Geräte. Bestehende, selbst eingerichtete Regeln bleiben erhalten — die neuen Regeln werden vorangestellt.":
|
||||
"Sets up standard protection: internet sharing (NAT) for your home network, and blocks unsolicited access from the internet to your router and your devices. Existing, manually created rules are kept — the new rules are placed at the front.",
|
||||
"Bereits vorhandene Regeln": "Existing Rules",
|
||||
"Dein Router hat bereits eigene Firewall-Regeln. Die neuen Regeln werden vorangestellt, bestehende bleiben erhalten — prüfe nach dem Anwenden trotzdem die Reihenfolge, z.B. über Winbox oder \"/ip firewall filter print\".":
|
||||
"Your router already has its own firewall rules. The new rules are placed at the front, existing ones are kept — still check the order after applying, e.g. via Winbox or \"/ip firewall filter print\".",
|
||||
"Isolierte Netzwerke": "Isolated Networks",
|
||||
"Diese Netzwerke werden vom Datenverkehr aller anderen konfigurierten Netzwerke abgeschottet, sobald der Firewall-Grundschutz aktiv ist.":
|
||||
"These networks are shielded from traffic from all other configured networks once the firewall baseline is active.",
|
||||
"Firewall (optional)": "Firewall (optional)",
|
||||
|
||||
"Geplante Änderungen": "Planned Changes",
|
||||
"Details anzeigen (RouterOS-Befehle)": "Show Details (RouterOS Commands)",
|
||||
"Vor dem Anwenden wird automatisch eine Sicherung der aktuellen Konfiguration erstellt (Tab \"Sicherungen\"). Ein garantiertes automatisches Zurückrollen bei Verbindungsabbruch gibt es nicht — bei Problemen die Sicherung im Tab \"Sicherungen\" verwenden oder den Router lokal (Ethernet/Konsole) wiederherstellen.":
|
||||
"Before applying, a backup of the current configuration is automatically created (\"Backups\" tab). There's no guaranteed automatic rollback if the connection drops — if there are problems, use the backup in the \"Backups\" tab, or restore the router locally (Ethernet/console).",
|
||||
"Ablauf": "Progress",
|
||||
"Änderungen erfolgreich angewendet": "Changes Applied Successfully",
|
||||
"Jetzt anwenden": "Apply Now",
|
||||
"Übersicht & Anwenden": "Overview & Apply",
|
||||
"Anwenden fehlgeschlagen": "Apply Failed",
|
||||
|
||||
// MARK: - Overview tab (OverviewView.swift + OverviewGraph.swift)
|
||||
"Aktualisieren": "Refresh",
|
||||
"Zurücksetzen": "Reset",
|
||||
"Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.":
|
||||
"Reset layout — moves manually dragged cards back to their original arrangement.",
|
||||
"Lade Router-Konfiguration…": "Loading router configuration…",
|
||||
"Konnte Konfiguration nicht laden": "Could Not Load Configuration",
|
||||
"Keine Konfiguration gefunden": "No Configuration Found",
|
||||
"Der Router meldet aktuell keine Interfaces/Adressen.": "The router currently reports no interfaces/addresses.",
|
||||
"Von": "From",
|
||||
"Nach": "To",
|
||||
"Öffnet dasselbe Bearbeiten-Formular wie im Experte-Tab und schreibt Änderungen direkt an den Router.":
|
||||
"Opens the same edit form as the Expert tab and writes changes directly to the router.",
|
||||
"Verbindungen": "Connections",
|
||||
"Zeigt den kompletten IST-Zustand des verbundenen Routers als Diagramm: Interfaces links, darauf aufbauend IP-Adressen, DHCP/Pools, Routen und rechts die Firewall/NAT-Regeln, die auf sie zugreifen. Linien zeigen echte Abhängigkeiten (z.B. \"DHCP-Server nutzt diesen Pool\"), keine Vermutungen.":
|
||||
"Shows the connected router's complete current state as a diagram: interfaces on the left, IP addresses/DHCP/pools/routes built on top of them, and the firewall/NAT rules that reference them on the right. Lines show real dependencies (e.g. \"DHCP server uses this pool\"), not guesses.",
|
||||
"Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.": "Clicking a card shows details and all its connections here on the right.",
|
||||
"Nicht im Diagramm (aber im Experte-Tab erreichbar):": "Not in the diagram (but reachable in the Expert tab):",
|
||||
"Spalten": "Columns",
|
||||
"Verbindungsarten": "Connection Types",
|
||||
|
||||
"Interfaces": "Interfaces",
|
||||
"Pools & DHCP": "Pools & DHCP",
|
||||
"Routen": "Routes",
|
||||
"Firewall & NAT": "Firewall & NAT",
|
||||
|
||||
"VLAN → Basis-Interface": "VLAN → Base Interface",
|
||||
"Bridge-Port": "Bridge Port",
|
||||
"WireGuard-Peer": "WireGuard Peer",
|
||||
"Interface → IP-Adresse": "Interface → IP Address",
|
||||
"DHCP / Pool": "DHCP / Pool",
|
||||
"Route → Interface": "Route → Interface",
|
||||
"Firewall/NAT → Interface": "Firewall/NAT → Interface",
|
||||
"Adress-Liste → Regel": "Address List → Rule",
|
||||
|
||||
"Das VLAN-Interface baut auf dem Basis-Interface auf — es ist ein eigenes, per VLAN-Kennung getrenntes Netzwerk auf demselben physischen Anschluss.":
|
||||
"The VLAN interface builds on the base interface — it's its own network, separated by VLAN tag, on the same physical port.",
|
||||
"Dieser physische Port ist Mitglied dieser Bridge — Geräte an diesem Port verhalten sich, als hingen sie am selben Kabel wie alle anderen Bridge-Ports.":
|
||||
"This physical port is a member of this bridge — devices on this port behave as if they hung off the same cable as every other bridge port.",
|
||||
"Dieser WireGuard-VPN-Tunnel läuft über dieses Interface.": "This WireGuard VPN tunnel runs over this interface.",
|
||||
"Diesem Interface ist diese IP-Adresse zugewiesen.": "This IP address is assigned to this interface.",
|
||||
"Diese DHCP-Komponente (Server, Pool oder Netzwerk-Optionen) gehört zu diesem Interface bzw. dieser Adresse.":
|
||||
"This DHCP component (server, pool, or network options) belongs to this interface or address.",
|
||||
"Diese Route führt über dieses Interface bzw. dieses Gateway.": "This route goes via this interface or gateway.",
|
||||
"Diese Firewall- oder NAT-Regel bezieht sich auf dieses Interface (als Eingang oder Ausgang).":
|
||||
"This firewall or NAT rule refers to this interface (as input or output).",
|
||||
"Diese Regel prüft, ob eine Adresse in dieser Adress-Liste steht.": "This rule checks whether an address is in this address list.",
|
||||
|
||||
"VPN: PPP-Benutzer/-Profile": "VPN: PPP Users/Profiles",
|
||||
"Queues/Bandbreiten-Steuerung": "Queues/Bandwidth Control",
|
||||
"System (Name/Uhrzeit/Scheduler/Skripte/Benutzerkonten)": "System (Name/Time/Scheduler/Scripts/User Accounts)",
|
||||
"Werkzeuge (Netwatch/E-Mail)": "Tools (Netwatch/Email)",
|
||||
"Firewall: Mangle- und Raw-Regeln": "Firewall: Mangle and Raw Rules",
|
||||
|
||||
// MARK: - LAN Scanner tab (DevicesView.swift)
|
||||
"Suche Geräte im Netz…": "Searching for devices on the network…",
|
||||
"Konnte Geräte nicht laden": "Could Not Load Devices",
|
||||
"Keine Ports gefunden": "No Ports Found",
|
||||
"Der Router meldet aktuell keine physischen Interfaces.": "The router currently reports no physical interfaces.",
|
||||
"Keine Geräte": "No Devices",
|
||||
"Führe Netzwerk-Test aus…": "Running network test…",
|
||||
"Neu scannen": "Rescan",
|
||||
"Feste Zuweisung entfernen": "Remove Static Assignment",
|
||||
"Feste IP zuweisen": "Assign Static IP",
|
||||
"Kein DHCP-Lease — feste Zuweisung hier nicht möglich": "No DHCP lease — static assignment not possible here",
|
||||
"DNS-Auflösung (nslookup)": "DNS Lookup (nslookup)",
|
||||
"Port-Scan": "Port Scan",
|
||||
"Netzwerk-Tools": "Network Tools",
|
||||
"Rohdaten anzeigen": "Show Raw Data",
|
||||
"Feste IP-Adresse zuweisen?": "Assign Static IP Address?",
|
||||
"Zuweisen": "Assign",
|
||||
"wird dauerhaft an": "will be permanently bound to",
|
||||
" gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch.":
|
||||
". Its address will no longer change automatically for this device.",
|
||||
"\n\nRückgängig machen: hier im LAN-Scanner-Tab per Rechtsklick auf das Gerät":
|
||||
"\n\nTo undo: right-click the device here in the LAN Scanner tab",
|
||||
" → \"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort":
|
||||
" → \"Remove Static Assignment\". The device won't immediately",
|
||||
" automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und":
|
||||
" get a new address automatically though — it first needs to briefly disconnect and",
|
||||
" neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten).":
|
||||
" reconnect (briefly unplug/replug the network cable, toggle Wi-Fi off/on, or restart it).",
|
||||
"\n\nBefehl:": "\n\nCommand:",
|
||||
"Feste Zuweisung entfernen?": "Remove Static Assignment?",
|
||||
"Die feste Zuweisung für": "The static assignment for",
|
||||
" wird entfernt. RouterOS kennt keine direkte Umkehrung von \"Make Static\" —":
|
||||
" will be removed. RouterOS has no direct reverse of \"Make Static\" —",
|
||||
" das Gerät bekommt erst beim nächsten Verbindungsaufbau (Kabel/WLAN neu":
|
||||
" the device will only get a new address via DHCP again the next time it connects (cable/Wi-Fi",
|
||||
" verbinden, Neustart) automatisch wieder eine Adresse per DHCP, eventuell":
|
||||
" reconnect, restart), possibly",
|
||||
" eine andere IP als bisher.": " a different IP than before.",
|
||||
"Fehler": "Error",
|
||||
"IP-Adresse": "IP Address",
|
||||
"MAC-Adresse": "MAC Address",
|
||||
"Status": "Status",
|
||||
"Kein DHCP": "No DHCP",
|
||||
"Fest": "Static",
|
||||
"Dynamisch": "Dynamic",
|
||||
"Rohdaten:": "Raw Data:",
|
||||
"Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.":
|
||||
"All fields RouterOS returned for this entry — useful if the status/port looks wrong here.",
|
||||
"Schließen": "Close",
|
||||
"Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.":
|
||||
"Run from the router itself (its own SSH connection) — tests reachability from the router to this device, not from this Mac.",
|
||||
"(keine Ausgabe)": "(no output)",
|
||||
"Port-Scan:": "Port Scan:",
|
||||
"TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).":
|
||||
"TCP connection attempt on common ports, run from this Mac (not from the router) — red = open, green = closed (device responds, but nothing is listening there), grey = no response (firewall, device off, or port filtered).",
|
||||
"Offen": "Open",
|
||||
"Geschlossen": "Closed",
|
||||
"Keine Antwort": "No Response",
|
||||
|
||||
// MARK: - Backups tab (BackupListView.swift)
|
||||
"Sicherung wirklich wiederherstellen?": "Really restore this backup?",
|
||||
"Wiederherstellen": "Restore",
|
||||
"Falsches Routermodell": "Wrong Router Model",
|
||||
"Wiederherstellung fehlgeschlagen": "Restore Failed",
|
||||
"Keine Sicherungen": "No Backups",
|
||||
"Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.": "Create a backup before making changes to the router.",
|
||||
"Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.":
|
||||
"Restore this backup onto the connected router — only for exactly the same router model.",
|
||||
"Speicherort": "Storage Location",
|
||||
"Ordner wählen…": "Choose Folder…",
|
||||
"Wähle einen eigenen Ordner für neue Sicherungen, z.B. auf einer externen Festplatte oder in iCloud Drive.":
|
||||
"Choose your own folder for new backups, e.g. on an external drive or in iCloud Drive.",
|
||||
"Standard verwenden": "Use Default",
|
||||
"Setzt den Speicherort zurück auf den App-eigenen Standardordner.": "Resets the storage location back to the app's own default folder.",
|
||||
"Gefahrenzone": "Danger Zone",
|
||||
"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.":
|
||||
"Resets the router completely to the manufacturer's default configuration — all existing changes (internet, home network, VLANs, Wi-Fi, firewall) are lost. The router then reboots.",
|
||||
"Setze zurück…": "Resetting…",
|
||||
"Werkseinstellungen wiederherstellen": "Restore Factory Defaults",
|
||||
"Stellt die vom Hersteller vorinstallierte Standardkonfiguration des Routers wieder her. Nur für den Notfall, falls etwas schiefgelaufen ist.":
|
||||
"Restores the router's factory-installed default configuration. Only for emergencies, if something has gone wrong.",
|
||||
"Befehl gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.":
|
||||
"Command sent — the router is now restarting (can take 1-2 minutes). Reconnect in the \"Connect\" tab afterwards.",
|
||||
"Wiederherstellung gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.":
|
||||
"Restore sent — the router is now restarting (can take 1-2 minutes). Reconnect in the \"Connect\" tab afterwards.",
|
||||
"Werkseinstellungen wirklich wiederherstellen?": "Really restore factory defaults?",
|
||||
"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.":
|
||||
"This deletes ALL existing settings (internet, home network, VLANs, Wi-Fi, firewall) and resets the router to its factory default configuration. The router reboots, and this app loses the connection afterwards. A backup is also created automatically beforehand.",
|
||||
"Zuerst im Tab 'Verbinden' mit dem Router verbinden.": "First connect to the router in the \"Connect\" tab.",
|
||||
"Sicherung jetzt erstellen": "Create Backup Now",
|
||||
"Zurücksetzen fehlgeschlagen": "Reset Failed",
|
||||
"Diese Sicherung stammt von einem": "This backup is from a",
|
||||
", der verbundene Router meldet sich als": ", the connected router identifies itself as",
|
||||
". Wiederherstellen abgebrochen, um das Gerät nicht unbrauchbar zu machen (\"brick\").":
|
||||
". Restore aborted to avoid bricking the device.",
|
||||
"unbekannt": "unknown",
|
||||
"WICHTIG: Diese Sicherung darf nur auf genau das Routermodell zurückgespielt werden, von dem sie stammt — sonst kann der Router unbrauchbar werden (\"brick\"). Sicherung:":
|
||||
"IMPORTANT: This backup may only be restored onto the exact router model it came from — otherwise the router can become unusable (\"bricked\"). Backup:",
|
||||
". Verbundener Router:": ". Connected router:",
|
||||
"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.":
|
||||
"This completely deletes ALL current settings (including the factory baseline configuration, not just your own changes) and replaces them with the backup's content. The router reboots, and this app loses the connection afterwards.",
|
||||
"Falls die Wiederherstellung fehlschlägt, bleibt der Router leer stehen, nicht auf Werkseinstellungen zurückgefallen.":
|
||||
"If the restore fails, the router is left blank, not reverted to factory defaults."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct BackupListView: View {
|
||||
/// 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 {
|
||||
@@ -37,17 +38,17 @@ struct BackupListView: View {
|
||||
private var restoreAwareContent: some View {
|
||||
baseContent
|
||||
.confirmationDialog(
|
||||
"Sicherung wirklich wiederherstellen?",
|
||||
L10n.t("Sicherung wirklich wiederherstellen?", appLanguage),
|
||||
isPresented: $showRestoreConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Wiederherstellen", role: .destructive) {
|
||||
Button(L10n.t("Wiederherstellen", appLanguage), role: .destructive) {
|
||||
if let credentials = connectionService.credentials, let backup = pendingRestoreBackup {
|
||||
viewModel.restore(backup, for: credentials)
|
||||
}
|
||||
pendingRestoreBackup = nil
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { pendingRestoreBackup = nil }
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { pendingRestoreBackup = nil }
|
||||
} message: {
|
||||
Text(restoreWarningText)
|
||||
}
|
||||
@@ -57,26 +58,26 @@ struct BackupListView: View {
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"Falsches Routermodell",
|
||||
L10n.t("Falsches Routermodell", appLanguage),
|
||||
isPresented: Binding(
|
||||
get: { modelMismatchMessage != nil },
|
||||
set: { if !$0 { modelMismatchMessage = nil } }
|
||||
),
|
||||
presenting: modelMismatchMessage
|
||||
) { _ in
|
||||
Button("OK") {}
|
||||
Button(L10n.t("OK", appLanguage)) {}
|
||||
} message: { message in
|
||||
Text(message)
|
||||
}
|
||||
.alert(
|
||||
"Wiederherstellung fehlgeschlagen",
|
||||
L10n.t("Wiederherstellung fehlgeschlagen", appLanguage),
|
||||
isPresented: Binding(
|
||||
get: { viewModel.restoreError != nil },
|
||||
set: { _ in viewModel.restoreError = nil }
|
||||
),
|
||||
presenting: viewModel.restoreError
|
||||
) { _ in
|
||||
Button("OK") {}
|
||||
Button(L10n.t("OK", appLanguage)) {}
|
||||
} message: { message in
|
||||
Text(message)
|
||||
}
|
||||
@@ -87,13 +88,13 @@ struct BackupListView: View {
|
||||
Group {
|
||||
if viewModel.backups.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Sicherungen",
|
||||
LocalizedStringKey(L10n.t("Keine Sicherungen", appLanguage)),
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
description: Text("Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.")
|
||||
description: Text(L10n.t("Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.", appLanguage))
|
||||
)
|
||||
} else {
|
||||
Form {
|
||||
Section("Sicherungen") {
|
||||
Section(L10n.t("Sicherungen", appLanguage)) {
|
||||
ForEach(viewModel.backups) { backup in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -109,11 +110,11 @@ struct BackupListView: View {
|
||||
Button {
|
||||
beginRestore(backup)
|
||||
} label: {
|
||||
Label("Wiederherstellen", systemImage: "tray.and.arrow.up")
|
||||
Label(L10n.t("Wiederherstellen", appLanguage), 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.")
|
||||
.help(L10n.t("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.", appLanguage))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,7 +124,7 @@ struct BackupListView: View {
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Speicherort")
|
||||
Text(L10n.t("Speicherort", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(viewModel.backupDirectoryPath)
|
||||
@@ -132,19 +133,19 @@ struct BackupListView: View {
|
||||
.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.")
|
||||
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("Gefahrenzone")
|
||||
Text(L10n.t("Gefahrenzone", appLanguage))
|
||||
.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.")
|
||||
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))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(role: .destructive) {
|
||||
@@ -153,25 +154,25 @@ struct BackupListView: View {
|
||||
if viewModel.isResettingToFactoryDefaults {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Setze zurück…")
|
||||
Text(L10n.t("Setze zurück…", appLanguage))
|
||||
}
|
||||
} else {
|
||||
Label("Werkseinstellungen wiederherstellen", systemImage: "exclamationmark.triangle")
|
||||
Label(L10n.t("Werkseinstellungen wiederherstellen", appLanguage), 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.")
|
||||
.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(
|
||||
"Befehl gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.",
|
||||
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"
|
||||
)
|
||||
.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.",
|
||||
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"
|
||||
)
|
||||
.font(.caption)
|
||||
@@ -190,25 +191,25 @@ struct BackupListView: View {
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Werkseinstellungen wirklich wiederherstellen?",
|
||||
L10n.t("Werkseinstellungen wirklich wiederherstellen?", appLanguage),
|
||||
isPresented: $showFactoryResetConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücksetzen", role: .destructive) {
|
||||
Button(L10n.t("Zurücksetzen", appLanguage), role: .destructive) {
|
||||
if let credentials = connectionService.credentials {
|
||||
viewModel.resetToFactoryDefaults(for: credentials)
|
||||
}
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
Button(L10n.t("Abbrechen", appLanguage), 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.")
|
||||
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("Sicherungen")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Sicherungen", appLanguage)))
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
@@ -219,34 +220,34 @@ struct BackupListView: View {
|
||||
if viewModel.isCreatingBackup {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Jetzt sichern", systemImage: "square.and.arrow.down")
|
||||
Label(L10n.t("Jetzt sichern", appLanguage), 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")
|
||||
.help(connectionService.credentials == nil ? L10n.t("Zuerst im Tab 'Verbinden' mit dem Router verbinden.", appLanguage) : L10n.t("Sicherung jetzt erstellen", appLanguage))
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"Sicherung fehlgeschlagen",
|
||||
L10n.t("Sicherung fehlgeschlagen", appLanguage),
|
||||
isPresented: Binding(
|
||||
get: { viewModel.errorMessage != nil },
|
||||
set: { _ in viewModel.errorMessage = nil }
|
||||
),
|
||||
presenting: viewModel.errorMessage
|
||||
) { _ in
|
||||
Button("OK") {}
|
||||
Button(L10n.t("OK", appLanguage)) {}
|
||||
} message: { message in
|
||||
Text(message)
|
||||
}
|
||||
.alert(
|
||||
"Zurücksetzen fehlgeschlagen",
|
||||
L10n.t("Zurücksetzen fehlgeschlagen", appLanguage),
|
||||
isPresented: Binding(
|
||||
get: { viewModel.resetError != nil },
|
||||
set: { _ in viewModel.resetError = nil }
|
||||
),
|
||||
presenting: viewModel.resetError
|
||||
) { _ in
|
||||
Button("OK") {}
|
||||
Button(L10n.t("OK", appLanguage)) {}
|
||||
} message: { message in
|
||||
Text(message)
|
||||
}
|
||||
@@ -264,7 +265,7 @@ struct BackupListView: View {
|
||||
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\")."
|
||||
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
|
||||
@@ -285,11 +286,12 @@ struct BackupListView: View {
|
||||
}
|
||||
|
||||
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."
|
||||
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: " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct DevicesView: View {
|
||||
@State private var showStaticConfirmation = false
|
||||
/// Same reasoning as `showStaticConfirmation` — independent of `pendingStaticRemoval`.
|
||||
@State private var showRemovalConfirmation = false
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
@@ -29,33 +30,33 @@ struct DevicesView: View {
|
||||
Group {
|
||||
if connectionService.credentials == nil {
|
||||
ContentUnavailableView(
|
||||
"Nicht verbunden",
|
||||
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
|
||||
systemImage: "network.slash",
|
||||
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
|
||||
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
|
||||
)
|
||||
} else if viewModel.isLoading && viewModel.devices.isEmpty {
|
||||
ProgressView("Suche Geräte im Netz…")
|
||||
ProgressView(L10n.t("Suche Geräte im Netz…", appLanguage))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = viewModel.loadError, viewModel.devices.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Konnte Geräte nicht laden", systemImage: "exclamationmark.triangle",
|
||||
L10n.t("Konnte Geräte nicht laden", appLanguage), systemImage: "exclamationmark.triangle",
|
||||
description: Text(error)
|
||||
)
|
||||
} else if viewModel.portGroups.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Ports gefunden", systemImage: "wifi.slash",
|
||||
description: Text("Der Router meldet aktuell keine physischen Interfaces.")
|
||||
L10n.t("Keine Ports gefunden", appLanguage), systemImage: "wifi.slash",
|
||||
description: Text(L10n.t("Der Router meldet aktuell keine physischen Interfaces.", appLanguage))
|
||||
)
|
||||
} else {
|
||||
Form {
|
||||
ForEach(viewModel.portGroups) { group in
|
||||
Section {
|
||||
if group.devices.isEmpty {
|
||||
Text("Keine Geräte").font(.caption).foregroundStyle(.secondary)
|
||||
Text(L10n.t("Keine Geräte", appLanguage)).font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
DeviceColumnHeader()
|
||||
DeviceColumnHeader(appLanguage: appLanguage)
|
||||
ForEach(group.devices) { device in
|
||||
DeviceRow(device: device)
|
||||
DeviceRow(device: device, appLanguage: appLanguage)
|
||||
.contextMenu {
|
||||
deviceMenu(for: device)
|
||||
}
|
||||
@@ -73,7 +74,7 @@ struct DevicesView: View {
|
||||
if viewModel.isRunningNetworkTool {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Führe Netzwerk-Test aus…").font(.caption)
|
||||
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
@@ -81,7 +82,7 @@ struct DevicesView: View {
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
.navigationTitle("LAN-Scanner")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("LAN-Scanner", appLanguage)))
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
@@ -90,7 +91,7 @@ struct DevicesView: View {
|
||||
if viewModel.isLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("Neu scannen", systemImage: "arrow.clockwise")
|
||||
Label(L10n.t("Neu scannen", appLanguage), systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
// Per explicit request: a plain icon+text toolbar button up in the corner got
|
||||
@@ -108,10 +109,11 @@ struct DevicesView: View {
|
||||
}
|
||||
.withStaticAssignmentDialogs(
|
||||
viewModel: viewModel,
|
||||
appLanguage: appLanguage,
|
||||
showStaticConfirmation: $showStaticConfirmation,
|
||||
showRemovalConfirmation: $showRemovalConfirmation
|
||||
)
|
||||
.withDeviceDetailSheets(viewModel: viewModel, rawFieldsDevice: $rawFieldsDevice)
|
||||
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, rawFieldsDevice: $rawFieldsDevice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,18 +125,18 @@ struct DevicesView: View {
|
||||
viewModel.pendingStaticRemoval = device
|
||||
showRemovalConfirmation = true
|
||||
} label: {
|
||||
Label("Feste Zuweisung entfernen", systemImage: "pin.slash")
|
||||
Label(L10n.t("Feste Zuweisung entfernen", appLanguage), systemImage: "pin.slash")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
viewModel.pendingStaticAssignment = device
|
||||
showStaticConfirmation = true
|
||||
} label: {
|
||||
Label("Feste IP zuweisen", systemImage: "pin.fill")
|
||||
Label(L10n.t("Feste IP zuweisen", appLanguage), systemImage: "pin.fill")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich")
|
||||
Text(L10n.t("Kein DHCP-Lease — feste Zuweisung hier nicht möglich", appLanguage))
|
||||
}
|
||||
Divider()
|
||||
Menu {
|
||||
@@ -152,123 +154,132 @@ struct DevicesView: View {
|
||||
Button {
|
||||
viewModel.runDnsLookup(for: device)
|
||||
} label: {
|
||||
Label("DNS-Auflösung (nslookup)", systemImage: "magnifyingglass")
|
||||
Label(L10n.t("DNS-Auflösung (nslookup)", appLanguage), systemImage: "magnifyingglass")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
viewModel.runPortScan(for: device)
|
||||
} label: {
|
||||
Label("Port-Scan", systemImage: "list.bullet.rectangle.portrait")
|
||||
Label(L10n.t("Port-Scan", appLanguage), systemImage: "list.bullet.rectangle.portrait")
|
||||
}
|
||||
} label: {
|
||||
Label("Netzwerk-Tools", systemImage: "stethoscope")
|
||||
Label(L10n.t("Netzwerk-Tools", appLanguage), systemImage: "stethoscope")
|
||||
}
|
||||
.disabled(viewModel.isRunningNetworkTool)
|
||||
Divider()
|
||||
Button {
|
||||
rawFieldsDevice = device
|
||||
} label: {
|
||||
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
|
||||
Label(L10n.t("Rohdaten anzeigen", appLanguage), systemImage: "list.bullet.rectangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split out of the dialog `message:` closures below — same Bug 19 reasoning, but this time it's
|
||||
/// a single long string built from many `+`-chained `L10n.t(...)` calls (not a modifier chain)
|
||||
/// that timed out the type-checker. A plain function with an explicit `-> String` return type
|
||||
/// checks each piece independently instead of inferring the whole chained expression at once.
|
||||
private func staticAssignmentMessage(device: LanDevice, appLanguage: String, cliLine: String?) -> String {
|
||||
"\(device.ipAddress) " + L10n.t("wird dauerhaft an", appLanguage) + " \(device.macAddress)"
|
||||
+ (device.hostName.map { " (\($0))" } ?? "")
|
||||
+ L10n.t(" gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch.", appLanguage)
|
||||
+ L10n.t("\n\nRückgängig machen: hier im LAN-Scanner-Tab per Rechtsklick auf das Gerät", appLanguage)
|
||||
+ L10n.t(" → \"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort", appLanguage)
|
||||
+ L10n.t(" automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und", appLanguage)
|
||||
+ L10n.t(" neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten).", appLanguage)
|
||||
+ L10n.t("\n\nBefehl:", appLanguage) + " \(cliLine ?? "")"
|
||||
}
|
||||
|
||||
private func staticRemovalMessage(device: LanDevice, appLanguage: String, cliLine: String?) -> String {
|
||||
L10n.t("Die feste Zuweisung für", appLanguage) + " \(device.macAddress)"
|
||||
+ (device.hostName.map { " (\($0))" } ?? "")
|
||||
+ L10n.t(" wird entfernt. RouterOS kennt keine direkte Umkehrung von \"Make Static\" —", appLanguage)
|
||||
+ L10n.t(" das Gerät bekommt erst beim nächsten Verbindungsaufbau (Kabel/WLAN neu", appLanguage)
|
||||
+ L10n.t(" verbinden, Neustart) automatisch wieder eine Adresse per DHCP, eventuell", appLanguage)
|
||||
+ L10n.t(" eine andere IP als bisher.", appLanguage)
|
||||
+ L10n.t("\n\nBefehl:", appLanguage) + " \(cliLine ?? "")"
|
||||
}
|
||||
|
||||
/// Split out of `body` — same reasoning as `ConnectView`'s dialog-splitting extensions (Bug 19 in
|
||||
/// HANDOFF.md): too many `.alert`/`.confirmationDialog`/`.sheet` modifiers chained onto one view
|
||||
/// times out the Swift type-checker with a misleading error location, not an actual logic issue.
|
||||
private extension View {
|
||||
func withStaticAssignmentDialogs(
|
||||
viewModel: DevicesViewModel,
|
||||
appLanguage: String,
|
||||
showStaticConfirmation: Binding<Bool>,
|
||||
showRemovalConfirmation: Binding<Bool>
|
||||
) -> some View {
|
||||
self
|
||||
.confirmationDialog(
|
||||
"Feste IP-Adresse zuweisen?",
|
||||
L10n.t("Feste IP-Adresse zuweisen?", appLanguage),
|
||||
isPresented: showStaticConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Zuweisen") {
|
||||
Button(L10n.t("Zuweisen", appLanguage)) {
|
||||
Task { await viewModel.confirmStaticAssignment() }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { viewModel.cancelStaticAssignment() }
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { viewModel.cancelStaticAssignment() }
|
||||
} message: {
|
||||
if let device = viewModel.pendingStaticAssignment {
|
||||
Text(
|
||||
"\(device.ipAddress) wird dauerhaft an \(device.macAddress)"
|
||||
+ (device.hostName.map { " (\($0))" } ?? "")
|
||||
+ " gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch."
|
||||
+ "\n\nRückgängig machen: hier im LAN-Scanner-Tab per Rechtsklick auf das Gerät"
|
||||
+ " → \"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort"
|
||||
+ " automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und"
|
||||
+ " neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten)."
|
||||
+ "\n\nBefehl: \(viewModel.pendingCommand?.cliLine ?? "")"
|
||||
)
|
||||
Text(staticAssignmentMessage(device: device, appLanguage: appLanguage, cliLine: viewModel.pendingCommand?.cliLine))
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Feste Zuweisung entfernen?",
|
||||
L10n.t("Feste Zuweisung entfernen?", appLanguage),
|
||||
isPresented: showRemovalConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
Button(L10n.t("Entfernen", appLanguage), role: .destructive) {
|
||||
Task { await viewModel.confirmStaticRemoval() }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { viewModel.cancelStaticRemoval() }
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { viewModel.cancelStaticRemoval() }
|
||||
} message: {
|
||||
if let device = viewModel.pendingStaticRemoval {
|
||||
Text(
|
||||
"Die feste Zuweisung für \(device.macAddress)"
|
||||
+ (device.hostName.map { " (\($0))" } ?? "")
|
||||
+ " wird entfernt. RouterOS kennt keine direkte Umkehrung von \"Make Static\" —"
|
||||
+ " das Gerät bekommt erst beim nächsten Verbindungsaufbau (Kabel/WLAN neu"
|
||||
+ " verbinden, Neustart) automatisch wieder eine Adresse per DHCP, eventuell"
|
||||
+ " eine andere IP als bisher."
|
||||
+ "\n\nBefehl: \(viewModel.pendingRemovalCommand?.cliLine ?? "")"
|
||||
)
|
||||
Text(staticRemovalMessage(device: device, appLanguage: appLanguage, cliLine: viewModel.pendingRemovalCommand?.cliLine))
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"Fehler",
|
||||
L10n.t("Fehler", appLanguage),
|
||||
isPresented: Binding(
|
||||
get: { viewModel.applyError != nil },
|
||||
set: { if !$0 { viewModel.dismissApplyError() } }
|
||||
),
|
||||
presenting: viewModel.applyError
|
||||
) { _ in
|
||||
Button("OK") {}
|
||||
Button(L10n.t("OK", appLanguage)) {}
|
||||
} message: { error in
|
||||
Text(error)
|
||||
}
|
||||
}
|
||||
|
||||
func withDeviceDetailSheets(viewModel: DevicesViewModel, rawFieldsDevice: Binding<LanDevice?>) -> some View {
|
||||
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, rawFieldsDevice: Binding<LanDevice?>) -> some View {
|
||||
self
|
||||
.sheet(item: rawFieldsDevice) { device in
|
||||
RawFieldsSheet(device: device) { rawFieldsDevice.wrappedValue = nil }
|
||||
RawFieldsSheet(device: device, appLanguage: appLanguage) { rawFieldsDevice.wrappedValue = nil }
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { viewModel.networkToolResult },
|
||||
set: { if $0 == nil { viewModel.networkToolResult = nil } }
|
||||
)) { result in
|
||||
NetworkToolResultSheet(result: result) { viewModel.networkToolResult = nil }
|
||||
NetworkToolResultSheet(result: result, appLanguage: appLanguage) { viewModel.networkToolResult = nil }
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { viewModel.portScanResult },
|
||||
set: { if $0 == nil { viewModel.portScanResult = nil } }
|
||||
)) { result in
|
||||
PortScanResultSheet(result: result) { viewModel.portScanResult = nil }
|
||||
PortScanResultSheet(result: result, appLanguage: appLanguage) { viewModel.portScanResult = nil }
|
||||
}
|
||||
.alert(
|
||||
"Netzwerk-Test fehlgeschlagen",
|
||||
L10n.t("Netzwerk-Test fehlgeschlagen", appLanguage),
|
||||
isPresented: Binding(
|
||||
get: { viewModel.networkToolError != nil },
|
||||
set: { if !$0 { viewModel.dismissNetworkToolError() } }
|
||||
),
|
||||
presenting: viewModel.networkToolError
|
||||
) { _ in
|
||||
Button("OK") {}
|
||||
Button(L10n.t("OK", appLanguage)) {}
|
||||
} message: { error in
|
||||
Text(error)
|
||||
}
|
||||
@@ -285,13 +296,15 @@ private enum DeviceColumn {
|
||||
}
|
||||
|
||||
private struct DeviceColumnHeader: View {
|
||||
let appLanguage: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Spacer().frame(width: DeviceColumn.icon)
|
||||
Text("Name").frame(width: DeviceColumn.name, alignment: .leading)
|
||||
Text("IP-Adresse").frame(width: DeviceColumn.ip, alignment: .leading)
|
||||
Text("MAC-Adresse").frame(width: DeviceColumn.mac, alignment: .leading)
|
||||
Text("Status").frame(width: DeviceColumn.status, alignment: .leading)
|
||||
Text(L10n.t("Name", appLanguage)).frame(width: DeviceColumn.name, alignment: .leading)
|
||||
Text(L10n.t("IP-Adresse", appLanguage)).frame(width: DeviceColumn.ip, alignment: .leading)
|
||||
Text(L10n.t("MAC-Adresse", appLanguage)).frame(width: DeviceColumn.mac, alignment: .leading)
|
||||
Text(L10n.t("Status", appLanguage)).frame(width: DeviceColumn.status, alignment: .leading)
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption2.bold())
|
||||
@@ -301,6 +314,7 @@ private struct DeviceColumnHeader: View {
|
||||
|
||||
private struct DeviceRow: View {
|
||||
let device: LanDevice
|
||||
let appLanguage: String
|
||||
|
||||
/// Three distinct states, not two — a device with no matching DHCP lease at all is neither
|
||||
/// "Fest" (a real static reservation) nor "Dynamisch" (a real active lease); conflating it
|
||||
@@ -317,8 +331,8 @@ private struct DeviceRow: View {
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
guard device.hasLease else { return "Kein DHCP" }
|
||||
return device.isStatic ? "Fest" : "Dynamisch"
|
||||
guard device.hasLease else { return L10n.t("Kein DHCP", appLanguage) }
|
||||
return device.isStatic ? L10n.t("Fest", appLanguage) : L10n.t("Dynamisch", appLanguage)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -350,13 +364,14 @@ private struct DeviceRow: View {
|
||||
|
||||
private struct RawFieldsSheet: View {
|
||||
let device: LanDevice
|
||||
let appLanguage: String
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Rohdaten: \(device.macAddress)")
|
||||
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
|
||||
.font(.headline)
|
||||
Text("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.")
|
||||
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
@@ -373,7 +388,7 @@ private struct RawFieldsSheet: View {
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Schließen") { onClose() }
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -385,25 +400,26 @@ private struct RawFieldsSheet: View {
|
||||
/// `NetworkToolResult`'s doc comment for why this isn't parsed into a nicer structured view.
|
||||
private struct NetworkToolResultSheet: View {
|
||||
let result: NetworkToolResult
|
||||
let appLanguage: String
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(result.title)
|
||||
.font(.headline)
|
||||
Text("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.")
|
||||
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? "(keine Ausgabe)" : result.output)
|
||||
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Schließen") { onClose() }
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -417,13 +433,14 @@ private struct NetworkToolResultSheet: View {
|
||||
/// `NetworkToolResultSheet`'s "run from the router" caption right above it in this same file.
|
||||
private struct PortScanResultSheet: View {
|
||||
let result: PortScanResult
|
||||
let appLanguage: String
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Port-Scan: \(result.deviceLabel)")
|
||||
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
||||
.font(.headline)
|
||||
Text("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).")
|
||||
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
@@ -431,13 +448,13 @@ private struct PortScanResultSheet: View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { Divider() }
|
||||
PortScanRow(entry: entry)
|
||||
PortScanRow(entry: entry, appLanguage: appLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Schließen") { onClose() }
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -447,6 +464,7 @@ private struct PortScanResultSheet: View {
|
||||
|
||||
private struct PortScanRow: View {
|
||||
let entry: PortScanResult.Entry
|
||||
let appLanguage: String
|
||||
|
||||
private var color: Color {
|
||||
switch entry.status {
|
||||
@@ -458,9 +476,9 @@ private struct PortScanRow: View {
|
||||
|
||||
private var statusText: String {
|
||||
switch entry.status {
|
||||
case .open: return "Offen"
|
||||
case .closed: return "Geschlossen"
|
||||
case .unreachable: return "Keine Antwort"
|
||||
case .open: return L10n.t("Offen", appLanguage)
|
||||
case .closed: return L10n.t("Geschlossen", appLanguage)
|
||||
case .unreachable: return L10n.t("Keine Antwort", appLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ struct OverviewView: View {
|
||||
/// clicked node's underlying RouterOS item — same field schemas, same idempotent write path,
|
||||
/// instead of duplicating either here.
|
||||
@StateObject private var expertViewModel: ExpertViewModel
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var hoveredNodeID: String?
|
||||
@State private var hoveredEdge: OverviewEdge?
|
||||
@@ -113,9 +114,9 @@ struct OverviewView: View {
|
||||
Group {
|
||||
if connectionService.credentials == nil {
|
||||
ContentUnavailableView(
|
||||
"Nicht verbunden",
|
||||
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
|
||||
systemImage: "network.slash",
|
||||
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
|
||||
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
|
||||
)
|
||||
} else {
|
||||
HSplitView {
|
||||
@@ -126,7 +127,7 @@ struct OverviewView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Übersicht")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht", appLanguage)))
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
if viewModel.isLoading {
|
||||
@@ -135,17 +136,17 @@ struct OverviewView: View {
|
||||
Button {
|
||||
Task { await viewModel.load() }
|
||||
} label: {
|
||||
Label("Aktualisieren", systemImage: "arrow.clockwise")
|
||||
Label(L10n.t("Aktualisieren", appLanguage), systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
Button { scale = max(0.4, scale - 0.15) } label: { Image(systemName: "minus.magnifyingglass") }
|
||||
Button { scale = 1.0 } label: { Text("100%") }
|
||||
Button { scale = min(2.0, scale + 0.15) } label: { Image(systemName: "plus.magnifyingglass") }
|
||||
Button("Zurücksetzen") {
|
||||
Button(L10n.t("Zurücksetzen", appLanguage)) {
|
||||
nodeOffsets = [:]
|
||||
}
|
||||
.disabled(nodeOffsets.isEmpty)
|
||||
.help("Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.")
|
||||
.help(L10n.t("Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.", appLanguage))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -175,17 +176,17 @@ struct OverviewView: View {
|
||||
@ViewBuilder
|
||||
private var diagramArea: some View {
|
||||
if viewModel.isLoading && viewModel.graph.nodes.isEmpty {
|
||||
ProgressView("Lade Router-Konfiguration…")
|
||||
ProgressView(L10n.t("Lade Router-Konfiguration…", appLanguage))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = viewModel.loadError, viewModel.graph.nodes.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Konnte Konfiguration nicht laden", systemImage: "exclamationmark.triangle",
|
||||
L10n.t("Konnte Konfiguration nicht laden", appLanguage), systemImage: "exclamationmark.triangle",
|
||||
description: Text(error)
|
||||
)
|
||||
} else if viewModel.graph.nodes.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Konfiguration gefunden", systemImage: "square.dashed",
|
||||
description: Text("Der Router meldet aktuell keine Interfaces/Adressen.")
|
||||
L10n.t("Keine Konfiguration gefunden", appLanguage), systemImage: "square.dashed",
|
||||
description: Text(L10n.t("Der Router meldet aktuell keine Interfaces/Adressen.", appLanguage))
|
||||
)
|
||||
} else {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
@@ -201,7 +202,7 @@ struct OverviewView: View {
|
||||
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
|
||||
|
||||
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
|
||||
Text(category.rawValue)
|
||||
Text(L10n.t(category.rawValue, appLanguage))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.position(
|
||||
@@ -245,7 +246,7 @@ struct OverviewView: View {
|
||||
}
|
||||
|
||||
if let edge = hoveredEdge {
|
||||
EdgeTooltipView(edge: edge, graph: viewModel.graph)
|
||||
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage)
|
||||
.position(
|
||||
x: min(max(hoverPoint.x + 90, 90), layout.canvasSize.width - 90),
|
||||
y: max(hoverPoint.y - 26, 16)
|
||||
@@ -264,17 +265,17 @@ struct OverviewView: View {
|
||||
@ViewBuilder
|
||||
private var detailPanel: some View {
|
||||
if let edge = selectedEdge {
|
||||
EdgeDetailView(edge: edge, graph: viewModel.graph) { nodeID in
|
||||
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage) { nodeID in
|
||||
selectedEdge = nil
|
||||
viewModel.selectedNodeID = nodeID
|
||||
}
|
||||
} else if let id = viewModel.selectedNodeID, let node = viewModel.graph.nodes.first(where: { $0.id == id }) {
|
||||
NodeDetailView(node: node, graph: viewModel.graph, onEdit: editNode) { nodeID in
|
||||
NodeDetailView(node: node, graph: viewModel.graph, appLanguage: appLanguage, onEdit: editNode) { nodeID in
|
||||
selectedEdge = nil
|
||||
viewModel.selectedNodeID = nodeID
|
||||
}
|
||||
} else {
|
||||
LegendView()
|
||||
LegendView(appLanguage: appLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,13 +489,14 @@ private struct EdgesCanvas: View {
|
||||
private struct EdgeTooltipView: View {
|
||||
let edge: OverviewEdge
|
||||
let graph: OverviewGraph
|
||||
let appLanguage: String
|
||||
|
||||
private var fromTitle: String { graph.nodes.first(where: { $0.id == edge.from })?.title ?? edge.from }
|
||||
private var toTitle: String { graph.nodes.first(where: { $0.id == edge.to })?.title ?? edge.to }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(edge.kind.rawValue)
|
||||
Text(L10n.t(edge.kind.rawValue, appLanguage))
|
||||
.font(.caption.bold())
|
||||
Text("\(fromTitle) → \(toTitle)")
|
||||
.font(.caption2)
|
||||
@@ -521,6 +523,7 @@ private struct EdgeTooltipView: View {
|
||||
private struct EdgeDetailView: View {
|
||||
let edge: OverviewEdge
|
||||
let graph: OverviewGraph
|
||||
let appLanguage: String
|
||||
let onJumpToNode: (String) -> Void
|
||||
|
||||
private var fromNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.from }) }
|
||||
@@ -534,13 +537,13 @@ private struct EdgeDetailView: View {
|
||||
HStack {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.foregroundStyle(OverviewStyle.color(for: edge.kind))
|
||||
Text(edge.kind.rawValue).font(.title3.bold())
|
||||
Text(L10n.t(edge.kind.rawValue, appLanguage)).font(.title3.bold())
|
||||
}
|
||||
Text("Verbindung").font(.caption).foregroundStyle(.secondary)
|
||||
Text(L10n.t("Verbindung", appLanguage)).font(.caption).foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
Text(OverviewStyle.explanation(for: edge.kind))
|
||||
Text(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -553,10 +556,10 @@ private struct EdgeDetailView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Verbunden").font(.subheadline.bold())
|
||||
Text(L10n.t("Verbunden", appLanguage)).font(.subheadline.bold())
|
||||
|
||||
endpointRow(title: "Von", node: fromNode, fallbackTitle: fromTitle, id: edge.from)
|
||||
endpointRow(title: "Nach", node: toNode, fallbackTitle: toTitle, id: edge.to)
|
||||
endpointRow(title: L10n.t("Von", appLanguage), node: fromNode, fallbackTitle: fromTitle, id: edge.from)
|
||||
endpointRow(title: L10n.t("Nach", appLanguage), node: toNode, fallbackTitle: toTitle, id: edge.to)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -631,6 +634,7 @@ private struct NodeCardView: View {
|
||||
private struct NodeDetailView: View {
|
||||
let node: OverviewNode
|
||||
let graph: OverviewGraph
|
||||
let appLanguage: String
|
||||
let onEdit: (OverviewNode) -> Void
|
||||
let onSelectNode: (String) -> Void
|
||||
|
||||
@@ -646,12 +650,12 @@ private struct NodeDetailView: View {
|
||||
Button {
|
||||
onEdit(node)
|
||||
} label: {
|
||||
Label("Bearbeiten", systemImage: "pencil")
|
||||
Label(L10n.t("Bearbeiten", appLanguage), systemImage: "pencil")
|
||||
}
|
||||
.help("Öffnet dasselbe Bearbeiten-Formular wie im Experte-Tab und schreibt Änderungen direkt an den Router.")
|
||||
.help(L10n.t("Öffnet dasselbe Bearbeiten-Formular wie im Experte-Tab und schreibt Änderungen direkt an den Router.", appLanguage))
|
||||
}
|
||||
}
|
||||
Text(node.category.rawValue)
|
||||
Text(L10n.t(node.category.rawValue, appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -671,7 +675,7 @@ private struct NodeDetailView: View {
|
||||
let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id }
|
||||
if !connected.isEmpty {
|
||||
Divider()
|
||||
Text("Verbindungen").font(.subheadline.bold())
|
||||
Text(L10n.t("Verbindungen", appLanguage)).font(.subheadline.bold())
|
||||
ForEach(connected) { edge in
|
||||
let otherID = edge.from == node.id ? edge.to : edge.from
|
||||
let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID
|
||||
@@ -686,7 +690,7 @@ private struct NodeDetailView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(OverviewStyle.explanation(for: edge.kind))
|
||||
.help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,42 +700,44 @@ private struct NodeDetailView: View {
|
||||
}
|
||||
|
||||
private struct LegendView: View {
|
||||
let appLanguage: String
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Übersicht").font(.title3.bold())
|
||||
Text("Zeigt den kompletten IST-Zustand des verbundenen Routers als Diagramm: Interfaces links, darauf aufbauend IP-Adressen, DHCP/Pools, Routen und rechts die Firewall/NAT-Regeln, die auf sie zugreifen. Linien zeigen echte Abhängigkeiten (z.B. \"DHCP-Server nutzt diesen Pool\"), keine Vermutungen.")
|
||||
Text(L10n.t("Übersicht", appLanguage)).font(.title3.bold())
|
||||
Text(L10n.t("Zeigt den kompletten IST-Zustand des verbundenen Routers als Diagramm: Interfaces links, darauf aufbauend IP-Adressen, DHCP/Pools, Routen und rechts die Firewall/NAT-Regeln, die auf sie zugreifen. Linien zeigen echte Abhängigkeiten (z.B. \"DHCP-Server nutzt diesen Pool\"), keine Vermutungen.", appLanguage))
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
Text("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.")
|
||||
Text(L10n.t("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.", appLanguage))
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
Text("Nicht im Diagramm (aber im Experte-Tab erreichbar):").font(.caption.bold())
|
||||
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).font(.caption.bold())
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in
|
||||
Text("· \(area)")
|
||||
Text("· \(L10n.t(area, appLanguage))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Text("Spalten").font(.caption.bold())
|
||||
Text(L10n.t("Spalten", appLanguage)).font(.caption.bold())
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(OverviewNode.Category.allCases) { category in
|
||||
Label(category.rawValue, systemImage: "square.fill")
|
||||
Label(L10n.t(category.rawValue, appLanguage), systemImage: "square.fill")
|
||||
.foregroundStyle(OverviewStyle.color(for: category))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Text("Verbindungsarten").font(.caption.bold())
|
||||
Text(L10n.t("Verbindungsarten", appLanguage)).font(.caption.bold())
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(OverviewEdgeKind.allCases) { kind in
|
||||
Label(kind.rawValue, systemImage: "minus")
|
||||
Label(L10n.t(kind.rawValue, appLanguage), systemImage: "minus")
|
||||
.foregroundStyle(OverviewStyle.color(for: kind))
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
@@ -2,46 +2,37 @@ import SwiftUI
|
||||
|
||||
struct FirewallStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
if viewModel.mode == .expert {
|
||||
Toggle(
|
||||
"Firewall-Grundschutz einrichten (empfohlen)",
|
||||
L10n.t("Firewall-Grundschutz einrichten (empfohlen)", appLanguage),
|
||||
isOn: Binding(
|
||||
get: { viewModel.firewallSectionEnabled },
|
||||
set: { viewModel.setFirewallSectionEnabled($0) }
|
||||
)
|
||||
)
|
||||
.help("Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).")
|
||||
.help(L10n.t("Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).", appLanguage))
|
||||
} else {
|
||||
Text("Firewall-Grundschutz ist im einfachen Modus immer aktiv.")
|
||||
Text(L10n.t("Firewall-Grundschutz ist im einfachen Modus immer aktiv.", appLanguage))
|
||||
}
|
||||
Text(
|
||||
"Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, "
|
||||
+ "und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router "
|
||||
+ "und deine Geräte. Bestehende, selbst eingerichtete Regeln bleiben erhalten — "
|
||||
+ "die neuen Regeln werden vorangestellt."
|
||||
)
|
||||
Text(L10n.t("Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router und deine Geräte. Bestehende, selbst eingerichtete Regeln bleiben erhalten — die neuen Regeln werden vorangestellt.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.firewallSectionEnabled {
|
||||
Section("Bereits vorhandene Regeln") {
|
||||
Section(L10n.t("Bereits vorhandene Regeln", appLanguage)) {
|
||||
if viewModel.isLoadingFirewallRuleCounts {
|
||||
ProgressView()
|
||||
} else if let counts = viewModel.existingFirewallRuleCounts {
|
||||
LabeledContent("Filter-Regeln", value: "\(counts.filterRuleCount)")
|
||||
LabeledContent("NAT-Regeln", value: "\(counts.natRuleCount)")
|
||||
LabeledContent(L10n.t("Filter-Regeln", appLanguage), value: "\(counts.filterRuleCount)")
|
||||
LabeledContent(L10n.t("NAT-Regeln", appLanguage), value: "\(counts.natRuleCount)")
|
||||
if counts.filterRuleCount > 0 || counts.natRuleCount > 0 {
|
||||
Text(
|
||||
"Dein Router hat bereits eigene Firewall-Regeln. Die neuen Regeln werden "
|
||||
+ "vorangestellt, bestehende bleiben erhalten — prüfe nach dem Anwenden "
|
||||
+ "trotzdem die Reihenfolge, z.B. über Winbox oder "
|
||||
+ "\"/ip firewall filter print\"."
|
||||
)
|
||||
Text(L10n.t("Dein Router hat bereits eigene Firewall-Regeln. Die neuen Regeln werden vorangestellt, bestehende bleiben erhalten — prüfe nach dem Anwenden trotzdem die Reihenfolge, z.B. über Winbox oder \"/ip firewall filter print\".", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
@@ -52,9 +43,9 @@ struct FirewallStepView: View {
|
||||
}
|
||||
|
||||
if !viewModel.isolatedNetworkNames.isEmpty {
|
||||
Section("Isolierte Netzwerke") {
|
||||
Section(L10n.t("Isolierte Netzwerke", appLanguage)) {
|
||||
Text(viewModel.isolatedNetworkNames.joined(separator: ", "))
|
||||
Text("Diese Netzwerke werden vom Datenverkehr aller anderen konfigurierten Netzwerke abgeschottet, sobald der Firewall-Grundschutz aktiv ist.")
|
||||
Text(L10n.t("Diese Netzwerke werden vom Datenverkehr aller anderen konfigurierten Netzwerke abgeschottet, sobald der Firewall-Grundschutz aktiv ist.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -62,14 +53,14 @@ struct FirewallStepView: View {
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Firewall (optional)")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Firewall (optional)", appLanguage)))
|
||||
.onAppear {
|
||||
if viewModel.firewallSectionEnabled {
|
||||
viewModel.loadExistingFirewallRuleCounts()
|
||||
|
||||
@@ -3,17 +3,18 @@ import SwiftUI
|
||||
struct LanStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
let availableInterfaces: [NetworkInterface]
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
ForEach($viewModel.lanConfigs) { $config in
|
||||
Section("Netzwerk auf \(config.interfaceName)") {
|
||||
Picker("Anschluss/Bridge", selection: $config.interfaceName) {
|
||||
Section(L10n.t("Netzwerk auf", appLanguage) + " \(config.interfaceName)") {
|
||||
Picker(L10n.t("Anschluss/Bridge", appLanguage), selection: $config.interfaceName) {
|
||||
ForEach(availableInterfaces) { interface in
|
||||
Text(interface.name).tag(interface.name)
|
||||
}
|
||||
}
|
||||
.help("Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).")
|
||||
.help(L10n.t("Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).", appLanguage))
|
||||
.onChange(of: config.interfaceName) { _, _ in
|
||||
viewModel.checkPortConflict(for: config.id)
|
||||
}
|
||||
@@ -24,39 +25,40 @@ struct LanStepView: View {
|
||||
if viewModel.isCheckingPortConflict.contains(config.id) {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Prüfe, ob der Port frei ist…").font(.caption).foregroundStyle(.secondary)
|
||||
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else if let conflict = viewModel.lanPortConflicts[config.id] {
|
||||
PortConflictWarningView(
|
||||
conflict: conflict,
|
||||
isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id),
|
||||
appLanguage: appLanguage,
|
||||
onConfirm: { viewModel.acknowledgePortConflict(for: config.id) }
|
||||
)
|
||||
}
|
||||
|
||||
TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $config.routerAddress)
|
||||
.help("Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.")
|
||||
TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $config.networkAddress)
|
||||
.help("Der komplette Adressbereich dieses Netzwerks (z.B. /24 erlaubt bis zu 254 Geräte).")
|
||||
TextField("Automatische Adressvergabe von", text: $config.poolRangeStart)
|
||||
.help("Ab welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.")
|
||||
TextField("bis", text: $config.poolRangeEnd)
|
||||
.help("Bis zu welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.")
|
||||
TextField(L10n.t("Router-Adresse (z.B. 192.168.88.1/24)", appLanguage), text: $config.routerAddress)
|
||||
.help(L10n.t("Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.", appLanguage))
|
||||
TextField(L10n.t("Netzwerk (z.B. 192.168.88.0/24)", appLanguage), text: $config.networkAddress)
|
||||
.help(L10n.t("Der komplette Adressbereich dieses Netzwerks (z.B. /24 erlaubt bis zu 254 Geräte).", appLanguage))
|
||||
TextField(L10n.t("Automatische Adressvergabe von", appLanguage), text: $config.poolRangeStart)
|
||||
.help(L10n.t("Ab welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.", appLanguage))
|
||||
TextField(L10n.t("bis", appLanguage), text: $config.poolRangeEnd)
|
||||
.help(L10n.t("Bis zu welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.", appLanguage))
|
||||
Stepper(
|
||||
"Adresse behalten für \(config.leaseTimeHours) Stunden",
|
||||
L10n.t("Adresse behalten für", appLanguage) + " \(config.leaseTimeHours) " + L10n.t("Stunden", appLanguage),
|
||||
value: $config.leaseTimeHours,
|
||||
in: 1...168
|
||||
)
|
||||
.help("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.")
|
||||
TextField("DNS-Server", text: $config.dnsServers)
|
||||
.help("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.")
|
||||
.help(L10n.t("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.", appLanguage))
|
||||
TextField(L10n.t("DNS-Server", appLanguage), text: $config.dnsServers)
|
||||
.help(L10n.t("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.", appLanguage))
|
||||
|
||||
if viewModel.mode == .expert {
|
||||
Toggle("Von anderen Netzwerken isolieren", isOn: $config.isolated)
|
||||
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
|
||||
Toggle(L10n.t("Von anderen Netzwerken isolieren", appLanguage), isOn: $config.isolated)
|
||||
.help(L10n.t("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.", appLanguage))
|
||||
|
||||
if viewModel.lanConfigs.count > 1 {
|
||||
Button("Netzwerk entfernen", role: .destructive) {
|
||||
Button(L10n.t("Netzwerk entfernen", appLanguage), role: .destructive) {
|
||||
viewModel.removeLan(config.id)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +68,7 @@ struct LanStepView: View {
|
||||
|
||||
if viewModel.mode == .expert {
|
||||
Section {
|
||||
Button("Weiteres LAN-Netzwerk hinzufügen") {
|
||||
Button(L10n.t("Weiteres LAN-Netzwerk hinzufügen", appLanguage)) {
|
||||
viewModel.addLan()
|
||||
}
|
||||
}
|
||||
@@ -74,15 +76,15 @@ struct LanStepView: View {
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
|
||||
.disabled(!isStepValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Heimnetzwerk einrichten")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Heimnetzwerk einrichten", appLanguage)))
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
@@ -106,6 +108,7 @@ struct LanStepView: View {
|
||||
private struct PortConflictWarningView: View {
|
||||
let conflict: PortConflict
|
||||
let isAcknowledged: Bool
|
||||
let appLanguage: String
|
||||
let onConfirm: () -> Void
|
||||
|
||||
@State private var showConsequencesConfirmation = false
|
||||
@@ -113,58 +116,58 @@ private struct PortConflictWarningView: View {
|
||||
|
||||
var body: some View {
|
||||
if isAcknowledged {
|
||||
Label("\(conflict.interfaceName) wird beim Anwenden freigemacht", systemImage: "checkmark.shield")
|
||||
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Port \(conflict.interfaceName) ist nicht frei", systemImage: "exclamationmark.triangle.fill")
|
||||
Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.red)
|
||||
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
|
||||
Text("• \(reason.description)")
|
||||
.font(.caption)
|
||||
}
|
||||
Text("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.")
|
||||
Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Port jetzt freimachen…", role: .destructive) {
|
||||
Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
|
||||
showConsequencesConfirmation = true
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
|
||||
.confirmationDialog(
|
||||
"Port \(conflict.interfaceName) freimachen?",
|
||||
L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("freimachen?", appLanguage),
|
||||
isPresented: $showConsequencesConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Fortfahren", role: .destructive) {
|
||||
Button(L10n.t("Fortfahren", appLanguage), role: .destructive) {
|
||||
showFinalConfirmation = true
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||||
} message: {
|
||||
Text(consequenceText)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Wirklich sicher?",
|
||||
L10n.t("Wirklich sicher?", appLanguage),
|
||||
isPresented: $showFinalConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Ja, endgültig freimachen", role: .destructive) {
|
||||
Button(L10n.t("Ja, endgültig freimachen", appLanguage), role: .destructive) {
|
||||
onConfirm()
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||||
} message: {
|
||||
Text("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.")
|
||||
Text(L10n.t("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.", appLanguage))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var consequenceText: String {
|
||||
(["Folgendes wird entfernt, bevor \(conflict.interfaceName) als neues Netzwerk eingerichtet wird:"]
|
||||
([L10n.t("Folgendes wird entfernt, bevor", appLanguage) + " \(conflict.interfaceName) " + L10n.t("als neues Netzwerk eingerichtet wird:", appLanguage)]
|
||||
+ conflict.reasons.map { "• \($0.description)" }
|
||||
+ ["Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen."])
|
||||
+ [L10n.t("Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen.", appLanguage)])
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import SwiftUI
|
||||
|
||||
struct ModeStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Modus", selection: $viewModel.mode) {
|
||||
Text("Einfach").tag(SetupMode.simple)
|
||||
Text("Experte").tag(SetupMode.expert)
|
||||
Picker(L10n.t("Modus", appLanguage), selection: $viewModel.mode) {
|
||||
Text(L10n.t("Einfach", appLanguage)).tag(SetupMode.simple)
|
||||
Text(L10n.t("Experte", appLanguage)).tag(SetupMode.expert)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
@@ -16,12 +17,12 @@ struct ModeStepView: View {
|
||||
Section {
|
||||
switch viewModel.mode {
|
||||
case .simple:
|
||||
Text("Grundeinrichtung: Internetverbindung (WAN), ein Heimnetzwerk (LAN) mit DHCP, WLAN und ein fest aktivierter Firewall-Grundschutz.")
|
||||
Text("Kein VLAN, keine mehreren getrennten Netzwerke, keine Netzwerk-Isolation — für später jederzeit über den Expertenmodus nachrüstbar.")
|
||||
Text(L10n.t("Grundeinrichtung: Internetverbindung (WAN), ein Heimnetzwerk (LAN) mit DHCP, WLAN und ein fest aktivierter Firewall-Grundschutz.", appLanguage))
|
||||
Text(L10n.t("Kein VLAN, keine mehreren getrennten Netzwerke, keine Netzwerk-Isolation — für später jederzeit über den Expertenmodus nachrüstbar.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .expert:
|
||||
Text("Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.")
|
||||
Text(L10n.t("Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.", appLanguage))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -29,12 +30,12 @@ struct ModeStepView: View {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Modus wählen")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Modus wählen", appLanguage)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ struct ReviewApplyView: View {
|
||||
let credentials: RouterOSCredentials?
|
||||
|
||||
@State private var showCliDetails = false
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Geplante Änderungen") {
|
||||
Section(L10n.t("Geplante Änderungen", appLanguage)) {
|
||||
ForEach(viewModel.plannedCommands) { command in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(command.summary)
|
||||
@@ -19,17 +20,17 @@ struct ReviewApplyView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Toggle("Details anzeigen (RouterOS-Befehle)", isOn: $showCliDetails)
|
||||
Toggle(L10n.t("Details anzeigen (RouterOS-Befehle)", appLanguage), isOn: $showCliDetails)
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Vor dem Anwenden wird automatisch eine Sicherung der aktuellen Konfiguration erstellt (Tab \"Sicherungen\"). Ein garantiertes automatisches Zurückrollen bei Verbindungsabbruch gibt es nicht — bei Problemen die Sicherung im Tab \"Sicherungen\" verwenden oder den Router lokal (Ethernet/Konsole) wiederherstellen.")
|
||||
Text(L10n.t("Vor dem Anwenden wird automatisch eine Sicherung der aktuellen Konfiguration erstellt (Tab \"Sicherungen\"). Ein garantiertes automatisches Zurückrollen bei Verbindungsabbruch gibt es nicht — bei Problemen die Sicherung im Tab \"Sicherungen\" verwenden oder den Router lokal (Ethernet/Konsole) wiederherstellen.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !viewModel.applyLog.isEmpty {
|
||||
Section("Ablauf") {
|
||||
Section(L10n.t("Ablauf", appLanguage)) {
|
||||
ForEach(viewModel.applyLog, id: \.self) { line in
|
||||
Text(line).font(.caption)
|
||||
}
|
||||
@@ -38,18 +39,18 @@ struct ReviewApplyView: View {
|
||||
|
||||
if viewModel.didApplySuccessfully {
|
||||
Section {
|
||||
Label("Änderungen erfolgreich angewendet", systemImage: "checkmark.circle.fill")
|
||||
Label(L10n.t("Änderungen erfolgreich angewendet", appLanguage), systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
|
||||
.disabled(viewModel.isApplying)
|
||||
Spacer()
|
||||
if viewModel.didApplySuccessfully {
|
||||
Button("Fertig") { viewModel.finish() }
|
||||
Button(L10n.t("Fertig", appLanguage)) { viewModel.finish() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
} else {
|
||||
Button {
|
||||
@@ -60,7 +61,7 @@ struct ReviewApplyView: View {
|
||||
if viewModel.isApplying {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Jetzt anwenden")
|
||||
Text(L10n.t("Jetzt anwenden", appLanguage))
|
||||
}
|
||||
}
|
||||
.disabled(credentials == nil || viewModel.isApplying)
|
||||
@@ -69,15 +70,15 @@ struct ReviewApplyView: View {
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Übersicht & Anwenden")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht & Anwenden", appLanguage)))
|
||||
.alert(
|
||||
"Anwenden fehlgeschlagen",
|
||||
L10n.t("Anwenden fehlgeschlagen", appLanguage),
|
||||
isPresented: Binding(
|
||||
get: { viewModel.applyError != nil },
|
||||
set: { _ in }
|
||||
)
|
||||
) {
|
||||
Button("OK") {}
|
||||
Button(L10n.t("OK", appLanguage)) {}
|
||||
} message: {
|
||||
Text(viewModel.applyError ?? "")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct SetupView: View {
|
||||
@ObservedObject var connectionService: ConnectionService
|
||||
@StateObject private var viewModel: SetupViewModel
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
@@ -14,9 +15,9 @@ struct SetupView: View {
|
||||
Group {
|
||||
if connectionService.credentials == nil {
|
||||
ContentUnavailableView(
|
||||
"Nicht verbunden",
|
||||
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
|
||||
systemImage: "network.slash",
|
||||
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
|
||||
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
|
||||
)
|
||||
} else {
|
||||
switch viewModel.step {
|
||||
|
||||
@@ -3,68 +3,69 @@ import SwiftUI
|
||||
struct VlanStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
let availableInterfaces: [NetworkInterface]
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle(
|
||||
"Zusätzliches Netzwerk einrichten (VLAN, optional)",
|
||||
L10n.t("Zusätzliches Netzwerk einrichten (VLAN, optional)", appLanguage),
|
||||
isOn: Binding(
|
||||
get: { viewModel.vlanSectionEnabled },
|
||||
set: { viewModel.setVlanSectionEnabled($0) }
|
||||
)
|
||||
)
|
||||
.help("Erstellt ein zusätzliches Netzwerk mit eigenem Adressbereich und eigenem DHCP-Server.")
|
||||
Text("Ein VLAN ist ein zusätzliches Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Ob es vom Hauptnetzwerk abgeschottet ist, legst du unten pro Netzwerk über \"Von anderen Netzwerken isolieren\" fest. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.")
|
||||
.help(L10n.t("Erstellt ein zusätzliches Netzwerk mit eigenem Adressbereich und eigenem DHCP-Server.", appLanguage))
|
||||
Text(L10n.t("Ein VLAN ist ein zusätzliches Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Ob es vom Hauptnetzwerk abgeschottet ist, legst du unten pro Netzwerk über \"Von anderen Netzwerken isolieren\" fest. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.vlanSectionEnabled {
|
||||
ForEach($viewModel.vlans) { $vlan in
|
||||
Section("Netzwerk \"\(vlan.name)\"") {
|
||||
TextField("Name", text: $vlan.name)
|
||||
.help("Ein Name zur Wiedererkennung, z.B. \"Gäste\" oder \"Smart Home\".")
|
||||
Stepper("VLAN-ID: \(vlan.vlanID)", value: $vlan.vlanID, in: 2...4094)
|
||||
.help("Eine eindeutige Nummer zur technischen Unterscheidung dieses Netzwerks. Muss nur innerhalb deines Routers einmalig sein.")
|
||||
Picker("Basis-Anschluss", selection: $vlan.parentInterface) {
|
||||
Section("\(L10n.t("Netzwerk", appLanguage)) \"\(vlan.name)\"") {
|
||||
TextField(L10n.t("Name", appLanguage), text: $vlan.name)
|
||||
.help(L10n.t("Ein Name zur Wiedererkennung, z.B. \"Gäste\" oder \"Smart Home\".", appLanguage))
|
||||
Stepper("\(L10n.t("VLAN-ID", appLanguage)): \(vlan.vlanID)", value: $vlan.vlanID, in: 2...4094)
|
||||
.help(L10n.t("Eine eindeutige Nummer zur technischen Unterscheidung dieses Netzwerks. Muss nur innerhalb deines Routers einmalig sein.", appLanguage))
|
||||
Picker(L10n.t("Basis-Anschluss", appLanguage), selection: $vlan.parentInterface) {
|
||||
ForEach(availableInterfaces) { interface in
|
||||
Text(interface.name).tag(interface.name)
|
||||
}
|
||||
}
|
||||
.help("Der physische Anschluss, auf dem dieses zusätzliche Netzwerk aufbaut.")
|
||||
TextField("Router-Adresse (z.B. 192.168.20.1/24)", text: $vlan.routerAddress)
|
||||
.help("Die Adresse des Routers innerhalb dieses zusätzlichen Netzwerks.")
|
||||
TextField("Netzwerk (z.B. 192.168.20.0/24)", text: $vlan.networkAddress)
|
||||
.help("Der komplette Adressbereich dieses zusätzlichen Netzwerks.")
|
||||
TextField("Automatische Adressvergabe von", text: $vlan.poolRangeStart)
|
||||
.help("Ab welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.")
|
||||
TextField("bis", text: $vlan.poolRangeEnd)
|
||||
.help("Bis zu welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.")
|
||||
.help(L10n.t("Der physische Anschluss, auf dem dieses zusätzliche Netzwerk aufbaut.", appLanguage))
|
||||
TextField(L10n.t("Router-Adresse (z.B. 192.168.20.1/24)", appLanguage), text: $vlan.routerAddress)
|
||||
.help(L10n.t("Die Adresse des Routers innerhalb dieses zusätzlichen Netzwerks.", appLanguage))
|
||||
TextField(L10n.t("Netzwerk (z.B. 192.168.20.0/24)", appLanguage), text: $vlan.networkAddress)
|
||||
.help(L10n.t("Der komplette Adressbereich dieses zusätzlichen Netzwerks.", appLanguage))
|
||||
TextField(L10n.t("Automatische Adressvergabe von", appLanguage), text: $vlan.poolRangeStart)
|
||||
.help(L10n.t("Ab welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.", appLanguage))
|
||||
TextField(L10n.t("bis", appLanguage), text: $vlan.poolRangeEnd)
|
||||
.help(L10n.t("Bis zu welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.", appLanguage))
|
||||
|
||||
Toggle("Von anderen Netzwerken isolieren", isOn: $vlan.isolated)
|
||||
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
|
||||
Toggle(L10n.t("Von anderen Netzwerken isolieren", appLanguage), isOn: $vlan.isolated)
|
||||
.help(L10n.t("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.", appLanguage))
|
||||
|
||||
Button("Netzwerk entfernen", role: .destructive) {
|
||||
Button(L10n.t("Netzwerk entfernen", appLanguage), role: .destructive) {
|
||||
viewModel.removeVlan(vlan.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Weiteres Netzwerk hinzufügen") {
|
||||
Button(L10n.t("Weiteres Netzwerk hinzufügen", appLanguage)) {
|
||||
viewModel.addVlan()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Zusätzliche Netzwerke (VLAN)")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Zusätzliche Netzwerke (VLAN)", appLanguage)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,47 @@ import SwiftUI
|
||||
struct WanStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
let availableInterfaces: [NetworkInterface]
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Internet-Anschluss") {
|
||||
Picker("Anschluss (Port)", selection: $viewModel.wanConfig.interfaceName) {
|
||||
Section(L10n.t("Internet-Anschluss", appLanguage)) {
|
||||
Picker(L10n.t("Anschluss (Port)", appLanguage), selection: $viewModel.wanConfig.interfaceName) {
|
||||
ForEach(availableInterfaces) { interface in
|
||||
Text(interface.name).tag(interface.name)
|
||||
}
|
||||
}
|
||||
.help("Der Netzwerkport, über den dein Router mit dem Internet verbunden ist (z.B. das Kabel zu deinem Modem oder deiner Anschlussdose).")
|
||||
.help(L10n.t("Der Netzwerkport, über den dein Router mit dem Internet verbunden ist (z.B. das Kabel zu deinem Modem oder deiner Anschlussdose).", appLanguage))
|
||||
|
||||
Picker("Verbindungsart", selection: $viewModel.wanConfig.mode) {
|
||||
Picker(L10n.t("Verbindungsart", appLanguage), selection: $viewModel.wanConfig.mode) {
|
||||
ForEach(WanConnectionMode.allCases) { mode in
|
||||
Text(mode.label).tag(mode)
|
||||
Text(L10n.t(mode.label, appLanguage)).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.help("Wie sich der Router beim Internetanbieter anmeldet. \"Automatisch (DHCP)\" passt für die meisten Kabel-/Glasfaseranschlüsse.")
|
||||
.help(L10n.t("Wie sich der Router beim Internetanbieter anmeldet. \"Automatisch (DHCP)\" passt für die meisten Kabel-/Glasfaseranschlüsse.", appLanguage))
|
||||
}
|
||||
|
||||
switch viewModel.wanConfig.mode {
|
||||
case .dhcpClient:
|
||||
Section {
|
||||
Text("Der Router bezieht seine Internetadresse automatisch von deinem Anbieter. Für die meisten Kabel-/Glasfaser-Anschlüsse die richtige Wahl.")
|
||||
Text(L10n.t("Der Router bezieht seine Internetadresse automatisch von deinem Anbieter. Für die meisten Kabel-/Glasfaser-Anschlüsse die richtige Wahl.", appLanguage))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .staticIP:
|
||||
Section("Statische Adresse") {
|
||||
TextField("IP-Adresse (z.B. 203.0.113.5/24)", text: $viewModel.wanConfig.staticAddress)
|
||||
.help("Die feste IP-Adresse, die dir dein Anbieter zugewiesen hat, inklusive Netzmaske (die Zahl nach dem /, z.B. /24).")
|
||||
TextField("Gateway (z.B. 203.0.113.1)", text: $viewModel.wanConfig.staticGateway)
|
||||
.help("Die Adresse des nächsten Geräts Richtung Internet — steht meist in den Unterlagen deines Anbieters.")
|
||||
Section(L10n.t("Statische Adresse", appLanguage)) {
|
||||
TextField(L10n.t("IP-Adresse (z.B. 203.0.113.5/24)", appLanguage), text: $viewModel.wanConfig.staticAddress)
|
||||
.help(L10n.t("Die feste IP-Adresse, die dir dein Anbieter zugewiesen hat, inklusive Netzmaske (die Zahl nach dem /, z.B. /24).", appLanguage))
|
||||
TextField(L10n.t("Gateway (z.B. 203.0.113.1)", appLanguage), text: $viewModel.wanConfig.staticGateway)
|
||||
.help(L10n.t("Die Adresse des nächsten Geräts Richtung Internet — steht meist in den Unterlagen deines Anbieters.", appLanguage))
|
||||
}
|
||||
case .pppoe:
|
||||
Section("PPPoE-Zugangsdaten") {
|
||||
TextField("Benutzername", text: $viewModel.wanConfig.pppoeUsername)
|
||||
.help("Zugangsdaten von deinem Internetanbieter für die Einwahl (z.B. bei DSL-Anschlüssen).")
|
||||
SecureField("Passwort", text: $viewModel.wanConfig.pppoePassword)
|
||||
.help("Das zum Benutzernamen gehörende Passwort von deinem Internetanbieter.")
|
||||
Text("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).")
|
||||
Section(L10n.t("PPPoE-Zugangsdaten", appLanguage)) {
|
||||
TextField(L10n.t("Benutzername", appLanguage), text: $viewModel.wanConfig.pppoeUsername)
|
||||
.help(L10n.t("Zugangsdaten von deinem Internetanbieter für die Einwahl (z.B. bei DSL-Anschlüssen).", appLanguage))
|
||||
SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.wanConfig.pppoePassword)
|
||||
.help(L10n.t("Das zum Benutzernamen gehörende Passwort von deinem Internetanbieter.", appLanguage))
|
||||
Text(L10n.t("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -51,13 +52,13 @@ struct WanStepView: View {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
|
||||
.disabled(!isStepValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Internet einrichten")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Internet einrichten", appLanguage)))
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
|
||||
@@ -2,12 +2,13 @@ import SwiftUI
|
||||
|
||||
struct WifiStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if viewModel.wifiNetworks.isEmpty && viewModel.unsupportedWifiInterfaces.isEmpty {
|
||||
Section {
|
||||
Text("An diesem Gerät wurde kein WLAN erkannt. Dieser Schritt wird übersprungen.")
|
||||
Text(L10n.t("An diesem Gerät wurde kein WLAN erkannt. Dieser Schritt wird übersprungen.", appLanguage))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -15,8 +16,9 @@ struct WifiStepView: View {
|
||||
if !viewModel.unsupportedWifiInterfaces.isEmpty {
|
||||
Section {
|
||||
Text(
|
||||
"Dein Gerät nutzt den neueren RouterOS-WiFi-Treiber (\(viewModel.unsupportedWifiInterfaces.map(\.name).joined(separator: ", "))), "
|
||||
+ "den diese App noch nicht unterstützt. WLAN muss dafür vorerst manuell über Winbox eingerichtet werden."
|
||||
L10n.t("Dein Gerät nutzt den neueren RouterOS-WiFi-Treiber (", appLanguage)
|
||||
+ "\(viewModel.unsupportedWifiInterfaces.map(\.name).joined(separator: ", "))"
|
||||
+ L10n.t("), den diese App noch nicht unterstützt. WLAN muss dafür vorerst manuell über Winbox eingerichtet werden.", appLanguage)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -24,29 +26,29 @@ struct WifiStepView: View {
|
||||
}
|
||||
|
||||
ForEach($viewModel.wifiNetworks) { $network in
|
||||
Section("WLAN auf \(network.interfaceName)") {
|
||||
Toggle("Aktivieren", isOn: $network.enabled)
|
||||
.help("Schaltet WLAN auf diesem Funkmodul ein und vergibt Netzwerkname und Passwort.")
|
||||
Section(L10n.t("WLAN auf", appLanguage) + " \(network.interfaceName)") {
|
||||
Toggle(L10n.t("Aktivieren", appLanguage), isOn: $network.enabled)
|
||||
.help(L10n.t("Schaltet WLAN auf diesem Funkmodul ein und vergibt Netzwerkname und Passwort.", appLanguage))
|
||||
if network.enabled {
|
||||
TextField("Netzwerkname (SSID)", text: $network.ssid)
|
||||
.help("Der Name, den Geräte in ihrer WLAN-Liste sehen und mit dem sie sich verbinden.")
|
||||
SecureField("Passwort (mind. 8 Zeichen)", text: $network.password)
|
||||
.help("Das WLAN-Passwort (WPA2). Muss mindestens 8 Zeichen lang sein.")
|
||||
TextField(L10n.t("Netzwerkname (SSID)", appLanguage), text: $network.ssid)
|
||||
.help(L10n.t("Der Name, den Geräte in ihrer WLAN-Liste sehen und mit dem sie sich verbinden.", appLanguage))
|
||||
SecureField(L10n.t("Passwort (mind. 8 Zeichen)", appLanguage), text: $network.password)
|
||||
.help(L10n.t("Das WLAN-Passwort (WPA2). Muss mindestens 8 Zeichen lang sein.", appLanguage))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
|
||||
.disabled(!isStepValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("WLAN (falls vorhanden)")
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("WLAN (falls vorhanden)", appLanguage)))
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
|
||||
Reference in New Issue
Block a user