@devioarts/capacitor-mdns 0.0.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DevioartsCapacitorMdns.podspec +3 -3
  3. package/Package.swift +1 -1
  4. package/README.md +272 -101
  5. package/android/build.gradle +3 -9
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
  8. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
  9. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
  10. package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
  11. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +496 -175
  12. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +85 -49
  13. package/dist/docs.json +313 -15
  14. package/dist/esm/definitions.d.ts +89 -16
  15. package/dist/esm/definitions.js.map +1 -1
  16. package/dist/esm/index.js +22 -1
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/web.d.ts +8 -1
  19. package/dist/esm/web.js +35 -3
  20. package/dist/esm/web.js.map +1 -1
  21. package/dist/plugin.cjs.js +57 -4
  22. package/dist/plugin.cjs.js.map +1 -1
  23. package/dist/plugin.js +57 -4
  24. package/dist/plugin.js.map +1 -1
  25. package/docs/electron.md +91 -0
  26. package/docs/platform-setup.md +58 -0
  27. package/docs/playground.md +50 -0
  28. package/docs/runtime-behavior.md +80 -0
  29. package/electron/dist/plugin-settings.js +22 -0
  30. package/electron/dist/plugin-settings.js.map +1 -0
  31. package/electron/dist/plugin.cjs.js +774 -0
  32. package/electron/dist/plugin.cjs.js.map +1 -0
  33. package/electron/global.d.ts +2 -0
  34. package/electron/mdns-bridge.cjs +33 -4
  35. package/electron/mdns.ts +628 -107
  36. package/electron/rollup.config.mjs +22 -0
  37. package/electron/src/index.ts +2 -0
  38. package/electron/src/plugin-settings.ts +19 -0
  39. package/electron/tsconfig.json +21 -0
  40. package/electron/types-public/index.d.ts +47 -0
  41. package/ios/Sources/mDNSPlugin/mDNS.swift +481 -124
  42. package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +85 -23
  43. package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
  44. package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
  45. package/package.json +55 -20
  46. package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -15
@@ -1,8 +1,6 @@
1
1
  import Foundation
2
2
  import Darwin
3
- #if canImport(Network)
4
3
  import Network
5
- #endif
6
4
 
7
5
  /// iOS mDNS manager.
8
6
  /// - Publishes via `NetService`.
@@ -10,18 +8,34 @@ import Network
10
8
  /// - Resolves each candidate with `NetService` to obtain `port`, `hosts`, and optional TXT.
11
9
  /// - Produces a normalized array of dictionaries consumable by the Capacitor bridge.
