M14: Update-Check (Software+Firmware) + Karten-Design fuer Sektionen
Verbinden-Tab zeigt jetzt die volle Routerboard-Info (Modell/Revision/ Seriennummer/Firmware, aus /system routerboard) sowie einen RouterOS-Software-Update-Check (/system package update: Kanal/ installierte/neueste Version/Status, "Jetzt pruefen"/"Update installieren"), dazu "Firmware aktualisieren" fuer die Routerboard-Bootloader-Firmware und "Jetzt neu starten" danach. Alle vier Aktionen live bestaetigt, inklusive der zuvor unsicheren Frage, ob /system routerboard upgrade's normalerweise interaktive Bestaetigung den nicht-interaktiven SSH-Weg dieser App blockiert (tut es nicht). Bug 20 gefunden und gefixt: fetchMenuItems' Singleton-Fallback (Bug 8) reagierte nur auf eine geworfene Exception fuer "bad parameter terse", aber RouterOS liefert diesen Fehler fuer /system routerboard mit Exit-Code 0 zurueck (dasselbe Bug-10-Muster, diesmal beim Lesen statt Schreiben) - die Routerboard-Sektion blieb dadurch leer, ohne Fehler. Fix: zusaetzlich den Output-Text selbst pruefen, nicht nur die Exception. Design-Durchgang: Verbinden-Detailseite/Sicherungen/Geraete liefen auf nackter List ohne Rahmen - umgestellt auf Form+.formStyle(.grouped), denselben nativen macOS-Karten-Look, den Wizard und Experte-Tab schon hatten, fuer eine einheitliche App. Dark Mode auf Nachfrage gepueft und ohne Codeaenderung bestaetigt funktionierend. HANDOFF.md/CHATLOG.md aktualisiert: M14, Bug 20, Design-Durchgang, Dark-Mode-Bestaetigung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
This commit is contained in:
+46
@@ -630,3 +630,49 @@ sind dadurch hinfällig, siehe HANDOFF.md Nächste Schritte. Offen bleibt
|
|||||||
weiterhin die `.id`-Positionsüberlagerungs-Frage für den Experte-Tab
|
weiterhin die `.id`-Positionsüberlagerungs-Frage für den Experte-Tab
|
||||||
(Bug 15) und alle länger schon offenen Punkte (M7-Rest, M8-Hardware-
|
(Bug 15) und alle länger schon offenen Punkte (M7-Rest, M8-Hardware-
|
||||||
Test, WLAN/Bonding/PPPoE-Live-Tests).
|
Test, WLAN/Bonding/PPPoE-Live-Tests).
|
||||||
|
|
||||||
|
## Session: Update-Check (M14) + Design-Durchgang
|
||||||
|
|
||||||
|
- "ok, sieht gut aus. weiter mit der Update-Überprüfung." — mehrdeutig
|
||||||
|
(RouterOS-Software vs. Routerboard-Firmware), per `AskUserQuestion`
|
||||||
|
geklärt: "Beides". Vorher per Recherche-Agent geprüft: `/system
|
||||||
|
package update` (Felder channel/installed-version/latest-version/
|
||||||
|
status, check-for-updates dann install, install reboot automatisch)
|
||||||
|
und `/system routerboard upgrade` (braucht in Winbox normalerweise
|
||||||
|
y/n-Bestätigung, kein automatischer Reboot danach).
|
||||||
|
- Gebaut: Routerboard-Vollinfo + Software-Update-Sektion im
|
||||||
|
Verbinden-Tab, "Firmware aktualisieren" mit Warnhinweis zur unsicheren
|
||||||
|
Interaktivitäts-Frage.
|
||||||
|
- "davon sehe ih nichts, CMD+Q wurde ausgeführt und app neugestartet" →
|
||||||
|
"nur die neue 'Routerboard'-Sektion fehlt" → gemeinsam per SSH
|
||||||
|
verifiziert (`/system routerboard print terse` → "bad parameter
|
||||||
|
terse"), dann über den Experte-Tab denselben generischen Lesepfad
|
||||||
|
getestet ("Unerwsrtete Antwort vom Router: bad commandname add" beim
|
||||||
|
Anlegen-Versuch, Liste vorher leer) — Bug 20 gefunden: der bestehende
|
||||||
|
Singleton-Fallback (Bug 8) reagiert nur auf eine geworfene Exception,
|
||||||
|
aber RouterOS liefert "bad parameter terse" für dieses Menü mit
|
||||||
|
Exit-Code 0 zurück, landet also als normaler leerer Output. Fix:
|
||||||
|
zusätzlich den Output-Text selbst prüfen. "ok, sieht gut aus" danach.
|
||||||
|
- "sieht gut aus, der Button 'Firmware aktualisieren' funktioniert
|
||||||
|
auch?" — ehrlich beantwortet: kann ich nicht bestätigen ohne Live-Test.
|
||||||
|
Nutzer testete, Konsolen-Ausgabe geteilt ("Firmware upgraded
|
||||||
|
successfully, please reboot for changes to take effect!") — die
|
||||||
|
interaktive Bestätigung blockierte den SSH-Weg nicht, wie gehofft.
|
||||||
|
"ja, bau das" → "Jetzt neu starten"-Button ergänzt, ebenfalls sofort
|
||||||
|
funktionierend gemeldet.
|
||||||
|
- "ok, jetzt zum Design. ich hätte gerne die einzelnen Sektionen
|
||||||
|
sauberer getrennt - Trennstriche oder Rahmen um die Sektionen." — per
|
||||||
|
`AskUserQuestion` Umfang ("ganze App") und Stil ("Karten mit Rahmen")
|
||||||
|
geklärt. Geprüft: Wizard-Schritte und Experte-Tab nutzten schon
|
||||||
|
`.formStyle(.grouped)` (nativer macOS-Karten-Look), nur Verbinden-
|
||||||
|
Detailseite/Sicherungen/Geräte liefen auf nackter `List` — auf
|
||||||
|
`Form`+`.formStyle(.grouped)` umgestellt statt eine eigene
|
||||||
|
Karten-Komponente zu bauen.
|
||||||
|
- "wie sieht es mit einem Darkmode aus?" — Code geprüft (keine
|
||||||
|
`.preferredColorScheme`-Überschreibung, kein
|
||||||
|
`NSRequiresAquaSystemAppearance`, nur adaptive System-Farben) und
|
||||||
|
bestätigt: funktioniert schon ohne Codeänderung. Nutzer bestätigte
|
||||||
|
nach eigenem Test ("passt super und funktioniert").
|
||||||
|
- "ja, committen und beides aktualisieren" → dieser Eintrag, HANDOFF.md
|
||||||
|
(M14, Bug 20, Design-Durchgang, Dark-Mode-Bestätigung) aktualisiert,
|
||||||
|
Commit erstellt.
|
||||||
|
|||||||
+78
-7
@@ -34,8 +34,27 @@ Bugs, 14–17) und M13 (ein besonders ernster Bug, 18: erster Testlauf
|
|||||||
sperrte den Router komplett aus, nur per Hardware-Reset behebbar) beide
|
sperrte den Router komplett aus, nur per Hardware-Reset behebbar) beide
|
||||||
zäher als erwartet, am Ende aber jeweils vom Nutzer selbst bestätigt
|
zäher als erwartet, am Ende aber jeweils vom Nutzer selbst bestätigt
|
||||||
("das funktioniert jetzt super" / "das hat sofort funktioniert" / "gut,
|
("das funktioniert jetzt super" / "das hat sofort funktioniert" / "gut,
|
||||||
das hat funktioniert"). Release-Build unter
|
das hat funktioniert").
|
||||||
`/Applications/RouterOS Assistant.app` ist auf aktuellem Stand.
|
|
||||||
|
**Danach, gleiche Session, M14 "Update-Check":** Verbinden-Tab zeigt jetzt
|
||||||
|
volle Routerboard-Daten (Modell/Revision/Seriennummer/Firmware, aus
|
||||||
|
`/system routerboard`) und einen RouterOS-Software-Update-Check
|
||||||
|
(`/system package update`: Kanal/installierte/neueste Version/Status,
|
||||||
|
"Jetzt prüfen", "Update installieren"), plus "Firmware aktualisieren"
|
||||||
|
für die Routerboard-Bootloader-Firmware und einen "Jetzt neu starten"-
|
||||||
|
Button danach — alle vier Aktionen live bestätigt, inkl. der zuvor
|
||||||
|
unsicheren Frage, ob `/system routerboard upgrade`s normalerweise
|
||||||
|
interaktive Bestätigung den nicht-interaktiven SSH-Weg dieser App
|
||||||
|
blockiert (tut es nicht, lief sauber durch). Dabei ein weiterer Bug im
|
||||||
|
generischen Lese-Pfad gefunden (Bug 20, siehe unten). Anschließend ein
|
||||||
|
Design-Durchgang: Verbinden-Detailseite/Sicherungen/Geräte liefen auf
|
||||||
|
nackten `List`s ohne Rahmen — umgestellt auf `Form`+`.formStyle(.grouped)`,
|
||||||
|
denselben nativen macOS-"Karten"-Look, den Wizard und Experte-Tab schon
|
||||||
|
die ganze Zeit hatten. Dark Mode auf Nachfrage geprüft: funktioniert ohne
|
||||||
|
Codeänderung (keine `.preferredColorScheme`-Überschreibung, keine feste
|
||||||
|
Farbwerte, alles adaptive System-Farben) — vom Nutzer live bestätigt.
|
||||||
|
Release-Build unter `/Applications/RouterOS Assistant.app` ist auf
|
||||||
|
aktuellem Stand.
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
@@ -106,8 +125,9 @@ RouterOSAssistant/
|
|||||||
BackupService.swift — Config-Export (`/export terse`) über eigene SSH-Verbindung, wählbarer Zielordner, restoreBackup (M13: SFTP-Upload + reset-configuration+run-after-reset + Login-Erhalt), backupModel (Modell-Header-Parsing)
|
BackupService.swift — Config-Export (`/export terse`) über eigene SSH-Verbindung, wählbarer Zielordner, restoreBackup (M13: SFTP-Upload + reset-configuration+run-after-reset + Login-Erhalt), backupModel (Modell-Header-Parsing)
|
||||||
FactoryResetService.swift — /system reset-configuration über eigene SSH-Verbindung ("Gefahrenzone")
|
FactoryResetService.swift — /system reset-configuration über eigene SSH-Verbindung ("Gefahrenzone")
|
||||||
KeychainService.swift — Passwort-Speicherung
|
KeychainService.swift — Passwort-Speicherung
|
||||||
|
UpdateService.swift — M14: RouterOS-Software-Update (/system package update check-for-updates/install) + Routerboard-Firmware (upgrade) + reboot, eigene SSH-Verbindung
|
||||||
Features/
|
Features/
|
||||||
Wizard/Steps/Connect/ — Verbinden-Tab (inkl. Schnell-Backup-Button nach erfolgreicher Verbindung)
|
Wizard/Steps/Connect/ — Verbinden-Tab (inkl. Schnell-Backup-Button, Routerboard-Info, Software-/Firmware-Update-Check (M14), nach erfolgreicher Verbindung)
|
||||||
Wizard/Steps/Setup/ — Einrichten-Tab: Mode → Wan → Lan → (Vlan nur Experte) → Wifi → Firewall → Review/Apply
|
Wizard/Steps/Setup/ — Einrichten-Tab: Mode → Wan → Lan → (Vlan nur Experte) → Wifi → Firewall → Review/Apply
|
||||||
Expert/ — Experte-Tab (M10): ExpertView (Kategorie-/Menüliste + "eigener Pfad"), ExpertViewModel, ExpertMenuDetailView (Item-Liste + Add/Edit-Sheet)
|
Expert/ — Experte-Tab (M10): ExpertView (Kategorie-/Menüliste + "eigener Pfad"), ExpertViewModel, ExpertMenuDetailView (Item-Liste + Add/Edit-Sheet)
|
||||||
Overview/ — Übersicht-Tab (M11): OverviewView (Diagramm+Legende+Detailpanel), OverviewViewModel (buildGraph, rein), OverviewLayout (Spalten/Zeilen-Geometrie)
|
Overview/ — Übersicht-Tab (M11): OverviewView (Diagramm+Legende+Detailpanel), OverviewViewModel (buildGraph, rein), OverviewLayout (Spalten/Zeilen-Geometrie)
|
||||||
@@ -343,6 +363,21 @@ erreichen (siehe Bug 1 unten).
|
|||||||
Views mit vielen `.alert`/`.confirmationDialog`/`.sheet`-Modifiern in
|
Views mit vielen `.alert`/`.confirmationDialog`/`.sheet`-Modifiern in
|
||||||
Folge vorbeugend gleich aufteilen, statt erst bei diesem Fehler zu
|
Folge vorbeugend gleich aufteilen, statt erst bei diesem Fehler zu
|
||||||
reagieren.
|
reagieren.
|
||||||
|
20. **`fetchMenuItems`s Singleton-Fallback (Bug 8/M10) griff für
|
||||||
|
`/system routerboard` nicht — genau dasselbe Bug-10-Muster, nur an
|
||||||
|
einer Lesestelle statt beim Schreiben.** Die neue Routerboard-Sektion
|
||||||
|
(M14) blieb leer, kein Fehler, nichts. Ursache: der Fallback reagiert
|
||||||
|
bisher nur auf eine echte Exception ("bad parameter terse" via
|
||||||
|
Citadels `CommandFailed`), aber `/system routerboard print terse`
|
||||||
|
liefert diesen Fehlertext live bestätigt mit Exit-Code 0 zurück — der
|
||||||
|
Fehlertext landete also als normaler (unparsbarer) Output, `fetchMenuItems`
|
||||||
|
gab still `[]` zurück statt in den Singleton-Zweig zu fallen. Fix:
|
||||||
|
zusätzlich den zurückgegebenen Output-Text selbst auf "bad parameter
|
||||||
|
terse" prüfen, nicht nur die Exception — deckt beide Varianten ab, wie
|
||||||
|
RouterOS diesen Fehler je nach Menü meldet. Betrifft potenziell jedes
|
||||||
|
Singleton-Menü, nicht nur `/system routerboard` — der generische
|
||||||
|
Lese-Pfad wird von Übersicht-, Geräte- und Experte-Tab gemeinsam
|
||||||
|
genutzt.
|
||||||
|
|
||||||
**Lehren:** Citadel/NIOSSH-Fehler immer mit `String(describing:)` loggen,
|
**Lehren:** Citadel/NIOSSH-Fehler immer mit `String(describing:)` loggen,
|
||||||
nie `.localizedDescription`. Jede View, die ein ObservableObject aus einem
|
nie `.localizedDescription`. Jede View, die ein ObservableObject aus einem
|
||||||
@@ -392,7 +427,14 @@ ersten Fehler irgendwo im Skript komplett ab. **SwiftUI-Typprüfung kann
|
|||||||
bei langen Modifier-Ketten mit einer irreführenden Fehlerzeile
|
bei langen Modifier-Ketten mit einer irreführenden Fehlerzeile
|
||||||
timeouten** (Bug 19) — kein Hinweis auf einen echten Logikfehler an der
|
timeouten** (Bug 19) — kein Hinweis auf einen echten Logikfehler an der
|
||||||
genannten Stelle; bei vielen `.alert`/`.confirmationDialog` in Folge
|
genannten Stelle; bei vielen `.alert`/`.confirmationDialog` in Folge
|
||||||
vorbeugend in eigene `@ViewBuilder`-Properties aufteilen.
|
vorbeugend in eigene `@ViewBuilder`-Properties aufteilen. **"RouterOS'
|
||||||
|
Exit-Code ist kein verlässliches Erfolgssignal" (Bug 10) gilt auch für
|
||||||
|
Lesebefehle, nicht nur fürs Schreiben** (Bug 20) — derselbe
|
||||||
|
Singleton-Fallback, der für ein Menü per Exception ausgelöst wurde
|
||||||
|
(Bug 8), blieb für ein anderes Menü aus, weil RouterOS denselben
|
||||||
|
Fehlertext dort mit Exit-Code 0 zurückgibt; robuste Erkennung muss immer
|
||||||
|
auch den Output-Text selbst prüfen, nie nur auf eine geworfene Exception
|
||||||
|
vertrauen.
|
||||||
|
|
||||||
## `xcodebuild test` hängt — Gatekeeper, kein Code-Bug
|
## `xcodebuild test` hängt — Gatekeeper, kein Code-Bug
|
||||||
|
|
||||||
@@ -677,6 +719,25 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
|
|||||||
(Login-Erhalt vorangestellt) danach vom Nutzer bestätigt ("das hat
|
(Login-Erhalt vorangestellt) danach vom Nutzer bestätigt ("das hat
|
||||||
funktioniert"). Restore-Weg selbst (Modell-Match, SFTP-Upload,
|
funktioniert"). Restore-Weg selbst (Modell-Match, SFTP-Upload,
|
||||||
reset+run-after-reset) lief dagegen schon im ersten Versuch fehlerfrei.
|
reset+run-after-reset) lief dagegen schon im ersten Versuch fehlerfrei.
|
||||||
|
- ✅ M14: Update-Check im Verbinden-Tab — Routerboard-Vollinfo
|
||||||
|
(`/system routerboard`: Modell/Revision/Seriennummer/Firmware-Typ/
|
||||||
|
Firmware-Versionen), RouterOS-Software-Update-Check
|
||||||
|
(`/system package update`: Kanal/installiert/neueste Version/Status,
|
||||||
|
"Jetzt prüfen"+"Update installieren"), Routerboard-Firmware-Update
|
||||||
|
("Firmware aktualisieren") und "Jetzt neu starten" danach. **Alle vier
|
||||||
|
Aktionen live gegen Hardware verifiziert**, inkl. der zuvor offenen
|
||||||
|
Frage, ob `/system routerboard upgrade`s normalerweise interaktive
|
||||||
|
Bestätigung ("Do you really want to upgrade firmware? [y/n]") den
|
||||||
|
nicht-interaktiven SSH-Weg dieser App blockiert — tut es nicht,
|
||||||
|
Firmware-Update lief beim ersten Versuch sauber durch ("Firmware
|
||||||
|
upgraded successfully, please reboot..."), ebenso der Neustart danach.
|
||||||
|
Dabei ein weiterer Bug im generischen Lese-Pfad gefunden (Bug 20).
|
||||||
|
Anschließend Design-Durchgang: Verbinden-Detailseite/Sicherungen/
|
||||||
|
Geräte von nackter `List` auf `Form`+`.formStyle(.grouped)`
|
||||||
|
(macOS-typischer "Karten"-Look) umgestellt, damit einheitlich mit
|
||||||
|
Wizard/Experte-Tab. Dark Mode auf Nachfrage geprüft und ohne
|
||||||
|
Codeänderung bestätigt funktionierend (keine feste Farbwerte/
|
||||||
|
Appearance-Überschreibung im Code).
|
||||||
|
|
||||||
## Nächste Schritte
|
## Nächste Schritte
|
||||||
|
|
||||||
@@ -717,9 +778,19 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
|
|||||||
nicht mehr — **nicht mehr relevant, nicht danach suchen.** Aktueller
|
nicht mehr — **nicht mehr relevant, nicht danach suchen.** Aktueller
|
||||||
Stand: `Kay-Uwes-iMac` an ether2 und Test-Laptop `DEDELLB2M6GK3` an
|
Stand: `Kay-Uwes-iMac` an ether2 und Test-Laptop `DEDELLB2M6GK3` an
|
||||||
ether3, beide dynamisch, plus was auch immer die zuletzt erfolgreich
|
ether3, beide dynamisch, plus was auch immer die zuletzt erfolgreich
|
||||||
getestete Backup-Wiederherstellung zurückgespielt hat — vor
|
getestete Backup-Wiederherstellung zurückgespielt hat, und die
|
||||||
Annahmen über den genauen aktuellen Stand lieber neu per Geräte-/
|
Routerboard-Firmware wurde per M14 aktualisiert (Neustart ausgeführt)
|
||||||
Übersicht-Tab prüfen statt auf ältere Einträge hier zu vertrauen.
|
— vor Annahmen über den genauen aktuellen Stand lieber neu per
|
||||||
|
Geräte-/Übersicht-/Verbinden-Tab prüfen statt auf ältere Einträge hier
|
||||||
|
zu vertrauen.
|
||||||
|
10. **`fetchMenuItems`s Output-Text-Fallback (Bug 20) auf weitere
|
||||||
|
Singleton-Menüs prüfen** — live nur für `/system routerboard`
|
||||||
|
bestätigt gebraucht zu werden (`/system identity` etc. liefen schon
|
||||||
|
vorher über den Exception-Zweig, siehe Bug 8). Nicht geprüft, ob es
|
||||||
|
noch andere Menüs gibt, die den Fehler auf eine DRITTE Art melden
|
||||||
|
(weder Exception noch im Output-Text) — bei einer leer bleibenden
|
||||||
|
Liste in Übersicht/Geräte/Experte-Tab als erste Verdachtsquelle
|
||||||
|
prüfen.
|
||||||
|
|
||||||
Kein Gitea-Remote vorhanden — falls der Nutzer später eine Gitea-Instanz
|
Kein Gitea-Remote vorhanden — falls der Nutzer später eine Gitea-Instanz
|
||||||
aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe
|
aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe
|
||||||
|
|||||||
@@ -15,6 +15,34 @@ struct RouterDeviceInfo: Equatable {
|
|||||||
var uptime: String
|
var uptime: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything `/system routerboard print` reports — confirmed live (hEX, RouterOS 6.49.16):
|
||||||
|
/// routerboard, board-name, model, revision, serial-number, firmware-type, minimum-firmware,
|
||||||
|
/// current-firmware, upgrade-firmware. `model` is the RB-code (e.g. "RB750Gr3"), distinct from
|
||||||
|
/// `RouterDeviceInfo.boardName` (`/system resource print`'s "board-name", often the marketing
|
||||||
|
/// name, e.g. "hEX") — see `BackupService.backupModel` for why that distinction matters.
|
||||||
|
struct RouterBoardInfo: Equatable {
|
||||||
|
var model: String
|
||||||
|
var revision: String
|
||||||
|
var serialNumber: String
|
||||||
|
var firmwareType: String
|
||||||
|
var currentFirmware: String
|
||||||
|
var minimumFirmware: String
|
||||||
|
var upgradeFirmware: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/system package update print`'s fields — confirmed via MikroTik's own docs (its scripting
|
||||||
|
/// example uses these exact property names): channel, installed-version, latest-version, status.
|
||||||
|
/// Only "New version is available" is doc-verbatim-confirmed as a literal `status` value; other
|
||||||
|
/// values (including error states, prefixed "ERROR: ...") aren't from a documented closed list,
|
||||||
|
/// so this app never string-matches `status` beyond that one confirmed literal — it's always
|
||||||
|
/// just shown to the user verbatim rather than interpreted further.
|
||||||
|
struct PackageUpdateInfo: Equatable {
|
||||||
|
var channel: String
|
||||||
|
var installedVersion: String
|
||||||
|
var latestVersion: String
|
||||||
|
var status: String
|
||||||
|
}
|
||||||
|
|
||||||
struct NetworkInterface: Equatable, Identifiable {
|
struct NetworkInterface: Equatable, Identifiable {
|
||||||
var id: String { name }
|
var id: String { name }
|
||||||
var name: String
|
var name: String
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ final class SSHTransport: RouterOSTransport {
|
|||||||
let plain = try await run("\(menuPath) print without-paging")
|
let plain = try await run("\(menuPath) print without-paging")
|
||||||
return [RouterOSCliParser.parseSingletonItem(plain)]
|
return [RouterOSCliParser.parseSingletonItem(plain)]
|
||||||
}
|
}
|
||||||
|
// Same singleton rejection, but confirmed live (for "/system routerboard") that RouterOS
|
||||||
|
// doesn't always report it as a command failure Citadel would throw on — it can come
|
||||||
|
// back as plain output with exit code 0 instead, exactly the "exit code isn't reliable"
|
||||||
|
// lesson from Bug 10 applying to a *read* command here, not just add/set/remove. Without
|
||||||
|
// this, such a menu's data silently comes back empty rather than throwing or falling
|
||||||
|
// back — no crash, no error, just nothing, which is worse than either.
|
||||||
|
if output.contains("bad parameter terse") {
|
||||||
|
let plain = try await run("\(menuPath) print without-paging")
|
||||||
|
return [RouterOSCliParser.parseSingletonItem(plain)]
|
||||||
|
}
|
||||||
var items = RouterOSCliParser.parseGenericItems(output)
|
var items = RouterOSCliParser.parseGenericItems(output)
|
||||||
|
|
||||||
let idOutput = try await run(":put [\(menuPath) find]")
|
let idOutput = try await run(":put [\(menuPath) find]")
|
||||||
@@ -170,6 +180,48 @@ final class SSHTransport: RouterOSTransport {
|
|||||||
_ = try await run("/system reset-configuration no-defaults=yes run-after-reset=\(remoteName)")
|
_ = try await run("/system reset-configuration no-defaults=yes run-after-reset=\(remoteName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reboots immediately — needed after `upgradeRouterboardFirmware()`, which (confirmed via
|
||||||
|
/// docs) does not reboot on its own. Confirmed live: RouterOS' normally-interactive "Reboot,
|
||||||
|
/// yes? [y/n]" console prompt (same class of prompt `resetToFactoryDefaults()` and
|
||||||
|
/// `upgradeRouterboardFirmware()` also have) doesn't block this app's non-interactive SSH
|
||||||
|
/// exec — both of those ran fine without any special handling, this follows the same
|
||||||
|
/// established precedent.
|
||||||
|
func reboot() async throws {
|
||||||
|
_ = try? await run("/system reboot")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggers a fresh check against MikroTik's update servers — confirmed via docs that this
|
||||||
|
/// is one of two required steps (the other, reading the result, happens separately via
|
||||||
|
/// `fetchMenuItems("/system package update", ...)`, which now correctly falls back to the
|
||||||
|
/// singleton-item parser for this menu too). Requires the router to have working internet
|
||||||
|
/// access; a failure surfaces as the "status" field becoming an "ERROR: ..." string, not a
|
||||||
|
/// thrown exception here.
|
||||||
|
func checkForPackageUpdates() async throws {
|
||||||
|
_ = try? await run("/system package update check-for-updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads and installs the checked update. Confirmed via docs: this reboots the router
|
||||||
|
/// automatically on success, no separate reboot step — the connection dying mid-command is
|
||||||
|
/// the expected outcome, not a failure.
|
||||||
|
func installPackageUpdate() async throws {
|
||||||
|
_ = try? await run("/system package update install")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a newer RouterBOARD bootloader firmware — only becomes available after installing
|
||||||
|
/// a newer RouterOS package first (the firmware file ships bundled inside RouterOS packages,
|
||||||
|
/// confirmed via docs). Unlike this app's other mutating commands, success here is NOT
|
||||||
|
/// silent — RouterOS returns real confirmation text ("Firmware upgraded successfully, please
|
||||||
|
/// reboot for changes to take effect!"), so the raw output is returned rather than treated as
|
||||||
|
/// an error on any non-empty response. Confirmed via docs this command is normally
|
||||||
|
/// interactive in Winbox/console ("Do you really want to upgrade firmware? [y/n]") — not
|
||||||
|
/// verified whether that prompt applies over this app's non-interactive SSH exec too, though
|
||||||
|
/// `/system reset-configuration` (also normally interactive) has run fine this way all
|
||||||
|
/// session, so it's likely safe; flagged to the user in the UI regardless. Requires a manual
|
||||||
|
/// `/system reboot` afterward — not automatic (confirmed via docs).
|
||||||
|
func upgradeRouterboardFirmware() async throws -> String {
|
||||||
|
try await run("/system routerboard upgrade")
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs a command via `executeCommandStream` (not the simpler `executeCommand`), because
|
/// Runs a command via `executeCommandStream` (not the simpler `executeCommand`), because
|
||||||
/// `executeCommand` discards whatever output it already collected the moment the command
|
/// `executeCommand` discards whatever output it already collected the moment the command
|
||||||
/// exits non-zero — exactly the RouterOS error text we need. Collecting the stream ourselves
|
/// exits non-zero — exactly the RouterOS error text we need. Collecting the stream ourselves
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class ConnectionService: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var state: State = .idle
|
@Published private(set) var state: State = .idle
|
||||||
@Published private(set) var deviceInfo: RouterDeviceInfo?
|
@Published private(set) var deviceInfo: RouterDeviceInfo?
|
||||||
|
@Published private(set) var routerBoardInfo: RouterBoardInfo?
|
||||||
@Published private(set) var interfaces: [NetworkInterface] = []
|
@Published private(set) var interfaces: [NetworkInterface] = []
|
||||||
/// Credentials of the current (or last attempted) connection, shared with features
|
/// Credentials of the current (or last attempted) connection, shared with features
|
||||||
/// that need their own dedicated connection, e.g. BackupService's SSH export.
|
/// that need their own dedicated connection, e.g. BackupService's SSH export.
|
||||||
@@ -116,16 +117,36 @@ final class ConnectionService: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
deviceInfo = try await transport.fetchDeviceInfo()
|
deviceInfo = try await transport.fetchDeviceInfo()
|
||||||
interfaces = try await transport.fetchInterfaces()
|
interfaces = try await transport.fetchInterfaces()
|
||||||
|
// Best-effort: some devices (e.g. CHR/x86 virtual routers) have no physical
|
||||||
|
// RouterBOARD at all, so this menu can legitimately be absent — must not fail the
|
||||||
|
// whole connection over it.
|
||||||
|
routerBoardInfo = try? await Self.fetchRouterBoardInfo(using: transport)
|
||||||
state = .connected(kind: transport.kind)
|
state = .connected(kind: transport.kind)
|
||||||
} catch {
|
} catch {
|
||||||
state = .failed(error.localizedDescription)
|
state = .failed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Field names confirmed live (hEX, RouterOS 6.49.16, see `RouterBoardInfo`'s doc comment).
|
||||||
|
private static func fetchRouterBoardInfo(using transport: RouterOSTransport) async throws -> RouterBoardInfo {
|
||||||
|
let items = try await transport.fetchMenuItems(menuPath: "/system routerboard", restPath: "system/routerboard")
|
||||||
|
guard let fields = items.first?.fields else { throw RouterOSError.invalidResponse("system/routerboard") }
|
||||||
|
return RouterBoardInfo(
|
||||||
|
model: fields["model"] ?? "unbekannt",
|
||||||
|
revision: fields["revision"] ?? "unbekannt",
|
||||||
|
serialNumber: fields["serial-number"] ?? "unbekannt",
|
||||||
|
firmwareType: fields["firmware-type"] ?? "unbekannt",
|
||||||
|
currentFirmware: fields["current-firmware"] ?? "unbekannt",
|
||||||
|
minimumFirmware: fields["minimum-firmware"] ?? "unbekannt",
|
||||||
|
upgradeFirmware: fields["upgrade-firmware"] ?? "unbekannt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func disconnect() async {
|
func disconnect() async {
|
||||||
await activeTransport?.disconnect()
|
await activeTransport?.disconnect()
|
||||||
activeTransport = nil
|
activeTransport = nil
|
||||||
deviceInfo = nil
|
deviceInfo = nil
|
||||||
|
routerBoardInfo = nil
|
||||||
interfaces = []
|
interfaces = []
|
||||||
credentials = nil
|
credentials = nil
|
||||||
hasExpertToolBackedUpThisSession = false
|
hasExpertToolBackedUpThisSession = false
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// RouterOS software updates (`/system package update`) and RouterBOARD bootloader firmware
|
||||||
|
/// updates (`/system routerboard upgrade`) — two independent MikroTik update mechanisms
|
||||||
|
/// (confirmed via docs): package updates are the RouterOS operating system version itself;
|
||||||
|
/// routerboard firmware is the bootloader, which only has a newer version available after a
|
||||||
|
/// newer RouterOS package has already been installed (the firmware file ships bundled inside
|
||||||
|
/// RouterOS packages, not distributed on its own). Always runs over a dedicated SSH connection,
|
||||||
|
/// same reasoning as BackupService/FactoryResetService: these are one-shot system actions, not
|
||||||
|
/// menu items to add/set.
|
||||||
|
final class UpdateService {
|
||||||
|
/// Triggers a fresh check, waits (matching MikroTik's own documented scripting example's
|
||||||
|
/// timing: "check-for-updates once; :delay 3s"), then reads the result via the generic
|
||||||
|
/// menu-item engine — no polling loop, since RouterOS doesn't document a closed set of
|
||||||
|
/// intermediate "still checking" states to poll against; this mirrors the doc's own example
|
||||||
|
/// exactly rather than guessing at retry logic.
|
||||||
|
func checkForUpdates(for credentials: RouterOSCredentials) async throws -> PackageUpdateInfo {
|
||||||
|
let transport = SSHTransport(credentials: credentials)
|
||||||
|
try await transport.connect()
|
||||||
|
do {
|
||||||
|
try await transport.checkForPackageUpdates()
|
||||||
|
try await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
let items = try await transport.fetchMenuItems(menuPath: "/system package update", restPath: "system/package/update")
|
||||||
|
await transport.disconnect()
|
||||||
|
guard let fields = items.first?.fields else {
|
||||||
|
throw RouterOSError.invalidResponse("system/package/update")
|
||||||
|
}
|
||||||
|
return PackageUpdateInfo(
|
||||||
|
channel: fields["channel"] ?? "unbekannt",
|
||||||
|
installedVersion: fields["installed-version"] ?? "unbekannt",
|
||||||
|
latestVersion: fields["latest-version"] ?? "unbekannt",
|
||||||
|
status: fields["status"] ?? "unbekannt"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
await transport.disconnect()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reboots immediately — needed after `upgradeRouterboardFirmware` (not automatic, confirmed
|
||||||
|
/// via docs) or anytime else a manual restart is wanted.
|
||||||
|
func reboot(for credentials: RouterOSCredentials) async throws {
|
||||||
|
let transport = SSHTransport(credentials: credentials)
|
||||||
|
try await transport.connect()
|
||||||
|
try await transport.reboot()
|
||||||
|
await transport.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads, installs, and (confirmed via docs) automatically reboots.
|
||||||
|
func installUpdate(for credentials: RouterOSCredentials) async throws {
|
||||||
|
let transport = SSHTransport(credentials: credentials)
|
||||||
|
try await transport.connect()
|
||||||
|
// The router may reboot as part of this command; the connection dying mid-command
|
||||||
|
// instead of returning cleanly is an expected outcome here, not a failure.
|
||||||
|
try? await transport.installPackageUpdate()
|
||||||
|
await transport.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the RouterBOARD bootloader firmware bundled in the currently installed RouterOS
|
||||||
|
/// package. Returns RouterOS' own confirmation text — see
|
||||||
|
/// `SSHTransport.upgradeRouterboardFirmware`'s doc comment for what's confirmed vs. not about
|
||||||
|
/// this command's behavior over non-interactive SSH.
|
||||||
|
func upgradeRouterboardFirmware(for credentials: RouterOSCredentials) async throws -> String {
|
||||||
|
let transport = SSHTransport(credentials: credentials)
|
||||||
|
try await transport.connect()
|
||||||
|
do {
|
||||||
|
let output = try await transport.upgradeRouterboardFirmware()
|
||||||
|
await transport.disconnect()
|
||||||
|
return output
|
||||||
|
} catch {
|
||||||
|
await transport.disconnect()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,28 +92,33 @@ struct BackupListView: View {
|
|||||||
description: Text("Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.")
|
description: Text("Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
List(viewModel.backups) { backup in
|
Form {
|
||||||
HStack {
|
Section("Sicherungen") {
|
||||||
VStack(alignment: .leading) {
|
ForEach(viewModel.backups) { backup in
|
||||||
Text(backup.host).bold()
|
HStack {
|
||||||
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
|
VStack(alignment: .leading) {
|
||||||
.font(.caption)
|
Text(backup.host).bold()
|
||||||
.foregroundStyle(.secondary)
|
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
|
||||||
if let model = BackupService.backupModel(from: backup.fileURL) {
|
.font(.caption)
|
||||||
Text(model).font(.caption2).foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
if let model = BackupService.backupModel(from: backup.fileURL) {
|
||||||
|
Text(model).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
beginRestore(backup)
|
||||||
|
} label: {
|
||||||
|
Label("Wiederherstellen", systemImage: "tray.and.arrow.up")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(connectionService.credentials == nil || viewModel.isRestoring)
|
||||||
|
.help("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
beginRestore(backup)
|
|
||||||
} label: {
|
|
||||||
Label("Wiederherstellen", systemImage: "tray.and.arrow.up")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.disabled(connectionService.credentials == nil || viewModel.isRestoring)
|
|
||||||
.help("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ struct DevicesView: View {
|
|||||||
description: Text("Der Router meldet aktuell keine physischen Interfaces.")
|
description: Text("Der Router meldet aktuell keine physischen Interfaces.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
List {
|
Form {
|
||||||
ForEach(viewModel.portGroups) { group in
|
ForEach(viewModel.portGroups) { group in
|
||||||
Section {
|
Section {
|
||||||
if group.devices.isEmpty {
|
if group.devices.isEmpty {
|
||||||
@@ -66,6 +66,7 @@ struct DevicesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Geräte")
|
.navigationTitle("Geräte")
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ struct ConnectView: View {
|
|||||||
@ObservedObject private var connectionService: ConnectionService
|
@ObservedObject private var connectionService: ConnectionService
|
||||||
@StateObject private var viewModel: ConnectViewModel
|
@StateObject private var viewModel: ConnectViewModel
|
||||||
@StateObject private var backupViewModel = BackupViewModel()
|
@StateObject private var backupViewModel = BackupViewModel()
|
||||||
|
@State private var showUpdateInstallConfirmation = false
|
||||||
|
@State private var showFirmwareUpgradeConfirmation = false
|
||||||
|
@State private var showRebootConfirmation = false
|
||||||
|
|
||||||
init(connectionService: ConnectionService) {
|
init(connectionService: ConnectionService) {
|
||||||
self.connectionService = connectionService
|
self.connectionService = connectionService
|
||||||
@@ -11,6 +14,15 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
mainContent
|
||||||
|
.withUpdateDialogs(viewModel: viewModel, connectionService: connectionService, showUpdateInstallConfirmation: $showUpdateInstallConfirmation, showFirmwareUpgradeConfirmation: $showFirmwareUpgradeConfirmation)
|
||||||
|
.withRebootDialogs(viewModel: viewModel, connectionService: connectionService, isPresented: $showRebootConfirmation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split out of `body` — same reasoning as `BackupListView.restoreAwareContent` (Bug 19 in
|
||||||
|
/// HANDOFF.md): too many `.alert`/`.confirmationDialog` modifiers chained onto one view times
|
||||||
|
/// out the Swift type-checker with a misleading error location, not an actual logic issue.
|
||||||
|
private var mainContent: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
Form {
|
Form {
|
||||||
Section("Verbindung") {
|
Section("Verbindung") {
|
||||||
@@ -167,13 +179,101 @@ struct ConnectView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var deviceDetail: some View {
|
private var deviceDetail: some View {
|
||||||
if let info = connectionService.deviceInfo {
|
if let info = connectionService.deviceInfo {
|
||||||
List {
|
Form {
|
||||||
Section("Gerät") {
|
Section("Gerät") {
|
||||||
LabeledContent("Modell", value: info.boardName)
|
LabeledContent("Modell", value: info.boardName)
|
||||||
LabeledContent("RouterOS-Version", value: info.routerOSVersion)
|
LabeledContent("RouterOS-Version", value: info.routerOSVersion)
|
||||||
LabeledContent("Architektur", value: info.architecture)
|
LabeledContent("Architektur", value: info.architecture)
|
||||||
LabeledContent("Laufzeit", value: info.uptime)
|
LabeledContent("Laufzeit", value: info.uptime)
|
||||||
}
|
}
|
||||||
|
if let board = connectionService.routerBoardInfo {
|
||||||
|
Section("Routerboard") {
|
||||||
|
LabeledContent("Modell (RB-Code)", value: board.model)
|
||||||
|
LabeledContent("Revision", value: board.revision)
|
||||||
|
LabeledContent("Seriennummer", value: board.serialNumber)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
LabeledContent("Firmware-Typ", value: board.firmwareType)
|
||||||
|
LabeledContent("Firmware (aktuell)", value: board.currentFirmware)
|
||||||
|
LabeledContent("Firmware (minimal)", value: board.minimumFirmware)
|
||||||
|
LabeledContent("Firmware (verfügbar)", value: board.upgradeFirmware)
|
||||||
|
// Confirmed via docs: same value = up to date, different = a bootloader
|
||||||
|
// update is available (only ever becomes available after installing a
|
||||||
|
// newer RouterOS package first — the firmware ships bundled inside it).
|
||||||
|
if board.currentFirmware != board.upgradeFirmware {
|
||||||
|
Label("Firmware-Update verfügbar", systemImage: "arrow.up.circle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Button {
|
||||||
|
showFirmwareUpgradeConfirmation = true
|
||||||
|
} label: {
|
||||||
|
if viewModel.isUpgradingFirmware {
|
||||||
|
HStack { ProgressView(); Text("Aktualisiere…") }
|
||||||
|
} else {
|
||||||
|
Label("Firmware aktualisieren", systemImage: "arrow.up.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isUpgradingFirmware)
|
||||||
|
} else {
|
||||||
|
Label("Firmware aktuell", systemImage: "checkmark.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let message = viewModel.firmwareUpgradeResultMessage {
|
||||||
|
Text(message).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
showRebootConfirmation = true
|
||||||
|
} label: {
|
||||||
|
if viewModel.isRebooting {
|
||||||
|
HStack { ProgressView(); Text("Starte neu…") }
|
||||||
|
} else {
|
||||||
|
Label("Jetzt neu starten", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isRebooting)
|
||||||
|
.help("Nötig, damit die neu geschriebene Firmware aktiv wird — passiert nicht automatisch.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("Software-Update") {
|
||||||
|
if let update = viewModel.packageUpdateInfo {
|
||||||
|
LabeledContent("Kanal", value: update.channel)
|
||||||
|
LabeledContent("Installiert", value: update.installedVersion)
|
||||||
|
LabeledContent("Neueste Version", value: update.latestVersion)
|
||||||
|
LabeledContent("Status", value: update.status)
|
||||||
|
if update.status == "New version is available" {
|
||||||
|
Button {
|
||||||
|
showUpdateInstallConfirmation = true
|
||||||
|
} label: {
|
||||||
|
if viewModel.isInstallingUpdate {
|
||||||
|
HStack { ProgressView(); Text("Installiere…") }
|
||||||
|
} else {
|
||||||
|
Label("Update installieren", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isInstallingUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
if let credentials = connectionService.credentials {
|
||||||
|
viewModel.checkForUpdates(for: credentials)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if viewModel.isCheckingForUpdates {
|
||||||
|
HStack { ProgressView(); Text("Prüfe…") }
|
||||||
|
} else {
|
||||||
|
Label("Jetzt prüfen", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isCheckingForUpdates)
|
||||||
|
.help("Prüft bei MikroTik, ob eine neuere RouterOS-Version verfügbar ist. Braucht Internetzugang auf dem Router.")
|
||||||
|
|
||||||
|
if viewModel.didSendUpdateInstall {
|
||||||
|
Label(
|
||||||
|
"Update-Befehl gesendet — der Router lädt herunter, installiert und startet danach automatisch neu.",
|
||||||
|
systemImage: "arrow.clockwise"
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
Section("Interfaces") {
|
Section("Interfaces") {
|
||||||
ForEach(connectionService.interfaces) { interface in
|
ForEach(connectionService.interfaces) { interface in
|
||||||
HStack {
|
HStack {
|
||||||
@@ -188,6 +288,7 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Nicht verbunden",
|
"Nicht verbunden",
|
||||||
@@ -198,6 +299,134 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split into two small functions (software-update dialogs, firmware-upgrade dialogs), not one
|
||||||
|
/// big one — same Bug 19 reasoning as `mainContent`'s own split: keep each modifier chain short
|
||||||
|
/// enough that the type-checker doesn't time out.
|
||||||
|
private extension View {
|
||||||
|
func withUpdateDialogs(
|
||||||
|
viewModel: ConnectViewModel,
|
||||||
|
connectionService: ConnectionService,
|
||||||
|
showUpdateInstallConfirmation: Binding<Bool>,
|
||||||
|
showFirmwareUpgradeConfirmation: Binding<Bool>
|
||||||
|
) -> some View {
|
||||||
|
self
|
||||||
|
.withSoftwareUpdateDialogs(viewModel: viewModel, connectionService: connectionService, isPresented: showUpdateInstallConfirmation)
|
||||||
|
.withFirmwareUpgradeDialogs(viewModel: viewModel, connectionService: connectionService, isPresented: showFirmwareUpgradeConfirmation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSoftwareUpdateDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, isPresented: Binding<Bool>) -> some View {
|
||||||
|
self
|
||||||
|
.confirmationDialog(
|
||||||
|
"RouterOS-Update installieren?",
|
||||||
|
isPresented: isPresented,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Installieren", role: .destructive) {
|
||||||
|
if let credentials = connectionService.credentials {
|
||||||
|
viewModel.installUpdate(for: credentials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Lädt RouterOS \(viewModel.packageUpdateInfo?.latestVersion ?? "die neueste Version") herunter, installiert es und startet den Router danach automatisch neu — die Verbindung geht dabei verloren. Vorher am besten eine Sicherung erstellen (\"Jetzt sichern\" oben).")
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.didSendUpdateInstall) { _, didSend in
|
||||||
|
if didSend {
|
||||||
|
Task { await connectionService.disconnect() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Update-Prüfung fehlgeschlagen",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { viewModel.updateCheckError != nil },
|
||||||
|
set: { _ in viewModel.updateCheckError = nil }
|
||||||
|
),
|
||||||
|
presenting: viewModel.updateCheckError
|
||||||
|
) { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { message in
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Update-Installation fehlgeschlagen",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { viewModel.installUpdateError != nil },
|
||||||
|
set: { _ in viewModel.installUpdateError = nil }
|
||||||
|
),
|
||||||
|
presenting: viewModel.installUpdateError
|
||||||
|
) { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { message in
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withFirmwareUpgradeDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, isPresented: Binding<Bool>) -> some View {
|
||||||
|
self
|
||||||
|
.confirmationDialog(
|
||||||
|
"Routerboard-Firmware aktualisieren?",
|
||||||
|
isPresented: isPresented,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Aktualisieren", role: .destructive) {
|
||||||
|
if let credentials = connectionService.credentials {
|
||||||
|
viewModel.upgradeRouterboardFirmware(for: credentials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Schreibt die neue Bootloader-Firmware auf den Router. Danach ist ein manueller Neustart nötig — passiert nicht automatisch. Dieser Befehl fragt in einer interaktiven Sitzung normalerweise erst nach Bestätigung; ob das über diese App genauso abläuft, ist nicht abschließend getestet (ähnlich interaktive Befehle liefen bisher aber problemlos). Falls die App hier ungewöhnlich lange ohne Rückmeldung bleibt, bitte melden.")
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Firmware-Aktualisierung fehlgeschlagen",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { viewModel.firmwareUpgradeError != nil },
|
||||||
|
set: { _ in viewModel.firmwareUpgradeError = nil }
|
||||||
|
),
|
||||||
|
presenting: viewModel.firmwareUpgradeError
|
||||||
|
) { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { message in
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withRebootDialogs(viewModel: ConnectViewModel, connectionService: ConnectionService, isPresented: Binding<Bool>) -> some View {
|
||||||
|
self
|
||||||
|
.confirmationDialog(
|
||||||
|
"Router jetzt neu starten?",
|
||||||
|
isPresented: isPresented,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Neu starten", role: .destructive) {
|
||||||
|
if let credentials = connectionService.credentials {
|
||||||
|
viewModel.reboot(for: credentials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Startet den Router sofort neu. Die Verbindung geht dabei kurz verloren, danach im Tab \"Verbinden\" erneut verbinden.")
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.didSendReboot) { _, didSend in
|
||||||
|
if didSend {
|
||||||
|
Task { await connectionService.disconnect() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Neustart fehlgeschlagen",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { viewModel.rebootError != nil },
|
||||||
|
set: { _ in viewModel.rebootError = nil }
|
||||||
|
),
|
||||||
|
presenting: viewModel.rebootError
|
||||||
|
) { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { message in
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ConnectView(connectionService: ConnectionService())
|
ConnectView(connectionService: ConnectionService())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,25 @@ final class ConnectViewModel: ObservableObject {
|
|||||||
@Published var password: String = ""
|
@Published var password: String = ""
|
||||||
@Published var rememberPassword: Bool = true
|
@Published var rememberPassword: Bool = true
|
||||||
|
|
||||||
|
@Published private(set) var packageUpdateInfo: PackageUpdateInfo?
|
||||||
|
@Published private(set) var isCheckingForUpdates = false
|
||||||
|
@Published var updateCheckError: String?
|
||||||
|
|
||||||
|
@Published private(set) var isInstallingUpdate = false
|
||||||
|
@Published var installUpdateError: String?
|
||||||
|
@Published private(set) var didSendUpdateInstall = false
|
||||||
|
|
||||||
|
@Published private(set) var isUpgradingFirmware = false
|
||||||
|
@Published var firmwareUpgradeError: String?
|
||||||
|
@Published var firmwareUpgradeResultMessage: String?
|
||||||
|
|
||||||
|
@Published private(set) var isRebooting = false
|
||||||
|
@Published var rebootError: String?
|
||||||
|
@Published private(set) var didSendReboot = false
|
||||||
|
|
||||||
let connectionService: ConnectionService
|
let connectionService: ConnectionService
|
||||||
private let keychain = KeychainService()
|
private let keychain = KeychainService()
|
||||||
|
private let updateService = UpdateService()
|
||||||
|
|
||||||
init(connectionService: ConnectionService) {
|
init(connectionService: ConnectionService) {
|
||||||
self.connectionService = connectionService
|
self.connectionService = connectionService
|
||||||
@@ -41,4 +58,61 @@ final class ConnectViewModel: ObservableObject {
|
|||||||
await connectionService.trustCurrentSSHHostKeyAndRetry(fingerprint: fingerprint)
|
await connectionService.trustCurrentSSHHostKeyAndRetry(fingerprint: fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkForUpdates(for credentials: RouterOSCredentials) {
|
||||||
|
isCheckingForUpdates = true
|
||||||
|
updateCheckError = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
packageUpdateInfo = try await updateService.checkForUpdates(for: credentials)
|
||||||
|
} catch {
|
||||||
|
updateCheckError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isCheckingForUpdates = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installUpdate(for credentials: RouterOSCredentials) {
|
||||||
|
isInstallingUpdate = true
|
||||||
|
installUpdateError = nil
|
||||||
|
didSendUpdateInstall = false
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await updateService.installUpdate(for: credentials)
|
||||||
|
didSendUpdateInstall = true
|
||||||
|
} catch {
|
||||||
|
installUpdateError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isInstallingUpdate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeRouterboardFirmware(for credentials: RouterOSCredentials) {
|
||||||
|
isUpgradingFirmware = true
|
||||||
|
firmwareUpgradeError = nil
|
||||||
|
firmwareUpgradeResultMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
firmwareUpgradeResultMessage = try await updateService.upgradeRouterboardFirmware(for: credentials)
|
||||||
|
} catch {
|
||||||
|
firmwareUpgradeError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isUpgradingFirmware = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reboot(for credentials: RouterOSCredentials) {
|
||||||
|
isRebooting = true
|
||||||
|
rebootError = nil
|
||||||
|
didSendReboot = false
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await updateService.reboot(for: credentials)
|
||||||
|
didSendReboot = true
|
||||||
|
} catch {
|
||||||
|
rebootError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isRebooting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user