@devioarts/capacitor-mdns 0.0.3 → 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 +272 -101
- package/android/build.gradle +3 -9
- package/android/src/main/AndroidManifest.xml +2 -0
- 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 +496 -175
- package/android/src/main/java/com/devioarts/capacitor/mdns/mDNSPlugin.kt +85 -49
- package/dist/docs.json +313 -15
- package/dist/esm/definitions.d.ts +89 -16
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.js +22 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +8 -1
- package/dist/esm/web.js +35 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +57 -4
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +57 -4
- package/dist/plugin.js.map +1 -1
- package/docs/electron.md +91 -0
- package/docs/platform-setup.md +58 -0
- package/docs/playground.md +50 -0
- package/docs/runtime-behavior.md +80 -0
- package/electron/dist/plugin-settings.js +22 -0
- package/electron/dist/plugin-settings.js.map +1 -0
- package/electron/dist/plugin.cjs.js +774 -0
- package/electron/dist/plugin.cjs.js.map +1 -0
- package/electron/global.d.ts +2 -0
- package/electron/mdns-bridge.cjs +33 -4
- package/electron/mdns.ts +628 -107
- package/electron/rollup.config.mjs +22 -0
- package/electron/src/index.ts +2 -0
- package/electron/src/plugin-settings.ts +19 -0
- package/electron/tsconfig.json +21 -0
- package/electron/types-public/index.d.ts +47 -0
- package/ios/Sources/mDNSPlugin/mDNS.swift +481 -124
- package/ios/Sources/mDNSPlugin/mDNSPlugin.swift +85 -23
- package/ios/Tests/mDNSPluginTests/BridgeTests.swift +46 -0
- package/ios/Tests/mDNSPluginTests/MDNSTests.swift +490 -0
- package/package.json +55 -20
- package/ios/Tests/mDNSPluginTests/mDNSPluginTests.swift +0 -15
package/electron/mdns.ts
CHANGED
|
@@ -1,15 +1,56 @@
|
|
|
1
|
-
import type { Service, Browser } from 'bonjour-service';
|
|
2
1
|
import Bonjour from 'bonjour-service';
|
|
3
|
-
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';
|
|
4
7
|
|
|
5
8
|
import type {
|
|
6
9
|
MdnsBroadcastOptions,
|
|
7
10
|
MdnsBroadcastResult,
|
|
8
11
|
MdnsDiscoverOptions,
|
|
9
12
|
MdnsDiscoverResult,
|
|
13
|
+
MdnsPluginPlatformResult,
|
|
10
14
|
MdnsService,
|
|
11
15
|
MdnsStopResult,
|
|
12
|
-
|
|
16
|
+
MdnsWatchOptions,
|
|
17
|
+
MdnsWatchResult,
|
|
18
|
+
MdnsWatchStopResult,
|
|
19
|
+
} from '../src/definitions';
|
|
20
|
+
|
|
21
|
+
type Service = InstanceType<typeof Bonjour.Service>;
|
|
22
|
+
type Browser = InstanceType<typeof Bonjour.Browser>;
|
|
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
|
+
}
|
|
13
54
|
|
|
14
55
|
/**
|
|
15
56
|
* Electron main-process implementation of the mDNS/Bonjour functionality.
|
|
@@ -17,13 +58,76 @@ import type {
|
|
|
17
58
|
* Uses the `bonjour-service` package to:
|
|
18
59
|
* - publish (advertise) a single service;
|
|
19
60
|
* - discover services of a given type and normalize results to the shared schema;
|
|
20
|
-
* - wire the API over Electron's ipcMain (
|
|
61
|
+
* - wire the API over Electron's ipcMain through init().
|
|
21
62
|
*
|
|
22
63
|
* Notes:
|
|
23
64
|
* - Instance name matching is normalized and prefix-safe, mirroring iOS/Android behavior.
|
|
24
65
|
* - TXT records are forwarded as strings when present.
|
|
25
66
|
*/
|
|
26
|
-
export class mDNS {
|
|
67
|
+
export class mDNS extends EventEmitter {
|
|
68
|
+
private readonly publishTimeoutMs: number;
|
|
69
|
+
private readonly stopTimeoutMs: number;
|
|
70
|
+
private broadcastQueue: Promise<unknown> = Promise.resolve();
|
|
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>();
|
|
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
|
+
}
|
|
27
131
|
//<editor-fold desc="Init/Destroy">
|
|
28
132
|
private ipcRegistered = false;
|
|
29
133
|
private registerIpc(): void {
|
|
@@ -32,12 +136,24 @@ export class mDNS {
|
|
|
32
136
|
ipcMain.removeHandler('mdns:startBroadcast');
|
|
33
137
|
ipcMain.removeHandler('mdns:stopBroadcast');
|
|
34
138
|
ipcMain.removeHandler('mdns:discover');
|
|
139
|
+
ipcMain.removeHandler('mdns:getPluginPlatform');
|
|
140
|
+
ipcMain.removeHandler('mdns:watchServices');
|
|
141
|
+
ipcMain.removeHandler('mdns:stopWatching');
|
|
35
142
|
} catch {
|
|
36
143
|
/* ignore */
|
|
37
144
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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());
|
|
41
157
|
this.ipcRegistered = true;
|
|
42
158
|
}
|
|
43
159
|
|
|
@@ -47,38 +163,62 @@ export class mDNS {
|
|
|
47
163
|
ipcMain.removeHandler('mdns:startBroadcast');
|
|
48
164
|
ipcMain.removeHandler('mdns:stopBroadcast');
|
|
49
165
|
ipcMain.removeHandler('mdns:discover');
|
|
166
|
+
ipcMain.removeHandler('mdns:getPluginPlatform');
|
|
167
|
+
ipcMain.removeHandler('mdns:watchServices');
|
|
168
|
+
ipcMain.removeHandler('mdns:stopWatching');
|
|
50
169
|
} catch {
|
|
51
170
|
/* ignore */
|
|
52
171
|
}
|
|
53
172
|
this.ipcRegistered = false;
|
|
54
173
|
}
|
|
55
|
-
/**
|
|
174
|
+
/** Register manual IPC handlers. Idempotent. */
|
|
56
175
|
init(): void {
|
|
57
176
|
this.registerIpc();
|
|
58
177
|
}
|
|
59
178
|
|
|
60
179
|
async destroy(): Promise<void> {
|
|
180
|
+
this.destroyed = true;
|
|
181
|
+
this.pendingPublishError?.(new Error('mDNS instance destroyed during publish'));
|
|
182
|
+
await this.stopWatching();
|
|
61
183
|
this.unregisterIpc();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
this.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
184
|
+
for (const abort of [...this.activeDiscoveries]) abort('mDNS instance destroyed during discovery');
|
|
185
|
+
await this.withBroadcastLock(async () => {
|
|
186
|
+
await this.stopBroadcastUnlocked();
|
|
187
|
+
try {
|
|
188
|
+
this.bonjourInstance?.destroy();
|
|
189
|
+
this.transport?.removeListener('error', this.onNetworkError);
|
|
190
|
+
this.advertiser = undefined;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.warn('[mDNS] bonjour.destroy error:', err);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
68
195
|
}
|
|
69
196
|
|
|
70
197
|
//</editor-fold>
|
|
71
198
|
|
|
72
|
-
private bonjour = new Bonjour();
|
|
73
199
|
private advertiser?: Service;
|
|
74
200
|
|
|
75
201
|
// ----------------------------- utils -----------------------------
|
|
76
202
|
private toErr(err: unknown): string {
|
|
77
203
|
return err instanceof Error ? err.message : String(err);
|
|
78
204
|
}
|
|
205
|
+
private validatePort(port: unknown): string | null {
|
|
206
|
+
return Number.isInteger(port) && typeof port === 'number' && port > 0 && port <= 65535
|
|
207
|
+
? null
|
|
208
|
+
: 'Missing/invalid port';
|
|
209
|
+
}
|
|
210
|
+
private isRecord(value: unknown): value is Record<string, unknown> {
|
|
211
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
212
|
+
}
|
|
213
|
+
private withBroadcastLock<T>(work: () => Promise<T>): Promise<T> {
|
|
214
|
+
// Publish and stop share one Bonjour service; serializing them prevents orphaned advertisers.
|
|
215
|
+
const run = this.broadcastQueue.catch(() => undefined).then(work);
|
|
216
|
+
this.broadcastQueue = run.catch(() => undefined);
|
|
217
|
+
return run;
|
|
218
|
+
}
|
|
79
219
|
|
|
80
220
|
private normalize(name: string): string {
|
|
81
|
-
return name.replace(/ \(\d+\)$/, '');
|
|
221
|
+
return name.replace(/ \(\d+\)$/, '').replace(/\./g, '-');
|
|
82
222
|
}
|
|
83
223
|
private matchesTarget(candidate: string, target?: string | null): boolean {
|
|
84
224
|
if (!target) return true;
|
|
@@ -86,33 +226,73 @@ export class mDNS {
|
|
|
86
226
|
t = this.normalize(target);
|
|
87
227
|
return c === t || c.startsWith(t);
|
|
88
228
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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;
|
|
235
|
+
const raw = typeof typeWithDot === 'string' && typeWithDot.trim() ? typeWithDot.trim() : '_http._tcp.';
|
|
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);
|
|
94
270
|
}
|
|
95
271
|
private toFullType(type: string, protocol: 'tcp' | 'udp'): string {
|
|
96
272
|
return `_${type}._${protocol}.`;
|
|
97
273
|
}
|
|
98
274
|
|
|
99
|
-
private hasCbStop(x: unknown): x is { stop: (cb?: () => void) => void } {
|
|
100
|
-
return !!x && typeof (x as any).stop === 'function';
|
|
101
|
-
}
|
|
102
275
|
private async safeStopService(svc: Service | undefined): Promise<void> {
|
|
103
|
-
if (!svc
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
});
|
|
116
296
|
}
|
|
117
297
|
private safeStopBrowser(b: Browser | null | undefined): void {
|
|
118
298
|
if (!b) return;
|
|
@@ -124,61 +304,144 @@ export class mDNS {
|
|
|
124
304
|
}
|
|
125
305
|
|
|
126
306
|
// ----------------------------- API -----------------------------
|
|
307
|
+
/**
|
|
308
|
+
* Return the Electron main-process implementation marker.
|
|
309
|
+
*/
|
|
310
|
+
async getPluginPlatform(): Promise<MdnsPluginPlatformResult> {
|
|
311
|
+
return { platform: 'electron' };
|
|
312
|
+
}
|
|
313
|
+
|
|
127
314
|
/**
|
|
128
315
|
* Publish (advertise) a single Bonjour/mDNS service via bonjour-service.
|
|
129
316
|
* @param options See MdnsBroadcastOptions for type/name/port/txt.
|
|
130
317
|
* @returns Result indicating whether publishing is active and the final name.
|
|
131
318
|
*/
|
|
132
319
|
async startBroadcast(options: MdnsBroadcastOptions): Promise<MdnsBroadcastResult> {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
});
|
|
320
|
+
return this.withBroadcastLock(async () => {
|
|
321
|
+
const safeOptions = (options ?? {}) as Partial<MdnsBroadcastOptions>;
|
|
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 };
|
|
325
|
+
if (this.destroyed)
|
|
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 };
|
|
151
329
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
safeResolve({ publishing: true, name: svc.name || '', error: false, errorMessage: null });
|
|
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),
|
|
160
337
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
338
|
+
}
|
|
339
|
+
const { type, protocol } = parsed;
|
|
340
|
+
|
|
341
|
+
const stopped = await this.stopBroadcastUnlocked();
|
|
342
|
+
if (stopped.error) return { ...stopped, name: '' };
|
|
343
|
+
|
|
344
|
+
return new Promise<MdnsBroadcastResult>((resolve) => {
|
|
345
|
+
let settled = false;
|
|
346
|
+
let failing = false;
|
|
347
|
+
let timeout: NodeJS.Timeout | null = null;
|
|
348
|
+
const safeResolve = (r: MdnsBroadcastResult) => {
|
|
349
|
+
if (!settled) {
|
|
350
|
+
settled = true;
|
|
351
|
+
if (timeout) {
|
|
352
|
+
clearTimeout(timeout);
|
|
353
|
+
timeout = null;
|
|
354
|
+
}
|
|
355
|
+
this.pendingPublishError = undefined;
|
|
356
|
+
resolve(r);
|
|
172
357
|
}
|
|
173
|
-
safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(err) });
|
|
174
358
|
};
|
|
175
359
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
360
|
+
try {
|
|
361
|
+
const svc = this.bonjour.publish({
|
|
362
|
+
name:
|
|
363
|
+
typeof safeOptions.name === 'string' && safeOptions.name.trim()
|
|
364
|
+
? safeOptions.name.trim()
|
|
365
|
+
: 'DevIOArtsMDNS',
|
|
366
|
+
type,
|
|
367
|
+
protocol,
|
|
368
|
+
port: safeOptions.port as number,
|
|
369
|
+
txt: this.isRecord(safeOptions.txt) ? this.normalizeTxt(safeOptions.txt) : undefined,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const cleanupListeners = () => {
|
|
373
|
+
try {
|
|
374
|
+
svc.removeListener('up', onUp);
|
|
375
|
+
svc.removeListener('error', onError);
|
|
376
|
+
} catch {
|
|
377
|
+
/* ignore */
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const onUp = () => {
|
|
381
|
+
if (failing || settled) return;
|
|
382
|
+
cleanupListeners();
|
|
383
|
+
safeResolve({ publishing: true, name: svc.name || '', error: false, errorMessage: null });
|
|
384
|
+
};
|
|
385
|
+
const onError = async (err: unknown) => {
|
|
386
|
+
if (failing || settled) return;
|
|
387
|
+
failing = true;
|
|
388
|
+
if (timeout) clearTimeout(timeout);
|
|
389
|
+
timeout = null;
|
|
390
|
+
cleanupListeners();
|
|
391
|
+
const stopped = await this.stopBroadcastUnlocked();
|
|
392
|
+
safeResolve({
|
|
393
|
+
publishing: stopped.publishing,
|
|
394
|
+
name: '',
|
|
395
|
+
error: true,
|
|
396
|
+
errorMessage: [this.toErr(err), stopped.errorMessage].filter(Boolean).join('; '),
|
|
397
|
+
});
|
|
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
|
+
};
|
|
432
|
+
|
|
433
|
+
svc.once('up', onUp);
|
|
434
|
+
svc.once('error', onError);
|
|
435
|
+
timeout = setTimeout(onTimeout, this.publishTimeoutMs);
|
|
436
|
+
this.advertiser = svc;
|
|
437
|
+
this.pendingPublishError = (error) => {
|
|
438
|
+
void onError(error);
|
|
439
|
+
};
|
|
440
|
+
if (this.networkError) this.pendingPublishError(new Error(this.networkError));
|
|
441
|
+
} catch (e) {
|
|
442
|
+
safeResolve({ publishing: false, name: '', error: true, errorMessage: this.toErr(e) });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
182
445
|
});
|
|
183
446
|
}
|
|
184
447
|
|
|
@@ -187,6 +450,10 @@ export class mDNS {
|
|
|
187
450
|
* @returns Result indicating whether the advertiser is active and error info.
|
|
188
451
|
*/
|
|
189
452
|
async stopBroadcast(): Promise<MdnsStopResult> {
|
|
453
|
+
return this.withBroadcastLock(() => this.stopBroadcastUnlocked());
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async stopBroadcastUnlocked(): Promise<MdnsStopResult> {
|
|
190
457
|
try {
|
|
191
458
|
if (this.advertiser) {
|
|
192
459
|
await this.safeStopService(this.advertiser);
|
|
@@ -194,7 +461,7 @@ export class mDNS {
|
|
|
194
461
|
}
|
|
195
462
|
return { publishing: false, error: false, errorMessage: null };
|
|
196
463
|
} catch (e) {
|
|
197
|
-
return { publishing:
|
|
464
|
+
return { publishing: !!this.advertiser, error: true, errorMessage: this.toErr(e) };
|
|
198
465
|
}
|
|
199
466
|
}
|
|
200
467
|
|
|
@@ -205,9 +472,31 @@ export class mDNS {
|
|
|
205
472
|
* @param options See MdnsDiscoverOptions for type/name/timeout.
|
|
206
473
|
*/
|
|
207
474
|
async discover(options: MdnsDiscoverOptions = {}): Promise<MdnsDiscoverResult> {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
475
|
+
const safeOptions = (options ?? {}) as Partial<MdnsDiscoverOptions>;
|
|
476
|
+
if (this.destroyed) {
|
|
477
|
+
return { error: true, errorMessage: 'mDNS instance is destroyed', servicesFound: 0, services: [] };
|
|
478
|
+
}
|
|
479
|
+
if (this.networkError) return { error: true, errorMessage: this.networkError, servicesFound: 0, services: [] };
|
|
480
|
+
|
|
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);
|
|
211
500
|
|
|
212
501
|
let browser: Browser | null = null;
|
|
213
502
|
const services: MdnsService[] = [];
|
|
@@ -216,8 +505,17 @@ export class mDNS {
|
|
|
216
505
|
|
|
217
506
|
return new Promise<MdnsDiscoverResult>((resolve) => {
|
|
218
507
|
let timer: NodeJS.Timeout | null = null;
|
|
508
|
+
let settled = false;
|
|
219
509
|
|
|
220
|
-
const finish = () => {
|
|
510
|
+
const finish = (abortMessage?: string) => {
|
|
511
|
+
if (settled) return;
|
|
512
|
+
settled = true;
|
|
513
|
+
this.activeDiscoveries.delete(finish);
|
|
514
|
+
this.transport?.removeListener('response', onResponse);
|
|
515
|
+
if (abortMessage) {
|
|
516
|
+
hadError = true;
|
|
517
|
+
errMsg = abortMessage;
|
|
518
|
+
}
|
|
221
519
|
if (browser) {
|
|
222
520
|
this.safeStopBrowser(browser);
|
|
223
521
|
browser = null;
|
|
@@ -229,34 +527,61 @@ export class mDNS {
|
|
|
229
527
|
resolve({
|
|
230
528
|
error: hadError,
|
|
231
529
|
errorMessage: errMsg,
|
|
232
|
-
services,
|
|
233
|
-
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,
|
|
234
532
|
});
|
|
235
533
|
};
|
|
236
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);
|
|
237
565
|
try {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!services.some((x) => `${x.name}:${x.port}` === key)) services.push(item);
|
|
250
|
-
if (targetId && this.matchesTarget(item.name, targetId)) finish();
|
|
251
|
-
});
|
|
252
|
-
|
|
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);
|
|
253
577
|
// Record error and let the timeout conclude; results will still be returned.
|
|
254
|
-
|
|
578
|
+
const onBrowserError = (err: unknown) => {
|
|
255
579
|
hadError = true;
|
|
256
580
|
errMsg = this.toErr(err);
|
|
257
|
-
}
|
|
581
|
+
};
|
|
582
|
+
(browser as EventEmitter).on('error', onBrowserError);
|
|
258
583
|
|
|
259
|
-
timer = setTimeout(finish, timeoutMs);
|
|
584
|
+
timer = setTimeout(() => finish(), timeoutMs);
|
|
260
585
|
} catch (err) {
|
|
261
586
|
hadError = true;
|
|
262
587
|
errMsg = this.toErr(err);
|
|
@@ -264,4 +589,200 @@ export class mDNS {
|
|
|
264
589
|
}
|
|
265
590
|
});
|
|
266
591
|
}
|
|
592
|
+
|
|
593
|
+
private normalizeTxt(txt: Record<string, unknown>): Record<string, string> {
|
|
594
|
+
return Object.fromEntries(
|
|
595
|
+
Object.entries(txt).map(([key, value]) => [key, Buffer.isBuffer(value) ? value.toString('utf8') : String(value)]),
|
|
596
|
+
);
|
|
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
|
+
}
|
|
267
788
|
}
|