react-native-zeroconf 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -162,7 +162,7 @@ Publish a service on the network.
162
162
  | `domain` | string | `'local.'` | Domain |
163
163
  | `name` | string | required | Service name (should be unique) |
164
164
  | `port` | number | required | Port number |
165
- | `txt` | object | `{}` | TXT record key-value pairs |
165
+ | `txt` | object \| array | `{}` | TXT record key-value pairs, or `[key, value]` pairs to control the order |
166
166
  | `implType` | string | `'NSD'` | **Android only**: `'NSD'` or `'DNSSD'` |
167
167
 
168
168
  ```javascript
@@ -170,8 +170,18 @@ zeroconf.publishService('http', 'tcp', 'local.', 'MyWebServer', 8080, {
170
170
  path: '/api',
171
171
  version: '1.0',
172
172
  })
173
+
174
+ // TXT records are published in the order given. Use pairs when order matters and keys look like numbers
175
+ zeroconf.publishService('http', 'tcp', 'local.', 'MyWebServer', 8080, [
176
+ ['txtvers', '1'],
177
+ ['path', '/api'],
178
+ ])
173
179
  ```
174
180
 
181
+ TXT order is preserved on iOS and with `DNSSD` on Android. With `NSD`, the order depends on the Android system.
182
+
183
+ Published services are unpublished when the React Native instance is torn down (e.g. on reload). If the app process is killed, it can't announce anything: other devices only drop the service once its mDNS records expire.
184
+
175
185
  #### `unpublishService(name, implType)`
176
186
 
177
187
  Remove a published service.
