@devioarts/capacitor-mdns 0.0.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/DevioartsCapacitorMdns.podspec +3 -3
- package/Package.swift +1 -1
- package/README.md +272 -101
- package/android/build.gradle +3 -9
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +496 -175
- package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +85 -49
- package/dist/docs.json +313 -15
- package/dist/esm/definitions.d.ts +89 -16
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.js +22 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +8 -1
- package/dist/esm/web.js +35 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +57 -4
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +57 -4
- package/dist/plugin.js.map +1 -1
- package/docs/electron.md +91 -0
- package/docs/platform-setup.md +58 -0
- package/docs/playground.md +50 -0
- package/docs/runtime-behavior.md +80 -0
- package/electron/dist/plugin-settings.js +22 -0
- package/electron/dist/plugin-settings.js.map +1 -0
- package/electron/dist/plugin.cjs.js +774 -0
- package/electron/dist/plugin.cjs.js.map +1 -0
- package/electron/global.d.ts +2 -0
- package/electron/mdns-bridge.cjs +33 -4
- package/electron/mdns.ts +628 -107
- package/electron/rollup.config.mjs +22 -0
- package/electron/src/index.ts +2 -0
- package/electron/src/plugin-settings.ts +19 -0
- package/electron/tsconfig.json +21 -0
- package/electron/types-public/index.d.ts +47 -0
- package/ios/Sources/mDNSPlugin/mDNS.swift +481 -124
- package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +85 -23
- package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
- package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
- package/package.json +55 -20
- package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -15
|
@@ -3,13 +3,20 @@ package com.devioarts.capacitor.mdns
|
|
|
3
3
|
|
|
4
4
|
import android.content.Context
|
|
5
5
|
import android.net.nsd.NsdManager
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import kotlinx.coroutines
|
|
11
|
-
import
|
|
12
|
-
import
|
|
6
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
7
|
+
import kotlinx.coroutines.CoroutineDispatcher
|
|
8
|
+
import kotlinx.coroutines.CoroutineScope
|
|
9
|
+
import kotlinx.coroutines.Dispatchers
|
|
10
|
+
import kotlinx.coroutines.Job
|
|
11
|
+
import kotlinx.coroutines.SupervisorJob
|
|
12
|
+
import kotlinx.coroutines.TimeoutCancellationException
|
|
13
|
+
import kotlinx.coroutines.cancel
|
|
14
|
+
import kotlinx.coroutines.delay
|
|
15
|
+
import kotlinx.coroutines.launch
|
|
16
|
+
import kotlinx.coroutines.sync.Mutex
|
|
17
|
+
import kotlinx.coroutines.sync.withLock
|
|
18
|
+
import kotlinx.coroutines.withContext
|
|
19
|
+
import kotlinx.coroutines.withTimeout
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
22
|
* Thin Android NSD (Bonjour/mDNS) wrapper used by the Capacitor bridge.
|
|
@@ -21,42 +28,102 @@ import java.util.concurrent.atomic.AtomicReference
|
|
|
21
28
|
* - Timebox discovery; early-exit when the target match is resolved.
|
|
22
29
|
*
|
|
23
30
|
* Threading:
|
|
24
|
-
* - All
|
|
25
|
-
*
|
|
31
|
+
* - All mutable state is confined to the main thread. NSD delivers the legacy listener callbacks on
|
|
32
|
+
* its own internal thread, so every callback coming from [NsdBackend] hops to main via [runOnMain]
|
|
33
|
+
* before it touches any state.
|
|
26
34
|
*
|
|
27
35
|
* Platform notes:
|
|
28
|
-
* -
|
|
36
|
+
* - Resolved TXT records are available through NSD attributes.
|
|
37
|
+
* - Before API 34 only one `resolveService` may be active at a time; resolves are queued here.
|
|
29
38
|
*/
|
|
30
|
-
class mDNS(
|
|
31
|
-
|
|
32
|
-
private val
|
|
39
|
+
class mDNS internal constructor(
|
|
40
|
+
private val backend: NsdBackend,
|
|
41
|
+
private val mainThread: MainThread,
|
|
42
|
+
private val mainDispatcher: CoroutineDispatcher,
|
|
43
|
+
private val externalScope: CoroutineScope?,
|
|
44
|
+
private val publishTimeoutMs: Long,
|
|
45
|
+
private val resolveTimeoutMs: Long,
|
|
46
|
+
private val stopTimeoutMs: Long = 3000L
|
|
33
47
|
) {
|
|
48
|
+
constructor(context: Context, externalScope: CoroutineScope? = null) : this(
|
|
49
|
+
AndroidNsdBackend(context),
|
|
50
|
+
AndroidMainThread(),
|
|
51
|
+
Dispatchers.Main.immediate,
|
|
52
|
+
externalScope,
|
|
53
|
+
PUBLISH_TIMEOUT_MS,
|
|
54
|
+
RESOLVE_TIMEOUT_MS
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
private companion object {
|
|
58
|
+
const val PUBLISH_TIMEOUT_MS = 5000L
|
|
59
|
+
const val RESOLVE_TIMEOUT_MS = 5000L
|
|
60
|
+
const val RESOLVE_RETRY_DELAY_MS = 150L
|
|
61
|
+
const val MAX_RESOLVE_RETRIES = 3
|
|
62
|
+
val NAME_SUFFIX = Regex(""" \(\d+\)$""")
|
|
63
|
+
}
|
|
64
|
+
|
|
34
65
|
/** Normalized service representation returned to the bridge. */
|
|
35
66
|
data class MdnsService(
|
|
36
67
|
val name: String,
|
|
37
68
|
val type: String,
|
|
38
69
|
val hosts: List<String>, // numeric addresses (v4/v6)
|
|
39
|
-
val port: Int
|
|
70
|
+
val port: Int,
|
|
71
|
+
val txt: Map<String, String> = emptyMap()
|
|
40
72
|
)
|
|
41
73
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
74
|
+
private class RegistrationSession(
|
|
75
|
+
val onSuccess: (String) -> Unit,
|
|
76
|
+
val onError: (Throwable) -> Unit
|
|
77
|
+
) {
|
|
78
|
+
var timeoutJob: Job? = null
|
|
79
|
+
var token: Any? = null
|
|
80
|
+
var completed = false
|
|
81
|
+
var finished = false
|
|
82
|
+
/** True once the caller no longer wants it; a late confirmation must be unregistered. */
|
|
83
|
+
var cancelled = false
|
|
84
|
+
var stopping = false
|
|
85
|
+
var stopped = CompletableDeferred<Throwable?>()
|
|
86
|
+
var lockHeld = false
|
|
87
|
+
}
|
|
45
88
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
89
|
+
/** A discovered candidate waiting for (or undergoing) resolution. */
|
|
90
|
+
interface WatchListener {
|
|
91
|
+
fun onServiceFound(service: MdnsService)
|
|
92
|
+
fun onServiceLost(service: MdnsService)
|
|
93
|
+
fun onError(type: String, message: String, watching: Boolean)
|
|
94
|
+
}
|
|
49
95
|
|
|
50
|
-
|
|
51
|
-
|
|
96
|
+
private inner class Pending(val owner: DiscoverySession, val service: DiscoveredService) {
|
|
97
|
+
var attempts = 0
|
|
98
|
+
var done = false
|
|
99
|
+
var token: Any? = null
|
|
100
|
+
var timeoutJob: Job? = null
|
|
101
|
+
var retryJob: Job? = null
|
|
102
|
+
var generation = 0
|
|
103
|
+
}
|
|
52
104
|
|
|
53
|
-
/**
|
|
54
|
-
private val
|
|
55
|
-
private val
|
|
56
|
-
private
|
|
57
|
-
|
|
105
|
+
/** Optional external scope (from plugin); otherwise create our own on Main. */
|
|
106
|
+
private val scope = externalScope ?: CoroutineScope(SupervisorJob() + mainDispatcher)
|
|
107
|
+
private val broadcastMutex = Mutex()
|
|
108
|
+
private val watchMutex = Mutex()
|
|
109
|
+
private var closed = false
|
|
110
|
+
|
|
111
|
+
// Main-thread confined state.
|
|
112
|
+
private var activeRegistration: RegistrationSession? = null
|
|
113
|
+
private var activeDiscovery: DiscoverySession? = null
|
|
114
|
+
private var activeWatch: DiscoverySession? = null
|
|
115
|
+
private val resolveQueue = ArrayDeque<Pending>()
|
|
116
|
+
private val resolving = mutableSetOf<Pending>()
|
|
117
|
+
private val discoveryStops = mutableMapOf<Any, CompletableDeferred<Throwable?>>()
|
|
118
|
+
private val retryDiscoveryStops = mutableSetOf<Any>()
|
|
119
|
+
internal val isPublishing: Boolean get() = activeRegistration != null
|
|
120
|
+
|
|
121
|
+
private fun runOnMain(block: () -> Unit) {
|
|
122
|
+
if (mainThread.isCurrent()) block() else mainThread.post(block)
|
|
58
123
|
}
|
|
59
124
|
|
|
125
|
+
// ---- Broadcast -----------------------------------------------------------
|
|
126
|
+
|
|
60
127
|
/**
|
|
61
128
|
* Start advertising a service. If already registered, the previous one is unregistered first.
|
|
62
129
|
*
|
|
@@ -71,48 +138,93 @@ class mDNS(
|
|
|
71
138
|
name: String,
|
|
72
139
|
port: Int,
|
|
73
140
|
onSuccess: (String) -> Unit,
|
|
74
|
-
onError: (Throwable) -> Unit
|
|
141
|
+
onError: (Throwable) -> Unit,
|
|
142
|
+
txt: Map<String, String> = emptyMap()
|
|
75
143
|
) {
|
|
76
|
-
require(port
|
|
77
|
-
val type =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
144
|
+
require(port in 1..65535) { "Port must be between 1 and 65535" }
|
|
145
|
+
val type = MdnsValidation.type(typeRaw)
|
|
146
|
+
val normalizedName = MdnsValidation.name(name).ifEmpty { "DevIOArtsMDNS" }
|
|
147
|
+
MdnsValidation.txt(txt)
|
|
148
|
+
|
|
149
|
+
scope.launch(mainDispatcher) {
|
|
150
|
+
broadcastMutex.withLock {
|
|
151
|
+
try {
|
|
152
|
+
check(!closed) { "mDNS manager is closed" }
|
|
153
|
+
// Restarting while publish is pending must complete the old JS promise.
|
|
154
|
+
stopActiveRegistration("Replaced by a new broadcast request")
|
|
155
|
+
check(!closed) { "mDNS manager is closed" }
|
|
156
|
+
|
|
157
|
+
val session = RegistrationSession(onSuccess, onError)
|
|
158
|
+
val listener = object : NsdBackend.RegistrationListener {
|
|
159
|
+
override fun onRegistered(publishedName: String) =
|
|
160
|
+
runOnMain { handleRegistered(session, publishedName) }
|
|
161
|
+
|
|
162
|
+
override fun onFailed(errorCode: Int) = runOnMain {
|
|
163
|
+
finishRegistration(session)
|
|
164
|
+
completeRegistrationError(
|
|
165
|
+
session,
|
|
166
|
+
IllegalStateException("Registration failed: $errorCode")
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override fun onUnregistered() = runOnMain { finishRegistration(session) }
|
|
171
|
+
|
|
172
|
+
override fun onUnregistrationFailed(errorCode: Int) = runOnMain {
|
|
173
|
+
session.stopping = false
|
|
174
|
+
session.stopped.complete(IllegalStateException("Unregistration failed: $errorCode"))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
82
177
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
178
|
+
session.timeoutJob = scope.launch(mainDispatcher) {
|
|
179
|
+
delay(publishTimeoutMs)
|
|
180
|
+
session.cancelled = true
|
|
181
|
+
completeRegistrationError(
|
|
182
|
+
session,
|
|
183
|
+
IllegalStateException("Timed out waiting for service publish after ${publishTimeoutMs}ms")
|
|
184
|
+
)
|
|
185
|
+
unregister(session)
|
|
186
|
+
releaseRegistrationLock(session)
|
|
187
|
+
}
|
|
88
188
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
189
|
+
activeRegistration = session
|
|
190
|
+
try {
|
|
191
|
+
backend.acquireMulticastLock()
|
|
192
|
+
session.lockHeld = true
|
|
193
|
+
session.token = backend.registerService(type, normalizedName, port, txt.toMap(), listener)
|
|
194
|
+
if (session.cancelled) unregister(session)
|
|
195
|
+
} catch (t: Throwable) {
|
|
196
|
+
finishRegistration(session)
|
|
197
|
+
completeRegistrationError(session, t)
|
|
198
|
+
}
|
|
199
|
+
} catch (t: Throwable) {
|
|
200
|
+
onError(t)
|
|
201
|
+
}
|
|
95
202
|
}
|
|
96
|
-
|
|
97
|
-
regListenerRef.set(listener)
|
|
98
|
-
nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
|
|
99
203
|
}
|
|
100
204
|
}
|
|
101
205
|
|
|
102
206
|
/**
|
|
103
207
|
* Stop advertising the currently registered service (if any). Safe to call multiple times.
|
|
104
208
|
*/
|
|
105
|
-
fun stopBroadcast() {
|
|
106
|
-
|
|
107
|
-
|
|
209
|
+
fun stopBroadcast(onComplete: (Boolean, Throwable?) -> Unit = { _, _ -> }) {
|
|
210
|
+
scope.launch(mainDispatcher) {
|
|
211
|
+
broadcastMutex.withLock {
|
|
212
|
+
val error = try {
|
|
213
|
+
stopActiveRegistration("Publish stopped before completion")
|
|
214
|
+
null
|
|
215
|
+
} catch (t: Throwable) { t }
|
|
216
|
+
onComplete(isPublishing, error)
|
|
217
|
+
}
|
|
108
218
|
}
|
|
109
219
|
}
|
|
110
220
|
|
|
221
|
+
// ---- Discover ------------------------------------------------------------
|
|
222
|
+
|
|
111
223
|
/**
|
|
112
224
|
* Discover services, optionally filtering by instance name using a normalized exact-or-prefix match.
|
|
113
225
|
*
|
|
114
226
|
* Behavior:
|
|
115
|
-
* - Runs on the main dispatcher.
|
|
227
|
+
* - Runs on the main dispatcher; a new valid call replaces the previous one.
|
|
116
228
|
* - Resolves candidates to host/port.
|
|
117
229
|
* - If `targetName` is provided, compare using normalized names and accept exact OR prefix match.
|
|
118
230
|
* - Early-exits when a matching service is resolved (stops discovery immediately).
|
|
@@ -127,164 +239,373 @@ class mDNS(
|
|
|
127
239
|
typeRaw: String,
|
|
128
240
|
targetName: String? = null,
|
|
129
241
|
timeoutMs: Int = 3000
|
|
130
|
-
): List<MdnsService> = withContext(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
242
|
+
): List<MdnsService> = withContext(mainDispatcher) {
|
|
243
|
+
check(!closed) { "mDNS manager is closed" }
|
|
244
|
+
val type = MdnsValidation.type(typeRaw)
|
|
245
|
+
val target = MdnsValidation.name(targetName).takeIf { it.isNotEmpty() }
|
|
246
|
+
activeDiscovery?.fail(IllegalStateException("Discovery replaced by a new request"))
|
|
247
|
+
val session = DiscoverySession(listOf(type), target)
|
|
248
|
+
activeDiscovery = session
|
|
249
|
+
try {
|
|
250
|
+
session.start(timeoutMs.coerceAtLeast(0).toLong())
|
|
251
|
+
session.result.await()
|
|
252
|
+
} finally {
|
|
253
|
+
// Cleanup in all cases (early-exit, timeout, error or cancellation).
|
|
254
|
+
session.close()
|
|
255
|
+
if (activeDiscovery === session) activeDiscovery = null
|
|
256
|
+
}
|
|
257
|
+
}
|
|
138
258
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Close the manager and release any outstanding NSD listeners.
|
|
261
|
+
* Safe to call multiple times; also invoked by the plugin's handleOnDestroy().
|
|
262
|
+
*/
|
|
263
|
+
fun close(onComplete: () -> Unit = {}) {
|
|
264
|
+
runOnMain {
|
|
265
|
+
closed = true
|
|
266
|
+
val watch = activeWatch
|
|
267
|
+
activeWatch = null
|
|
268
|
+
watch?.close()
|
|
269
|
+
activeDiscovery?.fail(IllegalStateException("mDNS manager closed before discovery completed"))
|
|
270
|
+
scope.launch(mainDispatcher) {
|
|
271
|
+
try {
|
|
272
|
+
watchMutex.withLock { stopWatch() }
|
|
273
|
+
broadcastMutex.withLock {
|
|
274
|
+
stopActiveRegistration("mDNS manager closed before publish completed")
|
|
275
|
+
}
|
|
276
|
+
} catch (_: Throwable) {
|
|
277
|
+
// Keep the registration for cleanup if the OS confirms it after close.
|
|
278
|
+
} finally {
|
|
279
|
+
activeRegistration?.let { releaseRegistrationLock(it) }
|
|
280
|
+
onComplete()
|
|
281
|
+
if (externalScope == null) scope.cancel()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
145
284
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- Registration helpers ------------------------------------------------
|
|
288
|
+
|
|
289
|
+
private fun handleRegistered(session: RegistrationSession, publishedName: String) {
|
|
290
|
+
if (session.finished) return
|
|
291
|
+
if (session.cancelled) {
|
|
292
|
+
// The caller gave up (stop/replace/timeout) before the OS confirmed: undo it now.
|
|
293
|
+
unregister(session)
|
|
294
|
+
return
|
|
149
295
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
296
|
+
completeRegistrationSuccess(session, publishedName)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private fun completeRegistrationSuccess(session: RegistrationSession, publishedName: String) {
|
|
300
|
+
if (session.completed) return
|
|
301
|
+
session.completed = true
|
|
302
|
+
session.timeoutJob?.cancel()
|
|
303
|
+
session.onSuccess(publishedName)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun completeRegistrationError(
|
|
307
|
+
session: RegistrationSession,
|
|
308
|
+
error: Throwable
|
|
309
|
+
) {
|
|
310
|
+
if (session.completed) return
|
|
311
|
+
session.completed = true
|
|
312
|
+
session.timeoutJob?.cancel()
|
|
313
|
+
session.onError(error)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private suspend fun stopActiveRegistration(reason: String) {
|
|
317
|
+
val session = activeRegistration ?: return
|
|
318
|
+
session.cancelled = true
|
|
319
|
+
completeRegistrationError(session, IllegalStateException(reason))
|
|
320
|
+
if (session.stopped.isCompleted) session.stopped = CompletableDeferred()
|
|
321
|
+
// NsdManager identifies pending requests by their listener, so cancellation need not wait for confirmation.
|
|
322
|
+
unregister(session)
|
|
323
|
+
try {
|
|
324
|
+
withTimeout(stopTimeoutMs) { session.stopped.await() }?.let { throw it }
|
|
325
|
+
} catch (t: Throwable) {
|
|
326
|
+
releaseRegistrationLock(session)
|
|
327
|
+
if (t is TimeoutCancellationException) {
|
|
328
|
+
throw IllegalStateException("Timed out waiting for service stop after ${stopTimeoutMs}ms")
|
|
329
|
+
}
|
|
330
|
+
throw t
|
|
154
331
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private fun unregister(session: RegistrationSession) {
|
|
335
|
+
if (session.stopping) return
|
|
336
|
+
val token = session.token ?: return
|
|
337
|
+
session.stopping = true
|
|
338
|
+
try {
|
|
339
|
+
backend.unregisterService(token)
|
|
340
|
+
} catch (t: Throwable) {
|
|
341
|
+
session.stopping = false
|
|
342
|
+
session.stopped.complete(t)
|
|
159
343
|
}
|
|
344
|
+
}
|
|
160
345
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
346
|
+
private fun finishRegistration(session: RegistrationSession) {
|
|
347
|
+
session.finished = true
|
|
348
|
+
if (activeRegistration === session) activeRegistration = null
|
|
349
|
+
releaseRegistrationLock(session)
|
|
350
|
+
session.stopped.complete(null)
|
|
351
|
+
}
|
|
165
352
|
|
|
166
|
-
|
|
167
|
-
|
|
353
|
+
private fun releaseRegistrationLock(session: RegistrationSession) {
|
|
354
|
+
if (!session.lockHeld) return
|
|
355
|
+
session.lockHeld = false
|
|
356
|
+
backend.releaseMulticastLock()
|
|
357
|
+
}
|
|
168
358
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
359
|
+
// ---- Discovery session ---------------------------------------------------
|
|
360
|
+
|
|
361
|
+
/** Thanks to Idrimi for the original continuous discovery feature (PR #7). */
|
|
362
|
+
suspend fun watchServices(typesRaw: List<String>, listener: WatchListener): Int = watchMutex.withLock { withContext(mainDispatcher) {
|
|
363
|
+
check(!closed) { "mDNS manager is closed" }
|
|
364
|
+
require(typesRaw.isNotEmpty()) { "Missing or empty 'types' array" }
|
|
365
|
+
val types = typesRaw.map { MdnsValidation.type(it.also { raw -> require(raw.isNotBlank()) { "Invalid service type" } }) }.distinct()
|
|
366
|
+
stopWatch()?.let { throw it }
|
|
367
|
+
check(!closed) { "mDNS manager is closed" }
|
|
368
|
+
val session = DiscoverySession(types, null, listener)
|
|
369
|
+
activeWatch = session
|
|
370
|
+
try {
|
|
371
|
+
session.start(null)
|
|
372
|
+
types.size
|
|
373
|
+
} catch (t: Throwable) {
|
|
374
|
+
session.close()
|
|
375
|
+
if (activeWatch === session) activeWatch = null
|
|
376
|
+
throw t
|
|
175
377
|
}
|
|
378
|
+
} }
|
|
379
|
+
|
|
380
|
+
suspend fun stopWatching(): Throwable? = watchMutex.withLock { withContext(mainDispatcher) { stopWatch() } }
|
|
381
|
+
|
|
382
|
+
private suspend fun stopWatch(): Throwable? {
|
|
383
|
+
val old = activeWatch
|
|
384
|
+
activeWatch = null
|
|
385
|
+
val errors = mutableListOf<Throwable>()
|
|
386
|
+
old?.close()
|
|
387
|
+
for ((token, initial) in discoveryStops.toMap()) {
|
|
388
|
+
val result = if (retryDiscoveryStops.remove(token)) requestDiscoveryStop(token) else initial
|
|
389
|
+
val error = try { withTimeout(stopTimeoutMs) { result.await() } }
|
|
390
|
+
catch (t: Throwable) { if (t is TimeoutCancellationException) IllegalStateException("Timed out waiting for discovery stop") else t }
|
|
391
|
+
if (error != null) { retryDiscoveryStops += token; errors += error }
|
|
392
|
+
}
|
|
393
|
+
return errors.firstOrNull()
|
|
394
|
+
}
|
|
176
395
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
396
|
+
private fun requestDiscoveryStop(token: Any): CompletableDeferred<Throwable?> {
|
|
397
|
+
val result = CompletableDeferred<Throwable?>()
|
|
398
|
+
discoveryStops[token] = result
|
|
399
|
+
try { backend.stopDiscovery(token) }
|
|
400
|
+
catch (t: Throwable) { result.complete(t) }
|
|
401
|
+
return result
|
|
402
|
+
}
|
|
182
403
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
404
|
+
/** Discovery and watching share the device's single legacy resolver slot. */
|
|
405
|
+
private fun enqueue(p: Pending) {
|
|
406
|
+
if (p.done) return
|
|
407
|
+
if (backend.supportsConcurrentResolve) startResolve(p)
|
|
408
|
+
else { resolveQueue.addLast(p); pump() }
|
|
409
|
+
}
|
|
187
410
|
|
|
188
|
-
|
|
189
|
-
|
|
411
|
+
private fun pump() {
|
|
412
|
+
if (resolving.isNotEmpty()) return
|
|
413
|
+
val p = resolveQueue.removeFirstOrNull() ?: return
|
|
414
|
+
startResolve(p)
|
|
415
|
+
}
|
|
190
416
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
417
|
+
private fun startResolve(p: Pending) {
|
|
418
|
+
if (p.done) return
|
|
419
|
+
val generation = ++p.generation
|
|
420
|
+
resolving.add(p)
|
|
421
|
+
p.timeoutJob = scope.launch(mainDispatcher) {
|
|
422
|
+
delay(resolveTimeoutMs)
|
|
423
|
+
if (p.generation != generation) return@launch
|
|
424
|
+
if (p.done) { finishResolve(p); pump(); return@launch }
|
|
425
|
+
resolveFailed(p, "Timed out resolving ${p.service.name}", retry = p.owner.isWatching)
|
|
426
|
+
}
|
|
427
|
+
val listener = object : NsdBackend.ResolveListener {
|
|
428
|
+
override fun onResolved(service: MdnsService) = runOnMain {
|
|
429
|
+
if (p.generation != generation || p !in resolving) return@runOnMain
|
|
430
|
+
if (p.done) { finishResolve(p); pump(); return@runOnMain }
|
|
431
|
+
if (service.hosts.isEmpty()) {
|
|
432
|
+
resolveFailed(p, "Resolved service has no IP address", retry = true)
|
|
433
|
+
return@runOnMain
|
|
195
434
|
}
|
|
435
|
+
finishResolve(p)
|
|
436
|
+
p.owner.resolved(p, service)
|
|
437
|
+
pump()
|
|
196
438
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
} catch (_: Throwable) {
|
|
202
|
-
serviceInfoCallbacks.remove(callback)
|
|
439
|
+
override fun onFailed(errorCode: Int) = runOnMain {
|
|
440
|
+
if (p.generation != generation) return@runOnMain
|
|
441
|
+
if (p.done) { finishResolve(p); pump(); return@runOnMain }
|
|
442
|
+
resolveFailed(p, "Resolve failed: $errorCode", p.owner.isWatching || errorCode == NsdManager.FAILURE_ALREADY_ACTIVE)
|
|
203
443
|
}
|
|
204
444
|
}
|
|
445
|
+
try {
|
|
446
|
+
p.token = backend.resolve(p.service, listener)
|
|
447
|
+
if (p.done) p.token?.let { safeCancelResolve(it) }
|
|
448
|
+
} catch (t: Throwable) {
|
|
449
|
+
resolveFailed(p, t.message ?: "Resolve failed", retry = p.owner.isWatching)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
205
452
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
453
|
+
private fun resolveFailed(p: Pending, message: String, retry: Boolean) {
|
|
454
|
+
if (p.done || p !in resolving) return
|
|
455
|
+
p.timeoutJob?.cancel()
|
|
456
|
+
resolving.remove(p)
|
|
457
|
+
p.token?.let { safeCancelResolve(it) }
|
|
458
|
+
p.token = null
|
|
459
|
+
if (retry && p.attempts++ < MAX_RESOLVE_RETRIES) {
|
|
460
|
+
p.retryJob = scope.launch(mainDispatcher) {
|
|
461
|
+
delay(RESOLVE_RETRY_DELAY_MS)
|
|
462
|
+
enqueue(p)
|
|
210
463
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
464
|
+
} else {
|
|
465
|
+
p.done = true
|
|
466
|
+
p.owner.resolveFailed(p, message)
|
|
467
|
+
}
|
|
468
|
+
pump()
|
|
469
|
+
}
|
|
214
470
|
|
|
215
|
-
|
|
216
|
-
|
|
471
|
+
private fun finishResolve(p: Pending, cancelled: Boolean = false) {
|
|
472
|
+
p.done = true
|
|
473
|
+
p.retryJob?.cancel()
|
|
474
|
+
resolveQueue.remove(p)
|
|
475
|
+
// Legacy NSD cannot cancel a resolve; reserve its slot until its callback or timeout.
|
|
476
|
+
if (cancelled && !backend.supportsConcurrentResolve && p in resolving) return
|
|
477
|
+
p.timeoutJob?.cancel()
|
|
478
|
+
resolving.remove(p)
|
|
479
|
+
p.token?.let { safeCancelResolve(it) }
|
|
480
|
+
}
|
|
217
481
|
|
|
218
|
-
|
|
219
|
-
|
|
482
|
+
/** One browser group, with its own membership, callbacks and multicast lock. */
|
|
483
|
+
private inner class DiscoverySession(
|
|
484
|
+
private val types: List<String>,
|
|
485
|
+
private val targetName: String?,
|
|
486
|
+
private val watchListener: WatchListener? = null
|
|
487
|
+
) {
|
|
488
|
+
val result = CompletableDeferred<List<MdnsService>>()
|
|
489
|
+
val isWatching get() = watchListener != null
|
|
490
|
+
private val found = linkedMapOf<String, MdnsService>()
|
|
491
|
+
private val pending = mutableMapOf<String, Pending>()
|
|
492
|
+
private val discoveryTokens = mutableListOf<Any>()
|
|
493
|
+
private var timeoutJob: Job? = null
|
|
494
|
+
private var closed = false
|
|
495
|
+
private var lockHeld = false
|
|
496
|
+
|
|
497
|
+
fun start(timeoutMs: Long?) {
|
|
498
|
+
backend.acquireMulticastLock()
|
|
499
|
+
lockHeld = true
|
|
500
|
+
if (timeoutMs != null) timeoutJob = scope.launch(mainDispatcher) {
|
|
501
|
+
delay(timeoutMs)
|
|
502
|
+
finish()
|
|
503
|
+
}
|
|
504
|
+
for (type in types) {
|
|
505
|
+
if (closed) return
|
|
506
|
+
var token: Any? = null
|
|
507
|
+
var startFailed = false
|
|
508
|
+
val listener = object : NsdBackend.DiscoveryListener {
|
|
509
|
+
override fun onStartFailed(errorCode: Int) = runOnMain {
|
|
510
|
+
startFailed = true
|
|
511
|
+
discoveryTokens.remove(token)
|
|
512
|
+
fail(IllegalStateException("Discovery failed to start: $errorCode"), type)
|
|
513
|
+
}
|
|
514
|
+
override fun onStopFailed(errorCode: Int) = runOnMain {
|
|
515
|
+
val error = IllegalStateException("Discovery failed to stop: $errorCode")
|
|
516
|
+
if (closed) token?.let { discoveryStops[it]?.complete(error) }
|
|
517
|
+
else fail(error, type)
|
|
518
|
+
}
|
|
519
|
+
override fun onStopped() = runOnMain {
|
|
520
|
+
token?.let { discoveryStops.remove(it)?.complete(null); retryDiscoveryStops.remove(it) }
|
|
521
|
+
if (!closed) fail(IllegalStateException("Discovery stopped unexpectedly"), type)
|
|
522
|
+
}
|
|
523
|
+
override fun onFound(service: DiscoveredService) = runOnMain {
|
|
524
|
+
handleFound(DiscoveredService(service.name, type, service.handle))
|
|
525
|
+
}
|
|
526
|
+
override fun onLost(service: DiscoveredService) = runOnMain {
|
|
527
|
+
if (closed || !isWatching) return@runOnMain
|
|
528
|
+
val key = key(service.name, type)
|
|
529
|
+
pending.remove(key)?.let { finishResolve(it, cancelled = true); pump() }
|
|
530
|
+
found.remove(key)?.let { watchListener?.onServiceLost(it) }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
val startedToken = backend.startDiscovery(type, listener)
|
|
534
|
+
token = startedToken
|
|
535
|
+
if (startFailed) continue
|
|
536
|
+
if (closed) requestDiscoveryStop(startedToken) else discoveryTokens += startedToken
|
|
220
537
|
}
|
|
221
|
-
|
|
222
|
-
override fun onServiceLost(serviceInfo: NsdServiceInfo) { /* no-op */ }
|
|
223
538
|
}
|
|
224
539
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
540
|
+
fun finish() {
|
|
541
|
+
if (closed) return
|
|
542
|
+
val snapshot = found.values.toList()
|
|
543
|
+
close()
|
|
544
|
+
result.complete(snapshot)
|
|
545
|
+
}
|
|
228
546
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
547
|
+
fun fail(error: Throwable, type: String = types.first()) {
|
|
548
|
+
if (closed) return
|
|
549
|
+
close()
|
|
550
|
+
if (isWatching) {
|
|
551
|
+
if (activeWatch === this) activeWatch = null
|
|
552
|
+
watchListener?.onError(type, error.message ?: "Discovery failed", false)
|
|
553
|
+
} else result.completeExceptionally(error)
|
|
232
554
|
}
|
|
233
555
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
556
|
+
fun close() {
|
|
557
|
+
if (closed) return
|
|
558
|
+
closed = true
|
|
559
|
+
timeoutJob?.cancel()
|
|
560
|
+
pending.values.toList().forEach { finishResolve(it, cancelled = true) }
|
|
561
|
+
pending.clear()
|
|
562
|
+
pump()
|
|
563
|
+
for (token in discoveryTokens) {
|
|
564
|
+
requestDiscoveryStop(token)
|
|
565
|
+
}
|
|
566
|
+
discoveryTokens.clear()
|
|
567
|
+
if (lockHeld) {
|
|
568
|
+
lockHeld = false
|
|
569
|
+
backend.releaseMulticastLock()
|
|
570
|
+
}
|
|
241
571
|
}
|
|
242
|
-
}
|
|
243
572
|
|
|
244
|
-
|
|
245
|
-
* Close the manager and release any outstanding NSD listeners.
|
|
246
|
-
* Safe to call multiple times; also invoked by the plugin's handleOnDestroy().
|
|
247
|
-
*/
|
|
248
|
-
fun close() {
|
|
249
|
-
stopBroadcast()
|
|
250
|
-
discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
|
|
251
|
-
if (externalScope == null) scope.cancel()
|
|
252
|
-
}
|
|
573
|
+
private fun key(name: String, type: String) = "$name|$type"
|
|
253
574
|
|
|
254
|
-
|
|
575
|
+
private fun handleFound(service: DiscoveredService) {
|
|
576
|
+
if (closed || !matchesTarget(service.name)) return
|
|
577
|
+
val key = key(service.name, service.type)
|
|
578
|
+
if (key in found || key in pending) return
|
|
579
|
+
val p = Pending(this, service)
|
|
580
|
+
pending[key] = p
|
|
581
|
+
enqueue(p)
|
|
582
|
+
}
|
|
255
583
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
584
|
+
fun resolved(p: Pending, service: MdnsService) {
|
|
585
|
+
val key = key(p.service.name, p.service.type)
|
|
586
|
+
if (closed || pending.remove(key) !== p) return
|
|
587
|
+
val item = service.copy(type = p.service.type, hosts = service.hosts.sortedBy { ':' in it }, txt = service.txt.toMap())
|
|
588
|
+
found[key] = item
|
|
589
|
+
if (isWatching) watchListener?.onServiceFound(item)
|
|
590
|
+
else if (targetName != null && matchesTarget(item.name)) finish()
|
|
591
|
+
}
|
|
260
592
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
593
|
+
fun resolveFailed(p: Pending, message: String) {
|
|
594
|
+
if (closed || pending.remove(key(p.service.name, p.service.type)) !== p) return
|
|
595
|
+
watchListener?.onError(p.service.type, message, true)
|
|
596
|
+
}
|
|
265
597
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
try { nsd.unregisterServiceInfoCallback(l) } catch (_: Throwable) {}
|
|
598
|
+
private fun matchesTarget(candidate: String): Boolean {
|
|
599
|
+
val target = targetName ?: return true
|
|
600
|
+
return normalize(candidate).startsWith(normalize(target))
|
|
270
601
|
}
|
|
271
602
|
}
|
|
272
603
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
name = s.serviceName,
|
|
276
|
-
type = s.serviceType,
|
|
277
|
-
hosts = hostAddresses(s),
|
|
278
|
-
port = s.port
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
private fun hostAddresses(s: NsdServiceInfo): List<String> =
|
|
282
|
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
283
|
-
s.hostAddresses.mapNotNull { it.hostAddress }
|
|
284
|
-
} else {
|
|
285
|
-
legacyHostAddress(s)?.let { listOf(it) } ?: emptyList()
|
|
286
|
-
}
|
|
604
|
+
/** Normalization for the " (n)" suffix appended by the OS. */
|
|
605
|
+
private fun normalize(s: String): String = s.replace(NAME_SUFFIX, "").replace('.', '-')
|
|
287
606
|
|
|
288
|
-
|
|
289
|
-
private fun
|
|
607
|
+
/** Best-effort stop service info updates; NSD may throw if not registered. */
|
|
608
|
+
private fun safeCancelResolve(token: Any) {
|
|
609
|
+
try { backend.cancelResolve(token) } catch (_: Throwable) {}
|
|
610
|
+
}
|
|
290
611
|
}
|