yaver-feedback-react-native 0.9.0 → 0.9.3

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.
Files changed (144) hide show
  1. package/README.md +102 -1
  2. package/app.plugin.js +126 -9
  3. package/dist/AuthOverlay.d.ts +1 -14
  4. package/dist/AuthOverlay.js +9 -62
  5. package/dist/BlackBox.js +9 -1
  6. package/dist/DeployPanel.js +65 -0
  7. package/dist/Discovery.js +13 -8
  8. package/dist/DogfoodRuntime.d.ts +124 -0
  9. package/dist/DogfoodRuntime.js +273 -0
  10. package/dist/FeedbackModal.js +505 -65
  11. package/dist/LoginScreen.d.ts +1 -5
  12. package/dist/LoginScreen.js +2 -6
  13. package/dist/MachinePickerScreen.d.ts +1 -3
  14. package/dist/MachinePickerScreen.js +6 -18
  15. package/dist/P2PClient.d.ts +178 -2
  16. package/dist/P2PClient.js +493 -3
  17. package/dist/P2PDogfoodDriver.d.ts +12 -0
  18. package/dist/P2PDogfoodDriver.js +118 -0
  19. package/dist/PairDeviceModal.d.ts +2 -3
  20. package/dist/ShakeDetector.js +2 -0
  21. package/dist/VibeChatScreen.d.ts +11 -1
  22. package/dist/VibeChatScreen.js +200 -41
  23. package/dist/YaverFeedback.d.ts +132 -0
  24. package/dist/YaverFeedback.js +633 -65
  25. package/dist/YaverModeBadge.d.ts +22 -0
  26. package/dist/YaverModeBadge.js +219 -0
  27. package/dist/__tests__/AuthDevices.test.js +1 -48
  28. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  29. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  30. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  31. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  32. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  33. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  34. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  35. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  36. package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
  37. package/dist/__tests__/DogfoodRuntime.test.js +116 -0
  38. package/dist/__tests__/P2PClient.test.js +2 -2
  39. package/dist/__tests__/P2PDogfoodDriver.test.d.ts +1 -0
  40. package/dist/__tests__/P2PDogfoodDriver.test.js +64 -0
  41. package/dist/__tests__/ReportIdentity.test.d.ts +25 -0
  42. package/dist/__tests__/ReportIdentity.test.js +180 -0
  43. package/dist/__tests__/SDKToken.test.js +1 -1
  44. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  45. package/dist/__tests__/ShakeToggle.test.js +121 -0
  46. package/dist/__tests__/YaverFeedback.test.js +13 -3
  47. package/dist/__tests__/deviceDogfood.test.d.ts +1 -0
  48. package/dist/__tests__/deviceDogfood.test.js +86 -0
  49. package/dist/__tests__/dogfoodPolicy.test.d.ts +1 -0
  50. package/dist/__tests__/dogfoodPolicy.test.js +33 -0
  51. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  52. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  53. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  54. package/dist/__tests__/reloadActions.test.js +129 -0
  55. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  56. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  57. package/dist/__tests__/types.test.js +5 -5
  58. package/dist/_core/ansi.d.ts +117 -0
  59. package/dist/_core/ansi.js +468 -0
  60. package/dist/_core/ansi.test.d.ts +1 -0
  61. package/dist/_core/ansi.test.js +225 -0
  62. package/dist/_core/buildFeedbackPrompt.d.ts +4 -9
  63. package/dist/_core/buildFeedbackPrompt.js +18 -72
  64. package/dist/_core/constants.d.ts +19 -6
  65. package/dist/_core/constants.js +20 -7
  66. package/dist/_core/device.d.ts +1 -5
  67. package/dist/_core/device.js +5 -14
  68. package/dist/_core/endpoints.d.ts +0 -7
  69. package/dist/_core/endpoints.js +0 -7
  70. package/dist/_core/index.d.ts +4 -0
  71. package/dist/_core/index.js +4 -0
  72. package/dist/_core/remoteless.d.ts +44 -0
  73. package/dist/_core/remoteless.js +75 -0
  74. package/dist/_core/trace.d.ts +47 -0
  75. package/dist/_core/trace.js +38 -0
  76. package/dist/_core/trace.test.d.ts +1 -0
  77. package/dist/_core/trace.test.js +60 -0
  78. package/dist/auth.d.ts +3 -60
  79. package/dist/auth.js +6 -88
  80. package/dist/deviceDogfood.d.ts +53 -0
  81. package/dist/deviceDogfood.js +137 -0
  82. package/dist/dogfoodPolicy.d.ts +25 -0
  83. package/dist/dogfoodPolicy.js +24 -0
  84. package/dist/index.d.ts +17 -4
  85. package/dist/index.js +33 -7
  86. package/dist/reloadActions.d.ts +88 -0
  87. package/dist/reloadActions.js +200 -0
  88. package/dist/storeShots.d.ts +67 -0
  89. package/dist/storeShots.js +137 -0
  90. package/dist/types.d.ts +208 -8
  91. package/package.json +13 -4
  92. package/src/AuthOverlay.tsx +20 -106
  93. package/src/BlackBox.ts +10 -1
  94. package/src/DeployPanel.tsx +74 -0
  95. package/src/Discovery.ts +13 -8
  96. package/src/DogfoodRuntime.ts +373 -0
  97. package/src/FeedbackModal.tsx +619 -79
  98. package/src/LoginScreen.tsx +2 -22
  99. package/src/MachinePickerScreen.tsx +6 -21
  100. package/src/P2PClient.ts +580 -4
  101. package/src/P2PDogfoodDriver.ts +132 -0
  102. package/src/PairDeviceModal.tsx +2 -3
  103. package/src/ShakeDetector.ts +1 -0
  104. package/src/VibeChatScreen.tsx +232 -42
  105. package/src/YaverFeedback.ts +631 -68
  106. package/src/YaverModeBadge.tsx +234 -0
  107. package/src/__tests__/AuthDevices.test.ts +1 -52
  108. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  109. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  110. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  111. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  112. package/src/__tests__/DogfoodRuntime.test.ts +135 -0
  113. package/src/__tests__/P2PClient.test.ts +2 -2
  114. package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
  115. package/src/__tests__/ReportIdentity.test.ts +212 -0
  116. package/src/__tests__/SDKToken.test.ts +1 -1
  117. package/src/__tests__/ShakeToggle.test.ts +153 -0
  118. package/src/__tests__/YaverFeedback.test.ts +15 -3
  119. package/src/__tests__/deviceDogfood.test.ts +77 -0
  120. package/src/__tests__/dogfoodPolicy.test.ts +34 -0
  121. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  122. package/src/__tests__/reloadActions.test.ts +171 -0
  123. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  124. package/src/__tests__/types.test.ts +5 -5
  125. package/src/_core/ansi.test.ts +250 -0
  126. package/src/_core/ansi.ts +475 -0
  127. package/src/_core/buildFeedbackPrompt.ts +18 -95
  128. package/src/_core/constants.ts +20 -6
  129. package/src/_core/device.ts +5 -16
  130. package/src/_core/endpoints.ts +0 -7
  131. package/src/_core/index.ts +4 -0
  132. package/src/_core/remoteless.ts +110 -0
  133. package/src/_core/trace.test.ts +58 -0
  134. package/src/_core/trace.ts +75 -0
  135. package/src/auth.ts +7 -156
  136. package/src/deviceDogfood.ts +171 -0
  137. package/src/dogfoodPolicy.ts +40 -0
  138. package/src/index.ts +61 -11
  139. package/src/reloadActions.ts +273 -0
  140. package/src/storeShots.ts +189 -0
  141. package/src/types.ts +209 -8
  142. package/dist/GuestOnboardingScreen.d.ts +0 -8
  143. package/dist/GuestOnboardingScreen.js +0 -282
  144. package/src/GuestOnboardingScreen.tsx +0 -307
