@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,5 +1,6 @@
1
1
  import Capacitor
2
2
  import Foundation
3
+ import CoreFoundation
3
4
 
4
5
  /**
5
6
  * Capacitor bridge for the iOS mDNS plugin.
@@ -23,24 +24,37 @@ public class mDNSPlugin: CAPPlugin, CAPBridgedPlugin {
23
24
  CAPPluginMethod(name: "getPluginPlatform", returnType: CAPPluginReturnPromise),
24
25
  CAPPluginMethod(name: "startBroadcast", returnType: CAPPluginReturnPromise),
25
26
  CAPPluginMethod(name: "stopBroadcast", returnType: CAPPluginReturnPromise),
26
- CAPPluginMethod(name: "discover", returnType: CAPPluginReturnPromise)
27
+ CAPPluginMethod(name: "discover", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "watchServices", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "stopWatching", returnType: CAPPluginReturnPromise)
27
30
  ]
28
31
 
29
32
  private let mdns = MDNS()
30
33
 
34
+ override public func load() {
35
+ super.load()
36
+ mdns.watchDelegate = self
37
+ }
38
+
31
39
  // MARK: - Helpers
32
40
 
33
41
  private func jnull(_ v: String?) -> Any {
34
42
  return v ?? NSNull()
35
43
  }
36
44
 
37
- private func normalizeType(_ raw: String?) -> String {
38
- var t = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
39
- if t.isEmpty { t = "_http._tcp." }
40
- if t.last != "." { t += "." }
41
- return t
45
+ private func stringOption(_ call: CAPPluginCall, _ key: String) throws -> String? {
46
+ guard let raw = call.options[key], !(raw is NSNull) else { return nil }
47
+ guard let value = raw as? String else { throw inputError("Invalid \(key)") }
48
+ return value
49
+ }
50
+ private func inputError(_ message: String) -> NSError {
51
+ NSError(domain: "mDNS", code: -1, userInfo: [NSLocalizedDescriptionKey: message])
52
+ }
53
+ private func numberOption(_ call: CAPPluginCall, _ key: String, fallback: Double) throws -> Double {
54
+ guard let raw = call.options[key], !(raw is NSNull) else { return fallback }
55
+ guard let value = raw as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID(), value.doubleValue.isFinite else { throw inputError("Invalid \(key)") }
56
+ return value.doubleValue
42
57
  }
43
-
44
58
  // MARK: - API
45
59
 
46
60
  /// getPluginPlatform()
@@ -52,21 +66,23 @@ public class mDNSPlugin: CAPPlugin, CAPBridgedPlugin {
52
66
 
53
67
  /// startBroadcast({ type?, name?, port, txt? })
54
68
  @objc public func startBroadcast(_ call: CAPPluginCall) {
55
- let type = normalizeType(call.getString("type"))
56
- let name = (call.getString("name")?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? "DevIOArtsMDNS"
57
- let port = call.getInt("port") ?? 0
58
- let txt = call.getObject("txt") as? [String: String]
59
-
60
- guard (1...65535).contains(port) else {
61
- call.resolve([
62
- "publishing": false,
63
- "name": "",
64
- "error": true,
65
- "errorMessage": "Missing/invalid port"
66
- ])
69
+ let type: String, name: String, port: Int, txt: [String: String]?
70
+ do {
71
+ type = try MDNS.validatedType(stringOption(call, "type") ?? "")
72
+ name = try MDNS.validatedName(stringOption(call, "name") ?? "")
73
+ let number = try numberOption(call, "port", fallback: 0)
74
+ guard (1...65535).contains(number), number.rounded(.towardZero) == number else { throw inputError("Missing/invalid port") }
75
+ port = Int(number)
76
+ let raw = call.options["txt"]
77
+ if raw == nil || raw is NSNull { txt = nil } else {
78
+ guard let values = raw as? [String: String] else { throw inputError("TXT must be an object of strings") }
79
+ txt = values
80
+ }
81
+ try MDNS.validateTXT(txt)
82
+ } catch {
83
+ call.resolve(["publishing": mdns.isPublishing, "name": "", "error": true, "errorMessage": error.localizedDescription])
67
84
  return
68
85
  }
69
-
70
86
  mdns.broadcast(type: type, name: name, port: port, txt: txt) { result in
71
87
  switch result {
72
88
  case .success(let finalName):
@@ -107,11 +123,17 @@ public class mDNSPlugin: CAPPlugin, CAPBridgedPlugin {
107
123
 
108
124
  /// discover({ type?, name?, timeout? })
109
125
  @objc public func discover(_ call: CAPPluginCall) {
110
- let type = normalizeType(call.getString("type"))
111
- let targetName = (call.getString("name")?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 }
112
- let timeout = call.getInt("timeout") ?? 3000
126
+ let type: String, targetName: String?, timeout: Int
127
+ do {
128
+ type = try MDNS.validatedType(stringOption(call, "type") ?? "")
129
+ let name = try MDNS.validatedName(stringOption(call, "name") ?? "")
130
+ targetName = name.isEmpty ? nil : name
131
+ timeout = Int(min(2_147_483_647, max(0, try numberOption(call, "timeout", fallback: 3000))))
132
+ } catch {
133
+ call.resolve(["error": true, "errorMessage": error.localizedDescription, "servicesFound": 0, "services": []])
134
+ return
135
+ }
113
136
  let useNW = call.getBool("useNW") ?? true
114
-
115
137
  mdns.discover(type: type, name: targetName, timeoutMs: timeout, useNW: useNW) { result in
116
138
  switch result {
117
139
  case .success(let services):
@@ -131,4 +153,35 @@ public class mDNSPlugin: CAPPlugin, CAPBridgedPlugin {
131
153
  }
132
154
  }
133
155
  }
156
+
157
+ @objc public func watchServices(_ call: CAPPluginCall) {
158
+ guard let types = call.getArray("types", String.self), !types.isEmpty else {
159
+ call.resolve(["error": true, "errorMessage": "Expected a nonempty array of service types", "watchersStarted": 0])
160
+ return
161
+ }
162
+ mdns.watchServices(types: types) { result in
163
+ switch result {
164
+ case .success(let count): call.resolve(["error": false, "errorMessage": NSNull(), "watchersStarted": count])
165
+ case .failure(let error): call.resolve(["error": true, "errorMessage": error.localizedDescription, "watchersStarted": 0])
166
+ }
167
+ }
168
+ }
169
+
170
+ @objc public func stopWatching(_ call: CAPPluginCall) {
171
+ mdns.stopWatching {
172
+ call.resolve(["watching": false, "error": false, "errorMessage": NSNull()])
173
+ }
174
+ }
175
+ }
176
+
177
+ extension mDNSPlugin: MDNSWatchDelegate {
178
+ public func mdns(_ mdns: MDNS, didFindService service: [String: Any]) {
179
+ notifyListeners("serviceFound", data: ["service": service])
180
+ }
181
+ public func mdns(_ mdns: MDNS, didLoseService service: [String: Any]) {
182
+ notifyListeners("serviceLost", data: ["service": service])
183
+ }
184
+ public func mdns(_ mdns: MDNS, watchError type: String, message: String, watching: Bool) {
185
+ notifyListeners("watchError", data: ["type": type, "errorMessage": message, "watching": watching])
186
+ }
134
187
  }
@@ -0,0 +1,46 @@
1
+ import Capacitor
2
+ import XCTest
3
+ @testable import mDNSPlugin
4
+
5
+ final class BridgeTests: XCTestCase {
6
+ private func invoke(_ method: String, options: [String: Any], perform: (CAPPluginCall) -> Void) throws -> [String: Any] {
7
+ var result: [String: Any]?
8
+ let call = try XCTUnwrap(CAPPluginCall(
9
+ callbackId: "test", methodName: method, options: options,
10
+ success: { response, _ in result = response?.data },
11
+ error: { _ in XCTFail("Invalid input must resolve with error: true") }
12
+ ))
13
+ perform(call)
14
+ return try XCTUnwrap(result)
15
+ }
16
+
17
+ func testBroadcastRejectsInvalidRawBridgeValues() throws {
18
+ let plugin = mDNSPlugin()
19
+ let cases: [[String: Any]] = [
20
+ ["port": 80.5], ["port": true], ["port": "80"], ["port": Double.nan],
21
+ ["port": 0], ["port": 65536], ["port": 80, "txt": ["a": 1]],
22
+ ["port": 80, "txt": ["a": true]], ["port": 80, "type": 1], ["port": 80, "name": true]
23
+ ]
24
+ for options in cases {
25
+ let result = try invoke("startBroadcast", options: options, perform: plugin.startBroadcast)
26
+ XCTAssertEqual(result["error"] as? Bool, true, "\(options)")
27
+ XCTAssertEqual(result["publishing"] as? Bool, false)
28
+ XCTAssertEqual(result["name"] as? String, "")
29
+ XCTAssertFalse(try XCTUnwrap(result["errorMessage"] as? String).isEmpty)
30
+ }
31
+ }
32
+
33
+ func testDiscoveryRejectsInvalidRawBridgeValues() throws {
34
+ let plugin = mDNSPlugin()
35
+ let cases: [[String: Any]] = [
36
+ ["timeout": true], ["timeout": "50"], ["timeout": Double.nan],
37
+ ["timeout": Double.infinity], ["type": 1], ["name": true]
38
+ ]
39
+ for options in cases {
40
+ let result = try invoke("discover", options: options, perform: plugin.discover)
41
+ XCTAssertEqual(result["error"] as? Bool, true)
42
+ XCTAssertEqual(result["servicesFound"] as? Int, 0)
43
+ XCTAssertTrue(try XCTUnwrap(result["services"] as? [Any]).isEmpty)
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,490 @@
1
+ import XCTest
2
+ import Network
3
+ @testable import mDNSPlugin
4
+
5
+ // MARK: - Fakes
6
+
7
+ /// `NetService` that never touches the network; tests drive the delegate callbacks by hand.
8
+ private final class FakeNetService: NetService {
9
+ var resolveCalls = 0
10
+ var publishCalls = 0
11
+ var stopCalls = 0
12
+ var fakePort = 8080
13
+ var fakeAddresses: [Data]?
14
+ var fakeTXT: Data?
15
+
16
+ override func resolve(withTimeout timeout: TimeInterval) { resolveCalls += 1 }
17
+ override func publish() { publishCalls += 1 }
18
+ override func stop() { stopCalls += 1 }
19
+ override var port: Int { fakePort }
20
+ override var addresses: [Data]? { fakeAddresses }
21
+ override func txtRecordData() -> Data? { fakeTXT }
22
+ }
23
+
24
+ private final class FakeBrowser: NetServiceBrowser {
25
+ var stopCalls = 0
26
+ override func searchForServices(ofType type: String, inDomain domainString: String) {}
27
+ override func stop() { stopCalls += 1 }
28
+ }
29
+
30
+ private final class WatchProbe: MDNSWatchDelegate {
31
+ var found: [[String: Any]] = []
32
+ var lost: [[String: Any]] = []
33
+ var errors: [(String, Bool)] = []
34
+ func mdns(_ mdns: MDNS, didFindService service: [String: Any]) { found.append(service) }
35
+ func mdns(_ mdns: MDNS, didLoseService service: [String: Any]) { lost.append(service) }
36
+ func mdns(_ mdns: MDNS, watchError type: String, message: String, watching: Bool) { errors.append((message, watching)) }
37
+ }
38
+
39
+ private final class Rig {
40
+ let mdns = MDNS()
41
+ var services: [FakeNetService] = []
42
+ var browsers: [FakeBrowser] = []
43
+
44
+ init(settleMs: Int = 30, publishTimeoutMs: Int = 5000) {
45
+ mdns.publishTimeoutMs = publishTimeoutMs
46
+ mdns.makeBrowser = { [weak self] in
47
+ let browser = FakeBrowser()
48
+ self?.browsers.append(browser)
49
+ return browser
50
+ }
51
+ mdns.makeService = { [weak self] domain, type, name, port in
52
+ let s = port.map { FakeNetService(domain: domain, type: type, name: name, port: $0) }
53
+ ?? FakeNetService(domain: domain, type: type, name: name)
54
+ self?.services.append(s)
55
+ return s
56
+ }
57
+ }
58
+
59
+ /// Start a discovery on the fallback browser and record its outcome.
60
+ func discover(name: String? = nil, timeoutMs: Int = 2000, outcome: @escaping (Result<[[String: Any]], Error>) -> Void) {
61
+ mdns.discover(type: "_http._tcp.", name: name, timeoutMs: timeoutMs, useNW: false, completion: outcome)
62
+ }
63
+
64
+ func found(_ name: String, type: String = "_http._tcp.", domain: String = "local.") {
65
+ mdns.handleServiceFound(name: name, type: type, domain: domain)
66
+ }
67
+
68
+ func resolve(_ service: FakeNetService, host: String = "192.168.1.5") {
69
+ var addr = sockaddr_in()
70
+ addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
71
+ addr.sin_family = sa_family_t(AF_INET)
72
+ inet_pton(AF_INET, host, &addr.sin_addr)
73
+ service.fakeAddresses = [withUnsafeBytes(of: &addr) { Data($0) }]
74
+ mdns.netServiceDidResolveAddress(service)
75
+ }
76
+ }
77
+
78
+ // MARK: - Tests
79
+
80
+ final class MDNSTests: XCTestCase {
81
+
82
+ private func pause(_ seconds: TimeInterval) {
83
+ let e = expectation(description: "delay")
84
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { e.fulfill() }
85
+ wait(for: [e], timeout: seconds + 2)
86
+ }
87
+
88
+ func testBrowsingContinuesForTheEntireTimeoutAfterAnIdleResult() {
89
+ let rig = Rig()
90
+ var outcome: Result<[[String: Any]], Error>?
91
+ let done = expectation(description: "full timeout")
92
+ rig.discover(timeoutMs: 300) { outcome = $0; done.fulfill() }
93
+ rig.found("First")
94
+ rig.resolve(rig.services[0])
95
+ pause(0.15)
96
+ XCTAssertNil(outcome)
97
+ rig.found("Late")
98
+ rig.resolve(rig.services[1])
99
+ wait(for: [done], timeout: 1)
100
+ guard case .success(let services)? = outcome else { return XCTFail("expected success") }
101
+ XCTAssertEqual(services.count, 2)
102
+ }
103
+ func testNameMatchingTreatsDotsAndHyphensEqually() {
104
+ let rig = Rig()
105
+ var outcome: Result<[[String: Any]], Error>?
106
+ rig.discover(name: "My-Device") { outcome = $0 }
107
+ rig.found("My.Device")
108
+ rig.resolve(rig.services[0])
109
+ guard case .success(let services)? = outcome else { return XCTFail("expected early match") }
110
+ XCTAssertEqual(services.first?["name"] as? String, "My.Device")
111
+ }
112
+ func testValidationEnforcesDnsAndUtf8LimitsWithoutStoppingPublisher() {
113
+ let rig = Rig()
114
+ rig.mdns.broadcast(type: "_http._tcp.", name: "Good", port: 80, txt: nil) { _ in }
115
+ for type in ["_123._tcp.", "_ab--cd._tcp.", "_abcdefghijklmnop._tcp.", "http"] {
116
+ XCTAssertThrowsError(try MDNS.validatedType(type))
117
+ }
118
+ XCTAssertEqual(try? MDNS.validatedType(" _HTTP._TCP "), "_http._tcp.")
119
+ XCTAssertThrowsError(try MDNS.validatedName(String(repeating: "é", count: 32)))
120
+ for txt in [["": "x"], ["a=b": "x"], ["a": String(repeating: "é", count: 127)], ["A": "", "a": ""], Dictionary(uniqueKeysWithValues: (0..<6).map { ("k\($0)", String(repeating: "x", count: 250)) })] {
121
+ rig.mdns.broadcast(type: "_http._tcp.", name: "Bad", port: 81, txt: txt) { result in
122
+ guard case .failure = result else { return XCTFail("invalid TXT accepted") }
123
+ }
124
+ XCTAssertEqual(rig.services[0].stopCalls, 0)
125
+ }
126
+ }
127
+ // MARK: Discovery
128
+
129
+ func testSlowResolveDoesNotFinishBeforeTheServiceIsResolved() {
130
+ let rig = Rig(settleMs: 30)
131
+ var outcome: Result<[[String: Any]], Error>?
132
+ let done = expectation(description: "discovery")
133
+ rig.discover { outcome = $0; done.fulfill() }
134
+
135
+ rig.found("A")
136
+ pause(0.15) // several debounce windows pass while the resolver is still outstanding
137
+ XCTAssertNil(outcome, "must not settle while a resolver is pending")
138
+
139
+ rig.resolve(rig.services[0])
140
+ wait(for: [done], timeout: 2)
141
+
142
+ guard case .success(let services)? = outcome else { return XCTFail("expected success") }
143
+ XCTAssertEqual(services.count, 1)
144
+ XCTAssertEqual(services.first?["name"] as? String, "A")
145
+ XCTAssertEqual(services.first?["port"] as? Int, 8080)
146
+ XCTAssertEqual(services.first?["hosts"] as? [String], ["192.168.1.5"])
147
+ }
148
+
149
+ func testRepeatedBrowseResultsDoNotSpawnDuplicateResolvers() {
150
+ let rig = Rig()
151
+ rig.discover { _ in }
152
+
153
+ // NWBrowser redelivers its full result set on every change, with un-normalized type/domain.
154
+ rig.found("A", type: "_http._tcp", domain: "local")
155
+ rig.found("A", type: "_http._tcp", domain: "local")
156
+ rig.found("A", type: "_http._tcp.", domain: "local.")
157
+ rig.found("A", type: "_http._tcp", domain: "")
158
+
159
+ XCTAssertEqual(rig.services.count, 1)
160
+ XCTAssertEqual(rig.services[0].resolveCalls, 1)
161
+ }
162
+
163
+ func testResolvedServiceWithoutAddressesKeepsWaitingForAddresses() {
164
+ let rig = Rig()
165
+ let done = expectation(description: "discovery")
166
+ rig.discover(name: "A", timeoutMs: 80) { outcome in
167
+ guard case .success(let services) = outcome else {
168
+ done.fulfill()
169
+ return XCTFail("expected success")
170
+ }
171
+ XCTAssertTrue(services.isEmpty)
172
+ done.fulfill()
173
+ }
174
+ rig.found("A")
175
+ rig.mdns.netServiceDidResolveAddress(rig.services[0])
176
+ wait(for: [done], timeout: 2)
177
+ }
178
+
179
+ func testTargetNameFiltersCandidatesAndFinishesEarly() {
180
+ let rig = Rig()
181
+ var outcome: Result<[[String: Any]], Error>?
182
+ let done = expectation(description: "discovery")
183
+ rig.discover(name: "Foo", timeoutMs: 10_000) { outcome = $0; done.fulfill() }
184
+
185
+ rig.found("Other")
186
+ XCTAssertEqual(rig.services.count, 0, "non-matching candidates must not be resolved")
187
+
188
+ rig.found("Foo (2)")
189
+ XCTAssertEqual(rig.services.count, 1)
190
+ rig.resolve(rig.services[0])
191
+ wait(for: [done], timeout: 1) // early exit, long before the 10s hard timeout
192
+
193
+ guard case .success(let services)? = outcome else { return XCTFail("expected success") }
194
+ XCTAssertEqual(services.compactMap { $0["name"] as? String }, ["Foo (2)"])
195
+ }
196
+
197
+ func testHardTimeoutReturnsOnlyResolvedServices() {
198
+ let rig = Rig()
199
+ var outcome: Result<[[String: Any]], Error>?
200
+ let done = expectation(description: "discovery")
201
+ rig.discover(timeoutMs: 80) { outcome = $0; done.fulfill() }
202
+
203
+ rig.found("NeverResolves")
204
+ wait(for: [done], timeout: 2)
205
+
206
+ guard case .success(let services)? = outcome else { return XCTFail("expected success") }
207
+ XCTAssertTrue(services.isEmpty)
208
+ }
209
+
210
+ func testUnresolvableServiceDoesNotBlockSettling() {
211
+ let rig = Rig(settleMs: 30)
212
+ var outcome: Result<[[String: Any]], Error>?
213
+ let done = expectation(description: "discovery")
214
+ rig.discover { outcome = $0; done.fulfill() }
215
+
216
+ rig.found("Bad")
217
+ rig.found("Good")
218
+ rig.mdns.netService(rig.services[0], didNotResolve: [:])
219
+ rig.resolve(rig.services[1])
220
+ wait(for: [done], timeout: 2)
221
+
222
+ guard case .success(let services)? = outcome else { return XCTFail("expected success") }
223
+ XCTAssertEqual(services.compactMap { $0["name"] as? String }, ["Good"])
224
+ }
225
+
226
+ func testNewDiscoveryReplacesTheOldOneAndIgnoresItsStaleCallbacks() {
227
+ let rig = Rig()
228
+ var first: Result<[[String: Any]], Error>?
229
+ var second: Result<[[String: Any]], Error>?
230
+ rig.discover { first = $0 }
231
+ rig.found("Old")
232
+ let stale = rig.services[0]
233
+
234
+ rig.discover { second = $0 }
235
+ guard case .failure(let err)? = first else { return XCTFail("first discovery must fail as replaced") }
236
+ XCTAssertTrue(err.localizedDescription.contains("replaced"))
237
+
238
+ rig.resolve(stale) // callback from the previous session
239
+ pause(0.1)
240
+ XCTAssertNil(second, "a stale resolver must not complete the new discovery")
241
+ }
242
+
243
+ // MARK: Publishing
244
+
245
+ func testPublishSuccessReportsServiceName() {
246
+ let rig = Rig()
247
+ var outcome: Result<String, Error>?
248
+ rig.mdns.broadcast(type: "_http._tcp.", name: "Svc", port: 8080, txt: ["k": "v"]) { outcome = $0 }
249
+
250
+ let svc = rig.services[0]
251
+ XCTAssertEqual(svc.publishCalls, 1)
252
+ rig.mdns.netServiceDidPublish(svc)
253
+
254
+ guard case .success(let name)? = outcome else { return XCTFail("expected success") }
255
+ XCTAssertEqual(name, "Svc")
256
+ }
257
+
258
+ func testPublishTimeoutFailsAndStopsThePublisher() {
259
+ let rig = Rig(publishTimeoutMs: 50)
260
+ let done = expectation(description: "publish")
261
+ var outcome: Result<String, Error>?
262
+ rig.mdns.broadcast(type: "_http._tcp.", name: "Svc", port: 8080, txt: nil) { outcome = $0; done.fulfill() }
263
+ wait(for: [done], timeout: 2)
264
+
265
+ guard case .failure(let err)? = outcome else { return XCTFail("expected failure") }
266
+ XCTAssertTrue(err.localizedDescription.contains("Timed out"))
267
+ XCTAssertEqual(rig.services[0].stopCalls, 1)
268
+ }
269
+
270
+ func testNewBroadcastFailsThePreviousPendingOne() {
271
+ let rig = Rig()
272
+ var first: Result<String, Error>?
273
+ var second: Result<String, Error>?
274
+ rig.mdns.broadcast(type: "_http._tcp.", name: "A", port: 1, txt: nil) { first = $0 }
275
+ rig.mdns.broadcast(type: "_http._tcp.", name: "B", port: 2, txt: nil) { second = $0 }
276
+
277
+ guard case .failure(let err)? = first else { return XCTFail("first broadcast must fail as replaced") }
278
+ XCTAssertTrue(err.localizedDescription.contains("Replaced"))
279
+ XCTAssertNil(second)
280
+ XCTAssertEqual(rig.services[0].stopCalls, 1)
281
+
282
+ rig.mdns.netServiceDidPublish(rig.services[0]) // stale publisher: ignored
283
+ XCTAssertNil(second)
284
+ rig.mdns.netServiceDidPublish(rig.services[1])
285
+ guard case .success(let name)? = second else { return XCTFail("expected success") }
286
+ XCTAssertEqual(name, "B")
287
+ }
288
+
289
+ func testDidNotPublishReportsErrorAndAllowsRepublishing() {
290
+ let rig = Rig()
291
+ var first: Result<String, Error>?
292
+ rig.mdns.broadcast(type: "_http._tcp.", name: "A", port: 1, txt: nil) { first = $0 }
293
+ rig.mdns.netService(rig.services[0], didNotPublish: [NetService.errorCode: NSNumber(value: -72001)])
294
+
295
+ guard case .failure(let err)? = first else { return XCTFail("expected failure") }
296
+ XCTAssertEqual((err as NSError).code, -72001)
297
+
298
+ var second: Result<String, Error>?
299
+ rig.mdns.broadcast(type: "_http._tcp.", name: "A", port: 1, txt: nil) { second = $0 }
300
+ rig.mdns.netServiceDidPublish(rig.services[1])
301
+ guard case .success? = second else { return XCTFail("expected success") }
302
+ }
303
+
304
+ func testStopBroadcastIsIdempotentAndFailsAPendingPublish() throws {
305
+ let rig = Rig()
306
+ var outcome: Result<String, Error>?
307
+ rig.mdns.broadcast(type: "_http._tcp.", name: "A", port: 1, txt: nil) { outcome = $0 }
308
+
309
+ try rig.mdns.stopBroadcast()
310
+ try rig.mdns.stopBroadcast()
311
+
312
+ guard case .failure? = outcome else { return XCTFail("pending publish must be failed by stop") }
313
+ XCTAssertEqual(rig.services[0].stopCalls, 1)
314
+ }
315
+
316
+ func testInvalidPortFailsImmediately() {
317
+ let rig = Rig()
318
+ for port in [0, -1, 65536] {
319
+ var outcome: Result<String, Error>?
320
+ rig.mdns.broadcast(type: "_http._tcp.", name: "A", port: port, txt: nil) { outcome = $0 }
321
+ guard case .failure? = outcome else { return XCTFail("port \(port) must be rejected") }
322
+ }
323
+ XCTAssertTrue(rig.services.isEmpty)
324
+ }
325
+
326
+ // MARK: Helpers
327
+
328
+ func testParseHostsHandlesIPv4AndIPv6() {
329
+ var v4 = sockaddr_in()
330
+ v4.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
331
+ v4.sin_family = sa_family_t(AF_INET)
332
+ inet_pton(AF_INET, "10.1.2.3", &v4.sin_addr)
333
+
334
+ var v6 = sockaddr_in6()
335
+ v6.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
336
+ v6.sin6_family = sa_family_t(AF_INET6)
337
+ inet_pton(AF_INET6, "fe80::1", &v6.sin6_addr)
338
+
339
+ let hosts = parseHosts([withUnsafeBytes(of: &v6) { Data($0) }, withUnsafeBytes(of: &v4) { Data($0) }])
340
+ XCTAssertEqual(hosts, ["10.1.2.3", "fe80::1"])
341
+ XCTAssertEqual(parseHosts(nil), [])
342
+ XCTAssertEqual(parseHosts([]), [])
343
+
344
+ v6.sin6_scope_id = 7
345
+ XCTAssertEqual(parseHosts([withUnsafeBytes(of: &v6) { Data($0) }]), ["fe80::1%7"])
346
+ }
347
+
348
+ func testLocalNetworkDeniedDetection() {
349
+ XCTAssertTrue(MDNS.isLocalNetworkDenied(.dns(-65570)))
350
+ XCTAssertFalse(MDNS.isLocalNetworkDenied(.dns(-65537)))
351
+ XCTAssertFalse(MDNS.isLocalNetworkDenied(.posix(.ECONNREFUSED)))
352
+ }
353
+ }
354
+
355
+ // MARK: - Continuous discovery regressions
356
+
357
+ extension MDNSTests {
358
+ private func watch(_ rig: Rig, _ probe: WatchProbe, types: [String] = ["_http._tcp."]) {
359
+ rig.mdns.watchDelegate = probe
360
+ rig.mdns.watchServices(types: types, useNW: false) { result in
361
+ guard case .success? = Optional(result) else { return XCTFail("watch failed: \(result)") }
362
+ }
363
+ }
364
+
365
+ func testWatchDeduplicatesTypesAndReturnsFullLostSnapshot() {
366
+ let rig = Rig()
367
+ let probe = WatchProbe()
368
+ watch(rig, probe, types: [" _http._tcp", "_http._tcp.", "_ipp._tcp."])
369
+ XCTAssertEqual(rig.browsers.count, 2)
370
+ let browser = rig.browsers[0]
371
+ let found = FakeNetService(domain: "local.", type: "_http._tcp.", name: "A")
372
+ rig.mdns.netServiceBrowser(browser, didFind: found, moreComing: false)
373
+ rig.mdns.netServiceBrowser(browser, didFind: found, moreComing: false)
374
+ XCTAssertEqual(rig.services.count, 1)
375
+ rig.resolve(rig.services[0])
376
+ XCTAssertEqual(probe.found.count, 1)
377
+ XCTAssertEqual(probe.found[0]["hosts"] as? [String], ["192.168.1.5"])
378
+ rig.mdns.netServiceBrowser(browser, didRemove: found, moreComing: false)
379
+ XCTAssertEqual(probe.lost.count, 1)
380
+ XCTAssertEqual(probe.lost[0] as NSDictionary, probe.found[0] as NSDictionary)
381
+ rig.mdns.stopWatching()
382
+ XCTAssertTrue(rig.browsers.allSatisfy { $0.stopCalls == 1 })
383
+ }
384
+
385
+ func testWatchLossAndStopIgnoreLateResolveAndBrowserCallbacks() {
386
+ let rig = Rig()
387
+ let probe = WatchProbe()
388
+ watch(rig, probe)
389
+ rig.mdns.handleWatchFound(name: "A", type: "_http._tcp", domain: "local")
390
+ let oldResolver = rig.services[0]
391
+ rig.mdns.handleWatchLost(name: "A", type: "_http._tcp.", domain: "local.")
392
+ rig.resolve(oldResolver)
393
+ XCTAssertTrue(probe.found.isEmpty)
394
+ XCTAssertTrue(probe.lost.isEmpty)
395
+ rig.mdns.handleWatchFound(name: "B", type: "_http._tcp.", domain: "local.")
396
+ let oldBrowser = rig.browsers[0]
397
+ rig.mdns.stopWatching()
398
+ watch(rig, probe)
399
+ rig.resolve(rig.services[1])
400
+ rig.mdns.netServiceBrowser(oldBrowser, didFind: oldResolver, moreComing: false)
401
+ rig.mdns.netServiceBrowser(oldBrowser, didNotSearch: [:])
402
+ XCTAssertTrue(probe.found.isEmpty)
403
+ XCTAssertTrue(probe.errors.isEmpty)
404
+ XCTAssertEqual(rig.services.count, 2)
405
+ rig.mdns.stopWatching()
406
+ }
407
+
408
+ func testWatchRetriesFailedResolveAndAllowsRediscoveryAfterExhaustion() {
409
+ let rig = Rig()
410
+ let probe = WatchProbe()
411
+ watch(rig, probe)
412
+ rig.mdns.handleWatchFound(name: "A", type: "_http._tcp.", domain: "local.")
413
+ for attempt in 0..<4 {
414
+ rig.mdns.netService(rig.services[attempt], didNotResolve: [:])
415
+ if attempt < 3 { pause(0.2) }
416
+ }
417
+ XCTAssertEqual(probe.errors.count, 1)
418
+ XCTAssertEqual(probe.errors[0].1, true)
419
+ rig.mdns.handleWatchFound(name: "A", type: "_http._tcp.", domain: "local.")
420
+ rig.resolve(rig.services[4])
421
+ XCTAssertEqual(probe.found.count, 1)
422
+ rig.mdns.stopWatching()
423
+ }
424
+
425
+ func testWatchRetriesWhenResolverNeverCallsBackAndCancelsRetryOnStop() {
426
+ let rig = Rig()
427
+ rig.mdns.resolveTimeoutSeconds = 0.03
428
+ watch(rig, WatchProbe())
429
+ rig.mdns.handleWatchFound(name: "A", type: "_http._tcp.", domain: "local.")
430
+ pause(0.22)
431
+ XCTAssertGreaterThanOrEqual(rig.services.count, 2)
432
+ rig.mdns.stopWatching()
433
+ let count = rig.services.count
434
+ pause(0.25)
435
+ XCTAssertEqual(rig.services.count, count)
436
+ }
437
+
438
+ func testWatchAndDiscoverStayIndependentAndInvalidReplacementKeepsWatch() {
439
+ let rig = Rig()
440
+ let probe = WatchProbe()
441
+ watch(rig, probe)
442
+ var discovery: Result<[[String: Any]], Error>?
443
+ rig.discover(name: "Once") { discovery = $0 }
444
+ rig.found("Once")
445
+ rig.mdns.handleWatchFound(name: "Watched", type: "_http._tcp.", domain: "local.")
446
+ rig.resolve(rig.services[1])
447
+ XCTAssertEqual(probe.found.count, 1)
448
+ XCTAssertNil(discovery)
449
+ for types in [[], [" "], ["http"]] {
450
+ rig.mdns.watchServices(types: types, useNW: false) { result in
451
+ guard case .failure = result else { return XCTFail("invalid watch accepted") }
452
+ }
453
+ }
454
+ XCTAssertEqual(rig.browsers[0].stopCalls, 0)
455
+ rig.mdns.stopWatching()
456
+ rig.resolve(rig.services[0])
457
+ pause(0.05)
458
+ guard case .success(let services)? = discovery else { return XCTFail("discovery failed") }
459
+ XCTAssertEqual(services.compactMap { $0["name"] as? String }, ["Once"])
460
+ }
461
+
462
+ func testNetworkWatchSnapshotsDeduplicateAndLossCancelsPendingResolution() {
463
+ let rig = Rig()
464
+ let probe = WatchProbe()
465
+ watch(rig, probe)
466
+ let values = [BrowseService(name: "A", type: "_http._tcp", domain: "local")]
467
+ rig.mdns.handleWatchResults(values, type: "_http._tcp.")
468
+ rig.mdns.handleWatchResults(values, type: "_http._tcp.")
469
+ XCTAssertEqual(rig.services.count, 1)
470
+ rig.mdns.handleWatchResults([], type: "_http._tcp.")
471
+ rig.resolve(rig.services[0])
472
+ XCTAssertTrue(probe.found.isEmpty)
473
+ XCTAssertTrue(probe.lost.isEmpty)
474
+ rig.mdns.stopWatching()
475
+ }
476
+
477
+ func testDeniedNetworkPermissionStopsWatchAndReportsError() {
478
+ let rig = Rig()
479
+ let probe = WatchProbe()
480
+ watch(rig, probe)
481
+ rig.mdns.handleWatchState(.waiting(.dns(-65570)), type: "_http._tcp.")
482
+ XCTAssertEqual(probe.errors.count, 1)
483
+ XCTAssertFalse(probe.errors[0].1)
484
+ XCTAssertTrue(probe.errors[0].0.contains("denied"))
485
+ XCTAssertEqual(rig.browsers[0].stopCalls, 1)
486
+ rig.mdns.handleWatchFound(name: "Late", type: "_http._tcp.", domain: "local.")
487
+ XCTAssertTrue(rig.services.isEmpty)
488
+ }
489
+
490
+ }