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>
This commit is contained in:
@@ -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…",
|
||||
|
||||
@@ -42,6 +42,22 @@ final class ConnectionService: ObservableObject {
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user