@@ -1,16 +1,20 @@
1
- import { NativeModules } from 'react-native';
1
+ import { NativeModules, Platform } from 'react-native';
2
2
  import { FeedbackConfig, CapturedError } from './types';
3
3
  import { YaverDiscovery } from './Discovery';
4
4
  import { BlackBox } from './BlackBox';
5
- import { P2PClient } from './P2PClient';
5
+ import { P2PClient, resolveReportIdentity } from './P2PClient';
6
6
  import { ShakeDetector } from './ShakeDetector';
7
+ import {
8
+ captureStoreScreenshots,
9
+ CaptureStoreScreenshotsOptions,
10
+ CaptureStoreScreenshotsResult,
11
+ } from './storeShots';
7
12
  import {
8
13
  configureAuthEndpoints,
9
14
  setStrictNativeAuth,
10
15
  getToken,
11
16
  getSelectedDeviceId,
12
17
  listReachableDevices,
13
- mintGuestSdkToken,
14
18
  clearToken,
15
19
  clearSelectedDeviceId,
16
20
  DEFAULT_CONVEX_SITE_URL,
@@ -22,6 +26,22 @@ import {
22
26
  setQuickIconColorPreset,
23
27
  QuickIconColorPreset,
24
28
  } from './preferences';
29
+ import { resolveSDKDogfood, type SDKDogfoodStatus } from './dogfoodPolicy';
30
+ import { YaverDeviceDogfood, type DeviceDogfoodOptions, type DeviceDogfoodSession, type DeviceDogfoodState } from './deviceDogfood';
31
+
32
+ export interface DogfoodOnboardingOptions extends DeviceDogfoodOptions {
33
+ /** Hint used to preselect the matching project returned by the owner machine. */
34
+ projectName?: string;
35
+ /** Framework fallback when the machine has not classified the project yet. */
36
+ framework?: string;
37
+ }
38
+
39
+ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
40
+ const maybeNodeTimer = timer as unknown as { unref?: () => void };
41
+ if (typeof maybeNodeTimer.unref === 'function') {
42
+ maybeNodeTimer.unref();
43
+ }
44
+ }
25
45
 
26
46
  // Is this JS runtime the Yaver mobile app's super-host bridge? The
27
47
  // YaverInfo native module is only registered inside Yaver's container
