@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.
- package/CHANGELOG.md +110 -0
- package/DevioartsCapacitorMdns.podspec +3 -3
- package/Package.swift +1 -1
- package/README.md +221 -37
- package/android/build.gradle +3 -9
- package/android/src/main/java/com/devioarts/capacitor/mdns/AndroidNsdBackend.kt +145 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsOptions.kt +32 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/MdnsValidation.kt +26 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/NsdBackend.kt +59 -0
- package/android/src/main/java/com/devioarts/capacitor/mdns/mDNS.kt +467 -243
- package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +80 -57
- package/dist/docs.json +250 -14
- package/dist/esm/definitions.d.ts +64 -17
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.js +22 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +6 -1
- package/dist/esm/web.js +23 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +45 -2
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +45 -2
- package/dist/plugin.js.map +1 -1
- package/docs/electron.md +29 -3
- package/docs/platform-setup.md +6 -0
- package/docs/playground.md +33 -2
- package/docs/runtime-behavior.md +68 -2
- package/electron/dist/plugin-settings.js +10 -3
- package/electron/dist/plugin-settings.js.map +1 -1
- package/electron/dist/plugin.cjs.js +519 -89
- package/electron/dist/plugin.cjs.js.map +1 -1
- package/electron/mdns-bridge.cjs +25 -2
- package/electron/mdns.ts +514 -81
- package/electron/rollup.config.mjs +1 -1
- package/electron/src/index.ts +1 -0
- package/electron/src/plugin-settings.ts +10 -3
- package/electron/types-public/index.d.ts +25 -3
- package/ios/Sources/mDNSPlugin/mDNS.swift +407 -117
- package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +77 -24
- package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
- package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
- package/package.json +11 -4
- 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
|
|
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
|
-
|
|
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
|
-
///
|
|
53
|
+
/// Discovery hard timeout.
|
|
43
54
|
private var hardTimeout: DispatchWorkItem?
|
|
44
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 \(
|
|
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(
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|