39 Commits
Author SHA1 Message Date
KayandClaude Sonnet 5 dc4557496b Neue CHANGELOG.md (Keep-a-Changelog-Format, nur pro Release gefuellt)
Rekonstruiert aus den bisherigen Git-Tag-Messages (v1.0.0/v1.1.0/v1.2.0)
und bugs.md/found.md - jede Version behaelt ihren eigenen Abschnitt,
neue Releases werden oben angehaengt statt die Datei zu ueberschreiben.
README verlinkt jetzt darauf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:30:49 +02:00
KayandClaude Sonnet 5 c791693d7e Release v1.2.0
SemVer Minor - seit v1.1.0: DHCP-Pool-Haenger gefixt (SSHTransport-
Timeout, bugs.md #11), Experte-Tab-Port-Konflikt-Pruefung (bugs.md #12,
neue Faehigkeit). Beides live vom Nutzer bestaetigt. Build + alle
111 Unit-Tests gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:26:49 +02:00
KayandClaude Sonnet 5 87395a5164 bugs.md #12 / found.md #16: Experte-Tab-Port-Konflikt live bestaetigt
Nutzer bestaetigte "funktioniert". Gitea-Issue #25 angelegt und
geschlossen, Status in bugs.md/found.md auf live bestaetigt aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:25:36 +02:00
KayandClaude Sonnet 5 547c3d778a bugs.md #12: Experte-Tab-Port-Konflikt-Pruefung, Live-Test durch Nutzer steht aus
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:22:51 +02:00
KayandClaude Sonnet 5 3c84d3fbaf docs: found.md #16 fuer die Experte-Tab-Port-Konflikt-Pruefung
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:21:56 +02:00
KayandClaude Sonnet 5 b503bac82c Experte-Tab: Port-Konflikt-Pruefung wie im Einrichten-Assistenten
Per explizitem Nutzerwunsch die "Port freimachen?"-Abfrage samt allen
Warnungen aus dem LAN-Schritt des Wizards auch auf den Experte-Tab
angewendet, ausgeloest durch echten Live-Fall (ether4 wurde manuell
ueber Expert als eigenes Netz angelegt, blieb dabei unbemerkt Bridge-
Mitglied - zwei DHCP-Server im selben Broadcast-Domain).

PortConflictWarningView aus LanStepView.swift in Features/Shared/
extrahiert (jetzt von Wizard UND Experte-Tab genutzt), neuer
immediateApply-Parameter fuer die kontextabhaengige Abschluss-Meldung
(Wizard: erst bei "Jetzt anwenden"; Experte: sofort bei "Anlegen"/
"Speichern"). Neue PortConflict.resolutionCommandsIncludingBridgeDetach()
- der Experte-Tab hat anders als der Wizard keinen separaten,
automatischen Bridge-Detach-Schritt, muss die Bridge-Entfernung also
selbst mit auflisten.

ExpertViewModel bekommt dieselbe Race-sichere Generation-Zaehler-Logik
wie SetupViewModel (bugs.md #1), angewendet auf das .interfacePick-Feld
des jeweils offenen Schemas. Speichern-Button gesperrt bis Konflikt
bestaetigt oder Port gewechselt.

6 neue Regressionstests. Build + alle 111 Unit-Tests gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:21:36 +02:00
KayandClaude Sonnet 5 57850cfe03 docs: bugs.md #11 mit Gitea-Issue #24 verlinkt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:51:42 +02:00
KayandClaude Sonnet 5 cae59db39e Fix Haenger beim Anlegen eines DHCP-Pools (bugs.md #11)
SSHTransport.connect()/run() hatten keinen Timeout - im Gegensatz zu
RestTransport (timeoutInterval=5). Jeder erste Schreibvorgang einer
Session loest ueber ensureSessionBackup() eine dedizierte SSH-
Verbindung aus; haengt die, blieb isApplying unbegrenzt aktiv (kein
Fehler, kein Recovery, nur Force-Quit). Live vom Nutzer bestaetigt
(Experte-Tab, dauerhaft haengend) bevor der Fix geschrieben wurde.

Neuer genererischer SSHTransport.withTimeout(_:operation:) (Task-
Group-Race gegen eine Deadline), angewendet auf connect() (10s) und
run() (30s). 3 neue Regressionstests fuer die Race-Logik isoliert.

Nebenbefund: xcodegen generate ueberschreibt Info.plist komplett aus
project.yml (kein Merge) - ein Regenerieren fuer die neue Testdatei
setzte die Version stillschweigend von 1.1.0 auf 1.0 zurueck. Version
jetzt explizit in project.yml verankert.

Build + alle 106 Unit-Tests gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:51:11 +02:00
KayandClaude Sonnet 5 e9e701a94d bugs.md: Nutzer-gemeldeter Hänger beim Anlegen eines DHCP-Pools
Neuer Eintrag #11: Nutzer-Meldung + Code-Audit-Befund (plausibler
Kandidat: SSHTransport.connect() hat keinen Timeout, jeder erste
Schreibvorgang pro Session loest ueber ensureSessionBackup() eine
dedizierte SSH-Verbindung aus - haengt die, blockiert die UI
unbegrenzt). Noch nicht bestaetigt, Status offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:45:25 +02:00
KayandClaude Sonnet 5 bad6328c8f Release v1.1.0
SemVer Minor - seit v1.0.0: Firewall-Isolation-Connection-Flush
(neue Fähigkeit, bugs.md #7), 3 Alert-Dismiss-Bugfixes (M34),
wizard_flow-Diagramm-Korrektur, 20 Gitea-Issues für alle bisherigen
Funde. Build + alle 103 Unit-Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:35:29 +02:00
KayandClaude Sonnet 5 dcb9d9e03c bugs.md #7: Isolation kappt jetzt auch bereits bestehende Verbindungen
Bisher wirkten die neuen Firewall-Isolationsregeln nur auf neue
Verbindungen - eine bereits offene Verbindung zwischen zwei gerade
isolierten Netzen lief unbeeinflusst weiter (Standard-Verhalten jeder
stateful Firewall). Neue SSHTransport.flushConnections/
ConnectionService.flushConnections entfernen per /ip firewall
connection remove [find where (src-address in A) and (dst-address in
B)] bereits getrackte Verbindungen zwischen isolierten Netzpaaren,
aufgerufen direkt nach den Firewall-Befehlen in SetupViewModel.apply().

FirewallConfig.NetworkSegment um networkAddress (CIDR) erweitert,
Paar-Logik in eine wiederverwendbare isolatedNetworkPairs-Property
extrahiert. networkA/networkB werden vor der SSH-Interpolation als
reine CIDR-Notation validiert (dieselbe Vorsicht wie bei der zuvor
gefixten CLI-Injection).

Ehrlicher Verifikationsstand dokumentiert statt Überclaiming: die
kombinierte remove-Bedingung ließ sich mangels zweier echter
Testnetze nicht end-to-end beweisen - ein erster scheinbarer Erfolg
stellte sich als Messfehler heraus (natürlicher ICMP-Conntrack-Timeout,
nicht der remove-Befehl selbst). Details in bugs.md #7.

Build + alle 103 Unit-Tests grün (1 neuer Regressionstest).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:30:51 +02:00
KayandClaude Sonnet 5 06ddd837f1 Docs: found.md/bugs.md-Einträge mit Gitea-Issues verlinkt
Alle 20 granularen Funde aus found.md (#1-10) und bugs.md (#1-10) als
einzelne Gitea-Issues angelegt (#3-22): 19 direkt geschlossen (Status
laut Doku "fixed"), 1 offen gelassen (bugs.md #7, Firewall-Isolation-
Beobachtung, bewusst ohne automatischen Fix). Jeder Markdown-Eintrag
verweist jetzt per Link auf sein Gitea-Issue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:11:22 +02:00
KayandClaude Sonnet 5 4810b7df63 Fix 3 nicht funktionierende Alert-Dismiss-Buttons (M34)
README-Milestone-Nachcheck (Port-Konflikt-Prüfung/"Fertig"-Button)
deckte drei eigenständige Bugs derselben Klasse auf: Cancel/OK-Buttons
bei ReviewApplyViews Apply-Fehler-Alert, ConnectViews
Zertifikat-Alert und ConnectViews SSH-Hostkey-Alert taten nichts oder
zu wenig - der jeweilige Verbindungs-/Fehlerzustand blieb hängen,
der Dialog konnte nicht sauber verlassen werden.

Neue ConnectionService.cancelPendingTrustConfirmation() und
SetupViewModel.dismissApplyError(), alle drei Alerts korrekt
verdrahtet (Button-Action + Bindings-Setter fuer Tap-Outside/Esc).
Totes dismissPendingSSHTrust() entfernt. 1 neuer Regressionstest,
alle 102 Unit-Tests gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:04:01 +02:00
KayandClaude Sonnet 5 510e750b3c Manual: wizard_flow-Diagramm zeigt jetzt Einfach/Experte-Verzweigung
Diagramm-Gegencheck aller 5 Mermaid-Diagramme gegen aktuellen Code:
architecture/connect_trust/backup_restore/focus_mode stimmen exakt.
wizard_flow zeigte den Wizard als reine lineare Kette inkl. VLAN-Schritt
und verschwieg die M9-Verzweigung (VLAN-Schritt wird im Einfach-Modus
übersprungen, Firewall-Grundschutz dort fest an statt Toggle). Diagramm
korrigiert, PNG/PDF/App-HTML (DE+EN) über build-manual.py neu generiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:43:34 +02:00
KayandClaude Sonnet 5 bace9fe3b2 gitignore: Release-Artifacts/ ausschließen
Gebaute .app/.zip für Gitea-Release-Uploads gehört nicht ins Repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:36:57 +02:00
KayandClaude Sonnet 5 57c3134161 Release v1.0.0
Erste getaggte Version. SemVer (MAJOR.MINOR.PATCH), ab hier laufende
Nummerierung. Stand: 33 Meilensteine (M1-M33), Setup-Wizard, Experte-Tab,
Übersicht, Geräte, Sicherungen, Settings, Handbuch (DE/EN), automatisches
Wiederverbinden - alles live gegen echte Hardware verifiziert. Aktuellster
Durchgang: dreifacher Tester-/Sicherheits-Deep-Dive (bugs.md), inkl. live
exploitierter und live gefixter RouterOS-CLI-Injection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:27:50 +02:00
KayandClaude Sonnet 5 1b5dc781b9 Docs: M33 (dreifacher Tester-/Sicherheits-Deep-Dive) in README/HANDOFF/CHATLOG
README-Milestone-Tabelle, Sicherheits-Absatz, HANDOFF-Abschnitt und
Chatlog-Nacherzählung für den kompletten Bugjagd-Bogen (bugs.md)
nachgezogen - Race Condition, RouterOS-CLI-Injection (live exploitiert
+gefixt), Parser-Datenverlust, TOFU-Härtung u.a.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:22:15 +02:00
KayandClaude Sonnet 5 aad2ccb7d4 Härtung: REST-Query-Injection-Parität + TOFU-Fingerprint-Fallback
Dritter, sicherheitsfokussierter Deep-Dive-Durchgang ("maximale Sicherheit"):

- RestTransport.fetchFieldValues hatte dieselbe ungeschützte
  String-Interpolation wie das SSH-Pendant aus dem vorigen Fix, nur als
  URL-Query statt CLI-Zeile - beim ersten Fix übersehen. Jetzt
  RFC-3986-konform percent-encoded.
- CertificateFingerprint.sha256 fiel bei Extraktionsfehlern auf einen
  festen String "unbekannt" zurück statt echtem Fingerabdruck -
  theoretisches TOFU-Pinning-Bypass-Fenster (zwei verschiedene,
  extraktions-fehlschlagende Zertifikate hätten sich denselben
  "Fingerabdruck" geteilt). Rückgabetyp optional, Extraktionsfehler
  führt jetzt zu hartem Verbindungsabbruch statt Trust-Dialog.

Beide Fixes defensiv/gehärtet, nicht live exploitiert. Zugangsdaten-
Speicherung (Keychain) und BackupServices eigene Escaping-Logik
gegengeprüft - bereits korrekt. Build + alle 101 Unit-Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:14:44 +02:00
KayandClaude Sonnet 5 800b48406b Fix live exploitierte RouterOS-CLI-Injection + Parser-Datenverlust
Deep-Dive-Gegencheck mit echtem Exploit-Nachweis gegen den Testrouter:

- RouterOSCommand.cliLine quotete Werte nur bei Leerzeichen und escapte
  eingebettete Anführungszeichen nie. Ein Kommentar wie test" ; :log
  warning "X schloss das Quoting vorzeitig und ließ RouterOS den Rest
  als zweiten Befehl ausführen. Live exploitiert (injizierter
  script,warning-Log-Eintrag) und live als behoben bestätigt. Betraf
  jede Schreiboperation über SSH - auf dem aktuellen Testrouter ist
  www-ssl deaktiviert, REST also unerreichbar, der Bug war aktiv.
- RouterOSCliParser.keyValues nahm an, print terse quote mehrwortige
  Werte - live an zwei Menüs widerlegt (RouterOS 7.24.4 quotet dort
  nichts). Trunkierte jeden mehrwortigen Wert beim ersten Leerzeichen.
  Fix: Token-Scan statt Regex.
- SSHTransport.fetchFieldValues defensiv gegen dieselbe Injection-Klasse
  gehärtet (aktuell nur hartkodiert aufgerufen, aber generische API).

2 neue Regressionstests, alle 101 Unit-Tests grün. Details in bugs.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:08:34 +02:00
KayandClaude Sonnet 5 3b84d31ec6 Fix 4 Bugs aus systematischem Tester-Durchgang (bugs.md)
Race Condition bei LAN-Port-Konflikt-Prüfung (Generation-Zähler), Firewall-Titel
widersprach sich im Einfach-Modus, 2 fehlende EN-Übersetzungen, Health-Check-
Herzschlag ignorierte laufende Wizard-/Experte-Schreibvorgänge. Außerdem M7/M9
im README/HANDOFF auf fertig aktualisiert (Modusschalter+Experte-Zweig liefen
bereits über die Multi-LAN/WLAN-Live-Tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:53:58 +02:00
KayandClaude Sonnet 5 123e46973a Docs: M7 Verbindungsabbruch-mitten-im-Apply per Code-Review geklärt
SetupViewModel.apply() fängt einen Fehler mitten in der Befehlsschleife
sauber per do/catch ab (kein Absturz/Hänger), applyLog zeigt den
Fortschritt bis zum Abbruch. Kein Code geändert, nur README-Status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:36:23 +02:00
KayandClaude Sonnet 5 25401cde78 M32: Automatisches Wiederverbinden nach Verbindungsabbruch
ConnectionService: Herzschlag-Task (alle 10s /system identity) erkennt
einen Verbindungsverlust während der Sitzung; bei Ausfall Retry-Schleife
(REST->SSH, alle 5s, unbegrenzt bis Erfolg oder manuellem "Trennen").
state bleibt bewusst .connected währenddessen, damit andere Tabs nicht
auf "Nicht verbunden" umspringen — isReconnecting/reconnectAttemptCount/
secondsUntilNextReconnectAttempt treiben einen Banner im Verbinden-Tab
mit Versuchszähler + Countdown.

Live bestätigt, zusätzlich beim echten Firmware-Update-Neustart erneut
gegengetestet. README/Manual (DE+EN) aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:32:31 +02:00
KayandClaude Sonnet 5 3a26f50800 M31: Handbuch in der App (Textanker, DE+EN)
"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und
allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und
springen per Textanker direkt zur passenden Manual-Stelle.

build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES-
Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller
Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache
automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs-
Dictionary wiederverwendet wird (714 Einträge geparst) statt einer
zweiten, separat gepflegten Übersetzung.

Live bestätigt (DE und EN).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:15:29 +02:00
KayandClaude Sonnet 5 e582728040 Zebra-Streifen für Tabellenansichten (found.md #8)
TableZebra-Helper (AppKit NSColor.alternatingContentBackgroundColors)
angewendet auf: LAN-Scanner-Geräte/Rohfelder/Port-Scan, Experte-
Eintragsliste + "Weitere Parameter"-Grid, Bekannte Router + Interface-
Liste (Verbinden), Sicherungsliste, Review/Apply-Log, Übersicht-
Knotendetails.

Root Cause für "keine Änderung erkennbar": .formStyle(.grouped) malt
einen eigenen blickdichten Hintergrund über Section-Zeilen, der
.listRowBackground verdeckt — .scrollContentBackground(.hidden) ergänzt.

Live bestätigt ("passt").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:22:26 +02:00
KayandClaude Sonnet 5 2848205660 Docs: LAN-Scanner Sparkline/Refreshrate live bestätigt
Kein Code geändert, nur found.md Status aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 16:02:18 +02:00
KayandClaude Sonnet 5 a5853b6bf1 Docs: Bonding (M10) erstmals gegen echte Hardware verifiziert
hAP lite, ether3+ether4, mode=active-backup: bond1 korrekt angelegt
und laufend. App meldete beim zweiten Anlege-Versuch einen Fehler
(Exit-Code 126, kein Klartext), Router-Zustand zeigte aber Erfolg —
vermutlich kurz abgerissene SSH-Verbindung durch Interface-
Neuinitialisierung, dokumentiert statt weiterverfolgt. Kein Code
geändert, nur HANDOFF/CHATLOG.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:57:06 +02:00
KayandClaude Sonnet 5 2852eaec2c Docs: WLAN-Schritt (M5/M10) erstmals gegen echte Hardware verifiziert
hAP lite (erstes Testgerät mit WLAN-Chip): Sicherheitsprofil-Anlage +
SSID/Passwort-Set liefen durch, Smartphone verband sich erfolgreich.
Kein Code geändert, nur offene Punkte in HANDOFF/README/CHATLOG geschlossen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:35:58 +02:00
KayandClaude Sonnet 5 a2c2be0730 Übersicht: Spaltenüberschriften auch im Fokus-Popup
focusPanel nutzte dieselbe Spalten-Layout-Logik wie das Hauptdiagramm,
hatte aber nie deren Header-Zeile (Interfaces/IP-Adressen/...) übernommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:23:40 +02:00
KayandClaude Sonnet 5 6f29bbcb2b M30: Experte-Tab Mode-Taste-Menü + SSH-Trust-/REST-Fixes
Neues Schema für /system routerboard mode-button (enabled/on-event/
hold-time), live gegen echte Hardware verifiziert.

Dabei drei echte Bugs gefunden und gefixt:
- SSH-Trust-Dead-End beim ersten Experte-Tab-Schreiben (Backup-vor-
  Schreiben-Pfad hatte keinen Weg, den Trust-Dialog auszulösen) —
  ConnectionService.noteUntrustedSSHHostKey
- RouterOSFieldSchema.clearable: optionale Felder senden nie mehr
  einen expliziten Leer-Wert, wenn das RouterOS ablehnt
- RouterOSMenuSchema.writesRequireSSH + ConnectionService.applyViaSSH:
  für Menüs ohne REST-Anbindung (bewiesen per direktem SSH-Test) wird
  zwingend eine dedizierte SSH-Verbindung genutzt statt REST

Live Ende-zu-Ende bestätigt: Skript anlegen, Mode-Taste zuweisen,
Tastendruck löst Skript korrekt aus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:14:31 +02:00
KayandClaude Sonnet 5 221b914b05 M29: App-Einstellungen (Settings-Fenster ⌘,)
Neues natives macOS-Settings-Fenster mit Sprache, Auto-Update-Check,
Farbschema (Standard/Kontrastreich), echter Textgrößen-Skalierung,
Bedienelement-Größe und LAN-Scanner-Refreshraten/Sparkline-Einstellungen.

- AppPreferences.swift: zentrale AppStorage-Keys, ColorTheme/AppTextSize/
  UIDensity
- SettingsView.swift: 3-Tab-Settings-Scene
- .environment(\.dynamicTypeSize) erwies sich auf macOS als wirkungslos
  (per ImageRenderer-Snapshot bewiesen) — durch eigenen appFontScale-
  Mechanismus ersetzt, ~110 .font(...)-Aufrufe app-weit umgestellt
- Farbschema auf Wunsch auch auf Experte-Tab-Sidebar ausgeweitet
- Docs aktualisiert: README/HANDOFF/CHATLOG/Manual.md+PDF, found.md

Live bestätigt nach mehreren Nachbesserungsrunden ("passt, lassen wir so").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:38:22 +02:00
KayandClaude Sonnet 5 53a2931239 LAN-Scanner: Sparkline breiter/schnelleres Fenster, Traffic in MB/s
TrafficSparkline-Breite 50pt→200pt, Zeitfenster 10s→30s, Poll-Intervall
3s→0,1s. Traffic-Anzeige von auto-skaliertem kbps/Mbps/Gbps auf festes
MB/s umgestellt. Alles per Nutzeranfrage iterativ angepasst, noch nicht
live gegen echten Router getestet (siehe found.md Fund 4). Manual.md +
Manual.pdf mit aktualisiert (build-manual.py). Fund 5 (App-Settings/
Preferences-Bereich) als offene Feature-Idee in found.md notiert, noch
nicht umgesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:30:22 +02:00
KayandClaude Sonnet 5 e2ffcaa250 Manual.md komplett neu: hochdetaillierte Referenz + PDF + Build-Pipeline
Manual.md ersetzt das bisherige kurze bilinguale Handbuch durch eine
hochpräzise Anleitung: Kapitel 0-4/6-7 zitieren die tatsächlichen
Tooltip-/Hilfetexte der App wörtlich (aus ConnectView, den
Einrichten-Wizard-Schritten, DevicesView, BackupListView, OverviewView),
Kapitel 5 (Experte-Feldreferenz, 44 Menüs/173 Felder) ist automatisiert
aus RouterOSSchemaCatalog.swift geparst statt von Hand übertragen, kann
also nie vom tatsächlichen App-Verhalten abweichen. Fünf
Mermaid-Diagramme (Architektur, Verbindungs-/Vertrauens-Ablauf,
Wizard-Schrittfolge, Fokus-Modus-Zustände, Sicherung/Wiederherstellung)
als Quelle unter Manual-assets/diagrams/*.mmd, vorgerendert nach PNG.
Echte App-Screenshots sind nicht möglich (keine macOS-UI-Automatisierung
verfügbar) — im Manual selbst offen benannt statt stillschweigend
ausgelassen.

Neues build-manual.py regeneriert die Experte-Referenztabellen
(zwischen festen Markern in Manual.md, Handschrift-Kapitel bleiben
unberührt), rendert geänderte Diagramme neu und exportiert Manual.pdf
über Markdown→HTML→headless Chrome print-to-pdf. README.md verweist auf
beides. Künftig bei jeder Manual-Aktualisierung erneut laufen lassen,
damit PDF und Referenztabellen nicht vom Quellcode abdriften.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:06:50 +02:00
KayandClaude Sonnet 5 c9671b017c M28: Übersicht Fokus-Modus + einheitliches Close-Button-Layout
Klick auf einen Knoten in der Übersicht öffnet jetzt ein schwebendes
Popup mit der kompletten verbundenen Kette (neu OverviewGraph.
connectedChain, volle transitive Hülle unabhängig vom Knotentyp,
bewusst getrennt von der bestehenden highlightedNodeIDs), sauber im
selben Spalten-Layout neu angeordnet, Rest des Diagramms abgedunkelt.
Nicht-modales Overlay statt .sheet, damit die rechte Seitenleiste
währenddessen bedienbar bleibt (Knoten direkt aus dem Popup heraus
bearbeitbar). Popup-Größe passt sich automatisch dem Inhalt an, keine
Scrollbalken.

Der dabei entstandene Close-Button-Header (Titel + Spacer + X, fest
oben, Divider direkt darunter) wurde auf alle vier Popup-Formulare der
App vereinheitlicht: Experte-Bearbeiten-Sheet und die drei
Devices-Sheets (Rohdaten, Netzwerk-Test, Port-Scan) — dort ersetzt er
jeweils den bisherigen einzelnen "Schließen"-Button unten.

Mehrere Design-Iterationen live mit dem User durchgespielt (Trennlinie
im Canvas → separates Panel → Popup → .sheet → non-modales Overlay),
finale Version live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:46:39 +02:00
KayandClaude Sonnet 5 684e1c184a Bug 39: "Weitere Parameter"-Liste im Experte-Sheet zweispaltig + lesbarer
Bei Menüs mit vielen ungekuratierten RouterOS-Feldern (z.B. "Alle
Interfaces (generisch)", Ethernet-Ports mit 30+ Extra-Parametern) war
das Edit-Sheet eine lange Einspalten-Liste mit viel Scrollen. Jetzt
echte Grid-Spalten für Parameter/Wert (statt TextField(titleKey:) mit
fest eingeblendetem Zeilenlabel, das auf macOS Platz frisst), einmalige
Kopfzeile, Sheet-Breite 420→900, horizontale Trennlinie pro Zeile,
Abstand zum "Parameter hinzufügen"-Button. Betrifft jedes Menü mit
ungekuratierten Feldern, nicht nur Interfaces. Live gegen echten
Router bestätigt (mehrere Nachbesserungsrunden anhand Screenshots).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:06:45 +02:00
Kay 0875d7c3a4 Merge branch 'main' of 192.168.178.222:kay/RouterOS 2026-09-16 19:44:15 +02:00
KayandClaude Sonnet 5 be04fc2fc0 Bug 38: NTP-Zeitserver-Feld korrigiert (HTTP 400)
/system/ntp/client hat in RouterOS 7.x kein "servers"-Feld — die Server
liegen in einer eigenen Liste /system ntp client servers. Schema in
RouterOSSchemaCatalog.swift aufgeteilt (Singleton enabled/mode + neue
Listen-Menü-Schema mit Feld "address"), zusätzlich "enabled" auf
"disabled" korrigiert (RouterOS-Konvention für Listen-Einträge).
Live gegen echten Router bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:43:59 +02:00
kay 47557760f8 Merge pull request 'Windows-Version.md aktualisiert' (#2) from Daniel/RouterOS:daniel-patch-2 into main
Reviewed-on: kay/RouterOS#2
2026-09-16 19:28:31 +02:00
Daniel 7fb7e721b9 Windows-Version.md aktualisiert 2026-09-16 19:27:51 +02:00
kay b8a39033d6 Windows-Version.md hinzugefügt 2026-09-16 19:27:00 +02:00
70 changed files with 12165 additions and 759 deletions
+4
View File
@@ -13,7 +13,11 @@ xcuserdata/
*.npk
*.cpgz
# Built release artifacts (uploaded as Gitea release assets, not repo source)
/Release-Artifacts/
# Router config backups (real device data, not app source) — created here when
# testing the app's "choose backup folder" feature with this directory selected.
/Backups/
*.rsc
__pycache__/
+61
View File
@@ -0,0 +1,61 @@
# Changelog
Alle nennenswerten Änderungen an RouterOS Assistant. Format angelehnt an
[Keep a Changelog](https://keepachangelog.com/), Versionierung nach [SemVer](https://semver.org/).
Wird nur bei einem Release gefüllt (nicht laufend während der Entwicklung) — Details zu jedem
einzelnen Fund/Fix stehen in [`bugs.md`](bugs.md)/[`found.md`](found.md), der volle
Entwicklungsverlauf in [`HANDOFF.md`](HANDOFF.md)/[`CHATLOG.md`](CHATLOG.md).
## [1.2.0] — 2026-09-17
### Hinzugefügt
- Experte-Tab: dieselbe Port-Konflikt-Prüfung ("Port freimachen?") wie im Einrichten-Assistenten,
jetzt auch für jedes Schema mit Interface-Feld (bugs.md #12) — live bestätigt.
### Behoben
- Hänger beim Anlegen eines DHCP-Pools (und generell bei jedem ersten Schreibvorgang einer
Session): `SSHTransport.connect()`/`run()` hatten keinen Timeout, ein hängender
Verbindungsaufbau blockierte die App unbegrenzt ohne Fehlermeldung (bugs.md #11) — live
bestätigt.
## [1.1.0] — 2026-09-17
### Hinzugefügt
- Firewall-Isolation trennt jetzt auch bereits bestehende (nicht nur neue) Verbindungen zwischen
isolierten Netzen (Connection-Flush, bugs.md #7).
### Behoben
- Drei nicht funktionierende Alert-"Abbrechen"/"OK"-Buttons (Zertifikat-Vertrauen-,
SSH-Hostkey-Vertrauen- und Apply-Fehler-Dialog blieben nach Klick hängen, `state` wurde nie
zurückgesetzt) (bugs.md #10).
- `wizard_flow`-Diagramm im Handbuch zeigte den Wizard fälschlich als reine lineare Kette —
Einfach/Experte-Verzweigung (VLAN-Schritt wird im Einfach-Modus übersprungen) ergänzt.
### Dokumentation
- Alle bisherigen Funde aus `found.md`/`bugs.md` als einzelne Gitea-Issues nachgetragen (#3-22).
## [1.0.0] — 2026-09-17
Erste getaggte Version. Vollständiger Funktionsumfang: Setup-Wizard (WAN/LAN/VLAN/WLAN/Firewall,
Einfach- und Experte-Modus), Experte-Tab (generischer RouterOS-Zugriff, 45 kuratierte Menüs),
Übersicht-Diagramm, Geräte-Tab (LAN-Scanner), Sicherungen, Einstellungen, Handbuch in der App
(DE/EN), automatisches Wiederverbinden nach Verbindungsabbruch — live gegen echte MikroTik-
Hardware (hEX, hAP lite) verifiziert.
### Sicherheit
- **RouterOS-CLI-Injection behoben** (schwerwiegendster Fund der gesamten Entwicklung): beliebige
Textfelder (Kommentare, SSID, Freitextparameter) konnten über ein eingebettetes `"` gefolgt von
`;` einen zweiten, unabhängigen RouterOS-Befehl einschleusen — live exploitiert und live als
behoben bestätigt (bugs.md #5).
- REST-Pendant zur selben Injection-Klasse geschlossen (`RestTransport.fetchFieldValues`, RFC-3986-
Encoding, bugs.md #8).
- TOFU-Zertifikatsprüfung gehärtet: ein Extraktionsfehler führte zu einem festen Fallback-String
statt eines echten Fingerabdrucks — theoretisches Pinning-Bypass-Fenster geschlossen (bugs.md #9).
### Behoben
- Genereller RouterOS-Antwort-Parser trunkierte jeden mehrwortigen Wert beim ersten Leerzeichen,
da RouterOS' `print terse` (anders als angenommen) keine mehrwortigen Werte quotet (bugs.md #6).
- Race Condition bei der Port-Konflikt-Prüfung im LAN-Schritt (bugs.md #1).
- Sich selbst widersprechender Firewall-Schritt-Titel im Einfach-Modus, zwei fehlende
Englisch-Übersetzungen, Health-Check-Herzschlag ignorierte laufende Schreibvorgänge
(bugs.md #2-4).
+158
View File
@@ -994,3 +994,161 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
aktualisiert (inkl. Mermaid-Diagramm um `NetworkToolsService`-SSH-
Verbindung und `PortScanner`s direkten Mac→Gerät-Pfad ergänzt),
Commit + Push.
## Session: App-Einstellungen / Settings-Fenster (M29)
- "erstelle eine für diese App sinnvolle struktur für die Einstellung
Settings" (found.md hatte das schon als offene Idee vermerkt) →
Rückfrage: natives macOS-Settings-Fenster (⌘,) statt eigenem Tab, alle
Bereiche (Sprache, Updates, Farben, Textgröße, Bedienelemente,
Refreshraten) auf einmal, Farben als 2 vordefinierte Themes statt
freier Farbwahl. Neues `AppPreferences.swift` + `SettingsView.swift`
gebaut, Build+98 Tests grün.
- Live-Test: "die änderung der Bedienelemente und Sparkling funktioniert,
bei Textgröße und Farbschemas sehe ich keine Änderung" → Farbschema:
drei Farben waren anfangs in beiden Themes identisch (Kopierfehler),
gefixt.
- Erneuter Live-Test: weiterhin "unverändert" bei beidem trotz Fix →
nicht weiter geraten, sondern per ImageRenderer-Snapshot bewiesen:
.environment(\.dynamicTypeSize, ...) hat auf macOS **keine Wirkung**
(anders als iOS) — SwiftUI-Textstile skalieren dort nicht über
Dynamic-Type-Kategorien. Rückfrage mit 3 Alternativen, Nutzer wählte
"echtes Text-only Scaling". Kompletter Neubau: eigener
\.appFontScale-Mechanismus, ~110 .font(...)-Aufrufe app-weit
automatisiert umgestellt (2 bewusste Ausnahmen: Übersicht-
Diagrammkarten, LAN-Scanner-Tabellenspalten — feste Breiten, würden
beim Skalieren brechen).
- "textgröße funktioniert, die Farbschemas nicht, prüfen. kannst du
selbst auf den router schauen und das prüfen?" → kein Router-Zugriff
möglich (kein Tool/Zugang), aber Bug sitzt ohnehin rein im
App-Rendering — LAN-Scanner-eigene Statusfarben (Traffic/Fest-
Dynamisch/Port-Scan) beim ersten Farbfix übersehen, weiterhin zu
ähnliche SwiftUI-Nachbarfarben (grün/mint, orange/gelb). Gefixt, per
Snapshot-Swatch-Vergleich selbst verifiziert vor erneuter Bitte um
Live-Test.
- "Lan-scanner und Übersicht funktionieren" (Farbschema bestätigt),
"der experte-tab zeigt noch keine Änderung" (Textgröße) → Ursache: die
komplette Experte-Sidebar-Liste und jedes Parameter/Wert-TextField
hatten von Anfang an gar kein .font(...) — automatisierte Umstellung
griff nur bei vorhandenen Font-Aufrufen. Fix: globaler
Default-Font-Fallback an der Fenster-Wurzel.
- "keine Änderung beim Farbschema unter Experte" → kein Bug, sondern
Scope: Farbschema war nie für den Experte-Tab verdrahtet. Rückfrage,
ob ausgeweitet werden soll → ja. Sidebar-Kategorien bekommen jetzt
einen Farbpunkt, gemappt auf dieselbe 5-Farb-Palette wie die Übersicht.
- "passt, lassen wir so. für später merken (found.md): anpassbare
Farben" → found.md Punkt 9 ergänzt (freie Farbwahl pro Kategorie statt
nur 2 Themes, für später). Milestone live bestätigt, Docs aktualisiert,
Commit + Push.
## Session: Experte-Tab "Mode-Taste"-Menü (M30)
- "weiter in der found.md" (nächster offener Punkt: Mode-Button) →
Recherche bei help.mikrotik.com (nicht geraten): echte Felder
enabled/on-event/hold-time, als neues Singleton-Schema gebaut,
inkl. Warnhinweis zur physischen Bestätigungspflicht ab RouterOS
7.1rc4.
- "schreib mir ein ungefährliches testscript zum testen der
Mode-Taste" → reiner :log-info-Befehl vorgeschlagen (keine
Konfigänderung).
- Nutzer stieß beim Skript-Anlegen zuerst auf einen SSH-Trust-Dead-End
(Bug 40, echte Ursache gefunden per Codelesen: Backup-vor-erstem-
Schreiben lief über eigene SSH-Verbindung ohne Trust-Dialog-
Anbindung) — gefixt (`ConnectionService.noteUntrustedSSHHostKey`).
- Mode-Taste speichern → HTTP 500. Erste Spur (leeres `hold-time`)
gefixt, Fehler blieb identisch — Nutzer testete denselben Befehl
direkt per SSH-Terminal: lief dort sofort. Damit bewiesen: RouterOS-
REST-Deckungslücke für dieses Menü, kein App-Bug. Fix:
`writesRequireSSH`-Flag + dedizierte SSH-Verbindung fürs Schreiben
(Bug 41).
- Tastendruck löste Skript aus, aber "not enough permissions" im Log.
Zwei Terminal-Fixversuche liefen ins Leere (Skriptname-Großschreibung
+ falsche `set`-Syntax trafen den Eintrag nie — `print detail`
bestätigte unveränderten Zustand). Mit korrektem Index-Befehl
(`dont-require-permissions=yes`) sofort gelöst (Bug 42, reines
RouterOS-Verhalten, kein App-Bug).
- Live Ende-zu-Ende bestätigt: Skript anlegen → Mode-Taste über die
App zuweisen+speichern → Tastendruck löst Skript korrekt aus, Log
zeigt den Eintrag. Docs aktualisiert, Commit + Push.
## Session: WLAN-Schritt erstmals gegen echte Hardware verifiziert (M5/M10)
- "neuer Router (hAP lite) mit WLAN" → erstes Testgerät mit echtem
WLAN-Chip seit Projektstart (beide bisherigen Testgeräte ohne WLAN).
"WLAN-Schritt im Wizard durchtesten" → Code-Stand geprüft
(Legacy-Treiber `/interface wireless`, passt zur hAP-lite-Hardware),
dann live durchgeklickt: verbunden, WLAN-Schritt (SSID+Passwort)
durchlaufen, Smartphone verband sich erfolgreich mit dem neu
eingerichteten WLAN. Damit M5 (WLAN-Schritt) und der WLAN-Teil von
M10 (Experte-Tab-Schemas) erstmals vollständig gegen Hardware
bestätigt — vorher nur der "kein WLAN"-Zweig getestet.
## Session: Bonding erstmals gegen echte Hardware verifiziert (M10)
- "Bonding testen" (hAP lite, ether3+ether4 frei) → Experte-Tab →
Interfaces → Bonding, `mode=active-backup`. Erster Versuch schlug
erwartungsgemäß fehl ("ether3 already in bridge" — Ports waren noch
LAN-Bridge-Mitglieder). Nach Entfernen aus der Bridge zweiter
Versuch: App meldete einen Fehler ("Exit-Code 126" ganz ohne
Klartext), `/interface bonding print` am Router zeigte aber `bond1`
bereits korrekt angelegt und laufend — vermutlich eine durch die
Interface-Neuinitialisierung kurz abgerissene SSH-Verbindung, die
die App fälschlich als Fehler wertete. Bonding selbst funktioniert
einwandfrei. "reicht, doku aktualisieren und committen, aber nicht
deployen" → HANDOFF.md aktualisiert, Commit ohne Release-Deploy.
## Session: Dreifacher Tester-/Sicherheits-Deep-Dive (M33)
> "jetzt bist du tester, Senior-entwickler und testest die auf auf alle
> Funktionalitäten. suche bugs, merke diese und zeige mir anschließend
> eine liste (bugs.md). die arbeitenn wir dann weiter ab. hier die
> Zugasdaten zum Testriuter (hap lite) IP: 192.168.88.1,, user: admin,
> passwort: NEHHT2TMH9"
Erste Runde: ohne UI-Automatisierung (nicht verfügbar) Code-Audit jedes
Feature-Bereichs gegen echte Router-Ground-Truth per SSH. Vier Funde in
`bugs.md`: Race Condition bei der Port-Konflikt-Prüfung im LAN-Schritt,
ein sich selbst widersprechender Titel im Firewall-Schritt (Einfach-Modus),
zwei fehlende Englisch-Übersetzungen, ein Health-Check-Herzschlag, der
laufende Schreibvorgänge ignorierte. "hau rein" → alle vier gefixt, Build +
99 Tests grün, committed (nicht gepusht).
> "erneuter gegencheck mit echtem deep dive, alles testen"
Zweite, tiefere Runde — diesmal mit echten Exploit-Versuchen gegen den
Router statt nur Code-Lesen. Schwerwiegendster Fund der ganzen Session:
`RouterOSCommand.cliLine` escapte eingebettete Anführungszeichen nie, ein
Kommentar wie `test" ; :log warning "X` schloss das Quoting vorzeitig und
ließ RouterOS den Rest als zweiten, eigenständigen Befehl ausführen — live
exploitiert (Router-Log zeigte den injizierten Eintrag), gefixt, Fix live
gegenverifiziert (kein injizierter Eintrag mehr), Testartefakte
aufgeräumt. Zweiter schwerer Fund: der generische Antwort-Parser nahm an,
RouterOS quote mehrwortige Werte in `print terse` — live an zwei Menüs
widerlegt, jeder mehrwortige Wert wurde beim ersten Leerzeichen
abgeschnitten. Dritter Punkt nur als Beobachtung dokumentiert: Netzwerk-
Isolation wirkt nicht rückwirkend auf bereits bestehende Verbindungen
(Standard-Firewall-Verhalten, bewusst nicht automatisch "gefixt"). Build +
101 Tests grün (2 neue Regressionstests), committed.
> "erneuter gegencheck mit echtem deep dive, nochmal alles testen, finale
> test für maximale sicherheit, test alles was du finden kannst."
Dritte, gezielt sicherheitsfokussierte Runde: Zugangsdaten-Speicherung
(Keychain), TOFU-Mechanismen, alle verbleibenden Interpolationsstellen.
Überwiegend positiv — Keychain-Nutzung, `SavedRoutersStore`,
`BackupService`s eigene Escaping-Logik bereits sauber. Zwei
Härtungsfixes: das REST-Pendant zur CLI-Injection aus Runde 2 wurde beim
ersten Fix übersehen (`RestTransport.fetchFieldValues`, jetzt
percent-encoded); die TOFU-Zertifikatsprüfung hatte einen
Fallback-Konstante-Blindfleck (`CertificateFingerprint.sha256` lieferte bei
Extraktionsfehlern immer denselben String statt eines echten
Fingerabdrucks — theoretisches Pinning-Bypass-Fenster, jetzt harter
Verbindungsabbruch statt Trust-Dialog). Build + 101 Tests grün, committed.
> "ok, alles speichern, updaten und committen"
README.md (Milestone-Tabelle M33, Sicherheits-Absatz um den
CLI-Injection-Fix ergänzt), HANDOFF.md (neuer Abschnitt M33) und dieses
Chatlog aktualisiert und committed. Insgesamt über drei Runden: 9 echte
Funde (6 gefixt und live/build-verifiziert, 2 defensiv gehärtet, 1
dokumentierte Beobachtung ohne Fix) — volle Details in `bugs.md`.
+206 -18
View File
@@ -728,10 +728,10 @@ wiederholen.
häufig wechselnden dynamischen Einträgen (DHCP-Leases, Verbindungs-
Tracking-Tabellen, falls je generisch angebunden) weiterhin als riskant
einstufen.
- **WLAN-`.set`-Pfad (M5) weiterhin ungetestet gegen echte Hardware**
nur der "kein WLAN"-Zweig ist bestätigt (zwei Testgeräte, beide ohne
WLAN-Chip). Sicherheitsprofil-Anlage + SSID/Passwort-`.set` auf einem
echten `/interface wireless`-Interface noch nie live gelaufen.
- ~~WLAN-`.set`-Pfad (M5) weiterhin ungetestet gegen echte Hardware~~
erledigt (2026-09-17, hAP lite): Sicherheitsprofil-Anlage +
SSID/Passwort-`.set` liefen durch, Smartphone verband sich
erfolgreich.
- ~~Firewall-Regeln mit korrektem WAN-Interface noch nicht erneut
bestätigt~~ — erledigt: beim M8-App-Test (siehe oben) lief ein voller
`FirewallConfig`-Apply mit `ether1` als WAN erneut durch,
@@ -981,8 +981,12 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und
- ✅ M1M4: Projektgerüst, Connect, Backup, WAN/LAN/DHCP, VLAN — gegen
echtes Testgerät verifiziert.
- ✅ M5: WLAN-Schritt — nur "kein WLAN"-Zweig verifiziert, `.set`-Pfad
weiterhin ungetestet (kein WLAN-Chip auf beiden Testgeräten bisher).
- ✅ M5: WLAN-Schritt — vollständig verifiziert (2026-09-17, neues
Testgerät hAP lite mit echtem WLAN-Chip): Sicherheitsprofil-Anlage +
SSID/Passwort-`.set` liefen durch, Smartphone verband sich
erfolgreich mit dem neu eingerichteten WLAN. Vorher (M1M4-Ära) nur
der "kein WLAN"-Zweig verifiziert, da beide bisherigen Testgeräte
keinen WLAN-Chip hatten.
- ✅ M6: Firewall-Schritt — live angewendet, dabei Bug 6 gefunden+gefixt;
Interface-Auswahl danach erneut bestätigt, Regel-Ergebnis mit korrektem
WAN-Port aber noch nicht erneut kontrolliert.
@@ -992,24 +996,29 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und
wiederherstellen" (Gefahrenzone im Sicherungen-Tab,
`/system reset-configuration no-defaults=no`), eigenes App-Icon
("Signal Router"-Motiv).
- 🔶 M7: Härtung — SSH-Hostkey-TOFU **fertig, gegen echte Hardware
- M7: Härtung — SSH-Hostkey-TOFU **fertig, gegen echte Hardware
bestätigt** (inkl. neuem "Trennen"-Button im Verbinden-Tab, der dafür
nötig wurde). REST-Schreibpfad gegen `www-ssl` seit 2026-09-16 verifiziert
(Bug 3032). Dabei zusätzlich Bug 37 gefixt: REST-Erstverbindungen
etablieren jetzt automatisch auch SSH-Trust im Hintergrund, statt dass
dedizierte SSH-Dienste (Backup u.a.) beim ersten Zugriff mit einem
unbestätigbaren Hostkey-Fehler dead-enden (live bestätigt, "passt").
Verbleibend: REST-Fehlerzustände bei Verbindungsabbruch mitten im Apply
noch nicht gezielt geprüft.
Verbindungsabbruch mitten im Apply per Code-Review bestätigt (sauberer
`do/catch`-Abbruch, `applyLog` zeigt Fortschritt, Backup davor als Netz)
— bewusst nicht live erzwungen.
- ✅ M8: Mehrere LAN-Interfaces mit eigenem DHCP + Netzwerk-Isolation
(eigene Firewall-Regeln pro LAN/VLAN) — **live gegen Hardware
verifiziert**, sowohl manuell (SSH) als auch über den App-Wizard
selbst (`ether4`), dabei drei App-Bugs gefunden+gefixt (Bug 2224,
siehe oben). Isolation, DNS und Internet vom Nutzer am echten Gerät
bestätigt.
- 🔶 M9: Einfach/Experte-Modus im Einrichten-Wizard — gebaut, Compile/
Unit-Test-verifiziert. UI (Modusumschalter selbst) noch nicht manuell
durchgeklickt — nur M10s Experte-Tab wurde das (siehe M10).
- M9: Einfach/Experte-Modus im Einrichten-Wizard — Modusschalter +
Experte-Zweig **live gegen Hardware bestätigt**: der Multi-LAN/
Isolation-Test (2026-09-15, `ether4`) und der WLAN-Test (2026-09-17,
hAP lite) liefen beide über den App-Wizard im Experte-Modus (Multi-LAN,
VLAN-Schritt, Isolation-Toggle — alles nur im Experte-Zweig vorhanden).
Einfach-Zweig selbst (vereinfachter Pfad ohne VLAN/Isolation) nicht
separat live geklickt.
- ✅ M10: Experte-Tab (generischer RouterOS-Zugriff + kuratierte Firewall-
und weitere Schemas) — Kern-Logik **live gegen Hardware verifiziert**,
UI (VLAN anlegen/löschen, DHCP-Server mit Adress-Pool zuweisen) **vom
@@ -1612,6 +1621,53 @@ verallgemeinert** (2026-09-16, beim Live-Test von M26 gefunden):
beide über denselben Code-Pfad laufen. Live bestätigt
("passt, funktioniert").
## M33: Dreifacher Tester-/Sicherheits-Deep-Dive (2026-09-17)
Auf Nutzerwunsch ("jetzt bist du tester, Senior-entwickler... suche bugs" →
"erneuter Gegencheck mit echtem Deep Dive" → "finale Test für maximale
Sicherheit") drei aufeinanderfolgende Testrunden gegen den echten hAP-lite-
Testrouter, ohne UI-Automatisierung (Code-Audit + Router-Ground-Truth per
SSH, wo möglich mit echtem Exploit-Nachweis statt Vermutung). Volle Details,
Fix-Beschreibungen und Confidence-Einstufung je Fund in `bugs.md` — hier nur
die Kurzfassung:
- **Runde 1** (4 Funde, alle gefixt): Race Condition bei der
Port-Konflikt-Prüfung im LAN-Schritt (Generation-Zähler ergänzt),
Text-Widerspruch im Firewall-Schritt-Titel im Einfach-Modus, 2 fehlende
EN-Übersetzungen, Health-Check-Herzschlag ignorierte laufende
Wizard-/Experte-Schreibvorgänge.
- **Runde 2** (schwerwiegendste Funde der ganzen Session, live exploitiert
UND live als behoben bestätigt): `RouterOSCommand.cliLine` escapte
eingebettete Anführungszeichen nie — ein Kommentar wie
`test" ; :log warning "X` schloss das Quoting vorzeitig und ließ
RouterOS den Rest als zweiten, unabhängigen Befehl ausführen
(RouterOS-CLI-Injection). Live exploitiert (Log zeigte injizierten
Eintrag), Fix live gegenverifiziert. Auf dem Testrouter ist `www-ssl`
deaktiviert — REST also unerreichbar, jede Schreiboperation lief bereits
über SSH, der Bug war aktiv, nicht nur theoretisch. Zweiter schwerer Fund:
`RouterOSCliParser.keyValues` nahm an, `print terse` quote mehrwortige
Werte — live an zwei Menüs widerlegt (RouterOS 7.24.4 quotet dort nichts),
jeder mehrwortige Wert wurde beim ersten Leerzeichen abgeschnitten. Fix:
Token-Scan statt Regex. Dritter Punkt nur als Beobachtung dokumentiert,
nicht gefixt: Netzwerk-Isolation wirkt nicht rückwirkend auf bereits
bestehende Verbindungen (Standard-Firewall-Verhalten).
- **Runde 3** (gezielt sicherheitsfokussiert, 2 Härtungsfixes, sonst
überwiegend positiv bestätigt): Keychain-Nutzung, `SavedRoutersStore` und
`BackupService`s eigene Escaping-Logik gegengeprüft und sauber befunden.
REST-Pendant zur CLI-Injection aus Runde 2 beim ersten Fix übersehen
(`RestTransport.fetchFieldValues`, jetzt percent-encoded). TOFU-
Zertifikatsprüfung hatte einen Fallback-Konstante-Blindfleck
(`CertificateFingerprint.sha256` gab bei Extraktionsfehler immer denselben
String zurück statt eines echten Fingerabdrucks) — Rückgabetyp auf
optional geändert, Extraktionsfehler führt jetzt zu hartem
Verbindungsabbruch statt einem Trust-Dialog.
Build grün, alle 101 Unit-Tests grün (4 neue Regressionstests) nach allen
drei Runden. Noch nicht erneut live gegen den Router getestet (die Injection-
Fixes selbst wurden während der Entwicklung live exploitiert/verifiziert,
siehe oben — ein kompletter erneuter Durchklick-Test der App-UI steht noch
aus, da UI-Automatisierung nicht verfügbar ist).
## Nächste Schritte
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~,
@@ -1619,7 +1675,8 @@ verallgemeinert** (2026-09-16, beim Live-Test von M26 gefunden):
ü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. ~~WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen~~ —
erledigt (2026-09-17, hAP lite), siehe M5-Zeile oben.
2. ~~Rest von M7: REST-Schreibtest an einem Gerät mit aktivem
`www-ssl`~~ — erledigt (2026-09-16), siehe "Bekannte Einschränkungen"
oben (Bug 3032). `www-ssl` bleibt auf dem hEX-Testrouter aktiv
@@ -1627,11 +1684,36 @@ verallgemeinert** (2026-09-16, beim Live-Test von M26 gefunden):
REST-zuerst verbindet statt SSH-Fallback. Fehlerzustände/Politur im
REST-Pfad noch nicht gezielt geprüft (z.B. Verbindungsabbruch
mitten im Apply) — optional für später.
3. M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst) noch
manuell durchklicken — M10s Experte-Tab wurde bereits vom Nutzer
bestätigt (siehe oben), der Moduswechsel im Wizard noch nicht.
4. M10: WLAN-Schemas (an Gerät mit WLAN-Chip), Bonding, PPPoE-Client
(mit echten oder Test-ISP-Zugangsdaten) noch gegen Hardware verifizieren.
3. ~~M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst)
durchklicken~~der Experte-Zweig lief bereits über den App-Wizard bei
den Multi-LAN/Isolation- (2026-09-15) und WLAN-Tests (2026-09-17),
siehe M9-Zeile oben. Einfach-Zweig weiterhin nicht separat geklickt.
4. M10: ~~WLAN-Schemas (an Gerät mit WLAN-Chip)~~ — erledigt
(2026-09-17, hAP lite). ~~Bonding~~ — erledigt (2026-09-17, hAP
lite, `ether3`+`ether4`, `mode=active-backup`): `/interface bonding
add` schlug beim ersten Versuch erwartungsgemäß fehl ("ether3
already in bridge" — beide Ports waren noch LAN-Bridge-Mitglieder,
RouterOS erlaubt einen Port nicht gleichzeitig in Bridge und
Bonding; kein App-Bug). Nach Entfernen aus der Bridge und erneutem
Versuch meldete die App einen Fehler ("Exit-Code 126", ganz ohne
RouterOS-Klartext — ungewöhnlich, normale RouterOS-CLI-Fehler
liefern Text, siehe erster Versuch), aber `/interface bonding
print` am Router zeigte `bond1` bereits korrekt angelegt und
laufend. Vermutlich eine durch die Interface-Neuinitialisierung
beim Bridge-Entfernen kurz abgerissene SSH-Verbindung, die die App
fälschlich als Fehler interpretierte, obwohl der Befehl durchlief
— derselbe Grundsatz wie bei Bug 10/17 ("leere/fehlerhafte Antwort
ist kein Beweis, dass nichts passiert ist"), hier aber in die
andere Richtung: ein gemeldeter Fehler ist kein Beweis, dass nichts
passiert ist. Nicht weiter codeseitig verfolgt (einmalig
beobachtet, kein reproduzierbarer Bug-Report nötig) — bei erneutem
Auftreten zuerst immer den echten Router-Zustand per `print`
verifizieren, nicht der App-Fehlermeldung allein vertrauen.
PPPoE-Client weiterhin nicht gegen Hardware verifiziert — am hAP
lite nicht testbar (2026-09-17: Gerät hängt hinter einem bereits
konfigurierten Router, kein direkter PPPoE-fähiger WAN-Uplink
verfügbar); bräuchte ein
Testgerät direkt am Modem/ISP-Anschluss mit echten Zugangsdaten.
5. ~~Dauer-Editor (`RouterOSFieldSchema.Kind.duration`) auch auf weitere
Zeitwert-Felder anwenden~~ — erledigt (2026-09-16): WireGuard-Peer
"Keepalive" (Label-Zusatz "(Sekunden)" entfernt, Stepper zeigt Einheit
@@ -1845,3 +1927,109 @@ Release-Build, auf dem Stand des jeweils letzten Commits auf `main`
(App-Icon "Signal Router"). Nach jeder weiteren Code-Änderung muss dieser
Build-und-Kopier-Schritt (siehe oben) wiederholt werden, damit die
installierte App aktuell bleibt.
**M29: App-Einstellungen / Settings-Fenster** (2026-09-17, komplett neue
Infrastruktur, zuvor gab es nur den DE/EN-Toolbar-Schalter):
1. Natives macOS-Settings-Fenster (⌘,, `Settings { }`-Scene) statt eigenem
Tab — Rückfrage vorab geklärt. Drei Tabs: Allgemein (Sprache,
Auto-Update-Check beim Verbinden), Darstellung (Farbschema, Textgröße,
Bedienelemente), Netzwerk (LAN-Scanner Aktualisierungsrate/
Sparkline-Zeitfenster/-Breite). Zentrales `AppPreferences.swift`
(`Core/Models/`) bündelt alle `AppStorage`-Keys + drei Enums
(`ColorTheme`, `AppTextSize`, `UIDensity`).
2. **Farbschema** (`ColorTheme`, 2 vordefinierte Themes Standard/
Kontrastreich statt freier Farbwahl — bewusste Rückfrage-Entscheidung):
ersetzt `OverviewStyle.color(for:)` für Übersicht-Node-Kategorien/
Kanten, LAN-Scanner-Status (Traffic aktiv, Fest/Dynamisch, Port-Scan)
und (auf Nachfrage nachgezogen) die Experte-Tab-Sidebar-Kategorien
(13 `RouterOSMenuCategory`-Fälle auf die bestehende 5-Farb-Palette
gebucketet, Firewall-Untermenüs → `.firewall`, VPN/WLAN/Queues/
System/Tools → `.service`). Zwei Live-Test-Runden fanden echte Bugs:
drei Farben waren anfangs in beiden Themes identisch definiert
(Kopierfehler), und die LAN-Scanner-eigenen Statusfarben waren zu
ähnliche SwiftUI-Nachbarfarben (grün/mint, orange/gelb) — beide
gefixt, per `ImageRenderer`-Snapshot-Vergleich selbst verifiziert
statt nur behauptet.
3. **Textgröße** — erster Ansatz (`.environment(\.dynamicTypeSize, ...)`,
SwiftUIs Standardmechanismus) erwies sich als kompletter Fehlschlag:
per Snapshot-Vergleich bewiesen, dass diese Environment-Variable auf
macOS **keine Wirkung** auf Text-Rendering hat (anders als iOS).
Komplett neu gebaut: eigener `\.appFontScale`-Environment-Key +
`AppFontStyle`-Enum mit expliziten macOS-Basis-Punktgrößen pro
Textstil + `.appFont(...)`-Modifier. ~110 `.font(...)`-Aufrufe app-weit
automatisiert umgestellt (Scratchpad-Skript, textbasierte Ersetzung),
außer zwei fest-breiten Layout-Stellen (Übersicht-Diagramm-Knotenkarten,
LAN-Scanner-Tabellenspalten), die beim Skalieren brechen würden.
Danach noch ein Nachzügler-Fix: die Experte-Tab-Sidebar-Liste und alle
Parameter/Wert-`TextField`s hatten von Anfang an gar kein `.font(...)`
— automatisierte Ersetzung konnte dort nichts ersetzen. Fix: globaler
Default-Font-Fallback (`.environment(\.font, ...)`) an der
Fenster-Wurzel, wirkt überall dort, wo kein `.appFont(...)` bereits
greift.
4. **Bedienelemente/"Responsiveness"** (`UIDensity` → SwiftUIs
`.controlSize`, an der Fenster-Wurzel) und **LAN-Scanner-Refreshraten/
Sparkline-Fenster/-Breite** (`DevicesViewModel` liest die Werte bei
jedem Poll-Tick frisch aus `UserDefaults`, nicht nur beim Start) auf
Anhieb live bestätigt.
5. Bewusst nicht umgesetzt: Übersicht-Zoom-Standardwert, Fokus-Popup-
Größe/Verhalten (reine `@State`-Werte ohne Persistenz, nicht explizit
gefordert). Für später vorgemerkt (`found.md` Punkt 9): freie
Farbwahl pro Kategorie statt nur der 2 Themes.
Build+alle 98 Unit-Tests grün nach jedem Schritt. Live bestätigt
("passt, lassen wir so").
**M30: Experte-Tab — "Mode-Taste"-Menü (`/system routerboard mode-button`)**
(2026-09-17, gegen echte Hardware live verifiziert):
1. Recherchiert (help.mikrotik.com, nicht geraten): drei echte Felder
`enabled`/`on-event`/`hold-time` (Zeitspanne Min..Max, z.B.
"3s..5s"). Als neuer Singleton-Schema-Eintrag in
`RouterOSSchemaCatalog.systemFamily` gebaut, `on-event` als
`.menuItemPick` auf `/system script` (wie beim bestehenden
Zeitplaner). Warnhinweis ergänzt: ab RouterOS 7.1rc4 braucht jede
Änderung zusätzlich eine physische Tastenbestätigung am Gerät.
2. **Bug 41:** Speichern schlug mit `HTTP 500` fehl. Erste, falsche
Spur: `hold-time=""` im gesendeten Befehl (leeres optionales Feld
wurde trotzdem mitgeschickt) — dafür neuer
`RouterOSFieldSchema.clearable`-Parameter (Default `true`, bricht
bestehende Tests nicht), `hold-time` auf `clearable: false` gesetzt.
Fix wirkte (Feld verschwand aus dem gesendeten Befehl), **derselbe
HTTP-500-Fehler blieb aber** — Beweis, dass das nicht die
eigentliche Ursache war. Nutzer führte denselben Befehl direkt per
SSH-Terminal aus: lief dort sofort fehlerfrei, `print` bestätigte
alle Felder korrekt gesetzt. Damit zweifelsfrei belegt: dieses
RouterOS-Hardware-Menü hat schlicht keine REST-API-Anbindung auf
diesem Router — eine echte RouterOS-Deckungslücke, kein App-Bug.
Fix: neuer `RouterOSMenuSchema.writesRequireSSH`-Parameter +
`ConnectionService.applyViaSSH(_:)` (dedizierte SSH-Verbindung,
exakt das Muster von `BackupService`/`UpdateService`) —
`ExpertViewModel.saveEditingItem()` nutzt sie für so markierte
Schemas zwingend statt des Session-Transports. Mode-Taste
entsprechend markiert. Live bestätigt: Speichern über die App läuft
seitdem fehlerfrei.
3. **Bug 42 (kein App-Bug, reines RouterOS-Verhalten, hier
dokumentiert weil beim Live-Test dieser Funktion gefunden):**
Tastendruck löste das Skript aus, aber
`script,error executing script ... failed ... (not enough
permissions)` im Log. Skripte, die vom Mode-Button (RouterOS-intern
"sys2"-Kontext) ausgelöst werden, laufen mit eingeschränkten
Rechten, nicht mit dem vollen Standard-Policy-Set eines manuell
angelegten Skripts. Zwei erste Fix-Versuche (Policy einschränken,
dann `dont-require-permissions=yes` setzen) zeigten scheinbar keine
Wirkung — Ursache: Skriptname war tatsächlich "Button test" (großes
B), die Terminal-Befehle zielten auf "button test"/nutzten
ungültige `set`-Syntax ohne `[find ...]`/Index, trafen also nie den
echten Eintrag (`print detail` bestätigte das: Werte unverändert).
Mit korrektem Index-Befehl (`/system script set 0
dont-require-permissions=yes`) griff es sofort. **Für jedes
künftige Mode-Taste-Skript nötig:** `dont-require-permissions=yes`,
sonst schlägt die Ausführung vom Button aus fehl, obwohl dasselbe
Skript manuell/per Scheduler einwandfrei liefe. Die App hat dafür
noch kein Feld im "Skripte"-Schema (`/system script` kennt nur
Name+Inhalt) — für später vorgemerkt (`found.md` Punkt 6).
Build+alle 99 Unit-Tests grün. Live Ende-zu-Ende bestätigt: Skript
anlegen → Mode-Taste zuweisen+speichern (über die App) → Tastendruck
löst Skript korrekt aus, Log zeigt den Eintrag.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+12
View File
@@ -0,0 +1,12 @@
graph LR
User["macOS-Nutzer"] --> App["RouterOS Assistant"]
App --> T1["Verbinden"]
App --> T2["Einrichten (Wizard)"]
App --> T3["Übersicht"]
App --> T4["LAN-Scanner"]
App --> T5["Experte"]
App --> T6["Sicherungen"]
App -->|"bevorzugt"| REST["REST-API (/rest, HTTPS)"]
App -->|"Fallback + Backup/Update/Firmware"| SSH["SSH"]
REST --> Router[("MikroTik RouterOS Router")]
SSH --> Router
@@ -0,0 +1,9 @@
flowchart TD
A["Jetzt sichern"] --> B["Backup-Datei lokal gespeichert\n(wählbarer Speicherort)"]
B --> C{"Was tun?"}
C -->|"Wiederherstellen"| D{"Gleiches Routermodell?"}
D -->|"Ja"| E["Upload per SFTP"]
E --> F["Router spielt Backup ein\n(Login wird automatisch erhalten)"]
D -->|"Nein"| X["Abgelehnt — Schutz vor Brick"]
C -->|"Werkseinstellungen"| G["Automatisches Backup vorher"]
G --> H["Kompletter Reset auf Werkszustand\nRouter startet neu, Verbindung geht verloren"]
+13
View File
@@ -0,0 +1,13 @@
flowchart TD
A["Host, Benutzername, Passwort eingeben"] --> B{"Verbindungsweg"}
B -->|"REST verfügbar (bevorzugt)"| C["REST: TLS-Zertifikat prüfen"]
B -->|"REST nicht verfügbar"| D["SSH: Host-Schlüssel prüfen"]
C --> E{"Zertifikat schon bekannt?"}
E -->|"Nein, erste Verbindung"| F["Dialog: Vertrauen und verbinden"]
E -->|"Ja"| G["Verbunden"]
F --> G
D --> H{"Host-Schlüssel schon bekannt?"}
H -->|"Nein"| I["Dialog: Vertrauen und verbinden"]
H -->|"Ja"| G
I --> G
G --> J["SSH-Host-Schlüssel zusätzlich im Hintergrund geprüft — für Backup, Update-Check, Firmware-Update, die immer SSH brauchen"]
+7
View File
@@ -0,0 +1,7 @@
stateDiagram-v2
[*] --> Normal
Normal --> Fokussiert: Klick auf Knoten
Fokussiert --> Fokussiert: Klick auf anderen Knoten\nder gleichen Kette (Fokus wechselt)
Fokussiert --> Normal: X-Button im Popup
Fokussiert --> Normal: Klick auf leere Fläche
Fokussiert --> Normal: erneuter Klick auf denselben Knoten
+10
View File
@@ -0,0 +1,10 @@
flowchart LR
M{"Modus wählen"} -->|"Einfach"| W["WAN\n(Internetanschluss)"]
M -->|"Experte"| W
W --> L["LAN\n(Experte: ein oder mehrere Netzwerke)"]
L -->|"Experte"| V["VLAN\n(optional, zusätzliche Netzwerke)"]
L -->|"Einfach: übersprungen"| Wi["WLAN\n(nur falls erkannt)"]
V --> Wi
Wi --> F["Firewall-Grundschutz\n(Einfach: immer aktiv, Experte: optional)"]
F --> R["Review / Apply\n(jeder Befehl sichtbar, Auto-Backup vorher)"]
R -->|"Jetzt anwenden"| D["Fertig"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1183
View File
File diff suppressed because it is too large Load Diff
+1150 -343
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+39 -7
View File
@@ -31,6 +31,10 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
freiem Standort-Feld (Etage/Raum/Zweck), Liste scrollt ab ca. 4
Einträgen in sich selbst. Interface-Liste zeigt **Live-Traffic**: Punkt
grau/grün/pulsierend-grün je nachdem, ob der Port Verkehr führt.
**Automatisches Wiederverbinden**: fällt die Verbindung während der
Sitzung weg, versucht die App selbständig, sie wiederherzustellen
(Herzschlag alle 10s, Retry alle 5s, unbegrenzt), mit Versuchszähler +
Countdown im Tab.
- **Einrichten** — geführter Wizard (WAN → LAN → VLAN → WLAN → Firewall
→ Review/Apply) mit Einfach- und Experte-Modus. Mehrere LAN-Interfaces
mit eigenem DHCP-Server und optionaler Netzwerk-Isolation
@@ -72,6 +76,12 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
Neustarts). Vollständig übersetzt: kompletter Experte-Tab (alle 9
Menü-Familien) und der Verbinden-Tab; weitere Tabs folgen
schrittweise.
- **Einstellungen** (⌘,, natives macOS-Settings-Fenster) — Sprache,
automatischer RouterOS-Update-Check beim Verbinden, Farbschema
(Standard/Kontrastreich, für Übersicht-Diagramm + LAN-Scanner-
Statusfarben + Experte-Sidebar-Kategorien), Textgröße, Bedienelement-
Größe, LAN-Scanner-Aktualisierungsrate/Sparkline-Zeitfenster/-Breite.
Jede Änderung wirkt sofort, ohne Neustart.
## Architektur
@@ -137,11 +147,11 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| # | Feature | Status |
|---|---|---|
| M1M4 | Projektgerüst, Connect, Backup, WAN/LAN/DHCP, VLAN | ✅ live verifiziert |
| M5 | WLAN-Schritt | 🔶 nur "kein WLAN"-Zweig getestet |
| M5 | WLAN-Schritt (Legacy-Treiber, `/interface wireless`) | ✅ live verifiziert (hAP lite, Smartphone verbunden) |
| M6 | Firewall-Grundschutz | ✅ live verifiziert |
| M7 | Härtung (SSH-Hostkey-TOFU) | 🔶 TOFU + REST-Schreibpfad live verifiziert, Verbindungsabbruch-mitten-im-Apply noch offen |
| M7 | Härtung (SSH-Hostkey-TOFU) | TOFU + REST-Schreibpfad live verifiziert; Verbindungsabbruch-mitten-im-Apply per Code-Review bestätigt (sauberer `do/catch`-Abbruch, kein Absturz/Hänger, `applyLog` zeigt Fortschritt, Backup davor als Netz) — bewusst nicht live erzwungen (Risiko eines halb-konfigurierten Routers unverhältnismäßig zum Erkenntnisgewinn) |
| M8 | Mehrere LAN-Interfaces + Netzwerk-Isolation | ✅ live verifiziert |
| M9 | Einfach/Experte-Modusschalter im Wizard | 🔶 gebaut, UI-Test offen |
| M9 | Einfach/Experte-Modusschalter im Wizard | ✅ Modusschalter + Experte-Zweig live verifiziert (Multi-LAN/Isolation-Test 2026-09-15, WLAN-Test 2026-09-17 liefen beide über den App-Wizard im Experte-Modus); Einfach-Zweig selbst nicht separat live geklickt |
| M10 | Experte-Tab (generischer RouterOS-Zugriff) | ✅ live verifiziert |
| M11 | Übersicht-Tab (IST-Zustand-Diagramm) | ✅ live verifiziert |
| M12 | Geräte-Tab (LAN-Scanner + Static-IP) | ✅ live verifiziert |
@@ -160,7 +170,19 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M25 | Einrichten-Wizard-Politur: WAN-Zurück-Button, prominente Aktionsbuttons, leere Platzhalter-Felder (LAN/VLAN) | ✅ live verifiziert |
| M26 | Bekannte Router: Seriennummer hinterlegt, trennt zwei Geräte mit identischem Host+Benutzername | ✅ live verifiziert |
| M27 | Passwort-Anzeige-Button, Schlüsselbund nach Seriennummer getrennt, `terse`-Fallback verallgemeinert | ✅ live verifiziert |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Code-Review bestätigt (dabei 3 echte Alert-Dismiss-Bugs gefunden+gefixt, siehe M34), Live-Klicktest durch Nutzer weiterhin offen |
| M28 | Übersicht-Tab: Fokus-Modus (Klick auf Knoten → Kette im schwebenden Popup, Rest abgedunkelt); Close-Button-Layout in allen vier Popups vereinheitlicht | ✅ live verifiziert |
| M29 | Einstellungen-Fenster (⌘,): Sprache, Update-Auto-Check, Farbschema, Textgröße, Bedienelement-Größe, LAN-Scanner-Refreshraten | ✅ live verifiziert |
| M30 | Experte-Tab: "Mode-Taste"-Menü (`/system routerboard mode-button`), inkl. SSH-Zwangsweg für REST-Deckungslücken | ✅ live verifiziert |
| M31 | Handbuch in der App (⌘? -Buttons, Textanker, Übersicht/Wizard/45 Experte-Menüs), DE+EN vollständig übersetzt | ✅ live verifiziert |
| M32 | Automatisches Wiederverbinden bei Verbindungsabbruch (Herzschlag+Retry-Schleife, Versuchszähler+Countdown im Verbinden-Tab) | ✅ live verifiziert |
| M33 | Dreifacher Tester-/Sicherheits-Deep-Dive (9 Funde: RouterOS-CLI-Injection live exploitiert+gefixt, Parser-Datenverlust, Race Condition, TOFU-Härtung u.a.) | ✅ Build+101 Tests grün, Details in [`bugs.md`](bugs.md) |
| M34 | README-Nachcheck: 3 Alert-Dismiss-Bugs gefunden+gefixt ("Abbrechen"/OK-Buttons bei Zertifikat-/SSH-Hostkey-/Apply-Fehler-Dialogen ohne Wirkung), wizard_flow-Diagramm korrigiert | ✅ Build+102 Tests grün |
| M35 | bugs.md #7: Firewall-Isolation trennt jetzt auch bereits bestehende Verbindungen (Connection-Flush, bugs.md #7) | ✅ Build+103 Tests grün, Mechanismus teilweise live verifiziert (Details bugs.md) |
| M36 | Hänger beim Anlegen eines DHCP-Pools gefixt (SSHTransport ohne Timeout, bugs.md #11) | ✅ live bestätigt, Build+106 Tests grün |
| M37 | Experte-Tab: Port-Konflikt-Prüfung wie im Einrichten-Assistenten (bugs.md #12) | ✅ live bestätigt, Build+111 Tests grün |
Release-Historie (was sich zwischen den Versionen geändert hat): [`CHANGELOG.md`](CHANGELOG.md).
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
@@ -186,6 +208,7 @@ RouterOS/
│ │ │ ├── InterfaceTraffic.swift — ein Live-Durchsatz-Sample + TrafficSample (10s-Sparkline-Historie, LAN-Scanner)
│ │ │ ├── NetworkToolResult.swift — Rohausgabe von Ping/Traceroute/DNS-Auflösung
│ │ │ ├── PortScanResult.swift — Ergebnis eines Port-Scans, ein Eintrag pro Port
│ │ │ ├── AppPreferences.swift — Settings-Infrastruktur: AppStorage-Keys, ColorTheme/AppTextSize/UIDensity
│ │ │ └── RouterOSModels.swift — Credentials, DeviceInfo, RouterBoardInfo, PackageUpdateInfo, Fehler
│ │ ├── Networking/
│ │ │ ├── RouterOSTransport.swift — Protocol: connect/fetch.../apply/disconnect
@@ -211,12 +234,16 @@ RouterOS/
│ │ ├── Overview/ — Übersicht-Tab
│ │ ├── Devices/ — LAN-Scanner-Tab
│ │ ├── Expert/ — Experte-Tab
│ │ ── Backup/ — Sicherungen-Tab
│ │ ── Backup/ — Sicherungen-Tab
│ │ └── Settings/ — Einstellungen-Fenster (⌘,)
│ └── Resources/Assets.xcassets/ — App-Icon
├── RouterOSAssistantTests/ — Unit-Tests (Command-Builder, CLI-Parser, ViewModels, Mock-Transport)
├── project.yml — xcodegen-Projektdefinition (Quelle der Wahrheit, nicht das generierte .xcodeproj editieren)
├── HANDOFF.md — detaillierter technischer Stand, alle gefundenen Bugs, offene Punkte
── CHATLOG.md — chronologischer Session-Verlauf
── CHATLOG.md — chronologischer Session-Verlauf
├── Manual.md / Manual.pdf — Bedienungsanleitung (DE, Feldreferenz wörtlich aus RouterOSSchemaCatalog.swift extrahiert)
├── Manual-assets/ — Mermaid-Diagramme fürs Manual (*.mmd Quellen unter diagrams/, gerenderte *.png)
└── build-manual.py — regeneriert Manual.md-Feldreferenz + Diagramme + Manual.pdf (nach jeder Schema-/Manual-Änderung erneut laufen lassen)
```
## Setup / Bauen
@@ -247,4 +274,9 @@ Jede Schreibaktion zeigt vor der Ausführung den exakten RouterOS-Befehl
in einem Bestätigungsdialog. Destruktive Aktionen (Werksreset, Backup-
Wiederherstellung) liegen in einer eigenen "Gefahrenzone" mit
zusätzlichen Warnhinweisen. Zertifikate und SSH-Hostkeys werden per
Trust-on-First-Use (TOFU) behandelt, nicht blind akzeptiert.
Trust-on-First-Use (TOFU) behandelt, nicht blind akzeptiert. Passwörter
liegen ausschließlich im macOS-Keychain, nie im Klartext. Alle Werte,
die in RouterOS-CLI-Befehle oder REST-Anfragen eingebettet werden
(Kommentare, SSID, Freitextfelder im Experte-Tab, …), werden escaped
bzw. kodiert — live gegen einen echten Testrouter als Schutz gegen
Befehls-Injection verifiziert (Details: [`bugs.md`](bugs.md) #5, #8).
@@ -3,12 +3,19 @@ import SwiftUI
@main
struct RouterOSAssistantApp: App {
@StateObject private var connectionService = ConnectionService()
@StateObject private var manualNavigator = ManualNavigator()
/// Manual language override independent of the system locale, per the user's explicit
/// request for an in-app toggle button. `.environment(\.locale, ...)` does NOT make
/// `Text(LocalizedStringKey)` re-resolve against Localizable.xcstrings at runtime (confirmed
/// live: toggling had no visible effect), so views read this same AppStorage key directly and
/// translate via `L10n.t(_:_:)` instead.
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.textSizeKey) private var textSizeRaw: String = AppTextSize.standard.rawValue
@AppStorage(AppPreferences.uiDensityKey) private var uiDensityRaw: String = UIDensity.standard.rawValue
init() {
AppPreferences.registerDefaults()
}
var body: some Scene {
WindowGroup {
@@ -38,7 +45,27 @@ struct RouterOSAssistantApp: App {
.help(appLanguage == "de" ? "Switch to English" : "Auf Deutsch umschalten")
}
}
.controlSize((UIDensity(rawValue: uiDensityRaw) ?? .standard).controlSize)
.environment(\.appFontScale, (AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)
// Fallback for every `Text`/`TextField`/`Label` that has no explicit `.font(...)` of
// its own (confirmed live: the Experte tab's whole sidebar list and every Parameter/
// Wert `TextField` have none `.appFont(...)` only touches call sites that already
// had a `.font(...)` to replace, so unstyled text stayed fixed-size). `.environment
// (\.font, ...)` only ever supplies a *default*: anywhere `.appFont(...)` already sets
// an explicit font, that still wins.
.environment(\.font, .system(size: 13 * ((AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)))
.environmentObject(manualNavigator)
}
.environment(\.locale, Locale(identifier: appLanguage))
Settings {
SettingsView()
.environmentObject(manualNavigator)
}
Window(L10n.t("Handbuch", appLanguage), id: "manual") {
ManualView()
.environmentObject(manualNavigator)
}
}
}
+54 -1
View File
@@ -15,7 +15,16 @@ enum L10n {
}
private static let translations: [String: String] = [
"Netzwerk-Test fehlgeschlagen": "Network test failed",
"Fokus-Ansicht schließen": "Close focus view",
"Firewall": "Firewall",
"Verbinden": "Connect",
"Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…":
"Connection lost — trying to reconnect automatically…",
"Versuch": "Attempt",
"nächster in": "next in",
"Handbuch": "Manual",
"Hilfe zu diesem Bereich im Handbuch öffnen": "Open help for this area in the manual",
"Einrichten": "Setup",
"Übersicht": "Topology",
"LAN-Scanner": "LAN Scanner",
@@ -461,6 +470,17 @@ enum L10n {
"Uhrzeit/Zeitzone": "Time/Time Zone",
"Zeitzone": "Time Zone",
"Z.B. Europe/Berlin.": "E.g. Europe/Berlin.",
"Mode-Taste": "Mode Button",
"Steuert, welches Skript beim Drücken der physischen Mode-Taste am Router ausgeführt wird.":
"Controls which script runs when the router's physical Mode button is pressed.",
"Manche RouterBOARD-Geräte (z.B. hEX, cAP, hAP ac², LtAP mini, einige CCR/CRS) haben eine physische Mode-Taste an der Seite. Hier lässt sich festlegen, ob und wie lange sie gedrückt werden muss, damit ein zuvor unter \"Skripte\" angelegtes Skript ausgeführt wird.":
"Some RouterBOARD devices (e.g. hEX, cAP, hAP ac², LtAP mini, some CCR/CRS) have a physical Mode button on the side. This controls whether, and for how long, it must be held down to run a script previously created under \"Scripts\".",
"Ab RouterOS 7.1rc4 muss jede Aktivierung oder Änderung dieser Einstellung zusätzlich durch einen physischen Tastendruck (Reset- oder Mode-Taste) innerhalb von 60 Sekunden am Gerät selbst bestätigt werden — eine Änderung über diese App allein reicht nicht.":
"Starting with RouterOS 7.1rc4, enabling or changing this setting also requires a physical button press (Reset or Mode button) on the device itself within 60 seconds to confirm — a change through this app alone isn't enough.",
"Mode-Taste ein-/ausschalten.": "Turn the Mode button on/off.",
"Haltedauer (Min..Max)": "Hold Time (Min..Max)",
"Wie lange die Taste gedrückt gehalten werden muss, als Zeitspanne Min..Max, z.B. \"3s..5s\". Verfügbar ab RouterOS 6.47beta60.":
"How long the button must be held down, as a Min..Max time range, e.g. \"3s..5s\". Available from RouterOS 6.47beta60 onward.",
"Zeitserver (NTP)": "Time Server (NTP)",
"Falsche Systemzeit kann Zertifikatsprüfungen (HTTPS/REST) und Log-Zeitstempel durcheinanderbringen.":
"Wrong system time can mess up certificate checks (HTTPS/REST) and log timestamps.",
@@ -778,6 +798,7 @@ enum L10n {
// MARK: - Overview tab (OverviewView.swift + OverviewGraph.swift)
"Aktualisieren": "Refresh",
"Zurücksetzen": "Reset",
"Fokus": "Focus",
"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…",
@@ -879,6 +900,7 @@ enum L10n {
"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",
"Schließen, ohne zu speichern": "Close without saving",
"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)",
@@ -932,6 +954,37 @@ enum L10n {
"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."
"If the restore fails, the router is left blank, not reverted to factory defaults.",
// MARK: - Settings window (,)
"Allgemein": "General",
"Darstellung": "Appearance",
"Sprache": "Language",
"Entspricht dem 🇩🇪/🇬🇧-Knopf in der Toolbar — beide steuern dieselbe Einstellung.":
"Same as the 🇩🇪/🇬🇧 button in the toolbar — both control the same setting.",
"RouterOS-Updates": "RouterOS Updates",
"Beim Verbinden automatisch nach Updates suchen": "Automatically check for updates when connecting",
"Prüft nach jedem erfolgreichen Verbinden automatisch, ob eine neuere RouterOS-Version verfügbar ist — entspricht dem Button \"Nach Updates suchen\" im Tab \"Verbinden\".":
"Automatically checks after every successful connection whether a newer RouterOS version is available — same as the \"Check for updates\" button in the \"Connect\" tab.",
"Farbschema": "Color Theme",
"Standard": "Standard",
"Kontrastreich": "High Contrast",
"Gilt für die Diagrammfarben in der Übersicht und die Status-Farben im LAN-Scanner (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port).":
"Applies to the diagram colors in the Topology tab and the status colors in the LAN Scanner (traffic active, static/dynamic, open/closed port).",
"Textgröße": "Text Size",
"Klein": "Small",
"Groß": "Large",
"Sehr groß": "Extra Large",
"Bedienelemente": "Controls",
"Kompakt": "Compact",
"Komfortabel": "Comfortable",
"Größe von Buttons, Feldern und Abständen in der ganzen App (\"Responsiveness\").":
"Size of buttons, fields, and spacing throughout the app (\"responsiveness\").",
"LAN-Scanner: Traffic-Anzeige": "LAN Scanner: Traffic Display",
"Aktualisierungsrate": "Refresh Rate",
"Sparkline-Zeitfenster": "Sparkline Time Window",
"Sparkline-Breite": "Sparkline Width",
"Höhere Aktualisierungsraten zeigen den Traffic-Verlauf feiner, kosten aber mehr CPU bei vielen gleichzeitig aktiven Ports.":
"Higher refresh rates show the traffic trend more finely, but cost more CPU when many ports are active at once."
]
}
@@ -0,0 +1,250 @@
import SwiftUI
import AppKit
/// Alternating row backgrounds for table-like `Form`/`Section`/`ForEach` lists (LAN-Scanner
/// device rows, Experte-tab item lists) per explicit readability request. Uses AppKit's own
/// `NSColor.alternatingContentBackgroundColors` (the same pair `NSTableView` uses for zebra
/// striping) rather than a hand-picked gray, so it already matches the system's row-striping
/// color and adapts to Light/Dark Mode for free.
enum TableZebra {
static func color(for index: Int) -> Color {
let pair = NSColor.alternatingContentBackgroundColors
guard pair.count > 1 else { return .clear }
return Color(nsColor: pair[index % 2])
}
}
/// Central catalog for the "Settings" window (,) every value here is read/written through
/// `@AppStorage`/`UserDefaults.standard` under the raw string keys named on each property below,
/// so a view (`@AppStorage`, live-updating) and a background `Task` loop (`UserDefaults.standard`
/// reads each tick, no observation needed) can both stay in sync with the same preference without
/// plumbing it through init parameters. `registerDefaults()` is called once at app launch so a
/// fresh install has real values before any Settings screen has ever been opened, matching the
/// literal fallback each `@AppStorage`/`UserDefaults` read site also carries the two must be
/// kept in sync by hand since `@AppStorage` requires its default inline.
enum AppPreferences {
static let colorThemeKey = "colorTheme"
static let textSizeKey = "textSize"
static let uiDensityKey = "uiDensity"
static let autoCheckUpdatesOnConnectKey = "autoCheckUpdatesOnConnect"
static let lanScannerPollIntervalSecondsKey = "lanScannerPollIntervalSeconds"
static let lanScannerSparklineWindowSecondsKey = "lanScannerSparklineWindowSeconds"
static let lanScannerSparklineWidthKey = "lanScannerSparklineWidth"
static func registerDefaults() {
UserDefaults.standard.register(defaults: [
colorThemeKey: ColorTheme.standard.rawValue,
textSizeKey: AppTextSize.standard.rawValue,
uiDensityKey: UIDensity.standard.rawValue,
autoCheckUpdatesOnConnectKey: false,
lanScannerPollIntervalSecondsKey: 0.1,
lanScannerSparklineWindowSecondsKey: 30.0,
lanScannerSparklineWidthKey: 200.0,
])
}
}
/// Two predefined palettes for the Übersicht diagram's node/edge colors and the LAN-Scanner's
/// status dots (traffic-active, static/dynamic lease, open/closed port) a free per-category
/// color picker was explicitly ruled out in favor of this, so every color lives here rather than
/// scattered across the views that used to hardcode `Color.blue`/`.red`/etc. directly.
enum ColorTheme: String, CaseIterable, Identifiable {
case standard
case highContrast
var id: String { rawValue }
func color(for category: OverviewNode.Category) -> Color {
switch self {
case .standard:
switch category {
case .interface: return .blue
case .ipAddress: return .teal
case .service: return .purple
case .route: return .orange
case .firewall: return .red
}
case .highContrast:
switch category {
case .interface: return .yellow
case .ipAddress: return .green
case .service: return .pink
case .route: return .cyan
case .firewall: return .indigo
}
}
}
func color(for kind: OverviewEdgeKind) -> Color {
switch self {
case .standard:
switch kind {
case .vlan: return .indigo
case .bridgePort: return .cyan
case .wireguardPeer: return .pink
case .ipAddress: return .teal
case .dhcp: return .purple
case .route: return .orange
case .firewallInterface: return .red
case .addressList: return .yellow
}
case .highContrast:
switch kind {
case .vlan: return .yellow
case .bridgePort: return .brown
case .wireguardPeer: return .mint
case .ipAddress: return .green
case .dhcp: return .cyan
case .route: return .indigo
case .firewallInterface: return .pink
case .addressList: return .blue
}
}
}
/// Experte-tab sidebar: one dot per `RouterOSMenuCategory` (13 cases far more granular than
/// the 5-case `OverviewNode.Category`), reusing that same 5-color palette by bucketing every
/// firewall sub-menu under `.firewall` and everything without an obvious 1:1 match (VPN,
/// Wi-Fi/CAPsMAN, Queues, System, Tools) under `.service` keeps "Firewall is red" etc.
/// consistent with the Übersicht diagram instead of inventing a second, unrelated palette.
func color(for menuCategory: RouterOSMenuCategory) -> Color {
switch menuCategory {
case .firewallFilter, .firewallNat, .firewallMangle, .firewallRaw, .firewallAddressLists:
return color(for: OverviewNode.Category.firewall)
case .interfaces:
return color(for: OverviewNode.Category.interface)
case .ipAddressing:
return color(for: OverviewNode.Category.ipAddress)
case .routing:
return color(for: OverviewNode.Category.route)
case .vpn, .wireless, .queues, .system, .tools:
return color(for: OverviewNode.Category.service)
}
}
/// LAN-Scanner: port-header "traffic active" dot, and `DeviceRow`'s static/dynamic lease dot.
/// First draft used `.mint`/`.yellow`/`.pink` here too close to their standard counterparts
/// (green/orange/red) to read as a real change at small dot/icon size, per live feedback.
/// Now a genuinely different hue, not just a lighter shade of the same one.
var trafficActive: Color { self == .standard ? .green : .blue }
var staticLease: Color { self == .standard ? .orange : .purple }
var dynamicLease: Color { self == .standard ? .green : .blue }
var portOpen: Color { self == .standard ? .red : .orange }
var portClosed: Color { self == .standard ? .green : .blue }
}
/// Global text scale factor, read via `.environment(\.appFontScale, ...)` at the window root and
/// applied by every `.appFont(...)` call site (see below).
///
/// NOT built on `.environment(\.dynamicTypeSize, ...)` that was the first attempt, and it does
/// nothing on macOS: confirmed empirically (an `ImageRenderer` snapshot of the same `Text` at
/// `.xSmall` vs `.accessibility3` produced pixel-identical output). Unlike iOS, macOS's SwiftUI
/// text styles (`.headline`, `.caption`, ...) resolve to fixed point sizes that don't respond to
/// a Dynamic Type category at all, so that environment key is a no-op here. `.appFont(_:)` instead
/// gives each text style an explicit base point size (macOS's own approximate defaults) that this
/// scale factor actually multiplies.
enum AppTextSize: String, CaseIterable, Identifiable {
case small
case standard
case large
case extraLarge
var id: String { rawValue }
var scale: CGFloat {
switch self {
case .small: return 0.85
case .standard: return 1.0
case .large: return 1.2
case .extraLarge: return 1.4
}
}
}
private struct AppFontScaleKey: EnvironmentKey {
static let defaultValue: CGFloat = 1.0
}
extension EnvironmentValues {
var appFontScale: CGFloat {
get { self[AppFontScaleKey.self] }
set { self[AppFontScaleKey.self] = newValue }
}
}
/// Named, scalable stand-ins for the semantic `Font.TextStyle` cases actually used in this app
/// each carries the same base point size macOS itself uses for that style, so `.standard` (scale
/// 1.0) renders identically to the plain `Font.TextStyle` it replaces.
enum AppFontStyle {
case title2, title3, headline, body, callout, subheadline, caption, caption2
/// An explicit point size/weight/design not covered by a named style above (rare only where
/// the code already used `.font(.system(size:...))` directly).
case fixed(CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default)
fileprivate var baseSize: CGFloat {
switch self {
case .title2: return 17
case .title3: return 15
case .headline: return 13
case .body: return 13
case .callout: return 12
case .subheadline: return 11
case .caption: return 10
case .caption2: return 9
case .fixed(let size, _, _): return size
}
}
fileprivate var baseWeight: Font.Weight {
if case .headline = self { return .semibold }
if case .fixed(_, let weight, _) = self { return weight }
return .regular
}
fileprivate var design: Font.Design {
if case .fixed(_, _, let design) = self { return design }
return .default
}
}
private struct AppFontModifier: ViewModifier {
@Environment(\.appFontScale) private var scale
let style: AppFontStyle
let weight: Font.Weight?
let bold: Bool
let design: Font.Design?
func body(content: Content) -> some View {
let resolvedWeight = bold ? .bold : (weight ?? style.baseWeight)
content.font(.system(size: style.baseSize * scale, weight: resolvedWeight, design: design ?? style.design))
}
}
extension View {
/// Text-only scaling for the Settings "Textgröße" preference see `AppTextSize`'s doc comment
/// for why this exists instead of `.environment(\.dynamicTypeSize, ...)`. `bold`/`weight`/
/// `design` mirror the `.bold()`/`.weight(_:)`/`.monospaced()` chaining the replaced
/// `.font(...)` calls used.
func appFont(_ style: AppFontStyle, weight: Font.Weight? = nil, bold: Bool = false, design: Font.Design? = nil) -> some View {
modifier(AppFontModifier(style: style, weight: weight, bold: bold, design: design))
}
}
/// Global control/spacing density ("Responsiveness" from the found.md feature idea, made
/// concrete as SwiftUI's own `.controlSize`, applied once at the window root) compact fits more
/// on screen on a small display, comfortable gives buttons/fields more room to hit on a large one.
enum UIDensity: String, CaseIterable, Identifiable {
case compact
case standard
case comfortable
var id: String { rawValue }
var controlSize: ControlSize {
switch self {
case .compact: return .small
case .standard: return .regular
case .comfortable: return .large
}
}
}
@@ -20,6 +20,14 @@ struct FirewallConfig: Equatable {
struct NetworkSegment: Equatable {
var interfaceName: String
var isolated: Bool
/// CIDR range (e.g. "192.168.88.0/24") not used for the filter rules themselves
/// (those match on interface, not address), only for `isolatedNetworkPairs`, which
/// `SetupViewModel.apply()` uses to flush already-open connections between two networks
/// that just became isolated (bugs.md #7 / Gitea #19). Empty for a network with no
/// address filled in yet (e.g. mid-wizard-editing) such a network is simply skipped by
/// the connection-flush, same as it already is by the isolation filter rules once
/// applied (an unreachable address isolates itself).
var networkAddress: String = ""
}
var wanInterface: String
@@ -91,37 +99,49 @@ struct FirewallConfig: Equatable {
return [natCommand] + filterCommands + isolationCommands
}
/// Forward-drop rules between every network marked `isolated` and every other configured
/// network (both directions). Pairs are deduplicated so two mutually isolated networks
/// still only get one pair of rules, not two.
private func buildIsolationCommands(startingPlaceBefore: Int) -> [RouterOSCommand] {
/// Every pair of networks where at least one side is marked `isolated`, deduplicated so two
/// mutually isolated networks still only produce one pair, not two. Shared by
/// `buildIsolationCommands` (drop rules, by interface) and `SetupViewModel.apply()`'s
/// post-apply connection-flush (by address range, bugs.md #7 / Gitea #19) kept as one
/// source of truth so the two can never disagree about which pairs count as isolated.
var isolatedNetworkPairs: [(NetworkSegment, NetworkSegment)] {
var seenPairs = Set<Set<String>>()
var commands: [RouterOSCommand] = []
var placeBefore = startingPlaceBefore
var pairs: [(NetworkSegment, NetworkSegment)] = []
for network in networks where network.isolated {
for other in networks where other.interfaceName != network.interfaceName {
let pair = Set([network.interfaceName, other.interfaceName])
guard !seenPairs.contains(pair) else { continue }
seenPairs.insert(pair)
pairs.append((network, other))
}
}
for (from, to) in [(network.interfaceName, other.interfaceName), (other.interfaceName, network.interfaceName)] {
commands.append(
RouterOSCommand.add(
menuPath: "/ip firewall filter",
restPath: "ip/firewall/filter",
arguments: [
"chain": "forward",
"in-interface": from,
"out-interface": to,
"action": "drop",
"place-before": "\(placeBefore)"
],
summary: "Netzwerk \"\(from)\" von \"\(to)\" isolieren"
)
return pairs
}
/// Forward-drop rules between every isolated network pair (both directions).
private func buildIsolationCommands(startingPlaceBefore: Int) -> [RouterOSCommand] {
var commands: [RouterOSCommand] = []
var placeBefore = startingPlaceBefore
for (network, other) in isolatedNetworkPairs {
for (from, to) in [(network.interfaceName, other.interfaceName), (other.interfaceName, network.interfaceName)] {
commands.append(
RouterOSCommand.add(
menuPath: "/ip firewall filter",
restPath: "ip/firewall/filter",
arguments: [
"chain": "forward",
"in-interface": from,
"out-interface": to,
"action": "drop",
"place-before": "\(placeBefore)"
],
summary: "Netzwerk \"\(from)\" von \"\(to)\" isolieren"
)
placeBefore += 1
}
)
placeBefore += 1
}
}
@@ -14,8 +14,9 @@ struct InterfaceTraffic: Equatable {
/// One timestamped throughput reading, kept in a short rolling per-port history so the
/// LAN-Scanner's port headers can show a small sparkline of the last few seconds, not just the
/// current instantaneous value (Nutzerwunsch: "ein kleines Liniendiagramm der letzten 10 Sekunden
/// ... pro port"). Timestamped (not just appended in order) so trimming to "last 10 seconds" stays
/// correct even if a poll tick is ever delayed or skipped, rather than assuming a fixed cadence.
/// ... pro port", window later widened to 30s). Timestamped (not just appended in order) so
/// trimming to a fixed window stays correct even if a poll tick is ever delayed or skipped,
/// rather than assuming a fixed cadence.
struct TrafficSample: Identifiable {
let id = UUID()
let timestamp: Date
@@ -124,4 +124,30 @@ struct OverviewGraph: Equatable {
}
return visited
}
/// Every node reachable from `nodeID` by following edges in either direction, regardless of
/// category used by the Übersicht "Fokus-Modus" (`OverviewView`) to decide which nodes move
/// into their own strip below a divider when a node is clicked. Deliberately separate from
/// `highlightedNodeIDs`: that one only expands past one hop for interface nodes (a distinct,
/// already-live-confirmed scope for edge-brightness highlighting), while the focus strip is
/// meant to always show the complete parent/child chain of whatever was clicked.
func connectedChain(startingAt nodeID: String) -> Set<String> {
guard nodes.contains(where: { $0.id == nodeID }) else { return [] }
var visited: Set<String> = [nodeID]
var frontier = [nodeID]
while !frontier.isEmpty {
var next: [String] = []
for current in frontier {
for edge in edges {
if edge.from == current, visited.insert(edge.to).inserted {
next.append(edge.to)
} else if edge.to == current, visited.insert(edge.from).inserted {
next.append(edge.from)
}
}
}
frontier = next
}
return visited
}
}
@@ -62,4 +62,22 @@ struct PortConflict: Equatable {
}
}
}
/// `resolutionCommands()` alone for the Setup-Wizard context, where `DhcpServerCommandBuilder`
/// already unconditionally detaches the port from any bridge as its own separate safety net
/// see that method's doc comment. Contexts without that separate detach (the Experte tab's
/// generic "Port freimachen?" flow, applied to whatever menu the user is actually editing, not
/// specifically the LAN/DHCP command set) need the bridge-membership removal included here
/// instead, or acknowledging a bridge-member conflict there would silently do nothing for
/// that specific reason.
func resolutionCommandsIncludingBridgeDetach() -> [RouterOSCommand] {
let bridgeDetach: [RouterOSCommand] = reasons.contains(where: { if case .bridgeMember = $0 { return true } else { return false } })
? [RouterOSCommand.remove(
menuPath: "/interface bridge port", restPath: "interface/bridge/port",
matchField: "interface", matchValue: interfaceName,
summary: "\(interfaceName) aus Bridge lösen"
)]
: []
return bridgeDetach + resolutionCommands()
}
}
@@ -134,10 +134,22 @@ struct RouterOSCommand: Equatable, Identifiable {
.joined(separator: " ")
}
/// Always quotes, with `\` and `"` backslash-escaped inside the previous version only
/// quoted when the value contained a space and never escaped embedded quotes at all, which
/// let a value like `test" ; :log warning "INJECTED` (a completely plausible free-text
/// comment/SSID/hostname) close the quoted argument early and inject a second, independent
/// RouterOS command after the `;` RouterOS' console uses `;` as a statement separator, same
/// as the injection risk `SSHTransport.runDiagnosticCommand`'s doc comment already flags for
/// its own caller-sanitized input. Live-confirmed exploitable and live-confirmed fixed
/// (2026-09-17, bugs.md): unescaped, this executed an injected `:log warning` as a second
/// command; escaping `\`/`"` (verified live to be RouterOS' own escape syntax `\"` and `\\`
/// both round-tripped correctly through `print detail`) closes it. Quoting unconditionally
/// (not just "if it contains a space") also verified live to be always accepted, including for
/// plain single-word values and `yes`/`no` booleans no reason left to special-case those.
private static func quoteIfNeeded(_ value: String) -> String {
// An empty value must render as an explicit `""`, not a bare `key=` with nothing after
// the `=` that's how RouterOS' CLI represents "clear this field" versus a syntax error.
if value.isEmpty { return "\"\"" }
return value.contains(" ") ? "\"\(value)\"" : value
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
}
@@ -33,14 +33,22 @@ struct RouterOSFieldSchema: Identifiable, Equatable {
let defaultValue: String?
/// Whether a value is required before the item can be added.
let required: Bool
/// Whether blanking this field and saving should send an explicit `field=""` ("clear this")
/// to RouterOS. True by default this is the normal, tested behavior for plain free-text
/// fields like "comment". False for fields whose RouterOS type isn't really free text (e.g. a
/// time-interval-range like mode-button's "hold-time") confirmed live: RouterOS' REST API
/// can 500 on an empty string for a non-text property, where a leftover/never-set blank value
/// in the form should just mean "leave this alone", not "actively clear it".
let clearable: Bool
init(key: String, label: String, kind: Kind, help: String, defaultValue: String? = nil, required: Bool = false) {
init(key: String, label: String, kind: Kind, help: String, defaultValue: String? = nil, required: Bool = false, clearable: Bool = true) {
self.key = key
self.label = label
self.kind = kind
self.help = help
self.defaultValue = defaultValue
self.required = required
self.clearable = clearable
}
}
@@ -88,6 +96,12 @@ struct RouterOSMenuSchema: Identifiable, Equatable {
/// removed. The Expert tool skips the add/remove UI and the transports fetch/apply them
/// differently for menus flagged this way.
let isSingleton: Bool
/// True for menus confirmed live not to work over RouterOS' REST API at all the exact same
/// write succeeds instantly over plain SSH, REST 500s regardless of which fields are sent (not
/// a data-formatting issue this app can work around). Routes writes through a dedicated SSH
/// connection instead (`ConnectionService.applyViaSSH`), bypassing whatever the session's
/// active transport is. Confirmed for `/system routerboard mode-button` (2026-09-17).
let writesRequireSSH: Bool
init(
menuPath: String,
@@ -99,7 +113,8 @@ struct RouterOSMenuSchema: Identifiable, Equatable {
warning: String? = nil,
fields: [RouterOSFieldSchema] = [],
listColumns: [String] = [],
isSingleton: Bool = false
isSingleton: Bool = false,
writesRequireSSH: Bool = false
) {
self.menuPath = menuPath
self.restPath = restPath
@@ -111,5 +126,6 @@ struct RouterOSMenuSchema: Identifiable, Equatable {
self.fields = fields
self.listColumns = listColumns
self.isSingleton = isSingleton
self.writesRequireSSH = writesRequireSSH
}
}
@@ -654,21 +654,56 @@ enum RouterOSSchemaCatalog {
listColumns: ["time-zone-name"],
isSingleton: true
),
RouterOSMenuSchema(
menuPath: "/system routerboard mode-button", restPath: "system/routerboard/mode-button", category: .system,
displayName: "Mode-Taste",
summary: "Steuert, welches Skript beim Drücken der physischen Mode-Taste am Router ausgeführt wird.",
explanation: "Manche RouterBOARD-Geräte (z.B. hEX, cAP, hAP ac², LtAP mini, einige CCR/CRS) haben eine physische Mode-Taste an der Seite. Hier lässt sich festlegen, ob und wie lange sie gedrückt werden muss, damit ein zuvor unter \"Skripte\" angelegtes Skript ausgeführt wird.",
warning: "Ab RouterOS 7.1rc4 muss jede Aktivierung oder Änderung dieser Einstellung zusätzlich durch einen physischen Tastendruck (Reset- oder Mode-Taste) innerhalb von 60 Sekunden am Gerät selbst bestätigt werden — eine Änderung über diese App allein reicht nicht.",
fields: [
RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool,
help: "Mode-Taste ein-/ausschalten.", defaultValue: "no"),
RouterOSFieldSchema(key: "on-event", label: "Auszuführendes Skript",
kind: .menuItemPick(menuPath: "/system script", restPath: "system/script", valueField: "name"),
help: "Name eines zuvor unter \"Skripte\" angelegten Skripts."),
RouterOSFieldSchema(key: "hold-time", label: "Haltedauer (Min..Max)", kind: .text,
help: "Wie lange die Taste gedrückt gehalten werden muss, als Zeitspanne Min..Max, z.B. \"3s..5s\". Verfügbar ab RouterOS 6.47beta60.",
clearable: false)
],
listColumns: ["enabled", "on-event"],
isSingleton: true,
writesRequireSSH: true
),
RouterOSMenuSchema(
menuPath: "/system ntp client", restPath: "system/ntp/client", category: .system,
displayName: "Zeitserver (NTP)",
summary: "Hält die Router-Uhr über einen Zeitserver synchron.",
explanation: "",
explanation: "Die Server-Liste selbst liegt in einem eigenen Menü (\"NTP-Zeitserver-Liste\") — hier nur Ein/Aus und Modus.",
warning: "Falsche Systemzeit kann Zertifikatsprüfungen (HTTPS/REST) und Log-Zeitstempel durcheinanderbringen.",
fields: [
RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool,
help: "Zeitsynchronisation ein-/ausschalten.", defaultValue: "yes"),
RouterOSFieldSchema(key: "servers", label: "Zeitserver", kind: .text,
help: "Ein oder mehrere Server, kommagetrennt, z.B. pool.ntp.org.")
RouterOSFieldSchema(key: "mode", label: "Modus", kind: .enumPick(options: [
"unicast", "broadcast", "multicast", "manycast"
]), help: "Fast immer \"unicast\" (direkte Anfrage an feste Server).", defaultValue: "unicast")
],
listColumns: ["servers"],
listColumns: ["enabled", "mode"],
isSingleton: true
),
RouterOSMenuSchema(
menuPath: "/system ntp client servers", restPath: "system/ntp/client/servers", category: .system,
displayName: "NTP-Zeitserver-Liste",
summary: "Die Zeitserver, die der Client abfragt.",
explanation: "\"servers\" ist bei RouterOS 7.x kein Feld am NTP-Client selbst, sondern eine eigene Liste — jeder Server ist ein eigener Eintrag hier (statt kommagetrennt in einem Textfeld).",
fields: [
RouterOSFieldSchema(key: "address", label: "Adresse", kind: .text,
help: "Hostname oder IP eines Zeitservers, z.B. pool.ntp.org.", required: true),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool,
help: "Diesen Server deaktivieren, ohne ihn zu löschen.", defaultValue: "no"),
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: "Nur zur eigenen Wiedererkennung.")
],
listColumns: ["address", "disabled"]
),
generic("/system logging", "system/logging", .system, "Protokollierung",
"Was der Router mitschreibt und wohin (Speicher, Datei, Remote-Syslog, E-Mail).",
"Besteht aus \"rules\" (was protokolliert wird) und \"actions\" (wohin) — hier generischer Zugriff, verwandte Teile über \"Eigener Menüpfad\" (z.B. \"/system logging action\")."),
@@ -3,12 +3,19 @@ import Security
import CryptoKit
enum CertificateFingerprint {
static func sha256(of trust: SecTrust) -> String {
/// `nil` means the certificate chain couldn't be read at all the caller must treat that as
/// a hard connection failure, never as a fingerprint to compare or offer for trust. The
/// previous version returned the fixed string `"unbekannt"` in that case, which however
/// unlikely to trigger in practice meant every certificate that failed extraction shared
/// the exact same "fingerprint", so trusting one such (extraction-failed) certificate would
/// have silently trusted every other one too, defeating TOFU pinning for that host. bugs.md,
/// "maximale Sicherheit"-Durchgang, 2026-09-17 hardening, not a live-reproduced exploit.
static func sha256(of trust: SecTrust) -> String? {
guard
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let leaf = chain.first
else {
return "unbekannt"
return nil
}
let data = SecCertificateCopyData(leaf) as Data
let digest = SHA256.hash(data: data)
@@ -104,14 +104,31 @@ final class RestTransport: NSObject, RouterOSTransport {
/// property filters (`?field=value`), mirroring the console's `find field=value` used here
/// on the same assumption, not confirmed for this exact property. Unlike SSH, REST's GET
/// already returns full objects, so no separate id-overlay is involved here at all.
///
/// `whereValue` is percent-encoded before landing in the query string same defensive
/// reasoning as `SSHTransport.fetchFieldValues`'s CLI-escaping (see `RouterOSCommand`'s
/// `quoteIfNeeded` doc comment for the injection class this class of fix addresses): both
/// current callers only ever pass the hardcoded literal `"no"`, but an unencoded value
/// containing `&` could inject an additional, attacker-chosen query parameter into this
/// request for any future caller passing real (e.g. device-controlled) text.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
let data = try await send(path: "\(restPath)?\(whereField)=\(whereValue)", method: "GET", jsonBody: nil)
let encodedValue = whereValue.addingPercentEncoding(withAllowedCharacters: Self.queryValueAllowedCharacters) ?? whereValue
let data = try await send(path: "\(restPath)?\(whereField)=\(encodedValue)", method: "GET", jsonBody: nil)
guard let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return []
}
return Set(array.compactMap { $0[returnField] as? String })
}
/// RFC 3986 "unreserved characters" only deliberately stricter than `.urlQueryAllowed`,
/// which still permits `&`/`=`/`+`/`#` (valid query-string bytes in general, but exactly the
/// characters that let an encoded value be misread as introducing a second parameter).
private static let queryValueAllowedCharacters: CharacterSet = {
var set = CharacterSet.alphanumerics
set.insert(charactersIn: "-._~")
return set
}()
private static func menuItem(from item: [String: Any]) -> RouterOSMenuItem {
var fields: [String: String] = [:]
var id = ""
@@ -258,7 +275,14 @@ extension RestTransport: URLSessionDelegate {
return
}
let fingerprint = CertificateFingerprint.sha256(of: serverTrust)
// `nil` (certificate chain unreadable) is always a hard rejection never routed through
// `lastRejectedFingerprint`/`untrustedCertificate`, since that flow ends in a dialog
// offering to trust this exact fingerprint, and there is no reliable fingerprint to trust
// here (see `CertificateFingerprint.sha256`'s doc comment).
guard let fingerprint = CertificateFingerprint.sha256(of: serverTrust) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
if certificateTrust.isTrusted(host: credentials.host, fingerprint: fingerprint) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
@@ -98,20 +98,57 @@ enum RouterOSCliParser {
value == "true" || value == "yes"
}
/// RouterOS' `print terse` does *not* quote multi-word values at all live-confirmed
/// (2026-09-17, RouterOS 7.24.4, two independent menus: `/ip firewall address-list` and
/// `/interface ethernet`): a comment set to `"hello world"` comes back completely unquoted as
/// literal `comment=hello world name=ether2 ...`, indistinguishable from a second field by
/// punctuation alone. The previous regex-based parser (`("[^"]*"|\S+)`) assumed quoting
/// sometimes happened and matched only a bare `\S+` otherwise, so it silently truncated every
/// such value at the first space and dropped the remaining words entirely this app's own
/// `RouterOSCliParserTests.testParseInterfacesMatchesLiveHexOutput` already contained an
/// exact real-device dump with this exact shape (`last-link-up-time=2026-09-15 20:19:19`) and
/// nobody noticed, because that particular field happens not to be read by any curated
/// schema the bug is real and was simply invisible until a multi-word field someone
/// actually reads (e.g. any comment shown via the Expert tool's generic "Weitere Parameter")
/// got truncated.
///
/// Fix: token-scan instead of regex-match. A whitespace-separated word only starts a *new*
/// field if it itself looks like `key=...` (letters/digits/dots/hyphens then `=`); anything
/// else is appended to the value of whichever field started most recently. This correctly
/// handles the common case (free text with spaces) at the cost of one known, irreducible edge
/// case given RouterOS' ambiguous unquoted format: a value whose text itself contains a
/// `word=` substring (e.g. a comment literally reading "config=broken") gets misread as a new
/// field starting mid-value rare, and no worse than the previous parser's 100% failure rate
/// on every multi-word value. A defensive quote-strip is kept in case some other menu/RouterOS
/// version *does* quote (unverified either way beyond the two menus tested live).
private static func keyValues(from text: String) -> [String: String] {
var result: [String: String] = [:]
let pattern = #"([.a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return result }
let nsText = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
for match in matches {
let key = nsText.substring(with: match.range(at: 1))
var value = nsText.substring(with: match.range(at: 2))
let keyPattern = try! NSRegularExpression(pattern: #"^[.a-zA-Z0-9-]+="#)
var currentKey: String?
var currentValueWords: [String] = []
func commitCurrentField() {
guard let key = currentKey else { return }
var value = currentValueWords.joined(separator: " ")
if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {
value = String(value.dropFirst().dropLast())
}
result[key] = value
}
for word in text.split(separator: " ", omittingEmptySubsequences: true) {
let token = String(word)
let range = NSRange(token.startIndex..<token.endIndex, in: token)
if keyPattern.firstMatch(in: token, range: range) != nil {
commitCurrentField()
let parts = token.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
currentKey = String(parts[0])
currentValueWords = parts.count > 1 ? [String(parts[1])] : [""]
} else if currentKey != nil {
currentValueWords.append(token)
}
}
commitCurrentField()
return result
}
}
@@ -16,19 +16,35 @@ final class SSHTransport: RouterOSTransport {
self.hostKeyTrust = hostKeyTrust
}
/// Unlike `RestTransport` (explicit `request.timeoutInterval = 5` on every request), neither
/// this nor `run(_:)` used to bound how long they'd wait at all `Citadel.SSHClient.connect`
/// has no built-in timeout. Live-confirmed as a real, reproducible bug (bugs.md #11,
/// 2026-09-17): creating an `/ip pool` entry in the Experte tab hung the app permanently (no
/// error, no recovery, force-quit needed) traced to this connect call, reached via
/// `ExpertViewModel.saveEditingItem()`'s mandatory `ensureSessionBackup()`, which opens a
/// fresh, dedicated SSH connection (`BackupService`) before every session's first write. A
/// connection attempt that stalls (transient network hiccup, or the hAP-lite test router
/// itself being slow under load MIPS 24Kc/650MHz/1 core, seen at 80% CPU this session) had
/// no way to ever resolve, so `isApplying` never cleared. `withTimeout` below races the real
/// operation against a deadline and cancels whichever loses.
private static let connectTimeout: Duration = .seconds(10)
private static let commandTimeout: Duration = .seconds(30)
func connect() async throws {
do {
client = try await SSHClient.connect(
host: credentials.host,
port: credentials.sshPort,
authenticationMethod: .passwordBased(username: credentials.username, password: credentials.password),
hostKeyValidator: .custom(self),
reconnect: .never,
// RouterOS' SSH server typically only offers legacy algorithms
// (diffie-hellman-group14-sha1 key exchange, RSA host keys) that
// Citadel's defaults don't include `.all` adds them.
algorithms: .all
)
client = try await Self.withTimeout(Self.connectTimeout) {
try await SSHClient.connect(
host: self.credentials.host,
port: self.credentials.sshPort,
authenticationMethod: .passwordBased(username: self.credentials.username, password: self.credentials.password),
hostKeyValidator: .custom(self),
reconnect: .never,
// RouterOS' SSH server typically only offers legacy algorithms
// (diffie-hellman-group14-sha1 key exchange, RSA host keys) that
// Citadel's defaults don't include `.all` adds them.
algorithms: .all
)
}
} catch let error as RouterOSError {
throw error
} catch {
@@ -39,6 +55,21 @@ final class SSHTransport: RouterOSTransport {
}
}
/// Races `operation` against `duration`, cancelling whichever loses the generic mechanism
/// behind both `connect()`'s and `run(_:)`'s timeouts (bugs.md #11). `operation` must be
/// `@Sendable`: it runs inside a detached task group child, not on the caller's isolation.
static func withTimeout<T: Sendable>(_ duration: Duration, operation: @escaping @Sendable () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: duration)
throw RouterOSError.transportUnavailable("Zeitüberschreitung (\(Int(duration.components.seconds))s) — Router antwortet nicht.")
}
defer { group.cancelAll() }
return try await group.next()!
}
}
func fetchDeviceInfo() async throws -> RouterDeviceInfo {
let output = try await run("/system resource print without-paging")
return RouterOSCliParser.parseDeviceInfo(output)
@@ -173,8 +204,17 @@ final class SSHTransport: RouterOSTransport {
/// wrong (a real make-static conversion confirmed via Winbox wasn't found by it). `:put`
/// inside `:foreach` prints one value per line, so this splits on newlines, not ";" (the
/// semicolon-joined shape only applies to a single `:put [<menuPath> find ...]` list).
/// `whereValue` is quoted/escaped the same way as `RouterOSCommand`'s CLI rendering (see its
/// `quoteIfNeeded` doc comment for the live-confirmed injection this prevents) both current
/// callers only ever pass the hardcoded literal `"no"`, but this is a generic, reusable
/// `RouterOSTransport` method, so a future caller passing user/device-controlled text (e.g. a
/// hostname) must not silently reopen the same command-injection class this app has already
/// had to fix once.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
let script = ":foreach i in=[\(menuPath) find \(whereField)=\(whereValue)] do={:put [\(menuPath) get $i \(returnField)]}"
let escapedValue = whereValue
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = ":foreach i in=[\(menuPath) find \(whereField)=\"\(escapedValue)\"] do={:put [\(menuPath) get $i \(returnField)]}"
let output = try await run(script)
let values = output
.split(whereSeparator: \.isNewline)
@@ -199,6 +239,58 @@ final class SSHTransport: RouterOSTransport {
}
}
/// Forcibly ends already-tracked connections between two networks (bugs.md #7 / Gitea #19)
/// RouterOS' firewall rules only affect *new* connections, so a connection already open
/// between two networks at the moment they're marked isolated would otherwise keep flowing
/// through the pre-existing "forward established/related -> accept" rule indefinitely.
/// `/ip firewall connection` has no interface field, only address fields, so this matches by
/// CIDR-range membership instead. No REST equivalent exists for this CIDR-membership query
/// language (REST mirrors CRUD over menu paths, not scripting), so same reasoning as
/// `BackupService`/`NetworkToolsService`/`UpdateService` this always runs over SSH.
///
/// **Verification status, honestly (2026-09-17):** `print count-only where src-address in
/// <cidr>` is live-confirmed to filter by real subnet containment (40 matches for a
/// populated /24 vs. 0 for an empty one, and `remove [find where dst-address=<exact-ip>]`
/// single equality condition, no `in` is live-confirmed to actually delete an entry (a
/// disposable ICMP ping's tracked connection genuinely disappeared). The compound
/// `remove [find where (src-address in A) and (dst-address in B)]` form this function
/// actually uses could NOT be cleanly proven live: a first attempt looked successful, but
/// that was later traced to ICMP conntrack's very short natural timeout (a few seconds)
/// coinciding with the multi-second gap between separate manual SSH round-trips, not the
/// `remove` itself. A cleaner retest against a still-*active* long-lived TCP connection (an
/// open SSH session) showed the tracked entry reappearing immediately after `remove` which
/// is expected/correct behavior for connection tracking in general (removing the tracking
/// state doesn't RST the socket; the very next packet on an actively-flowing connection just
/// gets re-tracked as "new"), not proof the `remove` itself is a no-op, but this app has no
/// two genuinely separate test networks available to observe the one behavior that actually
/// matters here: whether a fresh isolation drop rule catches that re-tracked "new" packet
/// instead of silently re-admitting it. The `remove [find where ... in ...]` compound-CIDR
/// technique itself is real and community-documented (MikroTik forum), just not end-to-end
/// live-verified against this app's specific isolation scenario. Best-effort by design either
/// way (see `ConnectionService.flushConnections`'s caller in `SetupViewModel`) a live
/// multi-network test is the natural next verification step.
///
/// `networkA`/`networkB` are interpolated directly into the script, so they're validated as
/// plain CIDR notation first (digits/dots/slash only) an unvalidated value here would
/// reopen the exact command-injection class already fixed once in
/// `RouterOSCommand.quoteIfNeeded` (bugs.md #5). Both directions are removed since either
/// isolated network could be the connection's source or destination.
func flushConnections(between networkA: String, and networkB: String) async throws {
guard Self.isPlainCIDR(networkA), Self.isPlainCIDR(networkB) else {
throw RouterOSError.invalidResponse("Ungültiges Netzwerkformat: \(networkA) / \(networkB)")
}
let script = """
/ip firewall connection remove [find where (src-address in \(networkA)) and (dst-address in \(networkB))]
/ip firewall connection remove [find where (src-address in \(networkB)) and (dst-address in \(networkA))]
"""
_ = try await run(script)
}
private static func isPlainCIDR(_ value: String) -> Bool {
let allowed = CharacterSet(charactersIn: "0123456789./")
return !value.isEmpty && value.unicodeScalars.allSatisfy(allowed.contains)
}
/// Restores RouterOS' own vendor-default configuration and reboots the device. See
/// FactoryResetService for why this bypasses the RouterOSCommand add/set model entirely.
func resetToFactoryDefaults() async throws {
@@ -284,25 +376,33 @@ final class SSHTransport: RouterOSTransport {
/// `executeCommand` discards whatever output it already collected the moment the command
/// exits non-zero exactly the RouterOS error text we need. Collecting the stream ourselves
/// keeps that text available even when the command fails.
/// Wrapped in `withTimeout` for the same reason as `connect()` (bugs.md #11) a command that
/// never returns (router hangs mid-execution, connection drops without a clean error) used to
/// block forever with no recovery. The `CommandFailed` handling stays inside the timed
/// closure so `output` (partial text collected before the failure) is still in scope to build
/// the error detail the already-established domain error (`RouterOSError.invalidResponse`)
/// is what actually crosses the timeout race, not the raw Citadel type.
private func run(_ command: String) async throws -> String {
guard let client else { throw RouterOSError.notConnected }
var output = ""
do {
let stream = try await client.executeCommandStream(command)
for try await chunk in stream {
switch chunk {
case .stdout(let buffer), .stderr(let buffer):
output += String(buffer: buffer)
return try await Self.withTimeout(Self.commandTimeout) {
var output = ""
do {
let stream = try await client.executeCommandStream(command)
for try await chunk in stream {
switch chunk {
case .stdout(let buffer), .stderr(let buffer):
output += String(buffer: buffer)
}
}
return output
} catch let failure as SSHClient.CommandFailed {
let detail = output.trimmingCharacters(in: .whitespacesAndNewlines)
throw RouterOSError.invalidResponse(
"RouterOS meldete Fehler (Exit-Code \(failure.exitCode)) für \"\(command)\""
+ (detail.isEmpty ? "" : ": \(detail)")
)
}
return output
} catch let failure as SSHClient.CommandFailed {
let detail = output.trimmingCharacters(in: .whitespacesAndNewlines)
throw RouterOSError.invalidResponse(
"RouterOS meldete Fehler (Exit-Code \(failure.exitCode)) für \"\(command)\""
+ (detail.isEmpty ? "" : ": \(detail)")
)
}
}
}
@@ -29,6 +29,35 @@ final class ConnectionService: ObservableObject {
/// Credentials of the current (or last attempted) connection, shared with features
/// that need their own dedicated connection, e.g. BackupService's SSH export.
@Published private(set) var credentials: RouterOSCredentials?
/// True while a lost connection is being silently re-established in the background
/// see `startHealthMonitoring()`'s doc comment.
@Published private(set) var isReconnecting = false
/// How many REST+SSH reconnect rounds have failed so far this episode reset to 0
/// whenever reconnecting starts/succeeds. Purely informational (shown in the banner).
@Published private(set) var reconnectAttemptCount = 0
/// Ticks down once a second between reconnect attempts, `nil` while an attempt is
/// actually in flight or reconnecting isn't happening lets the banner show "next
/// attempt in Xs" instead of a bare spinner.
@Published private(set) var secondsUntilNextReconnectAttempt: Int?
private var healthMonitorTask: Task<Void, Never>?
private static let healthCheckInterval: Duration = .seconds(10)
private static let reconnectRetryInterval: Duration = .seconds(5)
/// How many callers currently consider themselves "mid-write" (Setup-Wizard apply, Experte-Tab
/// save/remove) while non-zero, the health-check heartbeat below skips its round so a slow
/// but otherwise-succeeding apply on weak hardware doesn't get mistaken for a dropped
/// connection and trigger a spurious "Wiederverbinden" banner (bugs.md #4, 2026-09-17).
private var activeWriteCount = 0
/// Call before a multi-step write (Setup-Wizard apply, Experte-Tab save/remove) starts, paired
/// with `endWrite()` once it finishes (success or failure) see `activeWriteCount`'s doc
/// comment.
func beginWrite() {
activeWriteCount += 1
}
func endWrite() {
activeWriteCount = max(0, activeWriteCount - 1)
}
private let certificateTrust: CertificateTrustStore
private let sshHostKeyTrust: SSHHostKeyTrustStore
@@ -94,6 +123,27 @@ final class ConnectionService: ObservableObject {
await connect(with: credentials)
}
/// Backs out of a blocked connect attempt (`.needsCertificateConfirmation`/
/// `.needsSSHHostKeyConfirmation`) without trusting anything back to `.idle` so the
/// Verbinden-tab form is usable again for a fresh attempt (e.g. different credentials, wrong
/// host typo). Without this, `ConnectView`'s "Abbrechen" buttons on both trust alerts had no
/// way to actually change `state`, so the alert's `isPresented` binding (derived from
/// `state`/`pendingSSHTrustFingerprint`) stayed true and the dialog could never really be
/// dismissed found during the README milestone re-check, 2026-09-17 (same class of bug as
/// `SetupViewModel.dismissApplyError()`, fixed moments earlier this session). Also clears
/// `pendingSSHTrustFingerprint`, covering the background-trust-probe trigger path too, so one
/// method correctly backs out of either of the two ways `ConnectView`'s SSH-host-key alert
/// can be showing (see `pendingSSHTrustFingerprint`'s own doc comment for that distinction).
func cancelPendingTrustConfirmation() {
switch state {
case .needsCertificateConfirmation, .needsSSHHostKeyConfirmation:
state = .idle
default:
break
}
pendingSSHTrustFingerprint = nil
}
func trustCurrentSSHHostKeyAndRetry(fingerprint: String) async {
guard let credentials else { return }
sshHostKeyTrust.trust(host: credentials.host, fingerprint: fingerprint)
@@ -109,8 +159,16 @@ final class ConnectionService: ObservableObject {
pendingSSHTrustFingerprint = nil
}
func dismissPendingSSHTrust() {
pendingSSHTrustFingerprint = nil
/// Safety net for when the proactive `verifySSHTrust` check (above) didn't already cover a
/// dedicated SSH service's host key confirmed live: a session's first Expert-tab write (its
/// pre-apply backup, over its own dedicated SSH connection) can still hit
/// `untrustedSSHHostKey` even after a successful connect, dead-ending as plain error text with
/// no way to resolve it (same root symptom as Bug 13/37, just at write-time instead of
/// connect-time). Callers catch `RouterOSError.untrustedSSHHostKey` around their own write
/// path and call this so the same Verbinden-tab trust dialog appears trusting there unblocks
/// a manual retry of whatever write just failed.
func noteUntrustedSSHHostKey(_ fingerprint: String) {
pendingSSHTrustFingerprint = fingerprint
}
/// Best-effort SSH host-key probe run once right after a successful REST connect see
@@ -139,6 +197,44 @@ final class ConnectionService: ObservableObject {
try await activeTransport.apply(command)
}
/// Applies a command over a dedicated, one-shot SSH connection instead of whatever
/// `activeTransport` currently is for the small number of menus confirmed live not to work
/// over RouterOS' REST API at all (not a data/formatting issue on this app's side: the exact
/// same write succeeds instantly over plain SSH/terminal, REST 500s regardless of which fields
/// are sent). Confirmed for `/system routerboard mode-button` (2026-09-17); same dedicated-
/// connection pattern as `BackupService`/`UpdateService`.
func applyViaSSH(_ command: RouterOSCommand) async throws {
guard let credentials else { throw RouterOSError.notConnected }
let transport = SSHTransport(credentials: credentials)
try await transport.connect()
do {
try await transport.apply(command)
await transport.disconnect()
} catch {
await transport.disconnect()
throw error
}
}
/// Forcibly ends already-tracked connections between two networks that were just marked
/// isolated see `SSHTransport.flushConnections(between:and:)`'s doc comment for the full
/// reasoning and live verification (bugs.md #7 / Gitea #19). Best-effort: called by
/// `SetupViewModel.apply()` right after the isolation filter rules themselves are in place,
/// so any connection that re-establishes immediately after being flushed hits the new drop
/// rule instead of reopening freely.
func flushConnections(between networkA: String, and networkB: String) async throws {
guard let credentials else { throw RouterOSError.notConnected }
let transport = SSHTransport(credentials: credentials)
try await transport.connect()
do {
try await transport.flushConnections(between: networkA, and: networkB)
await transport.disconnect()
} catch {
await transport.disconnect()
throw error
}
}
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchFirewallRuleCounts()
@@ -199,11 +295,87 @@ final class ConnectionService: ObservableObject {
// whole connection over it.
routerBoardInfo = try? await Self.fetchRouterBoardInfo(using: transport)
state = .connected(kind: transport.kind)
if healthMonitorTask == nil {
startHealthMonitoring()
}
} catch {
state = .failed(error.localizedDescription)
}
}
/// Per explicit request: a connection lost mid-session (router rebooted, Wi-Fi
/// dropped, cable unplugged) should try to recover itself instead of just sitting
/// there disconnected. Runs for as long as `state` is `.connected`, using a trivial
/// read (`/system identity`, the smallest possible singleton menu) as a heartbeat
/// cheap enough on the router to poll every `healthCheckInterval` indefinitely.
/// `state` deliberately stays `.connected` throughout a lost-and-recovering episode
/// so other tabs don't flash to their "not connected" placeholders over what's often
/// just a brief hiccup; only `isReconnecting` (a small banner) reflects it. Cancelled
/// in `disconnect()` an explicit user disconnect must not keep retrying.
private func startHealthMonitoring() {
healthMonitorTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: Self.healthCheckInterval)
guard !Task.isCancelled else { return }
await self?.checkConnectionHealthAndReconnectIfNeeded()
}
}
}
private func checkConnectionHealthAndReconnectIfNeeded() async {
guard case .connected = state, !isReconnecting, activeWriteCount == 0,
let activeTransport, let credentials else { return }
do {
_ = try await activeTransport.fetchMenuItems(menuPath: "/system identity", restPath: "system/identity")
} catch {
await reconnectLoop(credentials: credentials)
}
}
/// Retries REST-then-SSH (same order as a normal `connect()`) every
/// `reconnectRetryInterval` until one succeeds or the connection is cancelled from
/// under it (user hit "Trennen" `disconnect()` cancels `healthMonitorTask`, which
/// is this loop's own parent Task, and resets `state` to `.idle`, tripping the guard
/// below on the next iteration regardless).
private func reconnectLoop(credentials: RouterOSCredentials) async {
isReconnecting = true
reconnectAttemptCount = 0
while !Task.isCancelled {
guard case .connected = state else { break }
reconnectAttemptCount += 1
let rest = RestTransport(credentials: credentials, certificateTrust: certificateTrust)
if (try? await rest.connect()) != nil {
await finishConnecting(using: rest)
resetReconnectState()
return
}
let ssh = SSHTransport(credentials: credentials, hostKeyTrust: sshHostKeyTrust)
if (try? await ssh.connect()) != nil {
await finishConnecting(using: ssh)
resetReconnectState()
return
}
await countdownToNextReconnectAttempt()
}
resetReconnectState()
}
private func resetReconnectState() {
isReconnecting = false
reconnectAttemptCount = 0
secondsUntilNextReconnectAttempt = nil
}
private func countdownToNextReconnectAttempt() async {
let totalSeconds = Int(Self.reconnectRetryInterval.components.seconds)
for remaining in stride(from: totalSeconds, through: 1, by: -1) {
guard !Task.isCancelled else { return }
secondsUntilNextReconnectAttempt = remaining
try? await Task.sleep(for: .seconds(1))
}
secondsUntilNextReconnectAttempt = nil
}
/// Field names confirmed live (hEX, RouterOS 6.49.16, see `RouterBoardInfo`'s doc comment).
private static func fetchRouterBoardInfo(using transport: RouterOSTransport) async throws -> RouterBoardInfo {
let items = try await transport.fetchMenuItems(menuPath: "/system routerboard", restPath: "system/routerboard")
@@ -220,6 +392,9 @@ final class ConnectionService: ObservableObject {
}
func disconnect() async {
healthMonitorTask?.cancel()
healthMonitorTask = nil
resetReconnectState()
await activeTransport?.disconnect()
activeTransport = nil
deviceInfo = nil
@@ -95,15 +95,15 @@ struct BackupListView: View {
} else {
Form {
Section(L10n.t("Sicherungen", appLanguage)) {
ForEach(viewModel.backups) { backup in
ForEach(Array(viewModel.backups.enumerated()), id: \.element.id) { index, backup in
HStack {
VStack(alignment: .leading) {
Text(backup.host).bold()
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
if let model = BackupService.backupModel(from: backup.fileURL) {
Text(model).font(.caption2).foregroundStyle(.secondary)
Text(model).appFont(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
@@ -116,19 +116,21 @@ struct BackupListView: View {
.disabled(connectionService.credentials == nil || viewModel.isRestoring)
.help(L10n.t("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.", appLanguage))
}
.listRowBackground(TableZebra.color(for: index))
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
}
}
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.t("Speicherort", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
Text(viewModel.backupDirectoryPath)
.font(.caption)
.appFont(.caption)
.lineLimit(1)
.truncationMode(.middle)
.help(viewModel.backupDirectoryPath)
@@ -143,10 +145,10 @@ struct BackupListView: View {
.padding(.vertical, 4)
Text(L10n.t("Gefahrenzone", appLanguage))
.font(.caption.bold())
.appFont(.caption, bold: true)
.foregroundStyle(.red)
Text(L10n.t("Setzt den Router komplett auf die vom Hersteller mitgelieferte Standardkonfiguration zurück — alle bisherigen Änderungen (Internet, Heimnetz, VLANs, WLAN, Firewall) gehen verloren. Der Router startet danach neu.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
Button(role: .destructive) {
showFactoryResetConfirmation = true
@@ -168,14 +170,14 @@ struct BackupListView: View {
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)
.appFont(.caption)
}
if viewModel.didSendRestore {
Label(
L10n.t("Wiederherstellung gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", appLanguage),
systemImage: "arrow.clockwise"
)
.font(.caption)
.appFont(.caption)
}
}
.padding(8)
@@ -210,6 +212,7 @@ struct BackupListView: View {
}
}
.navigationTitle(LocalizedStringKey(L10n.t("Sicherungen", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabBackup) } }
.toolbar {
ToolbarItem {
Button {
@@ -20,6 +20,9 @@ struct DevicesView: View {
/// Same reasoning as `showStaticConfirmation` independent of `pendingStaticRemoval`.
@State private var showRemovalConfirmation = false
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
@AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
init(connectionService: ConnectionService) {
self.connectionService = connectionService
@@ -53,12 +56,12 @@ struct DevicesView: View {
ForEach(viewModel.portGroups) { group in
Section {
if group.devices.isEmpty {
Text(L10n.t("Keine Geräte", appLanguage)).font(.caption).foregroundStyle(.secondary)
Text(L10n.t("Keine Geräte", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
} else {
DeviceColumnHeader(appLanguage: appLanguage)
ForEach(group.devices) { device in
ForEach(Array(group.devices.enumerated()), id: \.element.id) { index, device in
HStack(spacing: 4) {
DeviceRow(device: device, appLanguage: appLanguage)
DeviceRow(device: device, appLanguage: appLanguage, theme: colorTheme)
Spacer()
// Per explicit request: right-click alone wasn't
// discoverable ("nicht eindeutig erkennbar oder
@@ -75,6 +78,7 @@ struct DevicesView: View {
.fixedSize()
.help(L10n.t("Aktionen", appLanguage))
}
.listRowBackground(TableZebra.color(for: index))
}
}
} header: {
@@ -82,26 +86,32 @@ struct DevicesView: View {
Text("\(group.title) (\(group.devices.count))")
if let traffic = viewModel.portTraffic[group.id] {
Spacer()
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [])
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [], width: sparklineWidth)
Label(
Self.formatTraffic(traffic),
systemImage: traffic.isActive ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle"
)
.font(.caption2)
.foregroundStyle(traffic.isActive ? .green : .secondary)
.appFont(.caption2)
.foregroundStyle(traffic.isActive ? colorTheme.trafficActive : .secondary)
}
}
}
}
}
.formStyle(.grouped)
// `.formStyle(.grouped)` paints its own opaque background over each
// Section's rows `.listRowBackground` (Zebra-Streifen below) was applied
// correctly but rendered invisible underneath it. Confirmed live ("keine
// Änderungen erkennbar"): this is the documented fix, not a bug in the color
// computation itself.
.scrollContentBackground(.hidden)
}
}
.overlay(alignment: .bottom) {
if viewModel.isRunningNetworkTool {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).font(.caption)
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).appFont(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -110,6 +120,7 @@ struct DevicesView: View {
}
}
.navigationTitle(LocalizedStringKey(L10n.t("LAN-Scanner", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabDevices) } }
.toolbar {
ToolbarItem {
Button {
@@ -152,25 +163,18 @@ struct DevicesView: View {
showStaticConfirmation: $showStaticConfirmation,
showRemovalConfirmation: $showRemovalConfirmation
)
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, rawFieldsDevice: $rawFieldsDevice)
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, theme: colorTheme, rawFieldsDevice: $rawFieldsDevice)
}
}
/// Same unit-suffix style RouterOS itself reports live traffic in (`SSHTransport.
/// parseBitsPerSecond`'s doc comment: "50.7kbps", "34.0kbps") kept consistent instead of
/// inventing a different display format for the same underlying number.
/// Fixed MB/s (megabytes/second, not auto-scaling kbps/Mbps/Gbps) per explicit request
/// bits-per-second from RouterOS divided by 8 (bytes) then by 1,000,000 (MB).
private static func formatTraffic(_ traffic: InterfaceTraffic) -> String {
"\(formatBitsPerSecond(traffic.rxBitsPerSecond))\(formatBitsPerSecond(traffic.txBitsPerSecond))"
"\(formatMegabytesPerSecond(traffic.rxBitsPerSecond))\(formatMegabytesPerSecond(traffic.txBitsPerSecond))"
}
private static func formatBitsPerSecond(_ bitsPerSecond: Int) -> String {
let units: [(suffix: String, divisor: Double)] = [
("Gbps", 1_000_000_000), ("Mbps", 1_000_000), ("kbps", 1_000)
]
for unit in units where Double(bitsPerSecond) >= unit.divisor {
return String(format: "%.1f%@", Double(bitsPerSecond) / unit.divisor, unit.suffix)
}
return "\(bitsPerSecond)bps"
private static func formatMegabytesPerSecond(_ bitsPerSecond: Int) -> String {
String(format: "%.2fMB/s", Double(bitsPerSecond) / 8 / 1_000_000)
}
@ViewBuilder
@@ -310,7 +314,7 @@ private extension View {
}
}
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, rawFieldsDevice: Binding<LanDevice?>) -> some View {
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, theme: ColorTheme, rawFieldsDevice: Binding<LanDevice?>) -> some View {
self
.sheet(item: rawFieldsDevice) { device in
RawFieldsSheet(device: device, appLanguage: appLanguage) { rawFieldsDevice.wrappedValue = nil }
@@ -325,7 +329,7 @@ private extension View {
get: { viewModel.portScanResult },
set: { if $0 == nil { viewModel.portScanResult = nil } }
)) { result in
PortScanResultSheet(result: result, appLanguage: appLanguage) { viewModel.portScanResult = nil }
PortScanResultSheet(result: result, appLanguage: appLanguage, theme: theme) { viewModel.portScanResult = nil }
}
.alert(
L10n.t("Netzwerk-Test fehlgeschlagen", appLanguage),
@@ -342,14 +346,15 @@ private extension View {
}
}
/// Small sparkline next to each port header last-10-seconds rolling window from
/// `DevicesViewModel.portTrafficHistory` (Nutzerwunsch: "ein kleines Liniendiagramm der letzten
/// 10 Sekunden ... pro Port"). No axes/labels by design a trend glance, not a readable chart;
/// Small sparkline next to each port header last-30-seconds rolling window from
/// `DevicesViewModel.portTrafficHistory` (window widened from the original 10s per explicit
/// follow-up request). No axes/labels by design a trend glance, not a readable chart;
/// the exact current numbers are already shown right next to it via the / text. Stays an empty
/// fixed-size placeholder (not collapsing/disappearing) with fewer than two points, so the row
/// layout doesn't jump around during the first couple of poll ticks after opening the tab.
private struct TrafficSparkline: View {
let history: [TrafficSample]
let width: Double
var body: some View {
Group {
@@ -369,7 +374,7 @@ private struct TrafficSparkline: View {
Color.clear
}
}
.frame(width: 50, height: 16)
.frame(width: width, height: 16)
}
}
@@ -402,6 +407,7 @@ private struct DeviceColumnHeader: View {
private struct DeviceRow: View {
let device: LanDevice
let appLanguage: String
let theme: ColorTheme
/// 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
@@ -414,7 +420,7 @@ private struct DeviceRow: View {
private var statusColor: Color {
guard device.hasLease else { return .secondary }
return device.isStatic ? .orange : .green
return device.isStatic ? theme.staticLease : theme.dynamicLease
}
private var statusText: String {
@@ -455,30 +461,47 @@ private struct RawFieldsSheet: View {
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
.font(.headline)
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)
// Same header layout/behavior as every other popup's close button (see `OverviewView
// .focusPanel`): fixed title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
// directly below, separate from the scrollable content's own padding.
VStack(spacing: 0) {
HStack {
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
.appFont(.headline)
Spacer()
Button {
onClose()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(L10n.t("Schließen", appLanguage))
}
.padding(10)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(device.rawFields, id: \.key) { pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
VStack(alignment: .leading, spacing: 12) {
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(device.rawFields.enumerated()), id: \.element.key) { index, pair in
HStack(alignment: .top) {
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
Spacer()
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
}
.padding(.vertical, 2)
.background(TableZebra.color(for: index))
}
}
}
}
HStack {
Spacer()
Button(L10n.t("Schließen", appLanguage)) { onClose() }
}
.padding()
}
.padding()
.frame(minWidth: 380, minHeight: 300)
}
}
@@ -491,25 +514,37 @@ private struct NetworkToolResultSheet: View {
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(result.title)
.font(.headline)
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 ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
VStack(spacing: 0) {
HStack {
Text(result.title)
.appFont(.headline)
Spacer()
Button(L10n.t("Schließen", appLanguage)) { onClose() }
Button {
onClose()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(L10n.t("Schließen", appLanguage))
}
.padding(10)
Divider()
VStack(alignment: .leading, spacing: 12) {
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))
.appFont(.caption)
.foregroundStyle(.secondary)
ScrollView {
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
.appFont(.caption, design: .monospaced)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
.padding()
}
.padding()
.frame(minWidth: 420, minHeight: 320)
}
}
@@ -521,30 +556,44 @@ private struct NetworkToolResultSheet: View {
private struct PortScanResultSheet: View {
let result: PortScanResult
let appLanguage: String
let theme: ColorTheme
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
.font(.headline)
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)
VStack(spacing: 0) {
HStack {
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
.appFont(.headline)
Spacer()
Button {
onClose()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(L10n.t("Schließen", appLanguage))
}
.padding(10)
Divider()
ScrollView {
VStack(spacing: 0) {
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { Divider() }
PortScanRow(entry: entry, appLanguage: appLanguage)
VStack(alignment: .leading, spacing: 12) {
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))
.appFont(.caption)
.foregroundStyle(.secondary)
ScrollView {
VStack(spacing: 0) {
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { Divider() }
PortScanRow(entry: entry, appLanguage: appLanguage, theme: theme)
.background(TableZebra.color(for: index))
}
}
}
}
HStack {
Spacer()
Button(L10n.t("Schließen", appLanguage)) { onClose() }
}
.padding()
}
.padding()
.frame(minWidth: 420, minHeight: 420)
}
}
@@ -552,11 +601,12 @@ private struct PortScanResultSheet: View {
private struct PortScanRow: View {
let entry: PortScanResult.Entry
let appLanguage: String
let theme: ColorTheme
private var color: Color {
switch entry.status {
case .open: return .red
case .closed: return .green
case .open: return theme.portOpen
case .closed: return theme.portClosed
case .unreachable: return .secondary
}
}
@@ -573,15 +623,15 @@ private struct PortScanRow: View {
HStack {
Circle().fill(color).frame(width: 8, height: 8)
Text("\(entry.port)")
.font(.system(.caption, design: .monospaced))
.appFont(.caption, design: .monospaced)
.frame(width: 50, alignment: .leading)
Text(entry.serviceName ?? "-")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
.frame(width: 140, alignment: .leading)
Spacer()
Text(statusText)
.font(.caption)
.appFont(.caption)
.foregroundStyle(color)
}
.padding(.vertical, 3)
@@ -39,10 +39,10 @@ final class DevicesViewModel: ObservableObject {
/// interface name), not by device). "unbekannt" (devices RouterOS couldn't resolve onto a
/// real port) is never polled it isn't a real interface.
@Published private(set) var portTraffic: [String: InterfaceTraffic] = [:]
/// Rolling last-10-seconds history per port, for the small sparkline next to each port
/// header (Nutzerwunsch: "ein kleines Liniendiagramm der letzten 10 Sekunden ... pro Port").
/// Trimmed by actual elapsed time each tick, not by a fixed sample count, so it stays a true
/// "last 10 seconds" window even if a poll tick is ever late.
/// Rolling last-30-seconds history per port, for the small sparkline next to each port
/// header (window widened from 10s to 30s per explicit follow-up request). Trimmed by
/// actual elapsed time each tick, not by a fixed sample count, so it stays a true
/// "last 30 seconds" window even if a poll tick is ever late.
@Published private(set) var portTrafficHistory: [String: [TrafficSample]] = [:]
private let trafficMonitor = InterfaceTrafficMonitor()
private var trafficPollingTask: Task<Void, Never>?
@@ -61,9 +61,14 @@ final class DevicesViewModel: ObservableObject {
self.networkToolsService = networkToolsService
}
/// Same 3s cadence/reasoning as `ConnectViewModel.startTrafficPolling` re-reads
/// `portGroups` every tick so a newly-appearing port (e.g. a VLAN interface added via the
/// Setup wizard while this tab is open) gets picked up without restarting the poll.
/// Cadence and sparkline window are both live Settings values (`AppPreferences`, defaults
/// 0.1s/30s) read fresh from `UserDefaults` each tick/append rather than captured once, so
/// a change in the Settings window while this tab is already polling takes effect on the next
/// tick instead of needing the tab reopened. Deliberately faster by default than
/// `ConnectViewModel.startTrafficPolling`'s fixed 3s (a separate, unrelated poll this tab's
/// sparkline needs a finer-grained trend, not just the link dot). Re-reads `portGroups` every
/// tick so a newly-appearing port (e.g. a VLAN interface added via the Setup wizard while this
/// tab is open) gets picked up without restarting the poll.
func startTrafficPolling(credentials: RouterOSCredentials) {
stopTrafficPolling()
trafficPollingTask = Task {
@@ -76,13 +81,15 @@ final class DevicesViewModel: ObservableObject {
appendTrafficHistory(traffic, at: Date())
}
}
try? await Task.sleep(for: .seconds(3))
let intervalSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerPollIntervalSecondsKey) as? Double ?? 0.1
try? await Task.sleep(for: .milliseconds(max(1, Int(intervalSeconds * 1000))))
}
}
}
private func appendTrafficHistory(_ traffic: [String: InterfaceTraffic], at timestamp: Date) {
let cutoff = timestamp.addingTimeInterval(-10)
let windowSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerSparklineWindowSecondsKey) as? Double ?? 30
let cutoff = timestamp.addingTimeInterval(-windowSeconds)
for (name, sample) in traffic {
var history = portTrafficHistory[name] ?? []
history.append(TrafficSample(timestamp: timestamp, totalBitsPerSecond: sample.rxBitsPerSecond + sample.txBitsPerSecond))
@@ -259,6 +266,9 @@ final class DevicesViewModel: ObservableObject {
// conflict enough that the alert never actually becomes visible a silent failure
// that looked like nothing happened at all.
pendingStaticAssignment = nil
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -291,6 +301,9 @@ final class DevicesViewModel: ObservableObject {
await load()
} catch {
pendingStaticRemoval = nil
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -9,15 +9,15 @@ struct ExpertMenuDetailView: View {
Form {
Section {
Text(L10n.t(schema.displayName, appLanguage))
.font(.title2)
.appFont(.title2)
.bold()
Text(L10n.t(schema.category.rawValue, appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
Text(L10n.t(schema.explanation, appLanguage))
if let warning = schema.warning {
Label(L10n.t(warning, appLanguage), systemImage: "exclamationmark.triangle")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.orange)
}
}
@@ -39,7 +39,7 @@ struct ExpertMenuDetailView: View {
// `.sheet(item:)` dismissal already forces a remount of this view on its own;
// delete's confirmationDialog doesn't tear this view down at all, so nothing
// was forcing SwiftUI to re-diff the Section in place.
ForEach(viewModel.items) { item in
ForEach(Array(viewModel.items.enumerated()), id: \.element.id) { index, item in
HStack {
Button {
viewModel.startEditing(item)
@@ -61,6 +61,7 @@ struct ExpertMenuDetailView: View {
}
}
.contentShape(Rectangle())
.listRowBackground(TableZebra.color(for: index))
}
}
} header: {
@@ -75,6 +76,10 @@ struct ExpertMenuDetailView: View {
.id(viewModel.items.count)
}
.formStyle(.grouped)
// See DevicesView's identical fix: `.formStyle(.grouped)` paints an opaque background
// over each Section's rows, hiding `.listRowBackground` (Zebra-Streifen) underneath it.
.scrollContentBackground(.hidden)
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.schema(schema.menuPath)) } }
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
.task(id: schema.id) { await viewModel.reloadItems() }
.sheet(item: $viewModel.editingItem) { item in
@@ -122,26 +127,98 @@ struct ExpertItemEditView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
Form {
// Header fixed outside the `Form` (not a `Section`, which would scroll away with the
// content) same layout/behavior as the Übersicht focus popup's header (`OverviewView
// .focusPanel`): title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
// directly below, per explicit request to keep every popup's close button consistent.
VStack(spacing: 0) {
HStack {
Text(L10n.t(schema.displayName, appLanguage)).appFont(.headline)
Spacer()
Button {
viewModel.cancelEditing()
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(L10n.t("Schließen, ohne zu speichern", appLanguage))
}
.padding(10)
Divider()
Form {
ForEach(schema.fields) { field in
fieldEditor(for: field)
}
// Gleiche "Port-Konflikt-Prüfung" wie im Einrichten-Assistenten (LAN-Schritt,
// found.md #1), hier auf das `.interfacePick`-Feld dieses Schemas angewendet per
// explizitem Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert
// anwenden ... mit allen Warnungen").
if let conflict = viewModel.interfacePortConflict {
PortConflictWarningView(
conflict: conflict,
isAcknowledged: viewModel.acknowledgedInterfacePortConflict,
appLanguage: appLanguage,
immediateApply: true,
onConfirm: { viewModel.acknowledgeInterfacePortConflict() }
)
} else if viewModel.isCheckingInterfacePortConflict {
HStack {
ProgressView().controlSize(.small)
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
}
}
Section(LocalizedStringKey(L10n.t("Weitere Parameter (frei)", appLanguage))) {
Text(L10n.t("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
ForEach($viewModel.extraFields) { $extra in
HStack {
TextField(L10n.t("Parameter", appLanguage), text: $extra.key)
TextField(L10n.t("Wert", appLanguage), text: $extra.value)
Button(role: .destructive) {
viewModel.extraFields.removeAll { $0.id == extra.id }
} label: {
Image(systemName: "minus.circle")
// Zwei Spalten statt einer Zeile pro Parameter bei Menüs mit vielen ungekuratierten
// RouterOS-Feldern (z.B. "Alle Interfaces (generisch)", wo Ethernet-Ports gut 30+
// Extra-Parameter mitbringen) halbiert das die Scroll-Länge dieses Formulars.
// Eigene Grid-Spalten für Parameter/Wert (statt eines TextFields pro Zeilenhälfte):
// `TextField(titleKey:text:)` zeigt den Titel auf macOS als feste Beschriftung vor
// dem Wert an (kein iOS-Platzhalter, der beim Tippen verschwindet) bei 30+ Zeilen
// fraß "Parameter"/"Wert" so bei jeder Zeile erneut Breite, während an den äußeren
// Rändern Platz ungenutzt blieb (live per Screenshot bestätigt). Jetzt eine einmalige
// Kopfzeile statt Pro-Zeile-Label, leere TextField-Titel, Wert-Spalten mit
// `maxWidth: .infinity` gemäß Inhalt.
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 14) {
GridRow {
extraColumnHeader("Parameter")
extraColumnHeader("Wert")
Color.clear.gridCellUnsizedAxes(.horizontal)
extraColumnHeader("Parameter")
extraColumnHeader("Wert")
}
ForEach(Array(stride(from: 0, to: viewModel.extraFields.count, by: 2).enumerated()), id: \.element) { rowIndex, start in
GridRow {
extraKeyField(at: start)
extraValueField(at: start)
if start + 1 < viewModel.extraFields.count {
Rectangle()
.fill(Color.secondary.opacity(0.25))
.frame(width: 1)
.frame(maxHeight: .infinity)
extraKeyField(at: start + 1)
extraValueField(at: start + 1)
} else {
Color.clear
Color.clear
Color.clear
}
}
.background(TableZebra.color(for: rowIndex))
Divider()
.gridCellColumns(5)
}
}
.frame(maxWidth: .infinity)
.padding(.bottom, 12)
Button(L10n.t("Parameter hinzufügen", appLanguage)) {
viewModel.extraFields.append(.init())
}
@@ -150,7 +227,7 @@ struct ExpertItemEditView: View {
if let command = viewModel.pendingCommand {
Section(LocalizedStringKey(L10n.t("Wird ausgeführt", appLanguage))) {
Text(command.cliLine)
.font(.system(.caption, design: .monospaced))
.appFont(.caption, design: .monospaced)
.foregroundStyle(.secondary)
}
}
@@ -161,20 +238,23 @@ struct ExpertItemEditView: View {
Section {
HStack {
Button(L10n.t("Abbrechen", appLanguage)) {
viewModel.cancelEditing()
dismiss()
}
Spacer()
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
showApplyConfirmation = true
}
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil || viewModel.hasUnresolvedInterfacePortConflict)
}
}
}
.formStyle(.grouped)
}
.frame(minWidth: 900, idealWidth: 900, minHeight: 520, idealHeight: 660)
.onAppear {
viewModel.checkInterfacePortConflict()
}
.onChange(of: viewModel.formValues[interfaceFieldKey ?? "", default: ""]) { _, _ in
viewModel.checkInterfacePortConflict()
}
.formStyle(.grouped)
.frame(minWidth: 420, minHeight: 480)
.confirmationDialog(
L10n.t("Jetzt am Router anwenden?", appLanguage),
isPresented: $showApplyConfirmation,
@@ -194,6 +274,41 @@ struct ExpertItemEditView: View {
}
}
/// The current schema's `.interfacePick` field key, if it has one mirrors
/// `ExpertViewModel.interfaceFieldKey`, kept here too since `.onChange` needs a concrete
/// key path to observe (a schema only ever has at most one such field).
private var interfaceFieldKey: String? {
schema.fields.first { if case .interfacePick = $0.kind { return true } else { return false } }?.key
}
private func extraColumnHeader(_ key: String) -> some View {
Text(L10n.t(key, appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
private func extraKeyField(at index: Int) -> some View {
TextField("", text: $viewModel.extraFields[index].key)
.textFieldStyle(.plain)
}
@ViewBuilder
private func extraValueField(at index: Int) -> some View {
HStack {
TextField("", text: $viewModel.extraFields[index].value)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity)
Button(role: .destructive) {
let id = viewModel.extraFields[index].id
viewModel.extraFields.removeAll { $0.id == id }
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func fieldEditor(for field: RouterOSFieldSchema) -> some View {
let binding = Binding<String>(
@@ -283,7 +398,7 @@ private struct DurationFieldEditor: View {
.frame(minWidth: 26)
}
Text(unit)
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.secondary)
}
}
@@ -4,6 +4,8 @@ struct ExpertView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: ExpertViewModel
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
/// A category's presence in this set means "collapsed". Starts with every category
/// collapsed (Nutzerwunsch: "standardmäßig sollten beim Öffnen des Experte-Tab alle
/// Sektion zugeklappt sein") click a header to fold its menu list open again.
@@ -51,7 +53,7 @@ struct ExpertView: View {
}
}
} header: {
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded) {
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded, categoryColor: colorTheme.color(for: category)) {
if isExpanded {
collapsedCategories.insert(category)
} else {
@@ -65,7 +67,7 @@ struct ExpertView: View {
Section {
if isCustomPathExpanded {
Text(L10n.t("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
TextField(L10n.t("Menüpfad, z.B. /ip firewall filter", appLanguage), text: $viewModel.customMenuPath)
.help(L10n.t("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".", appLanguage))
@@ -82,6 +84,7 @@ struct ExpertView: View {
}
.listStyle(.sidebar)
.navigationTitle(LocalizedStringKey(L10n.t("Experte", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabExpert) } }
} detail: {
if let schema = viewModel.selectedSchema {
ExpertMenuDetailView(
@@ -113,15 +116,18 @@ struct ExpertView: View {
/// nicht erst beim Hovern"). The whole header is a plain `Button` toggling `onToggle`, with
/// the section's own content shown/hidden by the caller via a plain `if isExpanded` instead
/// of the `Section(isExpanded:)` API, since that API owns its own (hover-only) chevron.
private func sectionHeader(_ title: String, isExpanded: Bool, onToggle: @escaping () -> Void) -> some View {
private func sectionHeader(_ title: String, isExpanded: Bool, categoryColor: Color? = nil, onToggle: @escaping () -> Void) -> some View {
Button(action: onToggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption.bold())
.appFont(.caption, bold: true)
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
if let categoryColor {
Circle().fill(categoryColor).frame(width: 8, height: 8)
}
Text(title)
.font(.title3.bold())
.appFont(.title3, bold: true)
.foregroundStyle(.primary)
}
.padding(.vertical, 6)
@@ -37,6 +37,19 @@ final class ExpertViewModel: ObservableObject {
@Published private(set) var applyError: String?
@Published var pendingRemoval: RouterOSMenuItem?
/// Same "Port-Konflikt-Prüfung" as the Setup Wizard's LAN step (found.md #1), applied here to
/// whichever `.interfacePick` field the current schema has per explicit request ("die
/// Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen"). Unlike
/// the wizard (one conflict per `LanDhcpConfig.ID` in a list), the edit sheet only ever has
/// one interface field open at a time, so a single value (not a dictionary) is enough.
@Published private(set) var interfacePortConflict: PortConflict?
@Published private(set) var isCheckingInterfacePortConflict = false
@Published private(set) var acknowledgedInterfacePortConflict = false
/// Bumped on every `checkInterfacePortConflict()` call, same race-safety reasoning as
/// `SetupViewModel.portConflictRequestGeneration` (bugs.md #1) a slower, older in-flight
/// check discards its own result if a newer one has since started.
private var interfacePortConflictGeneration = 0
private let connectionService: ConnectionService
private let backupService: BackupService
@@ -125,6 +138,7 @@ final class ExpertViewModel: ObservableObject {
})
extraFields = []
applyError = nil
resetInterfacePortConflictState()
}
func startEditing(_ item: RouterOSMenuItem) {
@@ -137,6 +151,56 @@ final class ExpertViewModel: ObservableObject {
.sorted { $0.key < $1.key }
.map { ExtraField(key: $0.key, value: $0.value) }
applyError = nil
resetInterfacePortConflictState()
}
/// The key of the current schema's `.interfacePick` field, if it has one a schema only
/// ever has at most one (matches the one physical/logical port the whole menu item applies
/// to, e.g. `/ip address`'s "interface").
private var interfaceFieldKey: String? {
selectedSchema?.fields.first { if case .interfacePick = $0.kind { return true } else { return false } }?.key
}
private func resetInterfacePortConflictState() {
interfacePortConflict = nil
isCheckingInterfacePortConflict = false
acknowledgedInterfacePortConflict = false
interfacePortConflictGeneration += 1
}
/// Mirrors `SetupViewModel.checkPortConflict(for:)` called whenever the `.interfacePick`
/// field's value changes (`ExpertMenuDetailView`'s `.onChange`). Best-effort, same as the
/// wizard's version: a failed check just means no warning shown for that attempt, never
/// blocks the sheet outright.
func checkInterfacePortConflict() {
guard let key = interfaceFieldKey else { return }
let interfaceName = formValues[key] ?? ""
acknowledgedInterfacePortConflict = false
interfacePortConflict = nil
guard !interfaceName.isEmpty else { return }
isCheckingInterfacePortConflict = true
interfacePortConflictGeneration += 1
let generation = interfacePortConflictGeneration
Task {
let result = try? await connectionService.checkPortConflict(interfaceName: interfaceName)
guard generation == interfacePortConflictGeneration else { return }
interfacePortConflict = result
isCheckingInterfacePortConflict = false
}
}
/// The user has been shown what's on this port and, after two explicit confirmations
/// (`PortConflictWarningView`), chose to have the app clear it immediately as part of this
/// save unlike the wizard (deferred to "Jetzt anwenden"), the Experte tab has no separate
/// review step, so acknowledging here takes effect on the very next "Anlegen"/"Speichern".
func acknowledgeInterfacePortConflict() {
acknowledgedInterfacePortConflict = true
}
/// Blocks "Anlegen"/"Speichern" until a found conflict is either acknowledged or the
/// interface field is changed to something actually free.
var hasUnresolvedInterfacePortConflict: Bool {
interfacePortConflict != nil && !acknowledgedInterfacePortConflict
}
func cancelEditing() {
@@ -144,6 +208,7 @@ final class ExpertViewModel: ObservableObject {
formValues = [:]
extraFields = []
applyError = nil
resetInterfacePortConflictState()
}
/// The command that saving the current edit sheet would run shown to the user before it
@@ -166,6 +231,16 @@ final class ExpertViewModel: ObservableObject {
var arguments: [String: String] = isNew
? formValues.filter { !$0.value.isEmpty }
: formValues.filter { $0.value != (editingItem.fields[$0.key] ?? "") }
// A non-`clearable` field (see `RouterOSFieldSchema.clearable`'s doc comment) never sends
// an explicit empty value, even if the diff above decided to "clear" it RouterOS can
// reject or even 500 an empty string for a property whose type isn't plain text (confirmed
// live: `/system routerboard mode-button`'s "hold-time", a time-interval-range field,
// 500'd on `hold-time=""`).
for field in schema.fields where !field.clearable {
if arguments[field.key]?.isEmpty == true {
arguments.removeValue(forKey: field.key)
}
}
// RouterOS' CLI parser rejects "true"/"false" for boolean parameters it only accepts
// "yes"/"no" (confirmed live: "disabled=false" on "/interface vlan add" produced
// "syntax error (line 1 column 30)"; "disabled=no" succeeded). Schema defaults are
@@ -207,12 +282,29 @@ final class ExpertViewModel: ObservableObject {
guard let command = pendingCommand else { return }
isApplying = true
applyError = nil
connectionService.beginWrite()
defer { connectionService.endWrite() }
do {
try await ensureSessionBackup()
try await connectionService.apply(command)
// Port-Konflikt-Auflösung (found.md #1-style, siehe `PortConflictWarningView`) läuft
// hier sofort vor dem eigentlichen Befehl anders als im Wizard gibt es im Experte-
// Tab keinen separaten Review-Schritt, an dem das gebündelt würde.
if let conflict = interfacePortConflict, acknowledgedInterfacePortConflict {
for resolutionCommand in conflict.resolutionCommandsIncludingBridgeDetach() {
try await connectionService.apply(resolutionCommand)
}
}
if selectedSchema?.writesRequireSSH == true {
try await connectionService.applyViaSSH(command)
} else {
try await connectionService.apply(command)
}
cancelEditing()
await reloadItems()
} catch {
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -222,6 +314,8 @@ final class ExpertViewModel: ObservableObject {
guard let schema = selectedSchema else { return }
isApplying = true
applyError = nil
connectionService.beginWrite()
defer { connectionService.endWrite() }
do {
let command = RouterOSCommand.remove(
menuPath: schema.menuPath, restPath: schema.restPath,
@@ -233,6 +327,9 @@ final class ExpertViewModel: ObservableObject {
pendingRemoval = nil
await reloadItems()
} catch {
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -0,0 +1,118 @@
import SwiftUI
import WebKit
/// Static/stable anchor ids matching the `<a id="...">` markers hand-placed in `Manual.md`
/// (tab-/step-anchors) and auto-generated by `build-manual.py`'s `schema_anchor()` (Experte
/// menus) the two sides are kept in sync by construction: this is the exact same string
/// transform as the Python function, not a generated file, so there's nothing to regenerate
/// when a schema's `menuPath` doesn't change.
enum ManualAnchor {
static let tabConnect = "tab-connect"
static let stepWAN = "step-wan"
static let stepLAN = "step-lan"
static let stepVLAN = "step-vlan"
static let stepWifi = "step-wifi"
static let stepFirewall = "step-firewall"
static let stepReview = "step-review"
static let tabOverview = "tab-overview"
static let tabDevices = "tab-devices"
static let tabExpert = "tab-expert"
static let tabBackup = "tab-backup"
static let tabSettings = "tab-settings"
/// Mirrors `build-manual.py`'s `schema_anchor(menu_path)` exactly.
static func schema(_ menuPath: String) -> String {
let trimmed = menuPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "schema-" + trimmed.replacingOccurrences(of: " ", with: "-")
}
}
/// Shared across the app (one instance, injected as an `@StateObject` in
/// `RouterOSAssistantApp`) so any "?"-help button anywhere can request the Handbuch window
/// jump to a specific section, whether or not that window is already open.
@MainActor
final class ManualNavigator: ObservableObject {
@Published var pendingAnchor: String?
func go(to anchor: String) {
// Reassigning the same value wouldn't trigger `didSet`/a fresh `onChange` in
// `ManualView` if the user clicks the same help button twice in a row nil the
// slot first so every request, including a repeat, actually re-scrolls.
pendingAnchor = nil
DispatchQueue.main.async { self.pendingAnchor = anchor }
}
}
private struct ManualWebView: NSViewRepresentable {
@ObservedObject var navigator: ManualNavigator
let language: String
/// One HTML file per language (`Manual.html` for "de", `Manual_en.html`, later
/// `Manual_es.html`, ...) `build-manual.py` names them the same way (`suffix = ""
/// if lang == "de" else f"_{lang}"`), so a new language needs no Swift change here,
/// only a new `Manual.<lang>.md` + an entry in `LANGUAGES` in that script.
final class Coordinator {
var loadedLanguage: String?
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> WKWebView {
let webView = WKWebView()
load(language, into: webView, coordinator: context.coordinator)
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
if context.coordinator.loadedLanguage != language {
load(language, into: webView, coordinator: context.coordinator)
}
guard let anchor = navigator.pendingAnchor else { return }
// The page is already loaded scrolling via JS instead of re-navigating to
// "Manual.html#anchor" avoids a full page reload/flash on every help-button click.
webView.evaluateJavaScript(
"document.getElementById(\(String(reflecting: anchor)))?.scrollIntoView({behavior: 'smooth', block: 'start'});"
)
}
private func load(_ language: String, into webView: WKWebView, coordinator: Coordinator) {
let resourceName = language == "de" ? "Manual" : "Manual_\(language)"
// Falls back to the German file if this language has no translated manual yet
// (e.g. a language the app UI supports but the manual hasn't been translated
// into) rather than showing a blank window.
guard let url = Bundle.main.url(forResource: resourceName, withExtension: "html")
?? Bundle.main.url(forResource: "Manual", withExtension: "html") else { return }
webView.loadFileURL(url, allowingReadAccessTo: url)
coordinator.loadedLanguage = language
}
}
struct ManualView: View {
@EnvironmentObject private var navigator: ManualNavigator
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
ManualWebView(navigator: navigator, language: appLanguage)
.frame(minWidth: 640, minHeight: 480)
}
}
/// Drop-in "?" toolbar button for any screen opens (or focuses, if already open) the
/// Handbuch window scrolled straight to `anchor`. One shared implementation so every tab/
/// wizard-step/Experte-schema screen wires help the same way.
struct ManualHelpButton: View {
let anchor: String
@AppStorage("appLanguage") private var appLanguage: String = "de"
@EnvironmentObject private var navigator: ManualNavigator
@Environment(\.openWindow) private var openWindow
var body: some View {
Button {
navigator.go(to: anchor)
openWindow(id: "manual")
} label: {
Image(systemName: "questionmark.circle")
}
.help(L10n.t("Hilfe zu diesem Bereich im Handbuch öffnen", appLanguage))
}
}
@@ -13,10 +13,18 @@ struct OverviewView: View {
/// instead of duplicating either here.
@StateObject private var expertViewModel: ExpertViewModel
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
@State private var scale: CGFloat = 1.0
@State private var hoveredNodeID: String?
@State private var hoveredEdge: OverviewEdge?
@State private var hoverPoint: CGPoint = .zero
/// The focus panel's own hover/tooltip state kept separate from the main diagram's, since
/// the panel is a genuinely separate view further down, not sharing the main canvas'
/// coordinate space (a shared `hoverPoint` would position the main diagram's floating tooltip
/// using panel-local coordinates while hovering an edge down there).
@State private var panelHoveredEdge: OverviewEdge?
@State private var panelHoverPoint: CGPoint = .zero
/// Clicking one of a selected node's highlighted lines shows a full explanation in the right
/// panel (mirroring node clicks) the floating hover tooltip alone only has room for the
/// bare kind/from/to, not a plain-language explanation of what the connection means.
@@ -63,9 +71,39 @@ struct OverviewView: View {
return result
}
/// "Fokus-Modus": the currently selected node's complete parent/child chain `nil` while
/// nothing is selected (focus mode off). Driven by `viewModel.selectedNodeID` directly (not a
/// separate flag) so the existing click-to-select/click-again-to-deselect toggle already in
/// the node tap gesture doubles as "enter/exit focus" for free.
private var focusChainIDs: Set<String>? {
guard let id = viewModel.selectedNodeID else { return nil }
return viewModel.graph.connectedChain(startingAt: id)
}
/// Sub-graph containing only the focused chain feeds both `focusSubLayout` (below) and the
/// focus panel's own node/edge rendering.
private var focusSubGraph: OverviewGraph? {
guard let chain = focusChainIDs else { return nil }
return OverviewGraph(
nodes: viewModel.graph.nodes.filter { chain.contains($0.id) },
edges: viewModel.graph.edges.filter { chain.contains($0.from) && chain.contains($0.to) }
)
}
/// Laid out with the exact same `OverviewLayout` column logic as the main diagram (per
/// explicit request), just scoped to the chain, so the focus panel reads as a smaller version
/// of the same diagram rather than a different layout style.
private var focusSubLayout: OverviewLayoutResult? {
guard let subGraph = focusSubGraph else { return nil }
return OverviewLayout.layout(for: subGraph)
}
/// `OverviewLayout`'s computed grid positions, shifted by each node's current (committed +
/// live-dragging) offset what both the node cards and `EdgesCanvas`'s line endpoints
/// actually draw at.
/// actually draw at. The main diagram's own layout never changes in focus mode the focused
/// chain is shown a second time, neatly re-laid-out, in the separate panel below (see
/// `focusPanel`); nodes here just dim if they're not part of the chain.
private var effectivePositions: [String: CGPoint] {
var result: [String: CGPoint] = [:]
result.reserveCapacity(layout.positions.count)
@@ -128,6 +166,7 @@ struct OverviewView: View {
}
}
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabOverview) } }
.toolbar {
ToolbarItemGroup {
if viewModel.isLoading {
@@ -189,21 +228,27 @@ struct OverviewView: View {
description: Text(L10n.t("Der Router meldet aktuell keine Interfaces/Adressen.", appLanguage))
)
} else {
ZStack {
ScrollView([.horizontal, .vertical]) {
ZStack(alignment: .topLeading) {
ZStack(alignment: .topLeading) {
EdgesCanvas(
graph: viewModel.graph,
positions: effectivePositions,
highlightedNodeIDs: highlightedNodeIDs,
highlightedNodeIDs: focusChainIDs ?? highlightedNodeIDs,
theme: colorTheme,
hoveredEdge: $hoveredEdge,
hoverPoint: $hoverPoint,
selectedEdge: $selectedEdge
selectedEdge: $selectedEdge,
onBackgroundTap: {
viewModel.selectedNodeID = nil
selectedEdge = nil
}
)
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
Text(L10n.t(category.rawValue, appLanguage))
.font(.headline)
.appFont(.headline)
.foregroundStyle(.secondary)
.position(
x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2,
@@ -213,13 +258,16 @@ struct OverviewView: View {
ForEach(viewModel.graph.nodes) { node in
if let point = effectivePositions[node.id] {
let isChainMember = focusChainIDs?.contains(node.id) == true
NodeCardView(
node: node,
isSelected: node.id == viewModel.selectedNodeID,
isHovered: node.id == hoveredNodeID
isHovered: node.id == hoveredNodeID,
theme: colorTheme
)
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
.position(point)
.opacity(focusChainIDs == nil || isChainMember ? 1.0 : 0.25)
.onTapGesture {
selectedEdge = nil
viewModel.selectedNodeID = (viewModel.selectedNodeID == node.id) ? nil : node.id
@@ -246,7 +294,7 @@ struct OverviewView: View {
}
if let edge = hoveredEdge {
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage)
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme)
.position(
x: min(max(hoverPoint.x + 90, 90), layout.canvasSize.width - 90),
y: max(hoverPoint.y - 26, 16)
@@ -254,33 +302,143 @@ struct OverviewView: View {
.allowsHitTesting(false)
}
}
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
.scaleEffect(scale, anchor: .topLeading)
.frame(width: layout.canvasSize.width * scale, height: layout.canvasSize.height * scale)
.padding()
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
.scaleEffect(scale, anchor: .topLeading)
.frame(width: layout.canvasSize.width * scale, height: layout.canvasSize.height * scale)
.padding()
}
if let subGraph = focusSubGraph, let subLayout = focusSubLayout {
// Not a `.sheet` a `.sheet` is a genuine OS-level modal that blocks the rest of
// the window, including the right-hand sidebar's "Bearbeiten" button. Per explicit
// follow-up request, editing a node from the sidebar has to work while this popup
// is still open, so it's a plain non-modal overlay instead the sidebar
// (`detailPanel`, a separate `HSplitView` pane, never covered by this overlay)
// stays fully interactive.
Color.black.opacity(0.25)
.ignoresSafeArea()
.onTapGesture {
viewModel.selectedNodeID = nil
selectedEdge = nil
}
focusPanel(subGraph: subGraph, subLayout: subLayout)
}
}
}
}
/// Separate panel below the main diagram showing the focused node's complete chain, neatly
/// re-laid-out with the same `OverviewLayout` column logic per explicit request: not a
/// divider splitting the same scrollable canvas (an earlier iteration of this), a genuinely
/// separate panel spanning the main diagram's own viewport width, height fit to its content,
/// with its own close button (background click / re-clicking the same node up in the main
/// diagram still also close it, same as before this just adds an explicit affordance).
@ViewBuilder
private func focusPanel(subGraph: OverviewGraph, subLayout: OverviewLayoutResult) -> some View {
VStack(spacing: 0) {
HStack {
Text(L10n.t("Fokus", appLanguage)).appFont(.headline)
Spacer()
Button {
viewModel.selectedNodeID = nil
selectedEdge = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(L10n.t("Fokus-Ansicht schließen", appLanguage))
}
.padding(10)
Divider()
// No `ScrollView` here on purpose per explicit request, this sheet sizes itself
// exactly to `subLayout.canvasSize` (below) instead of clipping/scrolling a fixed
// viewport, same "form sheet" sizing model as `ExpertItemEditView`.
ZStack(alignment: .topLeading) {
EdgesCanvas(
graph: subGraph,
positions: subLayout.positions,
highlightedNodeIDs: Set(subGraph.nodes.map(\.id)),
theme: colorTheme,
hoveredEdge: $panelHoveredEdge,
hoverPoint: $panelHoverPoint,
selectedEdge: $selectedEdge,
onBackgroundTap: {
viewModel.selectedNodeID = nil
selectedEdge = nil
}
)
.frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height)
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
Text(L10n.t(category.rawValue, appLanguage))
.appFont(.headline)
.foregroundStyle(.secondary)
.position(
x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2,
y: 14
)
}
ForEach(subGraph.nodes) { node in
if let point = subLayout.positions[node.id] {
NodeCardView(
node: node,
isSelected: node.id == viewModel.selectedNodeID,
isHovered: node.id == hoveredNodeID,
theme: colorTheme
)
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
.position(point)
.onTapGesture {
selectedEdge = nil
viewModel.selectedNodeID = node.id
}
.onHover { isHovering in
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
}
}
}
if let edge = panelHoveredEdge {
EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage, theme: colorTheme)
.position(
x: min(max(panelHoverPoint.x + 90, 90), subLayout.canvasSize.width - 90),
y: max(panelHoverPoint.y - 26, 16)
)
.allowsHitTesting(false)
}
}
.frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height)
}
.fixedSize()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.25), lineWidth: 1))
.shadow(color: .black.opacity(0.3), radius: 20, y: 8)
}
@ViewBuilder
private var detailPanel: some View {
if let edge = selectedEdge {
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage) { nodeID in
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme) { 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, appLanguage: appLanguage, onEdit: editNode) { nodeID in
NodeDetailView(node: node, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme, onEdit: editNode) { nodeID in
selectedEdge = nil
viewModel.selectedNodeID = nodeID
}
} else {
LegendView(appLanguage: appLanguage)
LegendView(appLanguage: appLanguage, theme: colorTheme)
}
}
}
/// Draws every edge as a bezier curve, colored by its `kind` (see `OverviewStyle.color(for:)`)
/// Draws every edge as a bezier curve, colored by its `kind` (see `colorTheme.color(for:)`)
/// so the different dependency types are visually separated even with nothing selected. Hovering
/// or clicking a node pushes its own edges to full strength and fades every other edge down to
/// a hint, so "what connects to this" reads instantly in a busy diagram. Also tracks the mouse
@@ -290,9 +448,16 @@ private struct EdgesCanvas: View {
let graph: OverviewGraph
let positions: [String: CGPoint]
let highlightedNodeIDs: Set<String>
let theme: ColorTheme
@Binding var hoveredEdge: OverviewEdge?
@Binding var hoverPoint: CGPoint
@Binding var selectedEdge: OverviewEdge?
/// Fires when a tap lands on the canvas but not on any edge this `Canvas` already covers
/// the full diagram area underneath the node cards, so its own tap gesture is the simplest
/// place to catch "clicked empty space" without a second, competing gesture recognizer. Used
/// by `OverviewView` to exit "Fokus-Modus" on a background click, mirroring the existing
/// click-the-same-node-again toggle.
let onBackgroundTap: () -> Void
/// How close the mouse needs to be to an edge's curve (in the canvas' own, unscaled point
/// space SwiftUI reports hover coordinates already adjusted for any ancestor `.scaleEffect`,
@@ -390,7 +555,7 @@ private struct EdgesCanvas: View {
var path = Path()
path.move(to: geometry.start)
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
let baseColor = OverviewStyle.color(for: edge.kind)
let baseColor = theme.color(for: edge.kind)
context.stroke(
path,
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
@@ -415,7 +580,10 @@ private struct EdgesCanvas: View {
.gesture(
SpatialTapGesture()
.onEnded { value in
guard let edge = nearestEdge(to: value.location) else { return }
guard let edge = nearestEdge(to: value.location) else {
onBackgroundTap()
return
}
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
}
)
@@ -490,6 +658,7 @@ private struct EdgeTooltipView: View {
let edge: OverviewEdge
let graph: OverviewGraph
let appLanguage: String
let theme: ColorTheme
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 }
@@ -497,12 +666,12 @@ private struct EdgeTooltipView: View {
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t(edge.kind.rawValue, appLanguage))
.font(.caption.bold())
.appFont(.caption, bold: true)
Text("\(fromTitle)\(toTitle)")
.font(.caption2)
.appFont(.caption2)
if let label = edge.label {
Text(label)
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.secondary)
}
}
@@ -510,7 +679,7 @@ private struct EdgeTooltipView: View {
.background(RoundedRectangle(cornerRadius: 6).fill(.regularMaterial))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(OverviewStyle.color(for: edge.kind), lineWidth: 1)
.stroke(theme.color(for: edge.kind), lineWidth: 1)
)
.fixedSize()
}
@@ -524,6 +693,7 @@ private struct EdgeDetailView: View {
let edge: OverviewEdge
let graph: OverviewGraph
let appLanguage: String
let theme: ColorTheme
let onJumpToNode: (String) -> Void
private var fromNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.from }) }
@@ -536,27 +706,27 @@ private struct EdgeDetailView: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "arrow.triangle.branch")
.foregroundStyle(OverviewStyle.color(for: edge.kind))
Text(L10n.t(edge.kind.rawValue, appLanguage)).font(.title3.bold())
.foregroundStyle(theme.color(for: edge.kind))
Text(L10n.t(edge.kind.rawValue, appLanguage)).appFont(.title3, bold: true)
}
Text(L10n.t("Verbindung", appLanguage)).font(.caption).foregroundStyle(.secondary)
Text(L10n.t("Verbindung", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
Divider()
Text(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
.font(.callout)
.appFont(.callout)
.fixedSize(horizontal: false, vertical: true)
if let label = edge.label {
Text(label)
.font(.callout)
.appFont(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider()
Text(L10n.t("Verbunden", appLanguage)).font(.subheadline.bold())
Text(L10n.t("Verbunden", appLanguage)).appFont(.subheadline, bold: true)
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)
@@ -572,18 +742,18 @@ private struct EdgeDetailView: View {
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(title).appFont(.caption).foregroundStyle(.secondary)
HStack(spacing: 4) {
if let node {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
.foregroundStyle(theme.color(for: node.category))
}
Text(fallbackTitle).font(.callout.bold())
Text(fallbackTitle).appFont(.callout, bold: true)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
@@ -597,12 +767,13 @@ private struct NodeCardView: View {
let node: OverviewNode
let isSelected: Bool
let isHovered: Bool
let theme: ColorTheme
var body: some View {
VStack(spacing: 2) {
HStack(spacing: 4) {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
.foregroundStyle(theme.color(for: node.category))
Text(node.title)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
@@ -620,7 +791,7 @@ private struct NodeCardView: View {
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(
isSelected || isHovered ? Color.accentColor : OverviewStyle.color(for: node.category).opacity(0.5),
isSelected || isHovered ? Color.accentColor : theme.color(for: node.category).opacity(0.5),
lineWidth: isSelected || isHovered ? 2 : 1
)
)
@@ -635,6 +806,7 @@ private struct NodeDetailView: View {
let node: OverviewNode
let graph: OverviewGraph
let appLanguage: String
let theme: ColorTheme
let onEdit: (OverviewNode) -> Void
let onSelectNode: (String) -> Void
@@ -643,8 +815,8 @@ private struct NodeDetailView: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
Text(node.title).font(.title3.bold())
.foregroundStyle(theme.color(for: node.category))
Text(node.title).appFont(.title3, bold: true)
Spacer()
if node.editTarget != nil {
Button {
@@ -656,18 +828,20 @@ private struct NodeDetailView: View {
}
}
Text(L10n.t(node.category.rawValue, appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
if !node.detail.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 4) {
ForEach(node.detail, id: \.key) { pair in
ForEach(Array(node.detail.enumerated()), id: \.element.key) { index, pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
}
.padding(.vertical, 2)
.background(TableZebra.color(for: index))
}
}
}
@@ -675,8 +849,8 @@ private struct NodeDetailView: View {
let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id }
if !connected.isEmpty {
Divider()
Text(L10n.t("Verbindungen", appLanguage)).font(.subheadline.bold())
ForEach(connected) { edge in
Text(L10n.t("Verbindungen", appLanguage)).appFont(.subheadline, bold: true)
ForEach(Array(connected.enumerated()), id: \.element.id) { index, edge in
let otherID = edge.from == node.id ? edge.to : edge.from
let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID
Button {
@@ -686,11 +860,14 @@ private struct NodeDetailView: View {
edge.label.map { "\(otherTitle) (\($0))" } ?? otherTitle,
systemImage: edge.from == node.id ? "arrow.right" : "arrow.left"
)
.foregroundStyle(OverviewStyle.color(for: edge.kind))
.font(.caption)
.foregroundStyle(theme.color(for: edge.kind))
.appFont(.caption)
}
.buttonStyle(.plain)
.help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
.background(TableZebra.color(for: index))
}
}
}
@@ -701,45 +878,46 @@ private struct NodeDetailView: View {
private struct LegendView: View {
let appLanguage: String
let theme: ColorTheme
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(L10n.t("Übersicht", appLanguage)).font(.title3.bold())
Text(L10n.t("Übersicht", appLanguage)).appFont(.title3, bold: true)
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)
.appFont(.caption)
Divider()
Text(L10n.t("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.", appLanguage))
.font(.caption)
.appFont(.caption)
Divider()
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).font(.caption.bold())
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).appFont(.caption, bold: true)
VStack(alignment: .leading, spacing: 3) {
ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in
Text("· \(L10n.t(area, appLanguage))")
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.secondary)
}
}
Divider()
Text(L10n.t("Spalten", appLanguage)).font(.caption.bold())
Text(L10n.t("Spalten", appLanguage)).appFont(.caption, bold: true)
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewNode.Category.allCases) { category in
Label(L10n.t(category.rawValue, appLanguage), systemImage: "square.fill")
.foregroundStyle(OverviewStyle.color(for: category))
.font(.caption)
.foregroundStyle(theme.color(for: category))
.appFont(.caption)
}
}
Divider()
Text(L10n.t("Verbindungsarten", appLanguage)).font(.caption.bold())
Text(L10n.t("Verbindungsarten", appLanguage)).appFont(.caption, bold: true)
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewEdgeKind.allCases) { kind in
Label(L10n.t(kind.rawValue, appLanguage), systemImage: "minus")
.foregroundStyle(OverviewStyle.color(for: kind))
.font(.caption)
.foregroundStyle(theme.color(for: kind))
.appFont(.caption)
}
}
}
@@ -748,30 +926,9 @@ private struct LegendView: View {
}
}
/// Colors moved to `ColorTheme` (`AppPreferences.swift`) so they respond to the Settings window's
/// "Farbschema" picker this enum now only holds the parts that don't vary by theme.
enum OverviewStyle {
static func color(for category: OverviewNode.Category) -> Color {
switch category {
case .interface: return .blue
case .ipAddress: return .teal
case .service: return .purple
case .route: return .orange
case .firewall: return .red
}
}
static func color(for kind: OverviewEdgeKind) -> Color {
switch kind {
case .vlan: return .indigo
case .bridgePort: return .cyan
case .wireguardPeer: return .pink
case .ipAddress: return .teal
case .dhcp: return .purple
case .route: return .orange
case .firewallInterface: return .red
case .addressList: return .yellow
}
}
/// Plain-language explanation of what this connection kind actually means shown in
/// `EdgeDetailView` so clicking a line answers "why is this connected" for someone who
/// doesn't already know RouterOS' internal terminology.
@@ -0,0 +1,149 @@
import SwiftUI
/// The app's native Settings window (,, wired up as a `Settings { }` scene in
/// `RouterOSAssistantApp`) every control here is a plain `@AppStorage` binding under a key
/// catalogued in `AppPreferences`, so there's no view model: nothing here is anything other than
/// persisted preference state, and each tab's changes take effect live (no "Apply"/restart)
/// since every consumer (`OverviewView`, `DevicesView`, `DevicesViewModel`, ...) either observes
/// the same `@AppStorage` key itself or re-reads `UserDefaults.standard` on its own next tick.
struct SettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
TabView {
GeneralSettingsView()
.tabItem { Label(L10n.t("Allgemein", appLanguage), systemImage: "gearshape") }
AppearanceSettingsView()
.tabItem { Label(L10n.t("Darstellung", appLanguage), systemImage: "paintpalette") }
NetworkSettingsView()
.tabItem { Label(L10n.t("Netzwerk", appLanguage), systemImage: "network") }
}
.frame(width: 480)
.fixedSize(horizontal: false, vertical: true)
}
}
private struct GeneralSettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.autoCheckUpdatesOnConnectKey) private var autoCheckUpdatesOnConnect: Bool = false
var body: some View {
Form {
Section(L10n.t("Sprache", appLanguage)) {
Picker(L10n.t("Sprache", appLanguage), selection: $appLanguage) {
Text("Deutsch").tag("de")
Text("English").tag("en")
}
.pickerStyle(.segmented)
.labelsHidden()
Text(L10n.t("Entspricht dem 🇩🇪/🇬🇧-Knopf in der Toolbar — beide steuern dieselbe Einstellung.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
Section(L10n.t("RouterOS-Updates", appLanguage)) {
Toggle(L10n.t("Beim Verbinden automatisch nach Updates suchen", appLanguage), isOn: $autoCheckUpdatesOnConnect)
Text(L10n.t("Prüft nach jedem erfolgreichen Verbinden automatisch, ob eine neuere RouterOS-Version verfügbar ist — entspricht dem Button \"Nach Updates suchen\" im Tab \"Verbinden\".", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
}
private struct AppearanceSettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
@AppStorage(AppPreferences.textSizeKey) private var textSizeRaw: String = AppTextSize.standard.rawValue
@AppStorage(AppPreferences.uiDensityKey) private var uiDensityRaw: String = UIDensity.standard.rawValue
private var colorTheme: Binding<ColorTheme> {
Binding(get: { ColorTheme(rawValue: colorThemeRaw) ?? .standard }, set: { colorThemeRaw = $0.rawValue })
}
private var textSize: Binding<AppTextSize> {
Binding(get: { AppTextSize(rawValue: textSizeRaw) ?? .standard }, set: { textSizeRaw = $0.rawValue })
}
private var uiDensity: Binding<UIDensity> {
Binding(get: { UIDensity(rawValue: uiDensityRaw) ?? .standard }, set: { uiDensityRaw = $0.rawValue })
}
var body: some View {
Form {
Section(L10n.t("Farbschema", appLanguage)) {
Picker(L10n.t("Farbschema", appLanguage), selection: colorTheme) {
Text(L10n.t("Standard", appLanguage)).tag(ColorTheme.standard)
Text(L10n.t("Kontrastreich", appLanguage)).tag(ColorTheme.highContrast)
}
.pickerStyle(.radioGroup)
Text(L10n.t("Gilt für die Diagrammfarben in der Übersicht und die Status-Farben im LAN-Scanner (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port).", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
Section(L10n.t("Textgröße", appLanguage)) {
Picker(L10n.t("Textgröße", appLanguage), selection: textSize) {
Text(L10n.t("Klein", appLanguage)).tag(AppTextSize.small)
Text(L10n.t("Standard", appLanguage)).tag(AppTextSize.standard)
Text(L10n.t("Groß", appLanguage)).tag(AppTextSize.large)
Text(L10n.t("Sehr groß", appLanguage)).tag(AppTextSize.extraLarge)
}
.pickerStyle(.segmented)
.labelsHidden()
}
Section(L10n.t("Bedienelemente", appLanguage)) {
Picker(L10n.t("Bedienelemente", appLanguage), selection: uiDensity) {
Text(L10n.t("Kompakt", appLanguage)).tag(UIDensity.compact)
Text(L10n.t("Standard", appLanguage)).tag(UIDensity.standard)
Text(L10n.t("Komfortabel", appLanguage)).tag(UIDensity.comfortable)
}
.pickerStyle(.segmented)
.labelsHidden()
Text(L10n.t("Größe von Buttons, Feldern und Abständen in der ganzen App (\"Responsiveness\").", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
}
private struct NetworkSettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.lanScannerPollIntervalSecondsKey) private var pollInterval: Double = 0.1
@AppStorage(AppPreferences.lanScannerSparklineWindowSecondsKey) private var sparklineWindow: Double = 30
@AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200
var body: some View {
Form {
Section(L10n.t("LAN-Scanner: Traffic-Anzeige", appLanguage)) {
Picker(L10n.t("Aktualisierungsrate", appLanguage), selection: $pollInterval) {
Text("0,1 s").tag(0.1)
Text("0,5 s").tag(0.5)
Text("1 s").tag(1.0)
Text("3 s").tag(3.0)
}
Picker(L10n.t("Sparkline-Zeitfenster", appLanguage), selection: $sparklineWindow) {
Text("10 s").tag(10.0)
Text("30 s").tag(30.0)
Text("60 s").tag(60.0)
}
Picker(L10n.t("Sparkline-Breite", appLanguage), selection: $sparklineWidth) {
Text("100 pt").tag(100.0)
Text("150 pt").tag(150.0)
Text("200 pt").tag(200.0)
Text("300 pt").tag(300.0)
}
Text(L10n.t("Höhere Aktualisierungsraten zeigen den Traffic-Verlauf feiner, kosten aber mehr CPU bei vielen gleichzeitig aktiven Ports.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
}
#Preview {
SettingsView()
}
@@ -0,0 +1,85 @@
import SwiftUI
/// Shown inline wherever a user is about to configure a physical port that
/// `ConnectionService.checkPortConflict(interfaceName:)` found already carrying other
/// configuration originally the Setup Wizard's LAN step only (found.md #1's "Port-Konflikt-
/// Prüfung"), now also the Experte tab's generic `.interfacePick` fields (per explicit request:
/// "die Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen").
/// Requires two separate confirmations before the app is allowed to clear it per explicit
/// request: this silently overriding a port's existing role (an active WAN dial-up, a bridge
/// membership, a manually-set address) previously wasn't visible to the user at all beyond
/// `DhcpServerCommandBuilder`'s own unconditional bridge detach; this makes the consequences
/// explicit and opt-in instead.
struct PortConflictWarningView: View {
let conflict: PortConflict
let isAcknowledged: Bool
let appLanguage: String
/// Wizard: resolution runs later, batched with everything else at "Jetzt anwenden" the
/// final confirmation dialog says so, and that a port switch above still undoes it. Experte
/// tab: there's no separate review/apply step, "Anlegen"/"Speichern" runs it immediately
/// a different final-confirmation message reflects that instead.
let immediateApply: Bool
let onConfirm: () -> Void
@State private var showConsequencesConfirmation = false
@State private var showFinalConfirmation = false
var body: some View {
if isAcknowledged {
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
.appFont(.caption)
.foregroundStyle(.orange)
} else {
VStack(alignment: .leading, spacing: 6) {
Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill")
.appFont(.subheadline, bold: true)
.foregroundStyle(.red)
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
Text("\(reason.description)")
.appFont(.caption)
}
Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
showConsequencesConfirmation = true
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
.confirmationDialog(
L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("freimachen?", appLanguage),
isPresented: $showConsequencesConfirmation,
titleVisibility: .visible
) {
Button(L10n.t("Fortfahren", appLanguage), role: .destructive) {
showFinalConfirmation = true
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(consequenceText)
}
.confirmationDialog(
L10n.t("Wirklich sicher?", appLanguage),
isPresented: $showFinalConfirmation,
titleVisibility: .visible
) {
Button(L10n.t("Ja, endgültig freimachen", appLanguage), role: .destructive) {
onConfirm()
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(immediateApply
? L10n.t("Wird sofort ausgeführt, sobald du unten auf \"Anlegen\"/\"Speichern\" klickst.", appLanguage)
: 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 {
([L10n.t("Folgendes wird entfernt, bevor", appLanguage) + " \(conflict.interfaceName) " + L10n.t("als neues Netzwerk eingerichtet wird:", appLanguage)]
+ conflict.reasons.map { "\($0.description)" }
+ [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")
}
}
@@ -63,6 +63,7 @@ struct ConnectView: View {
onDelete: { viewModel.removeSavedRouter(router.id) }
)
.padding(.vertical, 6)
.background(TableZebra.color(for: index))
}
}
}
@@ -106,6 +107,7 @@ struct ConnectView: View {
statusSection
}
.formStyle(.grouped)
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabConnect) } }
.frame(minWidth: 320)
} detail: {
deviceDetail
@@ -132,7 +134,9 @@ struct ConnectView: View {
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
viewModel.trustAndRetry(fingerprint: fingerprint)
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {
viewModel.cancelCertificatePrompt()
}
} message: { fingerprint in
Text(L10n.t("Der Router hat sich mit einem unbekannten Zertifikat gemeldet.", appLanguage)
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
@@ -175,10 +179,19 @@ struct ConnectView: View {
return nil
}
/// "Versuch 3 · nächster in 4s" / "Versuch 3 " (while an attempt is actually in
/// flight, between the countdown reaching 0 and the next REST/SSH connect call
/// resolving) one line, so the reconnect banner stays compact.
private var reconnectStatusLine: String {
let attempt = L10n.t("Versuch", appLanguage) + " \(connectionService.reconnectAttemptCount)"
guard let seconds = connectionService.secondsUntilNextReconnectAttempt else { return attempt + "" }
return attempt + " · " + L10n.t("nächster in", appLanguage) + " \(seconds)s"
}
private var certificateAlertBinding: Binding<Bool> {
Binding(
get: { certificateFingerprint != nil },
set: { _ in }
set: { isPresented in if !isPresented { viewModel.cancelCertificatePrompt() } }
)
}
@@ -192,7 +205,7 @@ struct ConnectView: View {
private var sshHostKeyAlertBinding: Binding<Bool> {
Binding(
get: { sshHostKeyFingerprint != nil },
set: { _ in }
set: { isPresented in if !isPresented { viewModel.dismissSSHHostKeyPrompt() } }
)
}
@@ -240,7 +253,7 @@ struct ConnectView: View {
L10n.t("Zuletzt gesichert:", appLanguage) + " \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))",
systemImage: "checkmark"
)
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
@@ -256,6 +269,21 @@ struct ConnectView: View {
private var deviceDetail: some View {
if let info = connectionService.deviceInfo {
Form {
if connectionService.isReconnecting {
Section {
VStack(alignment: .leading, spacing: 4) {
Label {
Text(L10n.t("Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…", appLanguage))
} icon: {
ProgressView().controlSize(.small)
}
Text(reconnectStatusLine)
.appFont(.caption)
.foregroundStyle(.secondary)
}
.foregroundStyle(.orange)
}
}
Section(L10n.t("Gerät", appLanguage)) {
LabeledContent(L10n.t("Modell", appLanguage), value: info.boardName)
LabeledContent(L10n.t("RouterOS-Version", appLanguage), value: info.routerOSVersion)
@@ -290,11 +318,11 @@ struct ConnectView: View {
.disabled(viewModel.isUpgradingFirmware)
} else {
Label(L10n.t("Firmware aktuell", appLanguage), systemImage: "checkmark.circle")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
if let message = viewModel.firmwareUpgradeResultMessage {
Text(message).font(.caption).foregroundStyle(.secondary)
Text(message).appFont(.caption).foregroundStyle(.secondary)
Button {
showRebootConfirmation = true
} label: {
@@ -347,11 +375,11 @@ struct ConnectView: View {
L10n.t("Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.", appLanguage),
systemImage: "arrow.clockwise"
)
.font(.caption)
.appFont(.caption)
}
}
Section("Interfaces") {
ForEach(connectionService.interfaces) { interface in
ForEach(Array(connectionService.interfaces.enumerated()), id: \.element.id) { index, interface in
HStack {
InterfaceActivityDot(
running: interface.running,
@@ -359,13 +387,16 @@ struct ConnectView: View {
)
VStack(alignment: .leading) {
Text(interface.name).bold()
Text(interface.type).font(.caption).foregroundStyle(.secondary)
Text(interface.type).appFont(.caption).foregroundStyle(.secondary)
}
}
.listRowBackground(TableZebra.color(for: index))
}
}
}
.formStyle(.grouped)
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabConnect) } }
.scrollContentBackground(.hidden)
} else {
ContentUnavailableView(
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
@@ -574,12 +605,12 @@ private struct SavedRouterRow: View {
Text(router.name).bold()
if !router.location.isEmpty {
Label(router.location, systemImage: "mappin.and.ellipse")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
Text("\(router.username)@\(router.host)")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
// Nutzerwunsch (2026-09-16): Seriennummer mit hinterlegen, damit zwei
// physisch unterschiedliche Router mit identischem Host+Benutzername (z.B.
@@ -588,7 +619,7 @@ private struct SavedRouterRow: View {
// SavedRoutersStore.recordSuccessfulConnection.
if let serialNumber = router.serialNumber {
Text("SN: \(serialNumber)")
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.tertiary)
}
}
@@ -88,6 +88,12 @@ final class ConnectViewModel: ObservableObject {
savedRouters = savedRoutersStore.recordSuccessfulConnection(
host: host, username: username, defaultName: defaultName, serialNumber: serialNumber
)
// Settings toggle (default off) reuses the same manual "Nach Updates suchen"
// path the button in this tab triggers, just fired automatically once a
// connection succeeds instead of waiting for a click.
if UserDefaults.standard.bool(forKey: AppPreferences.autoCheckUpdatesOnConnectKey) {
checkForUpdates(for: credentials)
}
}
}
}
@@ -139,8 +145,21 @@ final class ConnectViewModel: ObservableObject {
}
}
/// Backs out of whichever of the two trust-confirmation states is currently showing the
/// SSH-host-key alert (a blocked initial connect, or the background trust probe after a
/// successful REST connect see `ConnectionService.pendingSSHTrustFingerprint`'s doc
/// comment). Previously only cleared `pendingSSHTrustFingerprint`, which left the alert stuck
/// showing whenever it was triggered by the *other* path (`state ==
/// .needsSSHHostKeyConfirmation`) found during the README milestone re-check, 2026-09-17.
func dismissSSHHostKeyPrompt() {
connectionService.dismissPendingSSHTrust()
connectionService.cancelPendingTrustConfirmation()
}
/// Same "Abbrechen" fix as `dismissSSHHostKeyPrompt()`, for the unknown-TLS-certificate alert
/// its "Abbrechen" button previously had an empty action and could never actually clear
/// `connectionService.state` back out of `.needsCertificateConfirmation`.
func cancelCertificatePrompt() {
connectionService.cancelPendingTrustConfirmation()
}
func checkForUpdates(for credentials: RouterOSCredentials) {
@@ -20,7 +20,7 @@ struct FirewallStepView: View {
Text(L10n.t("Firewall-Grundschutz ist im einfachen Modus immer aktiv.", appLanguage))
}
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)
.appFont(.caption)
.foregroundStyle(.secondary)
}
@@ -33,11 +33,11 @@ struct FirewallStepView: View {
LabeledContent(L10n.t("NAT-Regeln", appLanguage), value: "\(counts.natRuleCount)")
if counts.filterRuleCount > 0 || counts.natRuleCount > 0 {
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)
.appFont(.caption)
.foregroundStyle(.orange)
}
} else if let error = viewModel.firewallRuleCountsError {
Text(error).font(.caption).foregroundStyle(.red)
Text(error).appFont(.caption).foregroundStyle(.red)
}
}
}
@@ -46,7 +46,7 @@ struct FirewallStepView: View {
Section(L10n.t("Isolierte Netzwerke", appLanguage)) {
Text(viewModel.isolatedNetworkNames.joined(separator: ", "))
Text(L10n.t("Diese Netzwerke werden vom Datenverkehr aller anderen konfigurierten Netzwerke abgeschottet, sobald der Firewall-Grundschutz aktiv ist.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
@@ -60,7 +60,8 @@ struct FirewallStepView: View {
}
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Firewall (optional)", appLanguage)))
.navigationTitle(LocalizedStringKey(L10n.t(viewModel.mode == .expert ? "Firewall (optional)" : "Firewall", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepFirewall) } }
.onAppear {
if viewModel.firewallSectionEnabled {
viewModel.loadExistingFirewallRuleCounts()
@@ -25,13 +25,14 @@ struct LanStepView: View {
if viewModel.isCheckingPortConflict.contains(config.id) {
HStack {
ProgressView().controlSize(.small)
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).font(.caption).foregroundStyle(.secondary)
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
}
} else if let conflict = viewModel.lanPortConflicts[config.id] {
PortConflictWarningView(
conflict: conflict,
isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id),
appLanguage: appLanguage,
immediateApply: false,
onConfirm: { viewModel.acknowledgePortConflict(for: config.id) }
)
}
@@ -85,6 +86,7 @@ struct LanStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Heimnetzwerk einrichten", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepLAN) } }
}
private var isStepValid: Bool {
@@ -99,75 +101,5 @@ struct LanStepView: View {
}
}
/// Shown inline under a LAN config's port Picker once `SetupViewModel.checkPortConflict(for:)`
/// finds the chosen port already carries other configuration. Requires two separate confirmations
/// before the app is allowed to clear it per explicit request: this silently overriding a port's
/// existing role (an active WAN dial-up, a bridge membership, a manually-set address) previously
/// wasn't visible to the user at all beyond `DhcpServerCommandBuilder`'s own unconditional bridge
/// detach; this makes the consequences explicit and opt-in instead.
private struct PortConflictWarningView: View {
let conflict: PortConflict
let isAcknowledged: Bool
let appLanguage: String
let onConfirm: () -> Void
@State private var showConsequencesConfirmation = false
@State private var showFinalConfirmation = false
var body: some View {
if isAcknowledged {
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
.font(.caption)
.foregroundStyle(.orange)
} else {
VStack(alignment: .leading, spacing: 6) {
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(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(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
showConsequencesConfirmation = true
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
.confirmationDialog(
L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("freimachen?", appLanguage),
isPresented: $showConsequencesConfirmation,
titleVisibility: .visible
) {
Button(L10n.t("Fortfahren", appLanguage), role: .destructive) {
showFinalConfirmation = true
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(consequenceText)
}
.confirmationDialog(
L10n.t("Wirklich sicher?", appLanguage),
isPresented: $showFinalConfirmation,
titleVisibility: .visible
) {
Button(L10n.t("Ja, endgültig freimachen", appLanguage), role: .destructive) {
onConfirm()
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
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 {
([L10n.t("Folgendes wird entfernt, bevor", appLanguage) + " \(conflict.interfaceName) " + L10n.t("als neues Netzwerk eingerichtet wird:", appLanguage)]
+ conflict.reasons.map { "\($0.description)" }
+ [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")
}
}
// `PortConflictWarningView` now lives in Features/Shared/ shared with the Experte tab's
// equivalent flow (see that file's doc comment for why).
@@ -19,7 +19,7 @@ struct ModeStepView: View {
case .simple:
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)
.appFont(.caption)
.foregroundStyle(.secondary)
case .expert:
Text(L10n.t("Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.", appLanguage))
@@ -10,29 +10,31 @@ struct ReviewApplyView: View {
var body: some View {
Form {
Section(L10n.t("Geplante Änderungen", appLanguage)) {
ForEach(viewModel.plannedCommands) { command in
ForEach(Array(viewModel.plannedCommands.enumerated()), id: \.element.id) { index, command in
VStack(alignment: .leading, spacing: 2) {
Text(command.summary)
if showCliDetails {
Text(command.cliLine)
.font(.system(.caption, design: .monospaced))
.appFont(.caption, design: .monospaced)
.foregroundStyle(.secondary)
}
}
.listRowBackground(TableZebra.color(for: index))
}
Toggle(L10n.t("Details anzeigen (RouterOS-Befehle)", appLanguage), isOn: $showCliDetails)
}
Section {
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)
.appFont(.caption)
.foregroundStyle(.secondary)
}
if !viewModel.applyLog.isEmpty {
Section(L10n.t("Ablauf", appLanguage)) {
ForEach(viewModel.applyLog, id: \.self) { line in
Text(line).font(.caption)
ForEach(Array(viewModel.applyLog.enumerated()), id: \.offset) { index, line in
Text(line).appFont(.caption)
.listRowBackground(TableZebra.color(for: index))
}
}
}
@@ -70,15 +72,17 @@ struct ReviewApplyView: View {
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht & Anwenden", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepReview) } }
.alert(
L10n.t("Anwenden fehlgeschlagen", appLanguage),
isPresented: Binding(
get: { viewModel.applyError != nil },
set: { _ in }
set: { isPresented in if !isPresented { viewModel.dismissApplyError() } }
)
) {
Button(L10n.t("OK", appLanguage)) {}
Button(L10n.t("OK", appLanguage)) { viewModel.dismissApplyError() }
} message: {
Text(viewModel.applyError ?? "")
}
@@ -73,8 +73,12 @@ final class SetupViewModel: ObservableObject {
/// All configured LAN + VLAN networks, for the firewall isolation rules.
private var networkSegments: [FirewallConfig.NetworkSegment] {
lanConfigs.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
+ vlans.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
lanConfigs.map {
FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated, networkAddress: $0.networkAddress)
}
+ vlans.map {
FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated, networkAddress: $0.networkAddress)
}
}
/// Interface names of networks marked isolated, for display on the Firewall step.
@@ -134,8 +138,15 @@ final class SetupViewModel: ObservableObject {
lanPortConflicts[id] = nil
acknowledgedPortConflicts.remove(id)
isCheckingPortConflict.remove(id)
portConflictRequestGeneration[id] = nil
}
/// Bumped on every `checkPortConflict(for:)` call for a given config, so a slower, older
/// in-flight check can recognize it's been superseded and discard its own result instead of
/// racing the newer one see that function's doc comment for the concrete failure this
/// prevents (bugs.md #1, 2026-09-17).
private var portConflictRequestGeneration: [LanDhcpConfig.ID: Int] = [:]
/// Live-checks whether the port currently picked for this LAN config already carries other
/// configuration (bridge membership, an existing address, WAN dial-up) called when the LAN
/// step appears and whenever its interface Picker selection changes. Any prior acknowledgement
@@ -143,14 +154,24 @@ final class SetupViewModel: ObservableObject {
/// port says nothing about the newly picked one. Best-effort a failed check (e.g. transient
/// connection hiccup) must not block the wizard; the user simply doesn't get the extra warning
/// for that attempt, same as before this feature existed.
///
/// Two call sites (`.onAppear` and `.onChange(of: interfaceName)`) can fire in quick
/// succession for the same `configID` while a previous check is still in flight without the
/// generation guard below, a slower-but-older response could land after a faster-but-newer one
/// and overwrite it with a stale port's result (bugs.md #1).
func checkPortConflict(for configID: LanDhcpConfig.ID) {
guard let config = lanConfigs.first(where: { $0.id == configID }) else { return }
let interfaceName = config.interfaceName
acknowledgedPortConflicts.remove(configID)
lanPortConflicts[configID] = nil
isCheckingPortConflict.insert(configID)
let generation = (portConflictRequestGeneration[configID] ?? 0) + 1
portConflictRequestGeneration[configID] = generation
Task {
defer { isCheckingPortConflict.remove(configID) }
lanPortConflicts[configID] = try? await connectionService.checkPortConflict(interfaceName: config.interfaceName)
let result = try? await connectionService.checkPortConflict(interfaceName: interfaceName)
guard portConflictRequestGeneration[configID] == generation else { return }
lanPortConflicts[configID] = result
isCheckingPortConflict.remove(configID)
}
}
@@ -247,6 +268,17 @@ final class SetupViewModel: ObservableObject {
setFirewallSectionEnabled(true)
}
/// Dismisses the "Anwenden fehlgeschlagen" alert `ReviewApplyView`'s `.alert(isPresented:)`
/// binding derives its presented state from `applyError != nil`, so without this the alert's
/// own OK button (whose action previously did nothing) could never actually clear the error:
/// SwiftUI's next re-render would immediately re-evaluate the getter as still true and the
/// alert would reappear or fail to dismiss. Found during the README milestone re-check,
/// 2026-09-17 (bugs.md-style fix see `SetupViewModel`'s port-conflict entry in bugs.md for
/// the same-feature-area race-condition fix earlier this session).
func dismissApplyError() {
applyError = nil
}
/// Ends the wizard after a successful apply without this, the completed review screen just
/// sits there with a disabled "Jetzt anwenden" and no way forward except "Zurück" (which would
/// re-walk now-stale steps against the router state this apply just changed). Resets to a
@@ -282,6 +314,7 @@ final class SetupViewModel: ObservableObject {
lanPortConflicts = [:]
isCheckingPortConflict = []
acknowledgedPortConflicts = []
portConflictRequestGeneration = [:]
applyLog = []
applyError = nil
didApplySuccessfully = false
@@ -316,6 +349,8 @@ final class SetupViewModel: ObservableObject {
didApplySuccessfully = false
Task {
connectionService.beginWrite()
defer { connectionService.endWrite() }
do {
applyLog.append("Sichere aktuelle Konfiguration…")
_ = try await backupService.createBackup(for: credentials)
@@ -341,6 +376,10 @@ final class SetupViewModel: ObservableObject {
try await applyIdempotently(command)
}
if firewallSectionEnabled {
await flushIsolatedNetworkConnections()
}
didApplySuccessfully = true
} catch {
applyError = error.localizedDescription
@@ -349,6 +388,28 @@ final class SetupViewModel: ObservableObject {
}
}
/// Ends already-open connections between networks that just became isolated (bugs.md #7 /
/// Gitea #19) without this, the new "forward drop" rules just added only block *new*
/// connections; anything already flowing between the two networks at apply-time would
/// otherwise keep going through the existing "forward established/related -> accept" rule
/// until it ends on its own. Runs after the firewall commands themselves so a connection that
/// re-establishes right after being flushed immediately hits the new drop rule.
///
/// Best-effort and silent on failure (`try?`), same as the rest of this best-effort area
/// (`checkPortConflict`): a flush that fails (e.g. a transient SSH hiccup) must not fail the
/// whole apply the isolation rules themselves are already in place either way, this only
/// affects whether a pre-existing connection gets cut immediately or lingers until it times
/// out on its own. A pair where either side has no network address yet (e.g. left blank) is
/// skipped there's no address range to match connections against.
private func flushIsolatedNetworkConnections() async {
let pairs = FirewallConfig(wanInterface: wanConfig.interfaceName, networks: networkSegments).isolatedNetworkPairs
for (network, other) in pairs {
guard !network.networkAddress.isEmpty, !other.networkAddress.isEmpty else { continue }
applyLog.append("Bestehende Verbindungen zwischen \"\(network.interfaceName)\" und \"\(other.interfaceName)\" trennen…")
_ = try? await connectionService.flushConnections(between: network.networkAddress, and: other.networkAddress)
}
}
/// Menus where a duplicate `.add` should instead reconfigure the existing entry in place
/// re-running any wizard step against an already-configured router hits this on every menu
/// that enforces uniqueness (dhcp-client/pppoe-client: one per interface, live-confirmed
@@ -17,7 +17,7 @@ struct VlanStepView: View {
)
.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)
.appFont(.caption)
.foregroundStyle(.secondary)
}
@@ -68,6 +68,7 @@ struct VlanStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Zusätzliche Netzwerke (VLAN)", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepVLAN) } }
}
/// Only gates on filled-in fields while the VLAN section is actually enabled with it off,
@@ -44,7 +44,7 @@ struct WanStepView: View {
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)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
@@ -60,6 +60,7 @@ struct WanStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Internet einrichten", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepWAN) } }
}
private var isStepValid: Bool {
@@ -20,7 +20,7 @@ struct WifiStepView: View {
+ "\(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)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
@@ -49,6 +49,7 @@ struct WifiStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("WLAN (falls vorhanden)", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepWifi) } }
}
private var isStepValid: Bool {
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.2.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>3</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppTransportSecurity</key>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -70,6 +70,25 @@ final class ConnectionServiceTests: XCTestCase {
XCTAssertEqual(service.state, .needsCertificateConfirmation(fingerprint: "AA:BB"))
}
/// Regression test for the README milestone re-check, 2026-09-17: `ConnectView`'s
/// "Abbrechen" button on the certificate-trust alert previously had an empty action and could
/// never actually clear `state` back out of `.needsCertificateConfirmation`, so the alert
/// (whose `isPresented` binding is derived from `state`) could never really be dismissed.
func testCancelPendingTrustConfirmationResetsCertificateConfirmationToIdle() async {
let service = ConnectionService()
await service.connect(
with: credentials,
makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.untrustedCertificate(fingerprint: "AA:BB")) },
makeSSHTransport: { MockTransport(kind: .ssh) }
)
XCTAssertEqual(service.state, .needsCertificateConfirmation(fingerprint: "AA:BB"))
service.cancelPendingTrustConfirmation()
XCTAssertEqual(service.state, .idle)
}
func testFailsWhenBothTransportsFail() async {
let service = ConnectionService()
@@ -14,6 +14,29 @@ final class ExpertViewModelTests: XCTestCase {
)
}
func testNonClearableFieldNeverSendsExplicitEmptyValue() {
let schema = RouterOSMenuSchema(
menuPath: "/system routerboard mode-button", restPath: "system/routerboard/mode-button", category: .system,
displayName: "Test", summary: "", explanation: "",
fields: [
RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool, help: "", defaultValue: "no"),
RouterOSFieldSchema(key: "hold-time", label: "Haltedauer", kind: .text, help: "", clearable: false)
],
isSingleton: true
)
let viewModel = ExpertViewModel(connectionService: ConnectionService())
viewModel.selectedSchema = schema
// A previously non-empty "hold-time" that the user's form now shows blank (whatever the
// cause) must still never be sent as an explicit clear only this field's `clearable:
// false` should suppress it; "enabled" (clearable, unaffected) still sends normally.
viewModel.startEditing(RouterOSMenuItem(id: "singleton", fields: ["enabled": "no", "hold-time": "3s..5s"]))
viewModel.formValues["enabled"] = "yes"
viewModel.formValues["hold-time"] = ""
XCTAssertNil(viewModel.pendingCommand?.arguments["hold-time"])
XCTAssertEqual(viewModel.pendingCommand?.arguments["enabled"], "yes")
}
func testClearingACuratedFieldSendsExplicitEmptyValue() {
let viewModel = ExpertViewModel(connectionService: ConnectionService())
viewModel.selectedSchema = makeSchema()
@@ -54,4 +77,58 @@ final class ExpertViewModelTests: XCTestCase {
XCTAssertNil(viewModel.pendingCommand?.arguments["distance"])
XCTAssertEqual(viewModel.pendingCommand?.arguments["comment"], "bridge")
}
/// Regression tests for the Experte-tab port-conflict check (per explicit request: "die
/// Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen"). No live
/// connection is set up here, so `checkInterfacePortConflict()`'s actual network call always
/// fails/is skipped these only cover the surrounding logic that doesn't need one: schemas
/// without an `.interfacePick` field are correctly ignored, and the unresolved-conflict gate
/// starts clear.
private func makeSchemaWithInterfaceField() -> RouterOSMenuSchema {
RouterOSMenuSchema(
menuPath: "/ip address", restPath: "ip/address", category: .ipAddressing,
displayName: "Test", summary: "", explanation: "",
fields: [
RouterOSFieldSchema(key: "address", label: "Adresse", kind: .text, help: ""),
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick, help: "")
]
)
}
func testCheckInterfacePortConflictNoOpsForSchemaWithoutInterfaceField() {
let viewModel = ExpertViewModel(connectionService: ConnectionService())
viewModel.selectedSchema = makeSchema() // no .interfacePick field
viewModel.startNewItem()
viewModel.checkInterfacePortConflict()
XCTAssertFalse(viewModel.isCheckingInterfacePortConflict)
XCTAssertNil(viewModel.interfacePortConflict)
XCTAssertFalse(viewModel.hasUnresolvedInterfacePortConflict)
}
func testCheckInterfacePortConflictNoOpsWhenInterfaceFieldIsEmpty() {
let viewModel = ExpertViewModel(connectionService: ConnectionService())
viewModel.selectedSchema = makeSchemaWithInterfaceField()
viewModel.startNewItem() // "interface" has no default, starts empty
viewModel.checkInterfacePortConflict()
XCTAssertFalse(viewModel.isCheckingInterfacePortConflict)
XCTAssertNil(viewModel.interfacePortConflict)
}
func testStartingOrCancelingEditResetsAcknowledgedConflictState() {
let viewModel = ExpertViewModel(connectionService: ConnectionService())
viewModel.selectedSchema = makeSchemaWithInterfaceField()
viewModel.startNewItem()
viewModel.acknowledgeInterfacePortConflict()
XCTAssertFalse(viewModel.hasUnresolvedInterfacePortConflict, "acknowledging clears the block even with no conflict object set")
viewModel.cancelEditing()
viewModel.startNewItem()
// A fresh edit must not inherit a stale acknowledgement from a previous one.
XCTAssertNil(viewModel.interfacePortConflict)
}
}
@@ -81,4 +81,25 @@ final class FirewallConfigTests: XCTestCase {
XCTAssertEqual(config.buildCommands().count, 8)
}
/// Regression test for bugs.md #7 / Gitea #19: `isolatedNetworkPairs` is the shared source of
/// truth `SetupViewModel.flushIsolatedNetworkConnections()` uses to know which network pairs
/// to flush already-open connections between must report the same pairs
/// `buildIsolationCommands` derives its drop rules from, including the network's address
/// range (not used by the drop rules themselves, only by the connection flush).
func testIsolatedNetworkPairsCarriesAddressesForConnectionFlush() {
let config = FirewallConfig(
wanInterface: "ether1",
networks: [
FirewallConfig.NetworkSegment(interfaceName: "bridge", isolated: true, networkAddress: "192.168.88.0/24"),
FirewallConfig.NetworkSegment(interfaceName: "vlan20", isolated: false, networkAddress: "192.168.20.0/24")
]
)
let pairs = config.isolatedNetworkPairs
XCTAssertEqual(pairs.count, 1)
XCTAssertEqual(Set([pairs[0].0.interfaceName, pairs[0].1.interfaceName]), ["bridge", "vlan20"])
XCTAssertEqual(Set([pairs[0].0.networkAddress, pairs[0].1.networkAddress]), ["192.168.88.0/24", "192.168.20.0/24"])
}
}
@@ -209,4 +209,33 @@ final class OverviewGraphTests: XCTestCase {
XCTAssertEqual(graph.highlightedNodeIDs(startingAt: poolID), [poolID])
XCTAssertEqual(graph.highlightedNodeIDs(startingAt: addressID), [addressID])
}
/// `connectedChain` backs the Übersicht "Fokus-Modus" and, unlike `highlightedNodeIDs`, always
/// walks the full transitive closure regardless of node category starting from the pool
/// (two hops from the interface) should still reach every other node in the same graph used
/// above.
func testConnectedChainExpandsTransitivelyForEveryNodeKind() {
let graph = OverviewViewModel.buildGraph(
interfaces: [item(["name": "ether1", "type": "ether"])],
bridgePorts: [], vlans: [], wireguardPeers: [],
addresses: [item(["address": "192.168.88.1/24", "interface": "ether1", "network": "192.168.88.0"])],
pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"])],
dhcpServers: [item(["name": "dhcp1", "interface": "ether1", "address-pool": "pool1"])],
dhcpNetworks: [item(["address": "192.168.88.0/24", "gateway": "192.168.88.1"])],
dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: []
)
let interfaceID = "Interfaces:ether1"
let addressID = "IP-Adressen:192.168.88.1/24"
let dhcpServerID = "Pools & DHCP:dhcp:dhcp1"
let poolID = "Pools & DHCP:pool:pool1"
let dhcpNetworkID = "Pools & DHCP:dhcpnet:192.168.88.0/24"
let fullChain: Set<String> = [interfaceID, addressID, dhcpServerID, poolID, dhcpNetworkID]
XCTAssertEqual(graph.connectedChain(startingAt: poolID), fullChain)
XCTAssertEqual(graph.connectedChain(startingAt: dhcpNetworkID), fullChain)
XCTAssertEqual(graph.connectedChain(startingAt: interfaceID), fullChain)
XCTAssertEqual(graph.connectedChain(startingAt: "does-not-exist"), [])
}
}
@@ -29,4 +29,26 @@ final class PortConflictTests: XCTestCase {
XCTAssertEqual(commands[1].menuPath, "/interface pppoe-client")
XCTAssertEqual(commands[1].operation, .remove(matchField: "interface", matchValue: "ether1"))
}
/// Regression test for the Experte-tab "Port freimachen?" flow (per explicit request): unlike
/// the Setup Wizard, the Experte tab has no separate step that unconditionally detaches a
/// bridge port on its own, so its resolution list must include that removal explicitly
/// `resolutionCommands()` alone (the wizard's version) deliberately leaves it out.
func testResolutionCommandsIncludingBridgeDetachAddsBridgePortRemoval() {
let conflict = PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge")])
let commands = conflict.resolutionCommandsIncludingBridgeDetach()
XCTAssertEqual(commands.count, 1)
XCTAssertEqual(commands[0].menuPath, "/interface bridge port")
XCTAssertEqual(commands[0].operation, .remove(matchField: "interface", matchValue: "ether4"))
}
func testResolutionCommandsIncludingBridgeDetachKeepsOtherReasonsToo() {
let conflict = PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge"), .dhcpClient])
let commands = conflict.resolutionCommandsIncludingBridgeDetach()
XCTAssertEqual(commands.count, 2)
XCTAssertEqual(commands[0].menuPath, "/interface bridge port")
XCTAssertEqual(commands[1].menuPath, "/ip dhcp-client")
}
}
@@ -85,4 +85,22 @@ final class RouterOSCliParserTests: XCTestCase {
XCTAssertEqual(interfaces.map(\.name), ["ether1", "ether2", "bridge", "lo"])
XCTAssertEqual(interfaces.map(\.type), ["ether", "ether", "bridge", "loopback"])
}
/// Regression test for bugs.md (2026-09-17, "erneuter Gegencheck"-Durchgang): RouterOS'
/// `print terse` does not quote multi-word values at all (live-confirmed, RouterOS 7.24.4,
/// two menus) a comment "multi word test value" comes back as literal unquoted
/// `comment=multi word test value name=ether2 ...`. The old regex parser silently truncated
/// this to just "multi" and dropped "word test value" entirely; `keyValues` now token-scans
/// instead, only starting a new field on a token that itself looks like `key=...`.
func testParseGenericItemsPreservesUnquotedMultiWordValue() {
let raw = "1 RS comment=multi word test value name=ether2 default-name=ether2 mtu=1500"
let items = RouterOSCliParser.parseGenericItems(raw)
XCTAssertEqual(items.count, 1)
XCTAssertEqual(items[0].fields["comment"], "multi word test value")
XCTAssertEqual(items[0].fields["name"], "ether2")
XCTAssertEqual(items[0].fields["default-name"], "ether2")
XCTAssertEqual(items[0].fields["mtu"], "1500")
}
}
@@ -85,7 +85,7 @@ final class RouterOSCommandBuilderTests: XCTestCase {
summary: "test"
)
XCTAssertEqual(command.cliLine, "/ip route set [find .id=*1] comment=\"\"")
XCTAssertEqual(command.cliLine, "/ip route set [find .id=\"*1\"] comment=\"\"")
}
func testCliLineRendersSortedQuotedArgumentsForAdd() {
@@ -96,7 +96,7 @@ final class RouterOSCommandBuilderTests: XCTestCase {
summary: "test"
)
XCTAssertEqual(command.cliLine, "/interface pppoe-client add password=\"a secret\" user=user@isp")
XCTAssertEqual(command.cliLine, "/interface pppoe-client add password=\"a secret\" user=\"user@isp\"")
}
func testCliLineRendersFindLookupForSet() {
@@ -109,7 +109,7 @@ final class RouterOSCommandBuilderTests: XCTestCase {
summary: "test"
)
XCTAssertEqual(command.cliLine, "/interface wireless set [find name=wlan1] ssid=Home")
XCTAssertEqual(command.cliLine, "/interface wireless set [find name=\"wlan1\"] ssid=\"Home\"")
}
func testCliLineRendersFindLookupForAction() {
@@ -122,6 +122,25 @@ final class RouterOSCommandBuilderTests: XCTestCase {
summary: "test"
)
XCTAssertEqual(command.cliLine, "/ip dhcp-server lease make-static [find .id=*7]")
XCTAssertEqual(command.cliLine, "/ip dhcp-server lease make-static [find .id=\"*7\"]")
}
/// Regression test for bugs.md #1 (2026-09-17, "erneuter Gegencheck"-Durchgang): a free-text
/// value containing an embedded `"` followed by `;` used to close the CLI argument's quoting
/// early and let RouterOS' console treat the rest as a second, independent command
/// live-confirmed exploitable (`:log warning "..."` executed as its own command via a comment
/// field) and live-confirmed fixed by escaping `\`/`"` and always quoting.
func testCliLineEscapesEmbeddedQuotesPreventingCommandInjection() {
let command = RouterOSCommand.add(
menuPath: "/ip firewall address-list",
restPath: "ip/firewall/address-list",
arguments: ["comment": "test\" ; :log warning \"INJECTED"],
summary: "test"
)
XCTAssertEqual(
command.cliLine,
"/ip firewall address-list add comment=\"test\\\" ; :log warning \\\"INJECTED\""
)
}
}
@@ -0,0 +1,45 @@
import XCTest
@testable import RouterOSAssistant
/// Regression tests for bugs.md #11 (2026-09-17): creating an `/ip pool` entry in the Experte tab
/// hung the app permanently, traced to `SSHTransport.connect()`/`run(_:)` having no timeout at
/// all a stalled connection attempt or command execution had no way to ever resolve. These
/// tests exercise the generic race mechanism (`SSHTransport.withTimeout`) directly, independent
/// of Citadel/real network I/O, since that's the actual bug: the race logic itself, not anything
/// SSH-specific.
final class SSHTransportTimeoutTests: XCTestCase {
func testFastOperationReturnsItsResultBeforeTheDeadline() async throws {
let result = try await SSHTransport.withTimeout(.seconds(1)) {
"done"
}
XCTAssertEqual(result, "done")
}
func testHangingOperationThrowsAfterTheDeadlineInsteadOfBlockingForever() async {
let start = ContinuousClock.now
do {
_ = try await SSHTransport.withTimeout(.milliseconds(200)) {
try await Task.sleep(for: .seconds(60))
return "never reached"
}
XCTFail("Expected a timeout error")
} catch {
let elapsed = ContinuousClock.now - start
XCTAssertLessThan(elapsed, .seconds(5), "Timeout should fire close to the deadline, not wait for the full 60s operation")
}
}
func testOperationsOwnThrownErrorPropagatesUnchangedWhenItFinishesFirst() async {
struct SampleError: Error, Equatable {}
do {
_ = try await SSHTransport.withTimeout(.seconds(1)) {
throw SampleError()
}
XCTFail("Expected SampleError to propagate")
} catch is SampleError {
// expected
} catch {
XCTFail("Expected SampleError, got \(error)")
}
}
}
+1
View File
@@ -0,0 +1 @@
Hier werden meine Features / Funde aus der Windows-Version hinterlegt. Damit diese anschließend mit der MAC-Verison verglichen werden können.
+407
View File
@@ -0,0 +1,407 @@
# bugs.md
Ergebnis eines systematischen Tester-/Senior-Dev-Durchgangs (2026-09-17) gegen den echten
hAP lite Testrouter (192.168.88.1, RouterOS 7.24.4). Hinweis zur Methode: die App-UI selbst
konnte nicht geklickt werden (keine macOS-UI-Automatisierung verfügbar) — stattdessen
Code-Audit jedes Feature-Bereichs, Router-Ground-Truth per SSH gegenprüft (`/export terse`,
Live-Konfiguration), und wo möglich per Skript verifiziert statt geraten (siehe je Eintrag).
Router-Zustand zum Testzeitpunkt: nahezu Werkszustand, nur die WLAN-Testkonfiguration aus
einer früheren Session vorhanden.
**Status:** `offen` | `postponed` | `fixed`
---
## 2026-09-17
### 1. Race Condition bei Port-Konflikt-Prüfung im LAN-Schritt
**Status:** fixed (Build grün, noch nicht live gegenreproduziert)
**Gitea-Issue:** [#13](http://192.168.178.222:3500/kay/RouterOS/issues/13)
**Confidence:** hoch (Logikfehler direkt im Code nachvollzogen, nicht live reproduziert)
`SetupViewModel.checkPortConflict(for:)` (`SetupViewModel.swift:146`) liest `config.interfaceName`
synchron beim Aufruf und startet dann einen `Task`, der das Ergebnis nach dem `await` unbedingt in
`lanPortConflicts[configID]` schreibt — ohne Generation-Counter oder Abbruch des vorherigen `Task`.
Der Aufruf passiert an zwei Stellen für dieselbe `configID`: `.onAppear` (bei jedem Erscheinen des
LAN-Schritts) und `.onChange(of: config.interfaceName)` (bei jeder Port-Auswahl). Wechselt der
Nutzer den Port zügig zweimal hintereinander (oder wechselt ihn, bevor die `onAppear`-Prüfung des
vorherigen Ports fertig ist), können zwei `Task`s parallel laufen. Der zuerst gestartete, aber
zuletzt fertige Task überschreibt das Ergebnis des neueren mit dem Stand des alten Ports — "last
response wins" statt "last request wins".
**Konkretes Fehlerbild:** Nutzer wählt Port A (hat Konflikt, z.B. Bridge-Mitgliedschaft), App prüft
noch, Nutzer wechselt schnell zu Port B (frei). Kommt Port As Prüfergebnis später zurück als Port
Bs, zeigt die App fälschlich eine Konflikt-Warnung für den inzwischen ausgewählten, tatsächlich
freien Port B — oder, im umgekehrten Fall, verschluckt eine echte Warnung für einen Port, der
tatsächlich bereits belegt ist, sodass "Weiter" freigeschaltet wird, obwohl der gewählte Port beim
Anwenden unbemerkt vorhandene Konfiguration überschreibt.
**Fix:** Generation-Zähler `portConflictRequestGeneration: [LanDhcpConfig.ID: Int]` ergänzt, bei
jedem `checkPortConflict(for:)`-Aufruf hochgezählt; ein abgeschlossener `Task` schreibt sein
Ergebnis nur, wenn seine Generation beim Abschluss noch die aktuellste ist — ein überholter Task
verwirft sein Ergebnis stillschweigend statt es zu übernehmen.
### 2. "Firewall (optional)"-Titel widerspricht sich selbst im Einfach-Modus
**Status:** fixed (Build grün, noch nicht live gegenreproduziert)
**Gitea-Issue:** [#14](http://192.168.178.222:3500/kay/RouterOS/issues/14)
**Confidence:** hoch (direkt im Code sichtbar)
`FirewallStepView.swift:63`: `.navigationTitle(... "Firewall (optional)" ...)` ist fest, unabhängig
vom Modus. Im Einfach-Modus zeigt derselbe Screen aber den Text "Firewall-Grundschutz ist im
einfachen Modus immer aktiv." (Zeile 20) — der Titel behauptet "optional", der Inhalt sagt "immer
an". Für Experte-Modus stimmt der Titel (dort gibt es den Toggle). Kleiner, aber sofort sichtbarer
Text-Widerspruch für jeden, der im Einfach-Modus durch den Wizard geht.
**Fix:** Titel modusabhängig gemacht — `viewModel.mode == .expert ? "Firewall (optional)" : "Firewall"`, neuer Key `"Firewall"` in `L10n.swift` ergänzt.
### 3. Fehlende Englisch-Übersetzungen (sichtbar im EN-UI)
**Status:** fixed (skriptgeprüft: beide Keys jetzt in `L10n.swift`, verbleibende zwei fehlende
**Gitea-Issue:** [#15](http://192.168.178.222:3500/kay/RouterOS/issues/15)
Keys "OK"/"Revision" bewusst nicht ergänzt — identisch in beiden Sprachen, kein sichtbarer Effekt)
**Confidence:** hoch (skriptgeprüft: alle `L10n.t(...)`-Aufrufstellen gegen `L10n.swift`s
Übersetzungs-Dictionary abgeglichen — `L10n.t` fällt bei fehlendem Key auf den deutschen
Originaltext zurück, siehe `L10n.swift:12-15`)
Zwei Stellen zeigen im Sprachmodus Englisch weiterhin deutschen Text:
- `Features/Devices/DevicesView.swift:335``"Netzwerk-Test fehlgeschlagen"` (Fehlermeldungstitel)
- `Features/Overview/OverviewView.swift:350``"Fokus-Ansicht schließen"` (Tooltip auf dem
Schließen-Button der Fokus-Ansicht)
(Ein dritter fehlender Key, `"Revision"`, ist praktisch nicht sichtbar, da das Wort in beiden
Sprachen identisch ist — nicht extra gelistet.)
**Fix-Ansatz:** Beide Strings in `L10n.swift`s `translations`-Dictionary ergänzen (`"Netzwerk-Test
fehlgeschlagen": "Network test failed"`, `"Fokus-Ansicht schließen": "Close focus view"`).
### 4. Health-Check-Heartbeat ignoriert laufende Wizard-/Experte-Schreibvorgänge
**Status:** fixed (Build grün, noch nicht live gegenreproduziert)
**Gitea-Issue:** [#16](http://192.168.178.222:3500/kay/RouterOS/issues/16)
Fix: `ConnectionService.beginWrite()`/`endWrite()` (Zähler `activeWriteCount`) ergänzt,
`checkConnectionHealthAndReconnectIfNeeded()` prüft jetzt zusätzlich `activeWriteCount == 0`.
`SetupViewModel.apply()` und `ExpertViewModel.saveEditingItem()`/`confirmRemoval()` klammern ihren
Task-Body jetzt mit `beginWrite()`/`defer { endWrite() }`.
**Confidence:** mittel (Logiklücke im Code nachvollzogen; nicht live reproduziert — REST läuft über
unabhängige HTTP-Requests und dürfte robust sein, SSH-Exec-Kanäle sind laut SSH-Protokoll
grundsätzlich nebenläufig nutzbar, siehe `SSHTransport.swift`/`RestTransport.swift` — insofern kein
Daten-Korruptionsrisiko, aber eine echte Lücke in der Ablaufsteuerung)
`ConnectionService.checkConnectionHealthAndReconnectIfNeeded()` (`ConnectionService.swift:273`)
läuft alle 10 Sekunden (`healthCheckInterval`) und prüft nur `!isReconnecting` — nicht, ob gerade
ein `SetupViewModel.apply()` oder `ExpertViewModel.saveEditingItem()` in Arbeit ist. Der hAP-lite-
Testrouter ist sehr schwach (MIPS 24Kc, 1 Kern, 650MHz, im Test bereits 54% CPU-Last im Leerlauf
gemessen). Ein mehrere Sekunden dauernder Apply (z.B. Bonding, mehrere Firewall-Regeln) kann
zeitlich mit dem Heartbeat kollidieren; schlägt der Heartbeat unter Last mit Timeout fehl, während
der eigentliche Apply eigentlich noch normal durchläuft, zeigt die App fälschlich den
"Wiederverbinden…"-Banner an, obwohl gar keine echte Verbindungsunterbrechung vorliegt — und
`reconnectLoop` könnte im ungünstigsten Fall sogar `finishConnecting` mit einer neuen
Transport-Instanz auslösen, während der ursprüngliche Apply noch auf der alten weiterläuft.
**Fix-Ansatz:** Einfachster Schutz: `SetupViewModel.isApplying` / `ExpertViewModel.isApplying` (oder
ein neuer gemeinsamer "isWriting"-Zähler auf `ConnectionService`) zusätzlich zur Guard-Bedingung in
`checkConnectionHealthAndReconnectIfNeeded()` prüfen, damit der Heartbeat während eines aktiven
Schreibvorgangs aussetzt.
---
## 2026-09-17 (Nachtrag: "erneuter Gegencheck mit echtem Deep Dive")
Zweite, tiefere Testrunde auf expliziten Nutzerwunsch. Diesmal inkl. echter Exploit-Verifikation
gegen den Router (nicht nur Code-Lesen) — beide folgenden Funde live nachgewiesen und live
gegenverifiziert, dass der Fix greift, mit anschließendem Aufräumen der Testartefakte.
### 5. RouterOS-CLI-Injection über beliebige Textfelder (SSH-Transportpfad)
**Status:** fixed (live exploitiert UND live als behoben bestätigt, siehe unten)
**Gitea-Issue:** [#17](http://192.168.178.222:3500/kay/RouterOS/issues/17)
**Confidence:** sehr hoch — kein Verdacht, sondern reproduzierter Exploit gegen den echten Router
`RouterOSCommand.cliLine` (`RouterOSCommand.swift`) baute CLI-Zeilen für den SSH-Transport per
String-Interpolation. Die alte `quoteIfNeeded(_:)` quotete einen Wert nur, wenn er ein Leerzeichen
enthielt, und escapte darin enthaltene `"`-Zeichen nie. RouterOS' Konsole behandelt `;` als
Befehlstrenner (dieselbe Gefahr, die `SSHTransport.runDiagnosticCommand`s Doku-Kommentar für
eigene, aufruferseitig sanitisierte Eingaben bereits benennt) — ein Wert wie
`test" ; :log warning "INJECTED` (ein völlig plausibler freier Kommentar/SSID/Hostname) schloss
das Anführungszeichen vorzeitig und ließ den Rest als zweiten, unabhängigen RouterOS-Befehl laufen.
**Exploit live bestätigt** (Testrouter, aufgeräumt danach): Befehl
`/ip firewall address-list add list=injection-test address=10.10.10.10 comment="test" ; :log warning "INJECTED-VIA-COMMENT-FIELD"`
über SSH ausgeführt → Log zeigt `script,warning INJECTED-VIA-COMMENT-FIELD` als eigenständig
ausgeführten zweiten Befehl, ausgelöst rein durch ein Kommentarfeld.
Betroffen: jeder Text, der über den SSH-Transport in ein `RouterOSCommand` läuft — praktisch jedes
Feld im Setup-Wizard (SSID, Kommentare, DNS-Server, …) und jedes Freitextfeld im Experte-Tab
("Weitere Parameter"). Der REST-Pfad ist NICHT betroffen (JSON-Encoding, kein String-Interpolieren
— siehe `RestTransport.apply`). Auf dem aktuellen hAP-lite-Testrouter ist `www-ssl` deaktiviert
(`/ip service print` bestätigt: Zeile 9, `X` = disabled) — REST ist also gar nicht erreichbar, jede
Schreiboperation läuft aktuell über SSH. Der Bug war damit live aktiv, nicht nur theoretisch.
**Fix:** `quoteIfNeeded` quotet jetzt immer und escaped `\``\\`, `"``\"` innerhalb der
Anführungszeichen — live verifiziert, dass das RouterOS' eigene Escape-Syntax ist (`\"`/`\\`
rundeten korrekt durch `print detail`). Unconditionelles Quoting live als unproblematisch bestätigt
(auch für einwortige Werte und `yes`/`no`-Booleans anstandslos akzeptiert). Gleiche Lücke defensiv
auch in `SSHTransport.fetchFieldValues` geschlossen (aktuell nur mit hartkodiertem `"no"`
aufgerufen, aber generische `RouterOSTransport`-Methode). Exploit-PoC danach erneut gegen den
Router gefahren — kein injizierter Log-Eintrag mehr, Kommentar korrekt als reiner Text gespeichert.
Neuer Regressionstest `testCliLineEscapesEmbeddedQuotesPreventingCommandInjection`. Build + alle
101 Unit-Tests grün.
### 6. Genereller RouterOS-Antwort-Parser trunkiert mehrwortige Werte
**Status:** fixed (Ursache live nachgewiesen, Fix per Unit-Test abgesichert)
**Gitea-Issue:** [#18](http://192.168.178.222:3500/kay/RouterOS/issues/18)
**Confidence:** sehr hoch — live gegen zwei unabhängige Menüs nachgewiesen
`RouterOSCliParser.keyValues(from:)` nahm an, `print terse` quote mehrwortige Werte in
Anführungszeichen (Regex `("[^"]*"|\S+)`). Live-Test (zwei unabhängige Menüs, `/ip firewall
address-list` und `/interface ethernet`, RouterOS 7.24.4) zeigt: **RouterOS quotet dort gar
nichts** — ein Kommentar `"multi word test value"` kommt als literales, unquotiertes
`comment=multi word test value name=ether2 ...` zurück. Der alte Regex-Parser matchte dafür nur
`\S+` und schnitt den Wert beim ersten Leerzeichen ab — "word test value" ging komplett verloren,
ohne Fehler, ohne Warnung.
Betrifft praktisch jedes mehrwortige Freitextfeld, das über den generischen Parser gelesen wird —
vor allem das Experte-Tab-"Weitere Parameter (frei)"-Grid, das laut eigenem Doku-Kommentar "fast
alle" Menüs generisch parst. Der Bug war bereits indirekt im eigenen Testcode sichtbar: der
existierende Live-Dump-Test `testParseInterfacesMatchesLiveHexOutput` enthält exakt dieses Muster
(`last-link-up-time=2026-09-15 20:19:19`, ein echter Gerätedump) — nur fiel es nie auf, weil dieses
Feld von keinem kuratierten Schema gelesen wird.
**Fix:** `keyValues` von Regex-Matching auf Token-Scanning umgestellt — ein Leerzeichen-getrenntes
Wort startet nur dann ein neues Feld, wenn es selbst wie `key=...` aussieht; alles andere wird an
den Wert des zuletzt begonnenen Feldes angehängt. Bekannter Restfall (inhärent durch RouterOS'
mehrdeutiges unquotiertes Format, nicht clientseitig lösbar): ein Wert, der selbst ein
`wort=`-Muster enthält (z.B. ein Kommentar "config=broken"), wird fälschlich als neues Feld
gelesen — deutlich seltener als die vorherige 100%-Fehlerquote bei jedem mehrwortigen Wert. Neuer
Regressionstest `testParseGenericItemsPreservesUnquotedMultiWordValue`, abgeleitet vom echten
Live-Dump. Build + alle 101 Unit-Tests grün.
### 7. Isolation wirkte nicht rückwirkend auf bereits bestehende Verbindungen
**Status:** fixed (best-effort, Mechanismus teilweise live verifiziert — Details unten)
**Gitea-Issue:** [#19](http://192.168.178.222:3500/kay/RouterOS/issues/19)
**Confidence:** mittel — siehe ehrliche Verifikationslage unten
`FirewallConfig.buildCommands()` (`FirewallConfig.swift`) setzt die Regel "forward
established,related → accept" auf Position 4, die Isolations-Drop-Regeln erst ab Position
`filterCommands.count` (7+). Für eine neue Verbindung zwischen zwei isolierten Netzen ist das
korrekt (erstes Paket hat `connection-state=new`, trifft also nicht Regel 4, sondern die
Isolations-Regel weiter hinten). Für eine zum Zeitpunkt des Anwendens bereits **bestehende**
(im Conntrack getrackte) Verbindung zwischen zwei gerade erst als isoliert markierten Netzen
griff dagegen weiterhin Regel 4 zuerst — sie blieb offen, bis sie von selbst endete. Das ist
Standardverhalten jeder stateful/conntrack-basierten Firewall (RouterOS, iptables, pf, …), kein
App-spezifischer Fehler.
**Fix:** neue `SSHTransport.flushConnections(between:and:)` + `ConnectionService.flushConnections`
(dedizierte SSH-Verbindung, kein REST-Äquivalent für RouterOS' CIDR-Mitgliedschafts-Abfragesprache
vorhanden) — entfernt per `/ip firewall connection remove [find where (src-address in A) and
(dst-address in B)]` (beide Richtungen) bereits getrackte Verbindungen zwischen zwei gerade
isolierten Netzen, aufgerufen von `SetupViewModel.apply()` direkt nach den Firewall-Befehlen
selbst (damit eine sofort neu aufgebaute Verbindung schon auf die neue Drop-Regel trifft).
`FirewallConfig.NetworkSegment` um `networkAddress` (CIDR) erweitert, Paar-Logik in eine
wiederverwendbare `isolatedNetworkPairs`-Property extrahiert. `networkA`/`networkB` werden vor
der Interpolation ins Skript als reine CIDR-Notation validiert (dieselbe Vorsicht wie bei Fund
#5 — sonst neue Injection-Fläche).
**Ehrliche Verifikationslage:** `print count-only where src-address in <cidr>` ist live bestätigt,
korrekt nach Subnetz zu filtern (40 Treffer bei besetztem /24 vs. 0 bei leerem). Ein einfaches
`remove [find where dst-address=<exakte-IP>]` (Gleichheit, kein `in`) ist live bestätigt, einen
Eintrag wirklich zu löschen (Test-ICMP-Verbindung verschwand). Die kombinierte Form `remove [find
where (a in X) and (b in Y)]`, die der Fix tatsächlich nutzt, ließ sich NICHT sauber live
beweisen: ein erster Testlauf sah erfolgreich aus, erwies sich aber als Messfehler (ICMP-Conntrack
verfällt von selbst in wenigen Sekunden — der Mehrfach-SSH-Testablauf mit Verzögerung dazwischen
täuschte ein Löschen nur vor). Ein sauberer Nachtest an einer echten, weiterhin aktiven
TCP-Verbindung zeigte den Eintrag sofort wieder auftauchen — erwartbares Verhalten für Connection-
Tracking allgemein (kein RST, nächstes Paket einer aktiven Verbindung wird einfach neu getrackt),
kein Beweis, dass `remove` wirkungslos ist, aber eben auch kein Beweis, dass die neue
Isolations-Regel das neu getrackte Paket tatsächlich abfängt. Dafür bräuchte es zwei echte, getrennte
Testnetze mit echten Endgeräten, die hier nicht verfügbar sind. Die `remove [find where ... in
...]`-Technik selbst ist real und community-dokumentiert (MikroTik-Forum), nur nicht
Ende-zu-Ende gegen dieses konkrete App-Szenario bewiesen. Best-effort per Design (Fehler bricht
den Apply-Vorgang nicht ab) — ein echter Mehrnetz-Test ist der natürliche nächste Schritt.
Build + alle 103 Unit-Tests grün (1 neuer Regressionstest für die Paar-Logik).
Nicht automatisch gefixt: ein Fix würde bedeuten, beim Aktivieren der Isolation gezielt
`/ip firewall connection remove` für die betroffenen Netzpaare auszulösen — ein zusätzlicher,
potenziell überraschender Seiteneffekt (kappt aktive Verbindungen, die der Nutzer evtl. bewusst
offen hat), der über die reine Bugfix-Aufgabe dieses Durchgangs hinausgeht und eine bewusste
Produktentscheidung ist, keine reine Korrektur. Nur als Beobachtung vermerkt.
---
## 2026-09-17 (Nachtrag 2: "finale Test für maximale Sicherheit")
Dritter Durchgang, gezielt sicherheitsfokussiert: Zugangsdaten-Speicherung (Keychain, SavedRoutersStore),
TOFU-Implementierung (Zertifikat/SSH-Hostkey), alle verbleibenden Stellen, die Text in Router-Befehle
oder URLs interpolieren. Positiv: `KeychainService` speichert Passwörter korrekt im macOS-Keychain
(nie Klartext/UserDefaults), `SavedRoutersStore`/`SavedRouter` enthalten kein Passwort-Feld,
`BackupService.escapeForRouterOSScript` escapte bereits vor diesem Durchgang korrekt (unabhängig von
Fund #5 — offenbar aus einem früheren Lockout-Vorfall gelernt), `SSHHostKeyFingerprint` hat keine
Fallback-Schwäche. Zwei weitere Lücken gefunden und gehärtet (defensiv, nicht live exploitiert):
### 8. REST-Pendant zu Fund #5: ungeschützte Query-String-Interpolation
**Status:** fixed (Build grün, nicht live exploitiert — aktuell nur mit hartkodiertem Wert aufgerufen)
**Gitea-Issue:** [#20](http://192.168.178.222:3500/kay/RouterOS/issues/20)
**Confidence:** hoch (dieselbe Bugklasse wie #5, nur im REST- statt SSH-Pfad)
`RestTransport.fetchFieldValues` baute den Query-String `"\(restPath)?\(whereField)=\(whereValue)"`
ohne jede Kodierung — beim SSH-Gegenstück wurde das in Fund #5 bereits gefixt, das REST-Pendant
dabei übersehen. Ein `whereValue` mit `&` hätte einen zusätzlichen Query-Parameter einschleusen
können. Aktuell nur mit hartkodiertem `"no"` aufgerufen (kein Live-Exploit möglich), aber generische
`RouterOSTransport`-Methode — Härtung für zukünftige Aufrufer mit echtem Nutzer-/Gerätetext.
**Fix:** `whereValue` wird jetzt RFC-3986-"unreserved characters"-only percent-encoded (strenger
als `.urlQueryAllowed`, das `&`/`=`/`+`/`#` weiterhin durchlässt).
### 9. TOFU-Zertifikatsprüfung hatte einen Fallback-Konstante-Blindfleck
**Status:** fixed (Build grün, Härtung — kein realistisch auslösbarer Live-Exploit gefunden)
**Gitea-Issue:** [#21](http://192.168.178.222:3500/kay/RouterOS/issues/21)
**Confidence:** mittel (theoretische Lücke im Code nachvollzogen, keine funktionierende PoC gebaut,
da eine echte TLS-Handshake-Situation gebraucht würde, in der `SecTrustCopyCertificateChain` trotz
abgeschlossenem Handshake leer zurückkommt — unüblich, aber laut Apple-API-Vertrag nicht ausgeschlossen)
`CertificateFingerprint.sha256(of:)` gab bei fehlgeschlagener Zertifikatsketten-Extraktion den festen
String `"unbekannt"` statt eines echten Fingerabdrucks zurück. Da `CertificateTrustStore.isTrusted`
Fingerabdrücke per exaktem String-Vergleich prüft, hätte das Vertrauen in EIN Zertifikat, dessen
Extraktion fehlschlägt, automatisch JEDES andere ebenfalls extraktions-fehlschlagende Zertifikat für
denselben Host als vertrauenswürdig durchgehen lassen — ein theoretisches TOFU-Pinning-Bypass-Fenster.
**Fix:** Rückgabetyp auf `String?` geändert; schlägt die Extraktion fehl, lehnt
`RestTransport.urlSession(_:didReceive:completionHandler:)` die Challenge jetzt hart ab
(`.cancelAuthenticationChallenge`, kein `lastRejectedFingerprint` gesetzt) — der Nutzer bekommt in
diesem Fall nie einen "Zertifikat vertrauen?"-Dialog mit einer nicht verifizierbaren Kennung
angeboten, sondern einen normalen Verbindungsfehler.
Build + alle 101 Unit-Tests grün nach beiden Fixes.
---
## Noch nicht geprüft / außerhalb dieses Durchgangs
- UI-Interaktion selbst (Klickpfade, Darstellung) — nicht automatisierbar, siehe Hinweis oben.
- PPPoE-Client — laut Nutzer am Testrouter nicht testbar (hängt hinter einem weiteren
konfigurierten Router, kein direkter ISP-Uplink). Kein neuer Fund, nur zur Vollständigkeit.
- Restliche Tabs/Bereiche (Übersicht-Diagramm-Interaktion, Sicherungen, Mode-Taste, Settings) wurden
im Code überflogen, ohne konkreten neuen Fund über die bereits in `found.md`/`HANDOFF.md`
dokumentierten Punkte hinaus.
---
## 2026-09-17 (Nachtrag 3: README-Milestone-Nachcheck)
Nutzer wies auf den noch offenen Milestone "LAN-Port-Konflikt-Prüfung + 'Fertig'-Button" in
README.md hin. Beim erneuten Code-Review dieses Bereichs (u.a. wegen des in Runde 1 bereits
gefixten Race-Bugs #1 in genau dieser Feature) drei weitere, eigenständige Funde derselben
Bugklasse ("Alert-Dismiss tut nichts") entdeckt und gefixt.
### 10. Drei nicht funktionierende Alert-"Abbrechen"/"OK"-Buttons
**Status:** fixed (Build + 102 Unit-Tests grün, 1 neuer Regressionstest)
**Gitea-Issue:** [#22](http://192.168.178.222:3500/kay/RouterOS/issues/22)
**Confidence:** hoch (Logikfehler direkt im Code nachvollzogen, nicht live reproduziert)
1. `ReviewApplyView`s "Anwenden fehlgeschlagen"-Alert: OK-Button-Action leer, `isPresented`-
Bindings-Setter ebenfalls No-Op — `applyError` wurde nie zurückgesetzt.
2. `ConnectView`s "Unbekanntes Zertifikat"-Alert: "Abbrechen"-Button-Action komplett leer —
`connectionService.state` blieb für immer auf `.needsCertificateConfirmation` hängen, kein Weg
zurück außer dem Zertifikat zu vertrauen.
3. `ConnectView`s "Unbekannter SSH-Schlüssel"-Alert: "Abbrechen" rief nur
`dismissPendingSSHTrust()` (löschte nur `pendingSSHTrustFingerprint`) — funktionierte nur für
einen von zwei möglichen Auslöse-Pfaden, beim anderen (`state == .needsSSHHostKeyConfirmation`)
blieb der Dialog hängen.
**Fix:** neue `ConnectionService.cancelPendingTrustConfirmation()` (setzt `state` bei beiden
"needs...Confirmation"-Fällen auf `.idle` zurück, löscht zusätzlich `pendingSSHTrustFingerprint`),
neue `SetupViewModel.dismissApplyError()`. Alle drei Alerts verdrahtet (Button-Action UND
Bindings-Setter, deckt auch Tap-Outside/Esc-Dismiss ab). Totes `dismissPendingSSHTrust()`
entfernt.
**Weiterhin offen:** die Port-Konflikt-Prüfung selbst (Warndialoge, "Weiter"-Sperre, "Fertig"-
Button) ist nach diesem zweiten Code-Review-Durchgang funktional plausibel, aber noch nie
tatsächlich in der App-UI durchgeklickt worden — dafür bräuchte es einen Live-Test durch den
Nutzer, da UI-Automatisierung hier nicht verfügbar ist.
---
## 2026-09-17 (Nachtrag 4: Nutzer-gemeldet, live in der App)
### 11. Hänger beim Anlegen eines DHCP-Pools
**Status:** fixed (Build + 106 Unit-Tests grün, 3 neue Regressionstests — Mechanismus isoliert
verifiziert, Root Cause vom Nutzer bestätigt, nicht erneut live im UI nachgestellt)
**Gitea-Issue:** [#24](http://192.168.178.222:3500/kay/RouterOS/issues/24)
**Confidence:** hoch (Nutzer bestätigte exakt das vorhergesagte Bild — Experte-Tab, dauerhaft
hängend, kein Fehlertext — bevor der Fix geschrieben wurde)
Nutzer-Meldung: "Hänger beim Anlegen eines DHCP-Pools" — App bleibt beim Anlegen eines
`/ip pool`-Eintrags hängen. Rückfrage bestätigte: Experte-Tab, bleibt dauerhaft hängen (kein
Selbst-Erholen, Neustart nötig) — exakt das Bild, das der Code-Audit-Kandidat vorhergesagt hatte.
**Code-Audit-Befund, plausibler Kandidat:** `SSHTransport.connect()` (`SSHTransport.swift`) setzt
für den `Citadel.SSHClient.connect(...)`-Aufruf **keinerlei Timeout** — im Gegensatz zu
`RestTransport`, das für jede Anfrage `request.timeoutInterval = 5` explizit setzt. Jeder erste
Schreibvorgang einer Session (egal ob Experte-Tab oder Einrichten-Assistent) löst zuerst
`ensureSessionBackup()` aus, was `BackupService.createBackup(for:)` über eine **dedizierte, neue
SSH-Verbindung** aufruft (`BackupService.swift`, "always over SSH"-Muster). Hängt dieser
`connect()`-Aufruf (z.B. durch einen kurzen Netzwerk-Aussetzer oder einen unter Last
langsam/nicht antwortenden Router — der hAP-lite-Testrouter ist mit MIPS 24Kc/650MHz/1 Kern sehr
schwach, in dieser Session bereits bis 80% CPU-Last beobachtet), gibt es keinen Timeout, der die
App wieder freigibt — `isApplying`/der Ladeindikator bliebe dann unbegrenzt aktiv, exakt das vom
Nutzer beschriebene Bild eines "Hängers" ohne Fehlermeldung.
Noch nicht bestätigt, ob das tatsächlich die Ursache ist — nur ein durch Code-Lesen gefundener,
plausibler Kandidat, kein reproduzierter Fehler. Gleiche Lücke beträfe auch `ConnectionService.
applyViaSSH`/`flushConnections` (beide nutzen ebenfalls `SSHTransport.connect()` ohne Timeout) und
`UpdateService`/`FactoryResetService`/`NetworkToolsService`, die dieselbe Transport-Klasse nutzen.
**Fix:** neuer generischer `SSHTransport.withTimeout(_:operation:)` (`withThrowingTaskGroup`-Race
zwischen der echten Operation und einem `Task.sleep`-Deadline-Task, verliert die Operation den
Wettlauf wird sie abgebrochen). Angewendet auf `connect()` (10s) und `run(_:)` (30s, großzügiger
bemessen, da Exports/Backups auf schwacher Hardware legitim länger brauchen können). Beide
Methoden bleiben `async throws`, keine Signaturänderung für Aufrufer.
**Verifikation:** die Race-Logik selbst ist per 3 neuer Unit-Tests isoliert bewiesen (unabhängig
von Citadel/echtem Netzwerk) — ein hängender Vorgang wirft nach der Deadline (getestet mit
200ms statt Produktions-Werten, damit der Test schnell bleibt), ein schneller Vorgang liefert sein
Ergebnis unverändert, ein eigener Fehler der Operation selbst propagiert unverändert durch. Nicht
erneut live im Experte-Tab nachgestellt (bräuchte einen erneuten, absichtlich provozierten
Netzwerk-Hänger) — der Nutzer hat die Ursache aber bereits vor dem Fix exakt bestätigt.
Nebenbefund beim Umsetzen: `xcodegen generate` überschreibt `Info.plist` komplett aus
`project.yml`s `info.properties` (kein Merge mit der Datei auf der Platte) — ein Regenerieren für
diese neue Testdatei setzte die App-Version dabei stillschweigend von 1.1.0 auf 1.0 zurück.
`CFBundleShortVersionString`/`CFBundleVersion` jetzt explizit in `project.yml` verankert, damit
das nicht wieder passiert.
---
## 2026-09-17 (Nachtrag 5: Nutzer-gemeldet, Feature-Erweiterung)
### 12. Experte-Tab: Port-Konflikt-Prüfung wie im Einrichten-Assistenten
**Status:** fixed (Build + 111 Unit-Tests grün, 6 neue Regressionstests, live vom Nutzer bestätigt:
"funktioniert")
**Gitea-Issue:** [#25](http://192.168.178.222:3500/kay/RouterOS/issues/25)
Live-Anlass: Nutzer legte über den Experte-Tab manuell ein eigenes Netz auf `ether4` an
(IP-Adresse, Pool, DHCP-Server). `ether4` blieb dabei unbemerkt Bridge-Mitglied der Haupt-Bridge —
zwei DHCP-Server im selben Broadcast-Domain, Isolation dadurch grundsätzlich nicht möglich. Kein
App-Bug im engeren Sinn, aber genau der Fall, den die Wizard-eigene Port-Konflikt-Prüfung
(found.md #1, bugs.md #1) normalerweise abfängt — beim manuellen Anlegen über den Experte-Tab gab
es diese Warnung bisher nicht.
Auf Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen
Warnungen") dieselbe Prüfung samt "Port freimachen?"-Dialog jetzt auch im Experte-Tab:
- `PortConflictWarningView` aus `LanStepView.swift` nach `Features/Shared/` extrahiert (jetzt von
Wizard UND Experte-Tab genutzt), neuer `immediateApply`-Parameter für die kontextabhängige
Abschluss-Meldung (Wizard: erst bei "Jetzt anwenden"; Experte-Tab: sofort bei
"Anlegen"/"Speichern", da es dort keinen separaten Review-Schritt gibt).
- Neue `PortConflict.resolutionCommandsIncludingBridgeDetach()` — anders als der Wizard hat der
Experte-Tab keinen automatischen, unbedingten Bridge-Detach-Schritt
(`DhcpServerCommandBuilder`), muss die Bridge-Entfernung bei Bestätigung also selbst mit
ausführen (sonst würde "Port freimachen" für genau den Fall, der diese Erweiterung ausgelöst
hat, wirkungslos bleiben).
- `ExpertViewModel` bekommt dieselbe race-sichere Generation-Zähler-Logik wie `SetupViewModel`
(bugs.md #1), angewendet auf das `.interfacePick`-Feld des jeweils geöffneten Schemas.
- Speichern-Button gesperrt, bis der Konflikt bestätigt oder ein anderer Port gewählt wurde.
**Verifikation:** Build grün, alle 111 Unit-Tests grün (6 neue Regressionstests — Schema-Erkennung
ohne/mit Interface-Feld, Acknowledge-State-Reset, `resolutionCommandsIncludingBridgeDetach()`
inkl. Bridge-Entfernung). Live im Experte-Tab durchgeklickt vom Nutzer bestätigt ("funktioniert").
+575
View File
@@ -0,0 +1,575 @@
#!/usr/bin/env python3
"""Regenerates Manual.md's Experte-reference table + all Mermaid diagrams + Manual.pdf.
Run after any change to RouterOSSchemaCatalog.swift, to a Manual-assets/diagrams/*.mmd
file, or to the hand-written prose in Manual.md itself same "update docs after every
live-tested milestone" habit as README.md, just automated for the parts that would
otherwise drift from the app's actual source text.
Requires:
- `pip install markdown-it-py linkify-it-py mdit-py-plugins` (HTML step)
- `npx @mermaid-js/mermaid-cli` reachable (diagram rendering auto-downloads on first use)
- Google Chrome installed at the default macOS path (HTML -> PDF via headless print)
Usage: python3 build-manual.py
"""
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SCHEMA_SWIFT = ROOT / "RouterOSAssistant/Core/Models/RouterOSSchemaCatalog.swift"
L10N_SWIFT = ROOT / "RouterOSAssistant/Core/Localization/L10n.swift"
ASSETS = ROOT / "Manual-assets"
DIAGRAMS = ASSETS / "diagrams"
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
# One entry per manual language. "de" has no translation dict (it *is* the source
# language everything else is written against); every other language reuses the same
# DE->lang dictionary the app itself ships in L10n.swift for its own UI — Chapter 5
# (Expert reference) is generated per language straight from RouterOSSchemaCatalog.swift's
# German strings run through that dictionary, so a new language needs nothing here beyond
# adding its md_source and appending translations to L10n.swift; no new Python code.
LANGUAGES = {
"de": {"md_source": ROOT / "Manual.md", "pdf": ROOT / "Manual.pdf"},
"en": {"md_source": ROOT / "Manual.en.md", "pdf": ROOT / "Manual_en.pdf"},
}
def load_l10n_translations():
"""Parses `private static let translations: [String: String] = [...]` out of
L10n.swift the exact same DE->EN dictionary the running app uses for its own UI
strings so Chapter 5's field labels/help texts translate identically to what the
app itself shows, instead of a second, separately-maintained translation."""
src = L10N_SWIFT.read_text(encoding="utf-8")
marker = "translations: [String: String] = ["
start = src.index(marker) + len(marker)
end = find_matching_bracket(src, start - 1)
body = src[start:end]
translations = {}
i = 0
while i < len(body):
if body[i] == '"':
key, i = read_string_literal(body, i)
j = body.index(":", i) + 1
while body[j] in " \t\n":
j += 1
value, i = read_string_literal(body, j)
translations[key] = value
else:
i += 1
return translations
def find_matching_bracket(s, start):
depth, i, in_string, escape = 0, start, False, False
while i < len(s):
c = s[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
return i
i += 1
raise ValueError("unbalanced brackets starting at %d" % start)
CATEGORY_NAMES = {
"firewallFilter": "Firewall: Filter-Regeln",
"firewallNat": "Firewall: NAT (Portweiterleitung etc.)",
"firewallMangle": "Firewall: Mangle (Markierung/QoS-Vorbereitung)",
"firewallRaw": "Firewall: Raw (vor Connection-Tracking)",
"firewallAddressLists": "Firewall: Adress-Listen",
"interfaces": "Interfaces (Bridge, VLAN, VPN-Tunnel...)",
"ipAddressing": "IP-Adressierung & Dienste",
"routing": "Routing",
"vpn": "VPN-Server/Clients",
"wireless": "WLAN / CAPsMAN",
"queues": "Queues / Bandbreiten-Steuerung",
"system": "System",
"tools": "Werkzeuge & Überwachung",
}
CATEGORY_ORDER = [
"System",
"Interfaces (Bridge, VLAN, VPN-Tunnel...)",
"IP-Adressierung & Dienste",
"Routing",
"VPN-Server/Clients",
"WLAN / CAPsMAN",
"Firewall: Filter-Regeln",
"Firewall: NAT (Portweiterleitung etc.)",
"Firewall: Mangle (Markierung/QoS-Vorbereitung)",
"Firewall: Raw (vor Connection-Tracking)",
"Firewall: Adress-Listen",
"Queues / Bandbreiten-Steuerung",
"Werkzeuge & Überwachung",
]
# --- Swift schema parsing (RouterOSSchemaCatalog.swift -> structured menu list) ---
def find_matching_paren(s, start):
depth, i, in_string, escape = 0, start, False, False
while i < len(s):
c = s[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i
i += 1
raise ValueError("unbalanced parens starting at %d" % start)
def read_string_literal(s, i):
assert s[i] == '"'
j, escape, out = i + 1, False, []
while j < len(s):
c = s[j]
if escape:
out.append({"\\": "\\", '"': '"', "n": "\n"}.get(c, c))
escape = False
elif c == "\\":
escape = True
elif c == '"':
return "".join(out), j + 1
else:
out.append(c)
j += 1
raise ValueError("unterminated string at %d" % i)
def extract_kwarg_string(call, key):
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*", call)
if not m or m.end() >= len(call) or call[m.end()] != '"':
return None
val, _ = read_string_literal(call, m.end())
return val
def extract_kwarg_bool(call, key):
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*(true|false)", call)
return m.group(1) == "true" if m else None
def find_call_args_span(call, key):
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*", call)
if not m:
return None
i = m.end()
depth, j, in_string, escape = 0, i, False, False
while j < len(call):
c = call[j]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c in "([":
depth += 1
elif c in ")]":
if depth == 0:
break
depth -= 1
elif c == "," and depth == 0:
break
j += 1
return call[i:j].strip()
def parse_kind(expr):
expr = expr.strip()
if expr in (".text", ".bool", ".int", ".duration", ".interfacePick"):
return {"type": expr[1:]}
if expr.startswith(".enumPick"):
opts_span = find_call_args_span(expr, "options")
opts = re.findall(r'"((?:[^"\\]|\\.)*)"', opts_span or "")
return {"type": "enumPick", "options": [o.replace('\\"', '"') for o in opts]}
if expr.startswith(".menuItemPick"):
return {"type": "menuItemPick", "menuPath": extract_kwarg_string(expr, "menuPath")}
return {"type": "raw"}
def parse_fields(fields_text):
fields = []
for m in re.finditer(r"RouterOSFieldSchema\(", fields_text):
start = m.end() - 1
end = find_matching_paren(fields_text, start)
call = fields_text[start + 1 : end]
kind_raw = find_call_args_span(call, "kind")
fields.append(
{
"key": extract_kwarg_string(call, "key"),
"label": extract_kwarg_string(call, "label"),
"kind": parse_kind(kind_raw) if kind_raw else {"type": "unknown"},
"help": extract_kwarg_string(call, "help") or "",
"defaultValue": extract_kwarg_string(call, "defaultValue"),
"required": bool(extract_kwarg_bool(call, "required")),
}
)
return fields
def parse_schema(src):
menus = []
for m in re.finditer(r"RouterOSMenuSchema\(", src):
start = m.end() - 1
end = find_matching_paren(src, start)
call = src[start + 1 : end]
cat_match = re.search(r"category\s*:\s*\.(\w+)", call)
fields_span = find_call_args_span(call, "fields")
menus.append(
{
"menuPath": extract_kwarg_string(call, "menuPath"),
"restPath": extract_kwarg_string(call, "restPath"),
"category": CATEGORY_NAMES.get(cat_match.group(1)) if cat_match else None,
"displayName": extract_kwarg_string(call, "displayName"),
"summary": extract_kwarg_string(call, "summary") or "",
"explanation": extract_kwarg_string(call, "explanation") or "",
"warning": extract_kwarg_string(call, "warning"),
"isSingleton": extract_kwarg_bool(call, "isSingleton") or False,
"fields": parse_fields(fields_span) if fields_span else [],
"generic": False,
}
)
for m in re.finditer(r"(?<![A-Za-z0-9_])generic\(", src):
start = m.end() - 1
end = find_matching_paren(src, start)
call = src[start + 1 : end]
args, depth, in_string, escape, cur = [], 0, False, False, ""
for c in call:
if in_string:
cur += c
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string, cur = True, cur + c
elif c in "([":
depth, cur = depth + 1, cur + c
elif c in ")]":
depth, cur = depth - 1, cur + c
elif c == "," and depth == 0:
args.append(cur.strip())
cur = ""
else:
cur += c
if cur.strip():
args.append(cur.strip())
def unquote(a):
a = a.strip()
return a[1:-1].replace('\\"', '"') if a.startswith('"') else a
positional = [a for a in args if not re.match(r"^\w+\s*:", a)]
cat_match = re.match(r"\.(\w+)", positional[2]) if len(positional) > 2 else None
warning = None
for a in args:
wm = re.match(r"warning\s*:\s*(.*)$", a, re.S)
if wm:
warning = unquote(wm.group(1))
summary = unquote(positional[4]) if len(positional) > 4 else ""
explanation = unquote(positional[5]) if len(positional) > 5 else ""
menus.append(
{
"menuPath": unquote(positional[0]) if positional else None,
"restPath": unquote(positional[1]) if len(positional) > 1 else None,
"category": CATEGORY_NAMES.get(cat_match.group(1)) if cat_match else None,
"displayName": unquote(positional[3]) if len(positional) > 3 else None,
"summary": summary,
"explanation": explanation or summary,
"warning": warning,
"isSingleton": False,
"fields": [],
"generic": True,
}
)
seen, ordered = set(), []
for menu in menus:
if not menu["menuPath"] or menu["menuPath"] in seen or not menu["category"]:
continue
seen.add(menu["menuPath"])
ordered.append(menu)
return ordered
STATIC_LABELS = {
"de": {
"singleton_note": " *(Einstellungsmenü — genau ein Eintrag, kein Anlegen/Löschen)*",
"menu_line": "RouterOS-Menü: `{menu}` · REST-Pfad: `{rest}`\n",
"warning": "> ⚠️ **Achtung:** {text}\n",
"generic_note": (
"*Noch kein kuratiertes Formular — alle Felder erscheinen als freie "
"Schlüssel/Wert-Paare (siehe „Eigener Menüpfad“).*\n"
),
"table_header": "| Feld | RouterOS-Parameter | Typ | Pflicht | Standard | Hilfetext |",
"yes": "Ja", "no": "Nein",
"no_fields": "*Keine kuratierten Felder — generischer Schlüssel/Wert-Zugriff.*\n",
"kind": {
"text": "Text", "bool": "Ja/Nein", "int": "Zahl",
"duration": "Zeitdauer (Tage/Std/Min/Sek, per Stepper)",
"interfacePick": "Auswahl aus Live-Interface-Liste des Routers",
"enumPick": "Auswahl (fest): ",
"menuItemPick": "Verweis auf bestehenden Eintrag unter `{menu}`",
},
},
"en": {
"singleton_note": " *(settings menu — exactly one entry, no add/remove)*",
"menu_line": "RouterOS menu: `{menu}` · REST path: `{rest}`\n",
"warning": "> ⚠️ **Warning:** {text}\n",
"generic_note": (
"*No curated form yet — every field appears as a free-form "
"key/value pair (see \"Custom Menu Path\").*\n"
),
"table_header": "| Field | RouterOS Parameter | Type | Required | Default | Help Text |",
"yes": "Yes", "no": "No",
"no_fields": "*No curated fields — generic key/value access.*\n",
"kind": {
"text": "Text", "bool": "Yes/No", "int": "Number",
"duration": "Time duration (days/hrs/min/sec, via stepper)",
"interfacePick": "Picker from the router's live interface list",
"enumPick": "Fixed choice: ",
"menuItemPick": "Reference to an existing entry under `{menu}`",
},
},
}
def kind_label(kind, labels):
t = kind.get("type")
if t in labels["kind"] and t != "enumPick" and t != "menuItemPick":
return labels["kind"][t]
if t == "enumPick":
return labels["kind"]["enumPick"] + ", ".join(f"`{o}`" for o in kind.get("options", []))
if t == "menuItemPick":
return labels["kind"]["menuItemPick"].format(menu=kind.get("menuPath"))
return ""
def esc(s):
return "" if s is None else s.replace("|", "\\|").replace("\n", " ")
def schema_anchor(menu_path):
"""Deterministic HTML anchor id for one Experte-menu's Manual heading, from its
RouterOS menu path the same transform is duplicated in Swift (ManualAnchors.swift)
so the app can compute a schema's anchor from `RouterOSMenuSchema.menuPath` alone,
with no generated mapping file to keep in sync."""
return "schema-" + menu_path.strip("/").replace(" ", "-")
def render_expert_reference(menus, lang, translate):
"""`translate(s)` maps a German source string (displayName/summary/explanation/
warning/field label/help/category name) to this language's text — identity for
"de", the app's own L10n.swift dictionary for every other language."""
labels = STATIC_LABELS[lang]
by_cat = {}
for m in menus:
by_cat.setdefault(m["category"], []).append(m)
lines = []
for cat in CATEGORY_ORDER:
if cat not in by_cat:
continue
lines.append(f"### {translate(cat)}\n")
for m in by_cat[cat]:
note = labels["singleton_note"] if m["isSingleton"] else ""
lines.append(f'<a id="{schema_anchor(m["menuPath"])}"></a>')
lines.append(f"#### {translate(m['displayName'])}{note}")
lines.append(labels["menu_line"].format(menu=m["menuPath"], rest=m["restPath"]))
if m["summary"]:
lines.append(f"{translate(m['summary'])}\n")
if m["explanation"] and m["explanation"] != m["summary"]:
lines.append(f"{translate(m['explanation'])}\n")
if m.get("warning"):
lines.append(labels["warning"].format(text=translate(m["warning"])))
if m["generic"]:
lines.append(labels["generic_note"])
elif m["fields"]:
lines.append(labels["table_header"])
lines.append("|---|---|---|---|---|---|")
for f in m["fields"]:
default = f"`{esc(f['defaultValue'])}`" if f["defaultValue"] else ""
required = labels["yes"] if f["required"] else labels["no"]
lines.append(
f"| {esc(translate(f['label']))} | `{esc(f['key'])}` | {kind_label(f['kind'], labels)} "
f"| {required} | {default} | {esc(translate(f['help']))} |"
)
lines.append("")
else:
lines.append(labels["no_fields"])
lines.append("")
return "\n".join(lines).strip()
def update_expert_reference_section(lang, md_path, translate):
src = SCHEMA_SWIFT.read_text(encoding="utf-8")
menus = parse_schema(src)
ref_md = render_expert_reference(menus, lang, translate)
manual = md_path.read_text(encoding="utf-8")
start_marker, end_marker = "<!-- EXPERT_REFERENCE_START -->", "<!-- EXPERT_REFERENCE_END -->"
start = manual.index(start_marker) + len(start_marker)
end = manual.index(end_marker)
manual = manual[:start] + "\n\n" + ref_md + "\n\n" + manual[end:]
md_path.write_text(manual, encoding="utf-8")
print(f"[{lang}] Expert reference updated: {len(menus)} menus, {sum(len(m['fields']) for m in menus)} fields")
# --- Diagram rendering ---
def render_diagrams():
DIAGRAMS.mkdir(parents=True, exist_ok=True)
for mmd in sorted(DIAGRAMS.glob("*.mmd")):
png = ASSETS / f"{mmd.stem}.png"
if png.exists() and png.stat().st_mtime > mmd.stat().st_mtime:
continue
subprocess.run(
["npx", "-y", "@mermaid-js/mermaid-cli", "-i", str(mmd), "-o", str(png), "-b", "white", "-s", "2"],
check=True,
)
print(f"rendered {png.name}")
# --- Markdown -> HTML -> PDF ---
STYLE = """
body { font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 10.5pt; line-height: 1.5; color: #1a1a1a; }
h1 { font-size: 20pt; border-bottom: 2px solid #2f6fb0; padding-bottom: 6px; }
h2 { font-size: 15pt; color: #2f6fb0; margin-top: 28px; border-bottom: 1px solid #ccc; padding-bottom: 3px; }
h3 { font-size: 12.5pt; color: #1a4a75; margin-top: 20px; }
h4 { font-size: 11pt; margin-top: 14px; margin-bottom: 4px; }
code { background: #f2f2f2; padding: 1px 4px; border-radius: 3px; font-size: 92%; }
table { border-collapse: collapse; width: 100%; margin: 8px 0 16px 0; font-size: 9pt; }
th, td { border: 1px solid #ccc; padding: 4px 6px; text-align: left; vertical-align: top; }
th { background: #eef4fb; }
tr { page-break-inside: avoid; }
blockquote { border-left: 4px solid #d9a441; background: #fff8ea; margin: 10px 0; padding: 6px 12px; }
img { max-width: 90%; display: block; margin: 14px auto; }
a { color: #2f6fb0; }
hr { border: none; border-top: 1px solid #ddd; margin: 24px 0; }
"""
def render_body_html(md_path):
from markdown_it import MarkdownIt
md_text = md_path.read_text(encoding="utf-8")
# html=True: lets the `<a id="...">` anchors inserted before each heading (see
# schema_anchor() and Manual.md's hand-placed tab-/step-anchors) pass through as
# real DOM ids instead of being escaped as literal text — CommonMark disables raw
# HTML by default, this is the one option that matters for that here.
md = MarkdownIt("gfm-like", {"html": True}).enable("table")
return md.render(md_text)
def fix_img_srcs(body_html, embed_as_data_uri):
import base64
import mimetypes
def fix(m):
prefix, path, suffix = m.group(1), m.group(2), m.group(3)
if path.startswith(("http://", "https://", "file://", "data:", "/")):
return m.group(0)
full = (ROOT / path).resolve()
if embed_as_data_uri:
mime = mimetypes.guess_type(str(full))[0] or "image/png"
data = base64.b64encode(full.read_bytes()).decode("ascii")
return f'{prefix}data:{mime};base64,{data}{suffix}'
return f'{prefix}file://{full}{suffix}'
return re.sub(r'(<img[^>]*src=")([^"]+)("[^>]*>)', fix, body_html)
def render_pdf(lang, md_path, pdf_path):
body_html = fix_img_srcs(render_body_html(md_path), embed_as_data_uri=False)
html = f"""<!DOCTYPE html><html lang="{lang}"><head><meta charset="utf-8">
<title>RouterOS Assistant Manual</title>
<style>
@page {{ size: A4; margin: 18mm 16mm; }}
{STYLE}</style></head><body>{body_html}</body></html>"""
html_path = ASSETS / f"_manual_build_{lang}.html"
html_path.write_text(html, encoding="utf-8")
subprocess.run(
[CHROME, "--headless", "--disable-gpu", "--no-pdf-header-footer",
f"--print-to-pdf={pdf_path}", f"file://{html_path}"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
html_path.unlink()
print(f"[{lang}] PDF written: {pdf_path} ({pdf_path.stat().st_size // 1024} KB)")
def render_app_html(lang, md_path):
"""Self-contained Manual[_lang].html (images embedded as base64 data URIs, no
external file references) bundled straight into the app as a resource see
ManualView.swift. Self-contained because once inside the .app bundle there's no
Manual-assets/ directory next to it to resolve relative image paths against."""
body_html = fix_img_srcs(render_body_html(md_path), embed_as_data_uri=True)
html = f"""<!DOCTYPE html><html lang="{lang}"><head><meta charset="utf-8">
<title>RouterOS Assistant Manual</title>
<style>
body {{ margin: 0; padding: 24px 32px; }}
{STYLE}</style></head><body>{body_html}</body></html>"""
suffix = "" if lang == "de" else f"_{lang}"
out = ROOT / f"RouterOSAssistant/Resources/Manual{suffix}.html"
out.write_text(html, encoding="utf-8")
print(f"[{lang}] App HTML written: {out} ({out.stat().st_size // 1024} KB)")
def main():
translations = load_l10n_translations()
def translate_en(german):
return translations.get(german, german)
translators = {"de": lambda s: s, "en": translate_en}
render_diagrams()
for lang, cfg in LANGUAGES.items():
update_expert_reference_section(lang, cfg["md_source"], translators[lang])
render_pdf(lang, cfg["md_source"], cfg["pdf"])
render_app_html(lang, cfg["md_source"])
if __name__ == "__main__":
main()
+388
View File
@@ -0,0 +1,388 @@
# Fundzettel
Notizzettel für Feedback/Funde des Users. Neue Einträge unten anhängen, Status pflegen.
**Status:** `offen` | `postponed` | `fixed`
---
## 2026-09-16
### 1. NTP Server bricht mit Error HTTP 400 ab
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#3](http://192.168.178.222:3500/kay/RouterOS/issues/3)
Ursache 1 (behoben): Experte-Schema PATCHte `servers` als kommagetrenntes Textfeld direkt auf `/system/ntp/client` — Feld existiert dort in RouterOS 7.x nicht, Server liegen in eigener Liste `/system ntp client servers`. Schema aufgeteilt: Singleton-Menü (enabled/mode) bleibt, neues Listen-Menü `/system ntp client servers` (Feld `address`) dazu.
Ursache 2 (behoben): Live-Test meldete beim Anlegen eines Server-Eintrags `HTTP 400: unknown parameter enabled` — Listen-Einträge in RouterOS nutzen durchgängig `disabled` (nicht `enabled`) zum Ein-/Ausschalten, wie der Rest des Schemas es auch macht. Feld in `RouterOSSchemaCatalog.swift` von `enabled`/"yes" auf `disabled`/"no" korrigiert. Build grün, noch nicht erneut live getestet.
### 2. Interfaces-Ansicht zu unübersichtlich
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#4](http://192.168.178.222:3500/kay/RouterOS/issues/4)
Interfaces-Einträge sehr umfangreich beim Öffnen. Zweispaltige Ansicht gewünscht, um Scrollen zu reduzieren.
Ursache: Der eigentliche Umfang kommt nicht von den kuratierten Feldern (bei "Alle Interfaces (generisch)" nur 3: Name/Kommentar/Deaktiviert), sondern vom "Weitere Parameter (frei)"-Block — jedes von RouterOS zurückgelieferte Feld, das nicht kuratiert ist, landet dort als eigene Zeile. Bei einem Ethernet-Port sind das leicht 30+ Zeilen. Fix: `ExpertMenuDetailView.swift` — dieser Block ist jetzt ein zweispaltiges `Grid` statt einer Liste (halbiert die Zeilenzahl), Sheet-Breite von 420 auf 640 erhöht. Betrifft alle Menüs mit vielen ungekuratierten Feldern, nicht nur Interfaces.
Nachbesserung 1 (User-Feedback: Zeilen zu eng, Spalten-Trennlinie gewünscht): verticalSpacing 8→16, horizontalSpacing 12→20, dünne vertikale Trennlinie zwischen den Spalten ergänzt.
Nachbesserung 2 (User-Feedback: Zeilen jetzt abgeschnitten, Fenster breiter machen und voll ausnutzen): Sheet-Breite minWidth 640→900 + idealWidth 900.
Nachbesserung 3 (User-Screenshot: an den äußeren Rändern noch viel ungenutzter Platz). Ursache gefunden: `TextField(titleKey:text:)` zeigt auf macOS den Titel als feste Beschriftung vor dem Wert an (kein iOS-Platzhalter, der beim Tippen verschwindet) — bei 30+ Zeilen fraß "Parameter"/"Wert" so bei jeder einzelnen Zeile erneut Breite. Fix: echte Grid-Spalten für Parameter/Wert mit einmaliger Kopfzeile statt Pro-Zeile-Label, leere TextField-Titel (`.textFieldStyle(.plain)`), Wert-Spalte mit `maxWidth: .infinity`.
Nachbesserung 4 (User-Feedback: letzte Zeile überschneidet sich mit dem Button "Parameter hinzufügen", zwischen Zeilen soll eine Trennlinie sein): horizontale `Divider()` nach jeder Zeile ergänzt (spannt alle 5 Grid-Spalten), 12pt Abstand unter dem Grid vor dem Button. Build grün, noch nicht visuell/live geprüft (kein Router hier erreichbar).
### 3. Übersicht: Fokus-Modus beim Anklicken eines Items
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#5](http://192.168.178.222:3500/kay/RouterOS/issues/5)
Wunsch: Klick auf einen Node in der Übersicht (Graph-Ansicht, `OverviewView.swift`) soll den Node in den freien Bereich schieben, alle anderen Nodes ausfaden, und die Parents/Childs des aktuell gewählten Nodes nach unten schieben und sauber neu anordnen.
Ist-Zustand (`OverviewView.swift`): Klick setzt aktuell nur `selectedNodeID` und highlightet die verbundenen Kanten/Nodes (`OverviewGraph.highlightedNodeIDs`) — Positionen ändern sich nicht. Es gibt bereits ein Spalten-Layout (`OverviewLayout`, feste `columnOrder`/`columnWidth`) plus manuelle Drag-Offsets pro Node (`nodeOffsets`), die on top der berechneten Grid-Positionen liegen und über einen "Zurücksetzen"-Button löschbar sind (nicht persistiert, geht bei Neuladen verloren).
Klärung (User-Antworten):
1. Horizontale Trennlinie über den Canvas; ausgewählter Node + Kette werden unterhalb dieser Trennlinie dargestellt (eigener Bereich, getrennt von der normalen Graph-Darstellung darüber).
2. Fokus-Modus ist temporär — aufhebbar (Klick auf leere Fläche / erneuter Klick), keine dauerhafte Layout-Änderung, `nodeOffsets` bleibt unberührt.
3. Ausfaden = abdunkeln (nicht ausblenden).
4. Komplette verbundene Kette (nicht nur 1 Ebene) — deckt sich mit dem bereits vorhandenen `highlightedNodeIDs` (rekursiv).
5. Ketten-Layout im unteren Bereich: gleiches Spalten-Layout wie oben (`OverviewLayout`-Logik wiederverwendet, nur auf die Kette beschränkt) — kein neuer Layout-Algorithmus nötig.
Umsetzung (`OverviewGraph.swift`, `OverviewView.swift`, `OverviewGraphTests.swift`, `L10n.swift`):
- Neue Methode `OverviewGraph.connectedChain(startingAt:)` — volle rekursive Kette unabhängig vom Node-Typ (bewusst getrennt von `highlightedNodeIDs`, das nur bei Interfaces mehr als 1 Hop expandiert — eine andere, bereits live bestätigte, bewusst enger gefasste Funktion). Mit Test abgedeckt.
- `OverviewView`: Fokus-Modus hängt direkt an `viewModel.selectedNodeID` (kein separater Zustand) — Klick auf Node selektiert wie bisher, macht das aber jetzt gleichzeitig zum Fokus-Trigger; erneuter Klick auf denselben Node hebt ihn wieder auf (bestehende Toggle-Logik, kein neuer Code nötig).
- Kette wird per `OverviewLayout.layout(for:)` auf ein gefiltertes Sub-Graph-Objekt neu angeordnet (`focusSubLayout`) und unterhalb einer Trennlinie eingeblendet (`effectivePositions` verschiebt nur Kettenknoten dorthin, alle anderen bleiben an ihrer normalen Position).
- Ausfaden: nicht-Ketten-Nodes bekommen `opacity(0.25)`; `EdgesCanvas` bekommt die Kette als `highlightedNodeIDs` übergeben, wodurch dessen bereits vorhandene Dimm-Logik für Kanten automatisch mitgreift (keine Kante kann je über die Trennlinie laufen, da eine Kette per Definition die transitive Hülle ist).
- Leere-Fläche-Klick hebt Fokus auf: `EdgesCanvas` bekommt einen neuen `onBackgroundTap`-Callback, der bei ihrem bereits vorhandenen Tap-Gesture-Handler greift, wenn kein Edge getroffen wurde.
- Dragging ist für Nodes deaktiviert, solange sie im Fokus-Streifen sitzen (Position wird bei jeder Auswahl neu berechnet, ein gespeicherter Drag-Offset würde sonst nach Verlassen des Fokus unerwartet wieder auftauchen).
Build grün, alle Tests (inkl. neuem `testConnectedChainExpandsTransitivelyForEveryNodeKind`) grün, App startet ohne Absturz. Noch nicht visuell/live geprüft — kein Tool für native-macOS-UI-Interaktion hier verfügbar, bitte im Übersicht-Tab gegentesten (Klick auf Node → Fokus-Streifen unten, Klick auf leere Fläche/erneuter Klick → Fokus verlassen, anderen Kettenknoten anklicken → Fokus wechselt).
Nachbesserung 1 (User-Feedback: nach Klick soll die Fokus-Ebene in den sichtbaren Viewport geschoben werden, ohne Scrollbars) — inzwischen durch Nachbesserung 2 ersetzt: `viewportSize` per `GeometryReader` mitverfolgt, Auto-Zoom+Auto-Scroll auf einen `"focusAnchor"`-Marker im Fokus-Streifen.
Nachbesserung 2 (User-Feedback: statt Trennlinie/eingebettetem Bereich ein separates Panel mit Breite des Original-Viewports, Höhe automatisch an Inhalt angepasst, eigener Close-Button) — ersetzt Nachbesserung 1 komplett: Der Divider-Ansatz (Kette in denselben zoombaren Canvas verschoben, Auto-Fit-Zoom+Auto-Scroll) ist raus. Hauptdiagramm bleibt jetzt unverändert an Ort und Stelle (keine Repositionierung mehr, kein `combinedCanvasSize`/`fitScale`/`ScrollViewReader` mehr) — nicht zur Kette gehörende Nodes werden weiterhin abgedunkelt (`opacity(0.25)`). Neues `focusPanel(subGraph:subLayout:)` erscheint als eigener Bereich unterhalb der Haupt-`ScrollView` (in einem `VStack`, kein Overlay): Breite = `viewportSize.width` (Breite der Haupt-Diagramm-Scrollview, per `GeometryReader` gemessen), Höhe = `subLayout.canvasSize.height + 56` (passt sich der Kettengröße an, kein künstliches Clamping). Eigene Kopfzeile mit "Fokus"-Titel + `xmark.circle.fill`-Button zum Schließen. Kette selbst per eigenem `EdgesCanvas`+Node-Loop gerendert (gleiches `OverviewLayout`, alle internen Kanten hell da komplett als `highlightedNodeIDs` durchgereicht), eigener Hover/Tooltip-State (`panelHoveredEdge`/`panelHoverPoint`), da eigener Koordinatenraum, getrennt von der Haupt-Diagramm-Tooltip-Logik. Klick auf einen Node im Panel wechselt den Fokus (kein Toggle-Aus mehr dort, dafür gibt's jetzt den Close-Button); Hintergrund-Klick/erneuter Klick auf denselben Node oben im Hauptdiagramm schließen weiterhin zusätzlich. Drag-Deaktivierung für Kettenknoten ist wieder raus (nicht mehr nötig, da Hauptdiagramm nicht mehr verschoben wird). Build+Tests grün, App startet ohne Absturz. Noch nicht live geprüft.
Nachbesserung 3 (User-Feedback: von Panel auf Popup wechseln) — inzwischen durch Nachbesserung 4 ersetzt: eigenes `ZStack`-Overlay mit Scrim + abgerundeter Karte statt fester VStack-Zeile.
Nachbesserung 4 (User-Feedback: "passt nicht", stattdessen wie beim Bearbeiten-Formular — Größe/Breite automatisch anpassen, Scrollbalken verhindern, Close-Button) — Sizing-Teil bleibt, Präsentation wurde in Nachbesserung 5 wieder korrigiert: natives `.sheet(isPresented:)` (gleiches Muster wie `ExpertItemEditView`), kein `ScrollView` mehr, `.fixedSize()` auf `subLayout.canvasSize` (exakte Content-Größe statt fixer Viewport-Breite).
Nachbesserung 5 (User-Feedback: Seitenleiste muss gleichzeitig bedienbar bleiben, um Knoten zu bearbeiten): `.sheet` ist ein echtes macOS-Modal — blockiert das gesamte Fenster inkl. der rechten Seitenleiste (`detailPanel`, "Bearbeiten"-Button), das war der Bug. Zurück auf nicht-modales `ZStack`-Overlay + Scrim (wie Nachbesserung 3), aber mit der exakten Content-Größe aus Nachbesserung 4 (kein `ScrollView`, `.fixedSize()` auf `subLayout.canvasSize`) statt der alten festen Viewport-Breite. `isFocusPresented`-Binding wieder entfernt. Sidebar bleibt jetzt durchgehend bedienbar, da sie eine eigene `HSplitView`-Pane ist, die vom Overlay nie erreicht wird. Build+Tests grün, App startet ohne Absturz.
Nachbesserung 6 (User-Wunsch: den Close-Button-Stil aus dem Fokus-Popup bei allen anderen Formularen/Sheets ebenfalls nachziehen) — betrifft nicht mehr nur Fund #3, sondern alle Sheet-Formulare der App: `ExpertItemEditView` (Experte-Bearbeiten-Sheet, `ExpertMenuDetailView.swift`) bekommt eine neue Kopfzeile mit Menü-Titel + `xmark.circle.fill`-Button (schließt via `cancelEditing()+dismiss()`, wie das bestehende "Abbrechen" unten — bleibt zusätzlich erhalten, da es semantisch mehr ist als nur schließen). Die drei Devices-Sheets (`RawFieldsSheet`, `NetworkToolResultSheet`, `PortScanResultSheet` in `DevicesView.swift`) hatten je nur einen "Schließen"-Textbutton unten — durch dieselbe Kopfzeile mit X-Button ersetzt (kein doppelter Close mehr). Neue L10n-Zeile "Schließen, ohne zu speichern" ergänzt.
Nachbesserung 7 (User-Feedback: alle Close-Buttons müssen in der Header-Leiste des Popups liegen, nicht doppelt): das untere "Abbrechen" in `ExpertItemEditView` war seit Nachbesserung 6 doppelt zum neuen Kopfzeilen-X — entfernt, einziger Close-Button ist jetzt der im Header. "Anlegen"/"Speichern" bleibt unten als reine Save-Aktion (kein Close). Geprüft: alle übrigen "Abbrechen"-Vorkommen in der App gehören zu nativen `.confirmationDialog`/`.alert`-Dialogen (keine Header-Leiste, nicht betroffen).
Nachbesserung 8 (User-Wunsch: Verhalten+Layout des Close-Buttons bei ALLEN Popups exakt wie beim Fokus-Popup der Übersicht) — Rückfrage gestellt, ob der Header beim Experte-Bearbeiten-Sheet dafür aus der scrollenden Form raus und fest oben stehen soll (mehr Umbau) oder nur optisch angeglichen wird; User: fest oben, wie Fokus-Popup. Umgesetzt in allen vier Popups (`ExpertItemEditView`, `RawFieldsSheet`, `NetworkToolResultSheet`, `PortScanResultSheet`): identische Struktur wie `OverviewView.focusPanel``HStack{Titel; Spacer; xmark.circle.fill-Button}.padding(10)` gefolgt direkt von `Divider()`, außerhalb jeder Form/ScrollView/Section, bleibt beim Scrollen des restlichen Inhalts immer sichtbar. Beim Experte-Sheet wanderte der Header dafür vor die `Form` (Mindesthöhe leicht erhöht: 480→520/idealHeight 620→660, damit durch den zusätzlichen festen Header nichts gestaucht wirkt). Bei den drei Devices-Sheets wurde die bisher gemeinsame Erklärungs-Caption+Divider+Content-Gruppe vom Header getrennt und bekam eigenes Padding. Build+Tests grün, App startet ohne Absturz. Noch nicht live geprüft — bisher nur der Fokus-Popup-Teil (Fund #3) wurde live bestätigt "sieht gut", der Rest noch nicht.
### 4. LAN-Scanner: Sparkline-Breite + Abfrage-Intervall
**Status:** fixed (live bestätigt: "ja, alles flüssig" — auch 0,1s-Takt keine spürbare CPU-Last)
**Gitea-Issue:** [#6](http://192.168.178.222:3500/kay/RouterOS/issues/6)
Wunsch: Sparkline-Breite verdoppeln, Traffic-Abfrage auf 0,5s setzen (statt 3s).
Umsetzung: `TrafficSparkline`-Frame in `DevicesView.swift` von 50pt auf 100pt Breite; `DevicesViewModel.startTrafficPolling` von `.seconds(3)` auf `.milliseconds(500)`. Bewusst nur den LAN-Scanner-Poll geändert, nicht `ConnectViewModel`s separate 3s-Schleife (die nur den Link-Punkt treibt, nicht die Sparkline) — User-Scope war explizit "im lan-scanner".
Nachbesserung 1 (User-Wunsch: nochmal doppelt so breit): 100pt → 200pt.
Nachbesserung 2 (User-Wunsch: Trafficanzeige in MB, Sparkline-Zeitfenster auf 30s): `formatBitsPerSecond` (kbps/Mbps/Gbps auto-skaliert) ersetzt durch festes `formatMegabytesPerSecond` (Bits/8/1.000.000, 2 Nachkommastellen, "MB/s"). Sparkline-Fenster in `DevicesViewModel.appendTrafficHistory` von 10s auf 30s (`cutoff`). Manual.md-Textstelle + `build-manual.py`-Lauf (PDF neu) mit aktualisiert.
Nachbesserung 3 (User-Wunsch: Aktualisierungsrate auf 0,1s): `startTrafficPolling` von `.milliseconds(500)` auf `.milliseconds(100)` — bei 30s-Fenster damit bis zu 300 Punkte pro Sparkline/Port (bisher ~60). Nicht weiter geprüft, ob das bei vielen Ports spürbar CPU kostet — bei Bedarf zurückmelden. Manual.md "Abfrage alle 0,1s" + PDF mit aktualisiert. Build grün, App startet ohne Absturz. Noch nicht live geprüft.
### 5. App-Einstellungen / Settings — eigener Menüpunkt
**Status:** fixed (live bestätigt: "passt, lassen wir so")
**Gitea-Issue:** [#7](http://192.168.178.222:3500/kay/RouterOS/issues/7)
Wunsch: eigener Einstellungen-Bereich für Personalisierung, Layout, Farbverwaltung, Refreshraten, Updates, Schriftgrößen, Responsiveness, etc.
Rückfrage vorab geklärt (AskUserQuestion): natives macOS-Settings-Fenster (⌘,), nicht eigener Tab; alle genannten Bereiche jetzt sofort umsetzen, nicht nur ein Kernstück; Farbverwaltung als 2 vordefinierte Themes ("Standard"/"Kontrastreich"), kein freier Color-Picker pro Kategorie.
Umsetzung:
- **`Core/Models/AppPreferences.swift`** (neu): zentrale `AppStorage`-Key-Konstanten + `AppPreferences.registerDefaults()` (in `RouterOSAssistantApp.init()` aufgerufen), plus drei Enums:
- `ColorTheme` (`standard`/`highContrast`) — Farben für Übersicht-Node-Kategorien, Kanten-Arten, und LAN-Scanner-Status (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port). Ersetzt die alten `OverviewStyle.color(for:)`-Funktionen (die jetzt nur noch `explanation(for:)`/`icon(for:)` halten).
- `AppTextSize` (Klein/Standard/Groß/Sehr groß) — Skalierungsfaktor (0,85/1,0/1,2/1,4), per `.environment(\.appFontScale, ...)` an der `WindowGroup`-Wurzel gesetzt.
- `UIDensity` (Kompakt/Standard/Komfortabel) — mappt auf `ControlSize`, ebenfalls an der Wurzel via `.controlSize(...)`. Deckt den "Responsiveness"-Wunsch pragmatisch als Bedienelement-Größe ab (die App ist eine feste macOS-Fensterlayout-App ohne eigentliche responsive Breakpoints — eine echte "Responsiveness"-Funktion in dem Sinn gibt es nicht, siehe unten).
- **`Features/Settings/SettingsView.swift`** (neu): `Settings { }`-Scene mit 3 Tabs — Allgemein (Sprache, Auto-Update-Check beim Verbinden), Darstellung (Farbschema, Textgröße, Bedienelemente), Netzwerk (LAN-Scanner Aktualisierungsrate/Sparkline-Zeitfenster/-Breite). Alle Controls sind reine `@AppStorage`-Bindings, kein eigenes ViewModel.
- **Live-Wirkung ohne Neustart**: `OverviewView`/`DevicesView` lesen `colorTheme` per `@AppStorage` und geben es an `EdgesCanvas`/`NodeCardView`/`NodeDetailView`/`EdgeDetailView`/`EdgeTooltipView`/`LegendView`/`DeviceRow`/`PortScanRow` durch. `DevicesViewModel.startTrafficPolling`/`appendTrafficHistory` lesen Intervall/Zeitfenster bei jedem Tick frisch aus `UserDefaults` (nicht einmalig beim Start), damit eine Änderung im Settings-Fenster sofort greift, auch während der Tab schon pollt.
- **"Beim Verbinden automatisch nach Updates suchen"** (neuer Toggle, Default aus): `ConnectViewModel.connect()` ruft nach erfolgreicher Verbindung automatisch dieselbe `checkForUpdates(for:)` auf, die sonst nur der manuelle Button im Verbinden-Tab auslöst.
- L10n.swift um alle neuen Settings-Strings ergänzt (DE/EN).
- `.xcodeproj` manuell um beide neue Dateien ergänzt (PBXBuildFile/PBXFileReference/PBXGroup/Sources-Build-Phase).
Bewusst nicht umgesetzt: Übersicht-Zoom-Standardwert und Fokus-Popup-Größe/Verhalten sind nicht in Settings aufgenommen — beides sind reine `@State`-Werte ohne bestehenden Persistenz-Mechanismus und wurden in der Rückfrage nicht explizit als "jetzt" gefordert; bei Bedarf als vierte Kategorie nachziehbar.
Nachbesserung 1 (Live-Test: "Bedienelemente und Sparkling funktioniert, bei Textgröße und Farbschemas sehe ich keine Änderung"):
- Farbschema-Bug gefunden: 3 Farben (`.service`-Kategorie, `.dhcp`- und `.wireguardPeer`-Kanten) waren in `standard` und `highContrast` exakt identisch definiert — Kopierfehler beim ersten Entwurf. Behoben, beide Paletten jetzt komplett überschneidungsfrei.
- Textgröße nachgebessert (Bereich auf macOS-Accessibility-Stufen erweitert für einen unübersehbaren Sprung).
Nachbesserung 2 (Live-Test: erneut "unverändert" bei beidem, trotz Fix 1) — Ursache mit Beweis gefunden statt weiter geraten: per `ImageRenderer`-Snapshot (Scratchpad-Skript) nachgewiesen, dass `.environment(\.dynamicTypeSize, ...)` auf macOS **gar keine Wirkung** hat — identisches Pixel-Rendering bei `.xSmall` und `.accessibility3`. Anders als iOS skalieren SwiftUI-Textstile auf macOS nicht über Dynamic-Type-Kategorien. Rückfrage an User: 3 Alternativen zur Wahl gestellt, User wählte "Echtes Text-only Scaling".
Umsetzung (komplett neuer Mechanismus, `AppTextSize.dynamicTypeSize` entfernt):
- Neuer `\.appFontScale`-Environment-Key (`CGFloat`, Default 1.0) + `AppFontStyle`-Enum mit expliziten macOS-Basis-Punktgrößen pro Textstil (title2/title3/headline/body/callout/subheadline/caption/caption2) + `.appFont(_:weight:bold:design:)`-View-Modifier, der bei jedem Aufruf `baseSize * scale` real rendert.
- Alle ~110 `.font(...)`-Aufrufe in der App auf `.appFont(...)` umgestellt (automatisiert per Scratchpad-Skript, textbasierte 1:1-Ersetzung pro bekanntem Font-Ausdruck) — **außer** zwei Stellen mit fest-breiten Pixel-Layouts, die beim Skalieren brechen würden: `OverviewView.NodeCardView` (Diagramm-Knotenkarten, feste `nodeWidth`/`nodeHeight`) und `DevicesView.DeviceRow`/`DeviceColumnHeader` (LAN-Scanner-Tabellenspalten mit festen `DeviceColumn`-Breiten + `lineLimit(1)`). Diese bleiben bewusst fix, sonst Text-Clipping/Überlappung.
- Mechanismus per `ImageRenderer`-Snapshot verifiziert (Basis-Text bei Skalierungsfaktor 0,85 vs. 1,4 rendert nachweislich unterschiedlich groß), bevor erneut um Live-Test gebeten wurde.
Build grün, alle 98 Unit-Tests grün. Bitte nochmal live testen — diesmal an Form-lastigen Stellen (z.B. Settings-Fenster selbst, LAN-Scanner-Überschriften/Fehlertexte, nicht die zwei bewusst fixen Ausnahmen).
### 6. Mode-Button Setup
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#8](http://192.168.178.222:3500/kay/RouterOS/issues/8)
Wunsch: an der Seite des Router ist eine Taste "Mode", diese ist belegbar (Scripte, Deaktivierung, etc). Einlesen, was geht, und bauen.
Recherche (help.mikrotik.com, nicht geraten): `/system routerboard mode-button` ist ein Singleton-Einstellungsmenü (kein Listen-Menü) mit drei echten Feldern:
- `enabled` (yes/no, Standard no)
- `on-event` (Name eines Skripts aus `/system script`)
- `hold-time` (Zeitspanne Min..Max, z.B. `3s..5s`, verfügbar ab RouterOS 6.47beta60)
Wichtiger Fund: ab RouterOS 7.1rc4 muss jede Aktivierung/Änderung dieser Einstellung zusätzlich per physischem Tastendruck (Reset- oder Mode-Taste) am Gerät selbst innerhalb von 60 Sekunden bestätigt werden — eine Änderung allein über die App reicht nicht. Als `warning` im Schema hinterlegt, damit das im Experte-Tab sichtbar ist.
Umsetzung: neuer Eintrag in `RouterOSSchemaCatalog.systemFamily` (`/system routerboard mode-button`, `category: .system`, `isSingleton: true`). `on-event` nutzt `.menuItemPick(menuPath: "/system script", ...)` (wie beim bestehenden Zeitplaner) statt Freitext — zeigt eine Auswahl der bereits angelegten Skripte. `hold-time` als `.text` (kein eigener Range-Editor gebaut, dafür wäre ein neuer `RouterOSFieldSchema.Kind` nötig — Skalierungsaufwand für ein einzelnes Feld aktuell nicht gerechtfertigt, Format steht im Hilfetext). DE/EN-Übersetzungen ergänzt. Erreichbar direkt im Experte-Tab unter "System".
Build grün, alle 98 Unit-Tests grün. Nicht live geprüft (kein Router mit Mode-Taste hier erreichbar) — bitte am echten Gerät: Skript unter "Skripte" anlegen, dann unter "Mode-Taste" auswählen + aktivieren, danach die physische Bestätigung am Gerät nicht vergessen (siehe Warnhinweis).
Nachbesserung 1 (Live-Test beim Skript-Anlegen, nicht Mode-Taste selbst — Bug 40): Skript-Anlegen im Experte-Tab hing dauerhaft an "Unbekannter SSH-Schlüssel ... Bitte bestätigen", auch nach mehrfachem "Anlegen"-Klick. Ursache gefunden (Code gelesen, nicht geraten): `ExpertViewModel.saveEditingItem()`/`confirmRemoval()` und `DevicesViewModel.confirmStaticAssignment()`/`confirmStaticRemoval()` rufen vor dem eigentlichen Schreiben `ensureSessionBackup()` — die erste Sicherung pro Sitzung läuft über `BackupService`s eigene, dedizierte SSH-Verbindung. `ConnectionService.verifySSHTrust` prüft den SSH-Host-Key zwar proaktiv direkt nach jedem erfolgreichen REST-Connect (Bug 13/37-Fix), aber wenn dieser proaktive Check aus irgendeinem Grund nicht (mehr) greift, hatte der Schreibpfad selbst keine Möglichkeit, `pendingSSHTrustFingerprint` zu setzen — der Fehler landete nur als toter Text in `applyError`, ohne Trust-Dialog, exakt derselbe Grundfehler wie Bug 13/37, nur diesmal beim Schreiben statt beim Verbinden.
Fix: neue `ConnectionService.noteUntrustedSSHHostKey(_:)` — alle vier oben genannten Catch-Blöcke rufen sie bei `RouterOSError.untrustedSSHHostKey` auf, wodurch derselbe Trust-Dialog im Verbinden-Tab erscheint, den `verifySSHTrust` auch nutzt. Bitte nach Rebuild: nochmal "Anlegen" versuchen, diesmal im Verbinden-Tab nach einer Trust-Bestätigung schauen, dort bestätigen, dann im Experte-Tab erneut "Anlegen".
Build grün, alle 98 Unit-Tests grün. Nicht live geprüft (kein Router hier erreichbar).
Nachbesserung 2 (Live-Test: Mode-Taste speichern → "Fehlermeldung HTTP 500", gesendeter Befehl laut Vorschau-Anzeige `hold-time=""`): mit echtem Diagnose-Unit-Test nachgestellt statt weiter spekuliert — bei realistischer Datenlage (Router-GET ohne "hold-time"-Feld) entsteht kein leerer Wert, also kein einfacher "Feld war nie gesetzt"-Fall. Tatsächliche Ursache bleibt ungeklärt (kein Router zum Nachstellen erreichbar), aber der Effekt ist reproduzierbar sichtbar: ein leeres `hold-time=""` geht raus und lässt RouterOS' REST-API mit HTTP 500 abbrechen (Time-Interval-Range-Typ akzeptiert wohl keinen leeren String, anders als ein reines Textfeld).
Fix: neuer `RouterOSFieldSchema.clearable`-Parameter (Default `true`, bestehendes Verhalten für alle anderen Felder unverändert) — ein Feld mit `clearable: false` schickt nie ein explizites Leeren (`feld=""`), selbst wenn die Diff-Logik das sonst täte. `hold-time` ist jetzt `clearable: false`. Neuer Test `testNonClearableFieldNeverSendsExplicitEmptyValue` deckt das ab, bestehender `testClearingACuratedFieldSendsExplicitEmptyValue` (Kommentar-Feld leeren) bleibt unverändert grün — betrifft also nur `hold-time`, nicht die generelle "Feld leeren"-Funktion.
Build grün, alle 99 Unit-Tests grün. Bitte nochmal live testen: Mode-Taste mit "Aktiviert" + Skript speichern, "Haltedauer" leer lassen.
Nachbesserung 3 (Live-Test: Fehler blieb identisch, obwohl `hold-time` laut Vorschau-Anzeige jetzt korrekt weggelassen wurde — bewies, dass Nachbesserung 2 zwar wirkt, aber nicht die Ursache war): User führte denselben Befehl direkt per SSH-Terminal am Router aus (`/system routerboard mode-button set enabled=yes on-event="Button test"`) — dort lief er sofort fehlerfrei, `print` bestätigte alle drei Felder korrekt gesetzt (inkl. Werks-Default `hold-time: 0s..1m`, den es demnach doch gibt — passt zur ursprünglichen Vermutung in Nachbesserung 2, war aber am Ende nicht die eigentliche Fehlerursache). Damit zweifelsfrei bewiesen: `/system routerboard mode-button` funktioniert über SSH einwandfrei, aber nicht über REST — eine echte Deckungslücke in RouterOS' REST-API für dieses Hardware-Menü, kein App-seitiger Datenfehler.
Fix: neuer `RouterOSMenuSchema.writesRequireSSH`-Parameter (Default `false`) + `ConnectionService.applyViaSSH(_:)` (dedizierte SSH-Verbindung, exakt dasselbe Muster wie `BackupService`/`UpdateService`). `ExpertViewModel.saveEditingItem()` nutzt für als `writesRequireSSH: true` markierte Schemas jetzt immer diese dedizierte SSH-Verbindung statt der Session-Transport (REST oder SSH, je nachdem wie verbunden wurde). Mode-Taste-Schema entsprechend markiert.
Build grün, alle 99 Unit-Tests grün. Live bestätigt: Mode-Taste-Speichern über die App lief jetzt fehlerfrei (kein HTTP 500 mehr).
Nachbesserung 4 (Log zeigte nach Tastendruck: `script,error executing script Button test from sys2 failed, please check it manually (not enough permissions)`) — separates, tieferes RouterOS-Problem, nicht mehr die App/REST-Frage: Skripte, die vom Mode-Button ("sys2"-Kontext) ausgelöst werden, laufen mit eingeschränkten Rechten, nicht mit den vollen Standard-Skript-Rechten. Zwei Versuche schlugen fehl, weil sie nie wirklich beim Router ankamen: der Skriptname war tatsächlich "Button test" (großes B), meine Terminal-Befehle zielten auf "button test" (klein) bzw. nutzten ungültige `set`-Syntax ohne `[find ...]`/Index — RouterOS matchte nichts, `print detail` zeigte danach unverändert `dont-require-permissions=no`. Mit korrektem Index-Befehl (`/system script set 0 dont-require-permissions=yes`) griff es sofort — Log zeigt seither saubere `script,info Mode-Taste gedrückt: ...`-Einträge bei jedem Tastendruck.
**Fazit für jedes künftige Mode-Taste-Skript**: `dont-require-permissions=yes` setzen, sonst schlägt die Ausführung vom Button aus mit "not enough permissions" fehl, obwohl dasselbe Skript manuell/per Scheduler einwandfrei liefe. Die App hat dafür aktuell noch kein Feld im "Skripte"-Schema (`/system script` kennt nur Name+Inhalt) — bei Bedarf als eigener Punkt ergänzbar (`policy`/`dont-require-permissions`-Felder).
Mode-Taste (Fund #6) ist damit vollständig live verifiziert: Anlegen über App → Skript korrekt zugewiesen → Speichern ohne Fehler → Tastendruck löst Skript korrekt aus.
### 9. Übersicht: Spaltenüberschriften fehlten im Fokus-Popup
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#11](http://192.168.178.222:3500/kay/RouterOS/issues/11)
Wunsch: Fokus-Popup (Klick auf Knoten in der Übersicht) soll dieselben Spaltenüberschriften (Interfaces, IP-Adressen, Pools & DHCP, Routen, Firewall & NAT) über den Knoten zeigen wie das Hauptdiagramm.
Ursache: `focusPanel(subGraph:subLayout:)` in `OverviewView.swift` rendert Kanten+Knoten der Kette per `OverviewLayout.layout(for:)` (gleiche Spalten-Logik wie oben), hatte aber nie den Header-`ForEach` über `OverviewLayout.columnOrder` übernommen, der im Hauptdiagramm dafür sorgt. `OverviewLayout.topInset` (44pt) reserviert bereits ausreichend Platz oberhalb der Knoten — Header-Block 1:1 aus dem Hauptdiagramm übernommen, keine Größenanpassung nötig.
Build+alle 99 Unit-Tests grün, live bestätigt.
### 7. Manual direkt in die App integrieren
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#9](http://192.168.178.222:3500/kay/RouterOS/issues/9)
Wunsch: Manual so in die App integrieren, dass ein Hilfepunkt in der aktuell geöffneten Sektion direkt zur passenden Stelle im Manual springt (Textanker).
Rückfrage vorab geklärt (AskUserQuestion): eingebettetes HTML (WebView) statt PDF/PDFKit; eigenes Hilfe-Fenster (wie Settings ⌘,) statt eigener Tab; Sprungmarken pro Wizard-Schritt + pro Experte-Schema, nicht nur pro Haupt-Tab.
Umsetzung:
- **`build-manual.py`**: `<a id="schema-...">`-Anker automatisch vor jede Experte-Menü-Überschrift eingefügt (`schema_anchor(menuPath)`, deterministisch aus dem RouterOS-Menüpfad). `Manual.md` bekam zusätzlich 12 handgesetzte `<a id="tab-..."/"step-...">`-Anker vor den 6 Haupt-Tab- und 6 Wizard-Schritt-Überschriften. Neue Funktion `render_app_html()` erzeugt ein eigenständiges `RouterOSAssistant/Resources/Manual.html` (Bilder als Base64 eingebettet, kein `Manual-assets/`-Ordner nötig im App-Bundle) — MarkdownIt jetzt mit `html=True`, sonst wären die `<a id>`-Tags als Text escaped statt als echte DOM-IDs zu rendern.
- **`ManualView.swift`** (neu, `Features/Manual/`): `ManualAnchor`-Enum mit den 12 statischen Ankern + `schema(menuPath:)` (spiegelt `schema_anchor()` exakt — kein generiertes Mapping nötig, beide Seiten wenden dieselbe simple Transformation an). `ManualNavigator` (ObservableObject, app-weit als `@StateObject` in `RouterOSAssistantApp`) hält den angeforderten Anker. `ManualWebView` (WKWebView via `NSViewRepresentable`) lädt das gebündelte `Manual.html` einmalig, springt bei Anker-Änderung per JavaScript `scrollIntoView` (kein Reload/Flackern). Neues `Window("Handbuch", id: "manual")` in `RouterOSAssistantApp.swift`.
- **`ManualHelpButton`**: wiederverwendbarer "?"-Toolbar-Button, öffnet/fokussiert das Handbuch-Fenster und springt zum übergebenen Anker. Eingebaut in: alle 6 Haupt-Tabs (Verbinden, Übersicht, LAN-Scanner, Experte, Sicherungen — je Toolbar), alle 6 Wizard-Schritte (WAN/LAN/VLAN/WLAN/Firewall/Review), und `ExpertMenuDetailView` bekommt pro geöffnetem Schema automatisch den passenden Anker (`ManualAnchor.schema(schema.menuPath)`) — alle 45 Experte-Menüs sind damit einzeln verlinkt, nicht nur der Experte-Tab pauschal.
Bewusst nicht umgesetzt: Settings-Fenster (⌘,) hat noch keinen Hilfe-Button — macOS-Settings-Fenster haben konventionell keine Toolbar, ein Button dort hätte nicht ins native Bild gepasst; bei Bedarf nachrüstbar (Kapitel 7 im Manual existiert bereits, Anker `tab-settings` ist gesetzt).
Build grün, alle 99 Unit-Tests grün, `Manual.html` bestätigt im App-Bundle (`Contents/Resources/Manual.html`). Noch nicht live geprüft — bitte: Hilfe-Buttons in mehreren Tabs/Wizard-Schritten/Experte-Menüs anklicken, prüfen ob das Handbuch-Fenster öffnet und zur richtigen Stelle springt.
Nachbesserung 1 (User-Feedback: "das manual schaltet aber nicht die Sprache in englisch um, denk dran weitere Sprachen folgen"): Rückfrage geklärt — komplette Handübersetzung aller Kapitel (nicht nur Kapitel 5 automatisch), Mechanismus generisch für beliebig viele Sprachen statt hart DE/EN.
Umsetzung:
- **`Manual.en.md`** (neu): vollständige Handübersetzung aller Fließtext-Kapitel (0,1,2,3,4,6,7) — dieselbe Struktur/Anker wie `Manual.md`. Kapitel 8 "English summary" (bisheriger Behelf) aus `Manual.md` entfernt, da jetzt redundant.
- **`build-manual.py`** generalisiert: neues `LANGUAGES`-Dict (`{"de": Manual.md, "en": Manual.en.md}`, für weitere Sprachen nur ein neuer Eintrag + `Manual.<code>.md` nötig, kein neuer Python-Code). Kapitel 5 (Experte-Referenz, automatisch generiert) wird jetzt pro Sprache übersetzt, indem `L10n.swift`s eigenes DE→EN-Übersetzungs-Dictionary der App selbst wiederverwendet wird (per Regex aus dem Swift-Quelltext geparst, 714 Einträge) — Feldlabels/Hilfetexte im Manual stimmen dadurch exakt mit dem überein, was die App in Englisch zeigt, keine zweite, separat gepflegte Übersetzung. Bug beim ersten Anlauf: Klammersuche fand die falsche `[` (die des Typannotation `[String: String]`, nicht die des Array-Literals) — 0 Übersetzungen geladen, dadurch blieb Kapitel 5 komplett Deutsch. Gefixt, Gegenprobe: 714 Einträge geladen.
- **`ManualView.swift`**: `ManualWebView` lädt jetzt `Manual.html` (de) oder `Manual_<sprache>.html` (alles andere), abhängig von `@AppStorage("appLanguage")`, mit Fallback auf Deutsch falls eine Sprache (noch) keine Übersetzung hat. Lädt bei Sprachwechsel neu (`Coordinator` merkt sich die zuletzt geladene Sprache), springt danach weiterhin zum aktuellen Anker.
Build grün, alle 99 Unit-Tests grün, `Manual.html` + `Manual_en.html` beide bestätigt im App-Bundle. Bitte nochmal live testen: Sprache auf Englisch umschalten, Hilfe-Button klicken, prüfen ob Handbuch-Fenster jetzt englisch anzeigt.
### 8. Abwechselnde Farbkombis bei Tabellenansichten
**Status:** fixed (live bestätigt: "passt")
**Gitea-Issue:** [#10](http://192.168.178.222:3500/kay/RouterOS/issues/10)
Bessere Lesbarkeit durch alternierende Zeilenfarben in Tabellenansichten (LAN-Scanner, Experte-Listen, etc.).
Umsetzung: neuer `TableZebra`-Helper (`AppPreferences.swift`) nutzt AppKits eigenes `NSColor.alternatingContentBackgroundColors` (dieselbe Farbpaar, das `NSTableView` fürs Zebra-Streifenmuster verwendet) statt einer selbst gewählten Grauabstufung — passt sich automatisch an Hell/Dunkelmodus an. Angewendet über `.listRowBackground(TableZebra.color(for: index))` auf: LAN-Scanner-Geräte-Zeilen (`DevicesView.swift`, pro Port-Gruppe neu bei Index 0 startend) und Experte-Tab-Eintragsliste (`ExpertMenuDetailView.swift`).
Bewusst nicht umgesetzt: "Weitere Parameter (frei)"-Grid im Experte-Bearbeiten-Sheet (Bug 2) — dort läuft die Zeilenzählung zweispaltig (zwei Einträge pro Grid-Zeile), Zebra-Streifen würden dort eher verwirren als helfen; bei Bedarf separat nachziehbar.
Build grün, alle 99 Unit-Tests grün.
Nachbesserung 1 (Live-Test: "keine Änderungen erkennbar") — Ursache mit Beweis gefunden statt weiter geraten: erst per `ImageRenderer`-Snapshot versucht (schlug fehl, `Form` rendert im Headless-Kontext leer — kein Ergebnis dazu), dann per Doku-Recherche bestätigt: `.formStyle(.grouped)` malt einen eigenen blickdichten Hintergrund über jede Section, der `.listRowBackground` komplett verdeckt — bekanntes, dokumentiertes SwiftUI-Verhalten, kein Fehler in der Farbberechnung. Fix: `.scrollContentBackground(.hidden)` auf beiden betroffenen `Form`s ergänzt (`DevicesView.swift`, `ExpertMenuDetailView.swift`), Standard-Kombination für genau diesen Fall.
Build grün, alle 99 Unit-Tests grün. Achtung beim Live-Test: kann auch den "Karten"-Hintergrund der übrigen Form-Bereiche (nicht nur die Zeilen) sichtbar verändern — bitte Gesamteindruck prüfen, nicht nur ob Streifen da sind.
Nachbesserung 2 (User-Feedback: auch das "Weitere Parameter"-Grid im Experte-Bearbeiten-Sheet soll Zeilen abwechselnd einfärben — Zeile 1,3,5... —, "bitte für das komplette Projekt umsetzen"): Grid-Zeilen (2 Parameter pro sichtbarer Zeile) bekommen jetzt `.background(TableZebra.color(for: rowIndex))` pro `GridRow`. Zusätzlich auf jede weitere echte Tabellen-/Listenansicht im Projekt ausgeweitet: "Bekannte Router"-Liste + Interface-Liste (Verbinden-Tab), Sicherungsliste (Sicherungen-Tab), Rohfelder-Sheet + Port-Scan-Ergebnisliste (LAN-Scanner), geplante Änderungen + Ablauf-Log (Einrichten-Wizard Review/Apply), Feld-Details + Verbindungsliste im Übersicht-Knoten-Detailpanel. Nicht angefasst: Experte-Sidebar (Navigationsliste, keine Datentabelle) und die Setup-Wizard-Konfigurationsformulare (VLAN/WLAN/LAN/WAN — je Zeile ein Mehrfeld-Unterformular, keine gleichförmigen Datenzeilen, Zebra-Streifen würden dort eher verwirren).
Build grün, alle 99 Unit-Tests grün. Bitte erneut live testen.
### 10. Selbständiger Wiederverbindungsversuch nach Disconnect
**Status:** fixed (live bestätigt, zusätzlich beim echten Firmware-Update-Neustart mitgetestet)
**Gitea-Issue:** [#12](http://192.168.178.222:3500/kay/RouterOS/issues/12)
Wunsch: fällt die Verbindung zum Router weg (z.B. während einer laufenden Sitzung), soll die App selbständig versuchen, die Verbindung wiederherzustellen, statt einfach im getrennten Zustand zu bleiben.
Ist-Zustand vor dem Fix: `ConnectionService.state` blieb `.connected`, bis der Nutzer explizit "Trennen" klickte — es gab überhaupt keinen Mechanismus, der einen tatsächlichen Verbindungsverlust (Router aus, Kabel raus, WLAN weg) erkannt hätte. Einzelne fehlschlagende Anfragen zeigten nur lokale Fehlermeldungen pro Tab, der App-weite Zustand blieb unberührt.
Umsetzung (`ConnectionService.swift`):
- Neuer Hintergrund-Task (`healthMonitorTask`), gestartet bei jeder erfolgreichen Verbindung: alle 10s ein minimaler Lese-Test (`/system identity`, kleinstmögliches Singleton-Menü) als Herzschlag.
- Schlägt der Herzschlag fehl: `isReconnecting = true`, danach Retry-Schleife alle 5s (REST zuerst, dann SSH-Fallback, dieselbe Reihenfolge wie ein normaler Connect) — läuft unbegrenzt weiter, bis entweder die Verbindung wiederhergestellt ist oder der Nutzer explizit "Trennen" klickt (bricht den Task sofort ab).
- `state` bleibt bewusst durchgehend `.connected`, damit andere Tabs während eines kurzen Aussetzers nicht auf ihre "Nicht verbunden"-Platzhalter umspringen — nur `isReconnecting` (neues `@Published`) spiegelt den Zustand, sichtbar als oranger Hinweis mit Spinner oben im Verbinden-Tab ("Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…").
- Reconnect nutzt bei Erfolg denselben `finishConnecting(using:)`-Pfad wie ein normaler Connect — Geräte-/Routerboard-Info und Interface-Liste werden dabei automatisch neu geladen.
Build grün, alle 99 Unit-Tests grün. Live bestätigt ("funktion").
Nachbesserung 1 (User-Wunsch: "ein countdown, während wiederverbindens noch mit einbauen und wieviel versuche bereits gelaufen sind"): zwei neue `@Published`-Werte in `ConnectionService``reconnectAttemptCount` (hochgezählt pro REST+SSH-Runde) und `secondsUntilNextReconnectAttempt` (sekündlich runtergezählt zwischen den Versuchen, `nil` während ein Versuch tatsächlich läuft). Banner im Verbinden-Tab zeigt jetzt eine zweite, kleinere Zeile: "Versuch 3 · nächster in 4s" (bzw. "Versuch 3 …" während der Verbindungsversuch selbst läuft).
Build grün, alle 99 Unit-Tests grün. Bitte nochmal live testen.
### 11. Systematischer Tester-Durchgang (bugs.md, 2026-09-17)
**Status:** fixed (Build + Tests grün, noch nicht live gegenreproduziert)
Auf Nutzerwunsch ("teste alle Funktionalitäten, suche bugs") Code-Audit gegen den echten
hAP-lite-Testrouter durchgeführt (UI selbst nicht klickbar, keine macOS-UI-Automatisierung
verfügbar — stattdessen Code-Review + Router-Ground-Truth per SSH). Vier Funde in `bugs.md`
dokumentiert und direkt gefixt:
1. Race Condition bei der Port-Konflikt-Prüfung im LAN-Schritt (`SetupViewModel.checkPortConflict`)
— schneller Portwechsel konnte ein überholtes Prüfergebnis über ein aktuelleres schreiben.
Fix: Generation-Zähler pro LAN-Config, überholte Antworten werden verworfen.
2. `FirewallStepView`-Titel behauptete "Firewall (optional)" auch im Einfach-Modus, wo der
Grundschutz laut Text direkt darunter fest aktiv ist. Fix: Titel modusabhängig.
3. Zwei fehlende Englisch-Übersetzungen (skriptgeprüft gegen alle `L10n.t(...)`-Aufrufstellen):
"Netzwerk-Test fehlgeschlagen", "Fokus-Ansicht schließen". Ergänzt in `L10n.swift`.
4. Health-Check-Herzschlag (`ConnectionService`, alle 10s) prüfte laufende Setup-Wizard-/
Experte-Schreibvorgänge nicht mit — auf schwacher Hardware (hAP lite: 1 Kern, 650MHz) konnte
ein langsamer, aber erfolgreicher Apply fälschlich als Verbindungsverlust gewertet werden.
Fix: neuer `beginWrite()`/`endWrite()`-Zähler, Herzschlag pausiert währenddessen.
Build grün, alle 99 Unit-Tests grün. Details je Fund in `bugs.md`.
### 12. Erneuter Deep-Dive-Gegencheck mit echtem Exploit-Nachweis (bugs.md #5-#7, 2026-09-17)
**Status:** fixed (2 kritische Live-Exploits nachgewiesen+gefixt+gegenverifiziert, 1 Beobachtung offen dokumentiert)
Auf Nutzerwunsch ("erneuter Gegencheck mit echtem Deep Dive, alles testen") tiefer weitergesucht,
diesmal mit echten Exploit-Versuchen gegen den Router statt nur Code-Lesen:
- **#5 RouterOS-CLI-Injection** (schwerwiegendster Fund der ganzen Session): `RouterOSCommand.cliLine`
quotete Werte nur bei Leerzeichen und escapte nie eingebettete `"`. Ein Kommentar wie
`test" ; :log warning "X` schloss das Quoting vorzeitig und ließ RouterOS den Rest als zweiten,
unabhängigen Befehl ausführen — live exploitiert (Log zeigt injizierten `script,warning`-Eintrag)
und live als behoben bestätigt (kein injizierter Eintrag mehr, Kommentar korrekt gespeichert).
Betraf jeden Text im Setup-Wizard/Experte-Tab, sobald über SSH geschrieben wird — und auf dem
aktuellen Testrouter ist `www-ssl` deaktiviert, REST also gar nicht erreichbar: jede
Schreiboperation lief bereits über SSH, der Bug war aktiv, nicht nur theoretisch.
- **#6 Generischer Parser trunkiert mehrwortige Werte**: `print terse` quotet in RouterOS 7.24.4
gar keine mehrwortigen Werte (live an zwei Menüs bestätigt) — der alte Regex-Parser schnitt
jeden solchen Wert beim ersten Leerzeichen ab, unbemerkt seit Session-Beginn (war bereits im
eigenen Live-Dump-Testcode sichtbar, fiel aber nie auf). Fix: Token-Scan statt Regex.
- **#7 Beobachtung, kein Fix**: Netzwerk-Isolation wirkt nicht rückwirkend auf bereits bestehende
(conntrack-getrackte) Verbindungen — Standardverhalten jeder stateful Firewall, nie getestet,
bewusst nicht automatisch "gefixt" (Conntrack-Flush wäre eine Produktentscheidung, kein reiner
Bugfix).
Build grün, alle 101 Unit-Tests grün (2 neue Regressionstests). Details in `bugs.md`.
### 13. Dritter Durchgang: gezielte Sicherheitshärtung (bugs.md #8-#9, 2026-09-17)
**Status:** fixed (2 Härtungsfixes, keine weiteren Live-Exploits gefunden)
Auf Nutzerwunsch ("finale Test für maximale Sicherheit, test alles was du finden kannst") gezielt
Zugangsdaten-Speicherung und TOFU-Mechanismen geprüft. Positiv bestätigt: Keychain-Nutzung korrekt,
keine Passwörter in UserDefaults/JSON, `BackupService`s eigene Skript-Escaping-Logik war bereits
vor diesem Durchgang korrekt. Zwei Härtungslücken gefunden und geschlossen (beide defensiv, nicht
live exploitiert — anders als #5/#6 im vorigen Durchgang):
- **#8**: `RestTransport.fetchFieldValues` hatte dieselbe ungeschützte String-Interpolation wie das
SSH-Pendant aus Fund #5, nur als URL-Query-String statt CLI-Zeile — beim ersten Fix übersehen.
Jetzt RFC-3986-konform percent-encoded.
- **#9**: `CertificateFingerprint.sha256` fiel bei Extraktionsfehlern auf einen festen String
"unbekannt" zurück statt einen echten Fingerabdruck zu liefern — theoretisches TOFU-Bypass-Fenster
(zwei verschiedene, beide extraktions-fehlschlagende Zertifikate hätten sich denselben
"Fingerabdruck" geteilt). Rückgabetyp auf optional geändert, Extraktionsfehler führt jetzt zu
hartem Verbindungsabbruch statt einem Vertrauens-Dialog mit unverifizierbarer Kennung.
Build grün, alle 101 Unit-Tests grün. Details in `bugs.md`.
### 14. README-Nachcheck: offener Milestone (Port-Konflikt/"Fertig") + 3 Alert-Dismiss-Bugs
**Status:** fixed (Build + 102 Unit-Tests grün, Live-Klicktest weiterhin offen)
Nutzer wies auf einen noch offenen Milestone in der README hin (LAN-Port-Konflikt-Prüfung +
"Fertig"-Button, nie live durchgeklickt). Beim erneuten Code-Review dieses Bereichs (u.a. wegen
des in dieser Session bereits gefixten Race-Bugs in genau dieser Feature) drei echte,
eigenständige Bugs derselben Klasse gefunden:
1. `ReviewApplyView`s "Anwenden fehlgeschlagen"-Alert: OK-Button-Action war leer, die
`isPresented`-Bindings-Setter-Closure ebenfalls ein No-Op — `applyError` wurde nie
zurückgesetzt, der Alert konnte sich nach dem Schließen theoretisch sofort wieder öffnen.
2. `ConnectView`s "Unbekanntes Zertifikat"-Alert: "Abbrechen"-Button-Action war komplett leer —
`connectionService.state` blieb für immer auf `.needsCertificateConfirmation` hängen, es gab
keinen Weg zurück außer dem Zertifikat zu vertrauen.
3. `ConnectView`s "Unbekannter SSH-Schlüssel"-Alert: "Abbrechen" rief nur
`dismissPendingSSHTrust()` (löschte nur `pendingSSHTrustFingerprint`) — funktionierte nur für
einen von zwei möglichen Auslöse-Pfaden dieses Alerts, beim anderen (`state ==
.needsSSHHostKeyConfirmation`) blieb der Dialog hängen.
Fix: neue `ConnectionService.cancelPendingTrustConfirmation()` (setzt `state` bei beiden
"needs...Confirmation"-Fällen auf `.idle` zurück, löscht zusätzlich
`pendingSSHTrustFingerprint`), neue `SetupViewModel.dismissApplyError()`. Alle drei Alerts
verdrahtet — sowohl der jeweilige Cancel/OK-Button als auch die Bindings-Setter-Closure (deckt
auch Tap-Outside/Esc-Dismiss ab). Totes `dismissPendingSSHTrust()` entfernt. Ein neuer
Regressionstest (`testCancelPendingTrustConfirmationResetsCertificateConfirmationToIdle`).
Build grün, alle 102 Unit-Tests grün. Der ursprünglich gemeldete Milestone (Port-Konflikt-Prüfung
selbst, inkl. Warndialoge, "Weiter"-Sperre, "Fertig"-Button) bleibt beim Status "Code-Review
bestätigt" — ein echter Live-Klicktest durch den Nutzer in der App-UI steht weiterhin aus, da
UI-Automatisierung in dieser Session nicht verfügbar ist.
### 15. bugs.md #7 bearbeitet: Isolation trennt jetzt auch bereits bestehende Verbindungen
**Status:** fixed (best-effort, Mechanismus teilweise live verifiziert)
**Gitea-Issue:** [#19](http://192.168.178.222:3500/kay/RouterOS/issues/19)
Auf Nutzerwunsch ("bearbeite #7") den zuvor bewusst zurückgestellten Punkt jetzt umgesetzt: neue
`SSHTransport.flushConnections`/`ConnectionService.flushConnections` entfernen per `/ip firewall
connection remove [find where (src-address in A) and (dst-address in B)]` bereits getrackte
Verbindungen zwischen zwei Netzen, sobald sie als isoliert angewendet werden — aufgerufen direkt
nach den Firewall-Befehlen in `SetupViewModel.apply()`.
Bemerkenswert am Weg dorthin: eine erste Live-Verifikation sah erfolgreich aus (Test-Verbindung
verschwand nach `remove`), erwies sich bei genauerem Hinsehen aber als Messfehler — die
ICMP-Test-Verbindung war einfach von selbst abgelaufen (RouterOS' sehr kurzer ICMP-Conntrack-
Timeout), nicht durch den `remove`-Befehl entfernt worden. Ein sauberer Nachtest an einer
tatsächlich noch aktiven TCP-Verbindung zeigte den Eintrag sofort wieder auftauchen. Root Cause
geklärt (Web-Recherche + Verhalten selbst nachvollzogen): Connection-Tracking-Removal sendet kein
RST, eine aktiv weiterlaufende Verbindung wird beim nächsten Paket einfach neu getrackt — kein
Beweis, dass `remove` nichts tut, aber auch kein Beweis, dass die neue Isolations-Regel das neu
getrackte Paket abfängt. Für den vollständigen Beweis fehlen zwei echte, getrennte Testnetze mit
echten Endgeräten. Dokumentation entsprechend ehrlich mit dem tatsächlichen Verifikationsstand
statt einer überzogenen "live bestätigt"-Behauptung versehen (siehe `bugs.md` #7 für die volle
Herleitung).
Build grün, alle 103 Unit-Tests grün (1 neuer Regressionstest für `FirewallConfig.isolatedNetworkPairs`).
### 16. Experte-Tab: Port-Konflikt-Prüfung wie im Einrichten-Assistenten
**Status:** fixed (Build + 111 Unit-Tests grün, 6 neue Regressionstests)
Live-Anlass: Nutzer legte über den Experte-Tab manuell ein eigenes Netz auf `ether4` an
(IP-Adresse, Pool, DHCP-Server) — `ether4` blieb dabei unbemerkt Bridge-Mitglied der Haupt-Bridge,
weshalb zwei DHCP-Server im selben Broadcast-Domain konkurrierten (kein App-Bug, aber genau der
Fall, den die Wizard-eigene Port-Konflikt-Prüfung normalerweise abfängt — beim manuellen Anlegen
über den Experte-Tab gab es diese Warnung bisher nicht).
Auf Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen
Warnungen") dieselbe Prüfung samt "Port freimachen?"-Dialog jetzt auch im Experte-Tab, angewendet
auf das `.interfacePick`-Feld des jeweils geöffneten Schemas (`/ip address`, `/ip dhcp-server`
etc.). `PortConflictWarningView` aus `LanStepView.swift` in `Features/Shared/` extrahiert, neuer
`immediateApply`-Parameter für die kontextabhängige Abschluss-Meldung (Wizard: erst bei "Jetzt
anwenden"; Experte-Tab: sofort bei "Anlegen"/"Speichern", da es dort keinen separaten Review-
Schritt gibt). Neue `PortConflict.resolutionCommandsIncludingBridgeDetach()` — anders als der
Wizard hat der Experte-Tab keinen automatischen, unbedingten Bridge-Detach-Schritt
(`DhcpServerCommandBuilder`), muss die Bridge-Entfernung bei Bestätigung also selbst mit
ausführen. `ExpertViewModel` bekommt dieselbe race-sichere Generation-Zähler-Logik wie
`SetupViewModel` (bugs.md #1). Speichern-Button gesperrt, bis der Konflikt bestätigt oder ein
anderer Port gewählt wurde.
Build grün, alle 111 Unit-Tests grün (6 neue Regressionstests). Live im Experte-Tab durchgeklickt
vom Nutzer bestätigt ("funktioniert").
+8
View File
@@ -29,6 +29,14 @@ targets:
path: RouterOSAssistant/Info.plist
properties:
CFBundleDisplayName: RouterOS Assistant
# xcodegen regenerates this whole file from these properties (plus its own
# defaults for anything unlisted) every time it runs — NOT a merge with
# whatever's already on disk. Without these two explicit here, `xcodegen
# generate` silently resets the app's version back to its built-in default
# "1.0"/"1", discarding any release version bump (found live, 2026-09-17,
# while adding a new test file triggered a regen and reverted v1.1.0 -> 1.0).
CFBundleShortVersionString: "1.2.0"
CFBundleVersion: "3"
LSApplicationCategoryType: public.app-category.utilities
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true