@@ -146,10 +166,19 @@ if (IS_HOST_MODE) {
146
166
  let config: FeedbackConfig | null = null;
147
167
  let enabled = false;
148
168
  let p2pClient: P2PClient | null = null;
169
+ let renderP2PClient: P2PClient | null = null;
149
170
  let shakeDetector: ShakeDetector | null = null;
171
+ /** Unsubscribe for the BlackBox command handler registered by init(). Held so
172
+ * a re-init (or destroy) can drop the old one instead of stacking. */
173
+ let commandUnsubscribe: (() => void) | null = null;
150
174
  let p2pAuthToken: string | null = null;
151
175
  let p2pRelayPassword: string = '';
176
+ /** Pending BlackBox auto-start retry. Held so destroy()/re-init can cancel it
177
+ * instead of leaving a timer chain running against a torn-down config. */
178
+ let autoStartTimer: ReturnType<typeof setTimeout> | null = null;
152
179
  let reportLaunchInFlight = false;
180
+ let crashReportInFlight = false;
181
+ let dogfoodOnboarding: DogfoodOnboardingOptions | null = null;
153
182
 
154
183
  /** Resolve the user's relay password by validating their auth token
155
184
  * against Convex. Used whenever we (re)build the P2PClient so a
@@ -196,6 +225,7 @@ async function resolveRelayPassword(authToken: string, convexUrl?: string): Prom
196
225
  /** Ring buffer of captured errors. */
197
226
  let errorBuffer: CapturedError[] = [];
198
227
  let maxErrors = 5;
228
+ let crashHandlerInstalled = false;
199
229
 
200
230
  /** Track whether BlackBox was running before disable (to restart on enable). */
201
231
  let blackBoxWasStreaming = false;
@@ -221,42 +251,35 @@ const flagCache: Map<string, { value: unknown; at: number }> = new Map();
221
251
  */
222
252
  export class YaverFeedback {
223
253
  private static async resolveP2PAuthToken(): Promise<string | null> {
224
- if (!config?.authToken) return null;
225
- if (!config.preferredDeviceId) return config.authToken;
226
- const devices = await listReachableDevices(config.authToken);
227
- const all = [...devices.owned, ...devices.shared];
228
- const selected = all.find((device) => device.deviceId === config?.preferredDeviceId);
229
- if (!selected || !selected.isGuest || selected.accessScope !== 'shared-scoped') {
230
- return config.authToken;
231
- }
232
- if (!selected.hostUserId) {
233
- return config.authToken;
234
- }
235
- const delegated = await mintGuestSdkToken(
236
- config.authToken,
237
- selected.hostUserId,
238
- selected.deviceId,
239
- );
240
- return delegated.token;
254
+ return config?.authToken ?? null;
241
255
  }
242
256
 
243
257
  private static async rebuildP2PClient(agentUrl?: string): Promise<void> {
244
- if (!config) return;
245
- const effectiveUrl = agentUrl ?? config.agentUrl;
258
+ const currentConfig = config;
259
+ if (!currentConfig) return;
260
+ const effectiveUrl = agentUrl ?? currentConfig.agentUrl;
246
261
  if (!effectiveUrl) {
247
262
  p2pClient = null;
263
+ renderP2PClient = null;
248
264
  p2pAuthToken = null;
249
265
  return;
250
266
  }
251
267
  const token = await YaverFeedback.resolveP2PAuthToken();
268
+ // reset() can clear config while relay-password resolution is awaiting.
269
+ // Do not resurrect a client from that stale init generation.
270
+ if (config !== currentConfig) return;
252
271
  if (!token) {
253
272
  p2pClient = null;
273
+ renderP2PClient = null;
254
274
  p2pAuthToken = null;
255
275
  return;
256
276
  }
257
277
  p2pAuthToken = token;
258
278
  const rp = await resolveRelayPassword(token);
279
+ if (config !== currentConfig) return;
259
280
  p2pClient = new P2PClient(effectiveUrl, token, rp);
281
+ const renderUrl = currentConfig.renderAgentUrl || effectiveUrl;
282
+ renderP2PClient = renderUrl === effectiveUrl ? p2pClient : new P2PClient(renderUrl, token, rp);
260
283
  }
261
284
 
262
285
  /**
@@ -266,12 +289,88 @@ export class YaverFeedback {
266
289
  * If no `agentUrl` is provided, the SDK will attempt auto-discovery
267
290
  * via `YaverDiscovery` on the first `startReport()` call.
268
291
  */
292
+ /** The runtime lane, when running on web inside a Yaver preview WebView. */
293
+ static detectWebLane(): 'browser' | 'webrtc' | null {
294
+ try {
295
+ if (Platform.OS !== 'web' || typeof window === 'undefined') return null;
296
+ const l = String((window as unknown as { __yaverLane?: string }).__yaverLane || '').toLowerCase();
297
+ if (l === 'browser' || l === 'webrtc') return l;
298
+ } catch { /* noop */ }
299
+ return null;
300
+ }
301
+
302
+ /**
303
+ * Mount a draggable DOM floating "Y" icon for the BROWSER lane (RN-web). It
304
+ * lives in the WebView's document.body (position:fixed, max z-index), so it
305
+ * renders ON TOP of the page inside the WebView and is never occluded by the
306
+ * fullScreen preview modal — the one occlusion-proof affordance on iOS.
307
+ * Tap opens the SDK's existing report flow (the RN FeedbackModal renders in
308
+ * the same WebView DOM, also un-occluded). No-op off-web or off-lane, and
309
+ * idempotent (a re-init reuses the existing node).
310
+ */
311
+ static mountBrowserLaneIcon(): void {
312
+ if (YaverFeedback.detectWebLane() !== 'browser') return;
313
+ try {
314
+ const doc = (globalThis as unknown as { document?: Document }).document;
315
+ if (!doc || !doc.body) return;
316
+ if (doc.getElementById('yaver-feedback-btn')) return; // idempotent
317
+ const btn = doc.createElement('div');
318
+ btn.id = 'yaver-feedback-btn';
319
+ btn.textContent = 'Y';
320
+ btn.title = 'Yaver Feedback — drag to move · tap to report';
321
+ btn.style.cssText =
322
+ 'position:fixed;bottom:20px;right:20px;width:44px;height:44px;border-radius:50%;' +
323
+ 'background:#6366f1;color:#fff;display:flex;align-items:center;justify-content:center;' +
324
+ 'cursor:pointer;z-index:99999;font-size:18px;font-weight:bold;touch-action:none;' +
325
+ 'box-shadow:0 4px 12px rgba(99,102,241,0.4);transition:transform 0.2s;';
326
+ const drag = { on: false, moved: false, sx: 0, sy: 0, ox: 0, oy: 0 };
327
+ const w = window as unknown as { innerWidth: number; innerHeight: number };
328
+ const applyPos = (x: number, y: number) => {
329
+ const m = 8;
330
+ const nx = Math.max(m, Math.min(w.innerWidth - 44 - m, x));
331
+ const ny = Math.max(m, Math.min(w.innerHeight - 44 - m, y));
332
+ btn.style.left = nx + 'px'; btn.style.top = ny + 'px';
333
+ btn.style.right = 'auto'; btn.style.bottom = 'auto';
334
+ try { localStorage.setItem('yaver-feedback-btn-pos', JSON.stringify({ x: nx, y: ny })); } catch { /* noop */ }
335
+ };
336
+ try { const s = localStorage.getItem('yaver-feedback-btn-pos'); if (s) { const p = JSON.parse(s); if (typeof p?.x === 'number') applyPos(p.x, p.y); } } catch { /* noop */ }
337
+ btn.addEventListener('pointerdown', (e: PointerEvent) => {
338
+ drag.on = true; drag.moved = false;
339
+ const r = btn.getBoundingClientRect();
340
+ drag.sx = e.clientX; drag.sy = e.clientY; drag.ox = r.left; drag.oy = r.top;
341
+ btn.setPointerCapture(e.pointerId); btn.style.transition = 'none';
342
+ });
343
+ btn.addEventListener('pointermove', (e: PointerEvent) => {
344
+ if (!drag.on) return;
345
+ const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
346
+ if (Math.abs(dx) + Math.abs(dy) > 5) drag.moved = true;
347
+ if (drag.moved) applyPos(drag.ox + dx, drag.oy + dy);
348
+ });
349
+ const end = (e: PointerEvent) => {
350
+ if (!drag.on) return;
351
+ drag.on = false; btn.style.transition = 'transform 0.2s';
352
+ try { btn.releasePointerCapture(e.pointerId); } catch { /* noop */ }
353
+ if (!drag.moved) { try { void YaverFeedback.startReport(); } catch { /* noop */ } }
354
+ };
355
+ btn.addEventListener('pointerup', end);
356
+ btn.addEventListener('pointercancel', end);
357
+ // Also answer the host's forwarded shake (Yaver injects yaver-feedback:launch).
358
+ const launch = () => { try { void YaverFeedback.startReport(); } catch { /* noop */ } };
359
+ window.addEventListener('yaver-feedback:launch', launch as EventListener);
360
+ (window as unknown as { __yaverFeedbackLaunch?: () => void }).__yaverFeedbackLaunch = launch;
361
+ doc.body.appendChild(btn);
362
+ } catch { /* noop — never break the guest app over a feedback affordance */ }
363
+ }
364
+
269
365
  static init(cfg: FeedbackConfig): void {
270
366
  config = {
271
367
  trigger: 'shake',
272
368
  maxRecordingDuration: 120,
273
369
  autoLogin: true,
274
370
  ...cfg,
371
+ agentUrl: cfg.codingAgentUrl || cfg.agentUrl,
372
+ codingDeviceId: cfg.codingDeviceId || cfg.preferredDeviceId,
373
+ renderDeviceId: cfg.renderDeviceId || cfg.codingDeviceId || cfg.preferredDeviceId,
275
374
  };
276
375
  if (IS_HOST_MODE) {
277
376
  // sfmg / talos / etc. running inside Yaver mobile (compile-time
@@ -293,6 +392,9 @@ export class YaverFeedback {
293
392
  }
294
393
  maxErrors = cfg.maxCapturedErrors ?? 5;
295
394
  errorBuffer = [];
395
+ if (cfg.crashReporting?.enabled && cfg.crashReporting?.installGlobalHandler) {
396
+ YaverFeedback.installCrashHandler();
397
+ }
296
398
  return;
297
399
  }
298
400
  if (config.disableShakeGesture && (!config.quickIcon || config.quickIcon === 'auto')) {
@@ -345,11 +447,16 @@ export class YaverFeedback {
345
447
  // is set, so set a placeholder header here that won't 401 a
346
448
  // direct-LAN url and will be overwritten before any relay hop.
347
449
  p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '', p2pRelayPassword);
450
+ const renderUrl = config.renderAgentUrl || config.agentUrl;
451
+ renderP2PClient = renderUrl === config.agentUrl
452
+ ? p2pClient
453
+ : new P2PClient(renderUrl, config.authToken ?? '', p2pRelayPassword);
348
454
  if (config.authToken) {
349
455
  void YaverFeedback.rebuildP2PClient(config.agentUrl);
350
456
  }
351
457
  } else {
352
458
  p2pClient = null;
459
+ renderP2PClient = null;
353
460
  // Auto-discover agent in the background when convexUrl or LAN is available
354
461
  if (enabled && (config.authToken || config.preferredDeviceId)) {
355
462
  YaverFeedback.discoverAgent();
@@ -359,24 +466,25 @@ export class YaverFeedback {
359
466
  // Set up error capture buffer size
360
467
  maxErrors = cfg.maxCapturedErrors ?? 5;
361
468
  errorBuffer = [];
362
-
363
- // Wire up shake detection when trigger is 'shake'
364
- if (shakeDetector) {
365
- shakeDetector.stop();
366
- shakeDetector = null;
367
- }
368
- if (enabled && config.trigger === 'shake' && !config.disableShakeGesture) {
369
- shakeDetector = new ShakeDetector();
370
- shakeDetector.start(() => {
371
- YaverFeedback.notifyShake();
372
- if (config?.reportingOnly) {
373
- YaverFeedback.sendAutoReport();
374
- } else {
375
- YaverFeedback.startReport();
376
- }
377
- });
469
+ if (cfg.crashReporting?.enabled && cfg.crashReporting?.installGlobalHandler) {
470
+ YaverFeedback.installCrashHandler();
378
471
  }
379
472
 
473
+ // Wire up shake detection when trigger is 'shake'. Rebuild from scratch:
474
+ // config was just replaced, so the old detector may be closing over stale
475
+ // settings.
476
+ YaverFeedback.stopShakeDetector();
477
+ YaverFeedback.syncShakeDetector();
478
+
479
+ // Browser lane (RN-web inside Yaver's fullScreen WebView preview): the
480
+ // native shake module doesn't exist on web, and Yaver's own container
481
+ // overlay is OCCLUDED behind the WebView modal on iOS. So mount an
482
+ // occlusion-proof DRAGGABLE DOM icon INSIDE the WebView — same pattern as
483
+ // yaver-feedback-web. Lane is stamped by Yaver via
484
+ // window.__yaverLane='browser' (mobile PREVIEW_LANE_SCRIPT). See
485
+ // docs/audits/feedback-sdk-lanes-audit-2026-07-28.md.
486
+ if (enabled) YaverFeedback.mountBrowserLaneIcon();
487
+
380
488
  // Wire up BlackBox command handlers for reload + status signals from
381
489
  // the agent. Opens an SSE channel the agent uses to:
382
490
  // - broadcast reload_bundle so the phone picks up a fresh Hermes
@@ -385,7 +493,14 @@ export class YaverFeedback {
385
493
  // assets…", "Done") so the SDK can surface it like a normal
386
494
  // "Working…" spinner instead of a silent 60-second freeze.
387
495
  if (enabled) {
388
- BlackBox.onCommand((cmd) => {
496
+ // onCommand appends to a static array and hands back an unsubscribe.
497
+ // Dropping that unsubscribe meant every re-init stacked another handler
498
+ // on top of the last, so an app that re-initialised (say, a settings
499
+ // toggle) reloaded twice per agent command, then three times, and so on.
500
+ // destroy() never cleared them either. Drop the previous registration
501
+ // first so init() is idempotent here.
502
+ commandUnsubscribe?.();
503
+ commandUnsubscribe = BlackBox.onCommand((cmd) => {
389
504
  if (cmd.command === 'reload') {
390
505
  if (cfg.onReload) {
391
506
  cfg.onReload();
@@ -447,16 +562,24 @@ export class YaverFeedback {
447
562
  // SFMG used to call BlackBox.start() inside YaverFeedbackWidget
448
563
  // after auth — that path still works (start() is idempotent), so
449
564
  // upgrading SDK without removing the manual call is safe.
565
+ // 4. RETRY, rather than checking once. This was a single 500ms
566
+ // timeout, and on a cold start it could not succeed: an app that
567
+ // passes neither agentUrl nor authToken to init() (the normal
568
+ // case — they're restored from storage) gets its session from
569
+ // `void hydrateSession()`, which reads two AsyncStorage keys and
570
+ // then awaits discoverAgent(), a Convex round trip plus LAN
571
+ // probing. That does not finish in 500ms, so the check found no
572
+ // agentUrl, gave up, and BlackBox never started — meaning no SSE
573
+ // channel, so the agent's `reload_bundle` command had nowhere to
574
+ // land and Hermes hot reload silently did nothing until the user
575
+ // shook the device and drove discovery by hand.
576
+ //
577
+ // The both-conditions guard is what keeps the 401 retry storm
578
+ // from coming back, so it is preserved exactly; we only re-check
579
+ // it over time instead of once. Bounded so a device that never
580
+ // signs in doesn't poll forever.
450
581
  if (cfg.autoStartBlackBox !== false) {
451
- setTimeout(() => {
452
- if (config?.agentUrl && (config?.authToken || p2pAuthToken)) {
453
- try {
454
- BlackBox.start();
455
- } catch (err) {
456
- console.warn('[YaverFeedback] BlackBox auto-start failed:', err);
457
- }
458
- }
459
- }, 500);
582
+ YaverFeedback.scheduleBlackBoxAutoStart();
460
583
  }
461
584
  }
462
585
 
@@ -471,6 +594,40 @@ export class YaverFeedback {
471
594
  // pass-through wrapper they insert into their own error chain
472
595
  }
473
596
 
597
+ /**
598
+ * Opt-in global crash handler. This wraps the existing React Native
599
+ * ErrorUtils handler and still calls it, so Sentry/Crashlytics/Bugsnag can
600
+ * remain the system of record. The SDK only uploads a crash report and,
601
+ * when configured, asks the agent to create a fix task.
602
+ */
603
+ static installCrashHandler(): void {
604
+ if (crashHandlerInstalled) return;
605
+ try {
606
+ const errorUtils = (globalThis as any)?.ErrorUtils;
607
+ if (!errorUtils?.getGlobalHandler || !errorUtils?.setGlobalHandler) {
608
+ return;
609
+ }
610
+ const next = errorUtils.getGlobalHandler();
611
+ errorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {
612
+ YaverFeedback.attachError(error, {
613
+ source: 'global-handler',
614
+ crashAware: true,
615
+ });
616
+ if (errorBuffer.length > 0) {
617
+ errorBuffer[errorBuffer.length - 1].isFatal = isFatal ?? true;
618
+ }
619
+ void YaverFeedback.reportCrash(error, {
620
+ isFatal: isFatal ?? true,
621
+ source: 'global-handler',
622
+ });
623
+ next?.(error, isFatal);
624
+ });
625
+ crashHandlerInstalled = true;
626
+ } catch (err) {
627
+ console.warn('[YaverFeedback] installCrashHandler failed:', err);
628
+ }
629
+ }
630
+
474
631
  /**
475
632
  * Run agent discovery in the background.
476
633
  * Called automatically from init() when no agentUrl is provided.
@@ -564,6 +721,12 @@ export class YaverFeedback {
564
721
  } else {
565
722
  await YaverFeedback.discoverAgent();
566
723
  }
724
+ // Signing in is the event the auto-start was waiting for. If its bounded
725
+ // retry already gave up (device left signed out past the window), this is
726
+ // what brings the command channel up without requiring an app restart.
727
+ if (config.autoStartBlackBox !== false && !BlackBox.isStreaming) {
728
+ YaverFeedback.scheduleBlackBoxAutoStart();
729
+ }
567
730
  }
568
731
 
569
732
  /** Returns true once the SDK has a session token it can use. */
@@ -571,6 +734,59 @@ export class YaverFeedback {
571
734
  return Boolean(config?.authToken);
572
735
  }
573
736
 
737
+ /**
738
+ * On-device App Store screenshot capture (Engine 2). Walks the routes
739
+ * the host hands us, screenshots each, and uploads to the agent which
740
+ * runs the App Store Connect backend. agentUrl / authToken / relay
741
+ * password default to the SDK's resolved session; the host only has to
742
+ * supply a navigation ref + the ordered route list.
743
+ *
744
+ * YaverFeedback.captureStoreScreenshots({
745
+ * app: 'sfmg',
746
+ * navigationRef,
747
+ * routes: ['/(tabs)/dashboard', '/(tabs)/messages', '/(tabs)/clients'],
748
+ * submit: true,
749
+ * })
750
+ */
751
+ static async captureStoreScreenshots(
752
+ opts: Omit<CaptureStoreScreenshotsOptions, 'agentUrl' | 'authToken' | 'relayPassword'> &
753
+ Partial<Pick<CaptureStoreScreenshotsOptions, 'agentUrl' | 'authToken' | 'relayPassword'>>,
754
+ ): Promise<CaptureStoreScreenshotsResult> {
755
+ const agentUrl = opts.agentUrl ?? config?.agentUrl ?? '';
756
+ const authToken = opts.authToken ?? config?.authToken ?? p2pAuthToken ?? '';
757
+ const relayPassword = opts.relayPassword ?? p2pRelayPassword;
758
+ if (!agentUrl || !authToken) {
759
+ return {
760
+ ok: false,
761
+ captured: 0,
762
+ uploaded: 0,
763
+ message: 'YaverFeedback not connected to an agent — init() with agentUrl + authToken first.',
764
+ };
765
+ }
766
+ return captureStoreScreenshots({ ...opts, agentUrl, authToken, relayPassword });
767
+ }
768
+
769
+ /**
770
+ * Let the mobile app / CLI kick THIS device into self-capturing. Registers
771
+ * a BlackBox command listener that fires captureStoreScreenshots when the
772
+ * agent pushes a `capture_store_shots` command (its `data` may override
773
+ * `submit` / `locale`). Returns an unsubscribe fn. Call once after init,
774
+ * passing the app's navigation ref + the routes to walk.
775
+ */
776
+ static enableStoreShotsOnCommand(
777
+ opts: Omit<CaptureStoreScreenshotsOptions, 'agentUrl' | 'authToken' | 'relayPassword'>,
778
+ ): () => void {
779
+ return BlackBox.onCommand((cmd) => {
780
+ if (cmd?.command !== 'capture_store_shots') return;
781
+ const data = (cmd as any).data ?? {};
782
+ void YaverFeedback.captureStoreScreenshots({
783
+ ...opts,
784
+ submit: typeof data.submit === 'boolean' ? data.submit : opts.submit,
785
+ locale: typeof data.locale === 'string' ? data.locale : opts.locale,
786
+ });
787
+ });
788
+ }
789
+
574
790
  /**
575
791
  * Request the embedded FeedbackModal to show the login screen. Works by
576
792
  * emitting an event the modal listens for — avoids forcing the host app
@@ -591,6 +807,27 @@ export class YaverFeedback {
591
807
  DeviceEventEmitter.emit('yaverFeedback:startMachinePicker');
592
808
  }
593
809
 
810
+ /** Begin the reusable host-app Dogfood wizard. Owner OAuth and machine
811
+ * selection intentionally happen before installation enrollment/runtime
812
+ * controls because starting builds or runners is owner-level authority. */
813
+ static beginDogfoodOnboarding(options: DogfoodOnboardingOptions): void {
814
+ dogfoodOnboarding = options;
815
+ if (!config) YaverFeedback.init({ autoLogin: true } as FeedbackConfig);
816
+ if (!YaverFeedback.isAuthed()) {
817
+ YaverFeedback.showLogin();
818
+ return;
819
+ }
820
+ YaverFeedback.showMachinePicker();
821
+ }
822
+
823
+ static getDogfoodOnboarding(): DogfoodOnboardingOptions | null {
824
+ return dogfoodOnboarding;
825
+ }
826
+
827
+ static clearDogfoodOnboarding(): void {
828
+ dogfoodOnboarding = null;
829
+ }
830
+
594
831
  /**
595
832
  * Update the selected remote device. Resets the cached agent URL so the
596
833
  * next `startReport()` (or FloatingButton press) rediscovers against the
@@ -598,9 +835,17 @@ export class YaverFeedback {
598
835
  */
599
836
  static async setPreferredDevice(deviceId: string): Promise<void> {
600
837
  if (!config) return;
838
+ const wasUnified = !config.renderDeviceId
839
+ || config.renderDeviceId === config.codingDeviceId
840
+ || config.renderDeviceId === config.preferredDeviceId;
601
841
  config.preferredDeviceId = deviceId;
842
+ config.codingDeviceId = deviceId;
843
+ if (wasUnified) config.renderDeviceId = deviceId;
602
844
  config.agentUrl = undefined;
845
+ config.codingAgentUrl = undefined;
846
+ if (wasUnified) config.renderAgentUrl = undefined;
603
847
  p2pClient = null;
848
+ renderP2PClient = null;
604
849
  p2pAuthToken = null;
605
850
  await YaverFeedback.discoverAgent();
606
851
  }
@@ -610,8 +855,7 @@ export class YaverFeedback {
610
855
  if (!config?.authToken || !config.preferredDeviceId) return null;
611
856
  const preferredDeviceId = config.preferredDeviceId;
612
857
  const devices = await listReachableDevices(config.authToken);
613
- const all = [...devices.owned, ...devices.shared];
614
- return all.find((device) => device.deviceId === preferredDeviceId) ?? null;
858
+ return devices.owned.find((device) => device.deviceId === preferredDeviceId) ?? null;
615
859
  }
616
860
 
617
861
  /**
@@ -671,6 +915,7 @@ export class YaverFeedback {
671
915
  config.agentUrl = undefined;
672
916
  }
673
917
  p2pClient = null;
918
+ renderP2PClient = null;
674
919
  p2pAuthToken = null;
675
920
  }
676
921
 
@@ -779,32 +1024,145 @@ export class YaverFeedback {
779
1024
  }
780
1025
  BlackBox.unwrapConsole(); // ensure console is restored even if BlackBox wasn't started
781
1026
  errorBuffer = [];
782
- if (shakeDetector) {
783
- shakeDetector.stop();
784
- shakeDetector = null;
785
- }
1027
+ YaverFeedback.stopShakeDetector();
786
1028
  } else {
787
1029
  // === ENABLE ===
788
1030
  if (blackBoxWasStreaming) {
789
1031
  BlackBox.start(); // restart with previous config
790
1032
  }
791
- // Restart shake detector if trigger is 'shake'
792
- if (config?.trigger === 'shake' && !config?.disableShakeGesture && !shakeDetector) {
793
- shakeDetector = new ShakeDetector();
794
- shakeDetector.start(() => {
795
- YaverFeedback.notifyShake();
796
- if (config?.reportingOnly) {
797
- YaverFeedback.sendAutoReport();
798
- } else {
799
- YaverFeedback.startReport();
800
- }
801
- });
802
- }
1033
+ enabled = true; // syncShakeDetector reads this
1034
+ YaverFeedback.syncShakeDetector();
803
1035
  }
804
1036
 
805
1037
  enabled = value;
806
1038
  }
807
1039
 
1040
+ /**
1041
+ * Turn shake-to-report on or off without tearing the SDK down.
1042
+ *
1043
+ * `trigger` is read only inside init(), so before this the only ways to
1044
+ * stop listening for a shake were setEnabled(false) — which also kills the
1045
+ * flight recorder and the agent command channel — or a re-init, which used
1046
+ * to stack a duplicate command handler. Neither is what "turn the shake
1047
+ * catcher off" should cost.
1048
+ *
1049
+ * Persists onto config.disableShakeGesture, so a later setEnabled(true)
1050
+ * honours it rather than resurrecting the listener.
1051
+ */
1052
+ static setShakeEnabled(value: boolean): void {
1053
+ if (!config) return;
1054
+ config.disableShakeGesture = !value;
1055
+ YaverFeedback.syncShakeDetector();
1056
+ }
1057
+
1058
+ /** Whether the shake listener is currently armed. */
1059
+ static isShakeEnabled(): boolean {
1060
+ return shakeDetector !== null;
1061
+ }
1062
+
1063
+ /**
1064
+ * Bring the shake listener in line with the current config. Idempotent —
1065
+ * safe to call whenever `enabled`, `trigger`, or `disableShakeGesture`
1066
+ * moves.
1067
+ */
1068
+ private static syncShakeDetector(): void {
1069
+ const want =
1070
+ enabled && config?.trigger === 'shake' && !config?.disableShakeGesture;
1071
+ if (want && !shakeDetector) {
1072
+ shakeDetector = new ShakeDetector();
1073
+ shakeDetector.start(() => {
1074
+ YaverFeedback.notifyShake();
1075
+ if (config?.reportingOnly) {
1076
+ YaverFeedback.sendAutoReport();
1077
+ } else {
1078
+ YaverFeedback.startReport();
1079
+ }
1080
+ });
1081
+ } else if (!want && shakeDetector) {
1082
+ YaverFeedback.stopShakeDetector();
1083
+ }
1084
+ }
1085
+
1086
+ private static stopShakeDetector(): void {
1087
+ if (shakeDetector) {
1088
+ shakeDetector.stop();
1089
+ shakeDetector = null;
1090
+ }
1091
+ }
1092
+
1093
+ /** Cancel a pending BlackBox auto-start retry chain. */
1094
+ private static cancelBlackBoxAutoStart(): void {
1095
+ if (autoStartTimer) {
1096
+ clearTimeout(autoStartTimer);
1097
+ autoStartTimer = null;
1098
+ }
1099
+ }
1100
+
1101
+ /**
1102
+ * Start BlackBox as soon as we have BOTH an agentUrl and a token, polling
1103
+ * until then.
1104
+ *
1105
+ * Both conditions are mandatory and deliberate: starting without a token
1106
+ * makes the SSE channel 401 and retry with backoff, which is the tight
1107
+ * string-concat + JSON-parse loop that used to SIGSEGV Hermes on iOS 18.3.1
1108
+ * during Screenshot & Fix. This only re-checks that same guard over time.
1109
+ *
1110
+ * Bounded at ~60s: long enough for AsyncStorage hydration plus a Convex
1111
+ * discovery round trip on a cold, off-LAN start, short enough that a device
1112
+ * whose user never signs in stops polling. Giving up here costs nothing —
1113
+ * setAuthToken() and startReport() both reschedule, so signing in later
1114
+ * still brings the channel up.
1115
+ */
1116
+ private static scheduleBlackBoxAutoStart(attempt = 0): void {
1117
+ const MAX_ATTEMPTS = 60;
1118
+ const FIRST_DELAY_MS = 500;
1119
+ const RETRY_DELAY_MS = 1000;
1120
+ // Re-run discovery every Nth tick rather than every tick: it is a Convex
1121
+ // round trip plus LAN probing, so once a second would hammer both. Every
1122
+ // 3s is responsive enough that waking the dev machine reconnects the phone
1123
+ // on its own, without the user wondering whether to restart the app.
1124
+ const REDISCOVER_EVERY = 3;
1125
+
1126
+ YaverFeedback.cancelBlackBoxAutoStart();
1127
+ if (!enabled || attempt >= MAX_ATTEMPTS) return;
1128
+
1129
+ const timer = setTimeout(() => {
1130
+ autoStartTimer = null;
1131
+ // A destroy() or setEnabled(false) between scheduling and firing.
1132
+ if (!enabled || !config) return;
1133
+ if (BlackBox.isStreaming) return;
1134
+
1135
+ if (config.agentUrl && (config.authToken || p2pAuthToken)) {
1136
+ try {
1137
+ // Pass the host's BlackBox config through. This used to call
1138
+ // start() bare, so a host that configured the flight recorder
1139
+ // — as the README itself shows — had it silently replaced by
1140
+ // defaults, and appName came out ''.
1141
+ BlackBox.start(config.blackBox);
1142
+ } catch (err) {
1143
+ console.warn('[YaverFeedback] BlackBox auto-start failed:', err);
1144
+ }
1145
+ return;
1146
+ }
1147
+
1148
+ // No agent yet. Re-attempt discovery instead of only re-reading the
1149
+ // flag: hydrateSession() runs discoverAgent() exactly once at init, so
1150
+ // if the dev machine was unreachable at that moment — asleep, phone
1151
+ // still on cellular before the relay came up, agent not yet serving —
1152
+ // agentUrl stayed null for the whole process lifetime and only an app
1153
+ // restart could recover it. discoverAgent() self-guards on "already
1154
+ // have a URL" and "no token", so calling it again is cheap and safe.
1155
+ if (!config.agentUrl && attempt > 0 && attempt % REDISCOVER_EVERY === 0) {
1156
+ void YaverFeedback.discoverAgent();
1157
+ }
1158
+
1159
+ YaverFeedback.scheduleBlackBoxAutoStart(attempt + 1);
1160
+ }, attempt === 0 ? FIRST_DELAY_MS : RETRY_DELAY_MS);
1161
+
1162
+ autoStartTimer = timer;
1163
+ unrefTimer(timer);
1164
+ }
1165
+
808
1166
  /** Returns whether the SDK is currently enabled. */
809
1167
  static isEnabled(): boolean {
810
1168
  return enabled;
@@ -815,6 +1173,63 @@ export class YaverFeedback {
815
1173
  return config;
816
1174
  }
817
1175
 
1176
+ /** Current third-party SDK mode. Fails closed unless enabled + account match. */
1177
+ static getDogfoodStatus(): SDKDogfoodStatus {
1178
+ return resolveSDKDogfood(config?.dogfood);
1179
+ }
1180
+
1181
+ /** One-call, account-free Dogfood bootstrap for third-party apps. On first
1182
+ * launch it creates/proves the installation key and returns pending; after
1183
+ * owner approval the same call obtains a short-lived scoped Yaver session
1184
+ * and enables Dogfood UX without the host app implementing OAuth. */
1185
+ static async enableDeviceDogfood(options: DeviceDogfoodOptions): Promise<{
1186
+ status: DeviceDogfoodState;
1187
+ installationId: string;
1188
+ session: DeviceDogfoodSession | null;
1189
+ }> {
1190
+ const client = new YaverDeviceDogfood(options);
1191
+ let status = await client.status();
1192
+ if (status === 'unregistered' || status === 'pending' || status === 'cancelled' || status === 'revoked' || status === 'superseded') {
1193
+ const enrolled = status === 'unregistered' || status === 'pending'
1194
+ ? await client.enroll(Platform.OS)
1195
+ : await client.reRegister(Platform.OS);
1196
+ status = enrolled.status;
1197
+ }
1198
+ const info = await client.enrollmentInfo();
1199
+ const session = status === 'active' ? await client.session() : null;
1200
+ if (!config) YaverFeedback.init({ autoLogin: false } as FeedbackConfig);
1201
+ if (config) {
1202
+ config.dogfood = {
1203
+ enabled: status === 'active' && !!session,
1204
+ appId: options.appId,
1205
+ installationId: info.installationId,
1206
+ installationStatus: status === 'active' && session ? 'active' : status === 'unregistered' ? 'pending' : status,
1207
+ label: options.label,
1208
+ };
1209
+ // A normal Yaver OAuth session remains authoritative for the runtime
1210
+ // wizard. Only account-free hosts adopt the narrow installation token.
1211
+ if (session && !config.authToken) await YaverFeedback.setAuthToken(session.token);
1212
+ }
1213
+ try {
1214
+ const { DeviceEventEmitter } = require('react-native');
1215
+ DeviceEventEmitter.emit('yaverFeedback:dogfoodChanged', { active: !!session, status });
1216
+ } catch { /* noop */ }
1217
+ return { status, installationId: info.installationId, session };
1218
+ }
1219
+
1220
+ /** Confirmed by the Y badge/UI before this is called. Reverts this SDK to
1221
+ * normal Feedback mode for the remainder of the app run. */
1222
+ static async exitDogfoodMode(): Promise<void> {
1223
+ const cfg = config?.dogfood;
1224
+ if (!cfg) return;
1225
+ cfg.enabled = false;
1226
+ await cfg.onExit?.();
1227
+ try {
1228
+ const { DeviceEventEmitter } = require('react-native');
1229
+ DeviceEventEmitter.emit('yaverFeedback:dogfoodChanged', { active: false, exited: true });
1230
+ } catch { /* noop */ }
1231
+ }
1232
+
818
1233
  /** Returns the resolved relay password the SDK is currently using.
819
1234
  * Empty string when no relay routing is in play (direct LAN agent
820
1235
  * URLs need no password). Callers attaching it to relay-routed
@@ -883,6 +1298,113 @@ export class YaverFeedback {
883
1298
  };
884
1299
  }
885
1300
 
1301
+ /**
1302
+ * Upload a crash-aware feedback bundle. Apps can call this from their own
1303
+ * error boundary/global handler. If crashReporting.autoFix is true, the SDK
1304
+ * also triggers the agent's feedback-fix task; reload delivery still happens
1305
+ * through the existing BlackBox command stream.
1306
+ */
1307
+ static async reportCrash(
1308
+ error: Error,
1309
+ opts?: {
1310
+ isFatal?: boolean;
1311
+ source?: 'manual' | 'global-handler';
1312
+ metadata?: Record<string, unknown>;
1313
+ autoFix?: boolean;
1314
+ },
1315
+ ): Promise<{ reportId?: string; taskId?: string }> {
1316
+ if (!config || !enabled || !config.crashReporting?.enabled) return {};
1317
+ if (crashReportInFlight) return {};
1318
+ crashReportInFlight = true;
1319
+ try {
1320
+ YaverFeedback.attachError(error, {
1321
+ ...(config.crashReporting.metadata || {}),
1322
+ ...(opts?.metadata || {}),
1323
+ crashAware: true,
1324
+ });
1325
+ if (errorBuffer.length > 0) {
1326
+ errorBuffer[errorBuffer.length - 1].isFatal = opts?.isFatal ?? true;
1327
+ }
1328
+
1329
+ if (!config.agentUrl) {
1330
+ await YaverFeedback.discoverAgent();
1331
+ }
1332
+ if (!config.agentUrl) {
1333
+ console.warn('[YaverFeedback] No agent URL — cannot send crash report.');
1334
+ return {};
1335
+ }
1336
+
1337
+ const { Platform, Dimensions } = require('react-native');
1338
+ const { uploadFeedback } = require('./upload');
1339
+ const { width, height } = Dimensions.get('window');
1340
+ let screenshotPath: string | undefined;
1341
+ if (config.crashReporting.captureScreenshot !== false) {
1342
+ try {
1343
+ const { captureScreenshot } = require('./capture');
1344
+ screenshotPath = await captureScreenshot();
1345
+ } catch {}
1346
+ }
1347
+
1348
+ const autoFix = opts?.autoFix ?? config.crashReporting.autoFix === true;
1349
+ const identity = resolveReportIdentity({
1350
+ projectName: config?.projectName,
1351
+ bundleId: config?.bundleId,
1352
+ surface: config?.surface,
1353
+ surfaces: config?.surfaces,
1354
+ stack: config?.stack,
1355
+ stacks: config?.stacks,
1356
+ testSurfaces: config?.testSurfaces,
1357
+ feedbackSdk: config?.feedbackSdk,
1358
+ feedbackTransport: config?.feedbackTransport,
1359
+ voiceCapabilities: config?.voiceCapabilities,
1360
+ sttProvider: config?.sttProvider,
1361
+ ttsProvider: config?.ttsProvider,
1362
+ });
1363
+ const bundle = {
1364
+ metadata: {
1365
+ timestamp: new Date().toISOString(),
1366
+ reportKind: 'crash',
1367
+ deviceInfo: {
1368
+ platform: Platform.OS,
1369
+ osVersion: String(Platform.Version),
1370
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
1371
+ screenWidth: width,
1372
+ screenHeight: height,
1373
+ appName: identity.appName,
1374
+ },
1375
+ app: identity.app,
1376
+ project: identity.project,
1377
+ userNote: '[Crash report]',
1378
+ crash: {
1379
+ message: error.message,
1380
+ isFatal: opts?.isFatal ?? true,
1381
+ source: opts?.source ?? 'manual',
1382
+ autoFixRequested: autoFix,
1383
+ },
1384
+ },
1385
+ screenshots: screenshotPath ? [screenshotPath] : [],
1386
+ errors: errorBuffer.length > 0 ? [...errorBuffer] : undefined,
1387
+ };
1388
+
1389
+ const uploaded = await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle, p2pRelayPassword);
1390
+ const reportId =
1391
+ (uploaded as { id?: string; reportId?: string } | null | undefined)?.id ??
1392
+ (uploaded as { reportId?: string } | null | undefined)?.reportId;
1393
+ let taskId: string | undefined;
1394
+ if (autoFix && reportId) {
1395
+ const client = p2pClient ?? new P2PClient(config.agentUrl, config.authToken ?? '', p2pRelayPassword);
1396
+ const fix = await client.triggerFix(reportId);
1397
+ taskId = fix?.taskId;
1398
+ }
1399
+ return { reportId, taskId };
1400
+ } catch (err) {
1401
+ console.warn('[YaverFeedback] Crash report failed:', err);
1402
+ return {};
1403
+ } finally {
1404
+ crashReportInFlight = false;
1405
+ }
1406
+ }
1407
+
886
1408
  /**
887
1409
  * Returns the P2P client instance.
888
1410
  * Available after init if agentUrl is set, or after first successful discovery.
@@ -891,6 +1413,18 @@ export class YaverFeedback {
891
1413
  return p2pClient;
892
1414
  }
893
1415
 
1416
+ /** Renderer/reload route; intentionally distinct from the coding client. */
1417
+ static getRenderP2PClient(): P2PClient | null {
1418
+ return renderP2PClient ?? p2pClient;
1419
+ }
1420
+
1421
+ static getMachineRouting(): { codingDeviceId?: string; renderDeviceId?: string } {
1422
+ return {
1423
+ codingDeviceId: config?.codingDeviceId || config?.preferredDeviceId,
1424
+ renderDeviceId: config?.renderDeviceId || config?.codingDeviceId || config?.preferredDeviceId,
1425
+ };
1426
+ }
1427
+
894
1428
  // ─── One-stop SaaS replacement methods ─────────────────────────
895
1429
  //
896
1430
  // These are the three solo-dev SaaS-replacement entry points
@@ -1010,6 +1544,7 @@ export class YaverFeedback {
1010
1544
  config.agentUrl = result.url;
1011
1545
  const rp = await resolveRelayPassword(config.authToken ?? '');
1012
1546
  p2pClient = new P2PClient(result.url, config.authToken ?? '', rp);
1547
+ renderP2PClient = p2pClient;
1013
1548
  }
1014
1549
  } catch {}
1015
1550
  }
@@ -1033,17 +1568,33 @@ export class YaverFeedback {
1033
1568
  // Screenshot capture may fail (e.g. no view ref) — continue without it
1034
1569
  }
1035
1570
 
1571
+ const identity = resolveReportIdentity({
1572
+ projectName: config?.projectName,
1573
+ bundleId: config?.bundleId,
1574
+ surface: config?.surface,
1575
+ surfaces: config?.surfaces,
1576
+ stack: config?.stack,
1577
+ stacks: config?.stacks,
1578
+ testSurfaces: config?.testSurfaces,
1579
+ feedbackSdk: config?.feedbackSdk,
1580
+ feedbackTransport: config?.feedbackTransport,
1581
+ voiceCapabilities: config?.voiceCapabilities,
1582
+ sttProvider: config?.sttProvider,
1583
+ ttsProvider: config?.ttsProvider,
1584
+ });
1036
1585
  const bundle = {
1037
1586
  metadata: {
1038
1587
  timestamp: new Date().toISOString(),
1039
- device: {
1588
+ deviceInfo: {
1040
1589
  platform: Platform.OS,
1041
1590
  osVersion: String(Platform.Version),
1042
1591
  model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
1043
1592
  screenWidth: width,
1044
1593
  screenHeight: height,
1594
+ appName: identity.appName,
1045
1595
  },
1046
- app: {},
1596
+ app: identity.app,
1597
+ project: identity.project,
1047
1598
  userNote: '[Auto-report via shake]',
1048
1599
  },
1049
1600
  screenshots: screenshotPath ? [screenshotPath] : [],
@@ -1211,10 +1762,22 @@ export class YaverFeedback {
1211
1762
  shakeDetector.stop();
1212
1763
  shakeDetector = null;
1213
1764
  }
1765
+ // Drop the agent command handler and un-monkey-patch console. Without
1766
+ // these, destroy() left the SDK half-alive: console stayed wrapped, and
1767
+ // the reload handler kept firing on a "destroyed" SDK — then a later
1768
+ // init() stacked a second one on top.
1769
+ commandUnsubscribe?.();
1770
+ commandUnsubscribe = null;
1771
+ // Before `enabled = false` / `config = null` below, so an in-flight retry
1772
+ // can't fire against a torn-down config.
1773
+ YaverFeedback.cancelBlackBoxAutoStart();
1774
+ BlackBox.stop();
1775
+ BlackBox.unwrapConsole();
1214
1776
  firstShakeFired = false;
1215
1777
  enabled = false;
1216
1778
  config = null;
1217
1779
  p2pClient = null;
1780
+ renderP2PClient = null;
1218
1781
  errorBuffer = [];
1219
1782
  }
1220
1783
  }