M29: App-Einstellungen (Settings-Fenster ⌘,)

Neues natives macOS-Settings-Fenster mit Sprache, Auto-Update-Check,
Farbschema (Standard/Kontrastreich), echter Textgrößen-Skalierung,
Bedienelement-Größe und LAN-Scanner-Refreshraten/Sparkline-Einstellungen.

- AppPreferences.swift: zentrale AppStorage-Keys, ColorTheme/AppTextSize/
  UIDensity
- SettingsView.swift: 3-Tab-Settings-Scene
- .environment(\.dynamicTypeSize) erwies sich auf macOS als wirkungslos
  (per ImageRenderer-Snapshot bewiesen) — durch eigenen appFontScale-
  Mechanismus ersetzt, ~110 .font(...)-Aufrufe app-weit umgestellt
- Farbschema auf Wunsch auch auf Experte-Tab-Sidebar ausgeweitet
- Docs aktualisiert: README/HANDOFF/CHATLOG/Manual.md+PDF, found.md

Live bestätigt nach mehreren Nachbesserungsrunden ("passt, lassen wir so").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-17 13:38:22 +02:00
co-authored by Claude Sonnet 5
parent 53a2931239
commit 221b914b05
25 changed files with 803 additions and 157 deletions
+46
View File
@@ -994,3 +994,49 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
aktualisiert (inkl. Mermaid-Diagramm um `NetworkToolsService`-SSH-
Verbindung und `PortScanner`s direkten Mac→Gerät-Pfad ergänzt),
Commit + Push.
## Session: App-Einstellungen / Settings-Fenster (M29)
- "erstelle eine für diese App sinnvolle struktur für die Einstellung
Settings" (found.md hatte das schon als offene Idee vermerkt) →
Rückfrage: natives macOS-Settings-Fenster (⌘,) statt eigenem Tab, alle
Bereiche (Sprache, Updates, Farben, Textgröße, Bedienelemente,
Refreshraten) auf einmal, Farben als 2 vordefinierte Themes statt
freier Farbwahl. Neues `AppPreferences.swift` + `SettingsView.swift`
gebaut, Build+98 Tests grün.
- Live-Test: "die änderung der Bedienelemente und Sparkling funktioniert,
bei Textgröße und Farbschemas sehe ich keine Änderung" → Farbschema:
drei Farben waren anfangs in beiden Themes identisch (Kopierfehler),
gefixt.
- Erneuter Live-Test: weiterhin "unverändert" bei beidem trotz Fix →
nicht weiter geraten, sondern per ImageRenderer-Snapshot bewiesen:
.environment(\.dynamicTypeSize, ...) hat auf macOS **keine Wirkung**
(anders als iOS) — SwiftUI-Textstile skalieren dort nicht über
Dynamic-Type-Kategorien. Rückfrage mit 3 Alternativen, Nutzer wählte
"echtes Text-only Scaling". Kompletter Neubau: eigener
\.appFontScale-Mechanismus, ~110 .font(...)-Aufrufe app-weit
automatisiert umgestellt (2 bewusste Ausnahmen: Übersicht-
Diagrammkarten, LAN-Scanner-Tabellenspalten — feste Breiten, würden
beim Skalieren brechen).
- "textgröße funktioniert, die Farbschemas nicht, prüfen. kannst du
selbst auf den router schauen und das prüfen?" → kein Router-Zugriff
möglich (kein Tool/Zugang), aber Bug sitzt ohnehin rein im
App-Rendering — LAN-Scanner-eigene Statusfarben (Traffic/Fest-
Dynamisch/Port-Scan) beim ersten Farbfix übersehen, weiterhin zu
ähnliche SwiftUI-Nachbarfarben (grün/mint, orange/gelb). Gefixt, per
Snapshot-Swatch-Vergleich selbst verifiziert vor erneuter Bitte um
Live-Test.
- "Lan-scanner und Übersicht funktionieren" (Farbschema bestätigt),
"der experte-tab zeigt noch keine Änderung" (Textgröße) → Ursache: die
komplette Experte-Sidebar-Liste und jedes Parameter/Wert-TextField
hatten von Anfang an gar kein .font(...) — automatisierte Umstellung
griff nur bei vorhandenen Font-Aufrufen. Fix: globaler
Default-Font-Fallback an der Fenster-Wurzel.
- "keine Änderung beim Farbschema unter Experte" → kein Bug, sondern
Scope: Farbschema war nie für den Experte-Tab verdrahtet. Rückfrage,
ob ausgeweitet werden soll → ja. Sidebar-Kategorien bekommen jetzt
einen Farbpunkt, gemappt auf dieselbe 5-Farb-Palette wie die Übersicht.
- "passt, lassen wir so. für später merken (found.md): anpassbare
Farben" → found.md Punkt 9 ergänzt (freie Farbwahl pro Kategorie statt
nur 2 Themes, für später). Milestone live bestätigt, Docs aktualisiert,
Commit + Push.
+52
View File
@@ -1845,3 +1845,55 @@ Release-Build, auf dem Stand des jeweils letzten Commits auf `main`
(App-Icon "Signal Router"). Nach jeder weiteren Code-Änderung muss dieser
Build-und-Kopier-Schritt (siehe oben) wiederholt werden, damit die
installierte App aktuell bleibt.
**M29: App-Einstellungen / Settings-Fenster** (2026-09-17, komplett neue
Infrastruktur, zuvor gab es nur den DE/EN-Toolbar-Schalter):
1. Natives macOS-Settings-Fenster (⌘,, `Settings { }`-Scene) statt eigenem
Tab — Rückfrage vorab geklärt. Drei Tabs: Allgemein (Sprache,
Auto-Update-Check beim Verbinden), Darstellung (Farbschema, Textgröße,
Bedienelemente), Netzwerk (LAN-Scanner Aktualisierungsrate/
Sparkline-Zeitfenster/-Breite). Zentrales `AppPreferences.swift`
(`Core/Models/`) bündelt alle `AppStorage`-Keys + drei Enums
(`ColorTheme`, `AppTextSize`, `UIDensity`).
2. **Farbschema** (`ColorTheme`, 2 vordefinierte Themes Standard/
Kontrastreich statt freier Farbwahl — bewusste Rückfrage-Entscheidung):
ersetzt `OverviewStyle.color(for:)` für Übersicht-Node-Kategorien/
Kanten, LAN-Scanner-Status (Traffic aktiv, Fest/Dynamisch, Port-Scan)
und (auf Nachfrage nachgezogen) die Experte-Tab-Sidebar-Kategorien
(13 `RouterOSMenuCategory`-Fälle auf die bestehende 5-Farb-Palette
gebucketet, Firewall-Untermenüs → `.firewall`, VPN/WLAN/Queues/
System/Tools → `.service`). Zwei Live-Test-Runden fanden echte Bugs:
drei Farben waren anfangs in beiden Themes identisch definiert
(Kopierfehler), und die LAN-Scanner-eigenen Statusfarben waren zu
ähnliche SwiftUI-Nachbarfarben (grün/mint, orange/gelb) — beide
gefixt, per `ImageRenderer`-Snapshot-Vergleich selbst verifiziert
statt nur behauptet.
3. **Textgröße** — erster Ansatz (`.environment(\.dynamicTypeSize, ...)`,
SwiftUIs Standardmechanismus) erwies sich als kompletter Fehlschlag:
per Snapshot-Vergleich bewiesen, dass diese Environment-Variable auf
macOS **keine Wirkung** auf Text-Rendering hat (anders als iOS).
Komplett neu gebaut: eigener `\.appFontScale`-Environment-Key +
`AppFontStyle`-Enum mit expliziten macOS-Basis-Punktgrößen pro
Textstil + `.appFont(...)`-Modifier. ~110 `.font(...)`-Aufrufe app-weit
automatisiert umgestellt (Scratchpad-Skript, textbasierte Ersetzung),
außer zwei fest-breiten Layout-Stellen (Übersicht-Diagramm-Knotenkarten,
LAN-Scanner-Tabellenspalten), die beim Skalieren brechen würden.
Danach noch ein Nachzügler-Fix: die Experte-Tab-Sidebar-Liste und alle
Parameter/Wert-`TextField`s hatten von Anfang an gar kein `.font(...)`
— automatisierte Ersetzung konnte dort nichts ersetzen. Fix: globaler
Default-Font-Fallback (`.environment(\.font, ...)`) an der
Fenster-Wurzel, wirkt überall dort, wo kein `.appFont(...)` bereits
greift.
4. **Bedienelemente/"Responsiveness"** (`UIDensity` → SwiftUIs
`.controlSize`, an der Fenster-Wurzel) und **LAN-Scanner-Refreshraten/
Sparkline-Fenster/-Breite** (`DevicesViewModel` liest die Werte bei
jedem Poll-Tick frisch aus `UserDefaults`, nicht nur beim Start) auf
Anhieb live bestätigt.
5. Bewusst nicht umgesetzt: Übersicht-Zoom-Standardwert, Fokus-Popup-
Größe/Verhalten (reine `@State`-Werte ohne Persistenz, nicht explizit
gefordert). Für später vorgemerkt (`found.md` Punkt 9): freie
Farbwahl pro Kategorie statt nur der 2 Themes.
Build+alle 98 Unit-Tests grün nach jedem Schritt. Live bestätigt
("passt, lassen wir so").
+54 -2
View File
@@ -31,7 +31,8 @@ entsprechen, was in der App tatsächlich angezeigt wird.
4. [LAN-Scanner](#4-lan-scanner)
5. [Experte](#5-experte)
6. [Sicherungen](#6-sicherungen)
7. [English summary](#7-english-summary)
7. [Einstellungen](#7-einstellungen)
8. [English summary](#8-english-summary)
---
@@ -1074,7 +1075,50 @@ Ein einzelner, geräteweiter Satz Einstellungen — kein Menü mit mehreren Eint
---
## 7. English summary
## 7. Einstellungen
Erreichbar über das App-Menü **RouterOS Assistant → Einstellungen…**
(oder ⌘,) — kein eigener Tab, sondern ein natives macOS-Settings-Fenster
mit drei Reitern. Jede Änderung wirkt sofort, ohne Neustart der App.
**Allgemein**
- **Sprache**: DE/EN-Umschalter — „Entspricht dem 🇩🇪/🇬🇧-Knopf in der
Toolbar — beide steuern dieselbe Einstellung.“
- **Beim Verbinden automatisch nach Updates suchen** (Standard: aus):
„Prüft nach jedem erfolgreichen Verbinden automatisch, ob eine neuere
RouterOS-Version verfügbar ist — entspricht dem Button „Nach Updates
suchen“ im Tab „Verbinden“.“
**Darstellung**
- **Farbschema**: Standard oder Kontrastreich. „Gilt für die
Diagrammfarben in der Übersicht und die Status-Farben im LAN-Scanner
(Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port).“ Färbt
außerdem die Kategorien in der Experte-Tab-Seitenleiste (Firewall,
Interfaces, IP-Adressierung, Routing, VPN/WLAN/Queues/System/
Werkzeuge) mit derselben Palette.
- **Textgröße**: Klein / Standard / Groß / Sehr groß. Gilt für Text
überall in der App — mit zwei bewussten Ausnahmen: die Knotenkarten im
Übersicht-Diagramm und die Tabellenspalten im LAN-Scanner bleiben fest
in ihrer Schriftgröße, da sie feste Kartenbreiten haben und sonst
Text abschneiden würden.
- **Bedienelemente**: Kompakt / Standard / Komfortabel — „Größe von
Buttons, Feldern und Abständen in der ganzen App.“
**Netzwerk**
- **Aktualisierungsrate** (0,1 s / 0,5 s / 1 s / 3 s): wie oft der
LAN-Scanner den Live-Traffic pro Port abfragt. „Höhere
Aktualisierungsraten zeigen den Traffic-Verlauf feiner, kosten aber
mehr CPU bei vielen gleichzeitig aktiven Ports.“
- **Sparkline-Zeitfenster** (10 s / 30 s / 60 s) und
**Sparkline-Breite** (100300pt): Länge bzw. Breite der kleinen
Traffic-Verlaufsgrafik neben jeder Port-Überschrift im LAN-Scanner.
---
## 8. English summary
RouterOS Assistant is a native macOS app that sets up and manages
MikroTik RouterOS routers through a guided wizard and six tabs: Connect,
@@ -1126,3 +1170,11 @@ language and persists across restarts.
restoring, to avoid bricking the device with the wrong backup),
configurable save location, and a danger-zone factory-reset switch
(auto-backs-up first).
- **Settings** (⌘,, native macOS settings window, not a tab): language,
auto-check for RouterOS updates on connect, a color theme (Standard/
High Contrast — Topology diagram, LAN Scanner status colors, Expert
sidebar categories), text size (with two deliberate exceptions: the
Topology diagram's node cards and the LAN Scanner's fixed-width table
columns stay fixed-size, or their layout would break), control-element
size, and the LAN Scanner's refresh rate/sparkline window/sparkline
width. Every change applies live, no restart needed.
BIN
View File
Binary file not shown.
+10 -1
View File
@@ -72,6 +72,12 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
Neustarts). Vollständig übersetzt: kompletter Experte-Tab (alle 9
Menü-Familien) und der Verbinden-Tab; weitere Tabs folgen
schrittweise.
- **Einstellungen** (⌘,, natives macOS-Settings-Fenster) — Sprache,
automatischer RouterOS-Update-Check beim Verbinden, Farbschema
(Standard/Kontrastreich, für Übersicht-Diagramm + LAN-Scanner-
Statusfarben + Experte-Sidebar-Kategorien), Textgröße, Bedienelement-
Größe, LAN-Scanner-Aktualisierungsrate/Sparkline-Zeitfenster/-Breite.
Jede Änderung wirkt sofort, ohne Neustart.
## Architektur
@@ -162,6 +168,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M27 | Passwort-Anzeige-Button, Schlüsselbund nach Seriennummer getrennt, `terse`-Fallback verallgemeinert | ✅ live verifiziert |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
| M28 | Übersicht-Tab: Fokus-Modus (Klick auf Knoten → Kette im schwebenden Popup, Rest abgedunkelt); Close-Button-Layout in allen vier Popups vereinheitlicht | ✅ live verifiziert |
| M29 | Einstellungen-Fenster (⌘,): Sprache, Update-Auto-Check, Farbschema, Textgröße, Bedienelement-Größe, LAN-Scanner-Refreshraten | ✅ live verifiziert |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
@@ -187,6 +194,7 @@ RouterOS/
│ │ │ ├── InterfaceTraffic.swift — ein Live-Durchsatz-Sample + TrafficSample (10s-Sparkline-Historie, LAN-Scanner)
│ │ │ ├── NetworkToolResult.swift — Rohausgabe von Ping/Traceroute/DNS-Auflösung
│ │ │ ├── PortScanResult.swift — Ergebnis eines Port-Scans, ein Eintrag pro Port
│ │ │ ├── AppPreferences.swift — Settings-Infrastruktur: AppStorage-Keys, ColorTheme/AppTextSize/UIDensity
│ │ │ └── RouterOSModels.swift — Credentials, DeviceInfo, RouterBoardInfo, PackageUpdateInfo, Fehler
│ │ ├── Networking/
│ │ │ ├── RouterOSTransport.swift — Protocol: connect/fetch.../apply/disconnect
@@ -212,7 +220,8 @@ RouterOS/
│ │ ├── Overview/ — Übersicht-Tab
│ │ ├── Devices/ — LAN-Scanner-Tab
│ │ ├── Expert/ — Experte-Tab
│ │ ── Backup/ — Sicherungen-Tab
│ │ ── Backup/ — Sicherungen-Tab
│ │ └── Settings/ — Einstellungen-Fenster (⌘,)
│ └── Resources/Assets.xcassets/ — App-Icon
├── RouterOSAssistantTests/ — Unit-Tests (Command-Builder, CLI-Parser, ViewModels, Mock-Transport)
├── project.yml — xcodegen-Projektdefinition (Quelle der Wahrheit, nicht das generierte .xcodeproj editieren)
@@ -9,6 +9,12 @@ struct RouterOSAssistantApp: App {
/// live: toggling had no visible effect), so views read this same AppStorage key directly and
/// translate via `L10n.t(_:_:)` instead.
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.textSizeKey) private var textSizeRaw: String = AppTextSize.standard.rawValue
@AppStorage(AppPreferences.uiDensityKey) private var uiDensityRaw: String = UIDensity.standard.rawValue
init() {
AppPreferences.registerDefaults()
}
var body: some Scene {
WindowGroup {
@@ -38,7 +44,20 @@ struct RouterOSAssistantApp: App {
.help(appLanguage == "de" ? "Switch to English" : "Auf Deutsch umschalten")
}
}
.controlSize((UIDensity(rawValue: uiDensityRaw) ?? .standard).controlSize)
.environment(\.appFontScale, (AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)
// Fallback for every `Text`/`TextField`/`Label` that has no explicit `.font(...)` of
// its own (confirmed live: the Experte tab's whole sidebar list and every Parameter/
// Wert `TextField` have none `.appFont(...)` only touches call sites that already
// had a `.font(...)` to replace, so unstyled text stayed fixed-size). `.environment
// (\.font, ...)` only ever supplies a *default*: anywhere `.appFont(...)` already sets
// an explicit font, that still wins.
.environment(\.font, .system(size: 13 * ((AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)))
}
.environment(\.locale, Locale(identifier: appLanguage))
Settings {
SettingsView()
}
}
}
+32 -1
View File
@@ -934,6 +934,37 @@ enum L10n {
"Dies löscht ALLE aktuellen Einstellungen restlos (auch die Werks-Grundkonfiguration, nicht nur deine eigenen Änderungen) und ersetzt sie durch den Inhalt der Sicherung. Der Router startet neu, diese App verliert danach die Verbindung.":
"This completely deletes ALL current settings (including the factory baseline configuration, not just your own changes) and replaces them with the backup's content. The router reboots, and this app loses the connection afterwards.",
"Falls die Wiederherstellung fehlschlägt, bleibt der Router leer stehen, nicht auf Werkseinstellungen zurückgefallen.":
"If the restore fails, the router is left blank, not reverted to factory defaults."
"If the restore fails, the router is left blank, not reverted to factory defaults.",
// MARK: - Settings window (,)
"Allgemein": "General",
"Darstellung": "Appearance",
"Sprache": "Language",
"Entspricht dem 🇩🇪/🇬🇧-Knopf in der Toolbar — beide steuern dieselbe Einstellung.":
"Same as the 🇩🇪/🇬🇧 button in the toolbar — both control the same setting.",
"RouterOS-Updates": "RouterOS Updates",
"Beim Verbinden automatisch nach Updates suchen": "Automatically check for updates when connecting",
"Prüft nach jedem erfolgreichen Verbinden automatisch, ob eine neuere RouterOS-Version verfügbar ist — entspricht dem Button \"Nach Updates suchen\" im Tab \"Verbinden\".":
"Automatically checks after every successful connection whether a newer RouterOS version is available — same as the \"Check for updates\" button in the \"Connect\" tab.",
"Farbschema": "Color Theme",
"Standard": "Standard",
"Kontrastreich": "High Contrast",
"Gilt für die Diagrammfarben in der Übersicht und die Status-Farben im LAN-Scanner (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port).":
"Applies to the diagram colors in the Topology tab and the status colors in the LAN Scanner (traffic active, static/dynamic, open/closed port).",
"Textgröße": "Text Size",
"Klein": "Small",
"Groß": "Large",
"Sehr groß": "Extra Large",
"Bedienelemente": "Controls",
"Kompakt": "Compact",
"Komfortabel": "Comfortable",
"Größe von Buttons, Feldern und Abständen in der ganzen App (\"Responsiveness\").":
"Size of buttons, fields, and spacing throughout the app (\"responsiveness\").",
"LAN-Scanner: Traffic-Anzeige": "LAN Scanner: Traffic Display",
"Aktualisierungsrate": "Refresh Rate",
"Sparkline-Zeitfenster": "Sparkline Time Window",
"Sparkline-Breite": "Sparkline Width",
"Höhere Aktualisierungsraten zeigen den Traffic-Verlauf feiner, kosten aber mehr CPU bei vielen gleichzeitig aktiven Ports.":
"Higher refresh rates show the traffic trend more finely, but cost more CPU when many ports are active at once."
]
}
@@ -0,0 +1,236 @@
import SwiftUI
/// Central catalog for the "Settings" window (,) every value here is read/written through
/// `@AppStorage`/`UserDefaults.standard` under the raw string keys named on each property below,
/// so a view (`@AppStorage`, live-updating) and a background `Task` loop (`UserDefaults.standard`
/// reads each tick, no observation needed) can both stay in sync with the same preference without
/// plumbing it through init parameters. `registerDefaults()` is called once at app launch so a
/// fresh install has real values before any Settings screen has ever been opened, matching the
/// literal fallback each `@AppStorage`/`UserDefaults` read site also carries the two must be
/// kept in sync by hand since `@AppStorage` requires its default inline.
enum AppPreferences {
static let colorThemeKey = "colorTheme"
static let textSizeKey = "textSize"
static let uiDensityKey = "uiDensity"
static let autoCheckUpdatesOnConnectKey = "autoCheckUpdatesOnConnect"
static let lanScannerPollIntervalSecondsKey = "lanScannerPollIntervalSeconds"
static let lanScannerSparklineWindowSecondsKey = "lanScannerSparklineWindowSeconds"
static let lanScannerSparklineWidthKey = "lanScannerSparklineWidth"
static func registerDefaults() {
UserDefaults.standard.register(defaults: [
colorThemeKey: ColorTheme.standard.rawValue,
textSizeKey: AppTextSize.standard.rawValue,
uiDensityKey: UIDensity.standard.rawValue,
autoCheckUpdatesOnConnectKey: false,
lanScannerPollIntervalSecondsKey: 0.1,
lanScannerSparklineWindowSecondsKey: 30.0,
lanScannerSparklineWidthKey: 200.0,
])
}
}
/// Two predefined palettes for the Übersicht diagram's node/edge colors and the LAN-Scanner's
/// status dots (traffic-active, static/dynamic lease, open/closed port) a free per-category
/// color picker was explicitly ruled out in favor of this, so every color lives here rather than
/// scattered across the views that used to hardcode `Color.blue`/`.red`/etc. directly.
enum ColorTheme: String, CaseIterable, Identifiable {
case standard
case highContrast
var id: String { rawValue }
func color(for category: OverviewNode.Category) -> Color {
switch self {
case .standard:
switch category {
case .interface: return .blue
case .ipAddress: return .teal
case .service: return .purple
case .route: return .orange
case .firewall: return .red
}
case .highContrast:
switch category {
case .interface: return .yellow
case .ipAddress: return .green
case .service: return .pink
case .route: return .cyan
case .firewall: return .indigo
}
}
}
func color(for kind: OverviewEdgeKind) -> Color {
switch self {
case .standard:
switch kind {
case .vlan: return .indigo
case .bridgePort: return .cyan
case .wireguardPeer: return .pink
case .ipAddress: return .teal
case .dhcp: return .purple
case .route: return .orange
case .firewallInterface: return .red
case .addressList: return .yellow
}
case .highContrast:
switch kind {
case .vlan: return .yellow
case .bridgePort: return .brown
case .wireguardPeer: return .mint
case .ipAddress: return .green
case .dhcp: return .cyan
case .route: return .indigo
case .firewallInterface: return .pink
case .addressList: return .blue
}
}
}
/// Experte-tab sidebar: one dot per `RouterOSMenuCategory` (13 cases far more granular than
/// the 5-case `OverviewNode.Category`), reusing that same 5-color palette by bucketing every
/// firewall sub-menu under `.firewall` and everything without an obvious 1:1 match (VPN,
/// Wi-Fi/CAPsMAN, Queues, System, Tools) under `.service` keeps "Firewall is red" etc.
/// consistent with the Übersicht diagram instead of inventing a second, unrelated palette.
func color(for menuCategory: RouterOSMenuCategory) -> Color {
switch menuCategory {
case .firewallFilter, .firewallNat, .firewallMangle, .firewallRaw, .firewallAddressLists:
return color(for: OverviewNode.Category.firewall)
case .interfaces:
return color(for: OverviewNode.Category.interface)
case .ipAddressing:
return color(for: OverviewNode.Category.ipAddress)
case .routing:
return color(for: OverviewNode.Category.route)
case .vpn, .wireless, .queues, .system, .tools:
return color(for: OverviewNode.Category.service)
}
}
/// LAN-Scanner: port-header "traffic active" dot, and `DeviceRow`'s static/dynamic lease dot.
/// First draft used `.mint`/`.yellow`/`.pink` here too close to their standard counterparts
/// (green/orange/red) to read as a real change at small dot/icon size, per live feedback.
/// Now a genuinely different hue, not just a lighter shade of the same one.
var trafficActive: Color { self == .standard ? .green : .blue }
var staticLease: Color { self == .standard ? .orange : .purple }
var dynamicLease: Color { self == .standard ? .green : .blue }
var portOpen: Color { self == .standard ? .red : .orange }
var portClosed: Color { self == .standard ? .green : .blue }
}
/// Global text scale factor, read via `.environment(\.appFontScale, ...)` at the window root and
/// applied by every `.appFont(...)` call site (see below).
///
/// NOT built on `.environment(\.dynamicTypeSize, ...)` that was the first attempt, and it does
/// nothing on macOS: confirmed empirically (an `ImageRenderer` snapshot of the same `Text` at
/// `.xSmall` vs `.accessibility3` produced pixel-identical output). Unlike iOS, macOS's SwiftUI
/// text styles (`.headline`, `.caption`, ...) resolve to fixed point sizes that don't respond to
/// a Dynamic Type category at all, so that environment key is a no-op here. `.appFont(_:)` instead
/// gives each text style an explicit base point size (macOS's own approximate defaults) that this
/// scale factor actually multiplies.
enum AppTextSize: String, CaseIterable, Identifiable {
case small
case standard
case large
case extraLarge
var id: String { rawValue }
var scale: CGFloat {
switch self {
case .small: return 0.85
case .standard: return 1.0
case .large: return 1.2
case .extraLarge: return 1.4
}
}
}
private struct AppFontScaleKey: EnvironmentKey {
static let defaultValue: CGFloat = 1.0
}
extension EnvironmentValues {
var appFontScale: CGFloat {
get { self[AppFontScaleKey.self] }
set { self[AppFontScaleKey.self] = newValue }
}
}
/// Named, scalable stand-ins for the semantic `Font.TextStyle` cases actually used in this app
/// each carries the same base point size macOS itself uses for that style, so `.standard` (scale
/// 1.0) renders identically to the plain `Font.TextStyle` it replaces.
enum AppFontStyle {
case title2, title3, headline, body, callout, subheadline, caption, caption2
/// An explicit point size/weight/design not covered by a named style above (rare only where
/// the code already used `.font(.system(size:...))` directly).
case fixed(CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default)
fileprivate var baseSize: CGFloat {
switch self {
case .title2: return 17
case .title3: return 15
case .headline: return 13
case .body: return 13
case .callout: return 12
case .subheadline: return 11
case .caption: return 10
case .caption2: return 9
case .fixed(let size, _, _): return size
}
}
fileprivate var baseWeight: Font.Weight {
if case .headline = self { return .semibold }
if case .fixed(_, let weight, _) = self { return weight }
return .regular
}
fileprivate var design: Font.Design {
if case .fixed(_, _, let design) = self { return design }
return .default
}
}
private struct AppFontModifier: ViewModifier {
@Environment(\.appFontScale) private var scale
let style: AppFontStyle
let weight: Font.Weight?
let bold: Bool
let design: Font.Design?
func body(content: Content) -> some View {
let resolvedWeight = bold ? .bold : (weight ?? style.baseWeight)
content.font(.system(size: style.baseSize * scale, weight: resolvedWeight, design: design ?? style.design))
}
}
extension View {
/// Text-only scaling for the Settings "Textgröße" preference see `AppTextSize`'s doc comment
/// for why this exists instead of `.environment(\.dynamicTypeSize, ...)`. `bold`/`weight`/
/// `design` mirror the `.bold()`/`.weight(_:)`/`.monospaced()` chaining the replaced
/// `.font(...)` calls used.
func appFont(_ style: AppFontStyle, weight: Font.Weight? = nil, bold: Bool = false, design: Font.Design? = nil) -> some View {
modifier(AppFontModifier(style: style, weight: weight, bold: bold, design: design))
}
}
/// Global control/spacing density ("Responsiveness" from the found.md feature idea, made
/// concrete as SwiftUI's own `.controlSize`, applied once at the window root) compact fits more
/// on screen on a small display, comfortable gives buttons/fields more room to hit on a large one.
enum UIDensity: String, CaseIterable, Identifiable {
case compact
case standard
case comfortable
var id: String { rawValue }
var controlSize: ControlSize {
switch self {
case .compact: return .small
case .standard: return .regular
case .comfortable: return .large
}
}
}
@@ -100,10 +100,10 @@ struct BackupListView: View {
VStack(alignment: .leading) {
Text(backup.host).bold()
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
if let model = BackupService.backupModel(from: backup.fileURL) {
Text(model).font(.caption2).foregroundStyle(.secondary)
Text(model).appFont(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
@@ -125,10 +125,10 @@ struct BackupListView: View {
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.t("Speicherort", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
Text(viewModel.backupDirectoryPath)
.font(.caption)
.appFont(.caption)
.lineLimit(1)
.truncationMode(.middle)
.help(viewModel.backupDirectoryPath)
@@ -143,10 +143,10 @@ struct BackupListView: View {
.padding(.vertical, 4)
Text(L10n.t("Gefahrenzone", appLanguage))
.font(.caption.bold())
.appFont(.caption, bold: true)
.foregroundStyle(.red)
Text(L10n.t("Setzt den Router komplett auf die vom Hersteller mitgelieferte Standardkonfiguration zurück — alle bisherigen Änderungen (Internet, Heimnetz, VLANs, WLAN, Firewall) gehen verloren. Der Router startet danach neu.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
Button(role: .destructive) {
showFactoryResetConfirmation = true
@@ -168,14 +168,14 @@ struct BackupListView: View {
L10n.t("Befehl gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", appLanguage),
systemImage: "arrow.clockwise"
)
.font(.caption)
.appFont(.caption)
}
if viewModel.didSendRestore {
Label(
L10n.t("Wiederherstellung gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", appLanguage),
systemImage: "arrow.clockwise"
)
.font(.caption)
.appFont(.caption)
}
}
.padding(8)
@@ -20,6 +20,9 @@ struct DevicesView: View {
/// Same reasoning as `showStaticConfirmation` independent of `pendingStaticRemoval`.
@State private var showRemovalConfirmation = false
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
@AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
init(connectionService: ConnectionService) {
self.connectionService = connectionService
@@ -53,12 +56,12 @@ struct DevicesView: View {
ForEach(viewModel.portGroups) { group in
Section {
if group.devices.isEmpty {
Text(L10n.t("Keine Geräte", appLanguage)).font(.caption).foregroundStyle(.secondary)
Text(L10n.t("Keine Geräte", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
} else {
DeviceColumnHeader(appLanguage: appLanguage)
ForEach(group.devices) { device in
HStack(spacing: 4) {
DeviceRow(device: device, appLanguage: appLanguage)
DeviceRow(device: device, appLanguage: appLanguage, theme: colorTheme)
Spacer()
// Per explicit request: right-click alone wasn't
// discoverable ("nicht eindeutig erkennbar oder
@@ -82,13 +85,13 @@ struct DevicesView: View {
Text("\(group.title) (\(group.devices.count))")
if let traffic = viewModel.portTraffic[group.id] {
Spacer()
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [])
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [], width: sparklineWidth)
Label(
Self.formatTraffic(traffic),
systemImage: traffic.isActive ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle"
)
.font(.caption2)
.foregroundStyle(traffic.isActive ? .green : .secondary)
.appFont(.caption2)
.foregroundStyle(traffic.isActive ? colorTheme.trafficActive : .secondary)
}
}
}
@@ -101,7 +104,7 @@ struct DevicesView: View {
if viewModel.isRunningNetworkTool {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).font(.caption)
Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).appFont(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -152,7 +155,7 @@ struct DevicesView: View {
showStaticConfirmation: $showStaticConfirmation,
showRemovalConfirmation: $showRemovalConfirmation
)
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, rawFieldsDevice: $rawFieldsDevice)
.withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, theme: colorTheme, rawFieldsDevice: $rawFieldsDevice)
}
}
@@ -303,7 +306,7 @@ private extension View {
}
}
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, rawFieldsDevice: Binding<LanDevice?>) -> some View {
func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, theme: ColorTheme, rawFieldsDevice: Binding<LanDevice?>) -> some View {
self
.sheet(item: rawFieldsDevice) { device in
RawFieldsSheet(device: device, appLanguage: appLanguage) { rawFieldsDevice.wrappedValue = nil }
@@ -318,7 +321,7 @@ private extension View {
get: { viewModel.portScanResult },
set: { if $0 == nil { viewModel.portScanResult = nil } }
)) { result in
PortScanResultSheet(result: result, appLanguage: appLanguage) { viewModel.portScanResult = nil }
PortScanResultSheet(result: result, appLanguage: appLanguage, theme: theme) { viewModel.portScanResult = nil }
}
.alert(
L10n.t("Netzwerk-Test fehlgeschlagen", appLanguage),
@@ -343,6 +346,7 @@ private extension View {
/// layout doesn't jump around during the first couple of poll ticks after opening the tab.
private struct TrafficSparkline: View {
let history: [TrafficSample]
let width: Double
var body: some View {
Group {
@@ -362,7 +366,7 @@ private struct TrafficSparkline: View {
Color.clear
}
}
.frame(width: 200, height: 16)
.frame(width: width, height: 16)
}
}
@@ -395,6 +399,7 @@ private struct DeviceColumnHeader: View {
private struct DeviceRow: View {
let device: LanDevice
let appLanguage: String
let theme: ColorTheme
/// Three distinct states, not two a device with no matching DHCP lease at all is neither
/// "Fest" (a real static reservation) nor "Dynamisch" (a real active lease); conflating it
@@ -407,7 +412,7 @@ private struct DeviceRow: View {
private var statusColor: Color {
guard device.hasLease else { return .secondary }
return device.isStatic ? .orange : .green
return device.isStatic ? theme.staticLease : theme.dynamicLease
}
private var statusText: String {
@@ -454,7 +459,7 @@ private struct RawFieldsSheet: View {
VStack(spacing: 0) {
HStack {
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
.font(.headline)
.appFont(.headline)
Spacer()
Button {
onClose()
@@ -471,15 +476,15 @@ private struct RawFieldsSheet: View {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(device.rawFields, id: \.key) { pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
}
}
}
@@ -502,7 +507,7 @@ private struct NetworkToolResultSheet: View {
VStack(spacing: 0) {
HStack {
Text(result.title)
.font(.headline)
.appFont(.headline)
Spacer()
Button {
onClose()
@@ -519,11 +524,11 @@ private struct NetworkToolResultSheet: View {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
ScrollView {
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
.font(.system(.caption, design: .monospaced))
.appFont(.caption, design: .monospaced)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
@@ -541,13 +546,14 @@ private struct NetworkToolResultSheet: View {
private struct PortScanResultSheet: View {
let result: PortScanResult
let appLanguage: String
let theme: ColorTheme
let onClose: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
.font(.headline)
.appFont(.headline)
Spacer()
Button {
onClose()
@@ -564,13 +570,13 @@ private struct PortScanResultSheet: View {
VStack(alignment: .leading, spacing: 12) {
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
ScrollView {
VStack(spacing: 0) {
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { Divider() }
PortScanRow(entry: entry, appLanguage: appLanguage)
PortScanRow(entry: entry, appLanguage: appLanguage, theme: theme)
}
}
}
@@ -584,11 +590,12 @@ private struct PortScanResultSheet: View {
private struct PortScanRow: View {
let entry: PortScanResult.Entry
let appLanguage: String
let theme: ColorTheme
private var color: Color {
switch entry.status {
case .open: return .red
case .closed: return .green
case .open: return theme.portOpen
case .closed: return theme.portClosed
case .unreachable: return .secondary
}
}
@@ -605,15 +612,15 @@ private struct PortScanRow: View {
HStack {
Circle().fill(color).frame(width: 8, height: 8)
Text("\(entry.port)")
.font(.system(.caption, design: .monospaced))
.appFont(.caption, design: .monospaced)
.frame(width: 50, alignment: .leading)
Text(entry.serviceName ?? "-")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
.frame(width: 140, alignment: .leading)
Spacer()
Text(statusText)
.font(.caption)
.appFont(.caption)
.foregroundStyle(color)
}
.padding(.vertical, 3)
@@ -61,10 +61,14 @@ final class DevicesViewModel: ObservableObject {
self.networkToolsService = networkToolsService
}
/// 0.1s cadence (faster than `ConnectViewModel.startTrafficPolling`'s 3s, per explicit
/// request this tab's sparkline needs a finer-grained trend, not just the link dot)
/// re-reads `portGroups` every tick so a newly-appearing port (e.g. a VLAN interface added
/// via the Setup wizard while this tab is open) gets picked up without restarting the poll.
/// Cadence and sparkline window are both live Settings values (`AppPreferences`, defaults
/// 0.1s/30s) read fresh from `UserDefaults` each tick/append rather than captured once, so
/// a change in the Settings window while this tab is already polling takes effect on the next
/// tick instead of needing the tab reopened. Deliberately faster by default than
/// `ConnectViewModel.startTrafficPolling`'s fixed 3s (a separate, unrelated poll this tab's
/// sparkline needs a finer-grained trend, not just the link dot). Re-reads `portGroups` every
/// tick so a newly-appearing port (e.g. a VLAN interface added via the Setup wizard while this
/// tab is open) gets picked up without restarting the poll.
func startTrafficPolling(credentials: RouterOSCredentials) {
stopTrafficPolling()
trafficPollingTask = Task {
@@ -77,13 +81,15 @@ final class DevicesViewModel: ObservableObject {
appendTrafficHistory(traffic, at: Date())
}
}
try? await Task.sleep(for: .milliseconds(100))
let intervalSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerPollIntervalSecondsKey) as? Double ?? 0.1
try? await Task.sleep(for: .milliseconds(max(1, Int(intervalSeconds * 1000))))
}
}
}
private func appendTrafficHistory(_ traffic: [String: InterfaceTraffic], at timestamp: Date) {
let cutoff = timestamp.addingTimeInterval(-30)
let windowSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerSparklineWindowSecondsKey) as? Double ?? 30
let cutoff = timestamp.addingTimeInterval(-windowSeconds)
for (name, sample) in traffic {
var history = portTrafficHistory[name] ?? []
history.append(TrafficSample(timestamp: timestamp, totalBitsPerSecond: sample.rxBitsPerSecond + sample.txBitsPerSecond))
@@ -9,15 +9,15 @@ struct ExpertMenuDetailView: View {
Form {
Section {
Text(L10n.t(schema.displayName, appLanguage))
.font(.title2)
.appFont(.title2)
.bold()
Text(L10n.t(schema.category.rawValue, appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
Text(L10n.t(schema.explanation, appLanguage))
if let warning = schema.warning {
Label(L10n.t(warning, appLanguage), systemImage: "exclamationmark.triangle")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.orange)
}
}
@@ -128,7 +128,7 @@ struct ExpertItemEditView: View {
// directly below, per explicit request to keep every popup's close button consistent.
VStack(spacing: 0) {
HStack {
Text(L10n.t(schema.displayName, appLanguage)).font(.headline)
Text(L10n.t(schema.displayName, appLanguage)).appFont(.headline)
Spacer()
Button {
viewModel.cancelEditing()
@@ -151,7 +151,7 @@ struct ExpertItemEditView: View {
Section(LocalizedStringKey(L10n.t("Weitere Parameter (frei)", appLanguage))) {
Text(L10n.t("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
// Zwei Spalten statt einer Zeile pro Parameter bei Menüs mit vielen ungekuratierten
// RouterOS-Feldern (z.B. "Alle Interfaces (generisch)", wo Ethernet-Ports gut 30+
@@ -202,7 +202,7 @@ struct ExpertItemEditView: View {
if let command = viewModel.pendingCommand {
Section(LocalizedStringKey(L10n.t("Wird ausgeführt", appLanguage))) {
Text(command.cliLine)
.font(.system(.caption, design: .monospaced))
.appFont(.caption, design: .monospaced)
.foregroundStyle(.secondary)
}
}
@@ -245,7 +245,7 @@ struct ExpertItemEditView: View {
private func extraColumnHeader(_ key: String) -> some View {
Text(L10n.t(key, appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
@@ -360,7 +360,7 @@ private struct DurationFieldEditor: View {
.frame(minWidth: 26)
}
Text(unit)
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.secondary)
}
}
@@ -4,6 +4,8 @@ struct ExpertView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: ExpertViewModel
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
/// A category's presence in this set means "collapsed". Starts with every category
/// collapsed (Nutzerwunsch: "standardmäßig sollten beim Öffnen des Experte-Tab alle
/// Sektion zugeklappt sein") click a header to fold its menu list open again.
@@ -51,7 +53,7 @@ struct ExpertView: View {
}
}
} header: {
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded) {
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded, categoryColor: colorTheme.color(for: category)) {
if isExpanded {
collapsedCategories.insert(category)
} else {
@@ -65,7 +67,7 @@ struct ExpertView: View {
Section {
if isCustomPathExpanded {
Text(L10n.t("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
TextField(L10n.t("Menüpfad, z.B. /ip firewall filter", appLanguage), text: $viewModel.customMenuPath)
.help(L10n.t("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".", appLanguage))
@@ -113,15 +115,18 @@ struct ExpertView: View {
/// nicht erst beim Hovern"). The whole header is a plain `Button` toggling `onToggle`, with
/// the section's own content shown/hidden by the caller via a plain `if isExpanded` instead
/// of the `Section(isExpanded:)` API, since that API owns its own (hover-only) chevron.
private func sectionHeader(_ title: String, isExpanded: Bool, onToggle: @escaping () -> Void) -> some View {
private func sectionHeader(_ title: String, isExpanded: Bool, categoryColor: Color? = nil, onToggle: @escaping () -> Void) -> some View {
Button(action: onToggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption.bold())
.appFont(.caption, bold: true)
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
if let categoryColor {
Circle().fill(categoryColor).frame(width: 8, height: 8)
}
Text(title)
.font(.title3.bold())
.appFont(.title3, bold: true)
.foregroundStyle(.primary)
}
.padding(.vertical, 6)
@@ -13,6 +13,8 @@ struct OverviewView: View {
/// instead of duplicating either here.
@StateObject private var expertViewModel: ExpertViewModel
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
@State private var scale: CGFloat = 1.0
@State private var hoveredNodeID: String?
@State private var hoveredEdge: OverviewEdge?
@@ -232,6 +234,7 @@ struct OverviewView: View {
graph: viewModel.graph,
positions: effectivePositions,
highlightedNodeIDs: focusChainIDs ?? highlightedNodeIDs,
theme: colorTheme,
hoveredEdge: $hoveredEdge,
hoverPoint: $hoverPoint,
selectedEdge: $selectedEdge,
@@ -244,7 +247,7 @@ struct OverviewView: View {
ForEach(Array(OverviewLayout.columnOrder.enumerated()), id: \.offset) { index, category in
Text(L10n.t(category.rawValue, appLanguage))
.font(.headline)
.appFont(.headline)
.foregroundStyle(.secondary)
.position(
x: OverviewLayout.leftInset + CGFloat(index) * OverviewLayout.columnWidth + OverviewLayout.nodeWidth / 2,
@@ -258,7 +261,8 @@ struct OverviewView: View {
NodeCardView(
node: node,
isSelected: node.id == viewModel.selectedNodeID,
isHovered: node.id == hoveredNodeID
isHovered: node.id == hoveredNodeID,
theme: colorTheme
)
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
.position(point)
@@ -289,7 +293,7 @@ struct OverviewView: View {
}
if let edge = hoveredEdge {
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage)
EdgeTooltipView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme)
.position(
x: min(max(hoverPoint.x + 90, 90), layout.canvasSize.width - 90),
y: max(hoverPoint.y - 26, 16)
@@ -332,7 +336,7 @@ struct OverviewView: View {
private func focusPanel(subGraph: OverviewGraph, subLayout: OverviewLayoutResult) -> some View {
VStack(spacing: 0) {
HStack {
Text(L10n.t("Fokus", appLanguage)).font(.headline)
Text(L10n.t("Fokus", appLanguage)).appFont(.headline)
Spacer()
Button {
viewModel.selectedNodeID = nil
@@ -356,6 +360,7 @@ struct OverviewView: View {
graph: subGraph,
positions: subLayout.positions,
highlightedNodeIDs: Set(subGraph.nodes.map(\.id)),
theme: colorTheme,
hoveredEdge: $panelHoveredEdge,
hoverPoint: $panelHoverPoint,
selectedEdge: $selectedEdge,
@@ -371,7 +376,8 @@ struct OverviewView: View {
NodeCardView(
node: node,
isSelected: node.id == viewModel.selectedNodeID,
isHovered: node.id == hoveredNodeID
isHovered: node.id == hoveredNodeID,
theme: colorTheme
)
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
.position(point)
@@ -386,7 +392,7 @@ struct OverviewView: View {
}
if let edge = panelHoveredEdge {
EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage)
EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage, theme: colorTheme)
.position(
x: min(max(panelHoverPoint.x + 90, 90), subLayout.canvasSize.width - 90),
y: max(panelHoverPoint.y - 26, 16)
@@ -406,22 +412,22 @@ struct OverviewView: View {
@ViewBuilder
private var detailPanel: some View {
if let edge = selectedEdge {
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage) { nodeID in
EdgeDetailView(edge: edge, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme) { nodeID in
selectedEdge = nil
viewModel.selectedNodeID = nodeID
}
} else if let id = viewModel.selectedNodeID, let node = viewModel.graph.nodes.first(where: { $0.id == id }) {
NodeDetailView(node: node, graph: viewModel.graph, appLanguage: appLanguage, onEdit: editNode) { nodeID in
NodeDetailView(node: node, graph: viewModel.graph, appLanguage: appLanguage, theme: colorTheme, onEdit: editNode) { nodeID in
selectedEdge = nil
viewModel.selectedNodeID = nodeID
}
} else {
LegendView(appLanguage: appLanguage)
LegendView(appLanguage: appLanguage, theme: colorTheme)
}
}
}
/// Draws every edge as a bezier curve, colored by its `kind` (see `OverviewStyle.color(for:)`)
/// Draws every edge as a bezier curve, colored by its `kind` (see `colorTheme.color(for:)`)
/// so the different dependency types are visually separated even with nothing selected. Hovering
/// or clicking a node pushes its own edges to full strength and fades every other edge down to
/// a hint, so "what connects to this" reads instantly in a busy diagram. Also tracks the mouse
@@ -431,6 +437,7 @@ private struct EdgesCanvas: View {
let graph: OverviewGraph
let positions: [String: CGPoint]
let highlightedNodeIDs: Set<String>
let theme: ColorTheme
@Binding var hoveredEdge: OverviewEdge?
@Binding var hoverPoint: CGPoint
@Binding var selectedEdge: OverviewEdge?
@@ -537,7 +544,7 @@ private struct EdgesCanvas: View {
var path = Path()
path.move(to: geometry.start)
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
let baseColor = OverviewStyle.color(for: edge.kind)
let baseColor = theme.color(for: edge.kind)
context.stroke(
path,
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
@@ -640,6 +647,7 @@ private struct EdgeTooltipView: View {
let edge: OverviewEdge
let graph: OverviewGraph
let appLanguage: String
let theme: ColorTheme
private var fromTitle: String { graph.nodes.first(where: { $0.id == edge.from })?.title ?? edge.from }
private var toTitle: String { graph.nodes.first(where: { $0.id == edge.to })?.title ?? edge.to }
@@ -647,12 +655,12 @@ private struct EdgeTooltipView: View {
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t(edge.kind.rawValue, appLanguage))
.font(.caption.bold())
.appFont(.caption, bold: true)
Text("\(fromTitle)\(toTitle)")
.font(.caption2)
.appFont(.caption2)
if let label = edge.label {
Text(label)
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.secondary)
}
}
@@ -660,7 +668,7 @@ private struct EdgeTooltipView: View {
.background(RoundedRectangle(cornerRadius: 6).fill(.regularMaterial))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(OverviewStyle.color(for: edge.kind), lineWidth: 1)
.stroke(theme.color(for: edge.kind), lineWidth: 1)
)
.fixedSize()
}
@@ -674,6 +682,7 @@ private struct EdgeDetailView: View {
let edge: OverviewEdge
let graph: OverviewGraph
let appLanguage: String
let theme: ColorTheme
let onJumpToNode: (String) -> Void
private var fromNode: OverviewNode? { graph.nodes.first(where: { $0.id == edge.from }) }
@@ -686,27 +695,27 @@ private struct EdgeDetailView: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "arrow.triangle.branch")
.foregroundStyle(OverviewStyle.color(for: edge.kind))
Text(L10n.t(edge.kind.rawValue, appLanguage)).font(.title3.bold())
.foregroundStyle(theme.color(for: edge.kind))
Text(L10n.t(edge.kind.rawValue, appLanguage)).appFont(.title3, bold: true)
}
Text(L10n.t("Verbindung", appLanguage)).font(.caption).foregroundStyle(.secondary)
Text(L10n.t("Verbindung", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
Divider()
Text(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
.font(.callout)
.appFont(.callout)
.fixedSize(horizontal: false, vertical: true)
if let label = edge.label {
Text(label)
.font(.callout)
.appFont(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider()
Text(L10n.t("Verbunden", appLanguage)).font(.subheadline.bold())
Text(L10n.t("Verbunden", appLanguage)).appFont(.subheadline, bold: true)
endpointRow(title: L10n.t("Von", appLanguage), node: fromNode, fallbackTitle: fromTitle, id: edge.from)
endpointRow(title: L10n.t("Nach", appLanguage), node: toNode, fallbackTitle: toTitle, id: edge.to)
@@ -722,18 +731,18 @@ private struct EdgeDetailView: View {
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(title).appFont(.caption).foregroundStyle(.secondary)
HStack(spacing: 4) {
if let node {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
.foregroundStyle(theme.color(for: node.category))
}
Text(fallbackTitle).font(.callout.bold())
Text(fallbackTitle).appFont(.callout, bold: true)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
@@ -747,12 +756,13 @@ private struct NodeCardView: View {
let node: OverviewNode
let isSelected: Bool
let isHovered: Bool
let theme: ColorTheme
var body: some View {
VStack(spacing: 2) {
HStack(spacing: 4) {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
.foregroundStyle(theme.color(for: node.category))
Text(node.title)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
@@ -770,7 +780,7 @@ private struct NodeCardView: View {
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(
isSelected || isHovered ? Color.accentColor : OverviewStyle.color(for: node.category).opacity(0.5),
isSelected || isHovered ? Color.accentColor : theme.color(for: node.category).opacity(0.5),
lineWidth: isSelected || isHovered ? 2 : 1
)
)
@@ -785,6 +795,7 @@ private struct NodeDetailView: View {
let node: OverviewNode
let graph: OverviewGraph
let appLanguage: String
let theme: ColorTheme
let onEdit: (OverviewNode) -> Void
let onSelectNode: (String) -> Void
@@ -793,8 +804,8 @@ private struct NodeDetailView: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: OverviewStyle.icon(for: node.kind))
.foregroundStyle(OverviewStyle.color(for: node.category))
Text(node.title).font(.title3.bold())
.foregroundStyle(theme.color(for: node.category))
Text(node.title).appFont(.title3, bold: true)
Spacer()
if node.editTarget != nil {
Button {
@@ -806,7 +817,7 @@ private struct NodeDetailView: View {
}
}
Text(L10n.t(node.category.rawValue, appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
if !node.detail.isEmpty {
@@ -814,9 +825,9 @@ private struct NodeDetailView: View {
VStack(alignment: .leading, spacing: 4) {
ForEach(node.detail, id: \.key) { pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
}
}
}
@@ -825,7 +836,7 @@ private struct NodeDetailView: View {
let connected = graph.edges.filter { $0.from == node.id || $0.to == node.id }
if !connected.isEmpty {
Divider()
Text(L10n.t("Verbindungen", appLanguage)).font(.subheadline.bold())
Text(L10n.t("Verbindungen", appLanguage)).appFont(.subheadline, bold: true)
ForEach(connected) { edge in
let otherID = edge.from == node.id ? edge.to : edge.from
let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID
@@ -836,8 +847,8 @@ private struct NodeDetailView: View {
edge.label.map { "\(otherTitle) (\($0))" } ?? otherTitle,
systemImage: edge.from == node.id ? "arrow.right" : "arrow.left"
)
.foregroundStyle(OverviewStyle.color(for: edge.kind))
.font(.caption)
.foregroundStyle(theme.color(for: edge.kind))
.appFont(.caption)
}
.buttonStyle(.plain)
.help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
@@ -851,45 +862,46 @@ private struct NodeDetailView: View {
private struct LegendView: View {
let appLanguage: String
let theme: ColorTheme
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(L10n.t("Übersicht", appLanguage)).font(.title3.bold())
Text(L10n.t("Übersicht", appLanguage)).appFont(.title3, bold: true)
Text(L10n.t("Zeigt den kompletten IST-Zustand des verbundenen Routers als Diagramm: Interfaces links, darauf aufbauend IP-Adressen, DHCP/Pools, Routen und rechts die Firewall/NAT-Regeln, die auf sie zugreifen. Linien zeigen echte Abhängigkeiten (z.B. \"DHCP-Server nutzt diesen Pool\"), keine Vermutungen.", appLanguage))
.font(.caption)
.appFont(.caption)
Divider()
Text(L10n.t("Auf ein Feld klicken zeigt hier rechts Details und alle Verbindungen.", appLanguage))
.font(.caption)
.appFont(.caption)
Divider()
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).font(.caption.bold())
Text(L10n.t("Nicht im Diagramm (aber im Experte-Tab erreichbar):", appLanguage)).appFont(.caption, bold: true)
VStack(alignment: .leading, spacing: 3) {
ForEach(OverviewGraph.unmappedAreas, id: \.self) { area in
Text("· \(L10n.t(area, appLanguage))")
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.secondary)
}
}
Divider()
Text(L10n.t("Spalten", appLanguage)).font(.caption.bold())
Text(L10n.t("Spalten", appLanguage)).appFont(.caption, bold: true)
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewNode.Category.allCases) { category in
Label(L10n.t(category.rawValue, appLanguage), systemImage: "square.fill")
.foregroundStyle(OverviewStyle.color(for: category))
.font(.caption)
.foregroundStyle(theme.color(for: category))
.appFont(.caption)
}
}
Divider()
Text(L10n.t("Verbindungsarten", appLanguage)).font(.caption.bold())
Text(L10n.t("Verbindungsarten", appLanguage)).appFont(.caption, bold: true)
VStack(alignment: .leading, spacing: 4) {
ForEach(OverviewEdgeKind.allCases) { kind in
Label(L10n.t(kind.rawValue, appLanguage), systemImage: "minus")
.foregroundStyle(OverviewStyle.color(for: kind))
.font(.caption)
.foregroundStyle(theme.color(for: kind))
.appFont(.caption)
}
}
}
@@ -898,30 +910,9 @@ private struct LegendView: View {
}
}
/// Colors moved to `ColorTheme` (`AppPreferences.swift`) so they respond to the Settings window's
/// "Farbschema" picker this enum now only holds the parts that don't vary by theme.
enum OverviewStyle {
static func color(for category: OverviewNode.Category) -> Color {
switch category {
case .interface: return .blue
case .ipAddress: return .teal
case .service: return .purple
case .route: return .orange
case .firewall: return .red
}
}
static func color(for kind: OverviewEdgeKind) -> Color {
switch kind {
case .vlan: return .indigo
case .bridgePort: return .cyan
case .wireguardPeer: return .pink
case .ipAddress: return .teal
case .dhcp: return .purple
case .route: return .orange
case .firewallInterface: return .red
case .addressList: return .yellow
}
}
/// Plain-language explanation of what this connection kind actually means shown in
/// `EdgeDetailView` so clicking a line answers "why is this connected" for someone who
/// doesn't already know RouterOS' internal terminology.
@@ -0,0 +1,149 @@
import SwiftUI
/// The app's native Settings window (,, wired up as a `Settings { }` scene in
/// `RouterOSAssistantApp`) every control here is a plain `@AppStorage` binding under a key
/// catalogued in `AppPreferences`, so there's no view model: nothing here is anything other than
/// persisted preference state, and each tab's changes take effect live (no "Apply"/restart)
/// since every consumer (`OverviewView`, `DevicesView`, `DevicesViewModel`, ...) either observes
/// the same `@AppStorage` key itself or re-reads `UserDefaults.standard` on its own next tick.
struct SettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
TabView {
GeneralSettingsView()
.tabItem { Label(L10n.t("Allgemein", appLanguage), systemImage: "gearshape") }
AppearanceSettingsView()
.tabItem { Label(L10n.t("Darstellung", appLanguage), systemImage: "paintpalette") }
NetworkSettingsView()
.tabItem { Label(L10n.t("Netzwerk", appLanguage), systemImage: "network") }
}
.frame(width: 480)
.fixedSize(horizontal: false, vertical: true)
}
}
private struct GeneralSettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.autoCheckUpdatesOnConnectKey) private var autoCheckUpdatesOnConnect: Bool = false
var body: some View {
Form {
Section(L10n.t("Sprache", appLanguage)) {
Picker(L10n.t("Sprache", appLanguage), selection: $appLanguage) {
Text("Deutsch").tag("de")
Text("English").tag("en")
}
.pickerStyle(.segmented)
.labelsHidden()
Text(L10n.t("Entspricht dem 🇩🇪/🇬🇧-Knopf in der Toolbar — beide steuern dieselbe Einstellung.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
Section(L10n.t("RouterOS-Updates", appLanguage)) {
Toggle(L10n.t("Beim Verbinden automatisch nach Updates suchen", appLanguage), isOn: $autoCheckUpdatesOnConnect)
Text(L10n.t("Prüft nach jedem erfolgreichen Verbinden automatisch, ob eine neuere RouterOS-Version verfügbar ist — entspricht dem Button \"Nach Updates suchen\" im Tab \"Verbinden\".", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
}
private struct AppearanceSettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
@AppStorage(AppPreferences.textSizeKey) private var textSizeRaw: String = AppTextSize.standard.rawValue
@AppStorage(AppPreferences.uiDensityKey) private var uiDensityRaw: String = UIDensity.standard.rawValue
private var colorTheme: Binding<ColorTheme> {
Binding(get: { ColorTheme(rawValue: colorThemeRaw) ?? .standard }, set: { colorThemeRaw = $0.rawValue })
}
private var textSize: Binding<AppTextSize> {
Binding(get: { AppTextSize(rawValue: textSizeRaw) ?? .standard }, set: { textSizeRaw = $0.rawValue })
}
private var uiDensity: Binding<UIDensity> {
Binding(get: { UIDensity(rawValue: uiDensityRaw) ?? .standard }, set: { uiDensityRaw = $0.rawValue })
}
var body: some View {
Form {
Section(L10n.t("Farbschema", appLanguage)) {
Picker(L10n.t("Farbschema", appLanguage), selection: colorTheme) {
Text(L10n.t("Standard", appLanguage)).tag(ColorTheme.standard)
Text(L10n.t("Kontrastreich", appLanguage)).tag(ColorTheme.highContrast)
}
.pickerStyle(.radioGroup)
Text(L10n.t("Gilt für die Diagrammfarben in der Übersicht und die Status-Farben im LAN-Scanner (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port).", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
Section(L10n.t("Textgröße", appLanguage)) {
Picker(L10n.t("Textgröße", appLanguage), selection: textSize) {
Text(L10n.t("Klein", appLanguage)).tag(AppTextSize.small)
Text(L10n.t("Standard", appLanguage)).tag(AppTextSize.standard)
Text(L10n.t("Groß", appLanguage)).tag(AppTextSize.large)
Text(L10n.t("Sehr groß", appLanguage)).tag(AppTextSize.extraLarge)
}
.pickerStyle(.segmented)
.labelsHidden()
}
Section(L10n.t("Bedienelemente", appLanguage)) {
Picker(L10n.t("Bedienelemente", appLanguage), selection: uiDensity) {
Text(L10n.t("Kompakt", appLanguage)).tag(UIDensity.compact)
Text(L10n.t("Standard", appLanguage)).tag(UIDensity.standard)
Text(L10n.t("Komfortabel", appLanguage)).tag(UIDensity.comfortable)
}
.pickerStyle(.segmented)
.labelsHidden()
Text(L10n.t("Größe von Buttons, Feldern und Abständen in der ganzen App (\"Responsiveness\").", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
}
private struct NetworkSettingsView: View {
@AppStorage("appLanguage") private var appLanguage: String = "de"
@AppStorage(AppPreferences.lanScannerPollIntervalSecondsKey) private var pollInterval: Double = 0.1
@AppStorage(AppPreferences.lanScannerSparklineWindowSecondsKey) private var sparklineWindow: Double = 30
@AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200
var body: some View {
Form {
Section(L10n.t("LAN-Scanner: Traffic-Anzeige", appLanguage)) {
Picker(L10n.t("Aktualisierungsrate", appLanguage), selection: $pollInterval) {
Text("0,1 s").tag(0.1)
Text("0,5 s").tag(0.5)
Text("1 s").tag(1.0)
Text("3 s").tag(3.0)
}
Picker(L10n.t("Sparkline-Zeitfenster", appLanguage), selection: $sparklineWindow) {
Text("10 s").tag(10.0)
Text("30 s").tag(30.0)
Text("60 s").tag(60.0)
}
Picker(L10n.t("Sparkline-Breite", appLanguage), selection: $sparklineWidth) {
Text("100 pt").tag(100.0)
Text("150 pt").tag(150.0)
Text("200 pt").tag(200.0)
Text("300 pt").tag(300.0)
}
Text(L10n.t("Höhere Aktualisierungsraten zeigen den Traffic-Verlauf feiner, kosten aber mehr CPU bei vielen gleichzeitig aktiven Ports.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
}
#Preview {
SettingsView()
}
@@ -240,7 +240,7 @@ struct ConnectView: View {
L10n.t("Zuletzt gesichert:", appLanguage) + " \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))",
systemImage: "checkmark"
)
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
@@ -290,11 +290,11 @@ struct ConnectView: View {
.disabled(viewModel.isUpgradingFirmware)
} else {
Label(L10n.t("Firmware aktuell", appLanguage), systemImage: "checkmark.circle")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
if let message = viewModel.firmwareUpgradeResultMessage {
Text(message).font(.caption).foregroundStyle(.secondary)
Text(message).appFont(.caption).foregroundStyle(.secondary)
Button {
showRebootConfirmation = true
} label: {
@@ -347,7 +347,7 @@ struct ConnectView: View {
L10n.t("Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.", appLanguage),
systemImage: "arrow.clockwise"
)
.font(.caption)
.appFont(.caption)
}
}
Section("Interfaces") {
@@ -359,7 +359,7 @@ struct ConnectView: View {
)
VStack(alignment: .leading) {
Text(interface.name).bold()
Text(interface.type).font(.caption).foregroundStyle(.secondary)
Text(interface.type).appFont(.caption).foregroundStyle(.secondary)
}
}
}
@@ -574,12 +574,12 @@ private struct SavedRouterRow: View {
Text(router.name).bold()
if !router.location.isEmpty {
Label(router.location, systemImage: "mappin.and.ellipse")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
Text("\(router.username)@\(router.host)")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
// Nutzerwunsch (2026-09-16): Seriennummer mit hinterlegen, damit zwei
// physisch unterschiedliche Router mit identischem Host+Benutzername (z.B.
@@ -588,7 +588,7 @@ private struct SavedRouterRow: View {
// SavedRoutersStore.recordSuccessfulConnection.
if let serialNumber = router.serialNumber {
Text("SN: \(serialNumber)")
.font(.caption2)
.appFont(.caption2)
.foregroundStyle(.tertiary)
}
}
@@ -88,6 +88,12 @@ final class ConnectViewModel: ObservableObject {
savedRouters = savedRoutersStore.recordSuccessfulConnection(
host: host, username: username, defaultName: defaultName, serialNumber: serialNumber
)
// Settings toggle (default off) reuses the same manual "Nach Updates suchen"
// path the button in this tab triggers, just fired automatically once a
// connection succeeds instead of waiting for a click.
if UserDefaults.standard.bool(forKey: AppPreferences.autoCheckUpdatesOnConnectKey) {
checkForUpdates(for: credentials)
}
}
}
}
@@ -20,7 +20,7 @@ struct FirewallStepView: View {
Text(L10n.t("Firewall-Grundschutz ist im einfachen Modus immer aktiv.", appLanguage))
}
Text(L10n.t("Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router und deine Geräte. Bestehende, selbst eingerichtete Regeln bleiben erhalten — die neuen Regeln werden vorangestellt.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
@@ -33,11 +33,11 @@ struct FirewallStepView: View {
LabeledContent(L10n.t("NAT-Regeln", appLanguage), value: "\(counts.natRuleCount)")
if counts.filterRuleCount > 0 || counts.natRuleCount > 0 {
Text(L10n.t("Dein Router hat bereits eigene Firewall-Regeln. Die neuen Regeln werden vorangestellt, bestehende bleiben erhalten — prüfe nach dem Anwenden trotzdem die Reihenfolge, z.B. über Winbox oder \"/ip firewall filter print\".", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.orange)
}
} else if let error = viewModel.firewallRuleCountsError {
Text(error).font(.caption).foregroundStyle(.red)
Text(error).appFont(.caption).foregroundStyle(.red)
}
}
}
@@ -46,7 +46,7 @@ struct FirewallStepView: View {
Section(L10n.t("Isolierte Netzwerke", appLanguage)) {
Text(viewModel.isolatedNetworkNames.joined(separator: ", "))
Text(L10n.t("Diese Netzwerke werden vom Datenverkehr aller anderen konfigurierten Netzwerke abgeschottet, sobald der Firewall-Grundschutz aktiv ist.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
@@ -25,7 +25,7 @@ struct LanStepView: View {
if viewModel.isCheckingPortConflict.contains(config.id) {
HStack {
ProgressView().controlSize(.small)
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).font(.caption).foregroundStyle(.secondary)
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
}
} else if let conflict = viewModel.lanPortConflicts[config.id] {
PortConflictWarningView(
@@ -117,19 +117,19 @@ private struct PortConflictWarningView: View {
var body: some View {
if isAcknowledged {
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
.font(.caption)
.appFont(.caption)
.foregroundStyle(.orange)
} else {
VStack(alignment: .leading, spacing: 6) {
Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill")
.font(.subheadline.bold())
.appFont(.subheadline, bold: true)
.foregroundStyle(.red)
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
Text("\(reason.description)")
.font(.caption)
.appFont(.caption)
}
Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
showConsequencesConfirmation = true
@@ -19,7 +19,7 @@ struct ModeStepView: View {
case .simple:
Text(L10n.t("Grundeinrichtung: Internetverbindung (WAN), ein Heimnetzwerk (LAN) mit DHCP, WLAN und ein fest aktivierter Firewall-Grundschutz.", appLanguage))
Text(L10n.t("Kein VLAN, keine mehreren getrennten Netzwerke, keine Netzwerk-Isolation — für später jederzeit über den Expertenmodus nachrüstbar.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
case .expert:
Text(L10n.t("Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.", appLanguage))
@@ -15,7 +15,7 @@ struct ReviewApplyView: View {
Text(command.summary)
if showCliDetails {
Text(command.cliLine)
.font(.system(.caption, design: .monospaced))
.appFont(.caption, design: .monospaced)
.foregroundStyle(.secondary)
}
}
@@ -25,14 +25,14 @@ struct ReviewApplyView: View {
Section {
Text(L10n.t("Vor dem Anwenden wird automatisch eine Sicherung der aktuellen Konfiguration erstellt (Tab \"Sicherungen\"). Ein garantiertes automatisches Zurückrollen bei Verbindungsabbruch gibt es nicht — bei Problemen die Sicherung im Tab \"Sicherungen\" verwenden oder den Router lokal (Ethernet/Konsole) wiederherstellen.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
if !viewModel.applyLog.isEmpty {
Section(L10n.t("Ablauf", appLanguage)) {
ForEach(viewModel.applyLog, id: \.self) { line in
Text(line).font(.caption)
Text(line).appFont(.caption)
}
}
}
@@ -17,7 +17,7 @@ struct VlanStepView: View {
)
.help(L10n.t("Erstellt ein zusätzliches Netzwerk mit eigenem Adressbereich und eigenem DHCP-Server.", appLanguage))
Text(L10n.t("Ein VLAN ist ein zusätzliches Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Ob es vom Hauptnetzwerk abgeschottet ist, legst du unten pro Netzwerk über \"Von anderen Netzwerken isolieren\" fest. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
@@ -44,7 +44,7 @@ struct WanStepView: View {
SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.wanConfig.pppoePassword)
.help(L10n.t("Das zum Benutzernamen gehörende Passwort von deinem Internetanbieter.", appLanguage))
Text(L10n.t("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).", appLanguage))
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
@@ -20,7 +20,7 @@ struct WifiStepView: View {
+ "\(viewModel.unsupportedWifiInterfaces.map(\.name).joined(separator: ", "))"
+ L10n.t("), den diese App noch nicht unterstützt. WLAN muss dafür vorerst manuell über Winbox eingerichtet werden.", appLanguage)
)
.font(.caption)
.appFont(.caption)
.foregroundStyle(.secondary)
}
}
+44 -7
View File
@@ -85,14 +85,51 @@ Nachbesserung 2 (User-Wunsch: Trafficanzeige in MB, Sparkline-Zeitfenster auf 30
Nachbesserung 3 (User-Wunsch: Aktualisierungsrate auf 0,1s): `startTrafficPolling` von `.milliseconds(500)` auf `.milliseconds(100)` — bei 30s-Fenster damit bis zu 300 Punkte pro Sparkline/Port (bisher ~60). Nicht weiter geprüft, ob das bei vielen Ports spürbar CPU kostet — bei Bedarf zurückmelden. Manual.md "Abfrage alle 0,1s" + PDF mit aktualisiert. Build grün, App startet ohne Absturz. Noch nicht live geprüft.
### 5. App-Einstellungen / Settings — eigener Menüpunkt
**Status:** offen (nur Feature-Idee notiert, noch nicht umgesetzt)
**Status:** fixed (live bestätigt: "passt, lassen wir so")
Wunsch: eigener Einstellungen-Bereich für Personalisierung, Layout, Farbverwaltung, Refreshraten, Updates, Schriftgrößen, Responsiveness, etc.
Ist-Zustand: es gibt aktuell **eine einzige** persistierte App-Einstellung (`@AppStorage("appLanguage")`, DE/EN-Umschalter in der Toolbar). Alles andere, was unter "Settings" fallen würde, ist im Code hart verdrahtet und über die Session verstreut — u.a. gerade erst in diesem Fund-Zettel mehrfach angefasste Werte:
- Refreshraten: LAN-Scanner-Traffic-Poll `DevicesViewModel.startTrafficPolling` (aktuell 0,1s), Verbinden-Tab-Traffic-Poll `ConnectViewModel.startTrafficPolling` (3s, separat)
- Sparkline-Zeitfenster (`DevicesViewModel.appendTrafficHistory`, aktuell 30s) und -Breite (`TrafficSparkline`, aktuell 200pt)
- Farben: `OverviewStyle.color(for:)` (Node-Kategorien, Kanten-Arten), Firewall-Icons, Status-Farben im LAN-Scanner (Port-Scan rot/grün/grau)
- Übersicht-Zoom-Standardwert, Fokus-Popup-Größe/Verhalten
Rückfrage vorab geklärt (AskUserQuestion): natives macOS-Settings-Fenster (⌘,), nicht eigener Tab; alle genannten Bereiche jetzt sofort umsetzen, nicht nur ein Kernstück; Farbverwaltung als 2 vordefinierte Themes ("Standard"/"Kontrastreich"), kein freier Color-Picker pro Kategorie.
Keine echte Settings-Infrastruktur (kein Settings-Tab/-Fenster, kein zentrales Preferences-Model) vorhanden — müsste komplett neu aufgebaut werden. Größerer, noch nicht spezifizierter Umbau; sollte vor Umsetzung geklärt werden: eigener Tab vs. natives macOS-Settings-Fenster (⌘,), welche der o.g. Werte tatsächlich zuerst einstellbar sein sollen, ob pro-Gerät oder global.
Umsetzung:
- **`Core/Models/AppPreferences.swift`** (neu): zentrale `AppStorage`-Key-Konstanten + `AppPreferences.registerDefaults()` (in `RouterOSAssistantApp.init()` aufgerufen), plus drei Enums:
- `ColorTheme` (`standard`/`highContrast`) — Farben für Übersicht-Node-Kategorien, Kanten-Arten, und LAN-Scanner-Status (Traffic aktiv, Fest/Dynamisch, offener/geschlossener Port). Ersetzt die alten `OverviewStyle.color(for:)`-Funktionen (die jetzt nur noch `explanation(for:)`/`icon(for:)` halten).
- `AppTextSize` (Klein/Standard/Groß/Sehr groß) — Skalierungsfaktor (0,85/1,0/1,2/1,4), per `.environment(\.appFontScale, ...)` an der `WindowGroup`-Wurzel gesetzt.
- `UIDensity` (Kompakt/Standard/Komfortabel) — mappt auf `ControlSize`, ebenfalls an der Wurzel via `.controlSize(...)`. Deckt den "Responsiveness"-Wunsch pragmatisch als Bedienelement-Größe ab (die App ist eine feste macOS-Fensterlayout-App ohne eigentliche responsive Breakpoints — eine echte "Responsiveness"-Funktion in dem Sinn gibt es nicht, siehe unten).
- **`Features/Settings/SettingsView.swift`** (neu): `Settings { }`-Scene mit 3 Tabs — Allgemein (Sprache, Auto-Update-Check beim Verbinden), Darstellung (Farbschema, Textgröße, Bedienelemente), Netzwerk (LAN-Scanner Aktualisierungsrate/Sparkline-Zeitfenster/-Breite). Alle Controls sind reine `@AppStorage`-Bindings, kein eigenes ViewModel.
- **Live-Wirkung ohne Neustart**: `OverviewView`/`DevicesView` lesen `colorTheme` per `@AppStorage` und geben es an `EdgesCanvas`/`NodeCardView`/`NodeDetailView`/`EdgeDetailView`/`EdgeTooltipView`/`LegendView`/`DeviceRow`/`PortScanRow` durch. `DevicesViewModel.startTrafficPolling`/`appendTrafficHistory` lesen Intervall/Zeitfenster bei jedem Tick frisch aus `UserDefaults` (nicht einmalig beim Start), damit eine Änderung im Settings-Fenster sofort greift, auch während der Tab schon pollt.
- **"Beim Verbinden automatisch nach Updates suchen"** (neuer Toggle, Default aus): `ConnectViewModel.connect()` ruft nach erfolgreicher Verbindung automatisch dieselbe `checkForUpdates(for:)` auf, die sonst nur der manuelle Button im Verbinden-Tab auslöst.
- L10n.swift um alle neuen Settings-Strings ergänzt (DE/EN).
- `.xcodeproj` manuell um beide neue Dateien ergänzt (PBXBuildFile/PBXFileReference/PBXGroup/Sources-Build-Phase).
Bewusst nicht umgesetzt: Übersicht-Zoom-Standardwert und Fokus-Popup-Größe/Verhalten sind nicht in Settings aufgenommen — beides sind reine `@State`-Werte ohne bestehenden Persistenz-Mechanismus und wurden in der Rückfrage nicht explizit als "jetzt" gefordert; bei Bedarf als vierte Kategorie nachziehbar.
Nachbesserung 1 (Live-Test: "Bedienelemente und Sparkling funktioniert, bei Textgröße und Farbschemas sehe ich keine Änderung"):
- Farbschema-Bug gefunden: 3 Farben (`.service`-Kategorie, `.dhcp`- und `.wireguardPeer`-Kanten) waren in `standard` und `highContrast` exakt identisch definiert — Kopierfehler beim ersten Entwurf. Behoben, beide Paletten jetzt komplett überschneidungsfrei.
- Textgröße nachgebessert (Bereich auf macOS-Accessibility-Stufen erweitert für einen unübersehbaren Sprung).
Nachbesserung 2 (Live-Test: erneut "unverändert" bei beidem, trotz Fix 1) — Ursache mit Beweis gefunden statt weiter geraten: per `ImageRenderer`-Snapshot (Scratchpad-Skript) nachgewiesen, dass `.environment(\.dynamicTypeSize, ...)` auf macOS **gar keine Wirkung** hat — identisches Pixel-Rendering bei `.xSmall` und `.accessibility3`. Anders als iOS skalieren SwiftUI-Textstile auf macOS nicht über Dynamic-Type-Kategorien. Rückfrage an User: 3 Alternativen zur Wahl gestellt, User wählte "Echtes Text-only Scaling".
Umsetzung (komplett neuer Mechanismus, `AppTextSize.dynamicTypeSize` entfernt):
- Neuer `\.appFontScale`-Environment-Key (`CGFloat`, Default 1.0) + `AppFontStyle`-Enum mit expliziten macOS-Basis-Punktgrößen pro Textstil (title2/title3/headline/body/callout/subheadline/caption/caption2) + `.appFont(_:weight:bold:design:)`-View-Modifier, der bei jedem Aufruf `baseSize * scale` real rendert.
- Alle ~110 `.font(...)`-Aufrufe in der App auf `.appFont(...)` umgestellt (automatisiert per Scratchpad-Skript, textbasierte 1:1-Ersetzung pro bekanntem Font-Ausdruck) — **außer** zwei Stellen mit fest-breiten Pixel-Layouts, die beim Skalieren brechen würden: `OverviewView.NodeCardView` (Diagramm-Knotenkarten, feste `nodeWidth`/`nodeHeight`) und `DevicesView.DeviceRow`/`DeviceColumnHeader` (LAN-Scanner-Tabellenspalten mit festen `DeviceColumn`-Breiten + `lineLimit(1)`). Diese bleiben bewusst fix, sonst Text-Clipping/Überlappung.
- Mechanismus per `ImageRenderer`-Snapshot verifiziert (Basis-Text bei Skalierungsfaktor 0,85 vs. 1,4 rendert nachweislich unterschiedlich groß), bevor erneut um Live-Test gebeten wurde.
Build grün, alle 98 Unit-Tests grün.
Nachbesserung 3 (Live-Test: "textgröße funktioniert, die Farbschemas nicht"): Textgröße bestätigt. Farbschema noch kaputt — beim Fix in Nachbesserung 1 nur die Übersicht-Diagrammfarben (Node-Kategorien/Kanten) überarbeitet, die LAN-Scanner-eigenen Theme-Farben (`trafficActive`/`staticLease`/`dynamicLease`/`portOpen`/`portClosed`) dabei übersehen — die waren weiterhin zu ähnliche SwiftUI-Nachbarfarben (grün/mint, orange/gelb, rot/pink), an einem kleinen Punkt/Icon praktisch nicht unterscheidbar. User fragte, ob selbst am Router geprüft werden kann — nein (kein Zugang/Tool dafür), aber Bug sitzt ohnehin rein im App-Rendering, nicht am Router: per `ImageRenderer`-Snapshot der tatsächlichen Farbwerte selbst verifiziert (Swatch-Vergleich beider Themes, sichtbar unterschiedlich) statt nur zu behaupten, es sei jetzt richtig. Neue Werte: `trafficActive`/`dynamicLease`/`portClosed` grün→blau, `staticLease` orange→lila, `portOpen` rot→orange.
Bitte nochmal live testen — diesmal auch LAN-Scanner-Statuspunkte (Fest/Dynamisch, Port-Scan) einschließen, nicht nur Übersicht-Diagramm.
Nachbesserung 4 (Live-Test: "Lan-scanner und Übersicht funktionieren" — Farbschema damit bestätigt; aber "Experte-Tab zeigt noch keine Änderung" bei Textgröße): Ursache gefunden — die komplette Sidebar-Liste (`Text(schema.displayName)` in `ExpertView.swift`) und jedes einzelne Parameter/Wert-`TextField` in `ExpertMenuDetailView.swift` hatten von Anfang an **gar kein** `.font(...)`. Die automatisierte `.appFont(...)`-Umstellung ersetzte nur vorhandene `.font(...)`-Aufrufe — unstyled Text/TextField (SwiftUI-Systemstandard, fix) blieb dadurch unangetastet. Fix: `.environment(\.font, .system(size: 13 * scale))` einmal an der `WindowGroup`-Wurzel als Fallback — wirkt nur dort, wo kein eigenes `.appFont(...)`/`.font(...)` bereits einen Font setzt (der gewinnt lokal weiterhin), deckt aber jede unstyled Stelle app-weit ab, nicht nur den Experte-Tab.
Build grün, alle 98 Unit-Tests grün.
Nachbesserung 5 (User-Feedback: "keine Änderung beim Farbschema unter Experte") — war kein Bug, sondern Scope: Farbschema war nie für den Experte-Tab verdrahtet, dort gab's schlicht keine kategorie-eingefärbten Elemente (Sidebar/Icons alle `.secondary`). Rückfrage: soll das ausgeweitet werden? User: ja. Umsetzung: `ColorTheme.color(for: RouterOSMenuCategory)` (neu, `AppPreferences.swift`) bucketet die 13 `RouterOSMenuCategory`-Fälle auf die bestehende 5-Farb-Palette (alle 5 Firewall-Untermenüs → `.firewall`, VPN/WLAN/Queues/System/Tools → `.service`) — dieselben Farben wie im Übersicht-Diagramm, keine zweite Palette. `ExpertView.sectionHeader(...)` bekommt neuen optionalen `categoryColor`-Parameter, zeigt bei Kategorie-Sektionen einen 8pt-Farbpunkt vor dem Titel (bei "Eigener Menüpfad" weiterhin `nil`, keine Farbe).
Build grün, alle 98 Unit-Tests grün.
6. Mode-Button Setup: an der Seite des Router ist eine Taste "Mode" diese ist belegbar (Scripte, Deaktivierung, etc). einlesen, was geht und bauen.
7. Manual direkt in die App integrieren.
8. Abwechselnde ´Farbkombis bei Tabellenansichten - besser Lesbarkeit
9. Settings → Darstellung: anpassbare Farben — freie Farbwahl pro Kategorie/Status statt nur der 2 vordefinierten Themes (Standard/Kontrastreich aus Punkt 5). Beim ursprünglichen Rückfrage bewusst auf 2 Themes beschränkt; falls später gewünscht, bräuchte es ColorPicker pro `OverviewNode.Category`/`OverviewEdgeKind`/LAN-Scanner-Status/`RouterOSMenuCategory` statt der festen `ColorTheme`-Switch-Statements in `AppPreferences.swift`.