forked from kay/RouterOS
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
|
||||
(Bug 15) und alle länger schon offenen Punkte (M7-Rest, M8-Hardware-
|
||||
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
|
||||
zäher als erwartet, am Ende aber jeweils vom Nutzer selbst bestätigt
|
||||
("das funktioniert jetzt super" / "das hat sofort funktioniert" / "gut,
|
||||
das hat funktioniert"). Release-Build unter
|
||||
`/Applications/RouterOS Assistant.app` ist auf aktuellem Stand.
|
||||
das hat funktioniert").
|
||||
|
||||
**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
|
||||
|
||||
@@ -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)
|
||||
FactoryResetService.swift — /system reset-configuration über eigene SSH-Verbindung ("Gefahrenzone")
|
||||
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/
|
||||
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
|
||||
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)
|
||||
@@ -343,6 +363,21 @@ erreichen (siehe Bug 1 unten).
|
||||
Views mit vielen `.alert`/`.confirmationDialog`/`.sheet`-Modifiern in
|
||||
Folge vorbeugend gleich aufteilen, statt erst bei diesem Fehler zu
|
||||
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,
|
||||
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
|
||||
timeouten** (Bug 19) — kein Hinweis auf einen echten Logikfehler an der
|
||||
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
|
||||
|
||||
@@ -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
|
||||
funktioniert"). Restore-Weg selbst (Modell-Match, SFTP-Upload,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Stand: `Kay-Uwes-iMac` an ether2 und Test-Laptop `DEDELLB2M6GK3` an
|
||||
ether3, beide dynamisch, plus was auch immer die zuletzt erfolgreich
|
||||
getestete Backup-Wiederherstellung zurückgespielt hat — vor
|
||||
Annahmen über den genauen aktuellen Stand lieber neu per Geräte-/
|
||||
Übersicht-Tab prüfen statt auf ältere Einträge hier zu vertrauen.
|
||||
getestete Backup-Wiederherstellung zurückgespielt hat, und die
|
||||
Routerboard-Firmware wurde per M14 aktualisiert (Neustart ausgeführt)
|
||||
— 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
|
||||
aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe
|
||||
|
||||
@@ -15,6 +15,34 @@ struct RouterDeviceInfo: Equatable {
|
||||
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 {
|
||||
var id: String { name }
|
||||
var name: String
|
||||
|
||||
@@ -82,6 +82,16 @@ final class SSHTransport: RouterOSTransport {
|
||||
let plain = try await run("\(menuPath) print without-paging")
|
||||
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)
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `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
|
||||
|
||||
@@ -14,6 +14,7 @@ final class ConnectionService: ObservableObject {
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var deviceInfo: RouterDeviceInfo?
|
||||
@Published private(set) var routerBoardInfo: RouterBoardInfo?
|
||||
@Published private(set) var interfaces: [NetworkInterface] = []
|
||||
/// Credentials of the current (or last attempted) connection, shared with features
|
||||
/// that need their own dedicated connection, e.g. BackupService's SSH export.
|
||||
@@ -116,16 +117,36 @@ final class ConnectionService: ObservableObject {
|
||||
do {
|
||||
deviceInfo = try await transport.fetchDeviceInfo()
|
||||
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)
|
||||
} catch {
|
||||
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 {
|
||||
await activeTransport?.disconnect()
|
||||
activeTransport = nil
|
||||
deviceInfo = nil
|
||||
routerBoardInfo = nil
|
||||
interfaces = []
|
||||
credentials = nil
|
||||
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.")
|
||||
)
|
||||
} else {
|
||||
List(viewModel.backups) { backup in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(backup.host).bold()
|
||||
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let model = BackupService.backupModel(from: backup.fileURL) {
|
||||
Text(model).font(.caption2).foregroundStyle(.secondary)
|
||||
Form {
|
||||
Section("Sicherungen") {
|
||||
ForEach(viewModel.backups) { backup in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(backup.host).bold()
|
||||
Text(backup.createdAt.formatted(date: .abbreviated, time: .standard))
|
||||
.font(.caption)
|
||||
.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) {
|
||||
|
||||
@@ -47,7 +47,7 @@ struct DevicesView: View {
|
||||
description: Text("Der Router meldet aktuell keine physischen Interfaces.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
Form {
|
||||
ForEach(viewModel.portGroups) { group in
|
||||
Section {
|
||||
if group.devices.isEmpty {
|
||||
@@ -66,6 +66,7 @@ struct DevicesView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Geräte")
|
||||
|
||||
@@ -4,6 +4,9 @@ struct ConnectView: View {
|
||||
@ObservedObject private var connectionService: ConnectionService
|
||||
@StateObject private var viewModel: ConnectViewModel
|
||||
@StateObject private var backupViewModel = BackupViewModel()
|
||||
@State private var showUpdateInstallConfirmation = false
|
||||
@State private var showFirmwareUpgradeConfirmation = false
|
||||
@State private var showRebootConfirmation = false
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
@@ -11,6 +14,15 @@ struct ConnectView: 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 {
|
||||
Form {
|
||||
Section("Verbindung") {
|
||||
@@ -167,13 +179,101 @@ struct ConnectView: View {
|
||||
@ViewBuilder
|
||||
private var deviceDetail: some View {
|
||||
if let info = connectionService.deviceInfo {
|
||||
List {
|
||||
Form {
|
||||
Section("Gerät") {
|
||||
LabeledContent("Modell", value: info.boardName)
|
||||
LabeledContent("RouterOS-Version", value: info.routerOSVersion)
|
||||
LabeledContent("Architektur", value: info.architecture)
|
||||
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") {
|
||||
ForEach(connectionService.interfaces) { interface in
|
||||
HStack {
|
||||
@@ -188,6 +288,7 @@ struct ConnectView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"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 {
|
||||
ConnectView(connectionService: ConnectionService())
|
||||
}
|
||||
|
||||
@@ -7,8 +7,25 @@ final class ConnectViewModel: ObservableObject {
|
||||
@Published var password: String = ""
|
||||
@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
|
||||
private let keychain = KeychainService()
|
||||
private let updateService = UpdateService()
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
@@ -41,4 +58,61 @@ final class ConnectViewModel: ObservableObject {
|
||||
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