@devioarts/capacitor-mdns 0.0.2 → 0.1.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.
Files changed (38) hide show
  1. package/Package.swift +1 -1
  2. package/README.md +54 -67
  3. package/android/build.gradle +11 -11
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +217 -51
  6. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +20 -9
  7. package/dist/docs.json +65 -3
  8. package/dist/esm/definitions.d.ts +28 -2
  9. package/dist/esm/definitions.js.map +1 -1
  10. package/dist/esm/index.js +1 -0
  11. package/dist/esm/index.js.map +1 -1
  12. package/dist/esm/web.d.ts +3 -1
  13. package/dist/esm/web.js +12 -3
  14. package/dist/esm/web.js.map +1 -1
  15. package/dist/plugin.cjs.js +13 -3
  16. package/dist/plugin.cjs.js.map +1 -1
  17. package/dist/plugin.js +13 -3
  18. package/dist/plugin.js.map +1 -1
  19. package/docs/electron.md +65 -0
  20. package/docs/platform-setup.md +52 -0
  21. package/docs/playground.md +19 -0
  22. package/docs/runtime-behavior.md +14 -0
  23. package/electron/dist/plugin-settings.js +15 -0
  24. package/electron/dist/plugin-settings.js.map +1 -0
  25. package/electron/dist/plugin.cjs.js +344 -0
  26. package/electron/dist/plugin.cjs.js.map +1 -0
  27. package/electron/global.d.ts +2 -0
  28. package/electron/mdns-bridge.cjs +9 -4
  29. package/electron/mdns.ts +151 -63
  30. package/electron/rollup.config.mjs +22 -0
  31. package/electron/src/index.ts +1 -0
  32. package/electron/src/plugin-settings.ts +12 -0
  33. package/electron/tsconfig.json +21 -0
  34. package/electron/types-public/index.d.ts +25 -0
  35. package/ios/Sources/mDNSPlugin/mDNS.swift +95 -28
  36. package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +12 -3
  37. package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +3 -9
  38. package/package.json +47 -22
package/Package.swift CHANGED
@@ -10,7 +10,7 @@ let package = Package(
10
10
  targets: ["mDNSPlugin"])
11
11
  ],
12
12
  dependencies: [
13
- .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0")
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
14
14
  ],
