Compare commits
No commits in common. "7fdf693f36d386e60fe576d9e5a449af4f7a6d4b" and "85f1c246188378eb817148c48d49ed9d8a191798" have entirely different histories.
7fdf693f36
...
85f1c24618
8 changed files with 205 additions and 952 deletions
|
|
@ -26,12 +26,10 @@ 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
|
||||||
|
|
@ -267,216 +265,76 @@ 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 startIpScan(call: PluginCall) {
|
fun ipScan(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")
|
||||||
}
|
}
|
||||||
val runId = "ipscan-${System.currentTimeMillis()}"
|
io.launch {
|
||||||
|
|
||||||
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 {
|
||||||
arpReadable = arpTableReadable()
|
// Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau,
|
||||||
|
// also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …).
|
||||||
// done/total bewusst PRO RUNDE gezaehlt (eigener Zaehler je
|
val alive = withContext(Dispatchers.IO) {
|
||||||
// probeRound()-Aufruf), nicht ueber beide Runden hinweg
|
hosts.map { ipInt ->
|
||||||
// aufsummiert — sonst zeigt die zweite Runde (nur die stillen
|
async {
|
||||||
// Adressen aus Runde 1) einen "done" jenseits von "total" an
|
val ip = intToIpv4(ipInt)
|
||||||
// (z.B. "504 von 254"), weil Runde 2 auf demselben Zaehler
|
if (InetAddress.getByName(ip).isReachable(350)) ip else null
|
||||||
// weiterlief statt neu bei 0 zu beginnen.
|
|
||||||
suspend fun probeRound(targets: List<Int>) {
|
|
||||||
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<Int> = 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
}.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)
|
||||||
}
|
}
|
||||||
|
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()
|
||||||
probeRound(hosts)
|
.put("devices", devices)
|
||||||
reconcileArp()
|
// Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein
|
||||||
val silent = hosts.filter { !results.containsKey(it) }
|
// gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden.
|
||||||
probeRound(silent) // zweite Runde NUR fuer die stillen Adressen
|
.put("probed", hosts.size)
|
||||||
reconcileArp()
|
.put("answered", alive.size)
|
||||||
|
.put("arpAvailable", arp.isNotEmpty()))
|
||||||
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) {
|
||||||
android.util.Log.e(TAG, "ipScan ($runId): ${e.message}", e)
|
call.reject("ipScan: ${e.message}")
|
||||||
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 */
|
||||||
|
|
@ -485,8 +343,6 @@ 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 = "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -596,13 +452,10 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
*/
|
*/
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun mdnsScan(call: PluginCall) {
|
fun mdnsScan(call: PluginCall) {
|
||||||
// 8-10 s Budget (ROADMAP_UMSETZUNG.md Phase 3) statt der alten 4 s — die
|
val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong()
|
||||||
// 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, discoveryOk) = discoverMdns(timeoutMs)
|
val found = discoverMdns(timeoutMs)
|
||||||
val arr = JSArray()
|
val arr = JSArray()
|
||||||
for ((ip, info) in found) {
|
for ((ip, info) in found) {
|
||||||
val services = JSArray()
|
val services = JSArray()
|
||||||
|
|
@ -612,12 +465,7 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
.put("name", info.name)
|
.put("name", info.name)
|
||||||
.put("services", services))
|
.put("services", services))
|
||||||
}
|
}
|
||||||
resolve(call, JSObject()
|
resolve(call, JSObject().put("devices", arr))
|
||||||
.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}")
|
||||||
}
|
}
|
||||||
|
|
@ -629,18 +477,8 @@ 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): Pair<Map<String, MdnsInfo>, Boolean> {
|
private fun discoverMdns(timeoutMs: Long): Map<String, MdnsInfo> {
|
||||||
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
|
||||||
|
|
@ -653,7 +491,6 @@ 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) { }
|
||||||
|
|
@ -671,20 +508,14 @@ 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 discoveryDeadline = System.currentTimeMillis() + timeoutMs
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
val resolveGraceMs = 1500L
|
while (System.currentTimeMillis() < deadline) {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -712,7 +543,7 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
}
|
}
|
||||||
try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { }
|
try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
return result to anyStarted
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,6 @@
|
||||||
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 ||
|
||||||
|
|
@ -92,7 +84,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length || device.foundVia}
|
{#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length}
|
||||||
<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">
|
||||||
|
|
@ -105,14 +97,6 @@
|
||||||
{#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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Tool, ToolPreview } from '$lib/tools/types';
|
import type { Tool } 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,
|
||||||
|
|
@ -17,10 +15,7 @@
|
||||||
protocol: Protocol;
|
protocol: Protocol;
|
||||||
device?: Device;
|
device?: Device;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onrun: (
|
onrun: (params: Record<string, string | number>) => Promise<void>;
|
||||||
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
|
||||||
|
|
@ -30,89 +25,22 @@
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
/**
|
async function execute() {
|
||||||
* '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 = '';
|
|
||||||
if (!tool.preview) {
|
|
||||||
await start();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
|
||||||
previewInfo = await tool.preview({ params: { ...params }, protocol, device });
|
|
||||||
step = 'preview';
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Vorschau fehlgeschlagen';
|
|
||||||
debugLog.add('error', `Tool-Vorschau "${tool.id}":`, e);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
error = '';
|
error = '';
|
||||||
cancelRequested = false;
|
|
||||||
cancelling = false;
|
|
||||||
progress = tool.supportsProgress ? { done: 0, total: 0, found: 0 } : null;
|
|
||||||
if (tool.supportsProgress) step = 'running';
|
|
||||||
busy = true;
|
|
||||||
try {
|
try {
|
||||||
await onrun(
|
await onrun({ ...params });
|
||||||
{ ...params },
|
|
||||||
tool.supportsProgress
|
|
||||||
? {
|
|
||||||
onProgress: (p) => {
|
|
||||||
progress = p;
|
|
||||||
},
|
|
||||||
isCancelled: () => cancelRequested,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
onclose();
|
onclose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Ausführen';
|
error = e instanceof Error ? e.message : 'Fehler beim Ausführen';
|
||||||
debugLog.add('error', `Tool "${tool.id}":`, e);
|
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 {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestCancel() {
|
|
||||||
cancelRequested = true;
|
|
||||||
cancelling = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="fixed inset-0 z-40 flex items-end bg-black/60" role="presentation" onclick={onclose}>
|
||||||
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"
|
||||||
|
|
@ -122,120 +50,48 @@
|
||||||
>
|
>
|
||||||
<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>
|
||||||
{#if step !== 'running'}
|
<button onclick={onclose} aria-label="Schließen"><X size={20} /></button>
|
||||||
<button onclick={onclose} aria-label="Schließen"><X size={20} /></button>
|
</div>
|
||||||
{/if}
|
<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">
|
||||||
|
{#each tool.params as field (field.key)}
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-zinc-400">{field.label}</span>
|
||||||
|
{#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>
|
</div>
|
||||||
|
|
||||||
{#if step === 'params'}
|
{#if error}
|
||||||
<p class="mb-3 text-xs text-zinc-400">{tool.description}</p>
|
<p class="mt-3 text-sm text-red-400">{error}</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">
|
|
||||||
{#each tool.params as field (field.key)}
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-zinc-400">{field.label}</span>
|
|
||||||
{#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}
|
|
||||||
</ul>
|
|
||||||
{#if hiddenFoundCount > 0}
|
|
||||||
<p class="mt-1 text-xs text-zinc-500">+{hiddenFoundCount} weitere</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -22,33 +22,6 @@ 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 {
|
||||||
|
|
@ -206,22 +179,18 @@ 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) */
|
||||||
* IP-Scan starten: Geräte im Subnetz finden (mehrgleisig — Ping, Port-Probe,
|
ipScan(opts: { subnet: string }): Promise<{
|
||||||
* ARP-Abgleich). Liefert sofort die Laufkennung + Adressenzahl zurück;
|
devices: ScannedDevice[];
|
||||||
* Fortschritt kommt über das Event `ipScanProgress`, das Endergebnis über
|
/** wie viele Adressen geprueft wurden */
|
||||||
* `ipScanFinished` (genau einmal, ob regulär durchgelaufen oder per
|
probed?: number;
|
||||||
* `cancelIpScan` abgebrochen).
|
/** wie viele davon geantwortet haben */
|
||||||
*/
|
answered?: number;
|
||||||
startIpScan(opts: { subnet: string }): Promise<{ runId: string; total: number }>;
|
/** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */
|
||||||
/** Laufenden IP-Scan abbrechen — das Teilergebnis kommt regulär über `ipScanFinished` */
|
arpAvailable?: boolean;
|
||||||
cancelIpScan(opts: { runId: string }): Promise<{ ok: boolean }>;
|
|
||||||
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
|
|
||||||
mdnsScan(opts: { timeoutMs?: number }): Promise<{
|
|
||||||
devices: MdnsDevice[];
|
|
||||||
/** false = die Suche konnte fuer KEINEN Diensttyp gestartet werden (echter Fehler, nicht "nichts gefunden") */
|
|
||||||
discoveryOk?: boolean;
|
|
||||||
}>;
|
}>;
|
||||||
|
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
|
||||||
|
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
|
||||||
/** 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;
|
||||||
|
|
@ -391,79 +360,6 @@ 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 {
|
||||||
|
|
@ -474,34 +370,52 @@ const mock: NetDiagScannerPlugin = {
|
||||||
prefixQuelle: 'adapter',
|
prefixQuelle: 'adapter',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async startIpScan(opts) {
|
async ipScan() {
|
||||||
const runId = 'mock-ipscan-' + Date.now();
|
return {
|
||||||
const total = 254;
|
devices: [
|
||||||
mockIpScanRunId = runId;
|
{
|
||||||
mockIpScanTotal = total;
|
ip: '192.168.1.1',
|
||||||
let done = 0;
|
mac: 'AA:BB:CC:00:00:01',
|
||||||
// Simuliert Fortschritt in Schuben, wie der native Sweep — Treffer
|
hostname: 'fritzbox',
|
||||||
// wachsen proportional mit, damit die Live-Trefferliste im Browser-Dev
|
vendor: 'AVM',
|
||||||
// etwas zu sehen bekommt.
|
deviceType: 'Router',
|
||||||
mockIpScanTimer = setInterval(() => {
|
openPorts: [53, 80, 443],
|
||||||
done = Math.min(total, done + Math.round(total / 12));
|
},
|
||||||
const foundCount = Math.min(
|
{
|
||||||
MOCK_IP_SCAN_DEVICES.length,
|
ip: '192.168.1.10',
|
||||||
Math.ceil((done / total) * MOCK_IP_SCAN_DEVICES.length),
|
mac: 'AA:BB:CC:00:00:0A',
|
||||||
);
|
hostname: 'switch-keller',
|
||||||
const foundIps = MOCK_IP_SCAN_DEVICES.slice(0, foundCount).map((d) => d.ip);
|
vendor: 'TP-Link',
|
||||||
ipScanProgressListeners.forEach((cb) => cb({ runId, done, total, found: foundCount, foundIps }));
|
deviceType: 'Switch',
|
||||||
if (done >= total) {
|
openPorts: [80],
|
||||||
finishMockIpScan(runId, total, false);
|
},
|
||||||
}
|
{
|
||||||
}, 250);
|
ip: '192.168.1.40',
|
||||||
return { runId, total };
|
mac: 'AA:BB:CC:00:00:28',
|
||||||
},
|
hostname: 'ipcam-hof',
|
||||||
async cancelIpScan() {
|
vendor: 'Hikvision',
|
||||||
if (mockIpScanTimer) {
|
deviceType: 'Kamera',
|
||||||
finishMockIpScan(mockIpScanRunId, mockIpScanTotal, true);
|
openPorts: [80, 554],
|
||||||
}
|
},
|
||||||
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 {
|
||||||
|
|
@ -510,7 +424,6 @@ 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() {
|
||||||
|
|
@ -831,50 +744,3 @@ 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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,12 @@
|
||||||
* 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 {
|
import { scanner, type MdnsDevice } from '../../scanner';
|
||||||
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, ToolContext, ToolPreview } from '../types';
|
import type { Tool } 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 {
|
||||||
|
|
@ -39,126 +26,17 @@ 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',
|
||||||
description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.',
|
// 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.',
|
||||||
scope: 'protocol',
|
scope: 'protocol',
|
||||||
supportsProgress: true,
|
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
key: 'subnet',
|
key: 'subnet',
|
||||||
|
|
@ -167,36 +45,24 @@ export const ipScanTool: Tool = {
|
||||||
placeholder: 'leer lassen → automatisch über WLAN/LAN',
|
placeholder: 'leer lassen → automatisch über WLAN/LAN',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
async preview(ctx): Promise<ToolPreview> {
|
|
||||||
const { subnet, source, ip, gateway } = await resolveSubnet(ctx);
|
|
||||||
if (!subnet) {
|
|
||||||
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) {
|
async run(ctx) {
|
||||||
const { subnet, source } = await resolveSubnet(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
|
||||||
|
if (!subnet) {
|
||||||
|
try {
|
||||||
|
const local = await scanner.getLocalSubnet();
|
||||||
|
subnet = String(local.subnet ?? '').trim();
|
||||||
|
} catch {
|
||||||
|
/* kein Adapter ermittelbar — unten abgefangen */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!subnet) {
|
if (!subnet) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -206,10 +72,10 @@ export const ipScanTool: Tool = {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer —
|
// Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer
|
||||||
// als Patch, NICHT durch direktes Beschreiben von ctx.protocol (der
|
if (!String(ctx.protocol.subnet ?? '').trim()) {
|
||||||
// Aufrufer wendet das nach dem Lauf gezielt an, siehe ToolRunResult).
|
ctx.protocol.subnet = subnet;
|
||||||
const protocolPatch = !String(ctx.protocol.subnet ?? '').trim() ? { subnet } : undefined;
|
}
|
||||||
|
|
||||||
debugLog.add(
|
debugLog.add(
|
||||||
'info',
|
'info',
|
||||||
|
|
@ -217,56 +83,20 @@ 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 weder auf Ping/Port noch per ARP auffallen (manche Kameras/
|
// Geräte, die nicht auf Ping antworten (manche Kameras/Drucker). Best-Effort.
|
||||||
// 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 {
|
||||||
const r = await scanner.mdnsScan({ timeoutMs: 9000 });
|
mdns = (await scanner.mdnsScan({ timeoutMs: 4000 })).devices;
|
||||||
mdns = r.devices;
|
|
||||||
mdnsOk = r.discoveryOk !== false;
|
|
||||||
} catch {
|
} catch {
|
||||||
mdnsOk = false;
|
/* mDNS fehlgeschlagen — IP-Scan bleibt trotzdem gültig */
|
||||||
}
|
}
|
||||||
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 })[] = scanResult.devices.map((d) => {
|
const merged: (Partial<Device> & { ip: string })[] = 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 {
|
||||||
|
|
@ -285,58 +115,20 @@ 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} ` +
|
||||||
`(${scanResult.probed} geprüft, ${scanResult.answered} geantwortet, ` +
|
`(${devices.length} per Ping/ARP, ${mdns.length} per mDNS)`,
|
||||||
`${scanResult.durationMs} ms, arpAvailable=${scanResult.arpAvailable}, ` +
|
|
||||||
`cancelled=${scanResult.cancelled}, mdns=${mdns.length}/${mdnsOk ? 'ok' : 'fehler'})`,
|
|
||||||
);
|
);
|
||||||
|
const via = source === 'adapter' ? ' (Adapter erkannt)' : '';
|
||||||
return {
|
return {
|
||||||
label: teile.join(' · '),
|
label: `${merged.length} Geräte im Netz ${subnet}${via}`,
|
||||||
result: {
|
result: { subnet, count: merged.length, quelle: source },
|
||||||
subnet,
|
measureStatus: merged.length > 0 ? 0 : 1,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,39 +25,12 @@ 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 */
|
||||||
|
|
@ -73,14 +46,6 @@ 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 */
|
||||||
|
|
@ -95,10 +60,6 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,6 @@ 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) */
|
||||||
|
|
|
||||||
|
|
@ -46,23 +46,11 @@
|
||||||
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');
|
||||||
|
|
||||||
/** IP-Adressen numerisch vergleichen (nicht als Text — "…9" vor "…10") */
|
/** Geräte mit Favoriten zuerst */
|
||||||
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((a, b) => {
|
[...(protocol?.devices ?? [])].sort(
|
||||||
const fav = Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false);
|
(a, b) => 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)
|
||||||
|
|
@ -132,34 +120,11 @@
|
||||||
activeDevice = device;
|
activeDevice = device;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Tool ausführen, Ergebnis ins Protokoll übernehmen */
|
||||||
* Tool ausführen, Ergebnis ins Protokoll übernehmen.
|
async function runTool(params: Record<string, string | number>) {
|
||||||
* `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({
|
const result = await tool.run({ params, protocol, device: activeDevice });
|
||||||
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 …)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue