import Foundation /// Builds the "Geräte" tab's LAN device list from a live router — DHCP leases enriched with the /// physical port each device was learned on (ARP + bridge host table) — grouped into one table /// per physical port, and drives the two write actions this tab offers: converting a dynamic /// lease to a permanent static one, and removing a static lease again (RouterOS has no reverse /// of "make-static", so "back to dynamic" is just removal — see `pendingStaticRemoval`). @MainActor final class DevicesViewModel: ObservableObject { @Published private(set) var devices: [LanDevice] = [] @Published private(set) var portGroups: [DevicePortGroup] = [] @Published private(set) var isLoading = false @Published private(set) var loadError: String? @Published var pendingStaticAssignment: LanDevice? /// The device whose static reservation the user is about to remove — RouterOS has no /// "make-dynamic" (confirmed against the official DHCP docs: only check-status, make-static, /// send-reconfigure exist for this menu), so "back to dynamic" is genuinely just removing the /// static lease; the device gets a fresh dynamic lease on its own next DHCP request, not /// immediately or necessarily on the same IP. @Published var pendingStaticRemoval: LanDevice? @Published private(set) var isApplying = false @Published private(set) var applyError: String? /// Set once a ping/traceroute/DNS lookup finishes — `.sheet(item:)` in `DevicesView` shows its /// raw output. `nil` in between runs and while one is in progress. @Published var networkToolResult: NetworkToolResult? @Published private(set) var isRunningNetworkTool = false @Published var networkToolError: String? /// Set once a port scan finishes — separate from `networkToolResult` since it needs a /// structured (colored per-port) display, not raw text. Runs directly from this Mac via /// `PortScanner`, not through the router — see its own doc comment for why. @Published var portScanResult: PortScanResult? private let connectionService: ConnectionService private let backupService: BackupService private let networkToolsService: NetworkToolsService init( connectionService: ConnectionService, backupService: BackupService = BackupService(), networkToolsService: NetworkToolsService = NetworkToolsService() ) { self.connectionService = connectionService self.backupService = backupService self.networkToolsService = networkToolsService } func runPing(for device: LanDevice) { runNetworkTool(title: "Ping: \(device.ipAddress)") { [networkToolsService] credentials in try await networkToolsService.ping(address: device.ipAddress, for: credentials) } } func runTraceroute(for device: LanDevice) { runNetworkTool(title: "Traceroute: \(device.ipAddress)") { [networkToolsService] credentials in try await networkToolsService.traceroute(address: device.ipAddress, for: credentials) } } func runDnsLookup(for device: LanDevice) { guard let hostName = device.hostName, !hostName.isEmpty else { return } runNetworkTool(title: "DNS-Auflösung: \(hostName)") { [networkToolsService] credentials in try await networkToolsService.resolve(hostname: hostName, for: credentials) } } func dismissNetworkToolError() { networkToolError = nil } /// Runs directly from this Mac (no router credentials involved), so it doesn't go through /// `runNetworkTool(title:action:)` — but still respects the same `isRunningNetworkTool` busy /// flag so a scan and a ping/traceroute/DNS lookup can't overlap and race on the same overlay. func runPortScan(for device: LanDevice) { guard !isRunningNetworkTool else { return } isRunningNetworkTool = true networkToolError = nil let label = (device.hostName?.isEmpty == false ? "\(device.hostName!) (\(device.ipAddress))" : device.ipAddress) Task { let results = await PortScanner.scan(host: device.ipAddress) portScanResult = PortScanResult( deviceLabel: label, entries: results.map { PortScanResult.Entry(port: $0.port.port, serviceName: $0.port.serviceName, status: $0.status) } ) isRunningNetworkTool = false } } private func runNetworkTool(title: String, action: @escaping (RouterOSCredentials) async throws -> String) { guard let credentials = connectionService.credentials, !isRunningNetworkTool else { return } isRunningNetworkTool = true networkToolError = nil Task { do { let output = try await action(credentials) networkToolResult = NetworkToolResult(title: title, output: output) } catch { networkToolError = error.localizedDescription } isRunningNetworkTool = false } } func load() async { isLoading = true loadError = nil do { async let leasesTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease") async let arpTask = connectionService.fetchMenuItems(menuPath: "/ip arp", restPath: "ip/arp") async let bridgeHostsTask = connectionService.fetchMenuItems(menuPath: "/interface bridge host", restPath: "interface/bridge/host") async let interfacesTask = connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface") async let dhcpServersTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server") let leases = try await leasesTask let arpEntries = try await arpTask let bridgeHosts = try await bridgeHostsTask let interfaces = try await interfacesTask let dhcpServers = try await dhcpServersTask // Best-effort: if this specific lookup fails (e.g. an older RouterOS version that // doesn't support filtering "find" on this property), fall back to "nothing known // static yet" rather than failing the whole tab — matches the safe default already // used when a lease is entirely unmatched. Returns MAC addresses, not `.id`s — see // `RouterOSTransport.fetchFieldValues`'s doc comment for why `.id` isn't trustworthy // for this menu. let staticMacs = Set((try? await connectionService.fetchFieldValues( menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease", whereField: "dynamic", whereValue: "no", returnField: "mac-address" ))?.map { $0.lowercased() } ?? []) let builtDevices = Self.buildDevices( leases: leases, arpEntries: arpEntries, bridgeHosts: bridgeHosts, interfaces: interfaces, dhcpServers: dhcpServers, staticMacAddresses: staticMacs ) devices = builtDevices portGroups = Self.buildPortGroups(devices: builtDevices, interfaces: interfaces) } catch { loadError = error.localizedDescription } isLoading = false } /// The command "Feste IP zuweisen" would run — shown to the user before it executes, same /// convention as the rest of the app (Wizard review screen, Expert tool's confirmation /// dialog). var pendingCommand: RouterOSCommand? { guard let device = pendingStaticAssignment, device.hasLease else { return nil } // Matched by MAC address, not `.id` — deliberately, see `RouterOSTransport. // fetchFieldValues`'s doc comment for why the `.id` this app would otherwise have on // hand (`device.leaseID`) isn't trustworthy for this menu. return .action( menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease", name: "make-static", matchField: "mac-address", matchValue: device.macAddress, summary: "Feste IP \(device.ipAddress) für \(device.macAddress)" ) } /// The command "Feste Zuweisung entfernen" would run — matched by MAC, same reasoning as /// `pendingCommand`. var pendingRemovalCommand: RouterOSCommand? { guard let device = pendingStaticRemoval, device.hasLease else { return nil } return .remove( menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease", matchField: "mac-address", matchValue: device.macAddress, summary: "Feste Zuweisung entfernen für \(device.macAddress)" ) } /// Backs up once per connection before this tab's first write — same shared flag the Expert /// tool uses (`ConnectionService.hasExpertToolBackedUpThisSession`), so a session that already /// backed up via one "power tool" doesn't back up again via the other. private func ensureSessionBackup() async throws { guard !connectionService.hasExpertToolBackedUpThisSession, let credentials = connectionService.credentials else { return } _ = try await backupService.createBackup(for: credentials) connectionService.markExpertToolBackedUpThisSession() } func confirmStaticAssignment() async { guard let command = pendingCommand, let device = pendingStaticAssignment else { return } isApplying = true applyError = nil do { try await ensureSessionBackup() try await connectionService.apply(command) // Confirmed live: RouterOS' SSH CLI can run "make-static [find ...]" without any // error output while genuinely not converting anything (a mismatched selector) — "no // error" alone isn't proof it worked (same lesson as Bug 10 in HANDOFF.md, one level // deeper: even a real, syntactically valid, silently-successful-looking command can // still not have the intended effect). Verify the lease is now actually in the static // set before declaring success — by MAC, same reasoning as `pendingCommand`. let staticMacs = Set((try await connectionService.fetchFieldValues( menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease", whereField: "dynamic", whereValue: "no", returnField: "mac-address" )).map { $0.lowercased() }) guard staticMacs.contains(device.macAddress.lowercased()) else { throw RouterOSError.invalidResponse( "Befehl lief ohne Fehlermeldung, aber der Router zeigt den Eintrag weiterhin als dynamisch (geprüft über \"find dynamic=no\"). Bitte manuell mit \"/ip dhcp-server lease print\" kontrollieren." ) } pendingStaticAssignment = nil await load() } catch { // Clear this on failure too, not just success — otherwise the confirmationDialog's // `isPresented` binding (tied to `pendingStaticAssignment != nil`) stays "open" while // SwiftUI also tries to present the error `.alert`, and the two presentations can // conflict enough that the alert never actually becomes visible — a silent failure // that looked like nothing happened at all. pendingStaticAssignment = nil applyError = error.localizedDescription } isApplying = false } func cancelStaticAssignment() { pendingStaticAssignment = nil } func confirmStaticRemoval() async { guard let command = pendingRemovalCommand, let device = pendingStaticRemoval else { return } isApplying = true applyError = nil do { try await ensureSessionBackup() try await connectionService.apply(command) // Same lesson as `confirmStaticAssignment` (Bug 10/17 in HANDOFF.md): empty output // isn't proof it worked. Verify the lease for this MAC is actually gone — a lingering // "no error" success is worth less than an honest check. let remainingLeases = try await connectionService.fetchMenuItems( menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease" ) let stillPresent = remainingLeases.contains { $0.fields["mac-address"]?.lowercased() == device.macAddress.lowercased() } guard !stillPresent else { throw RouterOSError.invalidResponse( "Befehl lief ohne Fehlermeldung, aber der Lease-Eintrag ist weiterhin vorhanden. Bitte manuell mit \"/ip dhcp-server lease print\" kontrollieren." ) } pendingStaticRemoval = nil await load() } catch { pendingStaticRemoval = nil applyError = error.localizedDescription } isApplying = false } func cancelStaticRemoval() { pendingStaticRemoval = nil } func dismissApplyError() { applyError = nil } // MARK: - Pure device list construction (testable without a live router) nonisolated static func buildDevices( leases: [RouterOSMenuItem], arpEntries: [RouterOSMenuItem], bridgeHosts: [RouterOSMenuItem], interfaces: [RouterOSMenuItem], dhcpServers: [RouterOSMenuItem], staticMacAddresses: Set = [] ) -> [LanDevice] { var bridgeNames = Set() for item in interfaces where item.fields["type"] == "bridge" { if let name = item.fields["name"] { bridgeNames.insert(name) } } // Exact: the bridge host table names the actual physical port a MAC was learned on // behind a bridge (RouterOS field "on-interface" — confirmed against MikroTik's // Bridging and Switching docs, not guessed). var exactPortByMAC: [String: String] = [:] for item in bridgeHosts { guard let mac = item.fields["mac-address"]?.lowercased(), let onInterface = item.fields["on-interface"] else { continue } exactPortByMAC[mac] = onInterface } // Fallback: ARP's "interface" field — exact if that interface isn't a bridge, otherwise // only "somewhere on this bridge" (resolved further by the bridge host table above when // available). var arpInterfaceByMAC: [String: String] = [:] for item in arpEntries { guard let mac = item.fields["mac-address"]?.lowercased(), let iface = item.fields["interface"] else { continue } arpInterfaceByMAC[mac] = iface } // Last resort: the DHCP server's own configured interface — network-level only, since a // server can sit on a bridge shared by several physical ports. var interfaceByDHCPServer: [String: String] = [:] for item in dhcpServers { guard let name = item.fields["name"], let iface = item.fields["interface"] else { continue } interfaceByDHCPServer[name] = iface } func resolvePort(mac: String, fallbackServer: String?) -> (port: String?, network: String?) { if let exact = exactPortByMAC[mac] { return (exact, nil) } if let iface = arpInterfaceByMAC[mac] { return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil) } if let server = fallbackServer, let iface = interfaceByDHCPServer[server] { return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil) } return (nil, nil) } var devices: [LanDevice] = [] var seenMACs = Set() for (index, item) in leases.enumerated() { guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue } let mac = macRaw.lowercased() seenMACs.insert(mac) // Confirmed live on a real hEX (two independent checks, "/ip dhcp-server lease // print"'s flags column before and after converting a lease to static): "dynamic" is // never emitted by "print terse" for this menu, in EITHER state — not omitted only // for the common case, genuinely absent from the parseable output entirely. Reading // the field at all (this app's first two attempts) can't work. Static/dynamic here // instead comes from `staticMacAddresses`, fetched via RouterOS' `find dynamic=no` — // matched by MAC, not `.id`, because `.id` from `fetchMenuItems`' positional overlay // was confirmed live to mis-pair with the wrong lease for this exact menu (a static // lease's `.id` got attached to a different, still-dynamic lease's row, making it // wrongly show "Fest" too). let isStaticLease = staticMacAddresses.contains(mac) let server = item.fields["server"] let (port, network) = resolvePort(mac: mac, fallbackServer: server) devices.append(LanDevice( // Unique per row, not just the MAC — if RouterOS ever reports two lease entries // for the same MAC (observed: a device that should be static still showing // dynamic after conversion, plausibly because RouterOS created a fresh dynamic // lease alongside the newly-static one), both must render, not silently collapse // to one via a SwiftUI List/ForEach duplicate-id collision. id: "lease-\(index)-\(mac)", ipAddress: address, macAddress: macRaw, hostName: item.fields["host-name"], isStatic: isStaticLease, hasLease: true, leaseID: item.id, dhcpServerName: server, resolvedPort: port, networkHint: network, comment: item.fields["comment"], rawFields: [(key: ".id", value: item.id)] + item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) } )) } // ARP-only devices: seen on the network but with no matching DHCP lease (statically // configured outside DHCP, or a stray/foreign device). Shown read-only — there's no // lease item to convert, and guessing which DHCP server's network a static-IP device // "belongs to" isn't something this app will do. for (index, item) in arpEntries.enumerated() { guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue } let mac = macRaw.lowercased() guard !seenMACs.contains(mac) else { continue } seenMACs.insert(mac) let (port, network) = resolvePort(mac: mac, fallbackServer: nil) devices.append(LanDevice( id: "arp-\(index)-\(mac)", ipAddress: address, macAddress: macRaw, hostName: nil, // No lease at all here — "isStatic" (a DHCP *static reservation*) simply doesn't // apply, so this must not read as "Fest" (a prior version wrongly hardcoded // `true`, which mislabeled every ARP-only device — including ordinary DHCP // clients whose lease didn't get matched — as statically assigned). The Geräte // view branches on `hasLease` first precisely to keep this case visually distinct // from both "Fest" and "Dynamisch". isStatic: false, hasLease: false, leaseID: nil, dhcpServerName: nil, resolvedPort: port, networkHint: network, comment: nil, rawFields: item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) } )) } return devices.sorted { isIPLess(ipSortKey($0.ipAddress), ipSortKey($1.ipAddress)) } } /// Groups devices by physical port for the per-port tables, including physical ports with /// zero devices (so unused ports are visible too) and a catch-all group for devices whose /// port isn't known exactly (only a bridge/network — see `LanDevice.networkHint`). nonisolated static func buildPortGroups(devices: [LanDevice], interfaces: [RouterOSMenuItem]) -> [DevicePortGroup] { let physicalTypes: Set = ["ether", "wlan", "wifi", "wireless", "sfp", "sfp-sfpplus", "sfp28", "combo"] var physicalPortNames = interfaces .filter { physicalTypes.contains($0.fields["type"] ?? "") } .compactMap { $0.fields["name"] } physicalPortNames.sort(by: naturalPortOrder) var devicesByPort: [String: [LanDevice]] = [:] var otherDevices: [LanDevice] = [] for device in devices { if let port = device.resolvedPort { devicesByPort[port, default: []].append(device) } else { otherDevices.append(device) } } var groups = physicalPortNames.map { port in DevicePortGroup(id: port, title: port, devices: devicesByPort[port] ?? []) } // A resolved port RouterOS reported that isn't in the physical-interface snapshot // (shouldn't normally happen) still gets its own group, so no device silently vanishes. let knownPortNames = Set(physicalPortNames) for (port, portDevices) in devicesByPort.sorted(by: { $0.key < $1.key }) where !knownPortNames.contains(port) { groups.append(DevicePortGroup(id: port, title: port, devices: portDevices)) } if !otherDevices.isEmpty { groups.append(DevicePortGroup(id: "unbekannt", title: "Unbekannter Port", devices: otherDevices)) } return groups } private nonisolated static func naturalPortOrder(_ lhs: String, _ rhs: String) -> Bool { func split(_ value: String) -> (prefix: String, number: Int) { let digits = value.reversed().prefix(while: \.isNumber) let numberPart = String(digits.reversed()) let prefix = String(value.dropLast(numberPart.count)) return (prefix, Int(numberPart) ?? 0) } let left = split(lhs) let right = split(rhs) return left.prefix == right.prefix ? left.number < right.number : left.prefix < right.prefix } private nonisolated static func ipSortKey(_ address: String) -> [Int] { address.split(separator: ".").compactMap { Int($0) } } private nonisolated static func isIPLess(_ lhs: [Int], _ rhs: [Int]) -> Bool { for (left, right) in zip(lhs, rhs) where left != right { return left < right } return lhs.count < rhs.count } }