diff --git a/HANDOFF.md b/HANDOFF.md index 87bb5e6..a560f31 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -996,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 30–32). 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 22–24, 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 @@ -1632,9 +1637,10 @@ 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. +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 diff --git a/README.md b/README.md index 2b8026c..70da226 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,9 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund. | M1–M4 | Projektgerüst, Connect, Backup, WAN/LAN/DHCP, VLAN | ✅ live verifiziert | | 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 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) | +| 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 | diff --git a/RouterOSAssistant/Core/Localization/L10n.swift b/RouterOSAssistant/Core/Localization/L10n.swift index 08773ec..bcb1575 100644 --- a/RouterOSAssistant/Core/Localization/L10n.swift +++ b/RouterOSAssistant/Core/Localization/L10n.swift @@ -15,6 +15,9 @@ 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…", diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index 6cfdcd7..21449c0 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -42,6 +42,22 @@ final class ConnectionService: ObservableObject { private var healthMonitorTask: Task? 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 @@ -271,7 +287,7 @@ final class ConnectionService: ObservableObject { } private func checkConnectionHealthAndReconnectIfNeeded() async { - guard case .connected = state, !isReconnecting, + guard case .connected = state, !isReconnecting, activeWriteCount == 0, let activeTransport, let credentials else { return } do { _ = try await activeTransport.fetchMenuItems(menuPath: "/system identity", restPath: "system/identity") diff --git a/RouterOSAssistant/Features/Expert/ExpertViewModel.swift b/RouterOSAssistant/Features/Expert/ExpertViewModel.swift index b0d201a..af45b58 100644 --- a/RouterOSAssistant/Features/Expert/ExpertViewModel.swift +++ b/RouterOSAssistant/Features/Expert/ExpertViewModel.swift @@ -217,6 +217,8 @@ final class ExpertViewModel: ObservableObject { guard let command = pendingCommand else { return } isApplying = true applyError = nil + connectionService.beginWrite() + defer { connectionService.endWrite() } do { try await ensureSessionBackup() if selectedSchema?.writesRequireSSH == true { @@ -239,6 +241,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, diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift index 8e5e9ab..84718cb 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift @@ -60,7 +60,7 @@ 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 { diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift index 8b4e4e5..b9d5c58 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -134,8 +134,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 +150,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) } } @@ -282,6 +299,7 @@ final class SetupViewModel: ObservableObject { lanPortConflicts = [:] isCheckingPortConflict = [] acknowledgedPortConflicts = [] + portConflictRequestGeneration = [:] applyLog = [] applyError = nil didApplySuccessfully = false @@ -316,6 +334,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) diff --git a/bugs.md b/bugs.md new file mode 100644 index 0000000..7726808 --- /dev/null +++ b/bugs.md @@ -0,0 +1,111 @@ +# 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) +**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) +**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 +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) + +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. + +--- + +## 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. diff --git a/found.md b/found.md index f1cab87..c1288a5 100644 --- a/found.md +++ b/found.md @@ -228,3 +228,25 @@ 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`.