@devioarts/capacitor-mdns 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DevioartsCapacitorMdns.podspec +3 -3
  3. package/Package.swift +1 -1
  4. package/README.md +221 -37
  5. package/android/build.gradle +3 -9
  6. package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
  7. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
  8. package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
  9. package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
  10. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +467 -243
  11. package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +80 -57
  12. package/dist/docs.json +250 -14
  13. package/dist/esm/definitions.d.ts +64 -17
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/index.js +22 -2
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/web.d.ts +6 -1
  18. package/dist/esm/web.js +23 -0
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +45 -2
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +45 -2
  23. package/dist/plugin.js.map +1 -1
  24. package/docs/electron.md +29 -3
  25. package/docs/platform-setup.md +6 -0
  26. package/docs/playground.md +33 -2
  27. package/docs/runtime-behavior.md +68 -2
  28. package/electron/dist/plugin-settings.js +10 -3
  29. package/electron/dist/plugin-settings.js.map +1 -1
  30. package/electron/dist/plugin.cjs.js +519 -89
  31. package/electron/dist/plugin.cjs.js.map +1 -1
  32. package/electron/mdns-bridge.cjs +25 -2
  33. package/electron/mdns.ts +514 -81
  34. package/electron/rollup.config.mjs +1 -1
  35. package/electron/src/index.ts +1 -0
  36. package/electron/src/plugin-settings.ts +10 -3
  37. package/electron/types-public/index.d.ts +25 -3
  38. package/ios/Sources/mDNSPlugin/mDNS.swift +407 -117
  39. package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +77 -24
  40. package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
  41. package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
  42. package/package.json +11 -4
  43. package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -9
@@ -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,10 +8,25 @@ 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 {
13
- private static let publishTimeoutMs = 5000
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
+ }
14
24
 
15
25
  // MARK: - Advertising
16
26
 
27
+ public weak var watchDelegate: MDNSWatchDelegate?
28
+ private var watchSession: WatchSession?
29
+
17
30
  private var publisher: NetService?
18
31
  private var publishCompletion: ((Result<String, Error>) -> Void)?
19
32
  private var publishTimeout: DispatchWorkItem?