15
15
  targets: [
16
16
  .target(
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # @devioarts/capacitor-mdns
2
2
 
3
- mDNS plugin for Capacitor that supports Bonjour/mDNS advertisements and discovery.
3
+ mDNS plugin for Capacitor that supports Bonjour/mDNS advertisements and discovery on iOS,
4
+ Android, Electron, and a safe web fallback.
4
5
 
5
- #### Supported platforms: ✓ iOS ✓ Android ✓ Electron
6
- #### Demo (sources): [application](https://github.com/devioarts/capacitor-examples/tree/main/capacitor-mdns) or directly [file](https://github.com/devioarts/capacitor-examples/blob/main/capacitor-mdns/src/Playground.tsx)
6
+ #### Supported platforms: ✓ iOS ✓ Android ✓ Electron ✓ Web fallback
7
+
8
+ The in-repository [`playground`](./playground) is the example app used during development.
7
9
 
8
10
  ## Install
9
11
 
@@ -12,82 +14,37 @@ npm install @devioarts/capacitor-mdns
12
14
  npx cap sync
13
15
  ```
14
16
 
15
- ## Android
16
- #### /android/app/src/main/AndroidManifest.xml
17
- ```xml
18
- <uses-permission android:name="android.permission.INTERNET" />
19
- ```
17
+ ## Usage
20
18
 
21
- ## iOS
22
- #### /ios/App/App/Info.plist
23
- ```xml
24
- <key>NSLocalNetworkUsageDescription</key>
25
- <string>It is needed for the correct functioning of the application</string>
26
- <key>NSAppTransportSecurity</key>
27
- <dict>
28
- <key>NSAllowsLocalNetworking</key>
29
- <true/>
30
- </dict>
31
- <key>NSBonjourServices</key>
32
- <array>
33
- <string>_http._tcp</string>
34
- </array>
35
- ```
19
+ ```ts
20
+ import { mDNS } from '@devioarts/capacitor-mdns';
36
21
 
37
- ---
38
- ## ElectronJS
22
+ const runtime = await mDNS.getPluginPlatform();
39
23
 
40
- ```shell
41
- npm i bonjour-service@1.3.0
24
+ await mDNS.startBroadcast({ type: '_http._tcp.', name: 'MyService', port: 9100 });
25
+ const result = await mDNS.discover({ type: '_http._tcp.', timeout: 3000 });
26
+ await mDNS.stopBroadcast();
42
27
  ```
43
28
 
44
- > Implementation example was developed on [capacitor-electron](https://github.com/devioarts/capacitor-examples/tree/main/capacitor-electron)
45
- > base, if you run electron differently, you may need to adjust the code.
29
+ ## Platform Setup
46
30
 
47
- #### /electron/main.ts
31
+ Android requires mDNS/network permissions in `AndroidManifest.xml`; the plugin also declares them
32
+ so they are merged during sync. iOS requires local network keys in `Info.plist`.
33
+ Electron support is intended for [`devioarts/capacitor-electron`](https://github.com/devioarts/capacitor-electron)
34
+ and uses metadata exported from `@devioarts/capacitor-mdns/electron/settings`.
48
35
 
49
- ```typescript
50
- // ...
51
- import { mDNS } from '@devioarts/capacitor-mdns/electron/mdns'
52
- // ...
53
- const mdns = new mDNS();
54
- // ...
55
- app.whenReady().then(() => {
56
- //...
57
- mdns.init();
58
- //...
59
- });
60
- /* Or you can use app.on:ready (whenReady is recomended)
61
- app.on('ready', () => {
62
- // ...
63
- mdns.init();
64
- // ...
65
- });
66
- */
67
-
68
- app.on('before-quit', async () => {
69
- // ...
70
- mdns.destroy();
71
- // ...
72
- })
73
- //...
74
- ```
75
- ### electron/preload.cjs
76
- ```javascript
77
- //...
78
- // THIS IS IMPORTANT FOR PLUGIN!
79
- const {createMDNSAPI} = require("@devioarts/capacitor-mdns/electron/mdns-bridge.cjs");
80
- //...
81
- // THIS IS IMPORTANT FOR PLUGIN!
82
- contextBridge.exposeInMainWorld('mDNS', createMDNSAPI({ ipcRenderer }));
83
- contextBridge.exposeInMainWorld('mdns', createMDNSAPI({ ipcRenderer })) // alias
84
- ```
36
+ Detailed setup lives in:
37
+
38
+ - [iOS and Android setup](./docs/platform-setup.md)
39
+ - [Electron setup](./docs/electron.md)
40
+ - [Playground](./docs/playground.md)
41
+ - [Runtime behavior](./docs/runtime-behavior.md)
85
42
 
86
- ---
87
43
  ## API
88
44
 
89
45
  <docgen-index>
90
46
 
47
+ * [`getPluginPlatform()`](#getpluginplatform)
91
48
  * [`startBroadcast(...)`](#startbroadcast)
92
49
  * [`stopBroadcast()`](#stopbroadcast)
93
50
  * [`discover(...)`](#discover)
@@ -101,6 +58,19 @@ contextBridge.exposeInMainWorld('mdns', createMDNSAPI({ ipcRenderer })) // alias
101
58
 
102
59
  Public API surface of the Capacitor mDNS plugin.
103
60
 
61
+ ### getPluginPlatform()
62
+
63
+ ```typescript
64
+ getPluginPlatform() => Promise<MdnsPluginPlatformResult>
65
+ ```
66
+
67
+ Return the platform implementation currently serving plugin calls.
68
+
69
+ **Returns:** <code>Promise&lt;<a href="#mdnspluginplatformresult">MdnsPluginPlatformResult</a>&gt;</code>
70
+
71
+ --------------------
72
+
73
+
104
74
  ### startBroadcast(...)
105
75
 
106
76
  ```typescript
@@ -151,6 +121,16 @@ Discover services of a given type and optionally filter by instance name.
151
121
  ### Interfaces
152
122
 
153
123
 
124
+ #### MdnsPluginPlatformResult
125
+
126
+ Result of getPluginPlatform(). Useful for validating that calls are routed
127
+ through the expected platform bridge.
128
+
129
+ | Prop | Type | Description |
130
+ | -------------- | ----------------------------------------------------------------- | -------------------------------------------------- |
131
+ | **`platform`** | <code><a href="#mdnspluginplatform">MdnsPluginPlatform</a></code> | Platform implementation that produced this result. |
132
+
133
+
154
134
  #### MdnsBroadcastResult
155
135
 
156
136
  Result of startBroadcast(). Indicates whether advertising is active
@@ -231,6 +211,13 @@ Options for Bonjour/mDNS discovery.
231
211
  ### Type Aliases
232
212
 
233
213
 
214
+ #### MdnsPluginPlatform
215
+
216
+ Runtime implementation that handled a plugin call.
217
+
218
+ <code>'ios' | 'android' | 'electron' | 'web'</code>
219
+
220
+
234
221
  #### MdnsTxt
235
222
 
236
223
  Key–value map for TXT records of a Bonjour/mDNS service.
@@ -11,7 +11,7 @@ buildscript {
11
11
  mavenCentral()
12
12
  }
13
13
  dependencies {
14
- classpath 'com.android.tools.build:gradle:8.7.2'
14
+ classpath 'com.android.tools.build:gradle:8.13.2'
15
15
  classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24"
16
16
  }
17
17
  }
@@ -20,23 +20,23 @@ apply plugin: 'com.android.library'
20
20
  apply plugin: 'org.jetbrains.kotlin.android'
21
21
 
22
22
  android {
23
- namespace "com.devioarts.capacitor.mdns"
24
- compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
23
+ namespace = "com.devioarts.capacitor.mdns"
24
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
25
25
  defaultConfig {
26
- minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
27
- targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
28
- versionCode 1
29
- versionName "1.0"
30
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
26
+ minSdk = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
27
+ targetSdk = project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
28
+ versionCode = 1
29
+ versionName = "1.0"
30
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
31
31
  }
32
32
  buildTypes {
33
33
  release {
34
- minifyEnabled false
34
+ minifyEnabled = false
35
35
  proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
36
36
  }
37
37
  }
38
- lintOptions {
39
- abortOnError false
38
+ lint {
39
+ abortOnError = false
40
40
  }
41
41
  compileOptions {
42
42
  sourceCompatibility JavaVersion.VERSION_21
@@ -1,2 +1,4 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
2
4
  </manifest>
@@ -4,9 +4,13 @@ package com.devioarts.capacitor.mdns
4
4
  import android.content.Context
5
5
  import android.net.nsd.NsdManager
6
6
  import android.net.nsd.NsdServiceInfo
7
+ import android.os.Build
7
8
  import android.os.Handler
8
9
  import android.os.Looper
9
10
  import kotlinx.coroutines.*
11
+ import kotlinx.coroutines.sync.Mutex
12
+ import kotlinx.coroutines.sync.withLock
13
+ import java.util.concurrent.Executor
10
14
  import java.util.concurrent.atomic.AtomicReference
11
15
 
12
16
  /**
@@ -29,27 +33,42 @@ class mDNS(
29
33
  context: Context,
30
34
  private val externalScope: CoroutineScope? = null
31
35
  ) {
36
+ private companion object {
37
+ const val PUBLISH_TIMEOUT_MS = 5000L
38
+ }
39
+
32
40
  /** Normalized service representation returned to the bridge. */
33
41
  data class MdnsService(
34
42
  val name: String,
35
43
  val type: String,
36
- val host: String?, // numeric address (v4/v6) or null if resolve fails
44
+ val hosts: List<String>, // numeric addresses (v4/v6)
37
45
  val port: Int
38
46
  )
39
47
 
48
+ private class RegistrationSession(
49
+ val listener: NsdManager.RegistrationListener,
50
+ val timeoutJob: Job,
51
+ val onSuccess: (String) -> Unit,
52
+ val onError: (Throwable) -> Unit
53
+ ) {
54
+ var completed = false
55
+ }
56
+
40
57
  /** NSD system service. */
41
58
  private val nsd: NsdManager =
42
59
  context.getSystemService(Context.NSD_SERVICE) as NsdManager
43
60
 
44
61
  /** Keep strong references to listeners so they outlive registration/discovery. */
45
- private val regListenerRef = AtomicReference<NsdManager.RegistrationListener?>(null)
62
+ private var activeRegistration: RegistrationSession? = null
46
63
  private val discListenerRef = AtomicReference<NsdManager.DiscoveryListener?>(null)
47
64
 
48
65
  /** Optional external scope (from plugin); otherwise create our own on Main. */
49
66
  private val scope = externalScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
67
+ private val discoverMutex = Mutex()
50
68
 
51
69
  /** Main looper utilities for non-suspending entry points. */
52
70
  private val mainHandler = Handler(Looper.getMainLooper())
71
+ private val mainExecutor = Executor { command -> mainHandler.post(command) }
53
72
  private inline fun runOnMain(crossinline block: () -> Unit) {
54
73
  if (Looper.myLooper() === Looper.getMainLooper()) block() else mainHandler.post { block() }
55
74
  }
@@ -70,29 +89,58 @@ class mDNS(
70
89
  onSuccess: (String) -> Unit,
71
90
  onError: (Throwable) -> Unit
72
91
  ) {
73
- require(port > 0) { "Port must be > 0" }
92
+ require(port in 1..65535) { "Port must be between 1 and 65535" }
74
93
  val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
75
94
 
76
95
  runOnMain {
77
- // Ensure a clean state before registering again.
78
- regListenerRef.getAndSet(null)?.let { safeUnregister(it) }
96
+ try {
97
+ // Restarting while publish is pending must complete the old JS promise.
98
+ stopActiveRegistration("Replaced by a new broadcast request")
79
99
 
80
- val info = NsdServiceInfo().apply {
81
- serviceType = type
82
- serviceName = name
83
- setPort(port)
84
- }
100
+ val info = NsdServiceInfo().apply {
101
+ serviceType = type
102
+ serviceName = name
103
+ setPort(port)
104
+ }
85
105
 
86
- val listener = object : NsdManager.RegistrationListener {
87
- override fun onServiceRegistered(nsi: NsdServiceInfo) = onSuccess(nsi.serviceName)
88
- override fun onRegistrationFailed(nsi: NsdServiceInfo, errorCode: Int) =
89
- onError(IllegalStateException("Registration failed: $errorCode"))
90
- override fun onServiceUnregistered(nsi: NsdServiceInfo) { /* no-op */ }
91
- override fun onUnregistrationFailed(nsi: NsdServiceInfo, errorCode: Int) { /* no-op */ }
92
- }
106
+ lateinit var session: RegistrationSession
107
+ val listener = object : NsdManager.RegistrationListener {
108
+ override fun onServiceRegistered(nsi: NsdServiceInfo) =
109
+ completeRegistrationSuccess(session, nsi.serviceName)
110
+
111
+ override fun onRegistrationFailed(nsi: NsdServiceInfo, errorCode: Int) =
112
+ completeRegistrationError(
113
+ session,
114
+ IllegalStateException("Registration failed: $errorCode"),
115
+ clearActive = true
116
+ )
117
+
118
+ override fun onServiceUnregistered(nsi: NsdServiceInfo) { /* no-op */ }
119
+ override fun onUnregistrationFailed(nsi: NsdServiceInfo, errorCode: Int) { /* no-op */ }
120
+ }
93
121
 
94
- regListenerRef.set(listener)
95
- nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
122
+ val timeoutJob = scope.launch {
123
+ delay(PUBLISH_TIMEOUT_MS)
124
+ runOnMain {
125
+ completeRegistrationError(
126
+ session,
127
+ IllegalStateException("Timed out waiting for service publish after ${PUBLISH_TIMEOUT_MS}ms"),
128
+ clearActive = true,
129
+ unregister = true
130
+ )
131
+ }
132
+ }
133
+
134
+ session = RegistrationSession(listener, timeoutJob, onSuccess, onError)
135
+ activeRegistration = session
136
+ try {
137
+ nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
138
+ } catch (t: Throwable) {
139
+ completeRegistrationError(session, t, clearActive = true)
140
+ }
141
+ } catch (t: Throwable) {
142
+ onError(t)
143
+ }
96
144
  }
97
145
  }
98
146
 
@@ -101,7 +149,7 @@ class mDNS(
101
149
  */
102
150
  fun stopBroadcast() {
103
151
  runOnMain {
104
- regListenerRef.getAndSet(null)?.let { safeUnregister(it) }
152
+ stopActiveRegistration("Publish stopped before completion")
105
153
  }
106
154
  }
107
155
 
@@ -124,12 +172,13 @@ class mDNS(
124
172
  typeRaw: String,
125
173
  targetName: String? = null,
126
174
  timeoutMs: Int = 3000
127
- ): List<MdnsService> = withContext(Dispatchers.Main.immediate) {
175
+ ): List<MdnsService> = discoverMutex.withLock { withContext(Dispatchers.Main.immediate) {
128
176
  val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
129
177
 
130
178
  // Prepare state
131
179
  discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
132
180
  val found = mutableListOf<MdnsService>()
181
+ val serviceInfoCallbacks = mutableListOf<NsdManager.ServiceInfoCallback>()
133
182
  val result = CompletableDeferred<List<MdnsService>>()
134
183
 
135
184
  // Normalization for "(n)" suffix appended by the OS.
@@ -139,40 +188,93 @@ class mDNS(
139
188
  val t = normalize(targetName ?: return true) // if no target, accept all
140
189
  return (c == t) || c.startsWith(t)
141
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) {
215
+ try {
216
+ nsd.resolveService(si, object : NsdManager.ResolveListener {
217
+ override fun onResolveFailed(s: NsdServiceInfo, errorCode: Int) { /* ignore */ }
218
+
219
+ override fun onServiceResolved(s: NsdServiceInfo) {
220
+ if (result.isCompleted) return
221
+ upsert(toMdnsService(s))
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.
231
+ }
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))
248
+
249
+ // Early-exit for exact/prefix target match.
250
+ if (targetName != null && matchesTarget(serviceInfo.serviceName)) {
251
+ completeWithCurrentResults(discoveryListener)
252
+ }
253
+ }
254
+ }
255
+
256
+ serviceInfoCallbacks.add(callback)
257
+ try {
258
+ nsd.registerServiceInfoCallback(si, mainExecutor, callback)
259
+ } catch (_: Throwable) {
260
+ serviceInfoCallbacks.remove(callback)
261
+ }
262
+ }
142
263
 
143
264
  val listener = object : NsdManager.DiscoveryListener {
144
265
  override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
145
- // Fail fast: stop discovery and complete with whatever we have (likely empty).
146
- safeStopDiscovery(this)
147
- if (!result.isCompleted) result.complete(found.toList())
266
+ completeWithError(this, IllegalStateException("Discovery failed to start: $errorCode"))
148
267
  }
149
268
  override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { /* no-op */ }
150
269
  override fun onDiscoveryStarted(serviceType: String) { /* no-op */ }
151
270
  override fun onDiscoveryStopped(serviceType: String) { /* no-op */ }
152
271
 
153
272
  override fun onServiceFound(si: NsdServiceInfo) {
273
+ if (result.isCompleted) return
154
274
  if (targetName != null && !matchesTarget(si.serviceName)) return
155
275
 
156
276
  val discoveryListener = this
157
- nsd.resolveService(si, object : NsdManager.ResolveListener {
158
- override fun onResolveFailed(s: NsdServiceInfo, errorCode: Int) { /* ignore */ }
159
-
160
- override fun onServiceResolved(s: NsdServiceInfo) {
161
- val item = MdnsService(
162
- name = s.serviceName,
163
- type = s.serviceType,
164
- host = s.host?.hostAddress,
165
- port = s.port
166
- )
167
- found.add(item)
168
-
169
- // Early-exit for exact/prefix target match.
170
- if (targetName != null && matchesTarget(s.serviceName)) {
171
- safeStopDiscovery(discoveryListener)
172
- if (!result.isCompleted) result.complete(found.toList())
173
- }
174
- }
175
- })
277
+ registerServiceInfoCallback(si, discoveryListener)
176
278
  }
177
279
 
178
280
  override fun onServiceLost(serviceInfo: NsdServiceInfo) { /* no-op */ }
@@ -180,12 +282,16 @@ class mDNS(
180
282
 
181
283
  // Start discovery and schedule timeout
182
284
  discListenerRef.set(listener)
183
- nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener)
285
+ try {
286
+ nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener)
287
+ } catch (t: Throwable) {
288
+ discListenerRef.compareAndSet(listener, null)
289
+ throw t
290
+ }
184
291
 
185
292
  val timeoutJob = scope.launch {
186
- delay(timeoutMs.toLong())
187
- safeStopDiscovery(listener)
188
- if (!result.isCompleted) result.complete(found.toList())
293
+ delay(timeoutMs.coerceAtLeast(0).toLong())
294
+ completeWithCurrentResults(listener)
189
295
  }
190
296
 
191
297
  try {
@@ -193,17 +299,20 @@ class mDNS(
193
299
  } finally {
194
300
  // Cleanup in all cases (early-exit or timeout)
195
301
  timeoutJob.cancel()
196
- discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
302
+ unregisterServiceInfoCallbacks()
303
+ if (discListenerRef.compareAndSet(listener, null)) safeStopDiscovery(listener)
197
304
  }
198
- }
305
+ } }
199
306
 
200
307
  /**
201
308
  * Close the manager and release any outstanding NSD listeners.
202
309
  * Safe to call multiple times; also invoked by the plugin's handleOnDestroy().
203
310
  */
204
311
  fun close() {
205
- stopBroadcast()
206
- discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
312
+ runOnMain {
313
+ stopActiveRegistration("mDNS manager closed before publish completed")
314
+ discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
315
+ }
207
316
  if (externalScope == null) scope.cancel()
208
317
  }
209
318
 
@@ -214,8 +323,65 @@ class mDNS(
214
323
  try { nsd.unregisterService(l) } catch (_: Throwable) {}
215
324
  }
216
325
 
326
+ private fun completeRegistrationSuccess(session: RegistrationSession, publishedName: String) {
327
+ if (session.completed) return
328
+ session.completed = true
329
+ session.timeoutJob.cancel()
330
+ session.onSuccess(publishedName)
331
+ }
332
+
333
+ private fun completeRegistrationError(
334
+ session: RegistrationSession,
335
+ error: Throwable,
336
+ clearActive: Boolean,
337
+ unregister: Boolean = false
338
+ ) {
339
+ if (session.completed) return
340
+ session.completed = true
341
+ session.timeoutJob.cancel()
342
+ if (unregister) safeUnregister(session.listener)
343
+ if (clearActive && activeRegistration === session) activeRegistration = null
344
+ session.onError(error)
345
+ }
346
+
347
+ private fun stopActiveRegistration(reason: String) {
348
+ val session = activeRegistration ?: return
349
+ activeRegistration = null
350
+ safeUnregister(session.listener)
351
+ if (!session.completed) {
352
+ completeRegistrationError(session, IllegalStateException(reason), clearActive = false)
353
+ } else {
354
+ session.timeoutJob.cancel()
355
+ }
356
+ }
357
+
217
358
  /** Best-effort stop discovery; NSD may throw if discovery is not active. */
218
359
  private fun safeStopDiscovery(l: NsdManager.DiscoveryListener) {
219
360
  try { nsd.stopServiceDiscovery(l) } catch (_: Throwable) {}
220
361
  }
362
+
363
+ /** Best-effort stop service info updates; NSD may throw if not registered. */
364
+ private fun safeUnregisterServiceInfoCallback(l: NsdManager.ServiceInfoCallback) {
365
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
366
+ try { nsd.unregisterServiceInfoCallback(l) } catch (_: Throwable) {}
367
+ }
368
+ }
369
+
370
+ private fun toMdnsService(s: NsdServiceInfo): MdnsService =
371
+ MdnsService(
372
+ name = s.serviceName,
373
+ type = s.serviceType,
374
+ hosts = hostAddresses(s),
375
+ port = s.port
376
+ )
377
+
378
+ private fun hostAddresses(s: NsdServiceInfo): List<String> =
379
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
380
+ s.hostAddresses.mapNotNull { it.hostAddress }
381
+ } else {
382
+ legacyHostAddress(s)?.let { listOf(it) } ?: emptyList()
383
+ }
384
+
385
+ @Suppress("DEPRECATION")
386
+ private fun legacyHostAddress(s: NsdServiceInfo): String? = s.host?.hostAddress
221
387
  }