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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user