yaver-feedback-react-native 0.7.8 → 0.7.10

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.
@@ -53,6 +53,7 @@ export const FeedbackModal: React.FC = () => {
53
53
  const [action, setAction] = useState<ActionState>('idle');
54
54
  const [error, setError] = useState<string | null>(null);
55
55
  const [toast, setToast] = useState<string | null>(null);
56
+ const [progress, setProgress] = useState<number | null>(null);
56
57
  const [isRecordingVideo, setIsRecordingVideo] = useState(false);
57
58
  const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
58
59
  const mountedRef = useRef(true);
@@ -74,10 +75,18 @@ export const FeedbackModal: React.FC = () => {
74
75
  // "stuck".
75
76
  const statusSub = DeviceEventEmitter.addListener(
76
77
  'yaverFeedback:status',
77
- (payload: { message?: string; phase?: string }) => {
78
+ (payload: { message?: string; phase?: string; progress?: number }) => {
78
79
  if (!mountedRef.current) return;
79
80
  const msg = payload?.message || payload?.phase || '';
80
81
  if (msg) setToast(msg);
82
+ if (typeof payload?.progress === 'number') {
83
+ setProgress(payload.progress);
84
+ }
85
+ // On final phases, fade the bar to 100% so the user sees
86
+ // completion before the modal auto-dismisses.
87
+ if (payload?.phase === 'done' || payload?.phase === 'error') {
88
+ setProgress(1);
89
+ }
81
90
  },
82
91
  );
83
92
  return () => {
@@ -154,14 +163,25 @@ export const FeedbackModal: React.FC = () => {
154
163
  const handleHotReload = useCallback(async () => {
155
164
  setAction('hot-reloading');
156
165
  setError(null);
166
+ setProgress(0);
167
+ setToast('Sending…');
157
168
  try {
169
+ // Default mode: bundle. Always rebuilds via the agent regardless
170
+ // of Metro state. P2PClient.reloadApp auto-resolves projectName +
171
+ // bundleId from expo-constants / NativeModules so the agent can
172
+ // map this app to its MobileProject scan entry without needing
173
+ // `yaver dev start` to have been run.
158
174
  await runWithReconnect(async (client) => {
159
- await client.reloadApp('dev');
175
+ await client.reloadApp('bundle');
160
176
  });
161
- setToast('Reload sent');
162
- closeSoon(800);
177
+ // We don't auto-close here — the agent's BlackBox status pings
178
+ // will keep the modal updated, and the on-device YaverBundleLoader
179
+ // will reload the JS once the fresh bundle arrives. Modal stays
180
+ // up for a beat so the user sees the final progress state.
181
+ closeSoon(2500);
163
182
  } catch (err: unknown) {
164
183
  setError(err instanceof Error ? err.message : String(err));
184
+ setProgress(null);
165
185
  } finally {
166
186
  if (mountedRef.current) setAction('idle');
167
187
  }
@@ -449,6 +469,16 @@ export const FeedbackModal: React.FC = () => {
449
469
  busy={action === 'sending-video'}
450
470
  />
451
471
 
472
+ {progress !== null && (
473
+ <View style={styles.progressTrack}>
474
+ <View
475
+ style={[
476
+ styles.progressFill,
477
+ { width: `${Math.round(progress * 100)}%` },
478
+ ]}
479
+ />
480
+ </View>
481
+ )}
452
482
  {toast && <Text style={styles.toast}>{toast}</Text>}
453
483
  {error && <Text style={styles.error}>{error}</Text>}
454
484
  </Pressable>
@@ -550,6 +580,18 @@ const styles = StyleSheet.create({
550
580
  fontSize: 15,
551
581
  fontWeight: '700',
552
582
  },
583
+ progressTrack: {
584
+ height: 6,
585
+ borderRadius: 3,
586
+ backgroundColor: 'rgba(255,255,255,0.08)',
587
+ overflow: 'hidden',
588
+ marginTop: 4,
589
+ },
590
+ progressFill: {
591
+ height: '100%',
592
+ backgroundColor: '#818cf8',
593
+ borderRadius: 3,
594
+ },
553
595
  toast: {
554
596
  color: '#22c55e',
555
597
  fontSize: 13,
package/src/P2PClient.ts CHANGED
@@ -7,6 +7,56 @@ export interface FeedbackEvent {
7
7
  data: any;
8
8
  }
9
9
 
10
+ /**
11
+ * Try to resolve `{projectName, bundleId}` for the running app so the
12
+ * agent can map the reload request to a MobileProject in its scan
13
+ * cache. Order: caller-supplied opts → Expo Constants → react-native
14
+ * NativeModules. None of the lookups throw — missing data just means
15
+ * the agent will fall back to its own dev-server resolution.
16
+ */
17
+ function resolveAppIdentity(opts?: {
18
+ projectName?: string;
19
+ bundleId?: string;
20
+ projectPath?: string;
21
+ }): { projectName?: string; bundleId?: string; projectPath?: string } {
22
+ let projectName = opts?.projectName;
23
+ let bundleId = opts?.bundleId;
24
+ const projectPath = opts?.projectPath;
25
+
26
+ if (!projectName || !bundleId) {
27
+ try {
28
+ const Constants = require('expo-constants').default ?? require('expo-constants');
29
+ const cfg = Constants?.expoConfig ?? Constants?.manifest ?? {};
30
+ projectName = projectName || cfg?.name;
31
+ bundleId =
32
+ bundleId ||
33
+ cfg?.ios?.bundleIdentifier ||
34
+ cfg?.android?.package;
35
+ } catch {
36
+ // expo-constants not installed (bare RN). Fall through.
37
+ }
38
+ }
39
+
40
+ if (!bundleId) {
41
+ try {
42
+ const { Platform, NativeModules } = require('react-native');
43
+ if (Platform.OS === 'ios') {
44
+ bundleId = NativeModules?.SettingsManager?.settings?.CFBundleIdentifier;
45
+ } else if (Platform.OS === 'android') {
46
+ bundleId = NativeModules?.PlatformConstants?.Package;
47
+ }
48
+ } catch {
49
+ // SettingsManager/PlatformConstants missing on some RN versions.
50
+ }
51
+ }
52
+
53
+ const out: { projectName?: string; bundleId?: string; projectPath?: string } = {};
54
+ if (projectName) out.projectName = projectName;
55
+ if (bundleId) out.bundleId = bundleId;
56
+ if (projectPath) out.projectPath = projectPath;
57
+ return out;
58
+ }
59
+
10
60
  /**
11
61
  * Translate a raw Go-agent error into something a user can act on.
12
62
  *
@@ -256,7 +306,10 @@ export class P2PClient {
256
306
  * via the BlackBox command channel.
257
307
  * @param mode - "dev" for hot reload, "bundle" for native bundle rebuild
258
308
  */
259
- async reloadApp(mode: 'dev' | 'bundle' = 'bundle'): Promise<{ ok: boolean }> {
309
+ async reloadApp(
310
+ mode: 'dev' | 'bundle' = 'bundle',
311
+ opts?: { projectName?: string; bundleId?: string; projectPath?: string },
312
+ ): Promise<{ ok: boolean }> {
260
313
  // Default path: always rebuild a fresh Hermes bundle.
261
314
  //
262
315
  // Rationale: the SDK's common caller is a phone user who's not
@@ -284,13 +337,26 @@ export class P2PClient {
284
337
  // Metro wasn't running.
285
338
  }
286
339
 
340
+ // Auto-resolve identity if the caller didn't pass it. Reads from
341
+ // expo-constants when present (host can pin via app.json
342
+ // `expo.name` / `ios.bundleIdentifier` / `android.package`); falls
343
+ // back to react-native's NativeModules.SettingsManager.settings
344
+ // (iOS `CFBundleIdentifier`, `CFBundleName`) and Application
345
+ // (Android packageName). On the agent side these resolve to the
346
+ // matching MobileProject in the cached scan, so we don't need
347
+ // `yaver dev start` to have run on the host.
348
+ const identity = resolveAppIdentity(opts);
349
+
287
350
  const res = await fetch(`${this.baseUrl}/dev/reload-app`, {
288
351
  method: 'POST',
289
352
  headers: {
290
353
  Authorization: `Bearer ${this.authToken}`,
291
354
  'Content-Type': 'application/json',
292
355
  },
293
- body: JSON.stringify({ mode: 'bundle' }),
356
+ body: JSON.stringify({
357
+ mode: 'bundle',
358
+ ...identity,
359
+ }),
294
360
  });
295
361
  if (!res.ok) {
296
362
  const text = await res.text().catch(() => '');
@@ -167,8 +167,8 @@ export class YaverFeedback {
167
167
  }
168
168
  } else if (cmd.command === 'status') {
169
169
  // Pipe agent progress pings to the UI. The FeedbackModal
170
- // subscribes to this event and renders the message while a
171
- // reload / build is in flight.
170
+ // subscribes to this event and renders the message + a
171
+ // progress bar while a reload / build is in flight.
172
172
  const message =
173
173
  typeof cmd.data?.message === 'string'
174
174
  ? (cmd.data.message as string)
@@ -177,10 +177,15 @@ export class YaverFeedback {
177
177
  typeof cmd.data?.phase === 'string'
178
178
  ? (cmd.data.phase as string)
179
179
  : '';
180
+ const progress =
181
+ typeof cmd.data?.progress === 'number'
182
+ ? Math.max(0, Math.min(1, cmd.data.progress as number))
183
+ : undefined;
180
184
  const { DeviceEventEmitter } = require('react-native');
181
185
  DeviceEventEmitter.emit('yaverFeedback:status', {
182
186
  message,
183
187
  phase,
188
+ progress,
184
189
  at: Date.now(),
185
190
  });
186
191
  }
@@ -0,0 +1,352 @@
1
+ // AUTO-SYNCED from shared/client-core/src/device.ts.
2
+ // DO NOT EDIT IN PLACE. Edit the source and re-run
3
+ // scripts/sync-client-core.sh. CI checks drift via `--check`.
4
+
5
+ /**
6
+ * Device dedup, merging, freshness, and target-picking.
7
+ *
8
+ * Phase-2 extract of the Yaver-mobile logic that lives at
9
+ * mobile/src/context/DeviceContext.tsx:193-413. Ported faithfully —
10
+ * mobile's collapseAliasDevices has been battle-tested through
11
+ * multiple Convex schema changes and the re-pair edge cases; this
12
+ * file is byte-identical in behaviour, only renamed for the shared
13
+ * RemoteDevice shape. The Feedback SDK used to carry its own copy
14
+ * (sdk/feedback/react-native/src/deviceDedup.ts) that drifted on
15
+ * `hwid` strong-identity and `runners`/`local` field preservation.
16
+ *
17
+ * Canonical device shape shared across every surface:
18
+ * - Mobile app: mobile/src/context/DeviceContext.tsx::Device (adapts
19
+ * into this shape at the Convex /devices/list fetch
20
+ * site).
21
+ * - Feedback SDK: src/auth.ts::RemoteDevice re-exports this type.
22
+ * - Web / Desktop: same.
23
+ *
24
+ * The mobile app may have extra view-model fields it wants to keep on
25
+ * its own Device interface (edgeProfile, sessionBinding, etc.) — those
26
+ * stay as local extensions; the core operates only on the fields
27
+ * declared here.
28
+ */
29
+
30
+ import { HEARTBEAT_STALE_MS } from './constants';
31
+
32
+ export interface CoreDevice {
33
+ /** Convex-issued device id. */
34
+ deviceId: string;
35
+ /** Display name — usually hostname. */
36
+ name: string;
37
+ /** OS family — "darwin", "linux", "windows". */
38
+ platform: string;
39
+ isOnline: boolean;
40
+ needsAuth: boolean;
41
+ runnerDown: boolean;
42
+ /** Unix ms of the latest heartbeat the agent sent to Convex. */
43
+ lastHeartbeat: number;
44
+ isGuest: boolean;
45
+ hostName?: string;
46
+ hostEmail?: string;
47
+ accessScope?: 'owner' | 'shared-scoped' | 'shared-legacy';
48
+ /** Primary LAN IP (or tunnel host) the agent advertised. */
49
+ quicHost: string;
50
+ quicPort: number;
51
+ /** HTTP port the agent listens on (usually 18080). */
52
+ httpPort?: number;
53
+ publicKey?: string;
54
+ /** Stable hardware identifier — dedup key for re-pair events. */
55
+ hwid?: string;
56
+ /**
57
+ * Every LAN IP the agent reported in its last heartbeat. Used by
58
+ * Discovery.raceProbe to try all interfaces in parallel.
59
+ */
60
+ localIps?: string[];
61
+ }
62
+
63
+ // ── Normalisers ───────────────────────────────────────────────────────
64
+
65
+ function normName(name: string | undefined): string {
66
+ return String(name || '').trim().toLowerCase().replace(/\.local$/i, '');
67
+ }
68
+
69
+ function normHost(host: string | undefined): string {
70
+ return String(host || '').trim().toLowerCase().replace(/\.local$/i, '');
71
+ }
72
+
73
+ // ── Keys ──────────────────────────────────────────────────────────────
74
+
75
+ export function deviceIdentityKey(d: CoreDevice): string {
76
+ if (d.hwid) return `hwid:${d.hwid}`;
77
+ if (d.publicKey) return `pub:${d.publicKey}`;
78
+ if (d.isGuest) {
79
+ const scope = d.hostEmail || d.hostName || 'guest';
80
+ return `guest:${scope}:${d.deviceId || d.name}`;
81
+ }
82
+ const n = normName(d.name);
83
+ const os = String(d.platform || '').trim().toLowerCase();
84
+ if (n && os) return `host:${os}:${n}`;
85
+ if (d.deviceId) return `id:${d.deviceId}`;
86
+ return `name:${d.name}`;
87
+ }
88
+
89
+ export function deviceAliasKey(d: CoreDevice): string | null {
90
+ if (d.isGuest) return null;
91
+ const n = normName(d.name);
92
+ const os = String(d.platform || '').trim().toLowerCase();
93
+ if (!n || !os) return null;
94
+ return `${os}:${n}`;
95
+ }
96
+
97
+ export function deviceEndpointKey(d: CoreDevice): string | null {
98
+ if (d.isGuest) return null;
99
+ const h = normHost(d.quicHost);
100
+ if (!h) return null;
101
+ return `${h}:${d.quicPort || 0}`;
102
+ }
103
+
104
+ // ── Merge rules ───────────────────────────────────────────────────────
105
+
106
+ export function mergeDeviceEntries(a: CoreDevice, b: CoreDevice): CoreDevice {
107
+ const incomingWins =
108
+ (!!a.needsAuth && !b.needsAuth) ||
109
+ (b.lastHeartbeat || 0) > (a.lastHeartbeat || 0) ||
110
+ (!!b.isOnline && !a.isOnline);
111
+ const base = incomingWins ? b : a;
112
+ const other = incomingWins ? a : b;
113
+ return {
114
+ ...other,
115
+ ...base,
116
+ quicHost: base.quicHost || other.quicHost,
117
+ quicPort: base.quicPort || other.quicPort,
118
+ httpPort: base.httpPort || other.httpPort,
119
+ isOnline: base.isOnline || other.isOnline,
120
+ runnerDown: base.runnerDown && other.runnerDown,
121
+ publicKey: base.publicKey || other.publicKey,
122
+ hwid: base.hwid || other.hwid,
123
+ lastHeartbeat: Math.max(a.lastHeartbeat || 0, b.lastHeartbeat || 0),
124
+ localIps: (() => {
125
+ const set = new Set<string>();
126
+ for (const ip of a.localIps || []) if (ip) set.add(ip);
127
+ for (const ip of b.localIps || []) if (ip) set.add(ip);
128
+ return set.size > 0 ? [...set] : undefined;
129
+ })(),
130
+ };
131
+ }
132
+
133
+ // When two rows share the same alias (hostname + OS) but differ on
134
+ // hwid / publicKey, prefer the authenticated + online row over a
135
+ // stale "needsAuth + offline" leftover. That leftover pattern is
136
+ // what re-pair / wipe-and-reinstall produces on Convex.
137
+ function pickActiveOverStaleNeedsAuth(
138
+ a: CoreDevice,
139
+ b: CoreDevice,
140
+ ): CoreDevice | null {
141
+ const aDead = a.needsAuth && !a.isOnline;
142
+ const bDead = b.needsAuth && !b.isOnline;
143
+ const aLive = !a.needsAuth && a.isOnline;
144
+ const bLive = !b.needsAuth && b.isOnline;
145
+ if (aDead && bLive) return b;
146
+ if (bDead && aLive) return a;
147
+ return null;
148
+ }
149
+
150
+ // ── Collapse (three-pass dedup) ───────────────────────────────────────
151
+
152
+ /**
153
+ * Collapse duplicate Convex rows so each physical machine appears
154
+ * exactly once. Three passes — identity key → alias key → endpoint key.
155
+ * Safe on empty lists; idempotent on already-deduped input.
156
+ */
157
+ export function collapseDevices(devices: CoreDevice[]): CoreDevice[] {
158
+ if (!Array.isArray(devices) || devices.length === 0) return [];
159
+
160
+ // Pass 1: identity key (hwid / publicKey / name+os).
161
+ const byIdentity = new Map<string, CoreDevice>();
162
+ for (const d of devices) {
163
+ const k = deviceIdentityKey(d);
164
+ const prev = byIdentity.get(k);
165
+ byIdentity.set(k, prev ? mergeDeviceEntries(prev, d) : d);
166
+ }
167
+
168
+ // Pass 2: alias key (os + normalised hostname), with strong-identity
169
+ // conflict resolution so two genuinely-different machines sharing a
170
+ // hostname don't silently merge.
171
+ const byAlias = new Map<string, CoreDevice>();
172
+ for (const d of byIdentity.values()) {
173
+ const k = deviceAliasKey(d);
174
+ if (!k) {
175
+ byAlias.set(`id:${d.deviceId}`, d);
176
+ continue;
177
+ }
178
+ const prev = byAlias.get(k);
179
+ if (!prev) {
180
+ byAlias.set(k, d);
181
+ continue;
182
+ }
183
+ const strongConflict =
184
+ (!!prev.hwid && !!d.hwid && prev.hwid !== d.hwid) ||
185
+ (!!prev.publicKey && !!d.publicKey && prev.publicKey !== d.publicKey);
186
+ if (strongConflict) {
187
+ const winner = pickActiveOverStaleNeedsAuth(prev, d);
188
+ if (winner) {
189
+ byAlias.set(k, winner);
190
+ continue;
191
+ }
192
+ }
193
+ byAlias.set(k, mergeDeviceEntries(prev, d));
194
+ }
195
+
196
+ // Pass 3: endpoint key (host:port) — last-chance dedup for rows
197
+ // that share a LAN address but slipped through identity + alias.
198
+ const byEndpoint = new Map<string, CoreDevice>();
199
+ for (const d of byAlias.values()) {
200
+ const k = deviceEndpointKey(d);
201
+ if (!k) {
202
+ byEndpoint.set(`id:${d.deviceId}`, d);
203
+ continue;
204
+ }
205
+ const prev = byEndpoint.get(k);
206
+ byEndpoint.set(k, prev ? mergeDeviceEntries(prev, d) : d);
207
+ }
208
+
209
+ return [...byEndpoint.values()];
210
+ }
211
+
212
+ // ── Freshness + target pick ───────────────────────────────────────────
213
+
214
+ /**
215
+ * "Fresh" matches the mobile app: online + heartbeat < 90 s. Clients
216
+ * read Convex's `isOnline` first (backend already applies its own 90 s
217
+ * gate from the server clock), then use this helper when they need the
218
+ * phone-side freshness opinion too — e.g. for auto-connect picks.
219
+ */
220
+ export function isDeviceFresh(d: CoreDevice, now = Date.now()): boolean {
221
+ if (!d.isOnline) return false;
222
+ if (!d.lastHeartbeat) return true;
223
+ return now - d.lastHeartbeat < HEARTBEAT_STALE_MS;
224
+ }
225
+
226
+ /**
227
+ * Choose the best candidate for an auto-connect attempt. Preference:
228
+ * 1. explicit `preferredDeviceId` that's still fresh
229
+ * 2. fresh (online + recent heartbeat) + has a quicHost
230
+ * 3. online + has a quicHost
231
+ * 4. first with a quicHost
232
+ */
233
+ export function pickTargetDevice(
234
+ devices: CoreDevice[],
235
+ preferredDeviceId?: string,
236
+ ): CoreDevice | null {
237
+ if (!devices.length) return null;
238
+ if (preferredDeviceId) {
239
+ const preferred = devices.find(
240
+ (d) => d.deviceId === preferredDeviceId && d.quicHost,
241
+ );
242
+ if (preferred && isDeviceFresh(preferred)) return preferred;
243
+ if (preferred) return preferred;
244
+ }
245
+ const fresh = devices.find((d) => isDeviceFresh(d) && d.quicHost);
246
+ if (fresh) return fresh;
247
+ const online = devices.find((d) => d.isOnline && d.quicHost);
248
+ if (online) return online;
249
+ return devices.find((d) => d.quicHost) || devices[0] || null;
250
+ }
251
+
252
+ // ── Probe candidate assembly ──────────────────────────────────────────
253
+
254
+ /**
255
+ * Build the set of `/health` candidate URLs for a target device —
256
+ * `quicHost` plus every LAN IP the agent reported in `localIps`,
257
+ * uniqued and formatted. This is the thing that makes direct LAN
258
+ * reloads "just work" on multi-homed hosts (en0 + utun tailscale +
259
+ * docker0 etc.) — the mobile app races them in parallel via
260
+ * Promise.any.
261
+ */
262
+ export function buildProbeCandidates(
263
+ target: CoreDevice,
264
+ defaultHttpPort = 18080,
265
+ ): string[] {
266
+ const port = target.httpPort ?? target.quicPort ?? defaultHttpPort;
267
+ const ips = new Set<string>();
268
+ if (target.quicHost) ips.add(target.quicHost);
269
+ for (const ip of target.localIps ?? []) {
270
+ if (ip) ips.add(ip);
271
+ }
272
+ return [...ips].map((ip) => `http://${ip}:${port}`);
273
+ }
274
+
275
+ // ── Parallel /health race (Promise.any polyfill baked in) ─────────────
276
+
277
+ export interface ProbeResult {
278
+ url: string;
279
+ hostname?: string;
280
+ version?: string;
281
+ latency?: number;
282
+ }
283
+
284
+ /**
285
+ * Race `/health` probes across N URLs. First 200 wins; everything else
286
+ * is abandoned. Older Hermes doesn't have Promise.any, so we hand-roll
287
+ * the same semantic.
288
+ */
289
+ export async function raceHealthProbes(
290
+ urls: string[],
291
+ opts: { timeoutMs?: number; headers?: Record<string, string> } = {},
292
+ ): Promise<ProbeResult | null> {
293
+ if (!urls || urls.length === 0) return null;
294
+ const timeoutMs = opts.timeoutMs ?? 2500;
295
+
296
+ const probeOne = async (url: string): Promise<ProbeResult | null> => {
297
+ const base = url.replace(/\/$/, '');
298
+ const start = Date.now();
299
+ try {
300
+ const controller = new AbortController();
301
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
302
+ const res = await fetch(`${base}/health`, {
303
+ method: 'GET',
304
+ headers: opts.headers,
305
+ signal: controller.signal,
306
+ });
307
+ clearTimeout(timer);
308
+ if (!res.ok) return null;
309
+ const latency = Date.now() - start;
310
+ let hostname: string | undefined;
311
+ let version: string | undefined;
312
+ try {
313
+ const data = await res.json();
314
+ hostname = data?.hostname ?? data?.name;
315
+ version = data?.version;
316
+ } catch {
317
+ // /health may return plain text
318
+ }
319
+ return { url: base, hostname, version, latency };
320
+ } catch {
321
+ return null;
322
+ }
323
+ };
324
+
325
+ return new Promise<ProbeResult | null>((resolve) => {
326
+ let remaining = urls.length;
327
+ let settled = false;
328
+ for (const url of urls) {
329
+ probeOne(url)
330
+ .then((r) => {
331
+ if (settled) return;
332
+ if (r) {
333
+ settled = true;
334
+ resolve(r);
335
+ return;
336
+ }
337
+ remaining -= 1;
338
+ if (remaining <= 0 && !settled) {
339
+ settled = true;
340
+ resolve(null);
341
+ }
342
+ })
343
+ .catch(() => {
344
+ remaining -= 1;
345
+ if (remaining <= 0 && !settled) {
346
+ settled = true;
347
+ resolve(null);
348
+ }
349
+ });
350
+ }
351
+ });
352
+ }
@@ -17,3 +17,4 @@
17
17
 
18
18
  export * from './constants';
19
19
  export * from './endpoints';
20
+ export * from './device';