@devioarts/capacitor-mdns 0.0.2

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.
@@ -0,0 +1,17 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'DevioartsCapacitorMdns'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license']
10
+ s.homepage = package['repository']['url']
11
+ s.author = package['author']
12
+ s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '14.0'
15
+ s.dependency 'Capacitor'
16
+ s.swift_version = '5.1'
17
+ end
package/Package.swift ADDED
@@ -0,0 +1,28 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "DevioartsCapacitorMdns",
6
+ platforms: [.iOS(.v14)],
7
+ products: [
8
+ .library(
9
+ name: "DevioartsCapacitorMdns",
10
+ targets: ["mDNSPlugin"])
11
+ ],
12
+ dependencies: [
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0")
14
+ ],
15
+ targets: [
16
+ .target(
17
+ name: "mDNSPlugin",
18
+ dependencies: [
19
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
20
+ .product(name: "Cordova", package: "capacitor-swift-pm")
21
+ ],
22
+ path: "ios/Sources/mDNSPlugin"),
23
+ .testTarget(
24
+ name: "mDNSPluginTests",
25
+ dependencies: ["mDNSPlugin"],
26
+ path: "ios/Tests/mDNSPluginTests")
27
+ ]
28
+ )
package/README.md ADDED
@@ -0,0 +1,248 @@
1
+ # @devioarts/capacitor-mdns
2
+
3
+ mDNS plugin for Capacitor that supports Bonjour/mDNS advertisements and discovery.
4
+
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)
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @devioarts/capacitor-mdns
12
+ npx cap sync
13
+ ```
14
+
15
+ ## Android
16
+ #### /android/app/src/main/AndroidManifest.xml
17
+ ```xml
18
+ <uses-permission android:name="android.permission.INTERNET" />
19
+ ```
20
+
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
+ ```
36
+
37
+ ---
38
+ ## ElectronJS
39
+
40
+ ```shell
41
+ npm i bonjour-service@1.3.0
42
+ ```
43
+
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.
46
+
47
+ #### /electron/main.ts
48
+
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
+ ```
85
+
86
+ ---
87
+ ## API
88
+
89
+ <docgen-index>
90
+
91
+ * [`startBroadcast(...)`](#startbroadcast)
92
+ * [`stopBroadcast()`](#stopbroadcast)
93
+ * [`discover(...)`](#discover)
94
+ * [Interfaces](#interfaces)
95
+ * [Type Aliases](#type-aliases)
96
+
97
+ </docgen-index>
98
+
99
+ <docgen-api>
100
+ <!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
101
+
102
+ Public API surface of the Capacitor mDNS plugin.
103
+
104
+ ### startBroadcast(...)
105
+
106
+ ```typescript
107
+ startBroadcast(options: MdnsBroadcastOptions) => Promise<MdnsBroadcastResult>
108
+ ```
109
+
110
+ Start advertising a Bonjour/mDNS service.
111
+
112
+ | Param | Type | Description |
113
+ | ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------ |
114
+ | **`options`** | <code><a href="#mdnsbroadcastoptions">MdnsBroadcastOptions</a></code> | - {@link <a href="#mdnsbroadcastoptions">MdnsBroadcastOptions</a>} |
115
+
116
+ **Returns:** <code>Promise&lt;<a href="#mdnsbroadcastresult">MdnsBroadcastResult</a>&gt;</code>
117
+
118
+ --------------------
119
+
120
+
121
+ ### stopBroadcast()
122
+
123
+ ```typescript
124
+ stopBroadcast() => Promise<MdnsStopResult>
125
+ ```
126
+
127
+ Stop advertising the currently registered service (no-op if none).
128
+
129
+ **Returns:** <code>Promise&lt;<a href="#mdnsstopresult">MdnsStopResult</a>&gt;</code>
130
+
131
+ --------------------
132
+
133
+
134
+ ### discover(...)
135
+
136
+ ```typescript
137
+ discover(options?: MdnsDiscoverOptions | undefined) => Promise<MdnsDiscoverResult>
138
+ ```
139
+
140
+ Discover services of a given type and optionally filter by instance name.
141
+
142
+ | Param | Type | Description |
143
+ | ------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- |
144
+ | **`options`** | <code><a href="#mdnsdiscoveroptions">MdnsDiscoverOptions</a></code> | - {@link <a href="#mdnsdiscoveroptions">MdnsDiscoverOptions</a>} |
145
+
146
+ **Returns:** <code>Promise&lt;<a href="#mdnsdiscoverresult">MdnsDiscoverResult</a>&gt;</code>
147
+
148
+ --------------------
149
+
150
+
151
+ ### Interfaces
152
+
153
+
154
+ #### MdnsBroadcastResult
155
+
156
+ Result of startBroadcast(). Indicates whether advertising is active
157
+ and the final service name. On failure, `error` is true and `errorMessage`
158
+ describes the issue.
159
+
160
+ | Prop | Type | Description |
161
+ | ------------------ | --------------------------- | -------------------------------------------------------------------- |
162
+ | **`error`** | <code>boolean</code> | True if the operation failed. |
163
+ | **`errorMessage`** | <code>string \| null</code> | Error description or null on success. |
164
+ | **`name`** | <code>string</code> | Final (possibly uniquified) service instance name. Empty on failure. |
165
+ | **`publishing`** | <code>boolean</code> | Whether the advertiser is currently active. |
166
+
167
+
168
+ #### MdnsBroadcastOptions
169
+
170
+ Options for starting a Bonjour/mDNS advertisement.
171
+
172
+ | Prop | Type | Description |
173
+ | ---------- | ------------------------------------------- | --------------------------------------------- |
174
+ | **`type`** | <code>string</code> | Service type (including the trailing dot). |
175
+ | **`name`** | <code>string</code> | Service instance name. |
176
+ | **`port`** | <code>number</code> | TCP port to advertise. |
177
+ | **`txt`** | <code><a href="#mdnstxt">MdnsTxt</a></code> | Optional TXT key–value pairs (UTF-8 strings). |
178
+
179
+
180
+ #### MdnsStopResult
181
+
182
+ Result of stopBroadcast(). Indicates whether the advertiser is active
183
+ after the call (normally false) and includes error information.
184
+
185
+ | Prop | Type | Description |
186
+ | ------------------ | --------------------------- | ------------------------------------------------------------------- |
187
+ | **`error`** | <code>boolean</code> | True if an error occurred while stopping. |
188
+ | **`errorMessage`** | <code>string \| null</code> | Error description or null on success. |
189
+ | **`publishing`** | <code>boolean</code> | Whether the advertiser remains active (should be false on success). |
190
+
191
+
192
+ #### MdnsDiscoverResult
193
+
194
+ Result of discover(). Contains normalized services and error information.
195
+
196
+ | Prop | Type | Description |
197
+ | ------------------- | --------------------------- | ------------------------------------------------------------------------------ |
198
+ | **`error`** | <code>boolean</code> | True if discovery encountered an error (partial results may still be present). |
199
+ | **`errorMessage`** | <code>string \| null</code> | Error description or null when no error occurred. |
200
+ | **`servicesFound`** | <code>number</code> | Convenience count equal to services.length. |
201
+ | **`services`** | <code>MdnsService[]</code> | Normalized list of discovered services. |
202
+
203
+
204
+ #### MdnsService
205
+
206
+ Normalized description of a discovered Bonjour/mDNS service.
207
+ Returned from {@link mDNSPlugin.discover}.
208
+
209
+ | Prop | Type | Description |
210
+ | ------------ | ------------------------------------------- | ----------------------------------------------------------------------------------------- |
211
+ | **`name`** | <code>string</code> | Instance name of the service (may be uniqued by the OS, e.g. "My App (2)"). |
212
+ | **`type`** | <code>string</code> | Full service type including the trailing dot, e.g. `"_http._tcp."`. |
213
+ | **`domain`** | <code>string</code> | Service domain; typically `"local."`. |
214
+ | **`port`** | <code>number</code> | TCP port the service advertises. |
215
+ | **`hosts`** | <code>string[]</code> | Resolved numeric IP addresses (IPv4/IPv6). |
216
+ | **`txt`** | <code><a href="#mdnstxt">MdnsTxt</a></code> | TXT dictionary (key → value). Usually present on iOS; Android NSD does not populate this. |
217
+
218
+
219
+ #### MdnsDiscoverOptions
220
+
221
+ Options for Bonjour/mDNS discovery.
222
+
223
+ | Prop | Type | Description |
224
+ | ------------- | -------------------- | ---------------------------------------------------------------- |
225
+ | **`type`** | <code>string</code> | Service type (including the trailing dot). |
226
+ | **`name`** | <code>string</code> | Optional instance name filter (prefix-safe). |
227
+ | **`timeout`** | <code>number</code> | Discovery timeout in milliseconds. |
228
+ | **`useNW`** | <code>boolean</code> | iOS-only hint to use `NWBrowser` instead of `NetServiceBrowser`. |
229
+
230
+
231
+ ### Type Aliases
232
+
233
+
234
+ #### MdnsTxt
235
+
236
+ Key–value map for TXT records of a Bonjour/mDNS service.
237
+ Values are UTF-8 strings; binary payloads are not supported by this API.
238
+
239
+ <code><a href="#record">Record</a>&lt;string, string&gt;</code>
240
+
241
+
242
+ #### Record
243
+
244
+ Construct a type with a set of properties K of type T
245
+
246
+ <code>{
247
  [P in K]: T;
1
248
  }</code>
249
+
250
+ </docgen-api>
@@ -0,0 +1,64 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
4
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.2.1'
5
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.6.1'
6
+ }
7
+
8
+ buildscript {
9
+ repositories {
10
+ google()
11
+ mavenCentral()
12
+ }
13
+ dependencies {
14
+ classpath 'com.android.tools.build:gradle:8.7.2'
15
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24"
16
+ }
17
+ }
18
+
19
+ apply plugin: 'com.android.library'
20
+ apply plugin: 'org.jetbrains.kotlin.android'
21
+
22
+ android {
23
+ namespace "com.devioarts.capacitor.mdns"
24
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
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"
31
+ }
32
+ buildTypes {
33
+ release {
34
+ minifyEnabled false
35
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
36
+ }
37
+ }
38
+ lintOptions {
39
+ abortOnError false
40
+ }
41
+ compileOptions {
42
+ sourceCompatibility JavaVersion.VERSION_21
43
+ targetCompatibility JavaVersion.VERSION_21
44
+ }
45
+
46
+ kotlinOptions {
47
+ jvmTarget = '21'
48
+ }
49
+ }
50
+
51
+ repositories {
52
+ google()
53
+ mavenCentral()
54
+ }
55
+
56
+
57
+ dependencies {
58
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
59
+ implementation project(':capacitor-android')
60
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
61
+ testImplementation "junit:junit:$junitVersion"
62
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
63
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
64
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,221 @@
1
+ package com.devioarts.capacitor.mdns
2
+ // English-only code and comments.
3
+
4
+ import android.content.Context
5
+ import android.net.nsd.NsdManager
6
+ import android.net.nsd.NsdServiceInfo
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import kotlinx.coroutines.*
10
+ import java.util.concurrent.atomic.AtomicReference
11
+
12
+ /**
13
+ * Thin Android NSD (Bonjour/mDNS) wrapper used by the Capacitor bridge.
14
+ *
15
+ * Responsibilities:
16
+ * - Register/unregister (advertise/stop) a single service.
17
+ * - Discover services of a given type and resolve them to host/port.
18
+ * - Optional exact-or-prefix name filter (iOS-like behavior, handles " (n)" suffix).
19
+ * - Timebox discovery; early-exit when the target match is resolved.
20
+ *
21
+ * Threading:
22
+ * - All public APIs are main-thread safe. Calls hop to the main looper internally,
23
+ * since NSD APIs deliver callbacks on the main thread and expect a looper.
24
+ *
25
+ * Platform notes:
26
+ * - Android NSD has no public API for TXT records.
27
+ */
28
+ class mDNS(
29
+ context: Context,
30
+ private val externalScope: CoroutineScope? = null
31
+ ) {
32
+ /** Normalized service representation returned to the bridge. */
33
+ data class MdnsService(
34
+ val name: String,
35
+ val type: String,
36
+ val host: String?, // numeric address (v4/v6) or null if resolve fails
37
+ val port: Int
38
+ )
39
+
40
+ /** NSD system service. */
41
+ private val nsd: NsdManager =
42
+ context.getSystemService(Context.NSD_SERVICE) as NsdManager
43
+
44
+ /** Keep strong references to listeners so they outlive registration/discovery. */
45
+ private val regListenerRef = AtomicReference<NsdManager.RegistrationListener?>(null)
46
+ private val discListenerRef = AtomicReference<NsdManager.DiscoveryListener?>(null)
47
+
48
+ /** Optional external scope (from plugin); otherwise create our own on Main. */
49
+ private val scope = externalScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
50
+
51
+ /** Main looper utilities for non-suspending entry points. */
52
+ private val mainHandler = Handler(Looper.getMainLooper())
53
+ private inline fun runOnMain(crossinline block: () -> Unit) {
54
+ if (Looper.myLooper() === Looper.getMainLooper()) block() else mainHandler.post { block() }
55
+ }
56
+
57
+ /**
58
+ * Start advertising a service. If already registered, the previous one is unregistered first.
59
+ *
60
+ * @param typeRaw Service type; trailing dot appended if missing (e.g., "_http._tcp.")
61
+ * @param name Instance name; OS may append " (n)" to ensure uniqueness.
62
+ * @param port TCP port (> 0).
63
+ * @param onSuccess Called with the (potentially uniquified) registered name.
64
+ * @param onError Called on registration failure.
65
+ */
66
+ fun broadcast(
67
+ typeRaw: String,
68
+ name: String,
69
+ port: Int,
70
+ onSuccess: (String) -> Unit,
71
+ onError: (Throwable) -> Unit
72
+ ) {
73
+ require(port > 0) { "Port must be > 0" }
74
+ val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
75
+
76
+ runOnMain {
77
+ // Ensure a clean state before registering again.
78
+ regListenerRef.getAndSet(null)?.let { safeUnregister(it) }
79
+
80
+ val info = NsdServiceInfo().apply {
81
+ serviceType = type
82
+ serviceName = name
83
+ setPort(port)
84
+ }
85
+
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
+ }
93
+
94
+ regListenerRef.set(listener)
95
+ nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Stop advertising the currently registered service (if any). Safe to call multiple times.
101
+ */
102
+ fun stopBroadcast() {
103
+ runOnMain {
104
+ regListenerRef.getAndSet(null)?.let { safeUnregister(it) }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Discover services, optionally filtering by instance name using a normalized exact-or-prefix match.
110
+ *
111
+ * Behavior:
112
+ * - Runs on the main dispatcher.
113
+ * - Resolves candidates to host/port.
114
+ * - If `targetName` is provided, compare using normalized names and accept exact OR prefix match.
115
+ * - Early-exits when a matching service is resolved (stops discovery immediately).
116
+ * - Otherwise returns what was found when `timeoutMs` elapses.
117
+ *
118
+ * @param typeRaw Service type; trailing dot appended if missing.
119
+ * @param targetName Optional instance name for normalized exact/prefix match.
120
+ * @param timeoutMs Timebox for discovery+resolve.
121
+ * @return List of resolved services accumulated (or early-exit match).
122
+ */
123
+ suspend fun discover(
124
+ typeRaw: String,
125
+ targetName: String? = null,
126
+ timeoutMs: Int = 3000
127
+ ): List<MdnsService> = withContext(Dispatchers.Main.immediate) {
128
+ val type = if (typeRaw.endsWith(".")) typeRaw else "$typeRaw."
129
+
130
+ // Prepare state
131
+ discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
132
+ val found = mutableListOf<MdnsService>()
133
+ val result = CompletableDeferred<List<MdnsService>>()
134
+
135
+ // Normalization for "(n)" suffix appended by the OS.
136
+ fun normalize(s: String): String = s.replace(Regex(" \\(\\d+\\)\$"), "")
137
+ fun matchesTarget(candidate: String): Boolean {
138
+ val c = normalize(candidate)
139
+ val t = normalize(targetName ?: return true) // if no target, accept all
140
+ return (c == t) || c.startsWith(t)
141
+ }
142
+
143
+ val listener = object : NsdManager.DiscoveryListener {
144
+ 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())
148
+ }
149
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { /* no-op */ }
150
+ override fun onDiscoveryStarted(serviceType: String) { /* no-op */ }
151
+ override fun onDiscoveryStopped(serviceType: String) { /* no-op */ }
152
+
153
+ override fun onServiceFound(si: NsdServiceInfo) {
154
+ if (targetName != null && !matchesTarget(si.serviceName)) return
155
+
156
+ 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
+ })
176
+ }
177
+
178
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) { /* no-op */ }
179
+ }
180
+
181
+ // Start discovery and schedule timeout
182
+ discListenerRef.set(listener)
183
+ nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener)
184
+
185
+ val timeoutJob = scope.launch {
186
+ delay(timeoutMs.toLong())
187
+ safeStopDiscovery(listener)
188
+ if (!result.isCompleted) result.complete(found.toList())
189
+ }
190
+
191
+ try {
192
+ result.await()
193
+ } finally {
194
+ // Cleanup in all cases (early-exit or timeout)
195
+ timeoutJob.cancel()
196
+ discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Close the manager and release any outstanding NSD listeners.
202
+ * Safe to call multiple times; also invoked by the plugin's handleOnDestroy().
203
+ */
204
+ fun close() {
205
+ stopBroadcast()
206
+ discListenerRef.getAndSet(null)?.let { safeStopDiscovery(it) }
207
+ if (externalScope == null) scope.cancel()
208
+ }
209
+
210
+ // ---- Internal helpers ----------------------------------------------------
211
+
212
+ /** Best-effort unregister; NSD may throw if already unregistered. */
213
+ private fun safeUnregister(l: NsdManager.RegistrationListener) {
214
+ try { nsd.unregisterService(l) } catch (_: Throwable) {}
215
+ }
216
+
217
+ /** Best-effort stop discovery; NSD may throw if discovery is not active. */
218
+ private fun safeStopDiscovery(l: NsdManager.DiscoveryListener) {
219
+ try { nsd.stopServiceDiscovery(l) } catch (_: Throwable) {}
220
+ }
221
+ }