9 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
15 changed files with 584 additions and 100 deletions
+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).
+4
View File
@@ -179,6 +179,10 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| 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).
@@ -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()
}
}
@@ -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)
@@ -345,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)")
)
}
}
}
@@ -154,6 +154,25 @@ struct ExpertItemEditView: View {
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))
.appFont(.caption)
@@ -223,13 +242,19 @@ struct ExpertItemEditView: View {
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()
}
.confirmationDialog(
L10n.t("Jetzt am Router anwenden?", appLanguage),
isPresented: $showApplyConfirmation,
@@ -249,6 +274,13 @@ 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)
@@ -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
@@ -221,6 +286,14 @@ final class ExpertViewModel: ObservableObject {
defer { connectionService.endWrite() }
do {
try await ensureSessionBackup()
// 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 {
@@ -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")
}
}
@@ -32,6 +32,7 @@ struct LanStepView: View {
conflict: conflict,
isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id),
appLanguage: appLanguage,
immediateApply: false,
onConfirm: { viewModel.acknowledgePortConflict(for: config.id) }
)
}
@@ -100,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")
.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(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).
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.0</string>
<string>1.2.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>3</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppTransportSecurity</key>
@@ -77,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)
}
}
@@ -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")
}
}
@@ -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)")
}
}
}
+87
View File
@@ -318,3 +318,90 @@ entfernt.
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").
+25
View File
@@ -361,3 +361,28 @@ statt einer überzogenen "live bestätigt"-Behauptung versehen (siehe `bugs.md`
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