diff --git a/RouterOSAssistant/Core/Services/BackupService.swift b/RouterOSAssistant/Core/Services/BackupService.swift index e259eb1..bee5357 100644 --- a/RouterOSAssistant/Core/Services/BackupService.swift +++ b/RouterOSAssistant/Core/Services/BackupService.swift @@ -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 } diff --git a/RouterOSAssistant/Features/Backup/BackupListView.swift b/RouterOSAssistant/Features/Backup/BackupListView.swift index 0ec8493..90a03c4 100644 --- a/RouterOSAssistant/Features/Backup/BackupListView.swift +++ b/RouterOSAssistant/Features/Backup/BackupListView.swift @@ -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 { diff --git a/RouterOSAssistant/Features/Backup/BackupViewModel.swift b/RouterOSAssistant/Features/Backup/BackupViewModel.swift index 5418bdc..dd14c8a 100644 --- a/RouterOSAssistant/Features/Backup/BackupViewModel.swift +++ b/RouterOSAssistant/Features/Backup/BackupViewModel.swift @@ -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 diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift index d38612f..934996d 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift @@ -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 { diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift index b0120d0..fdb1e9b 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift @@ -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 " diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift index 4d9cbc9..c13ddf4 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift @@ -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 { diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/VlanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/VlanStepView.swift index 021eeb3..caa14bb 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/VlanStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/VlanStepView.swift @@ -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) diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/WanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/WanStepView.swift index 5cd54b6..09cfbde 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/WanStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/WanStepView.swift @@ -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) diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/WifiStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/WifiStepView.swift index c4a2ae9..3a9d702 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/WifiStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/WifiStepView.swift @@ -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.") } } } diff --git a/RouterOSAssistantTests/BackupServiceTests.swift b/RouterOSAssistantTests/BackupServiceTests.swift new file mode 100644 index 0000000..6ba5211 --- /dev/null +++ b/RouterOSAssistantTests/BackupServiceTests.swift @@ -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) + } +}