yaver-feedback-react-native 0.7.7 → 0.7.9
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/MachinePickerScreen.js +18 -0
- package/dist/PairDeviceModal.d.ts +28 -0
- package/dist/PairDeviceModal.js +198 -0
- 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/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/package.json +1 -1
- package/src/Discovery.ts +13 -30
- package/src/MachinePickerScreen.tsx +22 -0
- package/src/PairDeviceModal.tsx +228 -0
- package/src/_core/device.ts +352 -0
- package/src/_core/index.ts +1 -0
- package/src/deviceDedup.ts +26 -166
- package/src/index.ts +2 -0
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
/**
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
listReachableDevices,
|
|
16
16
|
saveSelectedDeviceId,
|
|
17
17
|
} from './auth';
|
|
18
|
+
import { PairDeviceModal } from './PairDeviceModal';
|
|
18
19
|
|
|
19
20
|
export interface YaverMachinePickerProps {
|
|
20
21
|
token: string;
|
|
@@ -43,6 +44,7 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
|
|
|
43
44
|
const [refreshing, setRefreshing] = useState(false);
|
|
44
45
|
const [error, setError] = useState<string | null>(null);
|
|
45
46
|
const [list, setList] = useState<DeviceList>({ owned: [], shared: [] });
|
|
47
|
+
const [pairingDevice, setPairingDevice] = useState<RemoteDevice | null>(null);
|
|
46
48
|
|
|
47
49
|
const load = useCallback(async (silent = false) => {
|
|
48
50
|
if (!silent) setLoading(true);
|
|
@@ -66,6 +68,15 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
|
|
|
66
68
|
}, [load]);
|
|
67
69
|
|
|
68
70
|
const handlePick = async (device: RemoteDevice) => {
|
|
71
|
+
// Needs-auth device — show the in-SDK pair modal instead of
|
|
72
|
+
// treating the tap as a "pick". The user enters the 6-char code
|
|
73
|
+
// from their Mac terminal; the SDK POSTs it to /auth/pair/submit
|
|
74
|
+
// on the agent directly. Once the device flips out of bootstrap
|
|
75
|
+
// mode, the next load() picks up the fresh state.
|
|
76
|
+
if (device.isOnline && device.needsAuth) {
|
|
77
|
+
setPairingDevice(device);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
69
80
|
await saveSelectedDeviceId(device.deviceId);
|
|
70
81
|
onPick(device);
|
|
71
82
|
};
|
|
@@ -165,6 +176,17 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
|
|
|
165
176
|
</>
|
|
166
177
|
)}
|
|
167
178
|
</ScrollView>
|
|
179
|
+
|
|
180
|
+
<PairDeviceModal
|
|
181
|
+
device={pairingDevice}
|
|
182
|
+
onClose={() => setPairingDevice(null)}
|
|
183
|
+
onPaired={() => {
|
|
184
|
+
// Give the agent a moment to flip bootstrap → owner mode,
|
|
185
|
+
// then reload the list so the now-authenticated device shows
|
|
186
|
+
// up with a green dot and can be selected normally.
|
|
187
|
+
setTimeout(() => void load(true), 1500);
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
168
190
|
</SafeAreaView>
|
|
169
191
|
);
|
|
170
192
|
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Modal,
|
|
5
|
+
Platform,
|
|
6
|
+
Pressable,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
TextInput,
|
|
10
|
+
View,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import type { RemoteDevice } from './auth';
|
|
13
|
+
import { getConvexSiteUrl, getToken } from './auth';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* In-SDK remote-pair modal. Shown when the user taps a device in the
|
|
17
|
+
* machine picker that's in `needsAuth` state.
|
|
18
|
+
*
|
|
19
|
+
* Flow:
|
|
20
|
+
* 1. User types the 6-char bootstrap passkey printed in the
|
|
21
|
+
* `yaver serve` terminal on the Mac (also shown in Yaver mobile
|
|
22
|
+
* app when pairing interactively).
|
|
23
|
+
* 2. SDK POSTs to `http://<device.quicHost>:<device.httpPort>/auth/pair/submit?code=XXXXXX`
|
|
24
|
+
* with the user's Convex session token (same one the SDK already
|
|
25
|
+
* has after Apple / Google / email sign-in).
|
|
26
|
+
* 3. Agent validates the token against Convex, persists it, flips
|
|
27
|
+
* out of bootstrap mode — within a couple of seconds it'll
|
|
28
|
+
* report `needsAuth=false` in /devices/list.
|
|
29
|
+
*
|
|
30
|
+
* This avoids making the user bounce to the Yaver mobile app just to
|
|
31
|
+
* adopt a machine. Works for owners and shared-scope guests since the
|
|
32
|
+
* pair endpoint accepts any valid Convex session that matches the
|
|
33
|
+
* expected account type.
|
|
34
|
+
*/
|
|
35
|
+
export interface PairDeviceModalProps {
|
|
36
|
+
device: RemoteDevice | null;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
onPaired?: (device: RemoteDevice) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PairDeviceModal: React.FC<PairDeviceModalProps> = ({
|
|
42
|
+
device,
|
|
43
|
+
onClose,
|
|
44
|
+
onPaired,
|
|
45
|
+
}) => {
|
|
46
|
+
const [code, setCode] = useState('');
|
|
47
|
+
const [busy, setBusy] = useState(false);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
const [success, setSuccess] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (device) {
|
|
53
|
+
setCode('');
|
|
54
|
+
setError(null);
|
|
55
|
+
setSuccess(false);
|
|
56
|
+
}
|
|
57
|
+
}, [device?.deviceId]);
|
|
58
|
+
|
|
59
|
+
const handleSubmit = async () => {
|
|
60
|
+
if (!device) return;
|
|
61
|
+
const trimmed = code.trim().toUpperCase();
|
|
62
|
+
if (trimmed.length !== 6) {
|
|
63
|
+
setError('Code must be 6 characters.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const token = await getToken();
|
|
67
|
+
if (!token) {
|
|
68
|
+
setError('Not signed in.');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const host = (device.quicHost || '').trim();
|
|
72
|
+
const port = device.httpPort || device.quicPort || 18080;
|
|
73
|
+
if (!host) {
|
|
74
|
+
setError('No reachable address for this machine.');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setBusy(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
try {
|
|
80
|
+
const url = `http://${host}:${port}/auth/pair/submit?code=${encodeURIComponent(
|
|
81
|
+
trimmed,
|
|
82
|
+
)}`;
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
token,
|
|
88
|
+
convexSiteUrl: getConvexSiteUrl(),
|
|
89
|
+
// Backend reads userId from the session, but older agents
|
|
90
|
+
// expect it in the body. Pass empty string if unknown.
|
|
91
|
+
userId: '',
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
let msg = `Agent rejected pair (HTTP ${res.status}).`;
|
|
96
|
+
try {
|
|
97
|
+
const body = await res.json();
|
|
98
|
+
if (body?.error) msg = String(body.error);
|
|
99
|
+
} catch {
|
|
100
|
+
// body not JSON
|
|
101
|
+
}
|
|
102
|
+
throw new Error(msg);
|
|
103
|
+
}
|
|
104
|
+
setSuccess(true);
|
|
105
|
+
onPaired?.(device);
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
onClose();
|
|
108
|
+
}, 1200);
|
|
109
|
+
} catch (err: unknown) {
|
|
110
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
111
|
+
} finally {
|
|
112
|
+
setBusy(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Modal
|
|
118
|
+
visible={!!device}
|
|
119
|
+
animationType="slide"
|
|
120
|
+
transparent
|
|
121
|
+
onRequestClose={onClose}
|
|
122
|
+
>
|
|
123
|
+
<Pressable style={styles.overlay} onPress={onClose}>
|
|
124
|
+
<Pressable style={styles.card} onPress={(e) => e.stopPropagation()}>
|
|
125
|
+
<View style={styles.header}>
|
|
126
|
+
<Text style={styles.title}>Pair this Mac</Text>
|
|
127
|
+
<Pressable onPress={onClose} hitSlop={12} style={styles.closeBtn}>
|
|
128
|
+
<Text style={styles.closeIcon}>×</Text>
|
|
129
|
+
</Pressable>
|
|
130
|
+
</View>
|
|
131
|
+
|
|
132
|
+
<Text style={styles.deviceName}>{device?.name || device?.deviceId}</Text>
|
|
133
|
+
<Text style={styles.body}>
|
|
134
|
+
On the Mac where `yaver serve` is running, look for the 6-character code in the terminal output. Enter it here to adopt this machine.
|
|
135
|
+
</Text>
|
|
136
|
+
|
|
137
|
+
<TextInput
|
|
138
|
+
style={styles.codeInput}
|
|
139
|
+
value={code}
|
|
140
|
+
onChangeText={(v) => setCode(v.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6))}
|
|
141
|
+
placeholder="ABCDEF"
|
|
142
|
+
placeholderTextColor="#555"
|
|
143
|
+
autoCapitalize="characters"
|
|
144
|
+
autoCorrect={false}
|
|
145
|
+
maxLength={6}
|
|
146
|
+
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
|
|
147
|
+
/>
|
|
148
|
+
|
|
149
|
+
{error && <Text style={styles.error}>{error}</Text>}
|
|
150
|
+
{success && <Text style={styles.success}>Paired ✓</Text>}
|
|
151
|
+
|
|
152
|
+
<Pressable
|
|
153
|
+
onPress={handleSubmit}
|
|
154
|
+
disabled={busy || success || code.length !== 6}
|
|
155
|
+
style={({ pressed }) => [
|
|
156
|
+
styles.submit,
|
|
157
|
+
(busy || success || code.length !== 6) && styles.submitDisabled,
|
|
158
|
+
pressed && { opacity: 0.7 },
|
|
159
|
+
]}
|
|
160
|
+
>
|
|
161
|
+
{busy ? (
|
|
162
|
+
<ActivityIndicator color="#fff" />
|
|
163
|
+
) : (
|
|
164
|
+
<Text style={styles.submitText}>{success ? 'Paired' : 'Pair'}</Text>
|
|
165
|
+
)}
|
|
166
|
+
</Pressable>
|
|
167
|
+
</Pressable>
|
|
168
|
+
</Pressable>
|
|
169
|
+
</Modal>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const styles = StyleSheet.create({
|
|
174
|
+
overlay: {
|
|
175
|
+
flex: 1,
|
|
176
|
+
backgroundColor: 'rgba(0,0,0,0.55)',
|
|
177
|
+
justifyContent: 'flex-end',
|
|
178
|
+
},
|
|
179
|
+
card: {
|
|
180
|
+
backgroundColor: '#141422',
|
|
181
|
+
borderTopLeftRadius: 22,
|
|
182
|
+
borderTopRightRadius: 22,
|
|
183
|
+
padding: 24,
|
|
184
|
+
paddingBottom: 36,
|
|
185
|
+
gap: 14,
|
|
186
|
+
},
|
|
187
|
+
header: {
|
|
188
|
+
flexDirection: 'row',
|
|
189
|
+
alignItems: 'center',
|
|
190
|
+
justifyContent: 'space-between',
|
|
191
|
+
},
|
|
192
|
+
title: { fontSize: 20, fontWeight: '700', color: '#fff' },
|
|
193
|
+
closeBtn: {
|
|
194
|
+
width: 36,
|
|
195
|
+
height: 36,
|
|
196
|
+
borderRadius: 18,
|
|
197
|
+
alignItems: 'center',
|
|
198
|
+
justifyContent: 'center',
|
|
199
|
+
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
200
|
+
},
|
|
201
|
+
closeIcon: { color: '#fff', fontSize: 22, lineHeight: 24 },
|
|
202
|
+
deviceName: { fontSize: 15, fontWeight: '600', color: '#c7c8ff' },
|
|
203
|
+
body: { fontSize: 13, color: '#9ca3af', lineHeight: 18 },
|
|
204
|
+
codeInput: {
|
|
205
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
206
|
+
borderWidth: 1,
|
|
207
|
+
borderColor: 'rgba(255,255,255,0.14)',
|
|
208
|
+
borderRadius: 12,
|
|
209
|
+
paddingHorizontal: 16,
|
|
210
|
+
paddingVertical: 16,
|
|
211
|
+
fontSize: 22,
|
|
212
|
+
fontWeight: '700',
|
|
213
|
+
letterSpacing: 4,
|
|
214
|
+
textAlign: 'center',
|
|
215
|
+
color: '#fff',
|
|
216
|
+
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
|
217
|
+
},
|
|
218
|
+
error: { color: '#ef4444', fontSize: 13 },
|
|
219
|
+
success: { color: '#22c55e', fontSize: 14, fontWeight: '600' },
|
|
220
|
+
submit: {
|
|
221
|
+
backgroundColor: '#818cf8',
|
|
222
|
+
borderRadius: 12,
|
|
223
|
+
paddingVertical: 14,
|
|
224
|
+
alignItems: 'center',
|
|
225
|
+
},
|
|
226
|
+
submitDisabled: { opacity: 0.35 },
|
|
227
|
+
submitText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
|
228
|
+
});
|
|
@@ -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