@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,267 @@
1
+ import type { Service, Browser } from 'bonjour-service';
2
+ import Bonjour from 'bonjour-service';
3
+ import { ipcMain } from 'electron';
4
+
5
+ import type {
6
+ MdnsBroadcastOptions,
7
+ MdnsBroadcastResult,
8
+ MdnsDiscoverOptions,
9
+ MdnsDiscoverResult,
10
+ MdnsService,
11
+ MdnsStopResult,
12
+ } from '../src';
13
+
14
+ /**
15
+ * Electron main-process implementation of the mDNS/Bonjour functionality.
16
+ *
17
+ * Uses the `bonjour-service` package to:
18
+ * - publish (advertise) a single service;
19
+ * - discover services of a given type and normalize results to the shared schema;
20
+ * - wire the API over Electron's ipcMain (see registerIpc/attachOnReady).
21
+ *
22
+ * Notes:
23
+ * - Instance name matching is normalized and prefix-safe, mirroring iOS/Android behavior.
24
+ * - TXT records are forwarded as strings when present.
25
+ */
26
+ export class mDNS {
27
+ //<editor-fold desc="Init/Destroy">
28
+ private ipcRegistered = false;
29
+ private registerIpc(): void {
30
+ if (this.ipcRegistered) return;
31
+ try {
32
+ ipcMain.removeHandler('mdns:startBroadcast');
33
+ ipcMain.removeHandler('mdns:stopBroadcast');
34
+ ipcMain.removeHandler('mdns:discover');
35
+ } catch {
36
+ /* ignore */
37
+ }
38
+ ipcMain.handle('mdns:startBroadcast', (_evt, o: MdnsBroadcastOptions) => this.startBroadcast(o));
39
+ ipcMain.handle('mdns:stopBroadcast', () => this.stopBroadcast());
40
+ ipcMain.handle('mdns:discover', (_evt, o: MdnsDiscoverOptions) => this.discover(o));
41
+ this.ipcRegistered = true;
42
+ }
43
+
44
+ private unregisterIpc(): void {
45
+ if (!this.ipcRegistered) return;
46
+ try {
47
+ ipcMain.removeHandler('mdns:startBroadcast');
48
+ ipcMain.removeHandler('mdns:stopBroadcast');
49
+ ipcMain.removeHandler('mdns:discover');
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ this.ipcRegistered = false;
54
+ }
55
+ /** Manual IPC registration (if you set autoRegisterIpc: false). Idempotent. */
56
+ init(): void {
57
+ this.registerIpc();
58
+ }
59
+
60
+ async destroy(): Promise<void> {
61
+ this.unregisterIpc();
62
+ await this.stopBroadcast(); // no-op safe
63
+ try {
64
+ this.bonjour.destroy();
65
+ } catch (err) {
66
+ console.warn('[mDNS] bonjour.destroy error:', err);
67
+ }
68
+ }
69
+
70
+ //</editor-fold>
71
+
72
+ private bonjour = new Bonjour();
73
+ private advertiser?: Service;
74
+
75
+ // ----------------------------- utils -----------------------------
76
+ private toErr(err: unknown): string {
77
+ return err instanceof Error ? err.message : String(err);
78
+ }
79
+
80
+ private normalize(name: string): string {
81
+ return name.replace(/ \(\d+\)$/, '');
82
+ }
83
+ private matchesTarget(candidate: string, target?: string | null): boolean {
84
+ if (!target) return true;
85
+ const c = this.normalize(candidate),
86
+ t = this.normalize(target);
87
+ return c === t || c.startsWith(t);
88
+ }
89
+ private parseType(typeWithDot?: string): { type: string; protocol: 'tcp' | 'udp' } {
90
+ const s = (typeWithDot ?? '_http._tcp.').replace(/\.$/, '');
91
+ const m = /^_([^.]+)\._(tcp|udp)$/.exec(s);
92
+ if (!m) return { type: 'http', protocol: 'tcp' };
93
+ return { type: m[1], protocol: m[2] as 'tcp' | 'udp' };
94
+ }
95
+ private toFullType(type: string, protocol: 'tcp' | 'udp'): string {
96
+ return `_${type}._${protocol}.`;
97
+ }
98
+
99
+ private hasCbStop(x: unknown): x is { stop: (cb?: () => void) => void } {
100
+ return !!x && typeof (x as any).stop === 'function';
101
+ }
102
+ private async safeStopService(svc: Service | undefined): Promise<void> {
103
+ if (!svc || !this.hasCbStop(svc)) return;
104
+ try {
105
+ await new Promise<void>((res) => {
106
+ try {
107
+ svc.stop(() => res());
108
+ } catch (e) {
109
+ console.warn('[mDNS] service.stop threw:', e);
110
+ res();
111
+ }
112
+ });
113
+ } catch (err) {
114
+ console.warn('[mDNS] service.stop error:', err);
115
+ }
116
+ }
117
+ private safeStopBrowser(b: Browser | null | undefined): void {
118
+ if (!b) return;
119
+ try {
120
+ b.stop();
121
+ } catch (err) {
122
+ console.warn('[mDNS] browser.stop error:', err);
123
+ }
124
+ }
125
+
126
+ // ----------------------------- API -----------------------------
127
+ /**
128
+ * Publish (advertise) a single Bonjour/mDNS service via bonjour-service.
129
+ * @param options See MdnsBroadcastOptions for type/name/port/txt.
130
+ * @returns Result indicating whether publishing is active and the final name.
131
+ */
132
+ async startBroadcast(options: MdnsBroadcastOptions): Promise<MdnsBroadcastResult> {
133
+ const { type, protocol } = this.parseType(options.type);
134
+ return new Promise<MdnsBroadcastResult>((resolve) => {
135
+ let settled = false;
136
+ const safeResolve = (r: MdnsBroadcastResult) => {
137
+ if (!settled) {
138
+ settled = true;
139
+ resolve(r);
140
+ }
141
+ };
142
+
143
+ try {
144
+ const svc = this.bonjour.publish({
145
+ name: options.name || 'DevIOArtsMDNS',
146
+ type,
147
+ protocol,
148
+ port: options.port,
149
+ txt: options.txt,
150
+ });
151
+
152
+ const onUp = () => {
153
+ try {
154
+ svc.removeListener('up', onUp);
155
+ svc.removeListener('error', onError);
156
+ } catch {
157
+ /* ignore */
158
+ }
159
+ safeResolve({ publishing: true, name: svc.name || '', error: false, errorMessage: null });
160
+ };
161
+ const onError = (err: unknown) => {
162
+ try {
163
+ svc.removeListener('up', onUp);
164
+ svc.removeListener('error', onError);
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ try {
169
+ if (this.hasCbStop(svc)) svc.stop();
170
+ } catch (e) {
171
+ console.warn('[mDNS] publish stop on error:', e);
172
+ }
173
+ safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(err) });
174
+ };
175
+
176
+ svc.once('up', onUp);
177
+ svc.once('error', onError);
178
+ this.advertiser = svc;
179
+ } catch (e) {
180
+ safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(e) });
181
+ }
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Stop advertising the current service if any and clear internal state.
187
+ * @returns Result indicating whether the advertiser is active and error info.
188
+ */
189
+ async stopBroadcast(): Promise<MdnsStopResult> {
190
+ try {
191
+ if (this.advertiser) {
192
+ await this.safeStopService(this.advertiser);
193
+ this.advertiser = undefined;
194
+ }
195
+ return { publishing: false, error: false, errorMessage: null };
196
+ } catch (e) {
197
+ return { publishing: false, error: true, errorMessage: this.toErr(e) };
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Discover services of the given type and optionally filter by instance name.
203
+ * Deduplicates by (name:port), collects IPv4/IPv6 addresses and TXT, and
204
+ * resolves with either an early-exit match or after a timeout.
205
+ * @param options See MdnsDiscoverOptions for type/name/timeout.
206
+ */
207
+ async discover(options: MdnsDiscoverOptions = {}): Promise<MdnsDiscoverResult> {
208
+ const { type, protocol } = this.parseType(options.type);
209
+ const targetId = options.name || null;
210
+ const timeoutMs = options.timeout ?? 3000;
211
+
212
+ let browser: Browser | null = null;
213
+ const services: MdnsService[] = [];
214
+ let hadError = false;
215
+ let errMsg: string | null = null;
216
+
217
+ return new Promise<MdnsDiscoverResult>((resolve) => {
218
+ let timer: NodeJS.Timeout | null = null;
219
+
220
+ const finish = () => {
221
+ if (browser) {
222
+ this.safeStopBrowser(browser);
223
+ browser = null;
224
+ }
225
+ if (timer) {
226
+ clearTimeout(timer);
227
+ timer = null;
228
+ }
229
+ resolve({
230
+ error: hadError,
231
+ errorMessage: errMsg,
232
+ services,
233
+ servicesFound: services.length,
234
+ });
235
+ };
236
+
237
+ try {
238
+ browser = this.bonjour.find({ type, protocol }, (s) => {
239
+ if (!this.matchesTarget(s.name || '', targetId)) return;
240
+ const item: MdnsService = {
241
+ name: s.name || '',
242
+ type: this.toFullType(type, protocol),
243
+ domain: 'local.',
244
+ port: s.port ?? 0,
245
+ hosts: Array.isArray(s.addresses) ? s.addresses.slice() : [],
246
+ txt: s.txt && Object.keys(s.txt).length ? (s.txt as Record<string, string>) : undefined,
247
+ };
248
+ const key = `${item.name}:${item.port}`;
249
+ if (!services.some((x) => `${x.name}:${x.port}` === key)) services.push(item);
250
+ if (targetId && this.matchesTarget(item.name, targetId)) finish();
251
+ });
252
+
253
+ // Record error and let the timeout conclude; results will still be returned.
254
+ browser.on('error', (err: unknown) => {
255
+ hadError = true;
256
+ errMsg = this.toErr(err);
257
+ });
258
+
259
+ timer = setTimeout(finish, timeoutMs);
260
+ } catch (err) {
261
+ hadError = true;
262
+ errMsg = this.toErr(err);
263
+ finish();
264
+ }
265
+ });
266
+ }
267
+ }
@@ -0,0 +1,436 @@
1
+ import Foundation
2
+ import Darwin
3
+ #if canImport(Network)
4
+ import Network
5
+ #endif
6
+
7
+ /// iOS mDNS manager.
8
+ /// - Publishes via `NetService`.
9
+ /// - Discovers via `NWBrowser` (preferred) or `NetServiceBrowser` as a fallback.
10
+ /// - Resolves each candidate with `NetService` to obtain `port`, `hosts`, and optional TXT.
11
+ /// - Produces a normalized array of dictionaries consumable by the Capacitor bridge.
12
+ public class MDNS: NSObject {
13
+
14
+ // MARK: - Advertising
15
+
16
+ private var publisher: NetService?
17
+ private var publishCompletion: ((Result<String, Error>) -> Void)?
18
+
19
+ // MARK: - Discovery state
20
+
21
+ private var nsBrowser: NetServiceBrowser?
22
+ #if canImport(Network)
23
+ private var nwBrowser: Any? // keep as `Any` to allow building against older SDKs
24
+ #endif
25
+
26
+ /// Map the resolving `NetService` → service box (accumulates details while resolving).
27
+ private var resolveMap: [NetService: ServiceBox] = [:]
28
+ /// Discovered items (kept for dedup / output).
29
+ private var discovered: [ServiceBox] = []
30
+
31
+ /// Optional instance-name filter (exact or prefix, both normalized).
32
+ private var targetName: String?
33
+
34
+ /// Completion invoked once with either success(array) or failure(error).
35
+ private var discoverCompletion: ((Result<[[String: Any]], Error>) -> Void)?
36
+
37
+ /// Timers: hard timeout and short settle debounce.
38
+ private var hardTimeout: DispatchWorkItem?
39
+ private var settleDebounce: DispatchWorkItem?
40
+
41
+ // MARK: - Public API (thread-safe)
42
+
43
+ /// Start advertising one Bonjour/mDNS service.
44
+ /// - Parameters:
45
+ /// - type: Full service type with trailing dot, e.g. `_http._tcp.`
46
+ /// - name: Instance name (the OS may uniquify it by appending `" (n)"`).
47
+ /// - port: TCP port (> 0).
48
+ /// - txt: Optional TXT dictionary (string → string).
49
+ /// - completion: Called with the final (possibly uniquified) `name`, or an error.
50
+ public func broadcast(
51
+ type: String,
52
+ name: String,
53
+ port: Int,
54
+ txt: [String: String]?,
55
+ completion: @escaping (Result<String, Error>) -> Void
56
+ ) {
57
+ runOnMain { [weak self] in
58
+ guard let self = self else { return }
59
+ self.stopPublisherIfNeeded()
60
+ self.publishCompletion = completion
61
+
62
+ let svc = NetService(domain: "local.", type: type, name: name, port: Int32(port))
63
+ svc.includesPeerToPeer = true
64
+
65
+ if let txt = txt {
66
+ let kv: [String: Data] = txt.reduce(into: [:]) { acc, e in
67
+ acc[e.key] = e.value.data(using: .utf8) ?? Data()
68
+ }
69
+ svc.setTXTRecord(NetService.data(fromTXTRecord: kv))
70
+ }
71
+
72
+ svc.delegate = self
73
+ self.publisher = svc
74
+ // Use default publish options; connection listening not required here.
75
+ svc.publish()
76
+ }
77
+ }
78
+
79
+ /// Stop the currently advertised service (no-op safe).
80
+ /// Exposed as `throws` by the bridge, but this implementation does not throw.
81
+ public func stopBroadcast() throws {
82
+ runOnMain { [weak self] in
83
+ self?.stopPublisherIfNeeded()
84
+ }
85
+ }
86
+
87
+ /// Discover services of a given type.
88
+ /// - Parameters:
89
+ /// - type: Full service type with trailing dot, e.g. `_http._tcp.`
90
+ /// - name: Optional instance-name filter (exact or prefix; both normalized).
91
+ /// - timeoutMs: Hard timeout (milliseconds). Discovery always completes by this time.
92
+ /// - useNW: Prefer `NWBrowser` when available (default `true`), fallback to `NetServiceBrowser` otherwise.
93
+ /// - completion: Success with normalized service dictionaries, or failure with error.
94
+ public func discover(
95
+ type: String,
96
+ name: String?,
97
+ timeoutMs: Int,
98
+ useNW: Bool = true,
99
+ completion: @escaping (Result<[[String: Any]], Error>) -> Void
100
+ ) {
101
+ runOnMain { [weak self] in
102
+ guard let self = self else { return }
103
+ // Reset session state.
104
+ self.cancelTimers()
105
+ self.stopBrowsers()
106
+ self.resolveMap.removeAll()
107
+ self.discovered.removeAll()
108
+ self.targetName = name
109
+ self.discoverCompletion = completion
110
+
111
+ // Schedule hard timeout (always resolves/finishes).
112
+ let ht = DispatchWorkItem { [weak self] in self?.finishDiscovery() }
113
+ self.hardTimeout = ht
114
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(0, timeoutMs)), execute: ht)
115
+
116
+ // Start browsing: NWBrowser preferred when available and requested.
117
+ #if canImport(Network)
118
+ if useNW, #available(iOS 12.0, *) {
119
+ self.startNWBrowse(typeWithDot: type)
120
+ return
121
+ }
122
+ #endif
123
+ // Fallback: NetServiceBrowser
124
+ let b = NetServiceBrowser()
125
+ b.includesPeerToPeer = true
126
+ b.delegate = self
127
+ self.nsBrowser = b
128
+ // Empty domain discovers in default domains (typically "local.").
129
+ b.searchForServices(ofType: type, inDomain: "")
130
+ }
131
+ }
132
+
133
+ // MARK: - Internal: NWBrowser discovery
134
+
135
+ #if canImport(Network)
136
+ @available(iOS 12.0, *)
137
+ private func startNWBrowse(typeWithDot: String) {
138
+ // NWBrowser expects type without trailing dot.
139
+ let typeNoDot = typeWithDot.hasSuffix(".") ? String(typeWithDot.dropLast()) : typeWithDot
140
+
141
+ let params = NWParameters()
142
+ params.includePeerToPeer = true
143
+
144
+ let browser = NWBrowser(for: .bonjour(type: typeNoDot, domain: nil), using: params)
145
+ self.nwBrowser = browser
146
+
147
+ browser.stateUpdateHandler = { state in
148
+ // Intentionally quiet; errors are reflected via timeout or partial results.
149
+ // print("[mDNS][NW] state: \(state)")
150
+ switch state {
151
+ case .failed(let err):
152
+ // Do not finish immediately; let the hard timeout deliver partial results.
153
+ // Optionally, one could trigger a fallback here.
154
+ // print("[mDNS][NW] failed: \(err)")
155
+ _ = err // silence unused
156
+ default:
157
+ break
158
+ }
159
+ }
160
+
161
+ browser.browseResultsChangedHandler = { [weak self] results, _ in
162
+ guard let self = self else { return }
163
+ for r in results {
164
+ guard case let NWEndpoint.service(name: n, type: t, domain: d, interface: _) = r.endpoint else { continue }
165
+ if !self.matchesTarget(n) { continue }
166
+
167
+ // Use NetService for resolve (port/hosts/TXT) to keep output parity with fallback.
168
+ let key = self.keyFor(name: n, type: t, domain: d)
169
+ if self.discovered.contains(where: { $0.identityKey == key }) { continue }
170
+
171
+ let resolver = NetService(domain: d.isEmpty ? "local." : d,
172
+ type: t.hasSuffix(".") ? t : t + ".",
173
+ name: n)
174
+ resolver.includesPeerToPeer = true
175
+ resolver.delegate = self
176
+
177
+ let box = ServiceBox(name: n, type: resolver.type, domain: resolver.domain)
178
+ self.discovered.append(box)
179
+ self.resolveMap[resolver] = box
180
+ resolver.resolve(withTimeout: 5.0)
181
+
182
+ // Reschedule short settle window on each new find.
183
+ self.scheduleSettleDebounce()
184
+ }
185
+ }
186
+
187
+ browser.start(queue: .main)
188
+ }
189
+ #endif
190
+
191
+ // MARK: - Internal: finalize & helpers
192
+
193
+ /// Build the final output and complete the discovery promise.
194
+ private func finishDiscovery() {
195
+ cancelTimers()
196
+ stopBrowsers()
197
+
198
+ // Keep only resolved entries, and deduplicate by (name:port:host0).
199
+ var seen = Set<String>()
200
+ var out: [[String: Any]] = []
201
+
202
+ for box in discovered where box.resolved {
203
+ let key = "\(box.name):\(box.port):\(box.hosts.first ?? "")"
204
+ if seen.contains(key) { continue }
205
+ seen.insert(key)
206
+
207
+ var entry: [String: Any] = [
208
+ "name": box.name,
209
+ "type": box.type,
210
+ "domain": box.domain,
211
+ "port": box.port
212
+ ]
213
+ if !box.hosts.isEmpty { entry["hosts"] = box.hosts }
214
+ if !box.txt.isEmpty { entry["txt"] = box.txt }
215
+ out.append(entry)
216
+ }
217
+
218
+ // Clear state before invoking the callback.
219
+ resolveMap.removeAll()
220
+ discovered.removeAll()
221
+ targetName = nil
222
+
223
+ let cb = discoverCompletion
224
+ discoverCompletion = nil
225
+ cb?(.success(out))
226
+ }
227
+
228
+ /// Cancel timers (hard timeout and settle debounce).
229
+ private func cancelTimers() {
230
+ hardTimeout?.cancel(); hardTimeout = nil
231
+ settleDebounce?.cancel(); settleDebounce = nil
232
+ }
233
+
234
+ /// Schedule a short debounce to finish discovery when the system becomes idle.
235
+ private func scheduleSettleDebounce(_ ms: Int = 350) {
236
+ settleDebounce?.cancel()
237
+ let wi = DispatchWorkItem { [weak self] in self?.finishDiscovery() }
238
+ settleDebounce = wi
239
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(ms), execute: wi)
240
+ }
241
+
242
+ /// Stop any active browsers.
243
+ private func stopBrowsers() {
244
+ #if canImport(Network)
245
+ if #available(iOS 12.0, *) {
246
+ (nwBrowser as? NWBrowser)?.cancel()
247
+ nwBrowser = nil
248
+ }
249
+ #endif
250
+ // Stop all in-flight resolvers as well (best-effort).
251
+ for (svc, _) in resolveMap {
252
+ svc.stop()
253
+ svc.delegate = nil
254
+ }
255
+ resolveMap.removeAll()
256
+ nsBrowser?.stop()
257
+ nsBrowser?.delegate = nil
258
+ nsBrowser = nil
259
+ }
260
+
261
+ /// Stop the current publisher, if any.
262
+ private func stopPublisherIfNeeded() {
263
+ publisher?.stop()
264
+ publisher?.delegate = nil
265
+ publisher = nil
266
+ // Do not call `publishCompletion` here: externally, `stopBroadcast()` should be considered a neutral action.
267
+ publishCompletion = nil
268
+ }
269
+
270
+ /// Normalize a service name by removing the system-appended `" (n)"` suffix.
271
+ private func normalize(_ s: String) -> String {
272
+ return s.replacingOccurrences(of: #" \(\d+\)$"#, with: "", options: .regularExpression)
273
+ }
274
+
275
+ /// Does `candidate` match `targetName` exactly or by prefix (both normalized)? If no target, accept all.
276
+ private func matchesTarget(_ candidate: String) -> Bool {
277
+ guard let t = targetName, !t.isEmpty else { return true }
278
+ let c = normalize(candidate), tt = normalize(t)
279
+ return (c == tt) || c.hasPrefix(tt)
280
+ }
281
+
282
+ /// Build a stable identity key for deduplication.
283
+ private func keyFor(name: String, type: String, domain: String) -> String {
284
+ return "\(name)|\(type)|\(domain)"
285
+ }
286
+
287
+ /// Ensure execution on main queue (NSNetServices expect main run loop).
288
+ private func runOnMain(_ block: @escaping () -> Void) {
289
+ if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
290
+ }
291
+ }
292
+
293
+ // MARK: - NetServiceDelegate (publish + resolve)
294
+
295
+ extension MDNS: NetServiceDelegate {
296
+
297
+ // Publish callbacks
298
+ public func netServiceDidPublish(_ sender: NetService) {
299
+ let cb = publishCompletion
300
+ publishCompletion = nil
301
+ cb?(.success(sender.name))
302
+ }
303
+
304
+ public func netService(_ sender: NetService, didNotPublish errorDict: [String: NSNumber]) {
305
+ let cb = publishCompletion
306
+ publishCompletion = nil
307
+ let code = (errorDict[NetService.errorCode] as NSNumber?)?.intValue ?? -1
308
+ let err = NSError(
309
+ domain: "mDNS.publish",
310
+ code: code,
311
+ userInfo: [NSLocalizedDescriptionKey: "Failed to publish service", "info": errorDict]
312
+ )
313
+ cb?(.failure(err))
314
+ }
315
+
316
+ // Resolve callbacks
317
+ public func netServiceDidResolveAddress(_ sender: NetService) {
318
+ guard let box = resolveMap[sender] else { return }
319
+ box.port = Int(sender.port)
320
+ box.hosts = parseHosts(sender.addresses)
321
+ if let txtData = sender.txtRecordData(),
322
+ let dict = NetService.dictionary(fromTXTRecord: txtData) as? [String: Data] {
323
+ box.txt = dict.reduce(into: [:]) { acc, e in
324
+ acc[e.key] = String(data: e.value, encoding: .utf8) ?? ""
325
+ }
326
+ }
327
+ box.resolved = true
328
+
329
+ // Early finish if a specific target is requested and this service matches it.
330
+ if let t = targetName, !t.isEmpty, matchesTarget(sender.name) {
331
+ finishDiscovery()
332
+ } else {
333
+ // Otherwise, allow a brief settle window to collect late peers.
334
+ scheduleSettleDebounce()
335
+ }
336
+ }
337
+
338
+ public func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
339
+ // Drop the unresolved service and try to continue with others.
340
+ resolveMap.removeValue(forKey: sender)
341
+ scheduleSettleDebounce()
342
+ }
343
+ }
344
+
345
+ // MARK: - NetServiceBrowserDelegate (fallback browsing)
346
+
347
+ extension MDNS: NetServiceBrowserDelegate {
348
+
349
+ public func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
350
+ // Early filter by instance name.
351
+ guard matchesTarget(service.name) else { return }
352
+
353
+ // Create a dedicated resolver (do not reuse `service` directly).
354
+ let resolver = NetService(domain: service.domain, type: service.type, name: service.name)
355
+ resolver.includesPeerToPeer = true
356
+ resolver.delegate = self
357
+
358
+ // Track box + resolver pair.
359
+ let box = ServiceBox(name: service.name, type: service.type, domain: service.domain)
360
+ resolveMap[resolver] = box
361
+ discovered.append(box)
362
+
363
+ resolver.resolve(withTimeout: 5.0)
364
+
365
+ // If this wave ends soon, schedule settle debounce; else wait for more.
366
+ if !moreComing { scheduleSettleDebounce() }
367
+ }
368
+
369
+ public func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
370
+ // Not critical for snapshot output; ignore.
371
+ }
372
+
373
+ public func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
374
+ // Gracefully complete with whatever we have (plugin will surface error=true).
375
+ finishDiscovery()
376
+ }
377
+ }
378
+
379
+ // MARK: - Model
380
+
381
+ /// Mutable container while resolving a service.
382
+ private final class ServiceBox: Hashable {
383
+ let name: String
384
+ let type: String
385
+ let domain: String
386
+
387
+ var port: Int = 0
388
+ var hosts: [String] = []
389
+ var txt: [String: String] = [:]
390
+ var resolved: Bool = false
391
+
392
+ init(name: String, type: String, domain: String) {
393
+ self.name = name
394
+ self.type = type
395
+ self.domain = domain
396
+ }
397
+
398
+ var identityKey: String { "\(name)|\(type)|\(domain)" }
399
+
400
+ static func == (lhs: ServiceBox, rhs: ServiceBox) -> Bool {
401
+ lhs.identityKey == rhs.identityKey
402
+ }
403
+ func hash(into hasher: inout Hasher) { hasher.combine(identityKey) }
404
+ }
405
+
406
+ // MARK: - Address utilities
407
+
408
+ /// Convert `NetService.addresses` to numeric IPv4/IPv6 strings.
409
+ private func parseHosts(_ addrs: [Data]?) -> [String] {
410
+ guard let addrs = addrs, !addrs.isEmpty else { return [] }
411
+ var out: [String] = []
412
+ out.reserveCapacity(addrs.count)
413
+
414
+ for data in addrs {
415
+ data.withUnsafeBytes { raw in
416
+ guard let base = raw.baseAddress else { return }
417
+ let sa = base.assumingMemoryBound(to: sockaddr.self)
418
+ let family = Int32(sa.pointee.sa_family)
419
+
420
+ if family == AF_INET {
421
+ var addr = sa.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
422
+ var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
423
+ if inet_ntop(AF_INET, &addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil {
424
+ out.append(String(cString: buf))
425
+ }
426
+ } else if family == AF_INET6 {
427
+ var addr6 = sa.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee.sin6_addr }
428
+ var buf6 = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
429
+ if inet_ntop(AF_INET6, &addr6, &buf6, socklen_t(INET6_ADDRSTRLEN)) != nil {
430
+ out.append(String(cString: buf6))
431
+ }
432
+ }
433
+ }
434
+ }
435
+ return out
436
+ }