yaver-feedback-react-native 0.7.0 → 0.7.1
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.d.ts +40 -27
- package/dist/Discovery.js +164 -70
- package/dist/FeedbackModal.js +34 -7
- package/dist/MachinePickerScreen.js +5 -1
- package/dist/YaverFeedback.d.ts +10 -0
- package/dist/YaverFeedback.js +30 -0
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +36 -3
- package/dist/deviceDedup.d.ts +44 -0
- package/dist/deviceDedup.js +184 -0
- package/package.json +1 -1
- package/src/Discovery.ts +188 -101
- package/src/FeedbackModal.tsx +33 -7
- package/src/MachinePickerScreen.tsx +6 -1
- package/src/YaverFeedback.ts +27 -0
- package/src/auth.ts +46 -3
- package/src/deviceDedup.ts +184 -0
package/src/Discovery.ts
CHANGED
|
@@ -20,9 +20,17 @@ function getAsyncStorage(): AsyncStorageLike | null {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
import type { RemoteDevice } from './auth';
|
|
24
|
+
import {
|
|
25
|
+
collapseRemoteDevices,
|
|
26
|
+
pickTargetDevice,
|
|
27
|
+
HEARTBEAT_STALE_MS,
|
|
28
|
+
} from './deviceDedup';
|
|
29
|
+
|
|
23
30
|
const STORAGE_KEY = 'yaver_feedback_agent';
|
|
24
31
|
const DEFAULT_PORT = 18080;
|
|
25
|
-
const
|
|
32
|
+
const PROBE_TIMEOUT_MS = 2500;
|
|
33
|
+
const RELAY_PROBE_TIMEOUT_MS = 6000;
|
|
26
34
|
|
|
27
35
|
export interface DiscoveryResult {
|
|
28
36
|
url: string;
|
|
@@ -31,29 +39,43 @@ export interface DiscoveryResult {
|
|
|
31
39
|
latency: number;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
// LAN fallback sweep used ONLY when Convex lookup fails AND the stored
|
|
43
|
+
// cache probe fails. Keep tight — covers 192.168.1/0.x and 10.0.0/1.x
|
|
44
|
+
// with a handful of common host suffixes. The primary path is always
|
|
45
|
+
// Convex.
|
|
46
|
+
const LAN_SUBNETS = ['192.168.1', '192.168.0', '10.0.0', '10.0.1'];
|
|
47
|
+
const LAN_HOST_SUFFIXES = [1, 2, 50, 100, 101, 200];
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
|
-
* Device discovery for finding Yaver agents
|
|
50
|
+
* Device discovery for finding Yaver agents.
|
|
51
|
+
*
|
|
52
|
+
* **Convex is the primary source of truth.** The user's Convex account
|
|
53
|
+
* has the freshest IP / port for each registered agent, updated every
|
|
54
|
+
* 2 minutes via heartbeat. The SDK should therefore:
|
|
55
|
+
* 1. On every `discover()` call, re-query Convex for the latest IP
|
|
56
|
+
* (no local cache shortcut when `convexUrl` + `authToken` are
|
|
57
|
+
* available).
|
|
58
|
+
* 2. Dedup the returned list (Convex can carry stale rows after
|
|
59
|
+
* re-pair) and pick the freshest online machine.
|
|
60
|
+
* 3. Probe the machine's `quicHost:httpPort` directly.
|
|
61
|
+
* 4. If the direct probe fails (different LAN / roaming), route
|
|
62
|
+
* through the configured relay.
|
|
63
|
+
* 5. Store the successful URL only AFTER the probe confirms it's
|
|
64
|
+
* reachable. Stored cache is used only as a last-chance shortcut
|
|
65
|
+
* when Convex itself is unreachable.
|
|
40
66
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* 3. **LAN scan** — probe common LAN IPs via `/health` endpoint
|
|
67
|
+
* Compared to the previous implementation this removes the "trust
|
|
68
|
+
* stored URL first" shortcut that caused the SDK to keep trying a dead
|
|
69
|
+
* cached IP long after the Mac's IP rotated.
|
|
45
70
|
*/
|
|
46
71
|
export class YaverDiscovery {
|
|
47
|
-
/**
|
|
48
|
-
* Discover an agent. Tries Convex cloud first (if configured),
|
|
49
|
-
* then stored connection, then LAN scan.
|
|
50
|
-
*/
|
|
51
72
|
static async discover(options?: {
|
|
52
73
|
convexUrl?: string;
|
|
53
74
|
authToken?: string;
|
|
54
75
|
preferredDeviceId?: string;
|
|
55
76
|
}): Promise<DiscoveryResult | null> {
|
|
56
|
-
// Strategy 1: Convex
|
|
77
|
+
// Strategy 1: Convex — always tried first when credentials are
|
|
78
|
+
// available. No cache shortcut.
|
|
57
79
|
if (options?.convexUrl && options?.authToken) {
|
|
58
80
|
const result = await YaverDiscovery.discoverFromConvex(
|
|
59
81
|
options.convexUrl,
|
|
@@ -66,41 +88,60 @@ export class YaverDiscovery {
|
|
|
66
88
|
}
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
// Strategy 2:
|
|
91
|
+
// Strategy 2: Stored URL. Only used as a fallback when Convex was
|
|
92
|
+
// unreachable. A successful probe here means either the mobile is
|
|
93
|
+
// offline or Convex is — we'll trust the stored IP.
|
|
70
94
|
const stored = await YaverDiscovery.getStored();
|
|
71
95
|
if (stored) {
|
|
72
96
|
const result = await YaverDiscovery.probe(stored.url);
|
|
73
|
-
if (result)
|
|
74
|
-
return result;
|
|
75
|
-
}
|
|
97
|
+
if (result) return result;
|
|
76
98
|
await YaverDiscovery.clear();
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
// Strategy 3:
|
|
101
|
+
// Strategy 3: LAN fallback. Small sweep of common subnets. This is
|
|
102
|
+
// only hit when the user has no Convex session (device-local mode)
|
|
103
|
+
// or both Convex + cache lookups failed.
|
|
80
104
|
const candidates: string[] = [];
|
|
81
|
-
for (const subnet of
|
|
82
|
-
for (const suffix of
|
|
105
|
+
for (const subnet of LAN_SUBNETS) {
|
|
106
|
+
for (const suffix of LAN_HOST_SUFFIXES) {
|
|
83
107
|
candidates.push(`http://${subnet}.${suffix}:${DEFAULT_PORT}`);
|
|
84
108
|
}
|
|
85
109
|
}
|
|
86
|
-
|
|
87
110
|
const results = await Promise.allSettled(
|
|
88
111
|
candidates.map((url) => YaverDiscovery.probe(url)),
|
|
89
112
|
);
|
|
90
|
-
|
|
91
113
|
for (const r of results) {
|
|
92
114
|
if (r.status === 'fulfilled' && r.value) {
|
|
93
115
|
await YaverDiscovery.store(r.value);
|
|
94
116
|
return r.value;
|
|
95
117
|
}
|
|
96
118
|
}
|
|
97
|
-
|
|
98
119
|
return null;
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
123
|
+
* Re-query Convex ignoring any cached URL. Intended for the call
|
|
124
|
+
* site right after a probe/network failure — it's the "the IP
|
|
125
|
+
* probably changed, ask the source of truth again" path.
|
|
126
|
+
*/
|
|
127
|
+
static async refreshFromConvex(options: {
|
|
128
|
+
convexUrl: string;
|
|
129
|
+
authToken: string;
|
|
130
|
+
preferredDeviceId?: string;
|
|
131
|
+
}): Promise<DiscoveryResult | null> {
|
|
132
|
+
await YaverDiscovery.clear();
|
|
133
|
+
const result = await YaverDiscovery.discoverFromConvex(
|
|
134
|
+
options.convexUrl,
|
|
135
|
+
options.authToken,
|
|
136
|
+
options.preferredDeviceId,
|
|
137
|
+
);
|
|
138
|
+
if (result) await YaverDiscovery.store(result);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Fetch the agent URL from Convex. Dedups rows, prefers fresh ones,
|
|
144
|
+
* falls back to relay if the direct LAN IP isn't reachable.
|
|
104
145
|
*/
|
|
105
146
|
static async discoverFromConvex(
|
|
106
147
|
convexUrl: string,
|
|
@@ -108,17 +149,17 @@ export class YaverDiscovery {
|
|
|
108
149
|
preferredDeviceId?: string,
|
|
109
150
|
): Promise<DiscoveryResult | null> {
|
|
110
151
|
const base = convexUrl.replace(/\/$/, '');
|
|
111
|
-
|
|
112
152
|
try {
|
|
113
|
-
// Try cloud machines first (CPU/GPU managed machines)
|
|
153
|
+
// Try cloud machines first (CPU/GPU managed machines). These are
|
|
154
|
+
// long-lived with stable IPs so the direct probe is cheap.
|
|
114
155
|
const machinesRes = await fetch(`${base}/machines`, {
|
|
115
156
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
116
157
|
});
|
|
117
|
-
|
|
118
158
|
if (machinesRes.ok) {
|
|
119
159
|
const { machines } = await machinesRes.json();
|
|
120
160
|
const activeMachine = (machines ?? []).find(
|
|
121
|
-
(m: { status: string; serverIp?: string }) =>
|
|
161
|
+
(m: { status: string; serverIp?: string }) =>
|
|
162
|
+
m.status === 'active' && m.serverIp,
|
|
122
163
|
);
|
|
123
164
|
if (activeMachine?.serverIp) {
|
|
124
165
|
const url = `http://${activeMachine.serverIp}:${DEFAULT_PORT}`;
|
|
@@ -127,31 +168,78 @@ export class YaverDiscovery {
|
|
|
127
168
|
}
|
|
128
169
|
}
|
|
129
170
|
|
|
130
|
-
// Fall back to
|
|
171
|
+
// Fall back to personal devices registered in Convex.
|
|
131
172
|
const devicesRes = await fetch(`${base}/devices/list`, {
|
|
132
173
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
133
174
|
});
|
|
134
|
-
|
|
135
175
|
if (!devicesRes.ok) return null;
|
|
136
176
|
const data = await devicesRes.json();
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
177
|
+
const rawList = Array.isArray(data?.devices) ? data.devices : data;
|
|
178
|
+
if (!Array.isArray(rawList) || rawList.length === 0) return null;
|
|
179
|
+
|
|
180
|
+
// Normalise Convex fields → RemoteDevice shape so dedup works the
|
|
181
|
+
// same way `listReachableDevices` does it.
|
|
182
|
+
const normalised: RemoteDevice[] = rawList.map((d: any) => ({
|
|
183
|
+
deviceId: d.deviceId ?? d.id,
|
|
184
|
+
name: d.name ?? '',
|
|
185
|
+
platform: d.platform ?? d.os ?? '',
|
|
186
|
+
isOnline: !!d.isOnline,
|
|
187
|
+
needsAuth: !!d.needsAuth,
|
|
188
|
+
runnerDown: !!d.runnerDown,
|
|
189
|
+
lastHeartbeat: d.lastHeartbeat ?? 0,
|
|
190
|
+
isGuest: !!d.isGuest,
|
|
191
|
+
hostName: d.hostName,
|
|
192
|
+
hostEmail: d.hostEmail,
|
|
193
|
+
accessScope: d.accessScope ?? 'owner',
|
|
194
|
+
quicHost: d.quicHost ?? d.host ?? '',
|
|
195
|
+
quicPort: d.quicPort ?? 0,
|
|
196
|
+
httpPort: d.httpPort ?? d.quicPort,
|
|
197
|
+
publicKey: d.publicKey,
|
|
198
|
+
hwid: d.hardwareId ?? d.hwid,
|
|
199
|
+
localIps: Array.isArray(d.localIps)
|
|
200
|
+
? d.localIps
|
|
201
|
+
: Array.isArray(d.lanIps)
|
|
202
|
+
? d.lanIps
|
|
203
|
+
: undefined,
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
const deduped = collapseRemoteDevices(normalised);
|
|
207
|
+
const target = pickTargetDevice(deduped, preferredDeviceId);
|
|
208
|
+
if (!target) return null;
|
|
209
|
+
|
|
210
|
+
// Build the same candidate set the Yaver mobile app races on: the
|
|
211
|
+
// primary `quicHost` plus every LAN IP reported in the latest
|
|
212
|
+
// heartbeat (`localIps`). Multi-homed hosts commonly advertise
|
|
213
|
+
// en0 + utun (tailscale) + docker0 etc.; probing all of them in
|
|
214
|
+
// parallel makes the SDK "just work" on the same Wi-Fi without
|
|
215
|
+
// depending on which NIC the user's router DHCP'd them from.
|
|
216
|
+
const port = target.httpPort ?? target.quicPort ?? DEFAULT_PORT;
|
|
217
|
+
const ipSet = new Set<string>();
|
|
218
|
+
if (target.quicHost) ipSet.add(target.quicHost);
|
|
219
|
+
for (const ip of target.localIps ?? []) {
|
|
220
|
+
if (ip) ipSet.add(ip);
|
|
221
|
+
}
|
|
222
|
+
const candidates = Array.from(ipSet).map(
|
|
223
|
+
(ip) => `http://${ip}:${port}`,
|
|
224
|
+
);
|
|
143
225
|
|
|
144
|
-
if (
|
|
226
|
+
if (candidates.length > 0) {
|
|
227
|
+
const direct = await YaverDiscovery.raceProbe(candidates);
|
|
228
|
+
if (direct) return direct;
|
|
229
|
+
}
|
|
145
230
|
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
231
|
+
// Warn if the chosen target looks stale — informative only; we
|
|
232
|
+
// still fell through to the relay path below.
|
|
233
|
+
const stale =
|
|
234
|
+
target.lastHeartbeat &&
|
|
235
|
+
Date.now() - target.lastHeartbeat > HEARTBEAT_STALE_MS;
|
|
236
|
+
void stale;
|
|
151
237
|
|
|
152
|
-
// Direct
|
|
238
|
+
// Direct probes all failed — route through relay.
|
|
153
239
|
const relayResult = await YaverDiscovery.discoverViaRelay(
|
|
154
|
-
base,
|
|
240
|
+
base,
|
|
241
|
+
authToken,
|
|
242
|
+
target.deviceId,
|
|
155
243
|
);
|
|
156
244
|
if (relayResult) return relayResult;
|
|
157
245
|
|
|
@@ -162,9 +250,47 @@ export class YaverDiscovery {
|
|
|
162
250
|
}
|
|
163
251
|
|
|
164
252
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* `
|
|
253
|
+
* Race `/health` probes across N URLs in parallel. First 200 wins;
|
|
254
|
+
* everything else is abandoned. Mirrors the mobile app's
|
|
255
|
+
* `raceDirectCandidates` pattern — the single most reliable thing it
|
|
256
|
+
* does on same-LAN.
|
|
257
|
+
*/
|
|
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
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Discover agent via relay HTTP proxy. Uses the user's configured
|
|
293
|
+
* relay first (from `/auth/validate`), then the platform relay list.
|
|
168
294
|
*/
|
|
169
295
|
static async discoverViaRelay(
|
|
170
296
|
convexUrl: string,
|
|
@@ -172,7 +298,6 @@ export class YaverDiscovery {
|
|
|
172
298
|
deviceId: string,
|
|
173
299
|
): Promise<DiscoveryResult | null> {
|
|
174
300
|
try {
|
|
175
|
-
// Fetch relay server list from user settings first, then platform config
|
|
176
301
|
const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
|
|
177
302
|
headers: { Authorization: `Bearer ${authToken}` },
|
|
178
303
|
});
|
|
@@ -185,134 +310,98 @@ export class YaverDiscovery {
|
|
|
185
310
|
relayPassword = settingsData.relayPassword;
|
|
186
311
|
}
|
|
187
312
|
|
|
188
|
-
// If no user-level relay, fetch platform relay servers
|
|
189
313
|
if (!relayUrl) {
|
|
190
314
|
const configRes = await fetch(`${convexUrl}/platform-config?key=relay_servers`);
|
|
191
315
|
if (configRes.ok) {
|
|
192
316
|
const configData = await configRes.json();
|
|
193
|
-
const servers =
|
|
194
|
-
|
|
195
|
-
|
|
317
|
+
const servers =
|
|
318
|
+
typeof configData.value === 'string'
|
|
319
|
+
? JSON.parse(configData.value)
|
|
320
|
+
: configData.value;
|
|
196
321
|
if (Array.isArray(servers) && servers.length > 0) {
|
|
197
|
-
// Pick the first (highest priority) relay with an httpUrl
|
|
198
322
|
const relay = servers.find((s: { httpUrl?: string }) => s.httpUrl);
|
|
199
|
-
if (relay)
|
|
200
|
-
relayUrl = relay.httpUrl;
|
|
201
|
-
}
|
|
323
|
+
if (relay) relayUrl = relay.httpUrl;
|
|
202
324
|
}
|
|
203
325
|
}
|
|
204
326
|
}
|
|
205
|
-
|
|
206
327
|
if (!relayUrl) return null;
|
|
207
328
|
|
|
208
|
-
// Probe agent through relay: {relayHttpUrl}/d/{deviceId}/health
|
|
209
329
|
const relayBase = `${relayUrl.replace(/\/$/, '')}/d/${deviceId}`;
|
|
210
|
-
|
|
330
|
+
return YaverDiscovery.probeWithHeaders(relayBase, {
|
|
211
331
|
'X-Relay-Password': relayPassword || '',
|
|
212
332
|
});
|
|
213
|
-
return result;
|
|
214
333
|
} catch {
|
|
215
334
|
return null;
|
|
216
335
|
}
|
|
217
336
|
}
|
|
218
337
|
|
|
219
|
-
/**
|
|
220
|
-
* Probe with extra headers (e.g. relay password).
|
|
221
|
-
*/
|
|
222
338
|
static async probeWithHeaders(
|
|
223
339
|
url: string,
|
|
224
340
|
headers: Record<string, string>,
|
|
225
341
|
): Promise<DiscoveryResult | null> {
|
|
226
342
|
const base = url.replace(/\/$/, '');
|
|
227
343
|
const start = Date.now();
|
|
228
|
-
|
|
229
344
|
try {
|
|
230
345
|
const controller = new AbortController();
|
|
231
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
232
|
-
|
|
346
|
+
const timeoutId = setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS);
|
|
233
347
|
const response = await fetch(`${base}/health`, {
|
|
234
348
|
method: 'GET',
|
|
235
349
|
headers,
|
|
236
350
|
signal: controller.signal,
|
|
237
351
|
});
|
|
238
|
-
|
|
239
352
|
clearTimeout(timeoutId);
|
|
240
|
-
|
|
241
353
|
if (!response.ok) return null;
|
|
242
|
-
|
|
243
354
|
const latency = Date.now() - start;
|
|
244
355
|
let hostname = 'Unknown';
|
|
245
356
|
let version = 'unknown';
|
|
246
|
-
|
|
247
357
|
try {
|
|
248
358
|
const data = await response.json();
|
|
249
359
|
hostname = data.hostname ?? data.name ?? 'Unknown';
|
|
250
360
|
version = data.version ?? 'unknown';
|
|
251
361
|
} catch {
|
|
252
|
-
//
|
|
362
|
+
// /health may return plain text
|
|
253
363
|
}
|
|
254
|
-
|
|
255
364
|
return { url: base, hostname, version, latency };
|
|
256
365
|
} catch {
|
|
257
366
|
return null;
|
|
258
367
|
}
|
|
259
368
|
}
|
|
260
369
|
|
|
261
|
-
/**
|
|
262
|
-
* Probe a specific URL for a running Yaver agent.
|
|
263
|
-
* Hits the `/health` endpoint with a 2s timeout.
|
|
264
|
-
*/
|
|
370
|
+
/** Probe a specific URL for a running Yaver agent (2.5 s timeout). */
|
|
265
371
|
static async probe(url: string): Promise<DiscoveryResult | null> {
|
|
266
372
|
const base = url.replace(/\/$/, '');
|
|
267
373
|
const start = Date.now();
|
|
268
|
-
|
|
269
374
|
try {
|
|
270
375
|
const controller = new AbortController();
|
|
271
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
272
|
-
|
|
376
|
+
const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
273
377
|
const response = await fetch(`${base}/health`, {
|
|
274
378
|
method: 'GET',
|
|
275
379
|
signal: controller.signal,
|
|
276
380
|
});
|
|
277
|
-
|
|
278
381
|
clearTimeout(timeoutId);
|
|
279
|
-
|
|
280
|
-
if (!response.ok) {
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
|
-
|
|
382
|
+
if (!response.ok) return null;
|
|
284
383
|
const latency = Date.now() - start;
|
|
285
|
-
|
|
286
384
|
let hostname = 'Unknown';
|
|
287
385
|
let version = 'unknown';
|
|
288
|
-
|
|
289
386
|
try {
|
|
290
387
|
const data = await response.json();
|
|
291
388
|
hostname = data.hostname ?? data.name ?? 'Unknown';
|
|
292
389
|
version = data.version ?? 'unknown';
|
|
293
390
|
} catch {
|
|
294
|
-
//
|
|
391
|
+
// /health may return plain text
|
|
295
392
|
}
|
|
296
|
-
|
|
297
393
|
return { url: base, hostname, version, latency };
|
|
298
394
|
} catch {
|
|
299
395
|
return null;
|
|
300
396
|
}
|
|
301
397
|
}
|
|
302
398
|
|
|
303
|
-
/**
|
|
304
|
-
* Manually connect to a specific agent URL.
|
|
305
|
-
* Probes the URL and stores the connection if successful.
|
|
306
|
-
*/
|
|
307
399
|
static async connect(url: string): Promise<DiscoveryResult | null> {
|
|
308
400
|
const result = await YaverDiscovery.probe(url);
|
|
309
|
-
if (result)
|
|
310
|
-
await YaverDiscovery.store(result);
|
|
311
|
-
}
|
|
401
|
+
if (result) await YaverDiscovery.store(result);
|
|
312
402
|
return result;
|
|
313
403
|
}
|
|
314
404
|
|
|
315
|
-
/** Get the cached agent connection from storage. */
|
|
316
405
|
static async getStored(): Promise<{ url: string; hostname: string } | null> {
|
|
317
406
|
const storage = getAsyncStorage();
|
|
318
407
|
if (!storage) return null;
|
|
@@ -329,7 +418,6 @@ export class YaverDiscovery {
|
|
|
329
418
|
}
|
|
330
419
|
}
|
|
331
420
|
|
|
332
|
-
/** Store a successful discovery result. */
|
|
333
421
|
static async store(result: DiscoveryResult): Promise<void> {
|
|
334
422
|
const storage = getAsyncStorage();
|
|
335
423
|
if (!storage) return;
|
|
@@ -343,7 +431,6 @@ export class YaverDiscovery {
|
|
|
343
431
|
}
|
|
344
432
|
}
|
|
345
433
|
|
|
346
|
-
/** Clear the stored agent connection. */
|
|
347
434
|
static async clear(): Promise<void> {
|
|
348
435
|
const storage = getAsyncStorage();
|
|
349
436
|
if (!storage) return;
|
package/src/FeedbackModal.tsx
CHANGED
|
@@ -86,17 +86,43 @@ export const FeedbackModal: React.FC = () => {
|
|
|
86
86
|
setAction('idle');
|
|
87
87
|
}, []);
|
|
88
88
|
|
|
89
|
+
// Helper: run a P2P call; on network failure, ask YaverFeedback to
|
|
90
|
+
// re-query Convex for the fresh IP and retry once. Solves the common
|
|
91
|
+
// case where the Mac's LAN IP rotated while the SDK held a stale URL.
|
|
92
|
+
const runWithReconnect = useCallback(
|
|
93
|
+
async (fn: (client: NonNullable<ReturnType<typeof YaverFeedback.getP2PClient>>) => Promise<void>) => {
|
|
94
|
+
let client = YaverFeedback.getP2PClient();
|
|
95
|
+
if (!client) {
|
|
96
|
+
const ok = await YaverFeedback.reconnect();
|
|
97
|
+
if (ok) client = YaverFeedback.getP2PClient();
|
|
98
|
+
}
|
|
99
|
+
if (!client) {
|
|
100
|
+
throw new Error('Not connected to the agent yet.');
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await fn(client);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
106
|
+
const transient = /Network request failed|timeout|ECONNREFUSED|Failed to fetch|fetch failed|aborted/i.test(msg);
|
|
107
|
+
if (!transient) throw err;
|
|
108
|
+
const ok = await YaverFeedback.reconnect();
|
|
109
|
+
if (!ok) throw err;
|
|
110
|
+
const fresh = YaverFeedback.getP2PClient();
|
|
111
|
+
if (!fresh) throw err;
|
|
112
|
+
await fn(fresh);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[],
|
|
116
|
+
);
|
|
117
|
+
|
|
89
118
|
// ─── 1. Hot reload ─────────────────────────────────────────────────
|
|
90
119
|
const handleHotReload = useCallback(async () => {
|
|
91
|
-
const client = YaverFeedback.getP2PClient();
|
|
92
|
-
if (!client) {
|
|
93
|
-
setError('Not connected to the agent yet.');
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
120
|
setAction('hot-reloading');
|
|
97
121
|
setError(null);
|
|
98
122
|
try {
|
|
99
|
-
await client
|
|
123
|
+
await runWithReconnect(async (client) => {
|
|
124
|
+
await client.reloadApp('dev');
|
|
125
|
+
});
|
|
100
126
|
setToast('Reload sent');
|
|
101
127
|
closeSoon(800);
|
|
102
128
|
} catch (err: unknown) {
|
|
@@ -104,7 +130,7 @@ export const FeedbackModal: React.FC = () => {
|
|
|
104
130
|
} finally {
|
|
105
131
|
if (mountedRef.current) setAction('idle');
|
|
106
132
|
}
|
|
107
|
-
}, [closeSoon]);
|
|
133
|
+
}, [closeSoon, runWithReconnect]);
|
|
108
134
|
|
|
109
135
|
// ─── 2. Screenshot + Fix ───────────────────────────────────────────
|
|
110
136
|
//
|
|
@@ -72,7 +72,12 @@ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
|
|
|
72
72
|
|
|
73
73
|
const renderDevice = (device: RemoteDevice) => {
|
|
74
74
|
const selected = device.deviceId === currentDeviceId;
|
|
75
|
-
|
|
75
|
+
// Match the Yaver mobile app: HEARTBEAT_STALE_MS is 90 s. Using
|
|
76
|
+
// 60 s here flashed yellow on a single missed agent beat even
|
|
77
|
+
// though the Mac was up.
|
|
78
|
+
const stale =
|
|
79
|
+
device.lastHeartbeat > 0 &&
|
|
80
|
+
Date.now() - device.lastHeartbeat > 90_000;
|
|
76
81
|
const healthColor = !device.isOnline
|
|
77
82
|
? '#ef4444'
|
|
78
83
|
: device.needsAuth || device.runnerDown || stale
|
package/src/YaverFeedback.ts
CHANGED
|
@@ -201,6 +201,33 @@ export class YaverFeedback {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Force a fresh Convex lookup for the agent URL — ignoring any
|
|
206
|
+
* cached URL. Callers use this after a P2P request fails
|
|
207
|
+
* (connection refused / timeout) because the most common cause is
|
|
208
|
+
* the Mac's LAN IP rotating. Convex has the fresh one, so we
|
|
209
|
+
* re-query and probe `[quicHost, ...localIps]` in parallel.
|
|
210
|
+
*
|
|
211
|
+
* Returns true when a new URL was adopted.
|
|
212
|
+
*/
|
|
213
|
+
static async reconnect(): Promise<boolean> {
|
|
214
|
+
if (!config || !enabled) return false;
|
|
215
|
+
if (!config.authToken || !config.convexUrl) return false;
|
|
216
|
+
try {
|
|
217
|
+
const result = await YaverDiscovery.refreshFromConvex({
|
|
218
|
+
convexUrl: config.convexUrl,
|
|
219
|
+
authToken: config.authToken,
|
|
220
|
+
preferredDeviceId: config.preferredDeviceId,
|
|
221
|
+
});
|
|
222
|
+
if (!result) return false;
|
|
223
|
+
config.agentUrl = result.url;
|
|
224
|
+
p2pClient = new P2PClient(result.url, config.authToken ?? '');
|
|
225
|
+
return true;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
204
231
|
/**
|
|
205
232
|
* Pull a cached session token + selected device from AsyncStorage (populated
|
|
206
233
|
* by the in-SDK login + machine-picker screens). When present the SDK can
|
package/src/auth.ts
CHANGED
|
@@ -409,7 +409,17 @@ export interface RemoteDevice {
|
|
|
409
409
|
accessScope: 'owner' | 'shared-scoped' | 'shared-legacy';
|
|
410
410
|
quicHost: string;
|
|
411
411
|
quicPort: number;
|
|
412
|
+
/** Agent HTTP port — preferred over quicPort when present. */
|
|
413
|
+
httpPort?: number;
|
|
412
414
|
publicKey?: string;
|
|
415
|
+
/** Hardware identifier — used for dedup across re-pair events. */
|
|
416
|
+
hwid?: string;
|
|
417
|
+
/**
|
|
418
|
+
* Every LAN IP the agent reported in its last heartbeat. Useful on
|
|
419
|
+
* multi-homed hosts — probing all of them in parallel is the same
|
|
420
|
+
* trick the Yaver mobile app uses to "just work" on the same Wi-Fi.
|
|
421
|
+
*/
|
|
422
|
+
localIps?: string[];
|
|
413
423
|
}
|
|
414
424
|
|
|
415
425
|
export interface DeviceList {
|
|
@@ -420,6 +430,10 @@ export interface DeviceList {
|
|
|
420
430
|
/**
|
|
421
431
|
* Fetch the set of remote dev machines this user can reach. Splits into
|
|
422
432
|
* owned (user is the host) vs shared (host invited them as a guest).
|
|
433
|
+
*
|
|
434
|
+
* Collapses duplicate rows before splitting — Convex can return multiple
|
|
435
|
+
* rows per physical machine after a re-pair or hostname change, and the
|
|
436
|
+
* raw list used to render as "Kvancs-MacBook-Air.local ×3" in the picker.
|
|
423
437
|
*/
|
|
424
438
|
export async function listReachableDevices(
|
|
425
439
|
token: string,
|
|
@@ -430,10 +444,39 @@ export async function listReachableDevices(
|
|
|
430
444
|
});
|
|
431
445
|
if (!res.ok) return { owned: [], shared: [] };
|
|
432
446
|
const data = await res.json();
|
|
433
|
-
const
|
|
447
|
+
const raw = (data.devices ?? []) as any[];
|
|
448
|
+
// Normalise Convex field names → SDK's RemoteDevice shape. The
|
|
449
|
+
// backend returns `localIps`, sometimes the mobile-side mapping
|
|
450
|
+
// surfaces `lanIps` — accept either so the field survives.
|
|
451
|
+
const normalised: RemoteDevice[] = raw.map((d) => ({
|
|
452
|
+
deviceId: d.deviceId ?? d.id,
|
|
453
|
+
name: d.name ?? '',
|
|
454
|
+
platform: d.platform ?? d.os ?? '',
|
|
455
|
+
isOnline: !!d.isOnline,
|
|
456
|
+
needsAuth: !!d.needsAuth,
|
|
457
|
+
runnerDown: !!d.runnerDown,
|
|
458
|
+
lastHeartbeat: d.lastHeartbeat ?? 0,
|
|
459
|
+
isGuest: !!d.isGuest,
|
|
460
|
+
hostName: d.hostName,
|
|
461
|
+
hostEmail: d.hostEmail,
|
|
462
|
+
accessScope: d.accessScope ?? 'owner',
|
|
463
|
+
quicHost: d.quicHost ?? d.host ?? '',
|
|
464
|
+
quicPort: d.quicPort ?? 0,
|
|
465
|
+
httpPort: d.httpPort ?? d.quicPort,
|
|
466
|
+
publicKey: d.publicKey,
|
|
467
|
+
hwid: d.hardwareId ?? d.hwid,
|
|
468
|
+
localIps: Array.isArray(d.localIps)
|
|
469
|
+
? d.localIps
|
|
470
|
+
: Array.isArray(d.lanIps)
|
|
471
|
+
? d.lanIps
|
|
472
|
+
: undefined,
|
|
473
|
+
}));
|
|
474
|
+
// Lazy require so Jest + tree-shakers don't choke on a circular import.
|
|
475
|
+
const { collapseRemoteDevices } = require('./deviceDedup') as typeof import('./deviceDedup');
|
|
476
|
+
const deduped = collapseRemoteDevices(normalised);
|
|
434
477
|
return {
|
|
435
|
-
owned:
|
|
436
|
-
shared:
|
|
478
|
+
owned: deduped.filter((d) => !d.isGuest),
|
|
479
|
+
shared: deduped.filter((d) => d.isGuest),
|
|
437
480
|
};
|
|
438
481
|
} catch {
|
|
439
482
|
return { owned: [], shared: [] };
|