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
@@ -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
}
}
}