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