yaver-feedback-react-native 0.8.4 → 0.8.6

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.
@@ -72,6 +72,7 @@ export const FeedbackModal: React.FC = () => {
72
72
  // is our guaranteed UI for bringing the icon back — we surface a
73
73
  // small "Show quick icon" row when this is true.
74
74
  const [quickIconHidden, setQuickIconHidden] = useState(false);
75
+ const [runnerAuthModal, setRunnerAuthModal] = useState<string | null>(null);
75
76
  // Vibing-input mode: same expand-on-tap pattern as email login.
76
77
  // Tap "Vibing" once → the button reveals an input + Send; that lets
77
78
  // the user say WHAT they want to vibe on instead of firing a canned
@@ -809,6 +810,41 @@ export const FeedbackModal: React.FC = () => {
809
810
  busy={action === 'capturing'}
810
811
  />
811
812
 
813
+ {/* Remote sign-in buttons — trigger codex/claude device-auth
814
+ on the selected agent without leaving the app. Opens a
815
+ small native modal showing the verification URL + 8-char
816
+ code the user enters in any browser. No API keys. */}
817
+ <View style={runnerAuthRowStyles.container}>
818
+ <Pressable
819
+ onPress={() => setRunnerAuthModal('codex')}
820
+ disabled={busy}
821
+ style={({ pressed }) => [
822
+ runnerAuthRowStyles.button,
823
+ pressed && runnerAuthRowStyles.buttonPressed,
824
+ busy && runnerAuthRowStyles.buttonDisabled,
825
+ ]}
826
+ accessibilityRole="button"
827
+ accessibilityLabel="Remote sign-in Codex"
828
+ >
829
+ <Text style={runnerAuthRowStyles.buttonLabel}>Remote sign-in</Text>
830
+ <Text style={runnerAuthRowStyles.buttonName}>Codex</Text>
831
+ </Pressable>
832
+ <Pressable
833
+ onPress={() => setRunnerAuthModal('claude')}
834
+ disabled={busy}
835
+ style={({ pressed }) => [
836
+ runnerAuthRowStyles.button,
837
+ pressed && runnerAuthRowStyles.buttonPressed,
838
+ busy && runnerAuthRowStyles.buttonDisabled,
839
+ ]}
840
+ accessibilityRole="button"
841
+ accessibilityLabel="Remote sign-in Claude"
842
+ >
843
+ <Text style={runnerAuthRowStyles.buttonLabel}>Remote sign-in</Text>
844
+ <Text style={runnerAuthRowStyles.buttonName}>Claude</Text>
845
+ </Pressable>
846
+ </View>
847
+
812
848
  {progress !== null && (
813
849
  <View style={styles.progressTrack}>
814
850
  <View
@@ -839,6 +875,12 @@ export const FeedbackModal: React.FC = () => {
839
875
  </Pressable>
840
876
  </Modal>
841
877
  )}
878
+ {runnerAuthModal ? (
879
+ <RunnerAuthNativeModal
880
+ runner={runnerAuthModal}
881
+ onClose={() => setRunnerAuthModal(null)}
882
+ />
883
+ ) : null}
842
884
  </>
843
885
  );
844
886
  };
@@ -1195,3 +1237,281 @@ const styles = StyleSheet.create({
1195
1237
  opacity: 0.7,
1196
1238
  },
1197
1239
  });
