Tooltips für alle Konfigurationsfelder + wählbarer Backup-Ordner

.help(...)-Tooltips auf jedem Eingabefeld/Picker/Toggle in Connect-
sowie allen Einrichten-Schritten (WAN/LAN/VLAN/WLAN/Firewall) --
kurze Erklärung was der Wert bedeutet und welche Auswirkung er hat,
passend zum Laien-Anspruch der App.

Sicherungen-Tab: Speicherort jetzt änderbar (Ordner wählen über
nativen macOS-Dialog, Zurücksetzen auf Standard). BackupService hält
den gewählten Pfad in UserDefaults (BackupService.customDirectoryURL),
fällt ohne Auswahl weiter auf ~/Library/Application Support/... zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
This commit is contained in:
Kay
2026-09-12 20:30:22 +02:00
co-authored by Claude Sonnet 5
parent c1f9940eae
commit 9d1aecc452
10 changed files with 111 additions and 2 deletions
@@ -16,9 +16,24 @@ struct BackupRecord: Identifiable, Equatable {
final class BackupService {
private let fileManager = FileManager.default
private static let customDirectoryKey = "RouterOSAssistant.BackupDirectoryPath"
/// User-chosen backup folder, persisted across launches. `nil` means "use the default".
static var customDirectoryURL: URL? {
get {
guard let path = UserDefaults.standard.string(forKey: customDirectoryKey) else { return nil }
return URL(fileURLWithPath: path, isDirectory: true)
}
set { UserDefaults.standard.set(newValue?.path, forKey: customDirectoryKey) }
}
static var defaultDirectoryURL: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("RouterOSAssistant/Backups", isDirectory: true)
}
private var backupsDirectory: URL {
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let directory = base.appendingPathComponent("RouterOSAssistant/Backups", isDirectory: true)
let directory = Self.customDirectoryURL ?? Self.defaultDirectoryURL
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
@@ -1,8 +1,10 @@
import SwiftUI
import UniformTypeIdentifiers
struct BackupListView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel = BackupViewModel()
@State private var showFolderPicker = false
var body: some View {
NavigationStack {
@@ -24,6 +26,35 @@ struct BackupListView: View {
}
}
}
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
Text("Speicherort")
.font(.caption)
.foregroundStyle(.secondary)
Text(viewModel.backupDirectoryPath)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
.help(viewModel.backupDirectoryPath)
HStack {
Button("Ordner wählen…") { showFolderPicker = true }
.help("Wähle einen eigenen Ordner für neue Sicherungen, z.B. auf einer externen Festplatte oder in iCloud Drive.")
Button("Standard verwenden") { viewModel.resetBackupDirectoryToDefault() }
.help("Setzt den Speicherort zurück auf den App-eigenen Standardordner.")
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
.fileImporter(
isPresented: $showFolderPicker,
allowedContentTypes: [.folder]
) { result in
if case .success(let url) = result {
viewModel.setBackupDirectory(url)
}
}
.navigationTitle("Sicherungen")
.toolbar {
ToolbarItem {
@@ -5,6 +5,8 @@ final class BackupViewModel: ObservableObject {
@Published private(set) var backups: [BackupRecord] = []
@Published private(set) var isCreatingBackup = false
@Published var errorMessage: String?
@Published private(set) var backupDirectoryPath: String = BackupService.customDirectoryURL?.path
?? BackupService.defaultDirectoryURL.path
private let backupService = BackupService()
@@ -12,6 +14,18 @@ final class BackupViewModel: ObservableObject {
backups = backupService.listBackups()
}
func setBackupDirectory(_ url: URL) {
BackupService.customDirectoryURL = url
backupDirectoryPath = url.path
load()
}
func resetBackupDirectoryToDefault() {
BackupService.customDirectoryURL = nil
backupDirectoryPath = BackupService.defaultDirectoryURL.path
load()
}
func createBackup(for credentials: RouterOSCredentials) {
isCreatingBackup = true
errorMessage = nil
@@ -14,9 +14,13 @@ struct ConnectView: View {
Form {
Section("Verbindung") {
TextField("IP-Adresse oder Hostname", text: $viewModel.host)
.help("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.")
TextField("Benutzername", text: $viewModel.username)
.help("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".")
SecureField("Passwort", text: $viewModel.password)
.help("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.")
Toggle("Passwort merken", isOn: $viewModel.rememberPassword)
.help("Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.")
}
Section {
@@ -13,6 +13,7 @@ struct FirewallStepView: View {
set: { viewModel.setFirewallSectionEnabled($0) }
)
)
.help("Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).")
Text(
"Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, "
+ "und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router "
@@ -12,19 +12,26 @@ struct LanStepView: View {
Text(interface.name).tag(interface.name)
}
}
.help("Der interne Netzwerk-Anschluss, an dem deine Geräte zu Hause hängen (dein lokales Netzwerk, LAN).")
TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $viewModel.lanConfig.routerAddress)
.help("Die Adresse, unter der der Router selbst in deinem Heimnetz erreichbar ist.")
TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $viewModel.lanConfig.networkAddress)
.help("Der komplette Adressbereich deines Heimnetzes (z.B. /24 erlaubt bis zu 254 Geräte).")
}
Section("Automatische Adressvergabe (DHCP)") {
TextField("Von", text: $viewModel.lanConfig.poolRangeStart)
.help("Ab welcher Adresse der Router automatisch Adressen an deine Geräte vergibt.")
TextField("Bis", text: $viewModel.lanConfig.poolRangeEnd)
.help("Bis zu welcher Adresse der Router automatisch Adressen an deine Geräte vergibt.")
Stepper(
"Adresse behalten für \(viewModel.lanConfig.leaseTimeHours) Stunden",
value: $viewModel.lanConfig.leaseTimeHours,
in: 1...168
)
.help("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.")
TextField("DNS-Server", text: $viewModel.lanConfig.dnsServers)
.help("Welcher Server deinen Geräten Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.")
}
Section {
@@ -14,6 +14,7 @@ struct VlanStepView: View {
set: { viewModel.setVlanSectionEnabled($0) }
)
)
.help("Erstellt ein komplett getrenntes Netzwerk — Geräte darin können nicht auf dein Hauptnetzwerk zugreifen und umgekehrt.")
Text("Ein VLAN ist ein zusätzliches, komplett getrenntes Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -23,16 +24,23 @@ struct VlanStepView: View {
ForEach($viewModel.vlans) { $vlan in
Section("Netzwerk \"\(vlan.name)\"") {
TextField("Name", text: $vlan.name)
.help("Ein Name zur Wiedererkennung, z.B. \"Gäste\" oder \"Smart Home\".")
Stepper("VLAN-ID: \(vlan.vlanID)", value: $vlan.vlanID, in: 2...4094)
.help("Eine eindeutige Nummer zur technischen Unterscheidung dieses Netzwerks. Muss nur innerhalb deines Routers einmalig sein.")
Picker("Basis-Anschluss", selection: $vlan.parentInterface) {
ForEach(availableInterfaces) { interface in
Text(interface.name).tag(interface.name)
}
}
.help("Der physische Anschluss, auf dem dieses zusätzliche Netzwerk aufbaut.")
TextField("Router-Adresse (z.B. 192.168.20.1/24)", text: $vlan.routerAddress)
.help("Die Adresse des Routers innerhalb dieses zusätzlichen Netzwerks.")
TextField("Netzwerk (z.B. 192.168.20.0/24)", text: $vlan.networkAddress)
.help("Der komplette Adressbereich dieses zusätzlichen Netzwerks.")
TextField("Automatische Adressvergabe von", text: $vlan.poolRangeStart)
.help("Ab welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.")
TextField("bis", text: $vlan.poolRangeEnd)
.help("Bis zu welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.")
Button("Netzwerk entfernen", role: .destructive) {
viewModel.removeVlan(vlan.id)
@@ -12,6 +12,7 @@ struct WanStepView: View {
Text(interface.name).tag(interface.name)
}
}
.help("Der Netzwerkport, über den dein Router mit dem Internet verbunden ist (z.B. das Kabel zu deinem Modem oder deiner Anschlussdose).")
Picker("Verbindungsart", selection: $viewModel.wanConfig.mode) {
ForEach(WanConnectionMode.allCases) { mode in
@@ -19,6 +20,7 @@ struct WanStepView: View {
}
}
.pickerStyle(.inline)
.help("Wie sich der Router beim Internetanbieter anmeldet. \"Automatisch (DHCP)\" passt für die meisten Kabel-/Glasfaseranschlüsse.")
}
switch viewModel.wanConfig.mode {
@@ -30,12 +32,16 @@ struct WanStepView: View {
case .staticIP:
Section("Statische Adresse") {
TextField("IP-Adresse (z.B. 203.0.113.5/24)", text: $viewModel.wanConfig.staticAddress)
.help("Die feste IP-Adresse, die dir dein Anbieter zugewiesen hat, inklusive Netzmaske (die Zahl nach dem /, z.B. /24).")
TextField("Gateway (z.B. 203.0.113.1)", text: $viewModel.wanConfig.staticGateway)
.help("Die Adresse des nächsten Geräts Richtung Internet — steht meist in den Unterlagen deines Anbieters.")
}
case .pppoe:
Section("PPPoE-Zugangsdaten") {
TextField("Benutzername", text: $viewModel.wanConfig.pppoeUsername)
.help("Zugangsdaten von deinem Internetanbieter für die Einwahl (z.B. bei DSL-Anschlüssen).")
SecureField("Passwort", text: $viewModel.wanConfig.pppoePassword)
.help("Das zum Benutzernamen gehörende Passwort von deinem Internetanbieter.")
Text("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).")
.font(.caption)
.foregroundStyle(.secondary)
@@ -26,9 +26,12 @@ struct WifiStepView: View {
ForEach($viewModel.wifiNetworks) { $network in
Section("WLAN auf \(network.interfaceName)") {
Toggle("Aktivieren", isOn: $network.enabled)
.help("Schaltet WLAN auf diesem Funkmodul ein und vergibt Netzwerkname und Passwort.")
if network.enabled {
TextField("Netzwerkname (SSID)", text: $network.ssid)
.help("Der Name, den Geräte in ihrer WLAN-Liste sehen und mit dem sie sich verbinden.")
SecureField("Passwort (mind. 8 Zeichen)", text: $network.password)
.help("Das WLAN-Passwort (WPA2). Muss mindestens 8 Zeichen lang sein.")
}
}
}
@@ -0,0 +1,20 @@
import XCTest
@testable import RouterOSAssistant
final class BackupServiceTests: XCTestCase {
override func tearDown() {
BackupService.customDirectoryURL = nil
super.tearDown()
}
func testCustomDirectoryDefaultsToNil() {
BackupService.customDirectoryURL = nil
XCTAssertNil(BackupService.customDirectoryURL)
}
func testCustomDirectoryRoundTrips() {
let url = URL(fileURLWithPath: "/tmp/RouterOSAssistantTestBackups", isDirectory: true)
BackupService.customDirectoryURL = url
XCTAssertEqual(BackupService.customDirectoryURL?.path, url.path)
}
}