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 { final class BackupService {
private let fileManager = FileManager.default 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 { private var backupsDirectory: URL {
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let directory = Self.customDirectoryURL ?? Self.defaultDirectoryURL
let directory = base.appendingPathComponent("RouterOSAssistant/Backups", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
return directory return directory
} }
@@ -1,8 +1,10 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
struct BackupListView: View { struct BackupListView: View {
@ObservedObject var connectionService: ConnectionService @ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel = BackupViewModel() @StateObject private var viewModel = BackupViewModel()
@State private var showFolderPicker = false
var body: some View { var body: some View {
NavigationStack { 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") .navigationTitle("Sicherungen")
.toolbar { .toolbar {
ToolbarItem { ToolbarItem {
@@ -5,6 +5,8 @@ final class BackupViewModel: ObservableObject {
@Published private(set) var backups: [BackupRecord] = [] @Published private(set) var backups: [BackupRecord] = []
@Published private(set) var isCreatingBackup = false @Published private(set) var isCreatingBackup = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published private(set) var backupDirectoryPath: String = BackupService.customDirectoryURL?.path
?? BackupService.defaultDirectoryURL.path
private let backupService = BackupService() private let backupService = BackupService()
@@ -12,6 +14,18 @@ final class BackupViewModel: ObservableObject {
backups = backupService.listBackups() 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) { func createBackup(for credentials: RouterOSCredentials) {
isCreatingBackup = true isCreatingBackup = true
errorMessage = nil errorMessage = nil
@@ -14,9 +14,13 @@ struct ConnectView: View {
Form { Form {
Section("Verbindung") { Section("Verbindung") {
TextField("IP-Adresse oder Hostname", text: $viewModel.host) 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) TextField("Benutzername", text: $viewModel.username)
.help("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".")
SecureField("Passwort", text: $viewModel.password) SecureField("Passwort", text: $viewModel.password)
.help("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.")
Toggle("Passwort merken", isOn: $viewModel.rememberPassword) 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 { Section {
@@ -13,6 +13,7 @@ struct FirewallStepView: View {
set: { viewModel.setFirewallSectionEnabled($0) } 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( Text(
"Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, " "Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, "
+ "und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router " + "und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router "
@@ -12,19 +12,26 @@ struct LanStepView: View {
Text(interface.name).tag(interface.name) 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) 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) 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)") { Section("Automatische Adressvergabe (DHCP)") {
TextField("Von", text: $viewModel.lanConfig.poolRangeStart) 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) TextField("Bis", text: $viewModel.lanConfig.poolRangeEnd)
.help("Bis zu welcher Adresse der Router automatisch Adressen an deine Geräte vergibt.")
Stepper( Stepper(
"Adresse behalten für \(viewModel.lanConfig.leaseTimeHours) Stunden", "Adresse behalten für \(viewModel.lanConfig.leaseTimeHours) Stunden",
value: $viewModel.lanConfig.leaseTimeHours, value: $viewModel.lanConfig.leaseTimeHours,
in: 1...168 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) 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 { Section {
@@ -14,6 +14,7 @@ struct VlanStepView: View {
set: { viewModel.setVlanSectionEnabled($0) } 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.") 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -23,16 +24,23 @@ struct VlanStepView: View {
ForEach($viewModel.vlans) { $vlan in ForEach($viewModel.vlans) { $vlan in
Section("Netzwerk \"\(vlan.name)\"") { Section("Netzwerk \"\(vlan.name)\"") {
TextField("Name", text: $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) 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) { Picker("Basis-Anschluss", selection: $vlan.parentInterface) {
ForEach(availableInterfaces) { interface in ForEach(availableInterfaces) { interface in
Text(interface.name).tag(interface.name) 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) 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) 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) 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) TextField("bis", text: $vlan.poolRangeEnd)
.help("Bis zu welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.")
Button("Netzwerk entfernen", role: .destructive) { Button("Netzwerk entfernen", role: .destructive) {
viewModel.removeVlan(vlan.id) viewModel.removeVlan(vlan.id)
@@ -12,6 +12,7 @@ struct WanStepView: View {
Text(interface.name).tag(interface.name) 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) { Picker("Verbindungsart", selection: $viewModel.wanConfig.mode) {
ForEach(WanConnectionMode.allCases) { mode in ForEach(WanConnectionMode.allCases) { mode in
@@ -19,6 +20,7 @@ struct WanStepView: View {
} }
} }
.pickerStyle(.inline) .pickerStyle(.inline)
.help("Wie sich der Router beim Internetanbieter anmeldet. \"Automatisch (DHCP)\" passt für die meisten Kabel-/Glasfaseranschlüsse.")
} }
switch viewModel.wanConfig.mode { switch viewModel.wanConfig.mode {
@@ -30,12 +32,16 @@ struct WanStepView: View {
case .staticIP: case .staticIP:
Section("Statische Adresse") { Section("Statische Adresse") {
TextField("IP-Adresse (z.B. 203.0.113.5/24)", text: $viewModel.wanConfig.staticAddress) 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) 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: case .pppoe:
Section("PPPoE-Zugangsdaten") { Section("PPPoE-Zugangsdaten") {
TextField("Benutzername", text: $viewModel.wanConfig.pppoeUsername) 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) 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).") Text("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -26,9 +26,12 @@ struct WifiStepView: View {
ForEach($viewModel.wifiNetworks) { $network in ForEach($viewModel.wifiNetworks) { $network in
Section("WLAN auf \(network.interfaceName)") { Section("WLAN auf \(network.interfaceName)") {
Toggle("Aktivieren", isOn: $network.enabled) Toggle("Aktivieren", isOn: $network.enabled)
.help("Schaltet WLAN auf diesem Funkmodul ein und vergibt Netzwerkname und Passwort.")
if network.enabled { if network.enabled {
TextField("Netzwerkname (SSID)", text: $network.ssid) 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) 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)
}
}