@devioarts/capacitor-mdns 0.1.0 → 0.2.0

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