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.
- package/dist/Discovery.js +46 -31
- package/dist/FeedbackModal.js +41 -3
- package/dist/P2PClient.d.ts +5 -1
- package/dist/P2PClient.js +62 -2
- package/dist/YaverFeedback.js +6 -2
- package/dist/_core/device.d.ts +79 -0
- package/dist/_core/device.js +310 -0
- package/dist/_core/index.d.ts +1 -0
- package/dist/_core/index.js +1 -0
- package/dist/deviceDedup.d.ts +13 -37
- package/dist/deviceDedup.js +19 -169
- package/package.json +1 -1
- package/src/Discovery.ts +13 -30
- package/src/FeedbackModal.tsx +46 -4
- package/src/P2PClient.ts +68 -2
- package/src/YaverFeedback.ts +7 -2
- package/src/_core/device.ts +352 -0
- package/src/_core/index.ts +1 -0
- package/src/deviceDedup.ts +26 -166
package/src/FeedbackModal.tsx
CHANGED
|
@@ -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('
|
|
175
|
+
await client.reloadApp('bundle');
|
|
160
176
|
});
|
|
161
|
-
|
|
162
|
-
|
|
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(
|
|
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({
|
|
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(() => '');
|
package/src/YaverFeedback.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/_core/index.ts
CHANGED