diff --git a/Manual.en.md b/Manual.en.md index a1f98ae..1929f37 100644 --- a/Manual.en.md +++ b/Manual.en.md @@ -125,6 +125,17 @@ The dot in front of each interface in the device overview is gray (no link), green (link, but no traffic), or pulsing green (actively transferring data right now). +### Automatic Reconnection + +If the connection drops during an active session (router reboot, +cable/Wi-Fi briefly interrupted), the app tries to restore it on its +own — a heartbeat check every 10 seconds, and on failure a new connect +attempt every 5 seconds (REST first, then SSH), indefinitely until it +succeeds or "Disconnect" is clicked. An orange notice with a spinner +appears in the Connect tab meanwhile, including an attempt counter and a +countdown to the next try. Other tabs stay usable during a brief outage +instead of immediately switching to "Not connected". + --- ## 2. Setup (Wizard) diff --git a/Manual.md b/Manual.md index 87b4170..023ee0d 100644 --- a/Manual.md +++ b/Manual.md @@ -127,6 +127,18 @@ Der Punkt vor jedem Interface in der Geräte-Übersicht ist grau (kein Link), grün (Link, aber kein Datenverkehr) oder pulsierend grün (überträgt gerade tatsächlich Daten). +### Automatisches Wiederverbinden + +Fällt die Verbindung während einer aktiven Sitzung weg (Router-Neustart, +Kabel/WLAN kurz unterbrochen), versucht die App selbständig, sie +wiederherzustellen — alle 10 Sekunden ein Herzschlag-Test, bei Ausfall +alle 5 Sekunden ein neuer Verbindungsversuch (REST zuerst, dann SSH), +unbegrenzt bis zum Erfolg oder bis „Trennen“ geklickt wird. Ein oranger +Hinweis mit Spinner erscheint währenddessen im Verbinden-Tab, inkl. +Versuchszähler und Countdown bis zum nächsten Versuch. Andere Tabs +bleiben während eines kurzen Aussetzers nutzbar, statt sofort auf „Nicht +verbunden“ umzuspringen. + --- ## 2. Einrichten (Wizard) diff --git a/Manual.pdf b/Manual.pdf index 4b01efb..d7d3e5a 100644 Binary files a/Manual.pdf and b/Manual.pdf differ diff --git a/Manual_en.pdf b/Manual_en.pdf index 10eb6ba..c0fe0bf 100644 Binary files a/Manual_en.pdf and b/Manual_en.pdf differ diff --git a/README.md b/README.md index 40a0793..6597376 100644 --- a/README.md +++ b/README.md @@ -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 @@ -171,6 +175,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund. | 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 | Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md). diff --git a/RouterOSAssistant/Core/Localization/L10n.swift b/RouterOSAssistant/Core/Localization/L10n.swift index 61f3350..08773ec 100644 --- a/RouterOSAssistant/Core/Localization/L10n.swift +++ b/RouterOSAssistant/Core/Localization/L10n.swift @@ -16,6 +16,10 @@ enum L10n { private static let translations: [String: String] = [ "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", diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index 521e810..6cfdcd7 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -29,6 +29,19 @@ 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? + private static let healthCheckInterval: Duration = .seconds(10) + private static let reconnectRetryInterval: Duration = .seconds(5) private let certificateTrust: CertificateTrustStore private let sshHostKeyTrust: SSHHostKeyTrustStore @@ -230,11 +243,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, + 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") @@ -251,6 +340,9 @@ final class ConnectionService: ObservableObject { } func disconnect() async { + healthMonitorTask?.cancel() + healthMonitorTask = nil + resetReconnectState() await activeTransport?.disconnect() activeTransport = nil deviceInfo = nil diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift index b4f5c38..5eb54e1 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift @@ -177,6 +177,15 @@ 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 { Binding( get: { certificateFingerprint != nil }, @@ -258,6 +267,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) diff --git a/RouterOSAssistant/Resources/Manual.html b/RouterOSAssistant/Resources/Manual.html index 2206e10..f860140 100644 --- a/RouterOSAssistant/Resources/Manual.html +++ b/RouterOSAssistant/Resources/Manual.html @@ -142,6 +142,16 @@ Liste in sich selbst.

Der Punkt vor jedem Interface in der Geräte-Übersicht ist grau (kein Link), grün (Link, aber kein Datenverkehr) oder pulsierend grün (überträgt gerade tatsächlich Daten).

+

Automatisches Wiederverbinden

+

Fällt die Verbindung während einer aktiven Sitzung weg (Router-Neustart, +Kabel/WLAN kurz unterbrochen), versucht die App selbständig, sie +wiederherzustellen — alle 10 Sekunden ein Herzschlag-Test, bei Ausfall +alle 5 Sekunden ein neuer Verbindungsversuch (REST zuerst, dann SSH), +unbegrenzt bis zum Erfolg oder bis „Trennen“ geklickt wird. Ein oranger +Hinweis mit Spinner erscheint währenddessen im Verbinden-Tab, inkl. +Versuchszähler und Countdown bis zum nächsten Versuch. Andere Tabs +bleiben während eines kurzen Aussetzers nutzbar, statt sofort auf „Nicht +verbunden“ umzuspringen.


2. Einrichten (Wizard)

Geführter Schritt-für-Schritt-Assistent für die Grundkonfiguration. Ein diff --git a/RouterOSAssistant/Resources/Manual_en.html b/RouterOSAssistant/Resources/Manual_en.html index 04e8801..b4df944 100644 --- a/RouterOSAssistant/Resources/Manual_en.html +++ b/RouterOSAssistant/Resources/Manual_en.html @@ -140,6 +140,15 @@ list scrolls in place past about 4 entries.

The dot in front of each interface in the device overview is gray (no link), green (link, but no traffic), or pulsing green (actively transferring data right now).

+

Automatic Reconnection

+

If the connection drops during an active session (router reboot, +cable/Wi-Fi briefly interrupted), the app tries to restore it on its +own — a heartbeat check every 10 seconds, and on failure a new connect +attempt every 5 seconds (REST first, then SSH), indefinitely until it +succeeds or "Disconnect" is clicked. An orange notice with a spinner +appears in the Connect tab meanwhile, including an attempt counter and a +countdown to the next try. Other tabs stay usable during a brief outage +instead of immediately switching to "Not connected".


2. Setup (Wizard)

A guided step-by-step assistant for basic configuration. A mode switch diff --git a/found.md b/found.md index ba6e866..f1cab87 100644 --- a/found.md +++ b/found.md @@ -211,6 +211,20 @@ Nachbesserung 2 (User-Feedback: auch das "Weitere Parameter"-Grid im Experte-Bea Build grün, alle 99 Unit-Tests grün. Bitte erneut live testen. ### 10. Selbständiger Wiederverbindungsversuch nach Disconnect -**Status:** offen +**Status:** fixed (live bestätigt, zusätzlich beim echten Firmware-Update-Neustart mitgetestet) 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.