@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
package/electron/mdns.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import Bonjour from 'bonjour-service';
|
|
2
|
-
import { ipcMain } from 'electron';
|
|
2
|
+
import { app, BrowserWindow, ipcMain } from 'electron';
|
|
3
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { isIPv4 } from 'node:net';
|
|
3
7
|
|
|
4
8
|
import type {
|
|
5
9
|
MdnsBroadcastOptions,
|
|
@@ -9,29 +13,121 @@ import type {
|
|
|
9
13
|
MdnsPluginPlatformResult,
|
|
10
14
|
MdnsService,
|
|
11
15
|
MdnsStopResult,
|
|
16
|
+
MdnsWatchOptions,
|
|
17
|
+
MdnsWatchResult,
|
|
18
|
+
MdnsWatchStopResult,
|
|
12
19
|
} from '../src/definitions';
|
|
13
20
|
|
|
14
21
|
type Service = InstanceType<typeof Bonjour.Service>;
|
|
15
22
|
type Browser = InstanceType<typeof Bonjour.Browser>;
|
|
16
23
|
|
|
24
|
+
/** The subset of `bonjour-service` this class uses; injectable so tests can fake the network. */
|
|
25
|
+
export type BonjourLike = Pick<Bonjour, 'publish' | 'find' | 'destroy'>;
|
|
26
|
+
|
|
27
|
+
export interface MdnsOptions {
|
|
28
|
+
/** Max time to wait for a publish to come up (default 5000). */
|
|
29
|
+
publishTimeoutMs?: number;
|
|
30
|
+
/** Max time to wait for a service to stop (default 3000). */
|
|
31
|
+
stopTimeoutMs?: number;
|
|
32
|
+
/** Exact renderer origins allowed in addition to packaged app URLs (e.g. your dev server). */
|
|
33
|
+
trustedOrigins?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_DISCOVER_TIMEOUT_MS = 3000;
|
|
37
|
+
/** `setTimeout` treats delays above 2^31-1 as 1ms, so clamp. */
|
|
38
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
39
|
+
|
|
40
|
+
interface Watch {
|
|
41
|
+
types: string[];
|
|
42
|
+
browsers: Browser[];
|
|
43
|
+
services: Map<string, { service: MdnsService; fqdn: string; expires: number; announced: boolean }>;
|
|
44
|
+
timer?: NodeJS.Timeout;
|
|
45
|
+
onResponse?: (packet: { answers?: DnsRecord[]; additionals?: DnsRecord[] }) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface DnsRecord {
|
|
49
|
+
name: string;
|
|
50
|
+
type: string;
|
|
51
|
+
ttl: number;
|
|
52
|
+
data: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
/**
|
|
18
56
|
* Electron main-process implementation of the mDNS/Bonjour functionality.
|
|
19
57
|
*
|
|
20
58
|
* Uses the `bonjour-service` package to:
|
|
21
59
|
* - publish (advertise) a single service;
|
|
22
60
|
* - discover services of a given type and normalize results to the shared schema;
|
|
23
|
-
* - wire the API over Electron's ipcMain (
|
|
61
|
+
* - wire the API over Electron's ipcMain through init().
|
|
24
62
|
*
|
|
25
63
|
* Notes:
|
|
26
64
|
* - Instance name matching is normalized and prefix-safe, mirroring iOS/Android behavior.
|
|
27
65
|
* - TXT records are forwarded as strings when present.
|
|
28
66
|
*/
|
|
29
|
-
export class mDNS {
|
|
30
|
-
private readonly publishTimeoutMs
|
|
31
|
-
private readonly stopTimeoutMs
|
|
67
|
+
export class mDNS extends EventEmitter {
|
|
68
|
+
private readonly publishTimeoutMs: number;
|
|
69
|
+
private readonly stopTimeoutMs: number;
|
|
32
70
|
private broadcastQueue: Promise<unknown> = Promise.resolve();
|
|
33
71
|
private destroyed = false;
|
|
72
|
+
/** Settle callbacks of in-flight discoveries, so destroy() can end them instead of leaving them waiting. */
|
|
73
|
+
private readonly activeDiscoveries = new Set<(abortMessage?: string) => void>();
|
|
74
|
+
private bonjourInstance?: BonjourLike;
|
|
75
|
+
private transport?: EventEmitter & { query?: (name: string, type: string) => void };
|
|
76
|
+
private readonly trustedOrigins: Set<string>;
|
|
77
|
+
private pendingPublishError?: (error: Error) => void;
|
|
78
|
+
private watch?: Watch;
|
|
79
|
+
private networkError?: string;
|
|
80
|
+
private readonly stoppedBrowsers = new Set<Browser>();
|
|
81
|
+
private readonly deactivateBrowser = new Map<Browser, () => void>();
|
|
34
82
|
|
|
83
|
+
constructor(bonjour?: BonjourLike, options: MdnsOptions = {}) {
|
|
84
|
+
super();
|
|
85
|
+
this.trustedOrigins = new Set(options.trustedOrigins ?? []);
|
|
86
|
+
if (bonjour) this.attachBonjour(bonjour);
|
|
87
|
+
this.publishTimeoutMs = options.publishTimeoutMs ?? 5000;
|
|
88
|
+
this.stopTimeoutMs = options.stopTimeoutMs ?? 3000;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private attachBonjour(bonjour: BonjourLike): BonjourLike {
|
|
92
|
+
this.bonjourInstance = bonjour;
|
|
93
|
+
// shortcut: Bonjour 1.4 needs its internal transport for errors/TTL; recheck these tests on dependency upgrades.
|
|
94
|
+
this.transport = (bonjour as unknown as { server?: { mdns: EventEmitter } }).server?.mdns;
|
|
95
|
+
this.transport?.on('error', this.onNetworkError);
|
|
96
|
+
return bonjour;
|
|
97
|
+
}
|
|
98
|
+
private get bonjour(): BonjourLike {
|
|
99
|
+
return (
|
|
100
|
+
this.bonjourInstance ??
|
|
101
|
+
this.attachBonjour(new Bonjour({}, (err: Error) => console.warn('[mDNS] query response failed:', err)))
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
private trustedUrl(raw: string): boolean {
|
|
105
|
+
try {
|
|
106
|
+
const url = new URL(raw);
|
|
107
|
+
// Capacitor Electron's default dev renderer; packaged apps require an explicit origin.
|
|
108
|
+
if (app?.isPackaged === false && `${url.protocol}//${url.host}` === 'http://localhost:5173') return true;
|
|
109
|
+
if (
|
|
110
|
+
url.protocol === 'capacitor-electron:' &&
|
|
111
|
+
url.hostname === 'localhost' &&
|
|
112
|
+
!url.username &&
|
|
113
|
+
!url.password &&
|
|
114
|
+
!url.port
|
|
115
|
+
)
|
|
116
|
+
return true;
|
|
117
|
+
if (url.protocol === 'file:') {
|
|
118
|
+
const path = relative(app.getAppPath(), fileURLToPath(url));
|
|
119
|
+
return path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
|
120
|
+
}
|
|
121
|
+
return this.trustedOrigins.has(`${url.protocol}//${url.host}`);
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
private trustedSender(event: Electron.IpcMainInvokeEvent): boolean {
|
|
127
|
+
return (
|
|
128
|
+
!!event.senderFrame && event.senderFrame === event.sender.mainFrame && this.trustedUrl(event.senderFrame.url)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
35
131
|
//<editor-fold desc="Init/Destroy">
|
|
36
132
|
private ipcRegistered = false;
|
|
37
133
|
private registerIpc(): void {
|
|
@@ -41,13 +137,23 @@ export class mDNS {
|
|
|
41
137
|
ipcMain.removeHandler('mdns:stopBroadcast');
|
|
42
138
|
ipcMain.removeHandler('mdns:discover');
|
|
43
139
|
ipcMain.removeHandler('mdns:getPluginPlatform');
|
|
140
|
+
ipcMain.removeHandler('mdns:watchServices');
|
|
141
|
+
ipcMain.removeHandler('mdns:stopWatching');
|
|
44
142
|
} catch {
|
|
45
143
|
/* ignore */
|
|
46
144
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
145
|
+
const handle = (channel: string, action: (options: any) => unknown) => {
|
|
146
|
+
ipcMain.handle(channel, (event, options) => {
|
|
147
|
+
if (!this.trustedSender(event)) throw new Error('Untrusted mDNS IPC sender');
|
|
148
|
+
return action(options);
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
handle('mdns:startBroadcast', (o) => this.startBroadcast(o));
|
|
152
|
+
handle('mdns:stopBroadcast', () => this.stopBroadcast());
|
|
153
|
+
handle('mdns:discover', (o) => this.discover(o));
|
|
154
|
+
handle('mdns:getPluginPlatform', () => this.getPluginPlatform());
|
|
155
|
+
handle('mdns:watchServices', (o) => this.watchServices(o));
|
|
156
|
+
handle('mdns:stopWatching', () => this.stopWatching());
|
|
51
157
|
this.ipcRegistered = true;
|
|
52
158
|
}
|
|
53
159
|
|
|
@@ -58,23 +164,30 @@ export class mDNS {
|
|
|
58
164
|
ipcMain.removeHandler('mdns:stopBroadcast');
|
|
59
165
|
ipcMain.removeHandler('mdns:discover');
|
|
60
166
|
ipcMain.removeHandler('mdns:getPluginPlatform');
|
|
167
|
+
ipcMain.removeHandler('mdns:watchServices');
|
|
168
|
+
ipcMain.removeHandler('mdns:stopWatching');
|
|
61
169
|
} catch {
|
|
62
170
|
/* ignore */
|
|
63
171
|
}
|
|
64
172
|
this.ipcRegistered = false;
|
|
65
173
|
}
|
|
66
|
-
/**
|
|
174
|
+
/** Register manual IPC handlers. Idempotent. */
|
|
67
175
|
init(): void {
|
|
68
176
|
this.registerIpc();
|
|
69
177
|
}
|
|
70
178
|
|
|
71
179
|
async destroy(): Promise<void> {
|
|
72
180
|
this.destroyed = true;
|
|
181
|
+
this.pendingPublishError?.(new Error('mDNS instance destroyed during publish'));
|
|
182
|
+
await this.stopWatching();
|
|
73
183
|
this.unregisterIpc();
|
|
184
|
+
for (const abort of [...this.activeDiscoveries]) abort('mDNS instance destroyed during discovery');
|
|
74
185
|
await this.withBroadcastLock(async () => {
|
|
75
186
|
await this.stopBroadcastUnlocked();
|
|
76
187
|
try {
|
|
77
|
-
this.
|
|
188
|
+
this.bonjourInstance?.destroy();
|
|
189
|
+
this.transport?.removeListener('error', this.onNetworkError);
|
|
190
|
+
this.advertiser = undefined;
|
|
78
191
|
} catch (err) {
|
|
79
192
|
console.warn('[mDNS] bonjour.destroy error:', err);
|
|
80
193
|
}
|
|
@@ -83,7 +196,6 @@ export class mDNS {
|
|
|
83
196
|
|
|
84
197
|
//</editor-fold>
|
|
85
198
|
|
|
86
|
-
private bonjour = new Bonjour();
|
|
87
199
|
private advertiser?: Service;
|
|
88
200
|
|
|
89
201
|
// ----------------------------- utils -----------------------------
|
|
@@ -106,7 +218,7 @@ export class mDNS {
|
|
|
106
218
|
}
|
|
107
219
|
|
|
108
220
|
private normalize(name: string): string {
|
|
109
|
-
return name.replace(/ \(\d+\)$/, '');
|
|
221
|
+
return name.replace(/ \(\d+\)$/, '').replace(/\./g, '-');
|
|
110
222
|
}
|
|
111
223
|
private matchesTarget(candidate: string, target?: string | null): boolean {
|
|
112
224
|
if (!target) return true;
|
|
@@ -114,42 +226,73 @@ export class mDNS {
|
|
|
114
226
|
t = this.normalize(target);
|
|
115
227
|
return c === t || c.startsWith(t);
|
|
116
228
|
}
|
|
117
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Parse a `_name._tcp.` style type. Absent/blank input means the default `_http._tcp.`;
|
|
231
|
+
* anything malformed is rejected (null) rather than silently advertised as something else.
|
|
232
|
+
*/
|
|
233
|
+
private parseType(typeWithDot?: unknown): { type: string; protocol: 'tcp' | 'udp' } | null {
|
|
234
|
+
if (typeWithDot != null && typeof typeWithDot !== 'string') return null;
|
|
118
235
|
const raw = typeof typeWithDot === 'string' && typeWithDot.trim() ? typeWithDot.trim() : '_http._tcp.';
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
236
|
+
const m = /^_([a-z0-9](?:[a-z0-9-]{0,13}[a-z0-9])?)\._(tcp|udp)\.?$/i.exec(raw);
|
|
237
|
+
return m && /[a-z]/i.test(m[1]) && !m[1].includes('--')
|
|
238
|
+
? { type: m[1].toLowerCase(), protocol: m[2].toLowerCase() as 'tcp' | 'udp' }
|
|
239
|
+
: null;
|
|
240
|
+
}
|
|
241
|
+
private validateName(name: unknown): string | null {
|
|
242
|
+
return name != null &&
|
|
243
|
+
(typeof name !== 'string' || Buffer.byteLength(name.trim(), 'utf8') > 63 || /[\x00-\x1f\x7f]/.test(name))
|
|
244
|
+
? 'Invalid instance name (maximum 63 UTF-8 bytes, no control characters)'
|
|
245
|
+
: null;
|
|
246
|
+
}
|
|
247
|
+
private validateTxt(txt: unknown): string | null {
|
|
248
|
+
if (txt == null) return null;
|
|
249
|
+
if (!this.isRecord(txt)) return 'TXT must be an object of strings';
|
|
250
|
+
const keys = new Set<string>();
|
|
251
|
+
let totalBytes = 0;
|
|
252
|
+
for (const [key, value] of Object.entries(txt)) {
|
|
253
|
+
if (!/^[\x20-\x7e]+$/.test(key) || key.includes('=') || keys.has(key.toLowerCase()))
|
|
254
|
+
return 'Invalid or duplicate TXT key';
|
|
255
|
+
if (typeof value !== 'string' || Buffer.byteLength(`${key}=${value}`, 'utf8') > 255)
|
|
256
|
+
return 'TXT entries must be strings of at most 255 UTF-8 bytes including key=';
|
|
257
|
+
totalBytes += Buffer.byteLength(`${key}=${value}`, 'utf8') + 1;
|
|
258
|
+
if (totalBytes > 1300) return 'TXT record must fit in 1300 bytes including length prefixes';
|
|
259
|
+
keys.add(key.toLowerCase());
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private invalidTypeMessage(typeWithDot: unknown): string {
|
|
265
|
+
return `Invalid service type: ${String(typeWithDot)} (expected e.g. "_http._tcp.")`;
|
|
266
|
+
}
|
|
267
|
+
private normalizeTimeout(value: unknown): number {
|
|
268
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_DISCOVER_TIMEOUT_MS;
|
|
269
|
+
return Math.min(Math.max(0, Math.trunc(value)), MAX_TIMER_MS);
|
|
123
270
|
}
|
|
124
271
|
private toFullType(type: string, protocol: 'tcp' | 'udp'): string {
|
|
125
272
|
return `_${type}._${protocol}.`;
|
|
126
273
|
}
|
|
127
274
|
|
|
128
|
-
private hasCbStop(x: unknown): x is { stop: (cb?: () => void) => void } {
|
|
129
|
-
return !!x && typeof (x as any).stop === 'function';
|
|
130
|
-
}
|
|
131
275
|
private async safeStopService(svc: Service | undefined): Promise<void> {
|
|
132
|
-
if (!svc
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
276
|
+
if (!svc) return;
|
|
277
|
+
await new Promise<void>((res, reject) => {
|
|
278
|
+
let settled = false;
|
|
279
|
+
const done = (error?: unknown) => {
|
|
280
|
+
if (settled) return;
|
|
281
|
+
settled = true;
|
|
282
|
+
if (timeout) clearTimeout(timeout);
|
|
283
|
+
if (error) reject(error);
|
|
284
|
+
else res();
|
|
285
|
+
};
|
|
286
|
+
const timeout = setTimeout(
|
|
287
|
+
() => done(new Error(`Timed out waiting for service stop after ${this.stopTimeoutMs}ms`)),
|
|
288
|
+
this.stopTimeoutMs,
|
|
289
|
+
);
|
|
290
|
+
try {
|
|
291
|
+
svc.stop(done);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
done(e);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
153
296
|
}
|
|
154
297
|
private safeStopBrowser(b: Browser | null | undefined): void {
|
|
155
298
|
if (!b) return;
|
|
@@ -176,16 +319,31 @@ export class mDNS {
|
|
|
176
319
|
async startBroadcast(options: MdnsBroadcastOptions): Promise<MdnsBroadcastResult> {
|
|
177
320
|
return this.withBroadcastLock(async () => {
|
|
178
321
|
const safeOptions = (options ?? {}) as Partial<MdnsBroadcastOptions>;
|
|
179
|
-
const portError =
|
|
180
|
-
|
|
322
|
+
const portError =
|
|
323
|
+
this.validatePort(safeOptions.port) ?? this.validateName(safeOptions.name) ?? this.validateTxt(safeOptions.txt);
|
|
324
|
+
if (portError) return { publishing: !!this.advertiser, name: '', error: true, errorMessage: portError };
|
|
181
325
|
if (this.destroyed)
|
|
182
326
|
return { publishing: false, name: '', error: true, errorMessage: 'mDNS instance is destroyed' };
|
|
327
|
+
if (this.networkError)
|
|
328
|
+
return { publishing: !!this.advertiser, name: '', error: true, errorMessage: this.networkError };
|
|
183
329
|
|
|
184
|
-
|
|
330
|
+
const parsed = this.parseType(safeOptions.type);
|
|
331
|
+
if (!parsed) {
|
|
332
|
+
return {
|
|
333
|
+
publishing: !!this.advertiser,
|
|
334
|
+
name: '',
|
|
335
|
+
error: true,
|
|
336
|
+
errorMessage: this.invalidTypeMessage(safeOptions.type),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const { type, protocol } = parsed;
|
|
340
|
+
|
|
341
|
+
const stopped = await this.stopBroadcastUnlocked();
|
|
342
|
+
if (stopped.error) return { ...stopped, name: '' };
|
|
185
343
|
|
|
186
|
-
const { type, protocol } = this.parseType(safeOptions.type);
|
|
187
344
|
return new Promise<MdnsBroadcastResult>((resolve) => {
|
|
188
345
|
let settled = false;
|
|
346
|
+
let failing = false;
|
|
189
347
|
let timeout: NodeJS.Timeout | null = null;
|
|
190
348
|
const safeResolve = (r: MdnsBroadcastResult) => {
|
|
191
349
|
if (!settled) {
|
|
@@ -194,6 +352,7 @@ export class mDNS {
|
|
|
194
352
|
clearTimeout(timeout);
|
|
195
353
|
timeout = null;
|
|
196
354
|
}
|
|
355
|
+
this.pendingPublishError = undefined;
|
|
197
356
|
resolve(r);
|
|
198
357
|
}
|
|
199
358
|
};
|
|
@@ -219,31 +378,66 @@ export class mDNS {
|
|
|
219
378
|
}
|
|
220
379
|
};
|
|
221
380
|
const onUp = () => {
|
|
381
|
+
if (failing || settled) return;
|
|
222
382
|
cleanupListeners();
|
|
223
383
|
safeResolve({ publishing: true, name: svc.name || '', error: false, errorMessage: null });
|
|
224
384
|
};
|
|
225
|
-
const onError = (err: unknown) => {
|
|
385
|
+
const onError = async (err: unknown) => {
|
|
386
|
+
if (failing || settled) return;
|
|
387
|
+
failing = true;
|
|
388
|
+
if (timeout) clearTimeout(timeout);
|
|
389
|
+
timeout = null;
|
|
226
390
|
cleanupListeners();
|
|
227
|
-
|
|
228
|
-
if (this.advertiser === svc) this.advertiser = undefined;
|
|
229
|
-
safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(err) });
|
|
230
|
-
};
|
|
231
|
-
const onTimeout = () => {
|
|
232
|
-
cleanupListeners();
|
|
233
|
-
void this.safeStopService(svc);
|
|
234
|
-
if (this.advertiser === svc) this.advertiser = undefined;
|
|
391
|
+
const stopped = await this.stopBroadcastUnlocked();
|
|
235
392
|
safeResolve({
|
|
236
|
-
publishing:
|
|
393
|
+
publishing: stopped.publishing,
|
|
237
394
|
name: '',
|
|
238
395
|
error: true,
|
|
239
|
-
errorMessage:
|
|
396
|
+
errorMessage: [this.toErr(err), stopped.errorMessage].filter(Boolean).join('; '),
|
|
240
397
|
});
|
|
241
398
|
};
|
|
399
|
+
const onTimeout = () => {
|
|
400
|
+
void onError(
|
|
401
|
+
new Error(
|
|
402
|
+
svc.activated === false
|
|
403
|
+
? 'Service name is already in use on the network'
|
|
404
|
+
: `Timed out waiting for service publish after ${this.publishTimeoutMs}ms`,
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// shortcut: Bonjour 1.4 sends goodbye for conflicting probes; remove this hook when its probe teardown is fixed.
|
|
410
|
+
const serviceRecords = svc.records;
|
|
411
|
+
let announcementStarted = svc.published === true;
|
|
412
|
+
svc.records = () => {
|
|
413
|
+
if (svc.destroyed) return [];
|
|
414
|
+
// Bonjour sets published only after the send callback, but records are already registered before it.
|
|
415
|
+
announcementStarted = true;
|
|
416
|
+
return serviceRecords.call(svc);
|
|
417
|
+
};
|
|
418
|
+
const stop = svc.stop.bind(svc);
|
|
419
|
+
svc.stop = (callback?: (error?: unknown) => void) => {
|
|
420
|
+
const announced = announcementStarted || svc.published === true;
|
|
421
|
+
const conflict = !callback && svc.activated && !announced;
|
|
422
|
+
const records = svc.records;
|
|
423
|
+
if (!announced) svc.records = () => [];
|
|
424
|
+
try {
|
|
425
|
+
stop(callback);
|
|
426
|
+
svc.destroyed = true;
|
|
427
|
+
} finally {
|
|
428
|
+
svc.records = records;
|
|
429
|
+
}
|
|
430
|
+
if (conflict) void onError(new Error('Service name is already in use on the network'));
|
|
431
|
+
};
|
|
242
432
|
|
|
243
433
|
svc.once('up', onUp);
|
|
244
434
|
svc.once('error', onError);
|
|
245
435
|
timeout = setTimeout(onTimeout, this.publishTimeoutMs);
|
|
246
436
|
this.advertiser = svc;
|
|
437
|
+
this.pendingPublishError = (error) => {
|
|
438
|
+
void onError(error);
|
|
439
|
+
};
|
|
440
|
+
if (this.networkError) this.pendingPublishError(new Error(this.networkError));
|
|
247
441
|
} catch (e) {
|
|
248
442
|
safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(e) });
|
|
249
443
|
}
|
|
@@ -267,7 +461,7 @@ export class mDNS {
|
|
|
267
461
|
}
|
|
268
462
|
return { publishing: false, error: false, errorMessage: null };
|
|
269
463
|
} catch (e) {
|
|
270
|
-
return { publishing:
|
|
464
|
+
return { publishing: !!this.advertiser, error: true, errorMessage: this.toErr(e) };
|
|
271
465
|
}
|
|
272
466
|
}
|
|
273
467
|
|
|
@@ -282,10 +476,27 @@ export class mDNS {
|
|
|
282
476
|
if (this.destroyed) {
|
|
283
477
|
return { error: true, errorMessage: 'mDNS instance is destroyed', servicesFound: 0, services: [] };
|
|
284
478
|
}
|
|
479
|
+
if (this.networkError) return { error: true, errorMessage: this.networkError, servicesFound: 0, services: [] };
|
|
285
480
|
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
481
|
+
const parsed = this.parseType(safeOptions.type);
|
|
482
|
+
if (!parsed) {
|
|
483
|
+
return {
|
|
484
|
+
error: true,
|
|
485
|
+
errorMessage: this.invalidTypeMessage(safeOptions.type),
|
|
486
|
+
servicesFound: 0,
|
|
487
|
+
services: [],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const inputError =
|
|
491
|
+
this.validateName(safeOptions.name) ??
|
|
492
|
+
(safeOptions.timeout != null && (typeof safeOptions.timeout !== 'number' || !Number.isFinite(safeOptions.timeout))
|
|
493
|
+
? 'Invalid timeout'
|
|
494
|
+
: null);
|
|
495
|
+
if (inputError) return { error: true, errorMessage: inputError, servicesFound: 0, services: [] };
|
|
496
|
+
for (const abort of [...this.activeDiscoveries]) abort('Discovery replaced by a new request');
|
|
497
|
+
const { type, protocol } = parsed;
|
|
498
|
+
const targetId = typeof safeOptions.name === 'string' && safeOptions.name.trim() ? safeOptions.name.trim() : null;
|
|
499
|
+
const timeoutMs = this.normalizeTimeout(safeOptions.timeout);
|
|
289
500
|
|
|
290
501
|
let browser: Browser | null = null;
|
|
291
502
|
const services: MdnsService[] = [];
|
|
@@ -296,9 +507,15 @@ export class mDNS {
|
|
|
296
507
|
let timer: NodeJS.Timeout | null = null;
|
|
297
508
|
let settled = false;
|
|
298
509
|
|
|
299
|
-
const finish = () => {
|
|
510
|
+
const finish = (abortMessage?: string) => {
|
|
300
511
|
if (settled) return;
|
|
301
512
|
settled = true;
|
|
513
|
+
this.activeDiscoveries.delete(finish);
|
|
514
|
+
this.transport?.removeListener('response', onResponse);
|
|
515
|
+
if (abortMessage) {
|
|
516
|
+
hadError = true;
|
|
517
|
+
errMsg = abortMessage;
|
|
518
|
+
}
|
|
302
519
|
if (browser) {
|
|
303
520
|
this.safeStopBrowser(browser);
|
|
304
521
|
browser = null;
|
|
@@ -310,35 +527,61 @@ export class mDNS {
|
|
|
310
527
|
resolve({
|
|
311
528
|
error: hadError,
|
|
312
529
|
errorMessage: errMsg,
|
|
313
|
-
services,
|
|
314
|
-
servicesFound: services.length,
|
|
530
|
+
services: hadError ? [] : services.filter((s) => s.hosts.length > 0),
|
|
531
|
+
servicesFound: hadError ? 0 : services.filter((s) => s.hosts.length > 0).length,
|
|
315
532
|
});
|
|
316
533
|
};
|
|
317
534
|
|
|
535
|
+
const up = (s: Service) => {
|
|
536
|
+
if (settled) return;
|
|
537
|
+
const item = this.serviceResult(s, this.toFullType(type, protocol));
|
|
538
|
+
if (!this.matchesTarget(item.name, targetId)) return;
|
|
539
|
+
const previous = services.find((x) => x.name === item.name && x.port === item.port);
|
|
540
|
+
if (previous) Object.assign(previous, item, { hosts: this.orderHosts([...previous.hosts, ...item.hosts]) });
|
|
541
|
+
else services.push(item);
|
|
542
|
+
if (targetId && item.hosts.length) finish();
|
|
543
|
+
else if (!item.hosts.length && item.hostname) {
|
|
544
|
+
this.queryAddresses(item.hostname);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
const onResponse = (packet: { answers?: DnsRecord[]; additionals?: DnsRecord[] }) => {
|
|
548
|
+
if (settled) return;
|
|
549
|
+
const records = [...(packet.answers ?? []), ...(packet.additionals ?? [])];
|
|
550
|
+
for (const item of services) {
|
|
551
|
+
for (const record of records) {
|
|
552
|
+
if (
|
|
553
|
+
(record.type === 'A' || record.type === 'AAAA') &&
|
|
554
|
+
record.ttl &&
|
|
555
|
+
typeof record.data === 'string' &&
|
|
556
|
+
record.name.toLowerCase() === item.hostname?.toLowerCase() &&
|
|
557
|
+
!item.hosts.includes(record.data)
|
|
558
|
+
)
|
|
559
|
+
item.hosts = this.orderHosts([...item.hosts, record.data]);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (targetId && services.some((item) => item.hosts.length)) finish();
|
|
563
|
+
};
|
|
564
|
+
this.activeDiscoveries.add(finish);
|
|
318
565
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (!services.some((x) => `${x.name}:${x.port}` === key)) services.push(item);
|
|
331
|
-
if (targetId && this.matchesTarget(item.name, targetId)) finish();
|
|
332
|
-
});
|
|
333
|
-
|
|
566
|
+
const bonjour = this.bonjour;
|
|
567
|
+
if (settled) return;
|
|
568
|
+
this.transport?.on('response', onResponse);
|
|
569
|
+
browser = bonjour.find({ type, protocol }, up);
|
|
570
|
+
if (settled) {
|
|
571
|
+
this.safeStopBrowser(browser);
|
|
572
|
+
browser = null;
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
browser.on('srv-update', up);
|
|
576
|
+
browser.on('txt-update', up);
|
|
334
577
|
// Record error and let the timeout conclude; results will still be returned.
|
|
335
578
|
const onBrowserError = (err: unknown) => {
|
|
336
579
|
hadError = true;
|
|
337
580
|
errMsg = this.toErr(err);
|
|
338
581
|
};
|
|
339
|
-
(browser as
|
|
582
|
+
(browser as EventEmitter).on('error', onBrowserError);
|
|
340
583
|
|
|
341
|
-
timer = setTimeout(finish
|
|
584
|
+
timer = setTimeout(() => finish(), timeoutMs);
|
|
342
585
|
} catch (err) {
|
|
343
586
|
hadError = true;
|
|
344
587
|
errMsg = this.toErr(err);
|
|
@@ -352,4 +595,194 @@ export class mDNS {
|
|
|
352
595
|
Object.entries(txt).map(([key, value]) => [key, Buffer.isBuffer(value) ? value.toString('utf8') : String(value)]),
|
|
353
596
|
);
|
|
354
597
|
}
|
|
598
|
+
|
|
599
|
+
private queryAddresses(hostname: string): void {
|
|
600
|
+
try {
|
|
601
|
+
this.transport?.query?.(hostname, 'A');
|
|
602
|
+
this.transport?.query?.(hostname, 'AAAA');
|
|
603
|
+
} catch (err) {
|
|
604
|
+
this.onNetworkError(new Error(this.toErr(err)));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
private orderHosts(hosts: string[]): string[] {
|
|
608
|
+
return [...new Set(hosts)].sort((a, b) => Number(isIPv4(b)) - Number(isIPv4(a)));
|
|
609
|
+
}
|
|
610
|
+
private serviceResult(s: Service, type: string): MdnsService {
|
|
611
|
+
const suffix = `.${type}local`;
|
|
612
|
+
const name = s.fqdn?.toLowerCase().endsWith(suffix.toLowerCase()) ? s.fqdn.slice(0, -suffix.length) : s.name || '';
|
|
613
|
+
return {
|
|
614
|
+
name,
|
|
615
|
+
type,
|
|
616
|
+
domain: 'local.',
|
|
617
|
+
port: s.port ?? 0,
|
|
618
|
+
hosts: this.orderHosts(Array.isArray(s.addresses) ? s.addresses : []),
|
|
619
|
+
...(s.host ? { hostname: s.host } : {}),
|
|
620
|
+
...(s.txt && Object.keys(s.txt).length ? { txt: this.normalizeTxt(s.txt) } : {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private notify(event: string, data: unknown): void {
|
|
625
|
+
this.emit(event, data);
|
|
626
|
+
for (const win of BrowserWindow?.getAllWindows() ?? []) {
|
|
627
|
+
if (!win.isDestroyed() && !win.webContents.isDestroyed() && this.trustedUrl(win.webContents.getURL())) {
|
|
628
|
+
win.webContents.send(`event-mDNS-${event}`, data);
|
|
629
|
+
win.webContents.send(`mdns:event:${event}`, data);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private readonly onNetworkError = (err: Error): void => {
|
|
635
|
+
this.networkError = this.toErr(err);
|
|
636
|
+
this.pendingPublishError?.(err);
|
|
637
|
+
const type = this.watch?.types[0] ?? '';
|
|
638
|
+
const watching = !!this.watch;
|
|
639
|
+
void this.stopWatching();
|
|
640
|
+
for (const abort of [...this.activeDiscoveries]) abort(this.toErr(err));
|
|
641
|
+
if (watching) this.notify('watchError', { type, errorMessage: this.toErr(err), watching: false });
|
|
642
|
+
else console.warn('[mDNS]', err);
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
/** Thanks to Idrimi for the original continuous discovery feature (PR #7). */
|
|
646
|
+
async watchServices(options: MdnsWatchOptions): Promise<MdnsWatchResult> {
|
|
647
|
+
const failed = (message: string): MdnsWatchResult => ({ error: true, errorMessage: message, watchersStarted: 0 });
|
|
648
|
+
if (this.destroyed) return failed('mDNS instance is destroyed');
|
|
649
|
+
if (this.networkError) return failed(this.networkError);
|
|
650
|
+
if (!Array.isArray(options?.types) || !options.types.length) return failed("Missing or empty 'types' array");
|
|
651
|
+
const types = new Map<string, { type: string; protocol: 'tcp' | 'udp' }>();
|
|
652
|
+
for (const raw of options.types) {
|
|
653
|
+
const parsed = typeof raw === 'string' && raw.trim() ? this.parseType(raw) : null;
|
|
654
|
+
if (!parsed) return failed(this.invalidTypeMessage(raw));
|
|
655
|
+
types.set(this.toFullType(parsed.type, parsed.protocol), parsed);
|
|
656
|
+
}
|
|
657
|
+
const cleanup = this.stopWatch();
|
|
658
|
+
if (cleanup) return failed(cleanup);
|
|
659
|
+
const bonjour = this.bonjour;
|
|
660
|
+
if (this.networkError) return failed(this.networkError);
|
|
661
|
+
const watch: Watch = { types: [...types.keys()], browsers: [], services: new Map() };
|
|
662
|
+
this.watch = watch;
|
|
663
|
+
const addBrowser = (type: string, parsed: { type: string; protocol: 'tcp' | 'udp' }) => {
|
|
664
|
+
let active = true;
|
|
665
|
+
const up = (s: Service) => {
|
|
666
|
+
if (this.watch !== watch || !active) return;
|
|
667
|
+
const service = this.serviceResult(s, type);
|
|
668
|
+
const key = `${type}|${service.name}`;
|
|
669
|
+
const previous = watch.services.get(key);
|
|
670
|
+
if (!service.hosts.length && previous?.service.hostname === service.hostname)
|
|
671
|
+
service.hosts = [...(previous?.service.hosts ?? [])];
|
|
672
|
+
const isNew = !previous?.announced && service.hosts.length > 0;
|
|
673
|
+
watch.services.set(key, {
|
|
674
|
+
service,
|
|
675
|
+
fqdn: s.fqdn || `${service.name}.${type}local`,
|
|
676
|
+
expires: s.ttl ? Date.now() + s.ttl * 1000 : Infinity,
|
|
677
|
+
announced: !!previous?.announced || isNew,
|
|
678
|
+
});
|
|
679
|
+
if (!service.hosts.length && service.hostname) this.queryAddresses(service.hostname);
|
|
680
|
+
if (isNew) this.notify('serviceFound', { service: structuredClone(service) });
|
|
681
|
+
};
|
|
682
|
+
const browser = bonjour.find(parsed, up);
|
|
683
|
+
browser.on('down', (s: Service) => {
|
|
684
|
+
if (this.watch !== watch || !active) return;
|
|
685
|
+
const key = `${type}|${this.serviceResult(s, type).name}`;
|
|
686
|
+
const previous = watch.services.get(key);
|
|
687
|
+
watch.services.delete(key);
|
|
688
|
+
if (previous?.announced) this.notify('serviceLost', { service: structuredClone(previous.service) });
|
|
689
|
+
});
|
|
690
|
+
browser.on('srv-update', up);
|
|
691
|
+
browser.on('txt-update', up);
|
|
692
|
+
this.deactivateBrowser.set(browser, () => {
|
|
693
|
+
active = false;
|
|
694
|
+
});
|
|
695
|
+
if (this.watch !== watch) {
|
|
696
|
+
this.stopWatchBrowser(browser);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
watch.browsers.push(browser);
|
|
700
|
+
};
|
|
701
|
+
try {
|
|
702
|
+
for (const [type, parsed] of types) addBrowser(type, parsed);
|
|
703
|
+
if (this.watch !== watch) return failed('Watch stopped during startup');
|
|
704
|
+
watch.onResponse = (packet) => {
|
|
705
|
+
if (this.watch !== watch) return;
|
|
706
|
+
const records = [...(packet.answers ?? []), ...(packet.additionals ?? [])];
|
|
707
|
+
for (const item of watch.services.values()) {
|
|
708
|
+
const ptr = records.find((r) => r.type === 'PTR' && String(r.data).toLowerCase() === item.fqdn.toLowerCase());
|
|
709
|
+
if (ptr) item.expires = Date.now() + ptr.ttl * 1000;
|
|
710
|
+
const addresses = records.filter(
|
|
711
|
+
(r) =>
|
|
712
|
+
(r.type === 'A' || r.type === 'AAAA') && r.name.toLowerCase() === item.service.hostname?.toLowerCase(),
|
|
713
|
+
);
|
|
714
|
+
for (const address of addresses) {
|
|
715
|
+
if (typeof address.data !== 'string') continue;
|
|
716
|
+
const hosts = item.service.hosts.filter((host) => host !== address.data);
|
|
717
|
+
item.service.hosts = this.orderHosts(address.ttl ? [...hosts, address.data] : hosts);
|
|
718
|
+
}
|
|
719
|
+
if (!item.announced && item.service.hosts.length) {
|
|
720
|
+
item.announced = true;
|
|
721
|
+
this.notify('serviceFound', { service: structuredClone(item.service) });
|
|
722
|
+
if (this.watch !== watch) return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
this.transport?.on('response', watch.onResponse);
|
|
727
|
+
let ticks = 0;
|
|
728
|
+
watch.timer = setInterval(() => {
|
|
729
|
+
if (this.watch !== watch) return;
|
|
730
|
+
try {
|
|
731
|
+
const expired = [...watch.services.entries()].filter(([, item]) => item.expires <= Date.now());
|
|
732
|
+
for (const [key, item] of expired) {
|
|
733
|
+
watch.services.delete(key);
|
|
734
|
+
if (item.announced) this.notify('serviceLost', { service: structuredClone(item.service) });
|
|
735
|
+
if (this.watch !== watch) return;
|
|
736
|
+
}
|
|
737
|
+
// A fresh Browser forgets expired entries retained by bonjour-service's internal cache.
|
|
738
|
+
if (expired.length) {
|
|
739
|
+
for (const browser of watch.browsers.splice(0)) this.stopWatchBrowser(browser);
|
|
740
|
+
for (const [type, parsed] of types) addBrowser(type, parsed);
|
|
741
|
+
} else if (++ticks % 30 === 0) {
|
|
742
|
+
for (const browser of watch.browsers) browser.update?.();
|
|
743
|
+
}
|
|
744
|
+
} catch (err) {
|
|
745
|
+
this.onNetworkError(err instanceof Error ? err : new Error(this.toErr(err)));
|
|
746
|
+
}
|
|
747
|
+
}, 1000);
|
|
748
|
+
watch.timer.unref();
|
|
749
|
+
return { error: false, errorMessage: null, watchersStarted: types.size };
|
|
750
|
+
} catch (err) {
|
|
751
|
+
this.stopWatch();
|
|
752
|
+
return failed(this.toErr(err));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private stopWatchBrowser(browser: Browser): void {
|
|
757
|
+
this.deactivateBrowser.get(browser)?.();
|
|
758
|
+
this.deactivateBrowser.delete(browser);
|
|
759
|
+
try {
|
|
760
|
+
browser.stop();
|
|
761
|
+
this.stoppedBrowsers.delete(browser);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
this.stoppedBrowsers.add(browser);
|
|
764
|
+
throw err;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private stopWatch(): string | null {
|
|
769
|
+
const watch = this.watch;
|
|
770
|
+
this.watch = undefined;
|
|
771
|
+
if (watch?.timer) clearInterval(watch.timer);
|
|
772
|
+
if (watch?.onResponse) this.transport?.removeListener('response', watch.onResponse);
|
|
773
|
+
const errors: string[] = [];
|
|
774
|
+
for (const browser of new Set([...(watch?.browsers ?? []), ...this.stoppedBrowsers])) {
|
|
775
|
+
try {
|
|
776
|
+
this.stopWatchBrowser(browser);
|
|
777
|
+
} catch (err) {
|
|
778
|
+
errors.push(this.toErr(err));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return errors.length ? errors.join('; ') : null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async stopWatching(): Promise<MdnsWatchStopResult> {
|
|
785
|
+
const errorMessage = this.stopWatch();
|
|
786
|
+
return { watching: false, error: !!errorMessage, errorMessage };
|
|
787
|
+
}
|
|
355
788
|
}
|