@@ -22,9 +35,7 @@ public class MDNS: NSObject {
22
35
  // MARK: - Discovery state
23
36
 
24
37
  private var nsBrowser: NetServiceBrowser?
25
- #if canImport(Network)
26
- private var nwBrowser: Any? // keep as `Any` to allow building against older SDKs
27
- #endif
38
+ private var nwBrowser: NWBrowser?
28
39
 
29
40
  /// Map the resolving `NetService` → service box (accumulates details while resolving).
30
41
  private var resolveMap: [NetService: ServiceBox] = [:]
@@ -39,9 +50,17 @@ public class MDNS: NSObject {
39
50
  private var discoveryError: Error?
40
51
  private var discoverySession: UInt64 = 0
41
52
 
42
- /// Timers: hard timeout and short settle debounce.
53
+ /// Discovery hard timeout.
43
54
  private var hardTimeout: DispatchWorkItem?
44
- 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
+ }
45
64
 
46
65
  // MARK: - Public API (thread-safe)
47
66
 
@@ -53,16 +72,21 @@ public class MDNS: NSObject {
53
72
  /// - txt: Optional TXT dictionary (string → string).
54
73
  /// - completion: Called with the final (possibly uniquified) `name`, or an error.
55
74
  public func broadcast(
56
- type: String,
57
- name: String,
75
+ type rawType: String,
76
+ name rawName: String,
58
77
  port: Int,
59
78
  txt: [String: String]?,
60
79
  completion: @escaping (Result<String, Error>) -> Void
61
80
  ) {
62
- guard (1...65535).contains(port) else {
63
- completion(.failure(Self.error("Missing/invalid port")))
64
- return
65
- }
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 }
66
90
 
67
91
  runOnMain { [weak self] in
68
92
  guard let self = self else {
@@ -75,7 +99,7 @@ public class MDNS: NSObject {
75
99
  self.stopPublisherIfNeeded(reason: "Replaced by a new broadcast request")
76
100
  self.publishCompletion = completion
77
101
 
78
- let svc = NetService(domain: "local.", type: type, name: name, port: Int32(port))
102
+ let svc = self.makeService("local.", type, name, Int32(port))
79
103
  svc.includesPeerToPeer = true
80
104
 
81
105
  if let txt = txt {
@@ -109,12 +133,19 @@ public class MDNS: NSObject {
109
133
  /// - useNW: Prefer `NWBrowser` when available (default `true`), fallback to `NetServiceBrowser` otherwise.
110
134
  /// - completion: Success with normalized service dictionaries, or failure with error.
111
135
  public func discover(
112
- type: String,
113
- name: String?,
136
+ type rawType: String,
137
+ name rawName: String?,
114
138
  timeoutMs: Int,
115
139
  useNW: Bool = true,
116
140
  completion: @escaping (Result<[[String: Any]], Error>) -> Void
117
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 }
118
149
  runOnMain { [weak self] in
119
150
  guard let self = self else {
120
151
  completion(.failure(Self.error("mDNS manager was released before discovery could start")))
@@ -142,14 +173,12 @@ public class MDNS: NSObject {
142
173
  DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(max(0, timeoutMs)), execute: ht)
143
174
 
144
175
  // Start browsing: NWBrowser preferred when available and requested.
145
- #if canImport(Network)
146
- if useNW, #available(iOS 12.0, *) {
176
+ if useNW {
147
177
  self.startNWBrowse(typeWithDot: type, session: session)
148
178
  return
149
179
  }
150
- #endif
151
180
  // Fallback: NetServiceBrowser
152
- let b = NetServiceBrowser()
181
+ let b = self.makeBrowser()
153
182
  b.includesPeerToPeer = true
154
183
  b.delegate = self
155
184
  self.nsBrowser = b
@@ -160,8 +189,6 @@ public class MDNS: NSObject {
160
189
 
161
190
  // MARK: - Internal: NWBrowser discovery
162
191
 
163
- #if canImport(Network)
164
- @available(iOS 12.0, *)
165
192
  private func startNWBrowse(typeWithDot: String, session: UInt64) {
166
193
  // NWBrowser expects type without trailing dot.
167
194
  let typeNoDot = typeWithDot.hasSuffix(".") ? String(typeWithDot.dropLast()) : typeWithDot
@@ -172,12 +199,22 @@ public class MDNS: NSObject {
172
199
  let browser = NWBrowser(for: .bonjour(type: typeNoDot, domain: nil), using: params)
173
200
  self.nwBrowser = browser
174
201
 
175
- browser.stateUpdateHandler = { state in
202
+ browser.stateUpdateHandler = { [weak self] state in
176
203
  switch state {
177
204
  case .failed(let err):
178
205
  DispatchQueue.main.async { [weak self] in
179
206
  self?.finishDiscovery(session: session, error: err)
180
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
+ }
181
218
  default:
182
219
  break
183
220
  }
@@ -188,31 +225,12 @@ public class MDNS: NSObject {
188
225
  guard session == self.discoverySession, self.discoverCompletion != nil else { return }
189
226
  for r in results {
190
227
  guard case let NWEndpoint.service(name: n, type: t, domain: d, interface: _) = r.endpoint else { continue }
191
- if !self.matchesTarget(n) { continue }
192
-
193
- // Use NetService for resolve (port/hosts/TXT) to keep output parity with fallback.
194
- let key = self.keyFor(name: n, type: t, domain: d)
195
- if self.discovered.contains(where: { $0.identityKey == key }) { continue }
196
-
197
- let resolver = NetService(domain: d.isEmpty ? "local." : d,
198
- type: t.hasSuffix(".") ? t : t + ".",
199
- name: n)
200
- resolver.includesPeerToPeer = true
201
- resolver.delegate = self
202
-
203
- let box = ServiceBox(name: n, type: resolver.type, domain: resolver.domain)
204
- self.discovered.append(box)
205
- self.resolveMap[resolver] = box
206
- resolver.resolve(withTimeout: 5.0)
207
-
208
- // Reschedule short settle window on each new find.
209
- self.scheduleSettleDebounce(session: session)
228
+ self.handleServiceFound(name: n, type: t, domain: d)
210
229
  }
211
230
  }
212
231
 
213
232
  browser.start(queue: .main)
214
233
  }
215
- #endif
216
234
 
217
235
  // MARK: - Internal: finalize & helpers
218
236
 
@@ -227,20 +245,12 @@ public class MDNS: NSObject {
227
245
  var seen = Set<String>()
228
246
  var out: [[String: Any]] = []
229
247
 
230
- for box in discovered where box.resolved {
248
+ for box in discovered where box.resolved && !box.hosts.isEmpty {
231
249
  let key = "\(box.name):\(box.port):\(box.hosts.first ?? "")"
232
250
  if seen.contains(key) { continue }
233
251
  seen.insert(key)
234
252
 
235
- var entry: [String: Any] = [
236
- "name": box.name,
237
- "type": box.type,
238
- "domain": box.domain,
239
- "port": box.port
240
- ]
241
- if !box.hosts.isEmpty { entry["hosts"] = box.hosts }
242
- if !box.txt.isEmpty { entry["txt"] = box.txt }
243
- out.append(entry)
253
+ out.append(box.result)
244
254
  }
245
255
 
246
256
  // Clear state before invoking the callback.
@@ -259,30 +269,16 @@ public class MDNS: NSObject {
259
269
  }
260
270
  }
261
271
 
262
- /// Cancel timers (hard timeout and settle debounce).
272
+ /// Cancel the discovery timeout.
263
273
  private func cancelTimers() {
264
274
  hardTimeout?.cancel(); hardTimeout = nil
265
- settleDebounce?.cancel(); settleDebounce = nil
266
- }
267
-
268
- /// Schedule a short debounce to finish discovery when the system becomes idle.
269
- private func scheduleSettleDebounce(session: UInt64? = nil, _ ms: Int = 350) {
270
- settleDebounce?.cancel()
271
- let session = session ?? discoverySession
272
- let wi = DispatchWorkItem { [weak self] in self?.finishDiscovery(session: session) }
273
- settleDebounce = wi
274
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(ms), execute: wi)
275
275
  }
276
276
 
277
277
  /// Stop any active browsers.
278
278
  private func stopBrowsers() {
279
- #if canImport(Network)
280
- if #available(iOS 12.0, *) {
281
- (nwBrowser as? NWBrowser)?.cancel()
282
- nwBrowser = nil
283
- }
284
- #endif
285
- // 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).
286
282
  for (svc, _) in resolveMap {
287
283
  svc.stop()
288
284
  svc.delegate = nil
@@ -309,15 +305,17 @@ public class MDNS: NSObject {
309
305
  publishTimeout?.cancel()
310
306
  let wi = DispatchWorkItem { [weak self] in
311
307
  guard let self = self, session == self.publishSession, self.publishCompletion != nil else { return }
312
- self.stopPublisherIfNeeded(reason: "Timed out waiting for service publish after \(Self.publishTimeoutMs)ms")
308
+ self.stopPublisherIfNeeded(reason: "Timed out waiting for service publish after \(self.publishTimeoutMs)ms")
313
309
  }
314
310
  publishTimeout = wi
315
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Self.publishTimeoutMs), execute: wi)
311
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(publishTimeoutMs), execute: wi)
316
312
  }
317
313
 
318
314
  /// Normalize a service name by removing the system-appended `" (n)"` suffix.
319
315
  private func normalize(_ s: String) -> String {
320
- return s.replacingOccurrences(of: #" \(\d+\)$"#, with: "", options: .regularExpression)
316
+ return s
317
+ .replacingOccurrences(of: #" \(\d+\)$"#, with: "", options: .regularExpression)
318
+ .replacingOccurrences(of: ".", with: "-")
321
319
  }
322
320
 
323
321
  /// Does `candidate` match `targetName` exactly or by prefix (both normalized)? If no target, accept all.
@@ -342,6 +340,89 @@ public class MDNS: NSObject {
342
340
  }
343
341
  }
344
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
+ }
424
+ }
425
+
345
426
  // MARK: - NetServiceDelegate (publish + resolve)
346
427
 
347
428
  extension MDNS: NetServiceDelegate {
@@ -374,30 +455,26 @@ extension MDNS: NetServiceDelegate {
374
455
 
375
456
  // Resolve callbacks
376
457
  public func netServiceDidResolveAddress(_ sender: NetService) {
458
+ if watchResolved(sender) { return }
377
459
  guard let box = resolveMap[sender] else { return }
378
- box.port = Int(sender.port)
379
- box.hosts = parseHosts(sender.addresses)
380
- if let txtData = sender.txtRecordData() {
381
- let dict = NetService.dictionary(fromTXTRecord: txtData)
382
- box.txt = dict.reduce(into: [:]) { acc, e in
383
- acc[e.key] = String(data: e.value, encoding: .utf8) ?? ""
384
- }
385
- }
386
- 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
387
466
 
388
467
  // Early finish if a specific target is requested and this service matches it.
389
468
  if let t = targetName, !t.isEmpty, matchesTarget(sender.name) {
390
469
  finishDiscovery(session: discoverySession)
391
- } else {
392
- // Otherwise, allow a brief settle window to collect late peers.
393
- scheduleSettleDebounce(session: discoverySession)
394
470
  }
395
471
  }
396
472
 
397
473
  public func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
474
+ if watchResolveFailed(sender) { return }
398
475
  // Drop the unresolved service and try to continue with others.
399
- resolveMap.removeValue(forKey: sender)
400
- scheduleSettleDebounce(session: discoverySession)
476
+ guard resolveMap.removeValue(forKey: sender) != nil else { return }
477
+ sender.delegate = nil
401
478
  }
402
479
  }
403
480
 
@@ -406,33 +483,25 @@ extension MDNS: NetServiceDelegate {
406
483
  extension MDNS: NetServiceBrowserDelegate {
407
484
 
408
485
  public func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
409
- // Early filter by instance name.
410
- guard matchesTarget(service.name) else { return }
411
-
412
- let key = keyFor(name: service.name, type: service.type, domain: service.domain)
413
- if discovered.contains(where: { $0.identityKey == key }) { return }
414
-
415
- // Create a dedicated resolver (do not reuse `service` directly).
416
- let resolver = NetService(domain: service.domain, type: service.type, name: service.name)
417
- resolver.includesPeerToPeer = true
418
- resolver.delegate = self
419
-
420
- // Track box + resolver pair.
421
- let box = ServiceBox(name: service.name, type: service.type, domain: service.domain)
422
- resolveMap[resolver] = box
423
- discovered.append(box)
424
-
425
- resolver.resolve(withTimeout: 5.0)
426
-
427
- // If this wave ends soon, schedule settle debounce; else wait for more.
428
- if !moreComing { scheduleSettleDebounce(session: discoverySession) }
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)
429
492
  }
430
493
 
431
494
  public func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
432
- // Not critical for snapshot output; ignore.
495
+ guard watchSession?.browsers.values.contains(where: { $0 === browser }) == true else { return }
496
+ handleWatchLost(name: service.name, type: service.type, domain: service.domain)
433
497
  }
434
498
 
435
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 }
436
505
  let code = (errorDict[NetService.errorCode] as NSNumber?)?.intValue ?? -1
437
506
  let err = NSError(
438
507
  domain: "mDNS.discovery",
@@ -443,6 +512,217 @@ extension MDNS: NetServiceBrowserDelegate {
443
512
  }
444
513
  }
445
514
 
515
+ // MARK: - Continuous discovery
516
+
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
+ }
522
+
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()
552
+ }
553
+ }
554
+
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
+ }
594
+ }
595
+
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
723
+ }
724
+ }
725
+
446
726
  // MARK: - Model
447
727
 
448
728
  /// Mutable container while resolving a service.
@@ -453,6 +733,7 @@ private final class ServiceBox: Hashable {
453
733
 
454
734
  var port: Int = 0
455
735
  var hosts: [String] = []
736
+ var hostname: String?
456
737
  var txt: [String: String] = [:]
457
738
  var resolved: Bool = false
458
739
 
@@ -462,6 +743,13 @@ private final class ServiceBox: Hashable {
462
743
  self.domain = domain
463
744
  }
464
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
+
465
753
  var identityKey: String { "\(name)|\(type)|\(domain)" }
466
754
 
467
755
  static func == (lhs: ServiceBox, rhs: ServiceBox) -> Bool {
@@ -473,31 +761,33 @@ private final class ServiceBox: Hashable {
473
761
  // MARK: - Address utilities
474
762
 
475
763
  /// Convert `NetService.addresses` to numeric IPv4/IPv6 strings.
476
- private func parseHosts(_ addrs: [Data]?) -> [String] {
764
+ func parseHosts(_ addrs: [Data]?) -> [String] {
477
765
  guard let addrs = addrs, !addrs.isEmpty else { return [] }
478
766
  var out: [String] = []
479
767
  out.reserveCapacity(addrs.count)
480
768
 
481
769
  for data in addrs {
482
770
  data.withUnsafeBytes { raw in
483
- guard let base = raw.baseAddress else { return }
771
+ guard raw.count >= MemoryLayout<sockaddr>.size, let base = raw.baseAddress else { return }
484
772
  let sa = base.assumingMemoryBound(to: sockaddr.self)
485
773
  let family = Int32(sa.pointee.sa_family)
486
774
 
487
- if family == AF_INET {
775
+ if family == AF_INET, raw.count >= MemoryLayout<sockaddr_in>.size {
488
776
  var addr = sa.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
489
777
  var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
490
778
  if inet_ntop(AF_INET, &addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil {
491
779
  out.append(String(cString: buf))
492
780
  }
493
- } else if family == AF_INET6 {
494
- 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
495
784
  var buf6 = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
496
785
  if inet_ntop(AF_INET6, &addr6, &buf6, socklen_t(INET6_ADDRSTRLEN)) != nil {
497
- 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)")
498
788
  }
499
789
  }
500
790
  }
501
791
  }
502
- return out
792
+ return out.filter { !$0.contains(":") } + out.filter { $0.contains(":") }
503
793
  }