Compare commits

...

2 commits

Author SHA1 Message Date
7fdf693f36 Trigger: APK-Rebuild [apk]
All checks were successful
Build APK / build-apk (push) Successful in 2m28s
2026-08-15 15:57:18 +02:00
8b369fc3bc Phase 3: IP-Scanner komplett überarbeitet — mehrgleisige Suche, Live-Fortschritt
Bisher fand der IP-Scan Geräte nur per ICMP-Ping; ein Windows-PC mit aktiver
Firewall (Ping blockiert, Ports offen) fiel komplett durch. Kein Fortschritt
während des Laufs, kein Abbrechen, kein Hinweis was gescannt wird.

Nativ (NetDiagScannerPlugin.kt):
- Mehrgleisige Suche: gefunden bei Ping ODER Port-Probe ODER ARP-Tabellen-
  Eintrag. Runde 1 über alle Adressen, ARP-Abgleich, Runde 2 NUR für die
  weiterhin stillen Adressen, ARP erneut abgleichen.
- startIpScan()/cancelIpScan() statt eines einzigen langen ipScan()-Promise:
  läuft als Lauf mit Live-Events (ipScanProgress/ipScanFinished), Abbruch
  liefert das bis dahin gefundene Teilergebnis statt nichts.
- Eigener limitedParallelism(128)-Dispatcher, damit ein großer Sweep keinen
  gleichzeitigen Ping/Monitor/Dauertest ausbremst.
- arpAvailable ehrlich über File.canRead() statt "Tabelle war halt leer".
- mDNS-Budget 4s -> 9s, Discovery/Auflösung entkoppelt (eigene Nachlaufzeit),
  discoveryOk meldet einen echten Suchfehler statt stiller 0 Treffer.

App:
- scanner.ts: neue Events onIpScanProgress/onIpScanFinished.
- ipscan.ts: protocolPatch statt direktem Beschreiben von ctx.protocol;
  fehlgeschlagener Scan erzeugt jetzt auch eine Messung (Status 2 statt
  nichts); "neu"/"nicht mehr erreichbar" als Diagnosefelder.
- ToolDialog: generischer Vorschau-Schritt (tool.preview — Adapter, eigene
  IP, Gateway, Adressenzahl; ab >1024 Adressen Bestätigung "Trotzdem
  scannen") und Live-Fortschritt (tool.supportsProgress — Fortschrittsbalken,
  laufende Trefferliste, Abbrechen). Beide Mechanismen sind generisch für
  alle Tools nutzbar, nicht IP-Scan-spezifisch fest verdrahtet.
- DeviceCard zeigt den Fundweg ("via Ping/Port/ARP/mDNS") als Badge.
- Geräteliste numerisch nach IP sortiert (Favoriten weiter zuerst).

Im Emulator getestet (nicht nur Codereview): ICMP per iptables zu einem
Ziel geblockt, nur TCP/22 offen -> Gerät korrekt "via Port" gefunden.
/24 zweimal komplett gescannt: gleiche 4 Geräte, zweiter Lauf ohne "neu".
/20 (4094 Adressen) löst die Bestätigung korrekt aus. Abbrechen mitten im
Lauf liefert sauber ein Teilergebnis. Sync nach Dolibarr per DB-Query
verifiziert.

Dabei zwei echte Bugs gefunden, die reines Codereview nicht gefunden hätte:
Fortschritt zeigte "504 von 254" (Runde 2 zählte im Zähler von Runde 1
weiter), und das native Feld hieß "gefundenVia" statt "foundVia" wie von
der TS-Seite erwartet — der Fundweg kam nie an.

Details: ROADMAP_UMSETZUNG.md Phase 3
2026-08-15 12:47:28 +02:00
8 changed files with 953 additions and 206 deletions

View file

