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:
Kay
2026-09-14 16:34:50 +02:00
co-authored by Claude Sonnet 5
parent 20f8d5e0bf
commit e72da84843
10 changed files with 629 additions and 27 deletions
+46
View File
@@ -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
View File
@@ -34,8 +34,27 @@ Bugs, 1417) 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
}
}
}