@devioarts/capacitor-mdns 0.1.0 → 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 +221 -37
- package/android/build.gradle +3 -9
- 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 +467 -243
- package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +80 -57
- package/dist/docs.json +250 -14
- package/dist/esm/definitions.d.ts +64 -17
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.js +22 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +6 -1
- package/dist/esm/web.js +23 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +45 -2
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +45 -2
- package/dist/plugin.js.map +1 -1
- package/docs/electron.md +29 -3
- package/docs/platform-setup.md +6 -0
- package/docs/playground.md +33 -2
- package/docs/runtime-behavior.md +68 -2
- package/electron/dist/plugin-settings.js +10 -3
- package/electron/dist/plugin-settings.js.map +1 -1
- package/electron/dist/plugin.cjs.js +519 -89
- package/electron/dist/plugin.cjs.js.map +1 -1
- package/electron/mdns-bridge.cjs +25 -2
- package/electron/mdns.ts +514 -81
- package/electron/rollup.config.mjs +1 -1
- package/electron/src/index.ts +1 -0
- package/electron/src/plugin-settings.ts +10 -3
- package/electron/types-public/index.d.ts +25 -3
- package/ios/Sources/mDNSPlugin/mDNS.swift +407 -117
- package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +77 -24
- package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
- package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
- package/package.json +11 -4
- package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -9
|
@@ -3,15 +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
|
|
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
|
|
11
16
|
import kotlinx.coroutines.sync.Mutex
|
|
12
17
|
import kotlinx.coroutines.sync.withLock
|
|
13
|
-
import
|
|
14
|
-
import
|
|
18
|
+
import kotlinx.coroutines.withContext
|
|
19
|
+
import kotlinx.coroutines.withTimeout
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
22
|
* Thin Android NSD (Bonjour/mDNS) wrapper used by the Capacitor bridge.
|
|
@@ -23,18 +28,38 @@ import java.util.concurrent.atomic.AtomicReference
|
|
|
23
28
|
* - Timebox discovery; early-exit when the target match is resolved.
|
|
24
29
|
*
|
|
25
30
|
* Threading:
|
|
26
|
-
* - All
|
|
27
|
-
*
|
|
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.
|
|
28
34
|
*
|
|
29
35
|
* Platform notes:
|
|
30
|
-
* -
|
|
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.
|
|
31
38
|
*/
|
|
32
|
-
class mDNS(
|
|
33
|
-
|
|
34
|
-
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
|
|
35
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
|
+
|
|
36
57
|
private companion object {
|
|
37
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+\)$""")
|
|
38
63
|
}
|
|
39
64
|
|
|
40
65
|
/** Normalized service representation returned to the bridge. */
|
|
@@ -42,37 +67,63 @@ class mDNS(
|
|
|
42
67
|
val name: String,
|
|
43
68
|
val type: String,
|
|
44
69
|
val hosts: List<String>, // numeric addresses (v4/v6)
|
|
45
|
-
val port: Int
|
|
70
|
+
val port: Int,
|
|
71
|
+
val txt: Map<String, String> = emptyMap()
|
|
46
72
|
)
|
|
47
73
|
|
|
48
74
|
private class RegistrationSession(
|
|
49
|
-
val listener: NsdManager.RegistrationListener,
|
|
50
|
-
val timeoutJob: Job,
|
|
51
75
|
val onSuccess: (String) -> Unit,
|
|
52
76
|
val onError: (Throwable) -> Unit
|
|
53
77
|
) {
|
|
78
|
+
var timeoutJob: Job? = null
|
|
79
|
+
var token: Any? = null
|
|
54
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
|
|
55
87
|
}
|
|
56
88
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
|
60
95
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
|
64
104
|
|
|
65
105
|
/** Optional external scope (from plugin); otherwise create our own on Main. */
|
|
66
|
-
private val scope = externalScope ?: CoroutineScope(SupervisorJob() +
|
|
67
|
-
private val
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
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)
|
|
74
123
|
}
|
|
75
124
|
|
|
125
|
+
// ---- Broadcast -----------------------------------------------------------
|
|
126
|
+
|
|
76
127
|
/**
|
|
77
128
|
* Start advertising a service. If already registered, the previous one is unregistered first.
|
|
78
129
|
*
|
|
@@ -87,59 +138,67 @@ class mDNS(
|
|
|
87
138
|
name: String,
|
|
88
139
|
port: Int,
|
|
89
140
|
onSuccess: (String) -> Unit,
|
|
90
|
-
onError: (Throwable) -> Unit
|
|
141
|
+
onError: (Throwable) -> Unit,
|
|
142
|
+
txt: Map<String, String> = emptyMap()
|
|
91
143
|
) {
|
|
92
144
|
require(port in 1..65535) { "Port must be between 1 and 65535" }
|
|
93
|
-
val type =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
// Restarting while publish is pending must complete the old JS promise.
|
|
98
|
-
stopActiveRegistration("Replaced by a new broadcast request")
|
|
145
|
+
val type = MdnsValidation.type(typeRaw)
|
|
146
|
+
val normalizedName = MdnsValidation.name(name).ifEmpty { "DevIOArtsMDNS" }
|
|
147
|
+
MdnsValidation.txt(txt)
|
|
99
148
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|
|
110
169
|
|
|
111
|
-
|
|
112
|
-
completeRegistrationError(
|
|
113
|
-
session,
|
|
114
|
-
IllegalStateException("Registration failed: $errorCode"),
|
|
115
|
-
clearActive = true
|
|
116
|
-
)
|
|
170
|
+
override fun onUnregistered() = runOnMain { finishRegistration(session) }
|
|
117
171
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
172
|
+
override fun onUnregistrationFailed(errorCode: Int) = runOnMain {
|
|
173
|
+
session.stopping = false
|
|
174
|
+
session.stopped.complete(IllegalStateException("Unregistration failed: $errorCode"))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
121
177
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
178
|
+
session.timeoutJob = scope.launch(mainDispatcher) {
|
|
179
|
+
delay(publishTimeoutMs)
|
|
180
|
+
session.cancelled = true
|
|
125
181
|
completeRegistrationError(
|
|
126
182
|
session,
|
|
127
|
-
IllegalStateException("Timed out waiting for service publish after ${
|
|
128
|
-
clearActive = true,
|
|
129
|
-
unregister = true
|
|
183
|
+
IllegalStateException("Timed out waiting for service publish after ${publishTimeoutMs}ms")
|
|
130
184
|
)
|
|
185
|
+
unregister(session)
|
|
186
|
+
releaseRegistrationLock(session)
|
|
131
187
|
}
|
|
132
|
-
}
|
|
133
188
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
199
|
} catch (t: Throwable) {
|
|
139
|
-
|
|
200
|
+
onError(t)
|
|
140
201
|
}
|
|
141
|
-
} catch (t: Throwable) {
|
|
142
|
-
onError(t)
|
|
143
202
|
}
|
|
144
203
|
}
|
|
145
204
|
}
|
|
@@ -147,17 +206,25 @@ class mDNS(
|
|
|
147
206
|
/**
|
|
148
207
|
* Stop advertising the currently registered service (if any). Safe to call multiple times.
|
|
149
208
|
*/
|
|
150
|
-
fun stopBroadcast() {
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
}
|
|
153
218
|
}
|
|
154
219
|
}
|
|
155
220
|
|
|
221
|
+
// ---- Discover ------------------------------------------------------------
|
|
222
|
+
|
|
156
223
|
/**
|
|
157
224
|
* Discover services, optionally filtering by instance name using a normalized exact-or-prefix match.
|
|
158
225
|
*
|
|
159
226
|
* Behavior:
|
|
160
|
-
* - Runs on the main dispatcher.
|
|
227
|
+
* - Runs on the main dispatcher; a new valid call replaces the previous one.
|
|
161
228
|
* - Resolves candidates to host/port.
|
|
162
229
|
* - If `targetName` is provided, compare using normalized names and accept exact OR prefix match.
|
|
163
230
|
* - Early-exits when a matching service is resolved (stops discovery immediately).
|
|
@@ -172,216 +239,373 @@ class mDNS(
|
|
|
172
239
|
typeRaw: String,
|
|
173
240
|
targetName: String? = null,
|
|
174
241
|
timeoutMs: Int = 3000
|
|
175
|
-
): List<MdnsService> =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
val result = CompletableDeferred<List<MdnsService>>()
|
|
183
|
-
|
|
184
|
-
// Normalization for "(n)" suffix appended by the OS.
|
|
185
|
-
fun normalize(s: String): String = s.replace(Regex(" \\(\\d+\\)\$"), "")
|
|
186
|
-
fun matchesTarget(candidate: String): Boolean {
|
|
187
|
-
val c = normalize(candidate)
|
|
188
|
-
val t = normalize(targetName ?: return true) // if no target, accept all
|
|
189
|
-
return (c == t) || c.startsWith(t)
|
|
190
|
-
}
|
|
191
|
-
fun upsert(item: MdnsService) {
|
|
192
|
-
val index = found.indexOfFirst { it.name == item.name && it.type == item.type && it.port == item.port }
|
|
193
|
-
if (index >= 0) found[index] = item else found.add(item)
|
|
194
|
-
}
|
|
195
|
-
fun unregisterServiceInfoCallbacks() {
|
|
196
|
-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
|
|
197
|
-
serviceInfoCallbacks.toList().forEach { safeUnregisterServiceInfoCallback(it) }
|
|
198
|
-
serviceInfoCallbacks.clear()
|
|
199
|
-
}
|
|
200
|
-
fun completeWithCurrentResults(discoveryListener: NsdManager.DiscoveryListener) {
|
|
201
|
-
unregisterServiceInfoCallbacks()
|
|
202
|
-
safeStopDiscovery(discoveryListener)
|
|
203
|
-
discListenerRef.compareAndSet(discoveryListener, null)
|
|
204
|
-
if (!result.isCompleted) result.complete(found.toList())
|
|
205
|
-
}
|
|
206
|
-
fun completeWithError(discoveryListener: NsdManager.DiscoveryListener, error: Throwable) {
|
|
207
|
-
unregisterServiceInfoCallbacks()
|
|
208
|
-
safeStopDiscovery(discoveryListener)
|
|
209
|
-
discListenerRef.compareAndSet(discoveryListener, null)
|
|
210
|
-
if (!result.isCompleted) result.completeExceptionally(error)
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
@Suppress("DEPRECATION")
|
|
214
|
-
fun resolveLegacy(si: NsdServiceInfo, discoveryListener: NsdManager.DiscoveryListener) {
|
|
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
|
|
215
249
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// Early-exit for exact/prefix target match.
|
|
224
|
-
if (targetName != null && matchesTarget(s.serviceName)) {
|
|
225
|
-
completeWithCurrentResults(discoveryListener)
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
})
|
|
229
|
-
} catch (_: Throwable) {
|
|
230
|
-
// Some Android releases throw when the NSD daemon is busy; ignore this candidate.
|
|
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
|
|
231
256
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
fun registerServiceInfoCallback(si: NsdServiceInfo, discoveryListener: NsdManager.DiscoveryListener) {
|
|
235
|
-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
236
|
-
resolveLegacy(si, discoveryListener)
|
|
237
|
-
return
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
val callback = object : NsdManager.ServiceInfoCallback {
|
|
241
|
-
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { /* ignore */ }
|
|
242
|
-
override fun onServiceInfoCallbackUnregistered() { /* no-op */ }
|
|
243
|
-
override fun onServiceLost() { /* no-op */ }
|
|
244
|
-
|
|
245
|
-
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
|
246
|
-
if (result.isCompleted) return
|
|
247
|
-
upsert(toMdnsService(serviceInfo))
|
|
257
|
+
}
|
|
248
258
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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")
|
|
252
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()
|
|
253
282
|
}
|
|
254
283
|
}
|
|
255
|
-
|
|
256
|
-
serviceInfoCallbacks.add(callback)
|
|
257
|
-
try {
|
|
258
|
-
nsd.registerServiceInfoCallback(si, mainExecutor, callback)
|
|
259
|
-
} catch (_: Throwable) {
|
|
260
|
-
serviceInfoCallbacks.remove(callback)
|
|
261
|
-
}
|
|
262
284
|
}
|
|
285
|
+
}
|
|
263
286
|
|
|
264
|
-
|
|
265
|
-
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
|
266
|
-
completeWithError(this, IllegalStateException("Discovery failed to start: $errorCode"))
|
|
267
|
-
}
|
|
268
|
-
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { /* no-op */ }
|
|
269
|
-
override fun onDiscoveryStarted(serviceType: String) { /* no-op */ }
|
|
270
|
-
override fun onDiscoveryStopped(serviceType: String) { /* no-op */ }
|
|
287
|
+
// ---- Registration helpers ------------------------------------------------
|
|
271
288
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
295
|
+
}
|
|
296
|
+
completeRegistrationSuccess(session, publishedName)
|
|
297
|
+
}
|
|
275
298
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|
|
279
305
|
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
}
|
|
282
315
|
|
|
283
|
-
|
|
284
|
-
|
|
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)
|
|
285
323
|
try {
|
|
286
|
-
|
|
324
|
+
withTimeout(stopTimeoutMs) { session.stopped.await() }?.let { throw it }
|
|
287
325
|
} catch (t: Throwable) {
|
|
288
|
-
|
|
326
|
+
releaseRegistrationLock(session)
|
|
327
|
+
if (t is TimeoutCancellationException) {
|
|
328
|
+
throw IllegalStateException("Timed out waiting for service stop after ${stopTimeoutMs}ms")
|
|
329
|
+
}
|
|
289
330
|
throw t
|
|
290
331
|
}
|
|
332
|
+
}
|
|
291
333
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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)
|
|
295
343
|
}
|
|
344
|
+
}
|
|
345
|
+
|
|
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
|
+
}
|
|
296
352
|
|
|
353
|
+
private fun releaseRegistrationLock(session: RegistrationSession) {
|
|
354
|
+
if (!session.lockHeld) return
|
|
355
|
+
session.lockHeld = false
|
|
356
|
+
backend.releaseMulticastLock()
|
|
357
|
+
}
|
|
358
|
+
|
|
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
|
|
297
370
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
371
|
+
session.start(null)
|
|
372
|
+
types.size
|
|
373
|
+
} catch (t: Throwable) {
|
|
374
|
+
session.close()
|
|
375
|
+
if (activeWatch === session) activeWatch = null
|
|
376
|
+
throw t
|
|
304
377
|
}
|
|
305
378
|
} }
|
|
306
379
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 }
|
|
315
392
|
}
|
|
316
|
-
|
|
393
|
+
return errors.firstOrNull()
|
|
317
394
|
}
|
|
318
395
|
|
|
319
|
-
|
|
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
|
+
}
|
|
320
403
|
|
|
321
|
-
/**
|
|
322
|
-
private fun
|
|
323
|
-
|
|
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() }
|
|
324
409
|
}
|
|
325
410
|
|
|
326
|
-
private fun
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
session.onSuccess(publishedName)
|
|
411
|
+
private fun pump() {
|
|
412
|
+
if (resolving.isNotEmpty()) return
|
|
413
|
+
val p = resolveQueue.removeFirstOrNull() ?: return
|
|
414
|
+
startResolve(p)
|
|
331
415
|
}
|
|
332
416
|
|
|
333
|
-
private fun
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
434
|
+
}
|
|
435
|
+
finishResolve(p)
|
|
436
|
+
p.owner.resolved(p, service)
|
|
437
|
+
pump()
|
|
438
|
+
}
|
|
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)
|
|
443
|
+
}
|
|
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
|
+
}
|
|
345
451
|
}
|
|
346
452
|
|
|
347
|
-
private fun
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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)
|
|
463
|
+
}
|
|
353
464
|
} else {
|
|
354
|
-
|
|
465
|
+
p.done = true
|
|
466
|
+
p.owner.resolveFailed(p, message)
|
|
355
467
|
}
|
|
468
|
+
pump()
|
|
356
469
|
}
|
|
357
470
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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) }
|
|
361
480
|
}
|
|
362
481
|
|
|
363
|
-
/**
|
|
364
|
-
private
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
537
|
+
}
|
|
367
538
|
}
|
|
368
|
-
}
|
|
369
539
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
540
|
+
fun finish() {
|
|
541
|
+
if (closed) return
|
|
542
|
+
val snapshot = found.values.toList()
|
|
543
|
+
close()
|
|
544
|
+
result.complete(snapshot)
|
|
545
|
+
}
|
|
546
|
+
|
|
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)
|
|
383
554
|
}
|
|
384
555
|
|
|
385
|
-
|
|
386
|
-
|
|
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
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private fun key(name: String, type: String) = "$name|$type"
|
|
574
|
+
|
|
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
|
+
}
|
|
583
|
+
|
|
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
|
+
}
|
|
592
|
+
|
|
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
|
+
}
|
|
597
|
+
|
|
598
|
+
private fun matchesTarget(candidate: String): Boolean {
|
|
599
|
+
val target = targetName ?: return true
|
|
600
|
+
return normalize(candidate).startsWith(normalize(target))
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Normalization for the " (n)" suffix appended by the OS. */
|
|
605
|
+
private fun normalize(s: String): String = s.replace(NAME_SUFFIX, "").replace('.', '-')
|
|
606
|
+
|
|
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
|
+
}
|
|
387
611
|
}
|