@ -26,10 +26,12 @@ import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission import com.getcapacitor.annotation.Permission
import com.getcapacitor.annotation.PermissionCallback import com.getcapacitor.annotation.PermissionCallback
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -265,76 +267,216 @@ class NetDiagScannerPlugin : Plugin() {
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
/* IP-Scan: Geräte im Subnetz finden */ /* 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<String, Job>()
@PluginMethod @PluginMethod
fun ipScan(call: PluginCall) { fun startIpScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val hosts = hostsInSubnet(subnet) val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) { if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") 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 // Ohne wachgehaltenes Funkmodul faellt bei einem laengeren Sweep die
// Trefferquote, weil das WLAN zwischendurch in den Sparmodus geht. // Trefferquote, weil das WLAN zwischendurch in den Sparmodus geht.
val locks = acquireRadioLocks("ipscan", (hosts.size * 60L).coerceIn(30_000L, 600_000L)) val locks = acquireRadioLocks("ipscan", (hosts.size * 60L).coerceIn(30_000L, 600_000L))
val startMs = System.currentTimeMillis()
val results = ConcurrentHashMap<Int, EnrichedHost>()
var arpReadable = false
try { try {
// Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau, arpReadable = arpTableReadable()
// also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …).
val alive = withContext(Dispatchers.IO) { // done/total bewusst PRO RUNDE gezaehlt (eigener Zaehler je
hosts.map { ipInt -> // probeRound()-Aufruf), nicht ueber beide Runden hinweg
async { // aufsummiert — sonst zeigt die zweite Runde (nur die stillen
val ip = intToIpv4(ipInt) // Adressen aus Runde 1) einen "done" jenseits von "total" an
if (InetAddress.getByName(ip).isReachable(350)) ip else null // (z.B. "504 von 254"), weil Runde 2 auf demselben Zaehler
} // weiterlief statt neu bei 0 zu beginnen.
}.awaitAll().filterNotNull() suspend fun probeRound(targets: List<Int>) {
} if (targets.isEmpty()) return
val arp = readArpTable() val doneCount = java.util.concurrent.atomic.AtomicInteger(0)
// Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name, val progressStep = (targets.size / 40).coerceAtLeast(1)
// Quick-Port-Probe (für die Geräteart-Heuristik). withContext(ipScanDispatcher) {
val enriched = withContext(Dispatchers.IO) { targets.map { ipInt ->
alive.map { ip -> async {
async { val ip = intToIpv4(ipInt)
val hostname = try { var alive = try {
val n = InetAddress.getByName(ip).canonicalHostName InetAddress.getByName(ip).isReachable(900)
if (n != ip) n else "" } catch (_: Exception) { false }
} catch (_: Exception) { "" } var method = if (alive) "ping" else ""
EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip)) var ports: List<Int> = emptyList()
} if (!alive) {
}.awaitAll() ports = quickPortProbe(ip)
} if (ports.isNotEmpty()) { alive = true; method = "port" }
val devices = JSArray() }
for (h in enriched) { if (alive) results[ipInt] = EnrichedHost(ip, "", null, ports, method)
val dev = JSObject().put("ip", h.ip) val d = doneCount.incrementAndGet()
val mac = arp[h.ip] if (d % progressStep == 0 || d == targets.size) {
val vendor = mac?.let { ouiVendor(it) } ?: "" // Live-Trefferliste (nur IPs, ohne Anreicherung — die kommt
if (mac != null) dev.put("mac", mac) // erst im Endergebnis) für "laufende Trefferliste" in der UI.
if (vendor.isNotEmpty()) dev.put("vendor", vendor) val foundIps = JSArray()
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname) results.values.forEach { foundIps.put(it.ip) }
if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios) notifyListeners(
if (h.openPorts.isNotEmpty()) { "ipScanProgress",
val pa = JSArray() JSObject().put("runId", runId).put("done", d)
h.openPorts.sorted().forEach { pa.put(it) } .put("total", targets.size).put("found", results.size)
dev.put("openPorts", pa) .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) fun reconcileArp() {
// Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein val arp = readArpTable()
// gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden. for (ipInt in hosts) {
.put("probed", hosts.size) if (results.containsKey(ipInt)) continue
.put("answered", alive.size) val mac = arp[intToIpv4(ipInt)]
.put("arpAvailable", arp.isNotEmpty())) 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) { } 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 { } finally {
releaseRadioLocks(locks) 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<Int, EnrichedHost>,
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 */ /** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */
@ -343,6 +485,8 @@ class NetDiagScannerPlugin : Plugin() {
val hostname: String, val hostname: String,
val netbios: String?, val netbios: String?,
val openPorts: List<Int>, val openPorts: List<Int>,
/** Fundweg: "ping" | "port" | "arp" */
val method: String = "",
) )
/** /**
@ -452,10 +596,13 @@ class NetDiagScannerPlugin : Plugin() {
*/ */
@PluginMethod @PluginMethod
fun mdnsScan(call: PluginCall) { 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 { io.launch {
try { try {
val found = discoverMdns(timeoutMs) val (found, discoveryOk) = discoverMdns(timeoutMs)
val arr = JSArray() val arr = JSArray()
for ((ip, info) in found) { for ((ip, info) in found) {
val services = JSArray() val services = JSArray()
@ -465,7 +612,12 @@ class NetDiagScannerPlugin : Plugin() {
.put("name", info.name) .put("name", info.name)
.put("services", services)) .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) { } catch (e: Exception) {
call.reject("mdnsScan: ${e.message}") call.reject("mdnsScan: ${e.message}")
} }
@ -477,8 +629,18 @@ class NetDiagScannerPlugin : Plugin() {
val services: MutableSet<String> = ConcurrentHashMap.newKeySet() val services: MutableSet<String> = 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") @Suppress("DEPRECATION")
private fun discoverMdns(timeoutMs: Long): Map<String, MdnsInfo> { private fun discoverMdns(timeoutMs: Long): Pair<Map<String, MdnsInfo>, Boolean> {
val nsd = context.applicationContext val nsd = context.applicationContext
.getSystemService(Context.NSD_SERVICE) as NsdManager .getSystemService(Context.NSD_SERVICE) as NsdManager
val wifi = context.applicationContext val wifi = context.applicationContext
@ -491,6 +653,7 @@ class NetDiagScannerPlugin : Plugin() {
val result = ConcurrentHashMap<String, MdnsInfo>() val result = ConcurrentHashMap<String, MdnsInfo>()
val pending = ConcurrentLinkedQueue<NsdServiceInfo>() val pending = ConcurrentLinkedQueue<NsdServiceInfo>()
val listeners = ArrayList<NsdManager.DiscoveryListener>() val listeners = ArrayList<NsdManager.DiscoveryListener>()
var anyStarted = false
val mlock = wifi.createMulticastLock("netdiag-mdns").apply { val mlock = wifi.createMulticastLock("netdiag-mdns").apply {
setReferenceCounted(true) setReferenceCounted(true)
try { acquire() } catch (_: Exception) { } try { acquire() } catch (_: Exception) { }
@ -508,14 +671,20 @@ class NetDiagScannerPlugin : Plugin() {
try { try {
nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l) nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l)
listeners.add(l) listeners.add(l)
anyStarted = true
} catch (_: Exception) { } } catch (_: Exception) { }
} }
// Gefundene Dienste seriell auflösen — NsdManager.resolveService // Gefundene Dienste seriell auflösen — NsdManager.resolveService
// verträgt keine parallelen Aufrufe. // verträgt keine parallelen Aufrufe.
val deadline = System.currentTimeMillis() + timeoutMs val discoveryDeadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) { val resolveGraceMs = 1500L
val resolveDeadline = discoveryDeadline + resolveGraceMs
while (System.currentTimeMillis() < resolveDeadline) {
val info = pending.poll() val info = pending.poll()
if (info == null) { 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) Thread.sleep(100)
continue continue
} }
@ -543,7 +712,7 @@ class NetDiagScannerPlugin : Plugin() {
} }
try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { } try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { }
} }
return result return result to anyStarted
} }
/** /**

View file

@ -33,6 +33,14 @@
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess']; const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500']; 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<string, string> = {
ping: 'Ping',
port: 'Port',
arp: 'ARP',
mdns: 'mDNS',
};
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */ /** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
const title = $derived( const title = $derived(
device.customName || device.customName ||
@ -84,7 +92,7 @@
</div> </div>
</div> </div>
{#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length} {#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length || device.foundVia}
<div class="mt-1.5 flex flex-wrap gap-1"> <div class="mt-1.5 flex flex-wrap gap-1">
{#if device.deviceType} {#if device.deviceType}
<span class="rounded bg-sky-900/60 px-1.5 py-0.5 text-[10px] text-sky-300"> <span class="rounded bg-sky-900/60 px-1.5 py-0.5 text-[10px] text-sky-300">
@ -97,6 +105,14 @@
{#each device.mdnsServices ?? [] as svc (svc)} {#each device.mdnsServices ?? [] as svc (svc)}
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">{svc}</span> <span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">{svc}</span>
{/each} {/each}
{#if device.foundVia}
<span
class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500"
title="Beim letzten IP-Scan gefunden über"
>
via {foundViaLabel[device.foundVia] ?? device.foundVia}
</span>
{/if}
</div> </div>
{/if} {/if}

View file

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Tool } from '$lib/tools/types'; import type { Tool, ToolPreview } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types'; import type { Device, Protocol } from '$lib/types';
import { debugLog } from '$lib/debuglog.svelte'; import { debugLog } from '$lib/debuglog.svelte';
import { X } from 'lucide-svelte'; import { X } from 'lucide-svelte';
type LiveProgress = { done: number; total: number; found: number; foundIps?: string[] };
let { let {
tool, tool,
protocol, protocol,
@ -15,7 +17,10 @@
protocol: Protocol; protocol: Protocol;
device?: Device; device?: Device;
onclose: () => void; onclose: () => void;
onrun: (params: Record<string, string | number>) => Promise<void>; onrun: (
params: Record<string, string | number>,
live?: { onProgress?: (p: LiveProgress) => void; isCancelled?: () => boolean },
) => Promise<void>;
} = $props(); } = $props();
// Parameter mit Vorgabewerten füllen // Parameter mit Vorgabewerten füllen
@ -25,22 +30,89 @@
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
async function execute() { /**
busy = true; * 'params' = Eingabeformular (immer der Start)
* 'preview' = Vorschau vor dem Start, nur wenn `tool.preview` gesetzt ist
* (z.B. IP-Scan: Adapter/eigene IP/Maske/Gateway/Anzahl)
* 'running' = Live-Fortschritt, nur wenn `tool.supportsProgress` gesetzt ist
*/
let step = $state<'params' | 'preview' | 'running'>('params');
let previewInfo = $state<ToolPreview | null>(null);
let progress = $state<LiveProgress | null>(null);
let cancelRequested = false;
let cancelling = $state(false);
/** Nur die letzten paar Treffer live rendern — bei einem großen Netz (/20)
* können das mehrere tausend IPs werden, ein voll mitwachsendes DOM waere
* auf einem Mittelklasse-Handy spuerbar traege. */
const visibleFoundIps = $derived((progress?.foundIps ?? []).slice(-150));
const hiddenFoundCount = $derived(Math.max(0, (progress?.foundIps?.length ?? 0) - visibleFoundIps.length));
const progressPct = $derived(
progress && progress.total > 0 ? Math.min(100, Math.round((progress.done / progress.total) * 100)) : 0,
);
/** Formular abgeschickt — bei Tools mit Vorschau erst dort, sonst direkt starten */
async function showPreviewOrRun() {
error = ''; error = '';
if (!tool.preview) {
await start();
return;
}
busy = true;
try { try {
await onrun({ ...params }); previewInfo = await tool.preview({ params: { ...params }, protocol, device });
onclose(); step = 'preview';
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Ausführen'; error = e instanceof Error ? e.message : 'Vorschau fehlgeschlagen';
debugLog.add('error', `Tool "${tool.id}":`, e); debugLog.add('error', `Tool-Vorschau "${tool.id}":`, e);
} finally { } finally {
busy = false; busy = false;
} }
} }
async function start() {
error = '';
cancelRequested = false;
cancelling = false;
progress = tool.supportsProgress ? { done: 0, total: 0, found: 0 } : null;
if (tool.supportsProgress) step = 'running';
busy = true;
try {
await onrun(
{ ...params },
tool.supportsProgress
? {
onProgress: (p) => {
progress = p;
},
isCancelled: () => cancelRequested,
}
: undefined,
);
onclose();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Ausführen';
debugLog.add('error', `Tool "${tool.id}":`, e);
// Zurück auf das Formular, damit die Eingabe korrigiert werden kann —
// bei "running" gäbe es sonst kein Formular mehr zum Anzeigen des Fehlers.
step = 'params';
} finally {
busy = false;
}
}
function requestCancel() {
cancelRequested = true;
cancelling = true;
}
</script> </script>
<div class="fixed inset-0 z-40 flex items-end bg-black/60" role="presentation" onclick={onclose}> <div
class="fixed inset-0 z-40 flex items-end bg-black/60"
role="presentation"
onclick={step === 'running' ? undefined : onclose}
>
<div <div
class="w-full rounded-t-2xl bg-zinc-900 p-4 safe-bottom" class="w-full rounded-t-2xl bg-zinc-900 p-4 safe-bottom"
role="dialog" role="dialog"
@ -50,48 +122,120 @@
> >
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold">{tool.name}</h2> <h2 class="font-semibold">{tool.name}</h2>
<button onclick={onclose} aria-label="Schließen"><X size={20} /></button> {#if step !== 'running'}
<button onclick={onclose} aria-label="Schließen"><X size={20} /></button>
{/if}
</div> </div>
<p class="mb-3 text-xs text-zinc-400">{tool.description}</p>
{#if device}
<p class="mb-3 text-sm text-sky-400">Gerät: {device.ip}</p>
{/if}
<div class="flex flex-col gap-3"> {#if step === 'params'}
{#each tool.params as field (field.key)} <p class="mb-3 text-xs text-zinc-400">{tool.description}</p>
<label class="flex flex-col gap-1 text-sm"> {#if device}
<span class="text-zinc-400">{field.label}</span> <p class="mb-3 text-sm text-sky-400">Gerät: {device.ip}</p>
{#if field.type === 'select'} {/if}
<select
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2" <div class="flex flex-col gap-3">
bind:value={params[field.key]} {#each tool.params as field (field.key)}
> <label class="flex flex-col gap-1 text-sm">
{#each field.options ?? [] as opt (opt.value)} <span class="text-zinc-400">{field.label}</span>
<option value={opt.value}>{opt.label}</option> {#if field.type === 'select'}
<select
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
bind:value={params[field.key]}
>
{#each field.options ?? [] as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else}
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type={field.type === 'number' ? 'number' : 'text'}
placeholder={field.placeholder ?? ''}
bind:value={params[field.key]}
/>
{/if}
</label>
{/each}
</div>
{#if error}
<p class="mt-3 text-sm text-red-400">{error}</p>
{/if}
<button
class="mt-4 w-full rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={showPreviewOrRun}
disabled={busy}
>
{busy ? 'Bitte warten …' : tool.preview ? 'Weiter' : 'Ausführen'}
</button>
{:else if step === 'preview' && previewInfo}
<div class="flex flex-col gap-2 text-sm">
{#each previewInfo.lines as line (line.label)}
<div class="flex items-center justify-between border-b border-zinc-800 py-1.5">
<span class="text-zinc-400">{line.label}</span>
<span class="font-medium">{line.value}</span>
</div>
{/each}
</div>
{#if previewInfo.warning}
<p class="mt-3 rounded-lg bg-amber-900/40 p-2.5 text-sm text-amber-300">{previewInfo.warning}</p>
{/if}
{#if error}
<p class="mt-3 text-sm text-red-400">{error}</p>
{/if}
<div class="mt-4 flex gap-2">
<button
class="flex-1 rounded-lg border border-zinc-700 py-2.5 font-semibold text-zinc-300 active:bg-zinc-800 disabled:opacity-50"
onclick={() => (step = 'params')}
disabled={busy}
>
Zurück
</button>
<button
class="flex-1 rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={start}
disabled={busy}
>
{busy ? 'Bitte warten …' : (previewInfo.confirmLabel ?? 'Starten')}
</button>
</div>
{:else if step === 'running'}
<div class="flex flex-col gap-3">
{#if progress && progress.total > 0}
<div class="h-2.5 w-full overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-sky-500 transition-all" style="width: {progressPct}%"></div>
</div>
<p class="text-sm text-zinc-400">
{progress.done} von {progress.total} geprüft · {progress.found} gefunden
</p>
{:else}
<p class="text-sm text-zinc-400">Wird gestartet …</p>
{/if}
{#if visibleFoundIps.length > 0}
<div class="max-h-40 overflow-y-auto rounded-lg bg-zinc-800/60 p-2">
<ul class="flex flex-col gap-0.5 font-mono text-xs text-zinc-300">
{#each visibleFoundIps as ip (ip)}
<li>{ip}</li>
{/each} {/each}
</select> </ul>
{:else} {#if hiddenFoundCount > 0}
<input <p class="mt-1 text-xs text-zinc-500">+{hiddenFoundCount} weitere</p>
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2" {/if}
type={field.type === 'number' ? 'number' : 'text'} </div>
placeholder={field.placeholder ?? ''} {/if}
bind:value={params[field.key]}
/>
{/if}
</label>
{/each}
</div>
{#if error} <button
<p class="mt-3 text-sm text-red-400">{error}</p> class="mt-1 w-full rounded-lg border border-red-800 py-2.5 font-semibold text-red-400 active:bg-red-950 disabled:opacity-50"
onclick={requestCancel}
disabled={cancelling}
>
{cancelling ? 'Wird abgebrochen …' : 'Abbrechen'}
</button>
</div>
{/if} {/if}
<button
class="mt-4 w-full rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={execute}
disabled={busy}
>
{busy ? 'Messung läuft …' : 'Ausführen'}
</button>
</div> </div>
</div> </div>

View file

@ -22,6 +22,33 @@ export interface ScannedDevice {
netbiosName?: string; netbiosName?: string;
/** offene Ports aus der Quick-Port-Probe */ /** offene Ports aus der Quick-Port-Probe */
openPorts?: number[]; 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 */ /** Ein per mDNS/Bonjour gefundenes Gerät */
export interface MdnsDevice { export interface MdnsDevice {
@ -179,18 +206,22 @@ export interface NetDiagScannerPlugin {
/** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */ /** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */
prefixQuelle?: string; prefixQuelle?: string;
}>; }>;
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */ /**
ipScan(opts: { subnet: string }): Promise<{ * IP-Scan starten: Geräte im Subnetz finden (mehrgleisig Ping, Port-Probe,
devices: ScannedDevice[]; * ARP-Abgleich). Liefert sofort die Laufkennung + Adressenzahl zurück;
/** wie viele Adressen geprueft wurden */ * Fortschritt kommt über das Event `ipScanProgress`, das Endergebnis über
probed?: number; * `ipScanFinished` (genau einmal, ob regulär durchgelaufen oder per
/** wie viele davon geantwortet haben */ * `cancelIpScan` abgebrochen).
answered?: number; */
/** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */ startIpScan(opts: { subnet: string }): Promise<{ runId: string; total: number }>;
arpAvailable?: boolean; /** 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 … */ /** 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 */ /** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */
arpConflictScan(opts: { arpConflictScan(opts: {
subnet: string; subnet: string;
@ -360,6 +391,79 @@ function finishMockStress(reason: 'completed' | 'stopped'): StressResult {
return result; 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<typeof setInterval> | 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 = { const mock: NetDiagScannerPlugin = {
async getLocalSubnet() { async getLocalSubnet() {
return { return {
@ -370,52 +474,34 @@ const mock: NetDiagScannerPlugin = {
prefixQuelle: 'adapter', prefixQuelle: 'adapter',
}; };
}, },
async ipScan() { async startIpScan(opts) {
return { const runId = 'mock-ipscan-' + Date.now();
devices: [ const total = 254;
{ mockIpScanRunId = runId;
ip: '192.168.1.1', mockIpScanTotal = total;
mac: 'AA:BB:CC:00:00:01', let done = 0;
hostname: 'fritzbox', // Simuliert Fortschritt in Schuben, wie der native Sweep — Treffer
vendor: 'AVM', // wachsen proportional mit, damit die Live-Trefferliste im Browser-Dev
deviceType: 'Router', // etwas zu sehen bekommt.
openPorts: [53, 80, 443], mockIpScanTimer = setInterval(() => {
}, done = Math.min(total, done + Math.round(total / 12));
{ const foundCount = Math.min(
ip: '192.168.1.10', MOCK_IP_SCAN_DEVICES.length,
mac: 'AA:BB:CC:00:00:0A', Math.ceil((done / total) * MOCK_IP_SCAN_DEVICES.length),
hostname: 'switch-keller', );
vendor: 'TP-Link', const foundIps = MOCK_IP_SCAN_DEVICES.slice(0, foundCount).map((d) => d.ip);
deviceType: 'Switch', ipScanProgressListeners.forEach((cb) => cb({ runId, done, total, found: foundCount, foundIps }));
openPorts: [80], if (done >= total) {
}, finishMockIpScan(runId, total, false);
{ }
ip: '192.168.1.40', }, 250);
mac: 'AA:BB:CC:00:00:28', return { runId, total };
hostname: 'ipcam-hof', },
vendor: 'Hikvision', async cancelIpScan() {
deviceType: 'Kamera', if (mockIpScanTimer) {
openPorts: [80, 554], finishMockIpScan(mockIpScanRunId, mockIpScanTotal, true);
}, }
{ return { ok: true };
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 mdnsScan() { async mdnsScan() {
return { return {
@ -424,6 +510,7 @@ const mock: NetDiagScannerPlugin = {
{ ip: '192.168.1.30', name: 'Wohnzimmer-TV', services: ['_googlecast._tcp'] }, { ip: '192.168.1.30', name: 'Wohnzimmer-TV', services: ['_googlecast._tcp'] },
{ ip: '192.168.1.40', name: 'IP-Kamera Hof', services: ['_rtsp._tcp'] }, { ip: '192.168.1.40', name: 'IP-Kamera Hof', services: ['_rtsp._tcp'] },
], ],
discoveryOk: true,
}; };
}, },
async arpConflictScan() { async arpConflictScan() {
@ -744,3 +831,50 @@ export function onStressFinished(cb: (e: StressResult) => void): () => void {
stressFinishedListeners.delete(cb); 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<PluginListenerHandle>;
}
).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<PluginListenerHandle>;
}
).addListener('ipScanFinished', cb);
return () => {
void handle.then((h) => h.remove());
};
}
ipScanFinishedListeners.add(cb);
return () => {
ipScanFinishedListeners.delete(cb);
};
}

View file

@ -6,12 +6,25 @@
* Protokoll hinterlegte Netzbereich genutzt; ist auch der leer, * Protokoll hinterlegte Netzbereich genutzt; ist auch der leer,
* fragt das Tool den aktiven WLAN-/LAN-Adapter ab und scannt * fragt das Tool den aktiven WLAN-/LAN-Adapter ab und scannt
* dessen Subnetz direkt. * 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 { debugLog } from '../../debuglog.svelte';
import type { Device } from '../../types'; 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 */ /** Geräteart aus den angebotenen mDNS-Diensten ableiten */
function typeFromMdns(services: string[]): string { function typeFromMdns(services: string[]): string {
@ -26,17 +39,126 @@ function typeFromMdns(services: string[]): string {
return ''; 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<IpScanResult> {
return new Promise((resolve, reject) => {
let offProgress = () => {};
let offFinished = () => {};
let cancelTimer: ReturnType<typeof setInterval> | 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 = { export const ipScanTool: Tool = {
id: 'ipscan', id: 'ipscan',
category: 'netzwerk', category: 'netzwerk',
name: 'IP-Scanner', name: 'IP-Scanner',
icon: 'radar', icon: 'radar',
// Ehrliche Beschreibung: gefunden wird ein Gerät nur, wenn es auf Ping description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.',
// 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.',
scope: 'protocol', scope: 'protocol',
supportsProgress: true,
params: [ params: [
{ {
key: 'subnet', key: 'subnet',
@ -45,24 +167,36 @@ export const ipScanTool: Tool = {
placeholder: 'leer lassen → automatisch über WLAN/LAN', 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<ToolPreview> {
const { subnet, source, ip, gateway } = await resolveSubnet(ctx);
if (!subnet) { if (!subnet) {
try { return {
const local = await scanner.getLocalSubnet(); lines: [{ label: 'Netzbereich', value: 'nicht ermittelbar' }],
subnet = String(local.subnet ?? '').trim(); warning: 'Kein aktives WLAN/LAN gefunden — Netzbereich von Hand eintragen.',
} catch { confirmLabel: 'Trotzdem versuchen',
/* kein Adapter ermittelbar — unten abgefangen */ };
}
} }
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) { if (!subnet) {
return { return {
@ -72,10 +206,10 @@ export const ipScanTool: Tool = {
}; };
} }
// Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer // Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer
if (!String(ctx.protocol.subnet ?? '').trim()) { // als Patch, NICHT durch direktes Beschreiben von ctx.protocol (der
ctx.protocol.subnet = subnet; // Aufrufer wendet das nach dem Lauf gezielt an, siehe ToolRunResult).
} const protocolPatch = !String(ctx.protocol.subnet ?? '').trim() ? { subnet } : undefined;
debugLog.add( debugLog.add(
'info', 'info',
@ -83,20 +217,56 @@ export const ipScanTool: Tool = {
`Protokoll-Subnetz="${String(ctx.protocol.subnet ?? '')}" → ` + `Protokoll-Subnetz="${String(ctx.protocol.subnet ?? '')}" → ` +
`gescannt wird "${subnet}" (Quelle: ${source})`, `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 // 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 mdns: MdnsDevice[] = [];
let mdnsOk = true;
try { try {
mdns = (await scanner.mdnsScan({ timeoutMs: 4000 })).devices; const r = await scanner.mdnsScan({ timeoutMs: 9000 });
mdns = r.devices;
mdnsOk = r.discoveryOk !== false;
} catch { } catch {
/* mDNS fehlgeschlagen — IP-Scan bleibt trotzdem gültig */ mdnsOk = false;
} }
const mdnsByIp = new Map(mdns.map((m) => [m.ip, m])); const mdnsByIp = new Map(mdns.map((m) => [m.ip, m]));
// Beide Quellen per IP zusammenführen // Beide Quellen per IP zusammenführen
const merged: (Partial<Device> & { ip: string })[] = devices.map((d) => { const merged: (Partial<Device> & { ip: string })[] = scanResult.devices.map((d) => {
const m = mdnsByIp.get(d.ip); const m = mdnsByIp.get(d.ip);
if (!m) return d; if (!m) return d;
return { return {
@ -115,20 +285,58 @@ export const ipScanTool: Tool = {
mdnsName: m.name, mdnsName: m.name,
mdnsServices: m.services, mdnsServices: m.services,
deviceType: typeFromMdns(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( debugLog.add(
'info', 'info',
`IP-Scan Ergebnis: ${merged.length} Geräte in ${subnet} ` + `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 { return {
label: `${merged.length} Geräte im Netz ${subnet}${via}`, label: teile.join(' · '),
result: { subnet, count: merged.length, quelle: source }, result: {
measureStatus: merged.length > 0 ? 0 : 1, 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, devices: merged,
protocolPatch,
}; };
}, },
}; };

View file

@ -25,12 +25,39 @@ export interface ToolParamField {
placeholder?: string; 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 */ /** Kontext, mit dem ein Tool ausgeführt wird */
export interface ToolContext { export interface ToolContext {
params: Record<string, string | number>; params: Record<string, string | number>;
protocol: Protocol; protocol: Protocol;
/** gesetzt, wenn das Tool für ein einzelnes Gerät läuft (scope 'device') */ /** gesetzt, wenn das Tool für ein einzelnes Gerät läuft (scope 'device') */
device?: 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 */ /** Rückgabe eines Tool-Laufs */
@ -46,6 +73,14 @@ export interface ToolRunResult {
* übernommen (z.B. beim IP-Scan). `ip` ist Pflicht, alles Weitere optional. * übernommen (z.B. beim IP-Scan). `ip` ist Pflicht, alles Weitere optional.
*/ */
devices?: Array<Partial<Device> & { ip: string }>; devices?: Array<Partial<Device> & { 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<Protocol>;
} }
/** Ein Diagnose-Werkzeug */ /** Ein Diagnose-Werkzeug */
@ -60,6 +95,10 @@ export interface Tool {
/** 'protocol' = global, 'device' = pro gefundenem Gerät */ /** 'protocol' = global, 'device' = pro gefundenem Gerät */
scope: 'protocol' | 'device'; scope: 'protocol' | 'device';
params: ToolParamField[]; 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<ToolPreview>;
run(ctx: ToolContext): Promise<ToolRunResult>; run(ctx: ToolContext): Promise<ToolRunResult>;
} }

View file

@ -68,6 +68,8 @@ export interface Device {
mdnsName?: string; mdnsName?: string;
/** angebotene mDNS-Dienste, z.B. ['_printer._tcp', '_googlecast._tcp'] */ /** angebotene mDNS-Dienste, z.B. ['_printer._tcp', '_googlecast._tcp'] */
mdnsServices?: string[]; 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) */ /** Eingefrorener Snapshot eines IP-Scans (wieder aufrufbar) */

View file

@ -46,11 +46,23 @@
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest'); const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest');
const deviceTools = TOOLS.filter((t) => t.scope === 'device'); 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( const sortedDevices = $derived(
[...(protocol?.devices ?? [])].sort( [...(protocol?.devices ?? [])].sort((a, b) => {
(a, b) => Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false), 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) // Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
@ -120,11 +132,34 @@
activeDevice = device; activeDevice = device;
} }
/** Tool ausführen, Ergebnis ins Protokoll übernehmen */ /**
async function runTool(params: Record<string, string | number>) { * 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<string, string | number>,
live?: {
onProgress?: (p: { done: number; total: number; found: number; foundIps?: string[] }) => void;
isCancelled?: () => boolean;
},
) {
if (!protocol || !activeTool) return; if (!protocol || !activeTool) return;
const tool = activeTool; 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 // Neu gefundene Geräte übernehmen (z.B. IP-Scan) — alle gelieferten
// Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …) // Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …)