diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 447116b..816e619 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -26,10 +26,12 @@ import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.Permission import com.getcapacitor.annotation.PermissionCallback +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -265,76 +267,216 @@ class NetDiagScannerPlugin : Plugin() { /* --------------------------------------------------------------------- */ /* IP-Scan: Geräte im Subnetz finden */ + /* */ + /* Mehrgleisig (ROADMAP_UMSETZUNG.md Phase 3): ein Host gilt als gefunden, */ + /* wenn er auf Ping ODER eine kurze Port-Probe ODER einen ARP-Tabellen- */ + /* Eintrag antwortet. Die alte Fassung fragte nur ICMP ab und übersah */ + /* damit jedes Gerät mit aktiver Firewall (z.B. Windows-PC), das ICMP */ + /* blockt, aber TCP-Ports offen hat oder sich zumindest per ARP zeigt. */ + /* */ + /* Ablauf: Runde 1 (Ping+Port) über ALLE Adressen -> ARP-Tabelle lesen, */ + /* stille Adressen damit abgleichen -> Runde 2 NUR für die weiterhin */ + /* stillen Adressen (fängt Verluste durch kurze WLAN-Aussetzer ab) -> ARP- */ + /* Tabelle erneut lesen und abgleichen -> Anreicherung (Hostname, NetBIOS, */ + /* fehlende Portliste bei ARP-only-Treffern). */ + /* */ + /* Läuft als eigener Run mit `runId`: startIpScan liefert sofort {runId, */ + /* total} zurück, Fortschritt kommt über das Event `ipScanProgress`, das */ + /* Endergebnis über `ipScanFinished` (genau einmal, ob regulär durchgelaufen*/ + /* oder per cancelIpScan abgebrochen — dann mit `cancelled=true` und dem */ + /* bis dahin gefundenen Teilergebnis, keine leere Antwort). Eigener, auf */ + /* 128 begrenzter Dispatcher statt des geteilten Dispatchers.IO, damit ein */ + /* großer Sweep keinen gleichzeitig laufenden Ping/Monitor/Dauertest */ + /* ausbremst. */ /* --------------------------------------------------------------------- */ + private val ipScanDispatcher = Dispatchers.IO.limitedParallelism(128) + private val ipScanRuns = ConcurrentHashMap() + @PluginMethod - fun ipScan(call: PluginCall) { + fun startIpScan(call: PluginCall) { val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") val hosts = hostsInSubnet(subnet) if (hosts.isEmpty()) { return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") } - io.launch { + val runId = "ipscan-${System.currentTimeMillis()}" + + val job = io.launch { // Ohne wachgehaltenes Funkmodul faellt bei einem laengeren Sweep die // Trefferquote, weil das WLAN zwischendurch in den Sparmodus geht. val locks = acquireRadioLocks("ipscan", (hosts.size * 60L).coerceIn(30_000L, 600_000L)) + val startMs = System.currentTimeMillis() + val results = ConcurrentHashMap() + var arpReadable = false try { - // Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau, - // also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …). - val alive = withContext(Dispatchers.IO) { - hosts.map { ipInt -> - async { - val ip = intToIpv4(ipInt) - if (InetAddress.getByName(ip).isReachable(350)) ip else null - } - }.awaitAll().filterNotNull() - } - val arp = readArpTable() - // Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name, - // Quick-Port-Probe (für die Geräteart-Heuristik). - val enriched = withContext(Dispatchers.IO) { - alive.map { ip -> - async { - val hostname = try { - val n = InetAddress.getByName(ip).canonicalHostName - if (n != ip) n else "" - } catch (_: Exception) { "" } - EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip)) - } - }.awaitAll() - } - val devices = JSArray() - for (h in enriched) { - val dev = JSObject().put("ip", h.ip) - val mac = arp[h.ip] - val vendor = mac?.let { ouiVendor(it) } ?: "" - if (mac != null) dev.put("mac", mac) - if (vendor.isNotEmpty()) dev.put("vendor", vendor) - if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname) - if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios) - if (h.openPorts.isNotEmpty()) { - val pa = JSArray() - h.openPorts.sorted().forEach { pa.put(it) } - dev.put("openPorts", pa) + arpReadable = arpTableReadable() + + // done/total bewusst PRO RUNDE gezaehlt (eigener Zaehler je + // probeRound()-Aufruf), nicht ueber beide Runden hinweg + // aufsummiert — sonst zeigt die zweite Runde (nur die stillen + // Adressen aus Runde 1) einen "done" jenseits von "total" an + // (z.B. "504 von 254"), weil Runde 2 auf demselben Zaehler + // weiterlief statt neu bei 0 zu beginnen. + suspend fun probeRound(targets: List) { + if (targets.isEmpty()) return + val doneCount = java.util.concurrent.atomic.AtomicInteger(0) + val progressStep = (targets.size / 40).coerceAtLeast(1) + withContext(ipScanDispatcher) { + targets.map { ipInt -> + async { + val ip = intToIpv4(ipInt) + var alive = try { + InetAddress.getByName(ip).isReachable(900) + } catch (_: Exception) { false } + var method = if (alive) "ping" else "" + var ports: List = emptyList() + if (!alive) { + ports = quickPortProbe(ip) + if (ports.isNotEmpty()) { alive = true; method = "port" } + } + if (alive) results[ipInt] = EnrichedHost(ip, "", null, ports, method) + val d = doneCount.incrementAndGet() + if (d % progressStep == 0 || d == targets.size) { + // Live-Trefferliste (nur IPs, ohne Anreicherung — die kommt + // erst im Endergebnis) für "laufende Trefferliste" in der UI. + val foundIps = JSArray() + results.values.forEach { foundIps.put(it.ip) } + notifyListeners( + "ipScanProgress", + JSObject().put("runId", runId).put("done", d) + .put("total", targets.size).put("found", results.size) + .put("foundIps", foundIps), + ) + } + } + }.awaitAll() } - val nameHint = h.hostname.ifEmpty { h.netbios ?: "" } - val type = guessDeviceType(vendor, nameHint, h.openPorts) - if (type.isNotEmpty()) dev.put("deviceType", type) - devices.put(dev) } - resolve(call, JSObject() - .put("devices", devices) - // Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein - // gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden. - .put("probed", hosts.size) - .put("answered", alive.size) - .put("arpAvailable", arp.isNotEmpty())) + + fun reconcileArp() { + val arp = readArpTable() + for (ipInt in hosts) { + if (results.containsKey(ipInt)) continue + val mac = arp[intToIpv4(ipInt)] + if (mac != null) { + results[ipInt] = EnrichedHost(intToIpv4(ipInt), "", null, emptyList(), "arp") + } + } + } + + probeRound(hosts) + reconcileArp() + val silent = hosts.filter { !results.containsKey(it) } + probeRound(silent) // zweite Runde NUR fuer die stillen Adressen + reconcileArp() + + finishIpScan(runId, results, hosts.size, arpReadable, startMs, cancelled = false) + } catch (e: CancellationException) { + // NonCancellable: Anreicherung + Abschluss-Event brauchen noch + // Suspend-Aufrufe (withContext/InetAddress), die nach einer + // Job-Stornierung sonst sofort selbst wieder abbrechen wuerden — + // das Teilergebnis soll trotz Abbruch beim Aufrufer ankommen. + withContext(NonCancellable) { + finishIpScan(runId, results, hosts.size, arpReadable, startMs, cancelled = true) + } } catch (e: Exception) { - call.reject("ipScan: ${e.message}") + android.util.Log.e(TAG, "ipScan ($runId): ${e.message}", e) + notifyListeners( + "ipScanFinished", + JSObject().put("runId", runId).put("devices", JSArray()) + .put("probed", hosts.size).put("answered", 0) + .put("arpAvailable", arpReadable) + .put("durationMs", System.currentTimeMillis() - startMs) + .put("cancelled", false) + .put("error", e.message ?: "unbekannter Fehler"), + ) } finally { releaseRadioLocks(locks) + ipScanRuns.remove(runId) } } + ipScanRuns[runId] = job + resolve(call, JSObject().put("runId", runId).put("total", hosts.size)) + } + + /** Laufenden IP-Scan abbrechen — das bis dahin gefundene Teilergebnis kommt regulär über `ipScanFinished`. */ + @PluginMethod + fun cancelIpScan(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + ipScanRuns[runId]?.cancel() + resolve(call, JSObject().put("ok", true)) + } + + /** + * Anreicherung (Hostname, NetBIOS, fehlende Portliste bei ARP-only-Treffern) + * und Abschluss-Event eines IP-Scans — gemeinsamer Pfad für regulären + * Abschluss und Abbruch, damit in beiden Faellen dieselben Diagnosefelder + * geliefert werden. + */ + private suspend fun finishIpScan( + runId: String, + results: Map, + probed: Int, + arpReadable: Boolean, + startMs: Long, + cancelled: Boolean, + ) { + val enrichedFinal = withContext(ipScanDispatcher) { + results.values.map { h -> + async { + val hostname = try { + val n = InetAddress.getByName(h.ip).canonicalHostName + if (n != h.ip) n else "" + } catch (_: Exception) { "" } + val netbios = netbiosName(h.ip) + val ports = if (h.openPorts.isEmpty()) quickPortProbe(h.ip) else h.openPorts + h.copy(hostname = hostname, netbios = netbios, openPorts = ports) + } + }.awaitAll() + } + val arpFinal = readArpTable() + val devices = JSArray() + for (h in enrichedFinal.sortedBy { ipv4ToInt(it.ip) ?: 0 }) { + val dev = JSObject().put("ip", h.ip).put("foundVia", h.method) + val mac = arpFinal[h.ip] + val vendor = mac?.let { ouiVendor(it) } ?: "" + if (mac != null) dev.put("mac", mac) + if (vendor.isNotEmpty()) dev.put("vendor", vendor) + if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname) + if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios) + if (h.openPorts.isNotEmpty()) { + val pa = JSArray() + h.openPorts.sorted().forEach { pa.put(it) } + dev.put("openPorts", pa) + } + val nameHint = h.hostname.ifEmpty { h.netbios ?: "" } + val type = guessDeviceType(vendor, nameHint, h.openPorts) + if (type.isNotEmpty()) dev.put("deviceType", type) + devices.put(dev) + } + notifyListeners( + "ipScanFinished", + JSObject() + .put("runId", runId) + .put("devices", devices) + // Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein + // gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden. + .put("probed", probed) + .put("answered", results.size) + .put("arpAvailable", arpReadable) + .put("durationMs", System.currentTimeMillis() - startMs) + .put("cancelled", cancelled), + ) + } + + /** Ist /proc/net/arp fuer diesen Prozess lesbar? (ab Android 10 haeufig nicht) */ + private fun arpTableReadable(): Boolean { + return try { + File("/proc/net/arp").canRead() + } catch (_: Exception) { + false + } } /** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */ @@ -343,6 +485,8 @@ class NetDiagScannerPlugin : Plugin() { val hostname: String, val netbios: String?, val openPorts: List, + /** Fundweg: "ping" | "port" | "arp" */ + val method: String = "", ) /** @@ -452,10 +596,13 @@ class NetDiagScannerPlugin : Plugin() { */ @PluginMethod fun mdnsScan(call: PluginCall) { - val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong() + // 8-10 s Budget (ROADMAP_UMSETZUNG.md Phase 3) statt der alten 4 s — die + // meisten mDNS-Antworten trudeln erst nach 2-5 s ein, viele Drucker/ + // Kameras antworten erst auf eine zweite Anfrage-Runde. + val timeoutMs = ((call.getInt("timeoutMs") ?: 9000).toLong()).coerceIn(3000L, 15000L) io.launch { try { - val found = discoverMdns(timeoutMs) + val (found, discoveryOk) = discoverMdns(timeoutMs) val arr = JSArray() for ((ip, info) in found) { val services = JSArray() @@ -465,7 +612,12 @@ class NetDiagScannerPlugin : Plugin() { .put("name", info.name) .put("services", services)) } - resolve(call, JSObject().put("devices", arr)) + resolve(call, JSObject() + .put("devices", arr) + // false = die Dienstsuche konnte fuer KEINEN einzigen Diensttyp + // gestartet werden (z.B. NSD nicht verfuegbar) — etwas anderes + // als "gesucht, aber nichts gefunden". + .put("discoveryOk", discoveryOk)) } catch (e: Exception) { call.reject("mdnsScan: ${e.message}") } @@ -477,8 +629,18 @@ class NetDiagScannerPlugin : Plugin() { val services: MutableSet = ConcurrentHashMap.newKeySet() } + /** + * mDNS/Bonjour-Suche. Discovery und Auflösung sind entkoppelt: die Suche + * läuft das VOLLE Budget, die Auflösung bekommt danach noch eine kurze + * Nachlaufzeit (`resolveGraceMs`) — sonst gingen Dienste, die erst gegen + * Ende der Suchzeit gemeldet wurden, unaufgelöst verloren (die alte + * Fassung teilte sich Suche UND Auflösung dieselbe Deadline). + * + * @return gefundene Geräte + ob die Suche für mindestens einen Diensttyp + * überhaupt starten konnte (ehrliches Fehlersignal statt stumm 0). + */ @Suppress("DEPRECATION") - private fun discoverMdns(timeoutMs: Long): Map { + private fun discoverMdns(timeoutMs: Long): Pair, Boolean> { val nsd = context.applicationContext .getSystemService(Context.NSD_SERVICE) as NsdManager val wifi = context.applicationContext @@ -491,6 +653,7 @@ class NetDiagScannerPlugin : Plugin() { val result = ConcurrentHashMap() val pending = ConcurrentLinkedQueue() val listeners = ArrayList() + var anyStarted = false val mlock = wifi.createMulticastLock("netdiag-mdns").apply { setReferenceCounted(true) try { acquire() } catch (_: Exception) { } @@ -508,14 +671,20 @@ class NetDiagScannerPlugin : Plugin() { try { nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l) listeners.add(l) + anyStarted = true } catch (_: Exception) { } } // Gefundene Dienste seriell auflösen — NsdManager.resolveService // verträgt keine parallelen Aufrufe. - val deadline = System.currentTimeMillis() + timeoutMs - while (System.currentTimeMillis() < deadline) { + val discoveryDeadline = System.currentTimeMillis() + timeoutMs + val resolveGraceMs = 1500L + val resolveDeadline = discoveryDeadline + resolveGraceMs + while (System.currentTimeMillis() < resolveDeadline) { val info = pending.poll() if (info == null) { + // Suchzeit vorbei UND nichts mehr in der Warteschlange -> fertig, + // nicht bis zum Ende der Nachlaufzeit sinnlos weiterschlafen. + if (System.currentTimeMillis() >= discoveryDeadline && pending.isEmpty()) break Thread.sleep(100) continue } @@ -543,7 +712,7 @@ class NetDiagScannerPlugin : Plugin() { } try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { } } - return result + return result to anyStarted } /** diff --git a/src/lib/components/DeviceCard.svelte b/src/lib/components/DeviceCard.svelte index a1c64b2..c2ee329 100644 --- a/src/lib/components/DeviceCard.svelte +++ b/src/lib/components/DeviceCard.svelte @@ -33,6 +33,14 @@ const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess']; const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500']; + /** Fundweg des letzten IP-Scans (device.foundVia) als Klartext */ + const foundViaLabel: Record = { + ping: 'Ping', + port: 'Port', + arp: 'ARP', + mdns: 'mDNS', + }; + /** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */ const title = $derived( device.customName || @@ -84,7 +92,7 @@ - {#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length} + {#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length || device.foundVia}
{#if device.deviceType} @@ -97,6 +105,14 @@ {#each device.mdnsServices ?? [] as svc (svc)} {svc} {/each} + {#if device.foundVia} + + via {foundViaLabel[device.foundVia] ?? device.foundVia} + + {/if}
{/if} diff --git a/src/lib/components/ToolDialog.svelte b/src/lib/components/ToolDialog.svelte index 3956481..6bcd389 100644 --- a/src/lib/components/ToolDialog.svelte +++ b/src/lib/components/ToolDialog.svelte @@ -1,9 +1,11 @@ - diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index b444f1a..a7aee7f 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -22,6 +22,33 @@ export interface ScannedDevice { netbiosName?: string; /** offene Ports aus der Quick-Port-Probe */ openPorts?: number[]; + /** wie das Gerät gefunden wurde: 'ping' | 'port' | 'arp' */ + foundVia?: string; +} +/** Fortschritt eines laufenden IP-Scans (Live-Event `ipScanProgress`) */ +export interface IpScanProgressEvent { + runId: string; + done: number; + total: number; + found: number; + /** bisher gefundene IPs (noch ohne Anreicherung — die kommt erst im Endergebnis) */ + foundIps: string[]; +} +/** Endergebnis eines IP-Scans (Event `ipScanFinished`, genau einmal pro Lauf) */ +export interface IpScanResult { + runId: string; + devices: ScannedDevice[]; + /** wie viele Adressen geprueft wurden */ + probed: number; + /** wie viele davon geantwortet haben (Ping, Port oder ARP) */ + answered: number; + /** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */ + arpAvailable: boolean; + durationMs: number; + /** true = per cancelIpScan abgebrochen — devices ist dann ein Teilergebnis */ + cancelled: boolean; + /** gesetzt bei einem echten Fehler (z.B. Ausnahme im nativen Scan) */ + error?: string; } /** Ein per mDNS/Bonjour gefundenes Gerät */ export interface MdnsDevice { @@ -179,18 +206,22 @@ export interface NetDiagScannerPlugin { /** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */ prefixQuelle?: string; }>; - /** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */ - ipScan(opts: { subnet: string }): Promise<{ - devices: ScannedDevice[]; - /** wie viele Adressen geprueft wurden */ - probed?: number; - /** wie viele davon geantwortet haben */ - answered?: number; - /** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */ - arpAvailable?: boolean; - }>; + /** + * IP-Scan starten: Geräte im Subnetz finden (mehrgleisig — Ping, Port-Probe, + * ARP-Abgleich). Liefert sofort die Laufkennung + Adressenzahl zurück; + * Fortschritt kommt über das Event `ipScanProgress`, das Endergebnis über + * `ipScanFinished` (genau einmal, ob regulär durchgelaufen oder per + * `cancelIpScan` abgebrochen). + */ + startIpScan(opts: { subnet: string }): Promise<{ runId: string; total: number }>; + /** Laufenden IP-Scan abbrechen — das Teilergebnis kommt regulär über `ipScanFinished` */ + cancelIpScan(opts: { runId: string }): Promise<{ ok: boolean }>; /** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */ - mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>; + mdnsScan(opts: { timeoutMs?: number }): Promise<{ + devices: MdnsDevice[]; + /** false = die Suche konnte fuer KEINEN Diensttyp gestartet werden (echter Fehler, nicht "nichts gefunden") */ + discoveryOk?: boolean; + }>; /** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */ arpConflictScan(opts: { subnet: string; @@ -360,6 +391,79 @@ function finishMockStress(reason: 'completed' | 'stopped'): StressResult { return result; } +/* --- IP-Scan: Ereignis-Verteilung + Mock-Simulation --- */ +const ipScanProgressListeners = new Set<(e: IpScanProgressEvent) => void>(); +const ipScanFinishedListeners = new Set<(e: IpScanResult) => void>(); +let mockIpScanTimer: ReturnType | undefined; +let mockIpScanRunId = ''; +let mockIpScanTotal = 0; + +const MOCK_IP_SCAN_DEVICES: ScannedDevice[] = [ + { + ip: '192.168.1.1', + mac: 'AA:BB:CC:00:00:01', + hostname: 'fritzbox', + vendor: 'AVM', + deviceType: 'Router', + openPorts: [53, 80, 443], + foundVia: 'ping', + }, + { + ip: '192.168.1.10', + mac: 'AA:BB:CC:00:00:0A', + hostname: 'switch-keller', + vendor: 'TP-Link', + deviceType: 'Switch', + openPorts: [80], + foundVia: 'ping', + }, + { + ip: '192.168.1.40', + mac: 'AA:BB:CC:00:00:28', + hostname: 'ipcam-hof', + vendor: 'Hikvision', + deviceType: 'Kamera', + openPorts: [80, 554], + foundVia: 'port', + }, + { + ip: '192.168.1.50', + mac: 'AA:BB:CC:00:00:32', + hostname: 'handy', + vendor: 'Samsung', + deviceType: '', + openPorts: [], + foundVia: 'ping', + }, + { + ip: '192.168.1.77', + mac: 'AA:BB:CC:00:00:4D', + hostname: 'wallbox', + vendor: '', + netbiosName: 'WALLBOX', + deviceType: 'Wallbox', + openPorts: [80, 502], + foundVia: 'arp', + }, +]; + +/** Mock-IP-Scan beenden: Timer stoppen, Endergebnis bauen, ipScanFinished feuern. */ +function finishMockIpScan(runId: string, total: number, cancelled: boolean): IpScanResult { + if (mockIpScanTimer) clearInterval(mockIpScanTimer); + mockIpScanTimer = undefined; + const result: IpScanResult = { + runId, + devices: MOCK_IP_SCAN_DEVICES, + probed: total, + answered: MOCK_IP_SCAN_DEVICES.length, + arpAvailable: true, + durationMs: 3000, + cancelled, + }; + ipScanFinishedListeners.forEach((cb) => cb(result)); + return result; +} + const mock: NetDiagScannerPlugin = { async getLocalSubnet() { return { @@ -370,52 +474,34 @@ const mock: NetDiagScannerPlugin = { prefixQuelle: 'adapter', }; }, - async ipScan() { - return { - devices: [ - { - ip: '192.168.1.1', - mac: 'AA:BB:CC:00:00:01', - hostname: 'fritzbox', - vendor: 'AVM', - deviceType: 'Router', - openPorts: [53, 80, 443], - }, - { - ip: '192.168.1.10', - mac: 'AA:BB:CC:00:00:0A', - hostname: 'switch-keller', - vendor: 'TP-Link', - deviceType: 'Switch', - openPorts: [80], - }, - { - ip: '192.168.1.40', - mac: 'AA:BB:CC:00:00:28', - hostname: 'ipcam-hof', - vendor: 'Hikvision', - deviceType: 'Kamera', - openPorts: [80, 554], - }, - { - ip: '192.168.1.50', - mac: 'AA:BB:CC:00:00:32', - hostname: 'handy', - vendor: 'Samsung', - deviceType: '', - openPorts: [], - }, - { - ip: '192.168.1.77', - mac: 'AA:BB:CC:00:00:4D', - hostname: 'wallbox', - vendor: '', - netbiosName: 'WALLBOX', - deviceType: 'Wallbox', - openPorts: [80, 502], - }, - ], - }; + async startIpScan(opts) { + const runId = 'mock-ipscan-' + Date.now(); + const total = 254; + mockIpScanRunId = runId; + mockIpScanTotal = total; + let done = 0; + // Simuliert Fortschritt in Schuben, wie der native Sweep — Treffer + // wachsen proportional mit, damit die Live-Trefferliste im Browser-Dev + // etwas zu sehen bekommt. + mockIpScanTimer = setInterval(() => { + done = Math.min(total, done + Math.round(total / 12)); + const foundCount = Math.min( + MOCK_IP_SCAN_DEVICES.length, + Math.ceil((done / total) * MOCK_IP_SCAN_DEVICES.length), + ); + const foundIps = MOCK_IP_SCAN_DEVICES.slice(0, foundCount).map((d) => d.ip); + ipScanProgressListeners.forEach((cb) => cb({ runId, done, total, found: foundCount, foundIps })); + if (done >= total) { + finishMockIpScan(runId, total, false); + } + }, 250); + return { runId, total }; + }, + async cancelIpScan() { + if (mockIpScanTimer) { + finishMockIpScan(mockIpScanRunId, mockIpScanTotal, true); + } + return { ok: true }; }, async mdnsScan() { return { @@ -424,6 +510,7 @@ const mock: NetDiagScannerPlugin = { { ip: '192.168.1.30', name: 'Wohnzimmer-TV', services: ['_googlecast._tcp'] }, { ip: '192.168.1.40', name: 'IP-Kamera Hof', services: ['_rtsp._tcp'] }, ], + discoveryOk: true, }; }, async arpConflictScan() { @@ -744,3 +831,50 @@ export function onStressFinished(cb: (e: StressResult) => void): () => void { stressFinishedListeners.delete(cb); }; } + +/** + * Auf den Fortschritt eines laufenden IP-Scans hören (x von y geprüft, live + * gefunden). Gibt die Abmeldefunktion zurück. + */ +export function onIpScanProgress(cb: (e: IpScanProgressEvent) => void): () => void { + if (Capacitor.isNativePlatform()) { + const handle = ( + native as unknown as { + addListener( + name: string, + cb: (e: IpScanProgressEvent) => void, + ): Promise; + } + ).addListener('ipScanProgress', cb); + return () => { + void handle.then((h) => h.remove()); + }; + } + ipScanProgressListeners.add(cb); + return () => { + ipScanProgressListeners.delete(cb); + }; +} + +/** + * Auf das Ende eines IP-Scans hören — feuert genau einmal, egal ob der Lauf + * regulär durchlief oder per `cancelIpScan` abgebrochen wurde (dann mit + * `cancelled=true` und dem bis dahin gefundenen Teilergebnis). Gibt die + * Abmeldefunktion zurück. + */ +export function onIpScanFinished(cb: (e: IpScanResult) => void): () => void { + if (Capacitor.isNativePlatform()) { + const handle = ( + native as unknown as { + addListener(name: string, cb: (e: IpScanResult) => void): Promise; + } + ).addListener('ipScanFinished', cb); + return () => { + void handle.then((h) => h.remove()); + }; + } + ipScanFinishedListeners.add(cb); + return () => { + ipScanFinishedListeners.delete(cb); + }; +} diff --git a/src/lib/tools/netzwerk/ipscan.ts b/src/lib/tools/netzwerk/ipscan.ts index 18c6bb9..90c209a 100644 --- a/src/lib/tools/netzwerk/ipscan.ts +++ b/src/lib/tools/netzwerk/ipscan.ts @@ -6,12 +6,25 @@ * Protokoll hinterlegte Netzbereich genutzt; ist auch der leer, * fragt das Tool den aktiven WLAN-/LAN-Adapter ab und scannt * dessen Subnetz direkt. + * + * Mehrgleisig (ROADMAP_UMSETZUNG.md Phase 3): der native Scan gilt ein Gerät + * als gefunden, wenn es auf Ping ODER eine kurze Port-Probe ODER einen + * ARP-Tabellen-Eintrag antwortet — plus mDNS/Bonjour hier oben drauf. Läuft + * als eigener Lauf mit Live-Fortschritt/Abbruch (`ctx.onProgress`/ + * `ctx.isCancelled`, siehe ToolDialog) statt eines einzigen langen Promise + * ohne jede Rückmeldung. */ -import { scanner, type MdnsDevice } from '../../scanner'; +import { + scanner, + onIpScanProgress, + onIpScanFinished, + type MdnsDevice, + type IpScanResult, +} from '../../scanner'; import { debugLog } from '../../debuglog.svelte'; import type { Device } from '../../types'; -import type { Tool } from '../types'; +import type { Tool, ToolContext, ToolPreview } from '../types'; /** Geräteart aus den angebotenen mDNS-Diensten ableiten */ function typeFromMdns(services: string[]): string { @@ -26,17 +39,126 @@ function typeFromMdns(services: string[]): string { return ''; } +/** IP-Adressen numerisch vergleichen (nicht als Text — "192.168.1.9" vor "192.168.1.10") */ +function ipCompare(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 4; i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** Anzahl der Host-Adressen eines CIDR-Bereichs (fürs Vorschau-Fenster — muss nicht bis auf /31 exakt sein) */ +function hostCountForCidr(cidr: string): number | null { + const parts = cidr.trim().split('/'); + const prefix = parts.length > 1 ? Number(parts[1].trim()) : 24; + if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32) return null; + if (prefix >= 31) return 2 ** (32 - prefix); + const count = 2 ** (32 - prefix) - 2; + return count > 0 ? count : 0; +} + +/** + * Netzbereich nach derselben Regel wie `run()` ermitteln: Dialog-Eingabe → + * Protokoll-Feld → aktiver Adapter. Wird von `preview()` UND `run()` + * unabhängig aufgerufen (ein doppelter `getLocalSubnet()`-Aufruf bei leerem + * Feld ist unkritisch, < 100 ms) — so funktioniert `run()` auch dann korrekt, + * wenn ein Aufrufer die Vorschau übersprungen hat. + */ +async function resolveSubnet( + ctx: ToolContext, +): Promise<{ subnet: string; source: 'eingabe' | 'protokoll' | 'adapter'; ip: string; gateway: string }> { + const dialogValue = String(ctx.params.subnet ?? '').trim(); + let subnet = dialogValue || String(ctx.protocol.subnet ?? '').trim(); + const source: 'eingabe' | 'protokoll' | 'adapter' = dialogValue + ? 'eingabe' + : subnet + ? 'protokoll' + : 'adapter'; + let ip = ''; + let gateway = ''; + if (!subnet) { + try { + const local = await scanner.getLocalSubnet(); + subnet = String(local.subnet ?? '').trim(); + ip = local.ip ?? ''; + gateway = local.gateway ?? ''; + } catch { + /* kein Adapter ermittelbar — Aufrufer behandelt leeres subnet */ + } + } else { + // Auch bei Eingabe/Protokoll-Quelle den Adapter fragen — nur für die + // Vorschau-Anzeige (eigene IP/Gateway), best effort, kein Fehlerfall. + try { + const local = await scanner.getLocalSubnet(); + ip = local.ip ?? ''; + gateway = local.gateway ?? ''; + } catch { + /* egal, Vorschau zeigt dann nur den Netzbereich */ + } + } + return { subnet, source, ip, gateway }; +} + +/** + * IP-Scan starten und auf das Endergebnis (`ipScanFinished`) warten, dabei + * Fortschritt an `ctx.onProgress` durchreichen und `ctx.isCancelled()` + * periodisch abfragen — wird die true, `cancelIpScan` auslösen. Liefert auch + * bei Abbruch regulär auf (kein Reject), das Teilergebnis kommt über + * `ipScanFinished`. + */ +function runIpScan(subnet: string, ctx: ToolContext): Promise { + return new Promise((resolve, reject) => { + let offProgress = () => {}; + let offFinished = () => {}; + let cancelTimer: ReturnType | undefined; + let cancelSent = false; + + function cleanup() { + offProgress(); + offFinished(); + if (cancelTimer) clearInterval(cancelTimer); + } + + scanner + .startIpScan({ subnet }) + .then(({ runId }) => { + offProgress = onIpScanProgress((e) => { + if (e.runId !== runId) return; + ctx.onProgress?.({ done: e.done, total: e.total, found: e.found, foundIps: e.foundIps }); + }); + offFinished = onIpScanFinished((e) => { + if (e.runId !== runId) return; + cleanup(); + resolve(e); + }); + if (ctx.isCancelled) { + cancelTimer = setInterval(() => { + if (cancelSent) return; + if (ctx.isCancelled?.()) { + cancelSent = true; + scanner.cancelIpScan({ runId }).catch(() => {}); + } + }, 300); + } + }) + .catch((e) => { + cleanup(); + reject(e instanceof Error ? e : new Error(String(e))); + }); + }); +} + export const ipScanTool: Tool = { id: 'ipscan', category: 'netzwerk', name: 'IP-Scanner', icon: 'radar', - // Ehrliche Beschreibung: gefunden wird ein Gerät nur, wenn es auf Ping - // antwortet — oder wenn es sich per mDNS meldet. Die ARP-Tabelle wird - // lediglich nachgeschlagen, um MAC/Hersteller zu ergänzen, und ist ab - // Android 10 meist gar nicht lesbar. Mehrgleisige Suche: Phase 3. - description: 'Sucht Geräte, die auf Ping antworten oder sich per mDNS melden.', + description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.', scope: 'protocol', + supportsProgress: true, params: [ { key: 'subnet', @@ -45,24 +167,36 @@ export const ipScanTool: Tool = { placeholder: 'leer lassen → automatisch über WLAN/LAN', }, ], - async run(ctx) { - // 1. Dialog-Eingabe 2. im Protokoll hinterlegter Netzbereich - let subnet = String(ctx.params.subnet ?? '').trim() || String(ctx.protocol.subnet ?? '').trim(); - let source: 'eingabe' | 'protokoll' | 'adapter' = subnet - ? String(ctx.params.subnet ?? '').trim() - ? 'eingabe' - : 'protokoll' - : 'adapter'; - // 3. Nichts angegeben → aktiven Adapter abfragen und dessen Subnetz scannen + async preview(ctx): Promise { + const { subnet, source, ip, gateway } = await resolveSubnet(ctx); if (!subnet) { - try { - const local = await scanner.getLocalSubnet(); - subnet = String(local.subnet ?? '').trim(); - } catch { - /* kein Adapter ermittelbar — unten abgefangen */ - } + return { + lines: [{ label: 'Netzbereich', value: 'nicht ermittelbar' }], + warning: 'Kein aktives WLAN/LAN gefunden — Netzbereich von Hand eintragen.', + confirmLabel: 'Trotzdem versuchen', + }; } + const count = hostCountForCidr(subnet); + const quelle = + source === 'adapter' ? ' (Adapter erkannt)' : source === 'protokoll' ? ' (aus Protokoll)' : ''; + const lines: ToolPreview['lines'] = [{ label: 'Netzbereich', value: `${subnet}${quelle}` }]; + if (ip) lines.push({ label: 'Eigene IP', value: ip }); + if (gateway) lines.push({ label: 'Gateway', value: gateway }); + lines.push({ label: 'Zu prüfende Adressen', value: count != null ? String(count) : 'unbekannt' }); + + const big = count != null && count > 1024; + return { + lines, + warning: big + ? `Große Suche: ${count} Adressen — kann über eine Minute dauern und den Akku/WLAN-Funk stärker beanspruchen.` + : undefined, + confirmLabel: big ? 'Trotzdem scannen' : 'Scan starten', + }; + }, + + async run(ctx) { + const { subnet, source } = await resolveSubnet(ctx); if (!subnet) { return { @@ -72,10 +206,10 @@ export const ipScanTool: Tool = { }; } - // Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer - if (!String(ctx.protocol.subnet ?? '').trim()) { - ctx.protocol.subnet = subnet; - } + // Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer — + // als Patch, NICHT durch direktes Beschreiben von ctx.protocol (der + // Aufrufer wendet das nach dem Lauf gezielt an, siehe ToolRunResult). + const protocolPatch = !String(ctx.protocol.subnet ?? '').trim() ? { subnet } : undefined; debugLog.add( 'info', @@ -83,20 +217,56 @@ export const ipScanTool: Tool = { `Protokoll-Subnetz="${String(ctx.protocol.subnet ?? '')}" → ` + `gescannt wird "${subnet}" (Quelle: ${source})`, ); - const { devices } = await scanner.ipScan({ subnet }); + + const previousIps = new Set(ctx.protocol.devices.map((d) => d.ip)); + + let scanResult: IpScanResult; + try { + scanResult = await runIpScan(subnet, ctx); + } catch (e) { + // Ein gescheiterter Scan ist trotzdem eine Messung — kein stilles Nichts, + // sonst sieht man im Protokoll nicht, dass hier überhaupt etwas versucht wurde. + const msg = e instanceof Error ? e.message : String(e); + debugLog.add('error', `IP-Scan (${subnet}) fehlgeschlagen:`, e); + return { + label: `IP-Scan fehlgeschlagen: ${msg}`, + result: { subnet, fehler: msg }, + measureStatus: 2, + protocolPatch, + }; + } + + if (scanResult.error) { + return { + label: `IP-Scan fehlgeschlagen: ${scanResult.error}`, + result: { + subnet, + fehler: scanResult.error, + probed: scanResult.probed, + arpAvailable: scanResult.arpAvailable, + }, + measureStatus: 2, + protocolPatch, + }; + } // mDNS/Bonjour zusätzlich abfragen — liefert sprechende Namen und findet - // Geräte, die nicht auf Ping antworten (manche Kameras/Drucker). Best-Effort. + // Geräte, die weder auf Ping/Port noch per ARP auffallen (manche Kameras/ + // Drucker). Best-Effort: discoveryOk=false wird als Warnung vermerkt, + // macht den IP-Scan selbst aber nicht ungültig. let mdns: MdnsDevice[] = []; + let mdnsOk = true; try { - mdns = (await scanner.mdnsScan({ timeoutMs: 4000 })).devices; + const r = await scanner.mdnsScan({ timeoutMs: 9000 }); + mdns = r.devices; + mdnsOk = r.discoveryOk !== false; } catch { - /* mDNS fehlgeschlagen — IP-Scan bleibt trotzdem gültig */ + mdnsOk = false; } const mdnsByIp = new Map(mdns.map((m) => [m.ip, m])); // Beide Quellen per IP zusammenführen - const merged: (Partial & { ip: string })[] = devices.map((d) => { + const merged: (Partial & { ip: string })[] = scanResult.devices.map((d) => { const m = mdnsByIp.get(d.ip); if (!m) return d; return { @@ -115,20 +285,58 @@ export const ipScanTool: Tool = { mdnsName: m.name, mdnsServices: m.services, deviceType: typeFromMdns(m.services), + foundVia: 'mdns', }); } + merged.sort((a, b) => ipCompare(a.ip, b.ip)); + + // "Neu" / "Nicht mehr erreichbar" — Vergleich mit dem VOR diesem Lauf im + // Protokoll bekannten Gerätebestand (Diagnosefeld + sprechendes Label). + const neu = merged + .filter((d) => !previousIps.has(d.ip)) + .map((d) => d.ip) + .sort(ipCompare); + const gefundeneIps = new Set(merged.map((d) => d.ip)); + const nichtMehrErreichbar = ctx.protocol.devices + .filter((d) => !gefundeneIps.has(d.ip)) + .map((d) => d.ip) + .sort(ipCompare); + + const teile = [`${merged.length} Geräte im Netz ${subnet}`]; + if (source === 'adapter') teile.push('Adapter erkannt'); + if (scanResult.cancelled) teile.push('abgebrochen — Teilergebnis'); + if (neu.length) teile.push(`${neu.length} neu`); + if (nichtMehrErreichbar.length) teile.push(`${nichtMehrErreichbar.length} nicht mehr erreichbar`); + if (!mdnsOk) teile.push('mDNS-Suche fehlgeschlagen'); debugLog.add( 'info', `IP-Scan Ergebnis: ${merged.length} Geräte in ${subnet} ` + - `(${devices.length} per Ping/ARP, ${mdns.length} per mDNS)`, + `(${scanResult.probed} geprüft, ${scanResult.answered} geantwortet, ` + + `${scanResult.durationMs} ms, arpAvailable=${scanResult.arpAvailable}, ` + + `cancelled=${scanResult.cancelled}, mdns=${mdns.length}/${mdnsOk ? 'ok' : 'fehler'})`, ); - const via = source === 'adapter' ? ' (Adapter erkannt)' : ''; + return { - label: `${merged.length} Geräte im Netz ${subnet}${via}`, - result: { subnet, count: merged.length, quelle: source }, - measureStatus: merged.length > 0 ? 0 : 1, + label: teile.join(' · '), + result: { + subnet, + quelle: source, + count: merged.length, + probed: scanResult.probed, + answered: scanResult.answered, + arpAvailable: scanResult.arpAvailable, + durationMs: scanResult.durationMs, + abgebrochen: scanResult.cancelled, + neu, + nichtMehrErreichbar, + mdnsOk, + }, + // Abgebrochen mit Teilergebnis oder nichts gefunden -> Warnung statt OK; + // ein bewusst abgebrochener Scan ist kein Fehler, aber eben unvollständig. + measureStatus: scanResult.cancelled ? 1 : merged.length > 0 ? 0 : 1, devices: merged, + protocolPatch, }; }, }; diff --git a/src/lib/tools/types.ts b/src/lib/tools/types.ts index 2e9c208..ebd009e 100644 --- a/src/lib/tools/types.ts +++ b/src/lib/tools/types.ts @@ -25,12 +25,39 @@ export interface ToolParamField { placeholder?: string; } +/** Eine Zeile der Vorschau vor dem Start eines Tools */ +export interface ToolPreviewLine { + label: string; + value: string; +} + +/** + * Vorschau, die `ToolDialog` vor dem eigentlichen Lauf anzeigt (z.B. IP-Scan: + * Adapter, eigene IP, Maske, Gateway, Anzahl der zu prüfenden Adressen). + * Mit `warning` gesetzt zeigt der Dialog eine zweite Bestätigung, bevor der + * Lauf wirklich startet (z.B. bei einem sehr großen Adressbereich). + */ +export interface ToolPreview { + lines: ToolPreviewLine[]; + warning?: string; + /** Beschriftung des Bestätigen-Buttons, wenn `warning` gesetzt ist (Default: "Trotzdem starten") */ + confirmLabel?: string; +} + /** Kontext, mit dem ein Tool ausgeführt wird */ export interface ToolContext { params: Record; protocol: Protocol; /** gesetzt, wenn das Tool für ein einzelnes Gerät läuft (scope 'device') */ device?: Device; + /** + * Fortschritt melden — nur von Tools genutzt, die live Fortschritt liefern + * (z.B. IP-Scanner). `ToolDialog` zeigt bei `tool.supportsProgress` eine + * Fortschrittsanzeige + laufende Trefferliste statt des einfachen Spinners. + */ + onProgress?: (p: { done: number; total: number; found: number; foundIps?: string[] }) => void; + /** true, sobald der Benutzer "Abbrechen" gedrückt hat — das Tool sollte zügig mit einem Teilergebnis zurückkehren */ + isCancelled?: () => boolean; } /** Rückgabe eines Tool-Laufs */ @@ -46,6 +73,14 @@ export interface ToolRunResult { * übernommen (z.B. beim IP-Scan). `ip` ist Pflicht, alles Weitere optional. */ devices?: Array & { ip: string }>; + /** + * Optionale Änderungen am Protokoll selbst (z.B. der beim IP-Scan erkannte + * Netzbereich). Tools dürfen `ctx.protocol` NICHT direkt beschreiben — das + * geschieht unsichtbar am Aufrufer vorbei und wird beim nächsten `persist()` + * nicht sauber nachvollziehbar. Der Aufrufer wendet das Patch nach dem Lauf + * gezielt an. + */ + protocolPatch?: Partial; } /** Ein Diagnose-Werkzeug */ @@ -60,6 +95,10 @@ export interface Tool { /** 'protocol' = global, 'device' = pro gefundenem Gerät */ scope: 'protocol' | 'device'; params: ToolParamField[]; + /** true = das Tool meldet Fortschritt über `ctx.onProgress` — ToolDialog zeigt dann Fortschrittsanzeige + Abbrechen statt des einfachen Spinners */ + supportsProgress?: boolean; + /** Optionale Vorschau vor dem Start (z.B. IP-Scan: Adapter/eigene IP/Maske/Gateway/Anzahl) */ + preview?(ctx: ToolContext): Promise; run(ctx: ToolContext): Promise; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 90f25c3..6cbfefc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -68,6 +68,8 @@ export interface Device { mdnsName?: string; /** angebotene mDNS-Dienste, z.B. ['_printer._tcp', '_googlecast._tcp'] */ mdnsServices?: string[]; + /** wie das Gerät beim letzten IP-Scan gefunden wurde: 'ping' | 'port' | 'arp' | 'mdns' */ + foundVia?: string; } /** Eingefrorener Snapshot eines IP-Scans (wieder aufrufbar) */ diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte index 4fcf767..2b71f98 100644 --- a/src/routes/protokoll/[id]/+page.svelte +++ b/src/routes/protokoll/[id]/+page.svelte @@ -46,11 +46,23 @@ const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest'); const deviceTools = TOOLS.filter((t) => t.scope === 'device'); - /** Geräte mit Favoriten zuerst */ + /** IP-Adressen numerisch vergleichen (nicht als Text — "…9" vor "…10") */ + function ipCompare(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 4; i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; + } + + /** Geräte mit Favoriten zuerst, sonst numerisch nach IP */ const sortedDevices = $derived( - [...(protocol?.devices ?? [])].sort( - (a, b) => Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false), - ), + [...(protocol?.devices ?? [])].sort((a, b) => { + const fav = Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false); + return fav !== 0 ? fav : ipCompare(a.ip, b.ip); + }), ); // Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar) @@ -120,11 +132,34 @@ activeDevice = device; } - /** Tool ausführen, Ergebnis ins Protokoll übernehmen */ - async function runTool(params: Record) { + /** + * Tool ausführen, Ergebnis ins Protokoll übernehmen. + * `live` ist nur bei `tool.supportsProgress` gesetzt (z.B. IP-Scanner) — + * ToolDialog reicht darüber Fortschritt/Abbruch bis ins Tool durch. + */ + async function runTool( + params: Record, + live?: { + onProgress?: (p: { done: number; total: number; found: number; foundIps?: string[] }) => void; + isCancelled?: () => boolean; + }, + ) { if (!protocol || !activeTool) return; const tool = activeTool; - const result = await tool.run({ params, protocol, device: activeDevice }); + const result = await tool.run({ + params, + protocol, + device: activeDevice, + onProgress: live?.onProgress, + isCancelled: live?.isCancelled, + }); + + // Änderungen am Protokoll selbst (z.B. der beim IP-Scan erkannte + // Netzbereich) gezielt hier anwenden — Tools dürfen ctx.protocol nicht + // direkt beschreiben (siehe ToolRunResult.protocolPatch). + if (result.protocolPatch) { + Object.assign(protocol, result.protocolPatch); + } // Neu gefundene Geräte übernehmen (z.B. IP-Scan) — alle gelieferten // Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …)