yaver-feedback-react-native 0.8.13 → 0.9.2

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 (76) hide show
  1. package/app.plugin.js +126 -9
  2. package/dist/BlackBox.js +9 -1
  3. package/dist/DeployPanel.js +65 -0
  4. package/dist/Discovery.js +13 -2
  5. package/dist/FeedbackModal.js +477 -66
  6. package/dist/MachinePickerScreen.js +14 -5
  7. package/dist/P2PClient.d.ts +94 -1
  8. package/dist/P2PClient.js +281 -2
  9. package/dist/ShakeDetector.js +2 -0
  10. package/dist/VibeChatScreen.d.ts +6 -1
  11. package/dist/VibeChatScreen.js +204 -1
  12. package/dist/YaverFeedback.d.ts +98 -0
  13. package/dist/YaverFeedback.js +509 -46
  14. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  15. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  16. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  17. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  18. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  19. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  20. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  21. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  22. package/dist/__tests__/P2PClient.test.js +2 -2
  23. package/dist/__tests__/ReportIdentity.test.d.ts +1 -0
  24. package/dist/__tests__/ReportIdentity.test.js +168 -0
  25. package/dist/__tests__/SDKToken.test.js +1 -1
  26. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  27. package/dist/__tests__/ShakeToggle.test.js +121 -0
  28. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  29. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  30. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  31. package/dist/__tests__/reloadActions.test.js +129 -0
  32. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  33. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  34. package/dist/__tests__/types.test.js +5 -5
  35. package/dist/_core/device.d.ts +19 -9
  36. package/dist/_core/device.js +20 -14
  37. package/dist/capture.d.ts +6 -0
  38. package/dist/capture.js +53 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +11 -1
  41. package/dist/reloadActions.d.ts +88 -0
  42. package/dist/reloadActions.js +200 -0
  43. package/dist/storeShots.d.ts +67 -0
  44. package/dist/storeShots.js +137 -0
  45. package/dist/types.d.ts +215 -3
  46. package/dist/voice.d.ts +61 -0
  47. package/dist/voice.js +246 -0
  48. package/package.json +14 -3
  49. package/src/BlackBox.ts +10 -1
  50. package/src/DeployPanel.tsx +74 -0
  51. package/src/Discovery.ts +13 -2
  52. package/src/FeedbackModal.tsx +563 -87
  53. package/src/MachinePickerScreen.tsx +12 -3
  54. package/src/P2PClient.ts +314 -2
  55. package/src/ShakeDetector.ts +1 -0
  56. package/src/VibeChatScreen.tsx +219 -0
  57. package/src/YaverFeedback.ts +498 -46
  58. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  59. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  60. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  61. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  62. package/src/__tests__/P2PClient.test.ts +2 -2
  63. package/src/__tests__/ReportIdentity.test.ts +203 -0
  64. package/src/__tests__/SDKToken.test.ts +1 -1
  65. package/src/__tests__/ShakeToggle.test.ts +153 -0
  66. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  67. package/src/__tests__/reloadActions.test.ts +171 -0
  68. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  69. package/src/__tests__/types.test.ts +5 -5
  70. package/src/_core/device.ts +20 -14
  71. package/src/capture.ts +51 -0
  72. package/src/index.ts +21 -0
  73. package/src/reloadActions.ts +273 -0
  74. package/src/storeShots.ts +189 -0
  75. package/src/types.ts +217 -3
  76. package/src/voice.ts +270 -0
@@ -1,9 +1,14 @@
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,
@@ -23,6 +28,13 @@ import {
23
28
  QuickIconColorPreset,
24
29
  } from './preferences';
25
30
 
31
+ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
32
+ const maybeNodeTimer = timer as unknown as { unref?: () => void };
33
+ if (typeof maybeNodeTimer.unref === 'function') {
34
+ maybeNodeTimer.unref();
35
+ }
36
+ }
37
+
26
38
  // Is this JS runtime the Yaver mobile app's super-host bridge? The
27
39
  // YaverInfo native module is only registered inside Yaver's container
28
40
  // (mobile/ios/Yaver/YaverInfo.{swift,m} + Android counterpart); a
@@ -147,9 +159,16 @@ let config: FeedbackConfig | null = null;
147
159
  let enabled = false;
148
160
  let p2pClient: P2PClient | null = null;
149
161
  let shakeDetector: ShakeDetector | null = null;