1240
+
1241
+ /**
1242
+ * Minimal native modal for the codex/claude remote sign-in flow. Opens
1243
+ * the device-auth session on the connected agent, surfaces the
1244
+ * verification URL + one-time code, polls every 1.5 s, and turns green
1245
+ * the moment the CLI writes its auth.json. No API keys, no SSH.
1246
+ */
1247
+ const RunnerAuthNativeModal: React.FC<{
1248
+ runner: string;
1249
+ onClose: () => void;
1250
+ }> = ({ runner, onClose }) => {
1251
+ const [session, setSession] = useState<import('./types').RunnerBrowserAuthSession | null>(null);
1252
+ const [startError, setStartError] = useState<string | null>(null);
1253
+ const [copied, setCopied] = useState(false);
1254
+ const startedRef = useRef(false);
1255
+
1256
+ useEffect(() => {
1257
+ if (startedRef.current) return;
1258
+ startedRef.current = true;
1259
+ (async () => {
1260
+ try {
1261
+ const s = await YaverFeedback.startRunnerBrowserAuth(runner);
1262
+ setSession(s);
1263
+ } catch (err) {
1264
+ setStartError(err instanceof Error ? err.message : String(err));
1265
+ }
1266
+ })();
1267
+ }, [runner]);
1268
+
1269
+ useEffect(() => {
1270
+ if (!session) return;
1271
+ if (['completed', 'failed', 'cancelled'].includes(session.status)) return;
1272
+ const iv = setInterval(async () => {
1273
+ try {
1274
+ const s = await YaverFeedback.getRunnerBrowserAuthStatus(session.id);
1275
+ setSession(s);
1276
+ } catch {
1277
+ // keep polling
1278
+ }
1279
+ }, 1500);
1280
+ return () => clearInterval(iv);
1281
+ }, [session?.id, session?.status]);
1282
+
1283
+ const terminal = session && ['completed', 'failed', 'cancelled'].includes(session.status);
1284
+ const runnerLabel = runner === 'codex' ? 'OpenAI Codex' : runner === 'claude' ? 'Claude Code' : runner;
1285
+
1286
+ const handleClose = () => {
1287
+ if (session && !terminal) {
1288
+ YaverFeedback.cancelRunnerBrowserAuth(session.id).catch(() => {});
1289
+ }
1290
+ onClose();
1291
+ };
1292
+
1293
+ const copyCode = () => {
1294
+ if (!session?.code) return;
1295
+ try {
1296
+ // Avoid a hard Clipboard dep — host app can polyfill.
1297
+ const Clipboard = require('react-native').Clipboard;
1298
+ if (Clipboard?.setString) {
1299
+ Clipboard.setString(session.code);
1300
+ setCopied(true);
1301
+ setTimeout(() => setCopied(false), 1500);
1302
+ }
1303
+ } catch {
1304
+ // best-effort — code is visible on screen regardless
1305
+ }
1306
+ };
1307
+
1308
+ const openUrl = () => {
1309
+ if (!session?.openUrl) return;
1310
+ try {
1311
+ const { Linking } = require('react-native');
1312
+ Linking.openURL(session.openUrl).catch(() => {});
1313
+ } catch {
1314
+ /* ignore */
1315
+ }
1316
+ };
1317
+
1318
+ return (
1319
+ <Modal visible={true} transparent animationType="fade" onRequestClose={handleClose}>
1320
+ <View style={runnerAuthModalStyles.overlay}>
1321
+ <View style={runnerAuthModalStyles.card}>
1322
+ <View style={runnerAuthModalStyles.header}>
1323
+ <View style={{ flex: 1 }}>
1324
+ <Text style={runnerAuthModalStyles.title}>Sign in to {runnerLabel}</Text>
1325
+ <Text style={runnerAuthModalStyles.subtitle}>
1326
+ Opens a one-time URL + code. Enter it in any browser.
1327
+ </Text>
1328
+ </View>
1329
+ <Pressable onPress={handleClose} hitSlop={10}>
1330
+ <Text style={runnerAuthModalStyles.close}>×</Text>
1331
+ </Pressable>
1332
+ </View>
1333
+
1334
+ {startError ? (
1335
+ <View style={runnerAuthModalStyles.errorBox}>
1336
+ <Text style={runnerAuthModalStyles.errorTitle}>Couldn't start</Text>
1337
+ <Text style={runnerAuthModalStyles.errorBody}>{startError}</Text>
1338
+ </View>
1339
+ ) : !session ? (
1340
+ <Text style={runnerAuthModalStyles.dim}>
1341
+ Starting the sign-in flow on the remote machine…
1342
+ </Text>
1343
+ ) : session.status === 'completed' ? (
1344
+ <View style={runnerAuthModalStyles.successBox}>
1345
+ <Text style={runnerAuthModalStyles.successTitle}>✓ Signed in</Text>
1346
+ <Text style={runnerAuthModalStyles.successBody}>
1347
+ {session.detail || 'Auth stored on the remote machine.'}
1348
+ </Text>
1349
+ </View>
1350
+ ) : session.status === 'failed' || session.status === 'cancelled' ? (
1351
+ <View style={runnerAuthModalStyles.errorBox}>
1352
+ <Text style={runnerAuthModalStyles.errorTitle}>
1353
+ {session.status === 'cancelled' ? 'Cancelled' : 'Failed'}
1354
+ </Text>
1355
+ <Text style={runnerAuthModalStyles.errorBody}>
1356
+ {session.error || session.detail || 'The CLI exited before sign-in completed.'}
1357
+ </Text>
1358
+ </View>
1359
+ ) : (
1360
+ <View>
1361
+ {session.openUrl ? (
1362
+ <Pressable onPress={openUrl} style={runnerAuthModalStyles.urlBox}>
1363
+ <Text style={runnerAuthModalStyles.urlText} numberOfLines={2}>
1364
+ ↗ {session.openUrl}
1365
+ </Text>
1366
+ </Pressable>
1367
+ ) : (
1368
+ <Text style={runnerAuthModalStyles.dim}>
1369
+ Waiting for verification URL from the remote CLI…
1370
+ </Text>
1371
+ )}
1372
+ {session.code ? (
1373
+ <View style={{ marginTop: 12 }}>
1374
+ <Text style={runnerAuthModalStyles.codeLabel}>ENTER THIS CODE</Text>
1375
+ <Pressable onPress={copyCode} style={runnerAuthModalStyles.codeBox}>
1376
+ <Text style={runnerAuthModalStyles.codeText}>{session.code}</Text>
1377
+ <Text style={runnerAuthModalStyles.codeHint}>
1378
+ {copied ? 'copied' : 'tap to copy'}
1379
+ </Text>
1380
+ </Pressable>
1381
+ </View>
1382
+ ) : null}
1383
+ <Text style={runnerAuthModalStyles.phishingHint}>
1384
+ Device codes are a common phishing target. Never share this code. This dialog
1385
+ turns green automatically once sign-in completes.
1386
+ </Text>
1387
+ </View>
1388
+ )}
1389
+ </View>
1390
+ </View>
1391
+ </Modal>
1392
+ );
1393
+ };
1394
+
1395
+ const runnerAuthRowStyles = StyleSheet.create({
1396
+ container: {
1397
+ flexDirection: 'row',
1398
+ gap: 8,
1399
+ marginTop: 8,
1400
+ flexWrap: 'wrap',
1401
+ },
1402
+ button: {
1403
+ flexGrow: 1,
1404
+ flexBasis: 0,
1405
+ minWidth: 120,
1406
+ paddingHorizontal: 12,
1407
+ paddingVertical: 10,
1408
+ borderRadius: 10,
1409
+ borderWidth: 1,
1410
+ borderColor: 'rgba(148,163,184,0.22)',
1411
+ backgroundColor: 'rgba(15,23,42,0.6)',
1412
+ },
1413
+ buttonPressed: { opacity: 0.7 },
1414
+ buttonDisabled: { opacity: 0.4 },
1415
+ buttonLabel: {
1416
+ fontSize: 10,
1417
+ color: '#94a3b8',
1418
+ textTransform: 'uppercase',
1419
+ letterSpacing: 0.8,
1420
+ },
1421
+ buttonName: {
1422
+ marginTop: 2,
1423
+ fontSize: 14,
1424
+ fontWeight: '600',
1425
+ color: '#f1f5f9',
1426
+ },
1427
+ });
1428
+
1429
+ const runnerAuthModalStyles = StyleSheet.create({
1430
+ overlay: {
1431
+ flex: 1,
1432
+ justifyContent: 'center',
1433
+ alignItems: 'center',
1434
+ backgroundColor: 'rgba(2,6,23,0.75)',
1435
+ padding: 16,
1436
+ },
1437
+ card: {
1438
+ width: '100%',
1439
+ maxWidth: 420,
1440
+ backgroundColor: '#0f172a',
1441
+ borderRadius: 14,
1442
+ borderWidth: 1,
1443
+ borderColor: 'rgba(148,163,184,0.18)',
1444
+ padding: 18,
1445
+ },
1446
+ header: {
1447
+ flexDirection: 'row',
1448
+ alignItems: 'flex-start',
1449
+ marginBottom: 12,
1450
+ },
1451
+ title: { color: '#f1f5f9', fontSize: 16, fontWeight: '600' },
1452
+ subtitle: { color: '#94a3b8', fontSize: 11, marginTop: 2 },
1453
+ close: { color: '#94a3b8', fontSize: 22, lineHeight: 22, paddingHorizontal: 4 },
1454
+ dim: {
1455
+ color: '#94a3b8',
1456
+ fontSize: 12,
1457
+ padding: 12,
1458
+ borderRadius: 10,
1459
+ borderWidth: 1,
1460
+ borderColor: 'rgba(148,163,184,0.2)',
1461
+ backgroundColor: 'rgba(15,23,42,0.6)',
1462
+ },
1463
+ errorBox: {
1464
+ padding: 12,
1465
+ borderRadius: 10,
1466
+ borderWidth: 1,
1467
+ borderColor: 'rgba(248,113,113,0.35)',
1468
+ backgroundColor: 'rgba(248,113,113,0.1)',
1469
+ },
1470
+ errorTitle: { color: '#fca5a5', fontWeight: '600', marginBottom: 4, fontSize: 13 },
1471
+ errorBody: { color: '#fca5a5', fontSize: 12 },
1472
+ successBox: {
1473
+ padding: 14,
1474
+ borderRadius: 10,
1475
+ borderWidth: 1,
1476
+ borderColor: 'rgba(34,197,94,0.35)',
1477
+ backgroundColor: 'rgba(34,197,94,0.1)',
1478
+ },
1479
+ successTitle: { color: '#4ade80', fontSize: 14, fontWeight: '600', marginBottom: 4 },
1480
+ successBody: { color: '#86efac', fontSize: 12 },
1481
+ urlBox: {
1482
+ padding: 12,
1483
+ borderRadius: 10,
1484
+ borderWidth: 1,
1485
+ borderColor: 'rgba(99,102,241,0.35)',
1486
+ backgroundColor: 'rgba(99,102,241,0.1)',
1487
+ },
1488
+ urlText: { color: '#c7d2fe', fontSize: 13 },
1489
+ codeLabel: {
1490
+ fontSize: 10,
1491
+ fontWeight: '600',
1492
+ color: '#94a3b8',
1493
+ letterSpacing: 0.8,
1494
+ marginBottom: 4,
1495
+ },
1496
+ codeBox: {
1497
+ padding: 14,
1498
+ borderRadius: 10,
1499
+ borderWidth: 1,
1500
+ borderColor: 'rgba(148,163,184,0.22)',
1501
+ backgroundColor: 'rgba(15,23,42,0.8)',
1502
+ alignItems: 'center',
1503
+ },
1504
+ codeText: {
1505
+ color: '#f1f5f9',
1506
+ fontSize: 22,
1507
+ letterSpacing: 6,
1508
+ fontFamily: 'Menlo',
1509
+ },
1510
+ codeHint: { color: '#64748b', fontSize: 10, marginTop: 4, textTransform: 'uppercase' },
1511
+ phishingHint: {
1512
+ color: '#475569',
1513
+ fontSize: 10,
1514
+ marginTop: 12,
1515
+ lineHeight: 14,
1516
+ },
1517
+ });
package/src/P2PClient.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Platform } from 'react-native';
2
- import { FeedbackBundle, TestSession, VoiceCapability } from './types';
2
+ import { FeedbackBundle, RunnerBrowserAuthSession, TestSession, VoiceCapability } from './types';
3
3
 
