Files
RouterOS/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift
T
KayandClaude Sonnet 5 3034d8b874 Bug 37: SSH-Hostkey-Trust bei REST-Verbindungen proaktiv etablieren
Dedizierte SSH-Dienste (BackupService u.a.) sind der erste echte
SSH-Kontakt zu einem per REST verbundenen Router und trafen dort auf
einen unbestätigten Hostkey ohne Trust-UI (nur ReviewApplyView/
ExpertMenuDetailView-Fehlertext, kein Bestätigungsweg). Fix: nach
jedem erfolgreichen REST-Connect prüft ConnectionService den
SSH-Hostkey einmalig im Hintergrund und zeigt bei Bedarf denselben
Trust-Dialog wie der SSH-Fallback, ohne die aktive REST-Verbindung neu
aufzubauen. Live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:23:33 +02:00

640 lines
32 KiB
Swift

import SwiftUI
struct ConnectView: View {
@ObservedObject private var connectionService: ConnectionService
@StateObject private var viewModel: ConnectViewModel
@StateObject private var backupViewModel = BackupViewModel()
@State private var showUpdateInstallConfirmation = false
@State private var showFirmwareUpgradeConfirmation = false
@State private var showRebootConfirmation = false
/// Per explicit request: a way to check what's actually in the password field, e.g. after
/// selecting a "Bekannte Router" entry ("setze hinter das Passwort ein Auge-Symbol ... so
/// kann ich prüfen, ob die Daten übernommen wurden").
@State private var isPasswordVisible = false
@AppStorage("appLanguage") private var appLanguage: String = "de"
/// How many "Bekannte Router" rows are visible before the list scrolls in place — per
/// explicit request, that list shouldn't be allowed to grow indefinitely. `savedRouterRowHeight`
/// is an estimate (name + optional location line + "user@host" caption, plus vertical padding)
/// good enough for a scroll-height cap; it doesn't need to match exactly since `ScrollView`
/// only ever caps at `maxHeight`, never forces it when content is shorter.
private static let savedRoutersMaxVisibleRows: CGFloat = 4
private static let savedRouterRowHeight: CGFloat = 60
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: ConnectViewModel(connectionService: connectionService))
}
var body: some View {
mainContent
.withUpdateDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, showUpdateInstallConfirmation: $showUpdateInstallConfirmation, showFirmwareUpgradeConfirmation: $showFirmwareUpgradeConfirmation)
.withRebootDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: $showRebootConfirmation)
}
/// Split out of `body` — same reasoning as `BackupListView.restoreAwareContent` (Bug 19 in
/// HANDOFF.md): too many `.alert`/`.confirmationDialog` modifiers chained onto one view times
/// out the Swift type-checker with a misleading error location, not an actual logic issue.
private var mainContent: some View {
NavigationSplitView {
Form {
if !viewModel.savedRouters.isEmpty {
Section(L10n.t("Bekannte Router", appLanguage)) {
// Capped height + its own ScrollView instead of letting the ForEach grow
// the outer Form indefinitely — per explicit request, the saved-router
// list shouldn't be allowed to get long; past ~4 entries it scrolls in
// place instead. `maxHeight` only ever caps, so with 4 or fewer entries
// the ScrollView still just sizes to fit its content, no dead space.
ScrollView {
VStack(spacing: 0) {
ForEach(Array(viewModel.savedRouters.enumerated()), id: \.element.id) { index, router in
if index > 0 {
Divider()
}
SavedRouterRow(
router: router,
isCurrent: router.host == viewModel.host && router.username == viewModel.username,
appLanguage: appLanguage,
onSelect: { viewModel.selectSavedRouter(router) },
onSave: { newName, newLocation in
viewModel.renameSavedRouter(router.id, to: newName)
viewModel.updateSavedRouterLocation(router.id, to: newLocation)
},
onDelete: { viewModel.removeSavedRouter(router.id) }
)
.padding(.vertical, 6)
}
}
}
.frame(maxHeight: Self.savedRoutersMaxVisibleRows * Self.savedRouterRowHeight)
}
}
Section(L10n.t("Verbindung", appLanguage)) {
TextField(L10n.t("IP-Adresse oder Hostname", appLanguage), text: $viewModel.host)
.help(L10n.t("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.", appLanguage))
TextField(L10n.t("Benutzername", appLanguage), text: $viewModel.username)
.help(L10n.t("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".", appLanguage))
HStack {
Group {
if isPasswordVisible {
TextField(L10n.t("Passwort", appLanguage), text: $viewModel.password)
} else {
SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.password)
}
}
.help(L10n.t("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.", appLanguage))
Button {
isPasswordVisible.toggle()
} label: {
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
}
.buttonStyle(.plain)
.help(isPasswordVisible ? L10n.t("Passwort verbergen", appLanguage) : L10n.t("Passwort anzeigen", appLanguage))
}
Toggle(L10n.t("Passwort merken", appLanguage), isOn: $viewModel.rememberPassword)
.help(L10n.t("Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.", appLanguage))
}
Section {
Button(L10n.t("Verbinden", appLanguage)) {
viewModel.connect()
}
.disabled(viewModel.host.isEmpty || viewModel.username.isEmpty)
}
statusSection
}
.formStyle(.grouped)
.frame(minWidth: 320)
} detail: {
deviceDetail
}
.onAppear {
viewModel.onAppear()
backupViewModel.load()
if case .connected = connectionService.state, let credentials = connectionService.credentials {
viewModel.startTrafficPolling(credentials: credentials)
}
}
.onChange(of: connectionService.state) { _, newState in
if case .connected = newState, let credentials = connectionService.credentials {
viewModel.startTrafficPolling(credentials: credentials)
} else {
viewModel.stopTrafficPolling()
}
}
.alert(
L10n.t("Unbekanntes Zertifikat", appLanguage),
isPresented: certificateAlertBinding,
presenting: certificateFingerprint
) { fingerprint in
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
viewModel.trustAndRetry(fingerprint: fingerprint)
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { fingerprint in
Text(L10n.t("Der Router hat sich mit einem unbekannten Zertifikat gemeldet.", appLanguage)
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
+ L10n.t("Nur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist.", appLanguage))
}
.alert(
L10n.t("Unbekannter SSH-Schlüssel", appLanguage),
isPresented: sshHostKeyAlertBinding,
presenting: sshHostKeyFingerprint
) { fingerprint in
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {
viewModel.dismissSSHHostKeyPrompt()
}
} message: { fingerprint in
Text(L10n.t("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.", appLanguage)
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
+ L10n.t("Nur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist. Falls du diesen Router schon einmal verbunden hattest und sich der Fingerabdruck geändert hat, könnte das auf ein manipuliertes Netzwerk hindeuten — im Zweifel nicht bestätigen.", appLanguage))
}
.alert(
L10n.t("Sicherung fehlgeschlagen", appLanguage),
isPresented: Binding(
get: { backupViewModel.errorMessage != nil },
set: { _ in backupViewModel.errorMessage = nil }
),
presenting: backupViewModel.errorMessage
) { _ in
Button(L10n.t("OK", appLanguage)) {}
} message: { message in
Text(message)
}
}
private var certificateFingerprint: String? {
if case .needsCertificateConfirmation(let fingerprint) = connectionService.state {
return fingerprint
}
return nil
}
private var certificateAlertBinding: Binding<Bool> {
Binding(
get: { certificateFingerprint != nil },
set: { _ in }
)
}
private var sshHostKeyFingerprint: String? {
if case .needsSSHHostKeyConfirmation(let fingerprint) = connectionService.state {
return fingerprint
}
return connectionService.pendingSSHTrustFingerprint
}
private var sshHostKeyAlertBinding: Binding<Bool> {
Binding(
get: { sshHostKeyFingerprint != nil },
set: { _ in }
)
}
@ViewBuilder
private var statusSection: some View {
switch connectionService.state {
case .idle, .needsCertificateConfirmation, .needsSSHHostKeyConfirmation:
EmptyView()
case .connecting:
Section {
HStack {
ProgressView()
Text(L10n.t("Verbinde…", appLanguage))
}
}
case .connected(let kind):
Section {
Label(L10n.t("Verbunden", appLanguage) + " (\(kind == .rest ? "REST-API" : "SSH"))", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Button(L10n.t("Trennen", appLanguage), role: .destructive) {
Task { await connectionService.disconnect() }
}
.help(L10n.t("Beendet die Verbindung zum Router. Zugangsdaten bleiben erhalten (falls gemerkt).", appLanguage))
Button {
if let credentials = connectionService.credentials {
backupViewModel.createBackup(for: credentials)
}
} label: {
if backupViewModel.isCreatingBackup {
HStack {
ProgressView()
Text(L10n.t("Sichere…", appLanguage))
}
} else {
Label(L10n.t("Jetzt sichern", appLanguage), systemImage: "square.and.arrow.down")
}
}
.disabled(backupViewModel.isCreatingBackup)
.help(L10n.t("Sichert die aktuelle Router-Konfiguration — sinnvoll direkt nach dem Verbinden, bevor du im Einrichten-Tab etwas änderst.", appLanguage))
if let lastBackup = backupViewModel.backups.first(where: { $0.host == connectionService.credentials?.host }) {
Label(
L10n.t("Zuletzt gesichert:", appLanguage) + " \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))",
systemImage: "checkmark"
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
case .failed(let message):
Section {
Label(message, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
}
@ViewBuilder
private var deviceDetail: some View {
if let info = connectionService.deviceInfo {
Form {
Section(L10n.t("Gerät", appLanguage)) {
LabeledContent(L10n.t("Modell", appLanguage), value: info.boardName)
LabeledContent(L10n.t("RouterOS-Version", appLanguage), value: info.routerOSVersion)
LabeledContent(L10n.t("Architektur", appLanguage), value: info.architecture)
LabeledContent(L10n.t("Laufzeit", appLanguage), value: info.uptime)
}
if let board = connectionService.routerBoardInfo {
Section("Routerboard") {
LabeledContent(L10n.t("Modell (RB-Code)", appLanguage), value: board.model)
LabeledContent(L10n.t("Revision", appLanguage), value: board.revision)
LabeledContent(L10n.t("Seriennummer", appLanguage), value: board.serialNumber)
.textSelection(.enabled)
LabeledContent(L10n.t("Firmware-Typ", appLanguage), value: board.firmwareType)
LabeledContent(L10n.t("Firmware (aktuell)", appLanguage), value: board.currentFirmware)
LabeledContent(L10n.t("Firmware (minimal)", appLanguage), value: board.minimumFirmware)
LabeledContent(L10n.t("Firmware (verfügbar)", appLanguage), value: board.upgradeFirmware)
// Confirmed via docs: same value = up to date, different = a bootloader
// update is available (only ever becomes available after installing a
// newer RouterOS package first — the firmware ships bundled inside it).
if board.currentFirmware != board.upgradeFirmware {
Label(L10n.t("Firmware-Update verfügbar", appLanguage), systemImage: "arrow.up.circle.fill")
.foregroundStyle(.orange)
Button {
showFirmwareUpgradeConfirmation = true
} label: {
if viewModel.isUpgradingFirmware {
HStack { ProgressView(); Text(L10n.t("Aktualisiere…", appLanguage)) }
} else {
Label(L10n.t("Firmware aktualisieren", appLanguage), systemImage: "arrow.up.circle")
}
}
.disabled(viewModel.isUpgradingFirmware)
} else {
Label(L10n.t("Firmware aktuell", appLanguage), systemImage: "checkmark.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
if let message = viewModel.firmwareUpgradeResultMessage {
Text(message).font(.caption).foregroundStyle(.secondary)
Button {
showRebootConfirmation = true
} label: {
if viewModel.isRebooting {
HStack { ProgressView(); Text(L10n.t("Starte neu…", appLanguage)) }
} else {
Label(L10n.t("Jetzt neu starten", appLanguage), systemImage: "arrow.clockwise")
}
}
.disabled(viewModel.isRebooting)
.help(L10n.t("Nötig, damit die neu geschriebene Firmware aktiv wird — passiert nicht automatisch.", appLanguage))
}
}
}
Section(L10n.t("Software-Update", appLanguage)) {
if let update = viewModel.packageUpdateInfo {
LabeledContent(L10n.t("Kanal", appLanguage), value: update.channel)
LabeledContent(L10n.t("Installiert", appLanguage), value: update.installedVersion)
LabeledContent(L10n.t("Neueste Version", appLanguage), value: update.latestVersion)
LabeledContent(L10n.t("Status", appLanguage), value: update.status)
if update.status == "New version is available" {
Button {
showUpdateInstallConfirmation = true
} label: {
if viewModel.isInstallingUpdate {
HStack { ProgressView(); Text(L10n.t("Installiere…", appLanguage)) }
} else {
Label(L10n.t("Update installieren", appLanguage), systemImage: "arrow.down.circle")
}
}
.disabled(viewModel.isInstallingUpdate)
}
}
Button {
if let credentials = connectionService.credentials {
viewModel.checkForUpdates(for: credentials)
}
} label: {
if viewModel.isCheckingForUpdates {
HStack { ProgressView(); Text(L10n.t("Prüfe…", appLanguage)) }
} else {
Label(L10n.t("Jetzt prüfen", appLanguage), systemImage: "arrow.triangle.2.circlepath")
}
}
.disabled(viewModel.isCheckingForUpdates)
.help(L10n.t("Prüft bei MikroTik, ob eine neuere RouterOS-Version verfügbar ist. Braucht Internetzugang auf dem Router.", appLanguage))
if viewModel.didSendUpdateInstall {
Label(
L10n.t("Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.", appLanguage),
systemImage: "arrow.clockwise"
)
.font(.caption)
}
}
Section("Interfaces") {
ForEach(connectionService.interfaces) { interface in
HStack {
InterfaceActivityDot(
running: interface.running,
isActive: viewModel.interfaceTraffic[interface.name]?.isActive ?? false
)
VStack(alignment: .leading) {
Text(interface.name).bold()
Text(interface.type).font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
.formStyle(.grouped)
} else {
ContentUnavailableView(
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
systemImage: "network.slash",
description: Text(L10n.t("Verbinde dich mit deinem Router, um Geräteinformationen zu sehen.", appLanguage))
)
}
}
}
/// Split into two small functions (software-update dialogs, firmware-upgrade dialogs), not one
/// big one — same Bug 19 reasoning as `mainContent`'s own split: keep each modifier chain short
/// enough that the type-checker doesn't time out.
private extension View {
func withUpdateDialogs(
viewModel: ConnectViewModel,
connectionService: ConnectionService,
appLanguage: String,
showUpdateInstallConfirmation: Binding<Bool>,
showFirmwareUpgradeConfirmation: Binding<Bool>
) -> some View {
self
.withSoftwareUpdateDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: showUpdateInstallConfirmation)
.withFirmwareUpgradeDialogs(viewModel: viewModel, connectionService: connectionService, appLanguage: appLanguage, isPresented: showFirmwareUpgradeConfirmation)
}
func withSoftwareUpdateDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
self
.confirmationDialog(
L10n.t("RouterOS-Update installieren?", appLanguage),
isPresented: isPresented,
titleVisibility: .visible
) {
Button(L10n.t("Installieren", appLanguage), role: .destructive) {
if let credentials = connectionService.credentials {
viewModel.installUpdate(for: credentials)
}
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(L10n.t("Lädt RouterOS", appLanguage)
+ " \(viewModel.packageUpdateInfo?.latestVersion ?? L10n.t("die neueste Version", appLanguage))"
+ L10n.t(" herunter, installiert es und startet den Router danach automatisch neu — die Verbindung geht dabei verloren. Vorher am besten eine Sicherung erstellen (\"Jetzt sichern\" oben).", appLanguage))
}
.onChange(of: viewModel.didSendUpdateInstall) { _, didSend in
if didSend {
Task { await connectionService.disconnect() }
}
}
.alert(
L10n.t("Update-Prüfung fehlgeschlagen", appLanguage),
isPresented: Binding(
get: { viewModel.updateCheckError != nil },
set: { _ in viewModel.updateCheckError = nil }
),
presenting: viewModel.updateCheckError
) { _ in
Button(L10n.t("OK", appLanguage)) {}
} message: { message in
Text(message)
}
.alert(
L10n.t("Update-Installation fehlgeschlagen", appLanguage),
isPresented: Binding(
get: { viewModel.installUpdateError != nil },
set: { _ in viewModel.installUpdateError = nil }
),
presenting: viewModel.installUpdateError
) { _ in
Button(L10n.t("OK", appLanguage)) {}
} message: { message in
Text(message)
}
}
func withFirmwareUpgradeDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
self
.confirmationDialog(
L10n.t("Routerboard-Firmware aktualisieren?", appLanguage),
isPresented: isPresented,
titleVisibility: .visible
) {
Button(L10n.t("Aktualisieren", appLanguage), role: .destructive) {
if let credentials = connectionService.credentials {
viewModel.upgradeRouterboardFirmware(for: credentials)
}
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(L10n.t("Schreibt die neue Bootloader-Firmware auf den Router. Danach ist ein manueller Neustart nötig — passiert nicht automatisch. Dieser Befehl fragt in einer interaktiven Sitzung normalerweise erst nach Bestätigung; ob das über diese App genauso abläuft, ist nicht abschließend getestet (ähnlich interaktive Befehle liefen bisher aber problemlos). Falls die App hier ungewöhnlich lange ohne Rückmeldung bleibt, bitte melden.", appLanguage))
}
.alert(
L10n.t("Firmware-Aktualisierung fehlgeschlagen", appLanguage),
isPresented: Binding(
get: { viewModel.firmwareUpgradeError != nil },
set: { _ in viewModel.firmwareUpgradeError = nil }
),
presenting: viewModel.firmwareUpgradeError
) { _ in
Button(L10n.t("OK", appLanguage)) {}
} message: { message in
Text(message)
}
}
func withRebootDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, appLanguage: String, isPresented: Binding<Bool>) -> some View {
self
.confirmationDialog(
L10n.t("Router jetzt neu starten?", appLanguage),
isPresented: isPresented,
titleVisibility: .visible
) {
Button(L10n.t("Neu starten", appLanguage), role: .destructive) {
if let credentials = connectionService.credentials {
viewModel.reboot(for: credentials)
}
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
Text(L10n.t("Startet den Router sofort neu. Die Verbindung geht dabei kurz verloren, danach im Tab \"Verbinden\" erneut verbinden.", appLanguage))
}
.onChange(of: viewModel.didSendReboot) { _, didSend in
if didSend {
Task { await connectionService.disconnect() }
}
}
.alert(
L10n.t("Neustart fehlgeschlagen", appLanguage),
isPresented: Binding(
get: { viewModel.rebootError != nil },
set: { _ in viewModel.rebootError = nil }
),
presenting: viewModel.rebootError
) { _ in
Button(L10n.t("OK", appLanguage)) {}
} message: { message in
Text(message)
}
}
}
/// Status dot in front of an interface's name in the Verbinden-Tab's "Interfaces" list. Grey =
/// link down, solid green = link up but idle, pulsing green = actually carrying traffic right
/// now (per `InterfaceTrafficMonitor`'s live `/interface monitor-traffic` polling) — `running`
/// alone can't tell "idle" from "busy", it only reflects link negotiation.
private struct InterfaceActivityDot: View {
let running: Bool
let isActive: Bool
@State private var pulseDown = false
var body: some View {
Circle()
.fill(running ? Color.green : Color.secondary)
.frame(width: 8, height: 8)
.opacity(isActive && pulseDown ? 0.35 : 1.0)
.onChange(of: isActive, initial: true) { _, active in
if active {
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
pulseDown = true
}
} else {
withAnimation(.easeInOut(duration: 0.2)) {
pulseDown = false
}
}
}
}
}
/// One row in the Verbinden-Tab's "Bekannte Router" list — click fills in the host/username/
/// remembered password without connecting yet (the normal "Verbinden" button still does that),
/// so a wrong pick is a no-op. Editing (name + free-text location, e.g. "Keller" or "1. OG") is
/// inline (tap "Bearbeiten", edit both fields, commit) rather than a separate sheet.
private struct SavedRouterRow: View {
let router: SavedRouter
let isCurrent: Bool
let appLanguage: String
let onSelect: () -> Void
let onSave: (_ name: String, _ location: String) -> Void
let onDelete: () -> Void
@State private var isEditing = false
@State private var draftName = ""
@State private var draftLocation = ""
@FocusState private var isNameFieldFocused: Bool
var body: some View {
HStack {
// Not wrapped in a Button — a `Button(action: onSelect)` around this whole VStack
// previously nested the edit TextField *inside* a Button, and `.disabled(isEditing)`
// on that Button also disabled every descendant, including the TextField itself:
// nothing could be typed. Confirmed live (2026-09-15): "das umbenennen funktioniert
// nicht". `.onTapGesture` (guarded to skip while editing) gets the same "click row to
// select" behavior without disabling anything.
VStack(alignment: .leading, spacing: 2) {
if isEditing {
TextField(L10n.t("Name", appLanguage), text: $draftName)
.textFieldStyle(.roundedBorder)
.focused($isNameFieldFocused)
.onSubmit(commitEdits)
TextField(L10n.t("Standort (optional, z.B. Keller, 1. OG)", appLanguage), text: $draftLocation)
.textFieldStyle(.roundedBorder)
.onSubmit(commitEdits)
} else {
Text(router.name).bold()
if !router.location.isEmpty {
Label(router.location, systemImage: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text("\(router.username)@\(router.host)")
.font(.caption)
.foregroundStyle(.secondary)
// Nutzerwunsch (2026-09-16): Seriennummer mit hinterlegen, damit zwei
// physisch unterschiedliche Router mit identischem Host+Benutzername (z.B.
// beide auf MikroTiks Werks-Adresse 192.168.88.1/admin) trotzdem als
// getrennte Einträge unterscheidbar bleiben — siehe
// SavedRoutersStore.recordSuccessfulConnection.
if let serialNumber = router.serialNumber {
Text("SN: \(serialNumber)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.contentShape(Rectangle())
.onTapGesture {
guard !isEditing else { return }
onSelect()
}
Spacer()
if isCurrent {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.help(L10n.t("Aktuell ausgefüllt", appLanguage))
}
Menu {
if isEditing {
Button(L10n.t("Fertig", appLanguage), action: commitEdits)
} else {
Button(L10n.t("Bearbeiten", appLanguage)) {
draftName = router.name
draftLocation = router.location
isEditing = true
isNameFieldFocused = true
}
}
Button(L10n.t("Entfernen", appLanguage), role: .destructive, action: onDelete)
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
private func commitEdits() {
let trimmedName = draftName.trimmingCharacters(in: .whitespaces)
let trimmedLocation = draftLocation.trimmingCharacters(in: .whitespaces)
onSave(trimmedName.isEmpty ? router.name : trimmedName, trimmedLocation)
isEditing = false
}
}
#Preview {
ConnectView(connectionService: ConnectionService())
}