12
10
  public class MDNS: NSObject {
11
+ // Tunables and seams. Internal (not private) so unit tests can shorten timers and inject fake services.
12
+ internal var publishTimeoutMs = 5000
13
+ internal var resolveTimeoutSeconds: TimeInterval = 5.0
14
+ internal var makeBrowser: () -> NetServiceBrowser = { NetServiceBrowser() }
15
+ internal var makeService: (_ domain: String, _ type: String, _ name: String, _ port: Int32?) -> NetService =
16
+ MDNS.defaultMakeService
17
+
18
+ private static func defaultMakeService(_ domain: String, _ type: String, _ name: String, _ port: Int32?) -> NetService {
19
+ if let port = port {
20
+ return NetService(domain: domain, type: type, name: name, port: port)
21
+ }
22
+ return NetService(domain: domain, type: type, name: name)
23
+ }
13
24
 
14
25
  // MARK: - Advertising
15
26
 
27
+ public weak var watchDelegate: MDNSWatchDelegate?
28
+ private var watchSession: WatchSession?
29
+
16
30
  private var publisher: NetService?
17
31
  private var publishCompletion: ((Result<String, Error>) -> Void)?
32
+ private var publishTimeout: DispatchWorkItem?
33
+ private var publishSession: UInt64 = 0
18
34
 
19
35
  // MARK: - Discovery state
20
36
 
21
37
  private var nsBrowser: NetServiceBrowser?
22
- #if canImport(Network)
23
- private var nwBrowser: Any? // keep as `Any` to allow building against older SDKs
24
- #endif
38
+ private var nwBrowser: NWBrowser?
25
39
 
26
40
  /// Map the resolving `NetService` → service box (accumulates details while resolving).
27
41
  private var resolveMap: [NetService: ServiceBox] = [:]
@@ -33,10 +47,20 @@ public class MDNS: NSObject {
33
47
 
34
48
  /// Completion invoked once with either success(array) or failure(error).
35
49
  private var discoverCompletion: ((Result<[[String: Any]], Error>) -> Void)?
50
+ private var discoveryError: Error?
51
+ private var discoverySession: UInt64 = 0
36
52
 
37
- /// Timers: hard timeout and short settle debounce.
53
+ /// Discovery hard timeout.
38
54
  private var hardTimeout: DispatchWorkItem?
39
- private var settleDebounce: DispatchWorkItem?
55
+
56
+ deinit {
57
+ watchSession?.stop()
58
+ publishTimeout?.cancel()
59
+ publisher?.stop()
60
+ publisher?.delegate = nil
61
+ cancelTimers()
62
+ stopBrowsers()
63
+ }
40
64
 
41
65
  // MARK: - Public API (thread-safe)
42
66
 
@@ -48,18 +72,34 @@ public class MDNS: NSObject {
48
72
  /// - txt: Optional TXT dictionary (string → string).
49
73
  /// - completion: Called with the final (possibly uniquified) `name`, or an error.
50
74
  public func broadcast(
51
- type: String,
52
- name: String,
75
+ type rawType: String,
76
+ name rawName: String,
53
77
  port: Int,
54
78
  txt: [String: String]?,
55
79
  completion: @escaping (Result<String, Error>) -> Void
56
80
  ) {
81
+ let type: String
82
+ let name: String
83
+ do {
84
+ guard (1...65535).contains(port) else { throw Self.error("Missing/invalid port") }
85
+ type = try Self.validatedType(rawType)
86
+ let normalizedName = try Self.validatedName(rawName)
87
+ name = normalizedName.isEmpty ? "DevIOArtsMDNS" : normalizedName
88
+ try Self.validateTXT(txt)
89
+ } catch { completion(.failure(error)); return }
90
+
57
91
  runOnMain { [weak self] in
58
- guard let self = self else { return }
59
- self.stopPublisherIfNeeded()
92
+ guard let self = self else {
93
+ completion(.failure(Self.error("mDNS manager was released before publishing could start")))
94
+ return
95
+ }
96
+
97
+ self.publishSession &+= 1
98
+ let session = self.publishSession
99
+ self.stopPublisherIfNeeded(reason: "Replaced by a new broadcast request")
60
100
  self.publishCompletion = completion
61
101
 
62
- let svc = NetService(domain: "local.", type: type, name: name, port: Int32(port))
102
+ let svc = self.makeService("local.", type, name, Int32(port))
63
103
  svc.includesPeerToPeer = true
64
104
 
65
105
  if let txt = txt {
@@ -71,6 +111,7 @@ public class MDNS: NSObject {
71
111
 
72
112
  svc.delegate = self
73
113
  self.publisher = svc
114
+ self.schedulePublishTimeout(session: session)
74
115
  // Use default publish options; connection listening not required here.
75
116
  svc.publish()
76
117
  }
@@ -80,7 +121,7 @@ public class MDNS: NSObject {
80
121
  /// Exposed as `throws` by the bridge, but this implementation does not throw.
81
122
  public func stopBroadcast() throws {
82
123
  runOnMain { [weak self] in
83
- self?.stopPublisherIfNeeded()
124
+ self?.stopPublisherIfNeeded(reason: "Publish stopped before completion")
84
125
  }
85
126
  }
86
127
 
@@ -92,36 +133,52 @@ public class MDNS: NSObject {
92
133
  /// - useNW: Prefer `NWBrowser` when available (default `true`), fallback to `NetServiceBrowser` otherwise.
93
134
  /// - completion: Success with normalized service dictionaries, or failure with error.
94
135
  public func discover(
95
- type: String,
96
- name: String?,
136
+ type rawType: String,
137
+ name rawName: String?,
97
138
  timeoutMs: Int,
98
139
  useNW: Bool = true,
99
140
  completion: @escaping (Result<[[String: Any]], Error>) -> Void
100
141
  ) {
142
+ let type: String
143
+ let name: String?
144
+ do {
145
+ type = try Self.validatedType(rawType)
146
+ let trimmed = try Self.validatedName(rawName ?? "")
147
+ name = trimmed.isEmpty ? nil : trimmed
148
+ } catch { completion(.failure(error)); return }
101
149
  runOnMain { [weak self] in
102
- guard let self = self else { return }
150
+ guard let self = self else {
151
+ completion(.failure(Self.error("mDNS manager was released before discovery could start")))
152
+ return
153
+ }
154
+
155
+ if self.discoverCompletion != nil {
156
+ self.finishDiscovery(error: Self.error("Discovery replaced by a new request"))
157
+ }
158
+
159
+ self.discoverySession &+= 1
160
+ let session = self.discoverySession
103
161
  // Reset session state.
104
162
  self.cancelTimers()
105
163
  self.stopBrowsers()
106
164
  self.resolveMap.removeAll()
107
165
  self.discovered.removeAll()
108
166
  self.targetName = name
167
+ self.discoveryError = nil
109
168
  self.discoverCompletion = completion
110
169
 
111
170
  // Schedule hard timeout (always resolves/finishes).
112
- let ht = DispatchWorkItem { [weak self] in self?.finishDiscovery() }
171
+ let ht = DispatchWorkItem { [weak self] in self?.finishDiscovery(session: session) }
113
172
  self.hardTimeout = ht
114
173
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(0, timeoutMs)), execute: ht)
115
174
 
116
175
  // Start browsing: NWBrowser preferred when available and requested.
117
- #if canImport(Network)
118
- if useNW, #available(iOS 12.0, *) {
119
- self.startNWBrowse(typeWithDot: type)
176
+ if useNW {
177
+ self.startNWBrowse(typeWithDot: type, session: session)
120
178
  return
121
179
  }
122
- #endif
123
180
  // Fallback: NetServiceBrowser
124
- let b = NetServiceBrowser()
181
+ let b = self.makeBrowser()
125
182
  b.includesPeerToPeer = true
126
183
  b.delegate = self
127
184
  self.nsBrowser = b
@@ -132,9 +189,7 @@ public class MDNS: NSObject {
132
189
 
133
190
  // MARK: - Internal: NWBrowser discovery
134
191
 
135
- #if canImport(Network)
136
- @available(iOS 12.0, *)
137
- private func startNWBrowse(typeWithDot: String) {
192
+ private func startNWBrowse(typeWithDot: String, session: UInt64) {
138
193
  // NWBrowser expects type without trailing dot.
139
194
  let typeNoDot = typeWithDot.hasSuffix(".") ? String(typeWithDot.dropLast()) : typeWithDot
140
195
 
@@ -144,15 +199,22 @@ public class MDNS: NSObject {
144
199
  let browser = NWBrowser(for: .bonjour(type: typeNoDot, domain: nil), using: params)
145
200
  self.nwBrowser = browser
146
201
 
147
- browser.stateUpdateHandler = { state in
148
- // Intentionally quiet; errors are reflected via timeout or partial results.
149
- // print("[mDNS][NW] state: \(state)")
202
+ browser.stateUpdateHandler = { [weak self] state in
150
203
  switch state {
151
204
  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
205
+ DispatchQueue.main.async { [weak self] in
206
+ self?.finishDiscovery(session: session, error: err)
207
+ }
208
+ case .waiting(let err):
209
+ // Denied Local Network permission parks the browser in `.waiting` forever; surface it.
210
+ if Self.isLocalNetworkDenied(err) {
211
+ DispatchQueue.main.async { [weak self] in
212
+ self?.finishDiscovery(
213
+ session: session,
214
+ error: Self.error("Local network access denied. Enable it in Settings > Privacy > Local Network.")
215
+ )
216
+ }
217
+ }
156
218
  default:
157
219
  break
158
220
  }
@@ -160,38 +222,22 @@ public class MDNS: NSObject {
160
222
 
161
223
  browser.browseResultsChangedHandler = { [weak self] results, _ in
162
224
  guard let self = self else { return }
225
+ guard session == self.discoverySession, self.discoverCompletion != nil else { return }
163
226
  for r in results {
164
227
  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()
228
+ self.handleServiceFound(name: n, type: t, domain: d)
184
229
  }
185
230
  }
186
231
 
187
232
  browser.start(queue: .main)
188
233
  }
189
- #endif
190
234
 
191
235
  // MARK: - Internal: finalize & helpers
192
236
 
193
237
  /// Build the final output and complete the discovery promise.
194
- private func finishDiscovery() {
238
+ private func finishDiscovery(session: UInt64? = nil, error: Error? = nil) {
239
+ if let session = session, session != discoverySession { return }
240
+
195
241
  cancelTimers()
196
242
  stopBrowsers()
197
243
 
@@ -199,55 +245,40 @@ public class MDNS: NSObject {
199
245
  var seen = Set<String>()
200
246
  var out: [[String: Any]] = []
201
247
 
202
- for box in discovered where box.resolved {
248
+ for box in discovered where box.resolved && !box.hosts.isEmpty {
203
249
  let key = "\(box.name):\(box.port):\(box.hosts.first ?? "")"
204
250
  if seen.contains(key) { continue }
205
251
  seen.insert(key)
206
252
 
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)
253
+ out.append(box.result)
216
254
  }
217
255
 
218
256
  // Clear state before invoking the callback.
219
257
  resolveMap.removeAll()
220
258
  discovered.removeAll()
221
259
  targetName = nil
260
+ let finalError = error ?? discoveryError
261
+ discoveryError = nil
222
262
 
223
263
  let cb = discoverCompletion
224
264
  discoverCompletion = nil
225
- cb?(.success(out))
265
+ if let finalError = finalError {
266
+ cb?(.failure(finalError))
267
+ } else {
268
+ cb?(.success(out))
269
+ }
226
270
  }
227
271
 
228
- /// Cancel timers (hard timeout and settle debounce).
272
+ /// Cancel the discovery timeout.
229
273
  private func cancelTimers() {
230
274
  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
275
  }
241
276
 
242
277
  /// Stop any active browsers.
243
278
  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).
279
+ nwBrowser?.cancel()
280
+ nwBrowser = nil
281
+ // Stop all in-flight resolvers as well (best-effort).
251
282
  for (svc, _) in resolveMap {
252
283
  svc.stop()
253
284
  svc.delegate = nil
@@ -259,17 +290,32 @@ public class MDNS: NSObject {
259
290
  }
260
291
 
261
292
  /// Stop the current publisher, if any.
262
- private func stopPublisherIfNeeded() {
293
+ private func stopPublisherIfNeeded(reason: String) {
294
+ publishTimeout?.cancel()
295
+ publishTimeout = nil
263
296
  publisher?.stop()
264
297
  publisher?.delegate = nil
265
298
  publisher = nil
266
- // Do not call `publishCompletion` here: externally, `stopBroadcast()` should be considered a neutral action.
299
+ publishCompletion?(.failure(Self.error(reason)))
267
300
  publishCompletion = nil
268
301
  }
269
302
 
303
+ /// Enforce that NetService publish cannot leave a JS promise pending forever.
304
+ private func schedulePublishTimeout(session: UInt64) {
305
+ publishTimeout?.cancel()
306
+ let wi = DispatchWorkItem { [weak self] in
307
+ guard let self = self, session == self.publishSession, self.publishCompletion != nil else { return }
308
+ self.stopPublisherIfNeeded(reason: "Timed out waiting for service publish after \(self.publishTimeoutMs)ms")
309
+ }
310
+ publishTimeout = wi
311
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(publishTimeoutMs), execute: wi)
312
+ }
313
+
270
314
  /// Normalize a service name by removing the system-appended `" (n)"` suffix.
271
315
  private func normalize(_ s: String) -> String {
272
- return s.replacingOccurrences(of: #" \(\d+\)$"#, with: "", options: .regularExpression)
316
+ return s
317
+ .replacingOccurrences(of: #" \(\d+\)$"#, with: "", options: .regularExpression)
318
+ .replacingOccurrences(of: ".", with: "-")
273
319
  }
274
320
 
275
321
  /// Does `candidate` match `targetName` exactly or by prefix (both normalized)? If no target, accept all.
@@ -288,6 +334,93 @@ public class MDNS: NSObject {
288
334
  private func runOnMain(_ block: @escaping () -> Void) {
289
335
  if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
290
336
  }
337
+
338
+ private static func error(_ message: String) -> NSError {
339
+ return NSError(domain: "mDNS", code: -1, userInfo: [NSLocalizedDescriptionKey: message])
340
+ }
341
+ }
342
+
343
+ extension MDNS {
344
+ private static func validatedWatchTypes(_ types: [String]) throws -> [String] {
345
+ guard !types.isEmpty else { throw error("Missing or empty 'types' array") }
346
+ var unique: [String] = []
347
+ for raw in types {
348
+ guard !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw error("Invalid service type") }
349
+ let type = try validatedType(raw)
350
+ if !unique.contains(type) { unique.append(type) }
351
+ }
352
+ return unique
353
+ }
354
+ public var isPublishing: Bool {
355
+ if Thread.isMainThread { return publisher != nil }
356
+ return DispatchQueue.main.sync { publisher != nil }
357
+ }
358
+ internal static func validatedType(_ raw: String) throws -> String {
359
+ var type = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
360
+ if type.isEmpty { type = "_http._tcp." }
361
+ if type.hasSuffix(".") { type.removeLast() }
362
+ let service = type.components(separatedBy: ".").first.map { String($0.dropFirst()) } ?? ""
363
+ guard type.range(of: "^_[a-z0-9](?:[a-z0-9-]{0,13}[a-z0-9])?\\._(tcp|udp)$", options: .regularExpression) != nil,
364
+ service.range(of: "[a-z]", options: .regularExpression) != nil, !service.contains("--") else {
365
+ throw error("Invalid service type: \(raw)")
366
+ }
367
+ return type + "."
368
+ }
369
+ internal static func validatedName(_ raw: String) throws -> String {
370
+ let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
371
+ guard name.utf8.count <= 63, !raw.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else {
372
+ throw error("Invalid instance name (maximum 63 UTF-8 bytes, no control characters)")
373
+ }
374
+ return name
375
+ }
376
+ internal static func validateTXT(_ txt: [String: String]?) throws {
377
+ var keys = Set<String>()
378
+ var totalBytes = 0
379
+ for (key, value) in txt ?? [:] {
380
+ guard !key.isEmpty,
381
+ key.unicodeScalars.allSatisfy({ (32...126).contains($0.value) && $0 != "=" }),
382
+ keys.insert(key.lowercased()).inserted else {
383
+ throw error("Invalid or duplicate TXT key")
384
+ }
385
+ let bytes = "\(key)=\(value)".utf8.count
386
+ totalBytes += bytes + 1
387
+ guard totalBytes <= 1300 else { throw error("TXT record must fit in 1300 bytes including length prefixes") }
388
+ guard bytes <= 255 else { throw error("TXT entries must be at most 255 UTF-8 bytes including key=") }
389
+ }
390
+ }
391
+
392
+ /// `NWBrowser` parks in `.waiting(.dns(PolicyDenied))` when the Local Network permission is denied.
393
+ static func isLocalNetworkDenied(_ error: NWError) -> Bool {
394
+ if case .dns(let code) = error { return code == -65570 } // kDNSServiceErr_PolicyDenied
395
+ return false
396
+ }
397
+
398
+ // MARK: - Internal: candidate handling (shared by NWBrowser and NetServiceBrowser)
399
+
400
+ /// Register one browse result and start resolving it.
401
+ ///
402
+ /// The identity key is built from the *normalized* type/domain, the same values `ServiceBox`
403
+ /// stores. `NWBrowser` re-delivers its whole result set on every change, so a key built from the
404
+ /// raw values would never match and every change would spawn a new resolver per known service.
405
+ internal func handleServiceFound(name: String, type: String, domain: String) {
406
+ guard discoverCompletion != nil, matchesTarget(name) else { return }
407
+
408
+ let normType = (type.hasSuffix(".") ? type : type + ".").lowercased()
409
+ let normDomain = domain.isEmpty ? "local." : (domain.hasSuffix(".") ? domain : domain + ".")
410
+ let key = keyFor(name: name, type: normType, domain: normDomain)
411
+ if discovered.contains(where: { $0.identityKey == key }) { return }
412
+
413
+ // Dedicated resolver (never reuse the browser's service object).
414
+ let resolver = makeService(normDomain, normType, name, nil)
415
+ resolver.includesPeerToPeer = true
416
+ resolver.delegate = self
417
+
418
+ let box = ServiceBox(name: name, type: normType, domain: normDomain)
419
+ discovered.append(box)
420
+ resolveMap[resolver] = box
421
+ resolver.resolve(withTimeout: resolveTimeoutSeconds)
422
+
423
+ }
291
424
  }
292
425
 
293
426
  // MARK: - NetServiceDelegate (publish + resolve)
@@ -296,12 +429,19 @@ extension MDNS: NetServiceDelegate {
296
429
 
297
430
  // Publish callbacks
298
431
  public func netServiceDidPublish(_ sender: NetService) {
432
+ guard sender === publisher else { return }
433
+ publishTimeout?.cancel()
434
+ publishTimeout = nil
299
435
  let cb = publishCompletion
300
436
  publishCompletion = nil
301
437
  cb?(.success(sender.name))
302
438
  }
303
439
 
304
440
  public func netService(_ sender: NetService, didNotPublish errorDict: [String: NSNumber]) {
441
+ guard sender === publisher else { return }
442
+ publishTimeout?.cancel()
443
+ publishTimeout = nil
444
+ publisher = nil
305
445
  let cb = publishCompletion
306
446
  publishCompletion = nil
307
447
  let code = (errorDict[NetService.errorCode] as NSNumber?)?.intValue ?? -1
@@ -315,30 +455,26 @@ extension MDNS: NetServiceDelegate {
315
455
 
316
456
  // Resolve callbacks
317
457
  public func netServiceDidResolveAddress(_ sender: NetService) {
458
+ if watchResolved(sender) { return }
318
459
  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
460
+ readResolved(sender, into: box)
461
+
462
+ // This resolver is done.
463
+ resolveMap.removeValue(forKey: sender)
464
+ sender.stop()
465
+ sender.delegate = nil
328
466
 
329
467
  // Early finish if a specific target is requested and this service matches it.
330
468
  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()
469
+ finishDiscovery(session: discoverySession)
335
470
  }
336
471
  }
337
472
 
338
473
  public func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
474
+ if watchResolveFailed(sender) { return }
339
475
  // Drop the unresolved service and try to continue with others.
340
- resolveMap.removeValue(forKey: sender)
341
- scheduleSettleDebounce()
476
+ guard resolveMap.removeValue(forKey: sender) != nil else { return }
477
+ sender.delegate = nil
342
478
  }
343
479
  }
344
480
 
@@ -347,32 +483,243 @@ extension MDNS: NetServiceDelegate {
347
483
  extension MDNS: NetServiceBrowserDelegate {
348
484
 
349
485
  public func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
350
- // Early filter by instance name.
351
- guard matchesTarget(service.name) else { return }
486
+ if watchSession?.browsers.values.contains(where: { $0 === browser }) == true {
487
+ handleWatchFound(name: service.name, type: service.type, domain: service.domain)
488
+ return
489
+ }
490
+ guard browser === nsBrowser else { return }
491
+ handleServiceFound(name: service.name, type: service.type, domain: service.domain)
492
+ }
352
493
 
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
494
+ public func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
495
+ guard watchSession?.browsers.values.contains(where: { $0 === browser }) == true else { return }
496
+ handleWatchLost(name: service.name, type: service.type, domain: service.domain)
497
+ }
357
498
 
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)
499
+ public func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
500
+ if let type = watchSession?.browsers.first(where: { $0.value === browser })?.key {
501
+ failWatch(type: type, error: Self.error("Failed to search for services: \(errorDict)"))
502
+ return
503
+ }
504
+ guard browser === nsBrowser else { return }
505
+ let code = (errorDict[NetService.errorCode] as NSNumber?)?.intValue ?? -1
506
+ let err = NSError(
507
+ domain: "mDNS.discovery",
508
+ code: code,
509
+ userInfo: [NSLocalizedDescriptionKey: "Failed to search for services", "info": errorDict]
510
+ )
511
+ finishDiscovery(session: discoverySession, error: err)
512
+ }
513
+ }
514
+
515
+ // MARK: - Continuous discovery
362
516
 
363
- resolver.resolve(withTimeout: 5.0)
517
+ public protocol MDNSWatchDelegate: AnyObject {
518
+ func mdns(_ mdns: MDNS, didFindService service: [String: Any])
519
+ func mdns(_ mdns: MDNS, didLoseService service: [String: Any])
520
+ func mdns(_ mdns: MDNS, watchError type: String, message: String, watching: Bool)
521
+ }
364
522
 
365
- // If this wave ends soon, schedule settle debounce; else wait for more.
366
- if !moreComing { scheduleSettleDebounce() }
523
+ internal struct BrowseService {
524
+ let name: String
525
+ let type: String
526
+ let domain: String
527
+ }
528
+
529
+ private final class WatchSession {
530
+ var browsers: [String: NetServiceBrowser] = [:]
531
+ var networkBrowsers: [NWBrowser] = []
532
+ var known: [String: ServiceBox] = [:]
533
+ var resolvers: [NetService: ServiceBox] = [:]
534
+ var retries: [String: DispatchWorkItem] = [:]
535
+ var attempts: [String: Int] = [:]
536
+
537
+ func stop() {
538
+ for browser in networkBrowsers {
539
+ browser.stateUpdateHandler = nil
540
+ browser.browseResultsChangedHandler = nil
541
+ browser.cancel()
542
+ }
543
+ networkBrowsers.removeAll()
544
+ for browser in browsers.values { browser.delegate = nil; browser.stop() }
545
+ browsers.removeAll()
546
+ for resolver in resolvers.keys { resolver.delegate = nil; resolver.stop() }
547
+ resolvers.removeAll()
548
+ retries.values.forEach { $0.cancel() }
549
+ retries.removeAll()
550
+ known.removeAll()
551
+ attempts.removeAll()
367
552
  }
553
+ }
368
554
 
369
- public func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
370
- // Not critical for snapshot output; ignore.
555
+ extension MDNS {
556
+ /// Thanks to Idrimi for the original continuous discovery feature (PR #7).
557
+ public func watchServices(types: [String], useNW: Bool = true, completion: @escaping (Result<Int, Error>) -> Void) {
558
+ runOnMain { [weak self] in
559
+ guard let self = self else { completion(.failure(Self.error("mDNS manager released"))); return }
560
+ let unique: [String]
561
+ do { unique = try Self.validatedWatchTypes(types) } catch { completion(.failure(error)); return }
562
+ self.stopWatch()
563
+ let watch = WatchSession()
564
+ self.watchSession = watch
565
+ for type in unique {
566
+ if useNW {
567
+ let params = NWParameters()
568
+ params.includePeerToPeer = true
569
+ let browser = NWBrowser(for: .bonjour(type: String(type.dropLast()), domain: nil), using: params)
570
+ watch.networkBrowsers.append(browser)
571
+ browser.stateUpdateHandler = { [weak self, weak watch] state in
572
+ guard let self = self, let watch = watch, self.watchSession === watch else { return }
573
+ self.handleWatchState(state, type: type)
574
+ }
575
+ browser.browseResultsChangedHandler = { [weak self, weak watch] results, _ in
576
+ guard let self = self, let watch = watch, self.watchSession === watch else { return }
577
+ let services: [BrowseService] = results.compactMap { result in
578
+ guard case let .service(name, type, domain, _) = result.endpoint else { return nil }
579
+ return BrowseService(name: name, type: type, domain: domain)
580
+ }
581
+ self.handleWatchResults(services, type: type)
582
+ }
583
+ browser.start(queue: .main)
584
+ } else {
585
+ let browser = self.makeBrowser()
586
+ browser.includesPeerToPeer = true
587
+ browser.delegate = self
588
+ watch.browsers[type] = browser
589
+ browser.searchForServices(ofType: type, inDomain: "local.")
590
+ }
591
+ }
592
+ completion(.success(unique.count))
593
+ }
371
594
  }
372
595
 
373
- public func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
374
- // Gracefully complete with whatever we have (plugin will surface error=true).
375
- finishDiscovery()
596
+ public func stopWatching(completion: @escaping () -> Void = {}) {
597
+ runOnMain { [weak self] in self?.stopWatch(); completion() }
598
+ }
599
+
600
+ private func stopWatch() {
601
+ let old = watchSession
602
+ watchSession = nil
603
+ old?.stop()
604
+ }
605
+
606
+ internal func failWatch(type: String, error: Error) {
607
+ guard watchSession != nil else { return }
608
+ stopWatch()
609
+ watchDelegate?.mdns(self, watchError: type, message: error.localizedDescription, watching: false)
610
+ }
611
+
612
+ internal func handleWatchState(_ state: NWBrowser.State, type: String) {
613
+ switch state {
614
+ case .failed(let error): failWatch(type: type, error: error)
615
+ case .waiting(let error) where Self.isLocalNetworkDenied(error):
616
+ failWatch(type: type, error: Self.error("Local network access denied. Enable it in Settings > Privacy > Local Network."))
617
+ default: break
618
+ }
619
+ }
620
+
621
+ internal func handleWatchResults(_ services: [BrowseService], type: String) {
622
+ guard let watch = watchSession else { return }
623
+ var present = Set<String>()
624
+ for service in services {
625
+ let normalized = normalizedBox(name: service.name, type: service.type, domain: service.domain)
626
+ present.insert(normalized.identityKey)
627
+ handleWatchFound(name: service.name, type: service.type, domain: service.domain)
628
+ }
629
+ for box in Array(watch.known.values) where box.type == type && !present.contains(box.identityKey) {
630
+ guard watchSession === watch else { return }
631
+ handleWatchLost(name: box.name, type: box.type, domain: box.domain)
632
+ }
633
+ }
634
+
635
+ private func normalizedBox(name: String, type: String, domain: String) -> ServiceBox {
636
+ let type = (type.hasSuffix(".") ? type : type + ".").lowercased()
637
+ let domain = domain.isEmpty ? "local." : (domain.hasSuffix(".") ? domain : domain + ".")
638
+ return ServiceBox(name: name, type: type, domain: domain)
639
+ }
640
+
641
+ internal func handleWatchFound(name: String, type: String, domain: String) {
642
+ guard let watch = watchSession else { return }
643
+ let box = normalizedBox(name: name, type: type, domain: domain)
644
+ guard watch.known[box.identityKey] == nil else { return }
645
+ watch.known[box.identityKey] = box
646
+ resolveWatched(box, watch: watch)
647
+ }
648
+
649
+ private func resolveWatched(_ box: ServiceBox, watch: WatchSession) {
650
+ guard watchSession === watch, watch.known[box.identityKey] === box else { return }
651
+ watch.retries.removeValue(forKey: box.identityKey)
652
+ watch.attempts[box.identityKey, default: 0] += 1
653
+ let service = makeService(box.domain, box.type, box.name, nil)
654
+ service.includesPeerToPeer = true
655
+ service.delegate = self
656
+ watch.resolvers[service] = box
657
+ let timeout = DispatchWorkItem { [weak self, weak watch, weak service] in
658
+ guard let self = self, let watch = watch, let service = service, self.watchSession === watch else { return }
659
+ _ = self.watchResolveFailed(service)
660
+ }
661
+ watch.retries[box.identityKey] = timeout
662
+ DispatchQueue.main.asyncAfter(deadline: .now() + resolveTimeoutSeconds, execute: timeout)
663
+ service.resolve(withTimeout: resolveTimeoutSeconds)
664
+ }
665
+
666
+ internal func handleWatchLost(name: String, type: String, domain: String) {
667
+ guard let watch = watchSession else { return }
668
+ let key = normalizedBox(name: name, type: type, domain: domain).identityKey
669
+ guard let box = watch.known.removeValue(forKey: key) else { return }
670
+ watch.retries.removeValue(forKey: key)?.cancel()
671
+ watch.attempts.removeValue(forKey: key)
672
+ for (resolver, pending) in Array(watch.resolvers) where pending === box {
673
+ watch.resolvers.removeValue(forKey: resolver)
674
+ resolver.delegate = nil
675
+ resolver.stop()
676
+ }
677
+ if box.resolved { watchDelegate?.mdns(self, didLoseService: box.result) }
678
+ }
679
+
680
+ private func watchResolved(_ sender: NetService) -> Bool {
681
+ guard let watch = watchSession, let box = watch.resolvers[sender] else { return false }
682
+ readResolved(sender, into: box)
683
+ guard !box.hosts.isEmpty else { return true }
684
+ watch.resolvers.removeValue(forKey: sender)
685
+ watch.retries.removeValue(forKey: box.identityKey)?.cancel()
686
+ readResolved(sender, into: box)
687
+ sender.stop()
688
+ sender.delegate = nil
689
+ guard watch.known[box.identityKey] === box else { return true }
690
+ watchDelegate?.mdns(self, didFindService: box.result)
691
+ return true
692
+ }
693
+
694
+ private func watchResolveFailed(_ sender: NetService) -> Bool {
695
+ guard let watch = watchSession, let box = watch.resolvers.removeValue(forKey: sender) else { return false }
696
+ watch.retries.removeValue(forKey: box.identityKey)?.cancel()
697
+ sender.delegate = nil
698
+ sender.stop()
699
+ let key = box.identityKey
700
+ if watch.attempts[key, default: 0] < 4 {
701
+ let retry = DispatchWorkItem { [weak self, weak watch] in
702
+ guard let self = self, let watch = watch else { return }
703
+ self.resolveWatched(box, watch: watch)
704
+ }
705
+ watch.retries[key] = retry
706
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150), execute: retry)
707
+ } else {
708
+ watch.known.removeValue(forKey: key)
709
+ watch.attempts.removeValue(forKey: key)
710
+ watchDelegate?.mdns(self, watchError: box.type, message: "Failed to resolve \(box.name)", watching: true)
711
+ }
712
+ return true
713
+ }
714
+
715
+ private func readResolved(_ sender: NetService, into box: ServiceBox) {
716
+ box.port = Int(sender.port)
717
+ box.hosts = parseHosts(sender.addresses)
718
+ box.hostname = sender.hostName
719
+ if let txt = sender.txtRecordData() {
720
+ box.txt = NetService.dictionary(fromTXTRecord: txt).mapValues { String(data: $0, encoding: .utf8) ?? "" }
721
+ }
722
+ box.resolved = !box.hosts.isEmpty
376
723
  }
377
724
  }
378
725
 
@@ -386,6 +733,7 @@ private final class ServiceBox: Hashable {
386
733
 
387
734
  var port: Int = 0
388
735
  var hosts: [String] = []
736
+ var hostname: String?
389
737
  var txt: [String: String] = [:]
390
738
  var resolved: Bool = false
391
739
 
@@ -395,6 +743,13 @@ private final class ServiceBox: Hashable {
395
743
  self.domain = domain
396
744
  }
397
745
 
746
+ var result: [String: Any] {
747
+ var value: [String: Any] = ["name": name, "type": type, "domain": domain, "port": port, "hosts": hosts]
748
+ if let hostname = hostname { value["hostname"] = hostname }
749
+ if !txt.isEmpty { value["txt"] = txt }
750
+ return value
751
+ }
752
+
398
753
  var identityKey: String { "\(name)|\(type)|\(domain)" }
399
754
 
400
755
  static func == (lhs: ServiceBox, rhs: ServiceBox) -> Bool {
@@ -406,31 +761,33 @@ private final class ServiceBox: Hashable {
406
761
  // MARK: - Address utilities
407
762
 
408
763
  /// Convert `NetService.addresses` to numeric IPv4/IPv6 strings.
409
- private func parseHosts(_ addrs: [Data]?) -> [String] {
764
+ func parseHosts(_ addrs: [Data]?) -> [String] {
410
765
  guard let addrs = addrs, !addrs.isEmpty else { return [] }
411
766
  var out: [String] = []
412
767
  out.reserveCapacity(addrs.count)
413
768
 
414
769
  for data in addrs {
415
770
  data.withUnsafeBytes { raw in
416
- guard let base = raw.baseAddress else { return }
771
+ guard raw.count >= MemoryLayout<sockaddr>.size, let base = raw.baseAddress else { return }
417
772
  let sa = base.assumingMemoryBound(to: sockaddr.self)
418
773
  let family = Int32(sa.pointee.sa_family)
419
774
 
420
- if family == AF_INET {
775
+ if family == AF_INET, raw.count >= MemoryLayout<sockaddr_in>.size {
421
776
  var addr = sa.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
422
777
  var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
423
778
  if inet_ntop(AF_INET, &addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil {
424
779
  out.append(String(cString: buf))
425
780
  }
426
- } else if family == AF_INET6 {
427
- var addr6 = sa.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee.sin6_addr }
781
+ } else if family == AF_INET6, raw.count >= MemoryLayout<sockaddr_in6>.size {
782
+ let address = sa.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
783
+ var addr6 = address.sin6_addr
428
784
  var buf6 = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
429
785
  if inet_ntop(AF_INET6, &addr6, &buf6, socklen_t(INET6_ADDRSTRLEN)) != nil {
430
- out.append(String(cString: buf6))
786
+ let host = String(cString: buf6)
787
+ out.append(address.sin6_scope_id == 0 ? host : "\(host)%\(address.sin6_scope_id)")
431
788
  }
432
789
  }
433
790
  }
434
791
  }
435
- return out
792
+ return out.filter { !$0.contains(":") } + out.filter { $0.contains(":") }
436
793
  }