4
4
  export interface FeedbackEvent {
5
5
  type: string;
@@ -117,10 +117,18 @@ function friendlyReloadError(status: number, body: string): string {
117
117
  export class P2PClient {
118
118
  private baseUrl: string;
119
119
  private authToken: string;
120
+ /**
121
+ * Shared relay password. Required when baseUrl points through the
122
+ * Yaver managed relay (e.g. https://public.yaver.io/d/<deviceId>) —
123
+ * the relay rejects unauthenticated requests with 401. Attached as
124
+ * X-Relay-Password on every agent request.
125
+ */
126
+ private relayPassword: string;
120
127
 
121
- constructor(baseUrl: string, authToken: string) {
128
+ constructor(baseUrl: string, authToken: string, relayPassword: string = '') {
122
129
  this.baseUrl = baseUrl.replace(/\/$/, '');
123
130
  this.authToken = authToken;
131
+ this.relayPassword = relayPassword;
124
132
  }
125
133
 
126
134
  /** Update the base URL (e.g. after re-discovery). */
@@ -133,6 +141,56 @@ export class P2PClient {
133
141
  this.authToken = token;
134
142
  }
135
143
 
144
+ /** Update the relay password (used for managed-relay baseUrls). */
145
+ setRelayPassword(password: string): void {
146
+ this.relayPassword = password;
147
+ }
148
+
149
+ /** Merge in Authorization + (optional) X-Relay-Password on top of a header block. */
150
+ private authHeaders(extra: Record<string, string> = {}): Record<string, string> {
151
+ const h: Record<string, string> = { ...extra };
152
+ if (this.authToken) h.Authorization = `Bearer ${this.authToken}`;
153
+ if (this.relayPassword) h['X-Relay-Password'] = this.relayPassword;
154
+ return h;
155
+ }
156
+
157
+ /**
158
+ * Start a remote browser-style sign-in for a runner (codex --device-auth
159
+ * / claude auth login --console). Returns a session id; callers poll
160
+ * getRunnerBrowserAuthStatus to surface the verification URL + one-time
161
+ * code. No API keys involved — the CLI writes its own auth.json once
162
+ * the user completes the flow in any browser.
163
+ */
164
+ async startRunnerBrowserAuth(runner: string): Promise<RunnerBrowserAuthSession> {
165
+ const resp = await fetch(`${this.baseUrl}/runner-auth/browser/start`, {
166
+ method: 'POST',
167
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
168
+ body: JSON.stringify({ runner }),
169
+ });
170
+ if (!resp.ok) {
171
+ const text = await resp.text().catch(() => '');
172
+ throw new Error(`startRunnerBrowserAuth(${runner}) HTTP ${resp.status}: ${text}`);
173
+ }
174
+ const data = await resp.json();
175
+ return data.session as RunnerBrowserAuthSession;
176
+ }
177
+
178
+ async getRunnerBrowserAuthStatus(sessionId: string): Promise<RunnerBrowserAuthSession> {
179
+ const url = `${this.baseUrl}/runner-auth/browser/status?id=${encodeURIComponent(sessionId)}`;
180
+ const resp = await fetch(url, { headers: this.authHeaders() });
181
+ if (!resp.ok) {
182
+ const text = await resp.text().catch(() => '');
183
+ throw new Error(`getRunnerBrowserAuthStatus HTTP ${resp.status}: ${text}`);
184
+ }
185
+ const data = await resp.json();
186
+ return data.session as RunnerBrowserAuthSession;
187
+ }
188
+
189
+ async cancelRunnerBrowserAuth(sessionId: string): Promise<void> {
190
+ const url = `${this.baseUrl}/runner-auth/browser/cancel?id=${encodeURIComponent(sessionId)}`;
191
+ try { await fetch(url, { method: 'POST', headers: this.authHeaders() }); } catch { /* best-effort */ }
192
+ }
193
+
136
194
  /** Health check — returns true if the agent is reachable. */
137
195
  async health(): Promise<boolean> {
138
196
  try {
@@ -682,9 +740,7 @@ export class P2PClient {
682
740
  private async request(method: string, path: string): Promise<Response> {
683
741
  const response = await fetch(`${this.baseUrl}${path}`, {
684
742
  method,
685
- headers: {
686
- Authorization: `Bearer ${this.authToken}`,
687
- },
743
+ headers: this.authHeaders(),
688
744
  });
689
745
 
690
746
  if (!response.ok) {
@@ -133,6 +133,7 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
133
133
  const [shakenThisSession, setShakenThisSession] = useState(false);
134
134
  const [menuOpen, setMenuOpen] = useState(false);
135
135
  const [hostSuppressed] = useState<boolean>(() => isRunningInsideYaverHost());
136
+ const [launching, setLaunching] = useState(false);
136
137
 
137
138
  // Load the persisted disable flag once on mount. Until it resolves we
138
139
  // render nothing — a one-frame flash of the icon before hiding would
@@ -194,6 +195,37 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
194
195
  };
195
196
  }, []);
196
197
 
198
+ useEffect(() => {
199
+ const launchSub = DeviceEventEmitter.addListener(
200
+ 'yaverFeedback:reportLaunch',
201
+ (event?: { state?: string }) => {
202
+ if (event?.state === 'starting') {
203
+ setLaunching(true);
204
+ return;
205
+ }
206
+ setLaunching(false);
207
+ },
208
+ );
209
+ const reportSub = DeviceEventEmitter.addListener(
210
+ 'yaverFeedback:startReport',
211
+ () => setLaunching(false),
212
+ );
213
+ const loginSub = DeviceEventEmitter.addListener(
214
+ 'yaverFeedback:startLogin',
215
+ () => setLaunching(false),
216
+ );
217
+ const pickerSub = DeviceEventEmitter.addListener(
218
+ 'yaverFeedback:startMachinePicker',
219
+ () => setLaunching(false),
220
+ );
221
+ return () => {
222
+ launchSub.remove();
223
+ reportSub.remove();
224
+ loginSub.remove();
225
+ pickerSub.remove();
226
+ };
227
+ }, []);
228
+
197
229
  const panResponder = useRef(
198
230
  PanResponder.create({
199
231
  onStartShouldSetPanResponder: () => true,
@@ -231,9 +263,10 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
231
263
  ).current;
232
264
 
233
265
  const openFeedback = useCallback(() => {
266
+ if (launching) return;
234
267
  setMenuOpen(false);
235
268
  void YaverFeedback.startReport();
236
- }, []);
269
+ }, [launching]);
237
270
 
238
271
  const hideForever = useCallback(() => {
239
272
  setMenuOpen(false);
@@ -275,10 +308,12 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
275
308
  didDrag.current = false;
276
309
  return;
277
310
  }
311
+ if (launching) return;
278
312
  openFeedback();
279
313
  }}
280
314
  onLongPress={() => {
281
315
  if (didDrag.current) return;
316
+ if (launching) return;
282
317
  setMenuOpen((m) => !m);
283
318
  }}
284
319
  delayLongPress={LONG_PRESS_MS}
@@ -294,7 +329,7 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
294
329
  backgroundColor: presetColors?.backgroundColor ?? backgroundColor,
295
330
  borderColor: presetColors?.borderColor ?? borderColor,
296
331
  shadowColor: presetColors?.shadowColor ?? shadowColor,
297
- opacity: pressed ? 0.85 : 1,
332
+ opacity: launching ? 0.62 : pressed ? 0.85 : 1,
298
333
  },
299
334
  ]}
300
335
  >
@@ -51,6 +51,7 @@ let enabled = false;
51
51
  let p2pClient: P2PClient | null = null;
52
52
  let shakeDetector: ShakeDetector | null = null;
53
53
  let p2pAuthToken: string | null = null;
54
+ let reportLaunchInFlight = false;
54
55
 
55
56
  /** Ring buffer of captured errors. */
56
57
  let errorBuffer: CapturedError[] = [];
@@ -420,6 +421,37 @@ export class YaverFeedback {
420
421
  return all.find((device) => device.deviceId === preferredDeviceId) ?? null;
421
422
  }
422
423
 
424
+ /**
425
+ * Trigger remote device-auth for a CLI runner on the selected agent
426
+ * (codex login --device-auth / claude auth login --console). Returns
427
+ * the session so the host UI can render the verification URL + code.
428
+ *
429
+ * RN UI layer owns the modal (see FeedbackModal's runner sign-in
430
+ * buttons). This method just proxies into P2PClient — no browser
431
+ * launch, no API keys, works through the relay with an SDK token
432
+ * that carries the runner-auth scope.
433
+ */
434
+ static async startRunnerBrowserAuth(
435
+ runner: string,
436
+ ): Promise<import('./types').RunnerBrowserAuthSession> {
437
+ if (!p2pClient) {
438
+ throw new Error('Not connected to any agent. Select a machine first.');
439
+ }
440
+ return p2pClient.startRunnerBrowserAuth(runner);
441
+ }
442
+
443
+ static async getRunnerBrowserAuthStatus(
444
+ sessionId: string,
445
+ ): Promise<import('./types').RunnerBrowserAuthSession> {
446
+ if (!p2pClient) throw new Error('Not connected to any agent.');
447
+ return p2pClient.getRunnerBrowserAuthStatus(sessionId);
448
+ }
449
+
450
+ static async cancelRunnerBrowserAuth(sessionId: string): Promise<void> {
451
+ if (!p2pClient) return;
452
+ await p2pClient.cancelRunnerBrowserAuth(sessionId);
453
+ }
454
+
423
455
  /**
424
456
  * Sign out: clear cached token + device, tear down the P2P client. The
425
457
  * SDK stays enabled; the next feedback trigger will re-prompt for login.
@@ -450,47 +482,64 @@ export class YaverFeedback {
450
482
  if (!enabled) {
451
483
  return;
452
484
  }
485
+ if (reportLaunchInFlight) {
486
+ return;
487
+ }
453
488
 
454
- // If the caller has autoLogin enabled and we have no session yet, show
455
- // the in-SDK login flow instead of a failing discovery + warning spam.
456
- if (!config.authToken) {
457
- if (config.autoLogin !== false) {
458
- await YaverFeedback.hydrateSession();
459
- }
489
+ reportLaunchInFlight = true;
490
+ const { DeviceEventEmitter } = require('react-native');
491
+ DeviceEventEmitter.emit('yaverFeedback:reportLaunch', {
492
+ state: 'starting',
493
+ at: Date.now(),
494
+ });
495
+ try {
496
+
497
+ // If the caller has autoLogin enabled and we have no session yet, show
498
+ // the in-SDK login flow instead of a failing discovery + warning spam.
460
499
  if (!config.authToken) {
461
- YaverFeedback.showLogin();
462
- return;
500
+ if (config.autoLogin !== false) {
501
+ await YaverFeedback.hydrateSession();
502
+ }
503
+ if (!config.authToken) {
504
+ YaverFeedback.showLogin();
505
+ return;
506
+ }
463
507
  }
464
- }
465
508
 
466
- // Auto-discover if no agent URL was provided
467
- if (!config.agentUrl) {
468
- try {
469
- const result = await YaverDiscovery.discover({
470
- convexUrl: config.convexUrl,
471
- authToken: config.authToken,
472
- preferredDeviceId: config.preferredDeviceId,
473
- });
474
- if (result) {
475
- config.agentUrl = result.url;
476
- await YaverFeedback.rebuildP2PClient(result.url);
477
- } else if (config.autoLogin !== false && !config.preferredDeviceId) {
478
- // No agent discovered and no device picked yet — prompt the user
479
- // to pick one of their machines (handles the non-LAN case where
480
- // relay discovery requires knowing which deviceId to target).
481
- YaverFeedback.showMachinePicker();
482
- return;
483
- } else {
484
- console.warn('[YaverFeedback] No agent found. Check that `yaver serve` is running on the selected machine.');
509
+ // Auto-discover if no agent URL was provided
510
+ if (!config.agentUrl) {
511
+ try {
512
+ const result = await YaverDiscovery.discover({
513
+ convexUrl: config.convexUrl,
514
+ authToken: config.authToken,
515
+ preferredDeviceId: config.preferredDeviceId,
516
+ });
517
+ if (result) {
518
+ config.agentUrl = result.url;
519
+ await YaverFeedback.rebuildP2PClient(result.url);
520
+ } else if (config.autoLogin !== false && !config.preferredDeviceId) {
521
+ // No agent discovered and no device picked yet — prompt the user
522
+ // to pick one of their machines (handles the non-LAN case where
523
+ // relay discovery requires knowing which deviceId to target).
524
+ YaverFeedback.showMachinePicker();
525
+ return;
526
+ } else {
527
+ console.warn('[YaverFeedback] No agent found. Check that `yaver serve` is running on the selected machine.');
528
+ }
529
+ } catch (err) {
530
+ console.warn('[YaverFeedback] Auto-discovery failed:', err);
485
531
  }
486
- } catch (err) {
487
- console.warn('[YaverFeedback] Auto-discovery failed:', err);
488
532
  }
489
- }
490
533
 
491
- // Emit event that the FeedbackModal listens for
492
- const { DeviceEventEmitter } = require('react-native');
493
- DeviceEventEmitter.emit('yaverFeedback:startReport');
534
+ // Emit event that the FeedbackModal listens for
535
+ DeviceEventEmitter.emit('yaverFeedback:startReport');
536
+ } finally {
537
+ reportLaunchInFlight = false;
538
+ DeviceEventEmitter.emit('yaverFeedback:reportLaunch', {
539
+ state: 'settled',
540
+ at: Date.now(),
541
+ });
542
+ }
494
543
  }
495
544
 
496
545
  /** Returns true if the SDK has been initialized. */