@@ -235,10 +245,12 @@ useEffect(() => {
235
245
  fullName: 'XeroxPrinter._http._tcp.local.', // Full service name
236
246
  host: 'XeroxPrinter.local.', // Hostname
237
247
  port: 8080, // Port number
238
- addresses: [ // IP addresses (IPv4 and/or IPv6)
248
+ addresses: [ // IP addresses, IPv4 first
239
249
  '192.168.1.23',
240
250
  'fe80::aebc:123:ffff:abcd'
241
251
  ],
252
+ ipv4: ['192.168.1.23'], // IPv4 addresses only
253
+ ipv6: ['fe80::aebc:123:ffff:abcd'], // IPv6 addresses only
242
254
  txt: { // TXT record attributes
243
255
  path: '/status',
244
256
  color: 'yes'
@@ -246,6 +258,8 @@ useEffect(() => {
246
258
  }
247
259
  ```
248
260
 
261
+ On Android with `NSD`, `host` is the mDNS hostname on Android 16+. Older versions don't expose it, so `host` is usually the IP address (use `DNSSD` if you need the hostname there). `NSD` returns all addresses on Android 14+, and a single one before.
262
+
249
263
  ## Android Implementation Types
250
264
 
251
265
  This library supports two implementations on Android:
@@ -1,6 +1,6 @@
1
1
  package com.balthazargronon.RCTZeroconf;
2
2
 
3
- import com.facebook.react.bridge.ReadableMap;
3
+ import com.facebook.react.bridge.ReadableArray;
4
4
 
5
5
  public interface Zeroconf {
6
6
 
@@ -10,5 +10,13 @@ public interface Zeroconf {
10
10
 
11
11
  public void unregisterService(String serviceName);
12
12
 
13
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt);
13
+ /**
14
+ * @param txt TXT records as ordered [key, value] pairs
15
+ */
16
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt);
17
+
18
+ /**
19
+ * Unregister every service published through this implementation
20
+ */
21
+ void unregisterAllServices();
14
22
  }
@@ -7,7 +7,7 @@ import com.facebook.react.bridge.ReactApplicationContext;
7
7
  import com.facebook.react.bridge.ReactContext;
8
8
  import com.facebook.react.bridge.ReactContextBaseJavaModule;
9
9
  import com.facebook.react.bridge.ReactMethod;
10
- import com.facebook.react.bridge.ReadableMap;
10
+ import com.facebook.react.bridge.ReadableArray;
11
11
  import com.facebook.react.modules.core.DeviceEventManagerModule;
12
12
 
13
13
  import javax.annotation.Nullable;
@@ -73,7 +73,7 @@ public class ZeroconfModule extends ReactContextBaseJavaModule {
73
73
  }
74
74
 
75
75
  @ReactMethod
76
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt, String implType) {
76
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt, String implType) {
77
77
  try {
78
78
  getZeroconfImpl(implType).registerService(type, protocol, domain, name, port, txt);
79
79
  } catch (Throwable e) {
@@ -95,21 +95,36 @@ public class ZeroconfModule extends ReactContextBaseJavaModule {
95
95
  public void sendEvent(ReactContext reactContext,
96
96
  String eventName,
97
97
  @Nullable Object params) {
98
- reactContext
99
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
100
- .emit(eventName, params);
98
+ try {
99
+ reactContext
100
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
101
+ .emit(eventName, params);
102
+ } catch (Throwable e) {
103
+ // The JS side may already be gone, e.g. callbacks arriving during teardown
104
+ Log.w(getClass().getName(), "Could not send " + eventName + ": " + e.getMessage());
105
+ }
101
106
  }
102
107
 
103
- @Override
108
+ // Called on teardown by current React Native versions.
109
+ // No @Override so it still compiles against versions that don't declare it.
110
+ public void invalidate() {
111
+ teardown();
112
+ }
113
+
114
+ // Called on teardown by older React Native versions, deprecated in favor of invalidate().
115
+ // No @Override so it still compiles once it is removed.
104
116
  public void onCatalystInstanceDestroy() {
105
- super.onCatalystInstanceDestroy();
117
+ teardown();
118
+ }
119
+
120
+ private void teardown() {
106
121
  try {
107
122
  for (Zeroconf impl : zeroConfFactory.getCreatedImpls()) {
108
123
  impl.stop();
124
+ impl.unregisterAllServices();
109
125
  }
110
126
  } catch (Throwable e) {
111
127
  Log.e(getClass().getName(), e.getMessage(), e);
112
- sendEvent(getReactApplicationContext(), ZeroconfModule.EVENT_ERROR, "Exception During Catalyst Destroy: " + e.getMessage());
113
128
  }
114
129
  }
115
130
  }
@@ -14,8 +14,7 @@ import com.balthazargronon.RCTZeroconf.Zeroconf;
14
14
  import com.balthazargronon.RCTZeroconf.ZeroconfModule;
15
15
  import com.facebook.react.bridge.ReactApplicationContext;
16
16
 
17
- import com.facebook.react.bridge.ReadableMap;
18
- import com.facebook.react.bridge.ReadableMapKeySetIterator;
17
+ import com.facebook.react.bridge.ReadableArray;
19
18
  import com.facebook.react.bridge.WritableArray;
20
19
  import com.facebook.react.bridge.WritableMap;
21
20
  import com.facebook.react.bridge.WritableNativeArray;
@@ -24,6 +23,9 @@ import com.facebook.react.bridge.WritableNativeMap;
24
23
  import java.net.InetAddress;
25
24
  import java.nio.charset.StandardCharsets;
26
25
  import java.util.ArrayDeque;
26
+ import java.util.ArrayList;
27
+ import java.util.Collections;
28
+ import java.util.List;
27
29
  import java.util.Locale;
28
30
  import java.util.Map;
29
31
  import java.util.concurrent.ConcurrentHashMap;
@@ -139,7 +141,7 @@ public class NsdServiceImpl implements Zeroconf {
139
141
  }
140
142
 
141
143
  @Override
142
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt) {
144
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt) {
143
145
  String serviceType = String.format("_%s._%s.", type, protocol);
144
146
 
145
147
  final NsdManager nsdManager = this.getNsdManager();
@@ -148,10 +150,9 @@ public class NsdServiceImpl implements Zeroconf {
148
150
  serviceInfo.setServiceType(serviceType);
149
151
  serviceInfo.setPort(port);
150
152
 
151
- ReadableMapKeySetIterator iterator = txt.keySetIterator();
152
- while (iterator.hasNextKey()) {
153
- String key = iterator.nextKey();
154
- serviceInfo.setAttribute(key, txt.getString(key));
153
+ for (int i = 0; i < txt.size(); i++) {
154
+ ReadableArray pair = txt.getArray(i);
155
+ serviceInfo.setAttribute(pair.getString(0), pair.getString(1));
155
156
  }
156
157
 
157
158
  nsdManager.registerService(
@@ -171,6 +172,13 @@ public class NsdServiceImpl implements Zeroconf {
171
172
  }
172
173
  }
173
174
 
175
+ @Override
176
+ public void unregisterAllServices() {
177
+ for (String serviceName : new ArrayList<>(mPublishedServices.keySet())) {
178
+ unregisterService(serviceName);
179
+ }
180
+ }
181
+
174
182
  private NsdManager getNsdManager() {
175
183
  if (mNsdManager == null) {
176
184
  mNsdManager = (NsdManager) getReactApplicationContext().getSystemService(Context.NSD_SERVICE);
@@ -275,19 +283,55 @@ public class NsdServiceImpl implements Zeroconf {
275
283
  }
276
284
  }
277
285
 
286
+ /**
287
+ * All the addresses of the service on Android 14+, only one before
288
+ */
289
+ @SuppressWarnings("deprecation")
290
+ private static List<InetAddress> getHostAddresses(NsdServiceInfo serviceInfo) {
291
+ if (Build.VERSION.SDK_INT >= 34) {
292
+ return serviceInfo.getHostAddresses();
293
+ }
294
+ InetAddress host = serviceInfo.getHost();
295
+ return host == null ? Collections.<InetAddress>emptyList() : Collections.singletonList(host);
296
+ }
297
+
298
+ /**
299
+ * The mDNS hostname (e.g. "MyHost.local.") when available.
300
+ * NsdServiceInfo.getHostname() is API 36 (or backported through the Connectivity module),
301
+ * it is called through reflection so the library still compiles against older SDKs.
302
+ */
303
+ private static String getMdnsHostname(NsdServiceInfo serviceInfo) {
304
+ try {
305
+ Object hostname = NsdServiceInfo.class.getMethod("getHostname").invoke(serviceInfo);
306
+ if (hostname instanceof String && !((String) hostname).isEmpty()) {
307
+ return hostname + ".local.";
308
+ }
309
+ } catch (Exception e) {
310
+ // Not available on this device
311
+ }
312
+ return null;
313
+ }
314
+
278
315
  private WritableMap serviceInfoToMap(NsdServiceInfo serviceInfo) {
279
316
  WritableMap service = new WritableNativeMap();
280
317
  service.putString(ZeroconfModule.KEY_SERVICE_NAME, serviceInfo.getServiceName());
281
- final InetAddress host = serviceInfo.getHost();
318
+ final List<InetAddress> hostAddresses = getHostAddresses(serviceInfo);
282
319
  final String fullServiceName;
283
- if (host == null) {
320
+ if (hostAddresses.isEmpty()) {
284
321
  fullServiceName = serviceInfo.getServiceName();
285
322
  } else {
286
- fullServiceName = host.getHostName() + serviceInfo.getServiceType();
287
- service.putString(ZeroconfModule.KEY_SERVICE_HOST, host.getHostName());
323
+ String hostname = getMdnsHostname(serviceInfo);
324
+ if (hostname == null) {
325
+ // No hostname API before Android 16, falls back to a reverse lookup (often the IP)
326
+ hostname = hostAddresses.get(0).getHostName();
327
+ }
328
+ fullServiceName = hostname + serviceInfo.getServiceType();
329
+ service.putString(ZeroconfModule.KEY_SERVICE_HOST, hostname);
288
330
 
289
331
  WritableArray addresses = new WritableNativeArray();
290
- addresses.pushString(host.getHostAddress());
332
+ for (InetAddress address : hostAddresses) {
333
+ addresses.pushString(address.getHostAddress());
334
+ }
291
335
 
292
336
  service.putArray(ZeroconfModule.KEY_SERVICE_ADDRESSES, addresses);
293
337
  }
@@ -8,8 +8,7 @@ import android.util.Log;
8
8
  import com.balthazargronon.RCTZeroconf.Zeroconf;
9
9
  import com.balthazargronon.RCTZeroconf.ZeroconfModule;
10
10
  import com.facebook.react.bridge.ReactApplicationContext;
11
- import com.facebook.react.bridge.ReadableMap;
12
- import com.facebook.react.bridge.ReadableMapKeySetIterator;
11
+ import com.facebook.react.bridge.ReadableArray;
13
12
  import com.facebook.react.bridge.WritableArray;
14
13
  import com.facebook.react.bridge.WritableMap;
15
14
  import com.facebook.react.bridge.WritableNativeArray;
@@ -19,7 +18,9 @@ import com.github.druk.rx2dnssd.Rx2Dnssd;
19
18
  import com.github.druk.rx2dnssd.Rx2DnssdEmbedded;
20
19
 
21
20
  import java.net.InetAddress;
21
+ import java.util.ArrayList;
22
22
  import java.util.HashMap;
23
+ import java.util.LinkedHashMap;
23
24
  import java.util.Locale;
24
25
  import java.util.Map;
25
26
  import java.util.List;
@@ -187,7 +188,7 @@ public class DnssdImpl implements Zeroconf {
187
188
  }
188
189
 
189
190
  @Override
190
- public void registerService(String type, String protocol, String domain, String name, int port, ReadableMap txt) {
191
+ public void registerService(String type, String protocol, String domain, String name, int port, ReadableArray txt) {
191
192
  BonjourService bs = new BonjourService.Builder(0, 0, name, getServiceType(type, protocol), null)
192
193
  .port(port)
193
194
  .dnsRecords(getTxtRecordMap(txt))
@@ -210,12 +211,19 @@ public class DnssdImpl implements Zeroconf {
210
211
  mRegisteredDisposables.put(name, registerDisposable);
211
212
  }
212
213
 
213
- private Map<String, String> getTxtRecordMap(ReadableMap txt) {
214
- Map<String, String> txtMap = new HashMap<>();
215
- ReadableMapKeySetIterator iterator = txt.keySetIterator();
216
- while (iterator.hasNextKey()) {
217
- String key = iterator.nextKey();
218
- txtMap.put(key, txt.getString(key));
214
+ @Override
215
+ public void unregisterAllServices() {
216
+ for (String serviceName : new ArrayList<>(mRegisteredDisposables.keySet())) {
217
+ unregisterService(serviceName);
218
+ }
219
+ }
220
+
221
+ private Map<String, String> getTxtRecordMap(ReadableArray txt) {
222
+ // LinkedHashMap keeps the TXT record order
223
+ Map<String, String> txtMap = new LinkedHashMap<>();
224
+ for (int i = 0; i < txt.size(); i++) {
225
+ ReadableArray pair = txt.getArray(i);
226
+ txtMap.put(pair.getString(0), pair.getString(1));
219
227
  }
220
228
  return txtMap;
221
229
  }
package/dist/index.js CHANGED
@@ -11,6 +11,30 @@ const ImplType = exports.ImplType = {
11
11
  NSD: 'NSD',
12
12
  DNSSD: 'DNSSD'
13
13
  };
14
+ const isIPv6 = address => address.includes(':');
15
+
16
+ /**
17
+ * Sort addresses IPv4 first and expose them by family
18
+ */
19
+ const withAddressFamilies = service => {
20
+ const addresses = Array.isArray(service.addresses) ? service.addresses : [];
21
+ const ipv4 = addresses.filter(address => !isIPv6(address));
22
+ const ipv6 = addresses.filter(isIPv6);
23
+ return {
24
+ ...service,
25
+ addresses: [...ipv4, ...ipv6],
26
+ ipv4,
27
+ ipv6
28
+ };
29
+ };
30
+
31
+ /**
32
+ * TXT records as ordered [key, value] pairs, from an object or an array of pairs
33
+ */
34
+ const toTxtPairs = txt => {
35
+ const entries = Array.isArray(txt) ? txt : Object.entries(txt || {});
36
+ return entries.map(([key, value]) => [String(key), String(value)]);
37
+ };
14
38
  class Zeroconf extends _events.EventEmitter {
15
39
  constructor(props) {
16
40
  super(props);
@@ -61,10 +85,11 @@ class Zeroconf extends _events.EventEmitter {
61
85
  this.emit('remove', name);
62
86
  this.emit('update');
63
87
  });
64
- this._dListeners.resolved = _reactNative.DeviceEventEmitter.addListener('RNZeroconfResolved', service => {
65
- if (!service || !service.name) {
88
+ this._dListeners.resolved = _reactNative.DeviceEventEmitter.addListener('RNZeroconfResolved', data => {
89
+ if (!data || !data.name) {
66
90
  return;
67
91
  }
92
+ const service = withAddressFamilies(data);
68
93
  this._services[service.name] = service;
69
94
  this.emit('resolved', service);
70
95
  this.emit('update');
@@ -135,7 +160,7 @@ class Zeroconf extends _events.EventEmitter {
135
160
  * Publish a service
136
161
  */
137
162
  publishService(type, protocol, domain = 'local.', name, port, txt = {}, implType = ImplType.NSD) {
138
- const txtRecord = Object.fromEntries(Object.entries(txt || {}).map(([key, value]) => [key, String(value)]));
163
+ const txtRecord = toTxtPairs(txt);
139
164
  if (_reactNative.Platform.OS === 'android') {
140
165
  this._publishedImplTypes[name] = implType;
141
166
  RNZeroconf.registerService(type, protocol, domain, name, port, txtRecord, implType);
package/index.d.ts CHANGED
@@ -14,12 +14,22 @@ export interface Service {
14
14
  /** Hostname, e.g. `XeroxPrinter.local.` */
15
15
  host: string
16
16
  port: number
17
- /** IPv4 and/or IPv6 addresses */
17
+ /** IPv4 and IPv6 addresses, IPv4 first */
18
18
  addresses: string[]
19
+ ipv4: string[]
20
+ ipv6: string[]
19
21
  /** TXT record attributes */
20
22
  txt: Record<string, string>
21
23
  }
22
24
 
25
+ type TxtValue = string | number | boolean
26
+
27
+ /** TXT records as an object, or as [key, value] pairs to control the order */
28
+ export type TxtRecord = Record<string, TxtValue> | Array<[string, TxtValue]>
29
+
30
+ /** Published services don't carry the address family fields */
31
+ export type PublishedService = Omit<Service, 'ipv4' | 'ipv6'>
32
+
23
33
  export interface ZeroconfEvents {
24
34
  start: () => void
25
35
  stop: () => void
@@ -32,8 +42,8 @@ export interface ZeroconfEvents {
32
42
  /** Services list changed */
33
43
  update: () => void
34
44
  error: (error: Error) => void
35
- published: (service: Service) => void
36
- unpublished: (service: Service) => void
45
+ published: (service: PublishedService) => void
46
+ unpublished: (service: PublishedService) => void
37
47
  }
38
48
 
39
49
  /** Extends the `events` package EventEmitter */
@@ -79,7 +89,7 @@ export default class Zeroconf {
79
89
  domain: string | undefined,
80
90
  name: string,
81
91
  port: number,
82
- txt?: Record<string, string | number | boolean>,
92
+ txt?: TxtRecord,
83
93
  implType?: ImplType,
84
94
  ): void
85
95
 
@@ -10,8 +10,9 @@
10
10
  #import <React/RCTBridge.h>
11
11
  #import <React/RCTBridgeModule.h>
12
12
  #import <React/RCTEventDispatcher.h>
13
+ #import <React/RCTInvalidating.h>
13
14
 
14
- @interface RNZeroconf : NSObject <RCTBridgeModule, NSNetServiceBrowserDelegate, NSNetServiceDelegate>
15
+ @interface RNZeroconf : NSObject <RCTBridgeModule, RCTInvalidating, NSNetServiceBrowserDelegate, NSNetServiceDelegate>
15
16
 
16
17
  @property (nonatomic, strong) NSNetServiceBrowser *browser;
17
18
 
@@ -13,6 +13,11 @@
13
13
 
14
14
  @property (nonatomic, strong, readonly) NSMutableDictionary *resolvingServices;
15
15
  @property (nonatomic, strong, readonly) NSMutableDictionary *publishedServices;
16
+ // Services already retried after a resolve timeout
17
+ @property (nonatomic, strong, readonly) NSMutableSet<NSString *> *retriedServices;
18
+ // Stopped browsers, kept alive until their asynchronous stop has been processed
19
+ @property (nonatomic, strong, readonly) NSMutableSet<NSNetServiceBrowser *> *stoppingBrowsers;
20
+ @property (nonatomic, assign) NSTimeInterval resolveTimeoutSeconds;
16
21
 
17
22
  @end
18
23
 
@@ -25,13 +30,34 @@ RCT_EXPORT_MODULE()
25
30
  RCT_EXPORT_METHOD(scan:(NSString *)type protocol:(NSString *)protocol domain:(NSString *)domain)
26
31
  {
27
32
  [self stop];
33
+
34
+ // A fresh browser for each scan, restarting a search on a browser that is still stopping can crash (#154)
35
+ self.browser = [[NSNetServiceBrowser alloc] init];
36
+ [self.browser setDelegate:self];
28
37
  [self.browser searchForServicesOfType:[NSString stringWithFormat:@"_%@._%@.", type, protocol] inDomain:domain];
29
38
  }
30
39
 
31
40
  RCT_EXPORT_METHOD(stop)
32
41
  {
33
- [self.browser stop];
42
+ NSNetServiceBrowser *browser = self.browser;
43
+ if (browser) {
44
+ self.browser = nil;
45
+ [self.stoppingBrowsers addObject:browser];
46
+ [browser stop];
47
+
48
+ // The stop completes asynchronously on the run loop, release the browser well after it
49
+ __weak RNZeroconf *weakSelf = self;
50
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(10 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
51
+ browser.delegate = nil;
52
+ [weakSelf.stoppingBrowsers removeObject:browser];
53
+ });
54
+ }
55
+ for (NSNetService *service in self.resolvingServices.allValues) {
56
+ service.delegate = nil;
57
+ [service stop];
58
+ }
34
59
  [self.resolvingServices removeAllObjects];
60
+ [self.retriedServices removeAllObjects];
35
61
  }
36
62
 
37
63
  - (dispatch_queue_t)methodQueue
@@ -49,15 +75,14 @@ RCT_EXPORT_METHOD(registerService:(NSString *)type
49
75
  domain:(NSString *)domain
50
76
  name:(NSString *)name
51
77
  port:(int)port
52
- txt:(NSDictionary *)txt)
78
+ txt:(NSArray<NSArray<NSString *> *> *)txt)
53
79
  {
54
80
  const NSNetService *svc = [[NSNetService alloc] initWithDomain:domain type:[NSString stringWithFormat:@"_%@._%@.", type, protocol] name:name port:port];
55
81
  [svc setDelegate:self];
56
82
  [svc scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
57
83
 
58
- if (txt) {
59
- NSData *txtData = [NSNetService dataFromTXTRecordDictionary:txt];
60
- [svc setTXTRecordData:txtData];
84
+ if (txt.count > 0) {
85
+ [svc setTXTRecordData:[self TXTRecordDataFromPairs:txt]];
61
86
  }
62
87
 
63
88
  [svc publish];
@@ -94,7 +119,7 @@ RCT_EXPORT_METHOD(unregisterService:(NSString *) serviceName)
94
119
  self.resolvingServices[service.name] = service;
95
120
 
96
121
  service.delegate = self;
97
- [service resolveWithTimeout:5.0];
122
+ [service resolveWithTimeout:self.resolveTimeoutSeconds];
98
123
  }
99
124
 
100
125
  // When a service is removed.
@@ -128,7 +153,10 @@ RCT_EXPORT_METHOD(unregisterService:(NSString *) serviceName)
128
153
  // When the search stops.
129
154
  - (void) netServiceBrowserDidStopSearch:(NSNetServiceBrowser *)browser
130
155
  {
131
- [self.bridge.eventDispatcher sendDeviceEventWithName:@"RNZeroconfStop" body:nil];
156
+ // A browser replaced by a newer scan stopping late shouldn't report the new scan as stopped
157
+ if (self.browser == nil || self.browser == browser) {
158
+ [self.bridge.eventDispatcher sendDeviceEventWithName:@"RNZeroconfStop" body:nil];
159
+ }
132
160
  }
133
161
 
134
162
  // When the search starts.
@@ -147,12 +175,22 @@ RCT_EXPORT_METHOD(unregisterService:(NSString *) serviceName)
147
175
 
148
176
  sender.delegate = nil;
149
177
  [self.resolvingServices removeObjectForKey:sender.name];
178
+ [self.retriedServices removeObject:sender.name];
150
179
  }
151
180
 
152
181
  // When the service has failed to resolve it's network data (IP addresses, etc)
153
182
  - (void) netService:(NSNetService *)sender
154
183
  didNotResolve:(NSDictionary *)errorDict
155
184
  {
185
+ // Slow devices can time out, retry once before reporting the error (#186)
186
+ NSNumber *code = errorDict[NSNetServicesErrorCode];
187
+ if (code.integerValue == NSNetServicesTimeoutError && ![self.retriedServices containsObject:sender.name]) {
188
+ [self.retriedServices addObject:sender.name];
189
+ [sender resolveWithTimeout:self.resolveTimeoutSeconds];
190
+ return;
191
+ }
192
+ [self.retriedServices removeObject:sender.name];
193
+
156
194
  [self reportError:errorDict];
157
195
 
158
196
  sender.delegate = nil;
@@ -206,13 +244,49 @@ RCT_EXPORT_METHOD(unregisterService:(NSString *) serviceName)
206
244
  if (self) {
207
245
  _resolvingServices = [[NSMutableDictionary alloc] init];
208
246
  _publishedServices = [[NSMutableDictionary alloc] init];
209
- _browser = [[NSNetServiceBrowser alloc] init];
210
- [_browser setDelegate:self];
247
+ _retriedServices = [[NSMutableSet alloc] init];
248
+ _stoppingBrowsers = [[NSMutableSet alloc] init];
249
+ _resolveTimeoutSeconds = 5.0;
211
250
  }
212
251
 
213
252
  return self;
214
253
  }
215
254
 
255
+ // Builds the TXT record in the given order, unlike dataFromTXTRecordDictionary
256
+ - (NSData *) TXTRecordDataFromPairs:(NSArray<NSArray<NSString *> *> *)pairs
257
+ {
258
+ NSMutableData *data = [NSMutableData data];
259
+ for (NSArray<NSString *> *pair in pairs) {
260
+ if (pair.count != 2) {
261
+ continue;
262
+ }
263
+ NSData *entry = [[NSString stringWithFormat:@"%@=%@", pair[0], pair[1]] dataUsingEncoding:NSUTF8StringEncoding];
264
+ if (entry.length > 255) {
265
+ [self.bridge.eventDispatcher sendDeviceEventWithName:@"RNZeroconfError" body:[NSString stringWithFormat:@"TXT record entry %@ is longer than 255 bytes", pair[0]]];
266
+ continue;
267
+ }
268
+ uint8_t length = (uint8_t)entry.length;
269
+ [data appendBytes:&length length:1];
270
+ [data appendData:entry];
271
+ }
272
+ return data;
273
+ }
274
+
275
+ // Called when the bridge is torn down (e.g. reload): stop scanning and unpublish services
276
+ - (void) invalidate
277
+ {
278
+ [self stop];
279
+ // Delegates are unretained, detach them so stopping browsers never call a released module
280
+ for (NSNetServiceBrowser *browser in self.stoppingBrowsers) {
281
+ browser.delegate = nil;
282
+ }
283
+ for (NSNetService *service in self.publishedServices.allValues) {
284
+ service.delegate = nil;
285
+ [service stop];
286
+ }
287
+ [self.publishedServices removeAllObjects];
288
+ }
289
+
216
290
  - (void) reportError:(NSDictionary *)errorDict
217
291
  {
218
292
  [self.bridge.eventDispatcher sendDeviceEventWithName:@"RNZeroconfError" body:[NSString stringWithFormat:@"%@",errorDict]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-zeroconf",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "A Zeroconf discovery utility for react-native",
5
5
  "main": "dist",
6
6
  "types": "index.d.ts",
package/src/index.js CHANGED
@@ -8,6 +8,26 @@ export const ImplType = {
8
8
  DNSSD: 'DNSSD',
9
9
  }
10
10
 
11
+ const isIPv6 = address => address.includes(':')
12
+
13
+ /**
14
+ * Sort addresses IPv4 first and expose them by family
15
+ */
16
+ const withAddressFamilies = service => {
17
+ const addresses = Array.isArray(service.addresses) ? service.addresses : []
18
+ const ipv4 = addresses.filter(address => !isIPv6(address))
19
+ const ipv6 = addresses.filter(isIPv6)
20
+ return { ...service, addresses: [...ipv4, ...ipv6], ipv4, ipv6 }
21
+ }
22
+
23
+ /**
24
+ * TXT records as ordered [key, value] pairs, from an object or an array of pairs
25
+ */
26
+ const toTxtPairs = txt => {
27
+ const entries = Array.isArray(txt) ? txt : Object.entries(txt || {})
28
+ return entries.map(([key, value]) => [String(key), String(value)])
29
+ }
30
+
11
31
  export default class Zeroconf extends EventEmitter {
12
32
  constructor(props) {
13
33
  super(props)
@@ -72,11 +92,12 @@ export default class Zeroconf extends EventEmitter {
72
92
  this.emit('update')
73
93
  })
74
94
 
75
- this._dListeners.resolved = DeviceEventEmitter.addListener('RNZeroconfResolved', service => {
76
- if (!service || !service.name) {
95
+ this._dListeners.resolved = DeviceEventEmitter.addListener('RNZeroconfResolved', data => {
96
+ if (!data || !data.name) {
77
97
  return
78
98
  }
79
99
 
100
+ const service = withAddressFamilies(data)
80
101
  this._services[service.name] = service
81
102
  this.emit('resolved', service)
82
103
  this.emit('update')
@@ -157,9 +178,7 @@ export default class Zeroconf extends EventEmitter {
157
178
  * Publish a service
158
179
  */
159
180
  publishService(type, protocol, domain = 'local.', name, port, txt = {}, implType = ImplType.NSD) {
160
- const txtRecord = Object.fromEntries(
161
- Object.entries(txt || {}).map(([key, value]) => [key, String(value)]),
162
- )
181
+ const txtRecord = toTxtPairs(txt)
163
182
  if (Platform.OS === 'android') {
164
183
  this._publishedImplTypes[name] = implType
165
184
  RNZeroconf.registerService(type, protocol, domain, name, port, txtRecord, implType)