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.
@@ -0,0 +1,310 @@
1
+ "use strict";
2
+ // AUTO-SYNCED from shared/client-core/src/device.ts.
3
+ // DO NOT EDIT IN PLACE. Edit the source and re-run
4
+ // scripts/sync-client-core.sh. CI checks drift via `--check`.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.deviceIdentityKey = deviceIdentityKey;
7
+ exports.deviceAliasKey = deviceAliasKey;
8
+ exports.deviceEndpointKey = deviceEndpointKey;
9
+ exports.mergeDeviceEntries = mergeDeviceEntries;
10
+ exports.collapseDevices = collapseDevices;
11
+ exports.isDeviceFresh = isDeviceFresh;
12
+ exports.pickTargetDevice = pickTargetDevice;
13
+ exports.buildProbeCandidates = buildProbeCandidates;
14
+ exports.raceHealthProbes = raceHealthProbes;
15
+ /**
16
+ * Device dedup, merging, freshness, and target-picking.
17
+ *
18
+ * Phase-2 extract of the Yaver-mobile logic that lives at
19
+ * mobile/src/context/DeviceContext.tsx:193-413. Ported faithfully —
20
+ * mobile's collapseAliasDevices has been battle-tested through
21
+ * multiple Convex schema changes and the re-pair edge cases; this
22
+ * file is byte-identical in behaviour, only renamed for the shared
23
+ * RemoteDevice shape. The Feedback SDK used to carry its own copy
24
+ * (sdk/feedback/react-native/src/deviceDedup.ts) that drifted on
25
+ * `hwid` strong-identity and `runners`/`local` field preservation.
26
+ *
27
+ * Canonical device shape shared across every surface:
28
+ * - Mobile app: mobile/src/context/DeviceContext.tsx::Device (adapts
29
+ * into this shape at the Convex /devices/list fetch
30
+ * site).
31
+ * - Feedback SDK: src/auth.ts::RemoteDevice re-exports this type.
32
+ * - Web / Desktop: same.
33
+ *
34
+ * The mobile app may have extra view-model fields it wants to keep on
35
+ * its own Device interface (edgeProfile, sessionBinding, etc.) — those
36
+ * stay as local extensions; the core operates only on the fields
37
+ * declared here.
38
+ */
39
+ const constants_1 = require("./constants");
40
+ // ── Normalisers ───────────────────────────────────────────────────────
41
+ function normName(name) {
42
+ return String(name || '').trim().toLowerCase().replace(/\.local$/i, '');
43
+ }
44
+ function normHost(host) {
45
+ return String(host || '').trim().toLowerCase().replace(/\.local$/i, '');
46
+ }
47
+ // ── Keys ──────────────────────────────────────────────────────────────
48
+ function deviceIdentityKey(d) {
49
+ if (d.hwid)
50
+ return `hwid:${d.hwid}`;
51
+ if (d.publicKey)
52
+ return `pub:${d.publicKey}`;
53
+ if (d.isGuest) {
54
+ const scope = d.hostEmail || d.hostName || 'guest';
55
+ return `guest:${scope}:${d.deviceId || d.name}`;
56
+ }
57
+ const n = normName(d.name);
58
+ const os = String(d.platform || '').trim().toLowerCase();
59
+ if (n && os)
60
+ return `host:${os}:${n}`;
61
+ if (d.deviceId)
62
+ return `id:${d.deviceId}`;
63
+ return `name:${d.name}`;
64
+ }
65
+ function deviceAliasKey(d) {
66
+ if (d.isGuest)
67
+ return null;
68
+ const n = normName(d.name);
69
+ const os = String(d.platform || '').trim().toLowerCase();
70
+ if (!n || !os)
71
+ return null;
72
+ return `${os}:${n}`;
73
+ }
74
+ function deviceEndpointKey(d) {
75
+ if (d.isGuest)
76
+ return null;
77
+ const h = normHost(d.quicHost);
78
+ if (!h)
79
+ return null;
80
+ return `${h}:${d.quicPort || 0}`;
81
+ }
82
+ // ── Merge rules ───────────────────────────────────────────────────────
83
+ function mergeDeviceEntries(a, b) {
84
+ const incomingWins = (!!a.needsAuth && !b.needsAuth) ||
85
+ (b.lastHeartbeat || 0) > (a.lastHeartbeat || 0) ||
86
+ (!!b.isOnline && !a.isOnline);
87
+ const base = incomingWins ? b : a;
88
+ const other = incomingWins ? a : b;
89
+ return {
90
+ ...other,
91
+ ...base,
92
+ quicHost: base.quicHost || other.quicHost,
93
+ quicPort: base.quicPort || other.quicPort,
94
+ httpPort: base.httpPort || other.httpPort,
95
+ isOnline: base.isOnline || other.isOnline,
96
+ runnerDown: base.runnerDown && other.runnerDown,
97
+ publicKey: base.publicKey || other.publicKey,
98
+ hwid: base.hwid || other.hwid,
99
+ lastHeartbeat: Math.max(a.lastHeartbeat || 0, b.lastHeartbeat || 0),
100
+ localIps: (() => {
101
+ const set = new Set();
102
+ for (const ip of a.localIps || [])
103
+ if (ip)
104
+ set.add(ip);
105
+ for (const ip of b.localIps || [])
106
+ if (ip)
107
+ set.add(ip);
108
+ return set.size > 0 ? [...set] : undefined;
109
+ })(),
110
+ };
111
+ }
112
+ // When two rows share the same alias (hostname + OS) but differ on
113
+ // hwid / publicKey, prefer the authenticated + online row over a
114
+ // stale "needsAuth + offline" leftover. That leftover pattern is
115
+ // what re-pair / wipe-and-reinstall produces on Convex.
116
+ function pickActiveOverStaleNeedsAuth(a, b) {
117
+ const aDead = a.needsAuth && !a.isOnline;
118
+ const bDead = b.needsAuth && !b.isOnline;
119
+ const aLive = !a.needsAuth && a.isOnline;
120
+ const bLive = !b.needsAuth && b.isOnline;
121
+ if (aDead && bLive)
122
+ return b;
123
+ if (bDead && aLive)
124
+ return a;
125
+ return null;
126
+ }
127
+ // ── Collapse (three-pass dedup) ───────────────────────────────────────
128
+ /**
129
+ * Collapse duplicate Convex rows so each physical machine appears
130
+ * exactly once. Three passes — identity key → alias key → endpoint key.
131
+ * Safe on empty lists; idempotent on already-deduped input.
132
+ */
133
+ function collapseDevices(devices) {
134
+ if (!Array.isArray(devices) || devices.length === 0)
135
+ return [];
136
+ // Pass 1: identity key (hwid / publicKey / name+os).
137
+ const byIdentity = new Map();
138
+ for (const d of devices) {
139
+ const k = deviceIdentityKey(d);
140
+ const prev = byIdentity.get(k);
141
+ byIdentity.set(k, prev ? mergeDeviceEntries(prev, d) : d);
142
+ }
143
+ // Pass 2: alias key (os + normalised hostname), with strong-identity
144
+ // conflict resolution so two genuinely-different machines sharing a
145
+ // hostname don't silently merge.
146
+ const byAlias = new Map();
147
+ for (const d of byIdentity.values()) {
148
+ const k = deviceAliasKey(d);
149
+ if (!k) {
150
+ byAlias.set(`id:${d.deviceId}`, d);
151
+ continue;
152
+ }
153
+ const prev = byAlias.get(k);
154
+ if (!prev) {
155
+ byAlias.set(k, d);
156
+ continue;
157
+ }
158
+ const strongConflict = (!!prev.hwid && !!d.hwid && prev.hwid !== d.hwid) ||
159
+ (!!prev.publicKey && !!d.publicKey && prev.publicKey !== d.publicKey);
160
+ if (strongConflict) {
161
+ const winner = pickActiveOverStaleNeedsAuth(prev, d);
162
+ if (winner) {
163
+ byAlias.set(k, winner);
164
+ continue;
165
+ }
166
+ }
167
+ byAlias.set(k, mergeDeviceEntries(prev, d));
168
+ }
169
+ // Pass 3: endpoint key (host:port) — last-chance dedup for rows
170
+ // that share a LAN address but slipped through identity + alias.
171
+ const byEndpoint = new Map();
172
+ for (const d of byAlias.values()) {
173
+ const k = deviceEndpointKey(d);
174
+ if (!k) {
175
+ byEndpoint.set(`id:${d.deviceId}`, d);
176
+ continue;
177
+ }
178
+ const prev = byEndpoint.get(k);
179
+ byEndpoint.set(k, prev ? mergeDeviceEntries(prev, d) : d);
180
+ }
181
+ return [...byEndpoint.values()];
182
+ }
183
+ // ── Freshness + target pick ───────────────────────────────────────────
184
+ /**
185
+ * "Fresh" matches the mobile app: online + heartbeat < 90 s. Clients
186
+ * read Convex's `isOnline` first (backend already applies its own 90 s
187
+ * gate from the server clock), then use this helper when they need the
188
+ * phone-side freshness opinion too — e.g. for auto-connect picks.
189
+ */
190
+ function isDeviceFresh(d, now = Date.now()) {
191
+ if (!d.isOnline)
192
+ return false;
193
+ if (!d.lastHeartbeat)
194
+ return true;
195
+ return now - d.lastHeartbeat < constants_1.HEARTBEAT_STALE_MS;
196
+ }
197
+ /**
198
+ * Choose the best candidate for an auto-connect attempt. Preference:
199
+ * 1. explicit `preferredDeviceId` that's still fresh
200
+ * 2. fresh (online + recent heartbeat) + has a quicHost
201
+ * 3. online + has a quicHost
202
+ * 4. first with a quicHost
203
+ */
204
+ function pickTargetDevice(devices, preferredDeviceId) {
205
+ if (!devices.length)
206
+ return null;
207
+ if (preferredDeviceId) {
208
+ const preferred = devices.find((d) => d.deviceId === preferredDeviceId && d.quicHost);
209
+ if (preferred && isDeviceFresh(preferred))
210
+ return preferred;
211
+ if (preferred)
212
+ return preferred;
213
+ }
214
+ const fresh = devices.find((d) => isDeviceFresh(d) && d.quicHost);
215
+ if (fresh)
216
+ return fresh;
217
+ const online = devices.find((d) => d.isOnline && d.quicHost);
218
+ if (online)
219
+ return online;
220
+ return devices.find((d) => d.quicHost) || devices[0] || null;
221
+ }
222
+ // ── Probe candidate assembly ──────────────────────────────────────────
223
+ /**
224
+ * Build the set of `/health` candidate URLs for a target device —
225
+ * `quicHost` plus every LAN IP the agent reported in `localIps`,
226
+ * uniqued and formatted. This is the thing that makes direct LAN
227
+ * reloads "just work" on multi-homed hosts (en0 + utun tailscale +
228
+ * docker0 etc.) — the mobile app races them in parallel via
229
+ * Promise.any.
230
+ */
231
+ function buildProbeCandidates(target, defaultHttpPort = 18080) {
232
+ const port = target.httpPort ?? target.quicPort ?? defaultHttpPort;
233
+ const ips = new Set();
234
+ if (target.quicHost)
235
+ ips.add(target.quicHost);
236
+ for (const ip of target.localIps ?? []) {
237
+ if (ip)
238
+ ips.add(ip);
239
+ }
240
+ return [...ips].map((ip) => `http://${ip}:${port}`);
241
+ }
242
+ /**
243
+ * Race `/health` probes across N URLs. First 200 wins; everything else
244
+ * is abandoned. Older Hermes doesn't have Promise.any, so we hand-roll
245
+ * the same semantic.
246
+ */
247
+ async function raceHealthProbes(urls, opts = {}) {
248
+ if (!urls || urls.length === 0)
249
+ return null;
250
+ const timeoutMs = opts.timeoutMs ?? 2500;
251
+ const probeOne = async (url) => {
252
+ const base = url.replace(/\/$/, '');
253
+ const start = Date.now();
254
+ try {
255
+ const controller = new AbortController();
256
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
257
+ const res = await fetch(`${base}/health`, {
258
+ method: 'GET',
259
+ headers: opts.headers,
260
+ signal: controller.signal,
261
+ });
262
+ clearTimeout(timer);
263
+ if (!res.ok)
264
+ return null;
265
+ const latency = Date.now() - start;
266
+ let hostname;
267
+ let version;
268
+ try {
269
+ const data = await res.json();
270
+ hostname = data?.hostname ?? data?.name;
271
+ version = data?.version;
272
+ }
273
+ catch {
274
+ // /health may return plain text
275
+ }
276
+ return { url: base, hostname, version, latency };
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ };
282
+ return new Promise((resolve) => {
283
+ let remaining = urls.length;
284
+ let settled = false;
285
+ for (const url of urls) {
286
+ probeOne(url)
287
+ .then((r) => {
288
+ if (settled)
289
+ return;
290
+ if (r) {
291
+ settled = true;
292
+ resolve(r);
293
+ return;
294
+ }
295
+ remaining -= 1;
296
+ if (remaining <= 0 && !settled) {
297
+ settled = true;
298
+ resolve(null);
299
+ }
300
+ })
301
+ .catch(() => {
302
+ remaining -= 1;
303
+ if (remaining <= 0 && !settled) {
304
+ settled = true;
305
+ resolve(null);
306
+ }
307
+ });
308
+ }
309
+ });
310
+ }
@@ -12,3 +12,4 @@
12
12
  */
13
13
  export * from './constants';
14
14
  export * from './endpoints';
15
+ export * from './device';
@@ -31,3 +31,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
31
31
  */
32
32
  __exportStar(require("./constants"), exports);
33
33
  __exportStar(require("./endpoints"), exports);
34
+ __exportStar(require("./device"), exports);
@@ -1,45 +1,21 @@
1
1
  /**
2
- * Device deduplication + online-signal merging, ported from the Yaver
3
- * mobile app's DeviceContext.collapseAliasDevices.
2
+ * Re-export the canonical dedup / freshness / race-probe helpers from
3
+ * @yaver/client-core (mirrored into `./_core/`).
4
4
  *
5
- * Convex stores one row per pair (device, re-install). After a re-pair
6
- * or hostname change the list can contain 2-3 rows for the same
7
- * physical machine, with different hwid/publicKey values. The picker
8
- * then shows duplicates and the user can't tell which one is live.
5
+ * The SDK previously carried its own ~180-line copy of
6
+ * `collapseAliasDevices` ported by hand from mobile's
7
+ * DeviceContext.tsx. The copies drifted on `hwid` strong-identity,
8
+ * `runners` / `local` field preservation, and a few other subtle
9
+ * merge rules. Canonicalising via client-core eliminates that drift
10
+ * class — mobile's test case automatically covers the SDK's dedup
11
+ * behaviour too.
9
12
  *
10
- * This file collapses rows in three passes:
11
- * 1. Identity key (hwid publicKey "host:os:name" → id → name)
12
- * 2. Alias key (os + normalized-hostname) catches re-pairs that
13
- * mint a new hwid
14
- * 3. Endpoint key (host:port)
15
- *
16
- * When collapsing, `mergeDeviceEntries` prefers: authenticated over
17
- * needsAuth, online over offline, freshest lastHeartbeat.
13
+ * The shared module operates on a `CoreDevice` shape. The SDK's
14
+ * `RemoteDevice` is structurally compatible (same field names, same
15
+ * types), so the casts here are a no-op at runtime.
18
16
  */
19
17
  import type { RemoteDevice } from './auth';
20
- /**
21
- * Collapse a Convex device list so each physical machine appears once.
22
- * Safe on an empty list; idempotent on an already-deduped list.
23
- */
18
+ export { HEARTBEAT_STALE_MS } from './_core/constants';
24
19
  export declare function collapseRemoteDevices(devices: RemoteDevice[]): RemoteDevice[];
25
- /**
26
- * Threshold for "online" based on heartbeat age, in milliseconds.
27
- * Re-exported from `@yaver/client-core` so mobile + SDK + backend
28
- * agree on one number at all times.
29
- */
30
- import { HEARTBEAT_STALE_MS } from './_core/constants';
31
- export { HEARTBEAT_STALE_MS };
32
- /**
33
- * Returns a freshness flag consistent with the mobile app. A device is
34
- * "fresh" when it was online per Convex AND its heartbeat is within
35
- * `HEARTBEAT_STALE_MS`.
36
- */
37
20
  export declare function isDeviceFresh(d: RemoteDevice): boolean;
38
- /**
39
- * Pick the best candidate for an auto-connect attempt. Preference:
40
- * 1. matches the preferred deviceId when supplied + still fresh
41
- * 2. fresh (online + recent heartbeat) + has a quicHost
42
- * 3. online + has a quicHost
43
- * 4. first with a quicHost
44
- */
45
21
  export declare function pickTargetDevice(devices: RemoteDevice[], preferredDeviceId?: string): RemoteDevice | null;
@@ -1,185 +1,35 @@
1
1
  "use strict";
2
2
  /**
3
- * Device deduplication + online-signal merging, ported from the Yaver
4
- * mobile app's DeviceContext.collapseAliasDevices.
3
+ * Re-export the canonical dedup / freshness / race-probe helpers from
4
+ * @yaver/client-core (mirrored into `./_core/`).
5
5
  *
6
- * Convex stores one row per pair (device, re-install). After a re-pair
7
- * or hostname change the list can contain 2-3 rows for the same
8
- * physical machine, with different hwid/publicKey values. The picker
9
- * then shows duplicates and the user can't tell which one is live.
6
+ * The SDK previously carried its own ~180-line copy of
7
+ * `collapseAliasDevices` ported by hand from mobile's
8
+ * DeviceContext.tsx. The copies drifted on `hwid` strong-identity,
9
+ * `runners` / `local` field preservation, and a few other subtle
10
+ * merge rules. Canonicalising via client-core eliminates that drift
11
+ * class — mobile's test case automatically covers the SDK's dedup
12
+ * behaviour too.
10
13
  *
11
- * This file collapses rows in three passes:
12
- * 1. Identity key (hwid publicKey "host:os:name" → id → name)
13
- * 2. Alias key (os + normalized-hostname) catches re-pairs that
14
- * mint a new hwid
15
- * 3. Endpoint key (host:port)
16
- *
17
- * When collapsing, `mergeDeviceEntries` prefers: authenticated over
18
- * needsAuth, online over offline, freshest lastHeartbeat.
14
+ * The shared module operates on a `CoreDevice` shape. The SDK's
15
+ * `RemoteDevice` is structurally compatible (same field names, same
16
+ * types), so the casts here are a no-op at runtime.
19
17
  */
20
18
  Object.defineProperty(exports, "__esModule", { value: true });
21
19
  exports.HEARTBEAT_STALE_MS = void 0;
22
20
  exports.collapseRemoteDevices = collapseRemoteDevices;
23
21
  exports.isDeviceFresh = isDeviceFresh;
24
22
  exports.pickTargetDevice = pickTargetDevice;
25
- function normalizedName(name) {
26
- return String(name || '').trim().toLowerCase().replace(/\.local$/i, '');
27
- }
28
- function normalizedHost(host) {
29
- return String(host || '').trim().toLowerCase().replace(/\.local$/i, '');
30
- }
31
- function identityKey(d) {
32
- if (d.hwid)
33
- return `hwid:${d.hwid}`;
34
- if (d.publicKey)
35
- return `pub:${d.publicKey}`;
36
- if (d.isGuest) {
37
- const scope = d.hostEmail || d.hostName || 'guest';
38
- return `guest:${scope}:${d.deviceId || d.name}`;
39
- }
40
- const n = normalizedName(d.name);
41
- const os = String(d.platform || '').trim().toLowerCase();
42
- if (n && os)
43
- return `host:${os}:${n}`;
44
- if (d.deviceId)
45
- return `id:${d.deviceId}`;
46
- return `name:${d.name}`;
47
- }
48
- function aliasKey(d) {
49
- if (d.isGuest)
50
- return null;
51
- const n = normalizedName(d.name);
52
- const os = String(d.platform || '').trim().toLowerCase();
53
- if (!n || !os)
54
- return null;
55
- return `${os}:${n}`;
56
- }
57
- function endpointKey(d) {
58
- if (d.isGuest)
59
- return null;
60
- const h = normalizedHost(d.quicHost);
61
- if (!h)
62
- return null;
63
- return `${h}:${d.quicPort || 0}`;
64
- }
65
- function mergeEntries(existing, incoming) {
66
- const incomingWins = (!!existing.needsAuth && !incoming.needsAuth) ||
67
- (incoming.lastHeartbeat || 0) > (existing.lastHeartbeat || 0) ||
68
- (!!incoming.isOnline && !existing.isOnline);
69
- const base = incomingWins ? incoming : existing;
70
- const other = incomingWins ? existing : incoming;
71
- return {
72
- ...other,
73
- ...base,
74
- quicHost: base.quicHost || other.quicHost,
75
- quicPort: base.quicPort || other.quicPort,
76
- isOnline: base.isOnline || other.isOnline,
77
- runnerDown: base.runnerDown && other.runnerDown,
78
- publicKey: base.publicKey || other.publicKey,
79
- lastHeartbeat: Math.max(existing.lastHeartbeat || 0, incoming.lastHeartbeat || 0),
80
- };
81
- }
82
- // When two rows share the same alias key (hostname + OS) but differ on
83
- // hwid/publicKey, pick the active one over the stale needs-auth leftover.
84
- function pickActiveOverStaleNeedsAuth(a, b) {
85
- const aDead = a.needsAuth && !a.isOnline;
86
- const bDead = b.needsAuth && !b.isOnline;
87
- const aLive = !a.needsAuth && a.isOnline;
88
- const bLive = !b.needsAuth && b.isOnline;
89
- if (aDead && bLive)
90
- return b;
91
- if (bDead && aLive)
92
- return a;
93
- return null;
94
- }
95
- /**
96
- * Collapse a Convex device list so each physical machine appears once.
97
- * Safe on an empty list; idempotent on an already-deduped list.
98
- */
23
+ const device_1 = require("./_core/device");
24
+ var constants_1 = require("./_core/constants");
25
+ Object.defineProperty(exports, "HEARTBEAT_STALE_MS", { enumerable: true, get: function () { return constants_1.HEARTBEAT_STALE_MS; } });
99
26
  function collapseRemoteDevices(devices) {
100
- if (!Array.isArray(devices) || devices.length === 0)
101
- return [];
102
- const byIdentity = new Map();
103
- for (const d of devices) {
104
- const k = identityKey(d);
105
- const prev = byIdentity.get(k);
106
- byIdentity.set(k, prev ? mergeEntries(prev, d) : d);
107
- }
108
- const byAlias = new Map();
109
- for (const d of byIdentity.values()) {
110
- const k = aliasKey(d);
111
- if (!k) {
112
- byAlias.set(`id:${d.deviceId}`, d);
113
- continue;
114
- }
115
- const prev = byAlias.get(k);
116
- if (!prev) {
117
- byAlias.set(k, d);
118
- continue;
119
- }
120
- const strongIdentityConflict = !!prev.publicKey && !!d.publicKey && prev.publicKey !== d.publicKey;
121
- if (strongIdentityConflict) {
122
- const winner = pickActiveOverStaleNeedsAuth(prev, d);
123
- if (winner) {
124
- byAlias.set(k, winner);
125
- continue;
126
- }
127
- }
128
- byAlias.set(k, mergeEntries(prev, d));
129
- }
130
- const byEndpoint = new Map();
131
- for (const d of byAlias.values()) {
132
- const k = endpointKey(d);
133
- if (!k) {
134
- byEndpoint.set(`id:${d.deviceId}`, d);
135
- continue;
136
- }
137
- const prev = byEndpoint.get(k);
138
- byEndpoint.set(k, prev ? mergeEntries(prev, d) : d);
139
- }
140
- return [...byEndpoint.values()];
27
+ return (0, device_1.collapseDevices)(devices);
141
28
  }
142
- /**
143
- * Threshold for "online" based on heartbeat age, in milliseconds.
144
- * Re-exported from `@yaver/client-core` so mobile + SDK + backend
145
- * agree on one number at all times.
146
- */
147
- const constants_1 = require("./_core/constants");
148
- Object.defineProperty(exports, "HEARTBEAT_STALE_MS", { enumerable: true, get: function () { return constants_1.HEARTBEAT_STALE_MS; } });
149
- /**
150
- * Returns a freshness flag consistent with the mobile app. A device is
151
- * "fresh" when it was online per Convex AND its heartbeat is within
152
- * `HEARTBEAT_STALE_MS`.
153
- */
154
29
  function isDeviceFresh(d) {
155
- if (!d.isOnline)
156
- return false;
157
- if (!d.lastHeartbeat)
158
- return true;
159
- return Date.now() - d.lastHeartbeat < constants_1.HEARTBEAT_STALE_MS;
30
+ return (0, device_1.isDeviceFresh)(d);
160
31
  }
161
- /**
162
- * Pick the best candidate for an auto-connect attempt. Preference:
163
- * 1. matches the preferred deviceId when supplied + still fresh
164
- * 2. fresh (online + recent heartbeat) + has a quicHost
165
- * 3. online + has a quicHost
166
- * 4. first with a quicHost
167
- */
168
32
  function pickTargetDevice(devices, preferredDeviceId) {
169
- if (!devices.length)
170
- return null;
171
- if (preferredDeviceId) {
172
- const preferred = devices.find((d) => d.deviceId === preferredDeviceId && d.quicHost);
173
- if (preferred && isDeviceFresh(preferred))
174
- return preferred;
175
- if (preferred)
176
- return preferred;
177
- }
178
- const fresh = devices.find((d) => isDeviceFresh(d) && d.quicHost);
179
- if (fresh)
180
- return fresh;
181
- const online = devices.find((d) => d.isOnline && d.quicHost);
182
- if (online)
183
- return online;
184
- return devices.find((d) => d.quicHost) || devices[0] || null;
33
+ const pick = (0, device_1.pickTargetDevice)(devices, preferredDeviceId);
34
+ return (pick ?? null);
185
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Visual feedback SDK for Yaver — bug reports, screen recording, voice annotations, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/Discovery.ts CHANGED
@@ -256,36 +256,19 @@ export class YaverDiscovery {
256
256
  * does on same-LAN.
257
257
  */
258
258
  static async raceProbe(urls: string[]): Promise<DiscoveryResult | null> {
259
- if (!urls || urls.length === 0) return null;
260
- const attempts = urls.map((url) =>
261
- YaverDiscovery.probe(url).then((r) => {
262
- if (!r) throw new Error('no-200');
263
- return r;
264
- }),
265
- );
266
- try {
267
- // `Promise.any` isn't on every RN runtime yet (older Hermes).
268
- // Hand-roll the same behaviour so we don't need a polyfill.
269
- return await new Promise<DiscoveryResult | null>((resolve) => {
270
- let remaining = attempts.length;
271
- let settled = false;
272
- attempts.forEach((p) => {
273
- p.then((r) => {
274
- if (settled) return;
275
- settled = true;
276
- resolve(r);
277
- }).catch(() => {
278
- remaining -= 1;
279
- if (remaining <= 0 && !settled) {
280
- settled = true;
281
- resolve(null);
282
- }
283
- });
284
- });
285
- });
286
- } catch {
287
- return null;
288
- }
259
+ // Delegate to the canonical client-core implementation so mobile +
260
+ // SDK run the same race logic. Returned shape is `ProbeResult` —
261
+ // structurally compatible with DiscoveryResult minus the required
262
+ // hostname/version fields, which we backfill with sane defaults.
263
+ const { raceHealthProbes } = await import('./_core/device');
264
+ const res = await raceHealthProbes(urls, { timeoutMs: PROBE_TIMEOUT_MS });
265
+ if (!res) return null;
266
+ return {
267
+ url: res.url,
268
+ hostname: res.hostname ?? 'Unknown',
269
+ version: res.version ?? 'unknown',
270
+ latency: res.latency ?? 0,
271
+ };
289
272
  }
290
273
 
291
274
  /**