162
+ /** Unsubscribe for the BlackBox command handler registered by init(). Held so
163
+ * a re-init (or destroy) can drop the old one instead of stacking. */
164
+ let commandUnsubscribe: (() => void) | null = null;
150
165
  let p2pAuthToken: string | null = null;
151
166
  let p2pRelayPassword: string = '';
167
+ /** Pending BlackBox auto-start retry. Held so destroy()/re-init can cancel it
168
+ * instead of leaving a timer chain running against a torn-down config. */
169
+ let autoStartTimer: ReturnType<typeof setTimeout> | null = null;
152
170
  let reportLaunchInFlight = false;
171
+ let crashReportInFlight = false;
153
172
 
154
173
  /** Resolve the user's relay password by validating their auth token
155
174
  * against Convex. Used whenever we (re)build the P2PClient so a
@@ -196,6 +215,7 @@ async function resolveRelayPassword(authToken: string, convexUrl?: string): Prom
196
215
  /** Ring buffer of captured errors. */
197
216
  let errorBuffer: CapturedError[] = [];
198
217
  let maxErrors = 5;
218
+ let crashHandlerInstalled = false;
199
219
 
200
220
  /** Track whether BlackBox was running before disable (to restart on enable). */
201
221
  let blackBoxWasStreaming = false;
@@ -266,6 +286,79 @@ export class YaverFeedback {
266
286
  * If no `agentUrl` is provided, the SDK will attempt auto-discovery
267
287
  * via `YaverDiscovery` on the first `startReport()` call.
268
288
  */
289
+ /** The runtime lane, when running on web inside a Yaver preview WebView. */
290
+ static detectWebLane(): 'browser' | 'webrtc' | null {
291
+ try {
292
+ if (Platform.OS !== 'web' || typeof window === 'undefined') return null;
293
+ const l = String((window as unknown as { __yaverLane?: string }).__yaverLane || '').toLowerCase();
294
+ if (l === 'browser' || l === 'webrtc') return l;
295
+ } catch { /* noop */ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Mount a draggable DOM floating "Y" icon for the BROWSER lane (RN-web). It
301
+ * lives in the WebView's document.body (position:fixed, max z-index), so it
302
+ * renders ON TOP of the page inside the WebView and is never occluded by the
303
+ * fullScreen preview modal — the one occlusion-proof affordance on iOS.
304
+ * Tap opens the SDK's existing report flow (the RN FeedbackModal renders in
305
+ * the same WebView DOM, also un-occluded). No-op off-web or off-lane, and
306
+ * idempotent (a re-init reuses the existing node).
307
+ */
308
+ static mountBrowserLaneIcon(): void {
309
+ if (YaverFeedback.detectWebLane() !== 'browser') return;
310
+ try {
311
+ const doc = (globalThis as unknown as { document?: Document }).document;
312
+ if (!doc || !doc.body) return;
313
+ if (doc.getElementById('yaver-feedback-btn')) return; // idempotent
314
+ const btn = doc.createElement('div');
315
+ btn.id = 'yaver-feedback-btn';
316
+ btn.textContent = 'Y';
317
+ btn.title = 'Yaver Feedback — drag to move · tap to report';
318
+ btn.style.cssText =
319
+ 'position:fixed;bottom:20px;right:20px;width:44px;height:44px;border-radius:50%;' +
320
+ 'background:#6366f1;color:#fff;display:flex;align-items:center;justify-content:center;' +
321
+ 'cursor:pointer;z-index:99999;font-size:18px;font-weight:bold;touch-action:none;' +
322
+ 'box-shadow:0 4px 12px rgba(99,102,241,0.4);transition:transform 0.2s;';
323
+ const drag = { on: false, moved: false, sx: 0, sy: 0, ox: 0, oy: 0 };
324
+ const w = window as unknown as { innerWidth: number; innerHeight: number };
325
+ const applyPos = (x: number, y: number) => {
326
+ const m = 8;
327
+ const nx = Math.max(m, Math.min(w.innerWidth - 44 - m, x));
328
+ const ny = Math.max(m, Math.min(w.innerHeight - 44 - m, y));
329
+ btn.style.left = nx + 'px'; btn.style.top = ny + 'px';
330
+ btn.style.right = 'auto'; btn.style.bottom = 'auto';
331
+ try { localStorage.setItem('yaver-feedback-btn-pos', JSON.stringify({ x: nx, y: ny })); } catch { /* noop */ }
332
+ };
333
+ 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 */ }
334
+ btn.addEventListener('pointerdown', (e: PointerEvent) => {
335
+ drag.on = true; drag.moved = false;
336
+ const r = btn.getBoundingClientRect();
337
+ drag.sx = e.clientX; drag.sy = e.clientY; drag.ox = r.left; drag.oy = r.top;
338
+ btn.setPointerCapture(e.pointerId); btn.style.transition = 'none';
339
+ });
340
+ btn.addEventListener('pointermove', (e: PointerEvent) => {
341
+ if (!drag.on) return;
342
+ const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
343
+ if (Math.abs(dx) + Math.abs(dy) > 5) drag.moved = true;
344
+ if (drag.moved) applyPos(drag.ox + dx, drag.oy + dy);
345
+ });
346
+ const end = (e: PointerEvent) => {
347
+ if (!drag.on) return;
348
+ drag.on = false; btn.style.transition = 'transform 0.2s';
349
+ try { btn.releasePointerCapture(e.pointerId); } catch { /* noop */ }
350
+ if (!drag.moved) { try { void YaverFeedback.startReport(); } catch { /* noop */ } }
351
+ };
352
+ btn.addEventListener('pointerup', end);
353
+ btn.addEventListener('pointercancel', end);
354
+ // Also answer the host's forwarded shake (Yaver injects yaver-feedback:launch).
355
+ const launch = () => { try { void YaverFeedback.startReport(); } catch { /* noop */ } };
356
+ window.addEventListener('yaver-feedback:launch', launch as EventListener);
357
+ (window as unknown as { __yaverFeedbackLaunch?: () => void }).__yaverFeedbackLaunch = launch;
358
+ doc.body.appendChild(btn);
359
+ } catch { /* noop — never break the guest app over a feedback affordance */ }
360
+ }
361
+
269
362
  static init(cfg: FeedbackConfig): void {
270
363
  config = {
271
364
  trigger: 'shake',
@@ -293,6 +386,9 @@ export class YaverFeedback {
293
386
  }
294
387
  maxErrors = cfg.maxCapturedErrors ?? 5;
295
388
  errorBuffer = [];
389
+ if (cfg.crashReporting?.enabled && cfg.crashReporting?.installGlobalHandler) {
390
+ YaverFeedback.installCrashHandler();
391
+ }
296
392
  return;
297
393
  }
298
394
  if (config.disableShakeGesture && (!config.quickIcon || config.quickIcon === 'auto')) {
@@ -359,24 +455,25 @@ export class YaverFeedback {
359
455
  // Set up error capture buffer size
360
456
  maxErrors = cfg.maxCapturedErrors ?? 5;
361
457
  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
- });
458
+ if (cfg.crashReporting?.enabled && cfg.crashReporting?.installGlobalHandler) {
459
+ YaverFeedback.installCrashHandler();
378
460
  }
379
461
 
462
+ // Wire up shake detection when trigger is 'shake'. Rebuild from scratch:
463
+ // config was just replaced, so the old detector may be closing over stale
464
+ // settings.
465
+ YaverFeedback.stopShakeDetector();
466
+ YaverFeedback.syncShakeDetector();
467
+
468
+ // Browser lane (RN-web inside Yaver's fullScreen WebView preview): the
469
+ // native shake module doesn't exist on web, and Yaver's own container
470
+ // overlay is OCCLUDED behind the WebView modal on iOS. So mount an
471
+ // occlusion-proof DRAGGABLE DOM icon INSIDE the WebView — same pattern as
472
+ // yaver-feedback-web. Lane is stamped by Yaver via
473
+ // window.__yaverLane='browser' (mobile PREVIEW_LANE_SCRIPT). See
474
+ // docs/audits/feedback-sdk-lanes-audit-2026-07-28.md.
475
+ if (enabled) YaverFeedback.mountBrowserLaneIcon();
476
+
380
477
  // Wire up BlackBox command handlers for reload + status signals from
381
478
  // the agent. Opens an SSE channel the agent uses to:
382
479
  // - broadcast reload_bundle so the phone picks up a fresh Hermes
@@ -385,7 +482,14 @@ export class YaverFeedback {
385
482
  // assets…", "Done") so the SDK can surface it like a normal
386
483
  // "Working…" spinner instead of a silent 60-second freeze.
387
484
  if (enabled) {
388
- BlackBox.onCommand((cmd) => {
485
+ // onCommand appends to a static array and hands back an unsubscribe.
486
+ // Dropping that unsubscribe meant every re-init stacked another handler
487
+ // on top of the last, so an app that re-initialised (say, a settings
488
+ // toggle) reloaded twice per agent command, then three times, and so on.
489
+ // destroy() never cleared them either. Drop the previous registration
490
+ // first so init() is idempotent here.
491
+ commandUnsubscribe?.();
492
+ commandUnsubscribe = BlackBox.onCommand((cmd) => {
389
493
  if (cmd.command === 'reload') {
390
494
  if (cfg.onReload) {
391
495
  cfg.onReload();
@@ -447,16 +551,24 @@ export class YaverFeedback {
447
551
  // SFMG used to call BlackBox.start() inside YaverFeedbackWidget
448
552
  // after auth — that path still works (start() is idempotent), so
449
553
  // upgrading SDK without removing the manual call is safe.
554
+ // 4. RETRY, rather than checking once. This was a single 500ms
555
+ // timeout, and on a cold start it could not succeed: an app that
556
+ // passes neither agentUrl nor authToken to init() (the normal
557
+ // case — they're restored from storage) gets its session from
558
+ // `void hydrateSession()`, which reads two AsyncStorage keys and
559
+ // then awaits discoverAgent(), a Convex round trip plus LAN
560
+ // probing. That does not finish in 500ms, so the check found no
561
+ // agentUrl, gave up, and BlackBox never started — meaning no SSE
562
+ // channel, so the agent's `reload_bundle` command had nowhere to
563
+ // land and Hermes hot reload silently did nothing until the user
564
+ // shook the device and drove discovery by hand.
565
+ //
566
+ // The both-conditions guard is what keeps the 401 retry storm
567
+ // from coming back, so it is preserved exactly; we only re-check
568
+ // it over time instead of once. Bounded so a device that never
569
+ // signs in doesn't poll forever.
450
570
  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);
571
+ YaverFeedback.scheduleBlackBoxAutoStart();
460
572
  }
461
573
  }
462
574
 
@@ -471,6 +583,40 @@ export class YaverFeedback {
471
583
  // pass-through wrapper they insert into their own error chain
472
584
  }
473
585
 
586
+ /**
587
+ * Opt-in global crash handler. This wraps the existing React Native
588
+ * ErrorUtils handler and still calls it, so Sentry/Crashlytics/Bugsnag can
589
+ * remain the system of record. The SDK only uploads a crash report and,
590
+ * when configured, asks the agent to create a fix task.
591
+ */
592
+ static installCrashHandler(): void {
593
+ if (crashHandlerInstalled) return;
594
+ try {
595
+ const errorUtils = (globalThis as any)?.ErrorUtils;
596
+ if (!errorUtils?.getGlobalHandler || !errorUtils?.setGlobalHandler) {
597
+ return;
598
+ }
599
+ const next = errorUtils.getGlobalHandler();
600
+ errorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {
601
+ YaverFeedback.attachError(error, {
602
+ source: 'global-handler',
603
+ crashAware: true,
604
+ });
605
+ if (errorBuffer.length > 0) {
606
+ errorBuffer[errorBuffer.length - 1].isFatal = isFatal ?? true;
607
+ }
608
+ void YaverFeedback.reportCrash(error, {
609
+ isFatal: isFatal ?? true,
610
+ source: 'global-handler',
611
+ });
612
+ next?.(error, isFatal);
613
+ });
614
+ crashHandlerInstalled = true;
615
+ } catch (err) {
616
+ console.warn('[YaverFeedback] installCrashHandler failed:', err);
617
+ }
618
+ }
619
+
474
620
  /**
475
621
  * Run agent discovery in the background.
476
622
  * Called automatically from init() when no agentUrl is provided.
@@ -564,6 +710,12 @@ export class YaverFeedback {
564
710
  } else {
565
711
  await YaverFeedback.discoverAgent();
566
712
  }
713
+ // Signing in is the event the auto-start was waiting for. If its bounded
714
+ // retry already gave up (device left signed out past the window), this is
715
+ // what brings the command channel up without requiring an app restart.
716
+ if (config.autoStartBlackBox !== false && !BlackBox.isStreaming) {
717
+ YaverFeedback.scheduleBlackBoxAutoStart();
718
+ }
567
719
  }
568
720
 
569
721
  /** Returns true once the SDK has a session token it can use. */
@@ -571,6 +723,59 @@ export class YaverFeedback {
571
723
  return Boolean(config?.authToken);
572
724
  }
573
725
 
726
+ /**
727
+ * On-device App Store screenshot capture (Engine 2). Walks the routes
728
+ * the host hands us, screenshots each, and uploads to the agent which
729
+ * runs the App Store Connect backend. agentUrl / authToken / relay
730
+ * password default to the SDK's resolved session; the host only has to
731
+ * supply a navigation ref + the ordered route list.
732
+ *
733
+ * YaverFeedback.captureStoreScreenshots({
734
+ * app: 'sfmg',
735
+ * navigationRef,
736
+ * routes: ['/(tabs)/dashboard', '/(tabs)/messages', '/(tabs)/clients'],
737
+ * submit: true,
738
+ * })
739
+ */
740
+ static async captureStoreScreenshots(
741
+ opts: Omit<CaptureStoreScreenshotsOptions, 'agentUrl' | 'authToken' | 'relayPassword'> &
742
+ Partial<Pick<CaptureStoreScreenshotsOptions, 'agentUrl' | 'authToken' | 'relayPassword'>>,
743
+ ): Promise<CaptureStoreScreenshotsResult> {
744
+ const agentUrl = opts.agentUrl ?? config?.agentUrl ?? '';
745
+ const authToken = opts.authToken ?? config?.authToken ?? p2pAuthToken ?? '';
746
+ const relayPassword = opts.relayPassword ?? p2pRelayPassword;
747
+ if (!agentUrl || !authToken) {
748
+ return {
749
+ ok: false,
750
+ captured: 0,
751
+ uploaded: 0,
752
+ message: 'YaverFeedback not connected to an agent — init() with agentUrl + authToken first.',
753
+ };
754
+ }
755
+ return captureStoreScreenshots({ ...opts, agentUrl, authToken, relayPassword });
756
+ }
757
+
758
+ /**
759
+ * Let the mobile app / CLI kick THIS device into self-capturing. Registers
760
+ * a BlackBox command listener that fires captureStoreScreenshots when the
761
+ * agent pushes a `capture_store_shots` command (its `data` may override
762
+ * `submit` / `locale`). Returns an unsubscribe fn. Call once after init,
763
+ * passing the app's navigation ref + the routes to walk.
764
+ */
765
+ static enableStoreShotsOnCommand(
766
+ opts: Omit<CaptureStoreScreenshotsOptions, 'agentUrl' | 'authToken' | 'relayPassword'>,
767
+ ): () => void {
768
+ return BlackBox.onCommand((cmd) => {
769
+ if (cmd?.command !== 'capture_store_shots') return;
770
+ const data = (cmd as any).data ?? {};
771
+ void YaverFeedback.captureStoreScreenshots({
772
+ ...opts,
773
+ submit: typeof data.submit === 'boolean' ? data.submit : opts.submit,
774
+ locale: typeof data.locale === 'string' ? data.locale : opts.locale,
775
+ });
776
+ });
777
+ }
778
+
574
779
  /**
575
780
  * Request the embedded FeedbackModal to show the login screen. Works by
576
781
  * emitting an event the modal listens for — avoids forcing the host app
@@ -779,32 +984,145 @@ export class YaverFeedback {
779
984
  }
780
985
  BlackBox.unwrapConsole(); // ensure console is restored even if BlackBox wasn't started
781
986
  errorBuffer = [];
782
- if (shakeDetector) {
783
- shakeDetector.stop();
784
- shakeDetector = null;
785
- }
987
+ YaverFeedback.stopShakeDetector();
786
988
  } else {
787
989
  // === ENABLE ===
788
990
  if (blackBoxWasStreaming) {
789
991
  BlackBox.start(); // restart with previous config
790
992
  }
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
- }
993
+ enabled = true; // syncShakeDetector reads this
994
+ YaverFeedback.syncShakeDetector();
803
995
  }
804
996
 
805
997
  enabled = value;
806
998
  }
807
999
 
1000
+ /**
1001
+ * Turn shake-to-report on or off without tearing the SDK down.
1002
+ *
1003
+ * `trigger` is read only inside init(), so before this the only ways to
1004
+ * stop listening for a shake were setEnabled(false) — which also kills the
1005
+ * flight recorder and the agent command channel — or a re-init, which used
1006
+ * to stack a duplicate command handler. Neither is what "turn the shake
1007
+ * catcher off" should cost.
1008
+ *
1009
+ * Persists onto config.disableShakeGesture, so a later setEnabled(true)
1010
+ * honours it rather than resurrecting the listener.
1011
+ */
1012
+ static setShakeEnabled(value: boolean): void {
1013
+ if (!config) return;
1014
+ config.disableShakeGesture = !value;
1015
+ YaverFeedback.syncShakeDetector();
1016
+ }
1017
+
1018
+ /** Whether the shake listener is currently armed. */
1019
+ static isShakeEnabled(): boolean {
1020
+ return shakeDetector !== null;
1021
+ }
1022
+
1023
+ /**
1024
+ * Bring the shake listener in line with the current config. Idempotent —
1025
+ * safe to call whenever `enabled`, `trigger`, or `disableShakeGesture`
1026
+ * moves.
1027
+ */
1028
+ private static syncShakeDetector(): void {
1029
+ const want =
1030
+ enabled && config?.trigger === 'shake' && !config?.disableShakeGesture;
1031
+ if (want && !shakeDetector) {
1032
+ shakeDetector = new ShakeDetector();
1033
+ shakeDetector.start(() => {
1034
+ YaverFeedback.notifyShake();
1035
+ if (config?.reportingOnly) {
1036
+ YaverFeedback.sendAutoReport();
1037
+ } else {
1038
+ YaverFeedback.startReport();
1039
+ }
1040
+ });
1041
+ } else if (!want && shakeDetector) {
1042
+ YaverFeedback.stopShakeDetector();
1043
+ }
1044
+ }
1045
+
1046
+ private static stopShakeDetector(): void {
1047
+ if (shakeDetector) {
1048
+ shakeDetector.stop();
1049
+ shakeDetector = null;
1050
+ }
1051
+ }
1052
+
1053
+ /** Cancel a pending BlackBox auto-start retry chain. */
1054
+ private static cancelBlackBoxAutoStart(): void {
1055
+ if (autoStartTimer) {
1056
+ clearTimeout(autoStartTimer);
1057
+ autoStartTimer = null;
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Start BlackBox as soon as we have BOTH an agentUrl and a token, polling
1063
+ * until then.
1064
+ *
1065
+ * Both conditions are mandatory and deliberate: starting without a token
1066
+ * makes the SSE channel 401 and retry with backoff, which is the tight
1067
+ * string-concat + JSON-parse loop that used to SIGSEGV Hermes on iOS 18.3.1
1068
+ * during Screenshot & Fix. This only re-checks that same guard over time.
1069
+ *
1070
+ * Bounded at ~60s: long enough for AsyncStorage hydration plus a Convex
1071
+ * discovery round trip on a cold, off-LAN start, short enough that a device
1072
+ * whose user never signs in stops polling. Giving up here costs nothing —
1073
+ * setAuthToken() and startReport() both reschedule, so signing in later
1074
+ * still brings the channel up.
1075
+ */
1076
+ private static scheduleBlackBoxAutoStart(attempt = 0): void {
1077
+ const MAX_ATTEMPTS = 60;
1078
+ const FIRST_DELAY_MS = 500;
1079
+ const RETRY_DELAY_MS = 1000;
1080
+ // Re-run discovery every Nth tick rather than every tick: it is a Convex
1081
+ // round trip plus LAN probing, so once a second would hammer both. Every
1082
+ // 3s is responsive enough that waking the dev machine reconnects the phone
1083
+ // on its own, without the user wondering whether to restart the app.
1084
+ const REDISCOVER_EVERY = 3;
1085
+
1086
+ YaverFeedback.cancelBlackBoxAutoStart();
1087
+ if (!enabled || attempt >= MAX_ATTEMPTS) return;
1088
+
1089
+ const timer = setTimeout(() => {
1090
+ autoStartTimer = null;
1091
+ // A destroy() or setEnabled(false) between scheduling and firing.
1092
+ if (!enabled || !config) return;
1093
+ if (BlackBox.isStreaming) return;
1094
+
1095
+ if (config.agentUrl && (config.authToken || p2pAuthToken)) {
1096
+ try {
1097
+ // Pass the host's BlackBox config through. This used to call
1098
+ // start() bare, so a host that configured the flight recorder
1099
+ // — as the README itself shows — had it silently replaced by
1100
+ // defaults, and appName came out ''.
1101
+ BlackBox.start(config.blackBox);
1102
+ } catch (err) {
1103
+ console.warn('[YaverFeedback] BlackBox auto-start failed:', err);
1104
+ }
1105
+ return;
1106
+ }
1107
+
1108
+ // No agent yet. Re-attempt discovery instead of only re-reading the
1109
+ // flag: hydrateSession() runs discoverAgent() exactly once at init, so
1110
+ // if the dev machine was unreachable at that moment — asleep, phone
1111
+ // still on cellular before the relay came up, agent not yet serving —
1112
+ // agentUrl stayed null for the whole process lifetime and only an app
1113
+ // restart could recover it. discoverAgent() self-guards on "already
1114
+ // have a URL" and "no token", so calling it again is cheap and safe.
1115
+ if (!config.agentUrl && attempt > 0 && attempt % REDISCOVER_EVERY === 0) {
1116
+ void YaverFeedback.discoverAgent();
1117
+ }
1118
+
1119
+ YaverFeedback.scheduleBlackBoxAutoStart(attempt + 1);
1120
+ }, attempt === 0 ? FIRST_DELAY_MS : RETRY_DELAY_MS);
1121
+
1122
+ autoStartTimer = timer;
1123
+ unrefTimer(timer);
1124
+ }
1125
+
808
1126
  /** Returns whether the SDK is currently enabled. */
809
1127
  static isEnabled(): boolean {
810
1128
  return enabled;
@@ -883,6 +1201,113 @@ export class YaverFeedback {
883
1201
  };
884
1202
  }
885
1203
 
1204
+ /**
1205
+ * Upload a crash-aware feedback bundle. Apps can call this from their own
1206
+ * error boundary/global handler. If crashReporting.autoFix is true, the SDK
1207
+ * also triggers the agent's feedback-fix task; reload delivery still happens
1208
+ * through the existing BlackBox command stream.
1209
+ */
1210
+ static async reportCrash(
1211
+ error: Error,
1212
+ opts?: {
1213
+ isFatal?: boolean;
1214
+ source?: 'manual' | 'global-handler';
1215
+ metadata?: Record<string, unknown>;
1216
+ autoFix?: boolean;
1217
+ },
1218
+ ): Promise<{ reportId?: string; taskId?: string }> {
1219
+ if (!config || !enabled || !config.crashReporting?.enabled) return {};
1220
+ if (crashReportInFlight) return {};
1221
+ crashReportInFlight = true;
1222
+ try {
1223
+ YaverFeedback.attachError(error, {
1224
+ ...(config.crashReporting.metadata || {}),
1225
+ ...(opts?.metadata || {}),
1226
+ crashAware: true,
1227
+ });
1228
+ if (errorBuffer.length > 0) {
1229
+ errorBuffer[errorBuffer.length - 1].isFatal = opts?.isFatal ?? true;
1230
+ }
1231
+
1232
+ if (!config.agentUrl) {
1233
+ await YaverFeedback.discoverAgent();
1234
+ }
1235
+ if (!config.agentUrl) {
1236
+ console.warn('[YaverFeedback] No agent URL — cannot send crash report.');
1237
+ return {};
1238
+ }
1239
+
1240
+ const { Platform, Dimensions } = require('react-native');
1241
+ const { uploadFeedback } = require('./upload');
1242
+ const { width, height } = Dimensions.get('window');
1243
+ let screenshotPath: string | undefined;
1244
+ if (config.crashReporting.captureScreenshot !== false) {
1245
+ try {
1246
+ const { captureScreenshot } = require('./capture');
1247
+ screenshotPath = await captureScreenshot();
1248
+ } catch {}
1249
+ }
1250
+
1251
+ const autoFix = opts?.autoFix ?? config.crashReporting.autoFix === true;
1252
+ const identity = resolveReportIdentity({
1253
+ projectName: config?.projectName,
1254
+ bundleId: config?.bundleId,
1255
+ surface: config?.surface,
1256
+ surfaces: config?.surfaces,
1257
+ stack: config?.stack,
1258
+ stacks: config?.stacks,
1259
+ testSurfaces: config?.testSurfaces,
1260
+ feedbackSdk: config?.feedbackSdk,
1261
+ feedbackTransport: config?.feedbackTransport,
1262
+ voiceCapabilities: config?.voiceCapabilities,
1263
+ sttProvider: config?.sttProvider,
1264
+ ttsProvider: config?.ttsProvider,
1265
+ });
1266
+ const bundle = {
1267
+ metadata: {
1268
+ timestamp: new Date().toISOString(),
1269
+ reportKind: 'crash',
1270
+ deviceInfo: {
1271
+ platform: Platform.OS,
1272
+ osVersion: String(Platform.Version),
1273
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
1274
+ screenWidth: width,
1275
+ screenHeight: height,
1276
+ appName: identity.appName,
1277
+ },
1278
+ app: identity.app,
1279
+ project: identity.project,
1280
+ userNote: '[Crash report]',
1281
+ crash: {
1282
+ message: error.message,
1283
+ isFatal: opts?.isFatal ?? true,
1284
+ source: opts?.source ?? 'manual',
1285
+ autoFixRequested: autoFix,
1286
+ },
1287
+ },
1288
+ screenshots: screenshotPath ? [screenshotPath] : [],
1289
+ errors: errorBuffer.length > 0 ? [...errorBuffer] : undefined,
1290
+ };
1291
+
1292
+ const uploaded = await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle, p2pRelayPassword);
1293
+ const reportId =
1294
+ (uploaded as { id?: string; reportId?: string } | null | undefined)?.id ??
1295
+ (uploaded as { reportId?: string } | null | undefined)?.reportId;
1296
+ let taskId: string | undefined;
1297
+ if (autoFix && reportId) {
1298
+ const client = p2pClient ?? new P2PClient(config.agentUrl, config.authToken ?? '', p2pRelayPassword);
1299
+ const fix = await client.triggerFix(reportId);
1300
+ taskId = fix?.taskId;
1301
+ }
1302
+ return { reportId, taskId };
1303
+ } catch (err) {
1304
+ console.warn('[YaverFeedback] Crash report failed:', err);
1305
+ return {};
1306
+ } finally {
1307
+ crashReportInFlight = false;
1308
+ }
1309
+ }
1310
+
886
1311
  /**
887
1312
  * Returns the P2P client instance.
888
1313
  * Available after init if agentUrl is set, or after first successful discovery.
@@ -1033,17 +1458,33 @@ export class YaverFeedback {
1033
1458
  // Screenshot capture may fail (e.g. no view ref) — continue without it
1034
1459
  }
1035
1460
 
1461
+ const identity = resolveReportIdentity({
1462
+ projectName: config?.projectName,
1463
+ bundleId: config?.bundleId,
1464
+ surface: config?.surface,
1465
+ surfaces: config?.surfaces,
1466
+ stack: config?.stack,
1467
+ stacks: config?.stacks,
1468
+ testSurfaces: config?.testSurfaces,
1469
+ feedbackSdk: config?.feedbackSdk,
1470
+ feedbackTransport: config?.feedbackTransport,
1471
+ voiceCapabilities: config?.voiceCapabilities,
1472
+ sttProvider: config?.sttProvider,
1473
+ ttsProvider: config?.ttsProvider,
1474
+ });
1036
1475
  const bundle = {
1037
1476
  metadata: {
1038
1477
  timestamp: new Date().toISOString(),
1039
- device: {
1478
+ deviceInfo: {
1040
1479
  platform: Platform.OS,
1041
1480
  osVersion: String(Platform.Version),
1042
1481
  model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
1043
1482
  screenWidth: width,
1044
1483
  screenHeight: height,
1484
+ appName: identity.appName,
1045
1485
  },
1046
- app: {},
1486
+ app: identity.app,
1487
+ project: identity.project,
1047
1488
  userNote: '[Auto-report via shake]',
1048
1489
  },
1049
1490
  screenshots: screenshotPath ? [screenshotPath] : [],
@@ -1211,6 +1652,17 @@ export class YaverFeedback {
1211
1652
  shakeDetector.stop();
1212
1653
  shakeDetector = null;
1213
1654
  }
1655
+ // Drop the agent command handler and un-monkey-patch console. Without
1656
+ // these, destroy() left the SDK half-alive: console stayed wrapped, and
1657
+ // the reload handler kept firing on a "destroyed" SDK — then a later
1658
+ // init() stacked a second one on top.
1659
+ commandUnsubscribe?.();
1660
+ commandUnsubscribe = null;
1661
+ // Before `enabled = false` / `config = null` below, so an in-flight retry
1662
+ // can't fire against a torn-down config.
1663
+ YaverFeedback.cancelBlackBoxAutoStart();
1664
+ BlackBox.stop();
1665
+ BlackBox.unwrapConsole();
1214
1666
  firstShakeFired = false;
1215
1667
  enabled = false;
1216
1668
  config = null;