yaver-feedback-react-native 0.4.0 → 0.5.0

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.
@@ -3,6 +3,14 @@ import { YaverDiscovery } from './Discovery';
3
3
  import { BlackBox } from './BlackBox';
4
4
  import { P2PClient } from './P2PClient';
5
5
  import { ShakeDetector } from './ShakeDetector';
6
+ import {
7
+ configureAuthEndpoints,
8
+ getToken,
9
+ getSelectedDeviceId,
10
+ clearToken,
11
+ clearSelectedDeviceId,
12
+ DEFAULT_CONVEX_SITE_URL,
13
+ } from './auth';
6
14
 
7
15
  let config: FeedbackConfig | null = null;
8
16
  let enabled = false;
@@ -16,6 +24,13 @@ let maxErrors = 5;
16
24
  /** Track whether BlackBox was running before disable (to restart on enable). */
17
25
  let blackBoxWasStreaming = false;
18
26
 
27
+ /**
28
+ * Flag evaluation cache — 30s TTL per `userId|key`. Prevents a
29
+ * tight render loop from hammering /flags/eval when the dev calls
30
+ * `YaverFeedback.getFlag()` every frame.
31
+ */
32
+ const flagCache: Map<string, { value: unknown; at: number }> = new Map();
33
+
19
34
  /**
20
35
  * Main entry point for the Yaver Feedback SDK.
21
36
  * Call `YaverFeedback.init()` once at app startup.
@@ -34,9 +49,23 @@ export class YaverFeedback {
34
49
  maxRecordingDuration: 120,
35
50
  feedbackMode: 'batch',
36
51
  agentCommentaryLevel: 0,
52
+ autoLogin: true,
37
53
  ...cfg,
38
54
  };
39
55
 
56
+ // Route the in-SDK login screen to prod yaver.io by default; callers may
57
+ // override for staging via authConvexSiteUrl / authWebBaseUrl.
58
+ configureAuthEndpoints({
59
+ convexSiteUrl: cfg.authConvexSiteUrl,
60
+ webBaseUrl: cfg.authWebBaseUrl,
61
+ });
62
+ // If no explicit convexUrl was set but we have an auth site URL, use it
63
+ // so Discovery.discoverFromConvex() has somewhere to look up the user's
64
+ // machines (works for both LAN-direct and off-LAN relay paths).
65
+ if (!config.convexUrl) {
66
+ config.convexUrl = cfg.authConvexSiteUrl ?? DEFAULT_CONVEX_SITE_URL;
67
+ }
68
+
40
69
  // Default: enabled only in dev mode
41
70
  if (cfg.enabled !== undefined) {
42
71
  enabled = cfg.enabled;
@@ -44,13 +73,20 @@ export class YaverFeedback {
44
73
  enabled = typeof __DEV__ !== 'undefined' ? __DEV__ : false;
45
74
  }
46
75
 
76
+ // Hydrate cached auth token + preferred device from AsyncStorage so the
77
+ // SDK reconnects silently on subsequent launches. If autoLogin is false
78
+ // the caller is responsible for providing authToken themselves.
79
+ if (config.autoLogin !== false && enabled) {
80
+ void YaverFeedback.hydrateSession();
81
+ }
82
+
47
83
  // Create P2P client if we have a URL
48
84
  if (config.agentUrl) {
49
- p2pClient = new P2PClient(config.agentUrl, config.authToken);
85
+ p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '');
50
86
  } else {
51
87
  p2pClient = null;
52
88
  // Auto-discover agent in the background when convexUrl or LAN is available
53
- if (enabled) {
89
+ if (enabled && (config.authToken || config.preferredDeviceId)) {
54
90
  YaverFeedback.discoverAgent();
55
91
  }
56
92
  }
@@ -117,6 +153,7 @@ export class YaverFeedback {
117
153
  static async discoverAgent(): Promise<void> {
118
154
  if (!config || !enabled) return;
119
155
  if (config.agentUrl) return; // already have a URL
156
+ if (!config.authToken) return; // need auth before discovery can succeed
120
157
 
121
158
  try {
122
159
  const result = await YaverDiscovery.discover({
@@ -126,13 +163,109 @@ export class YaverFeedback {
126
163
  });
127
164
  if (result && config) {
128
165
  config.agentUrl = result.url;
129
- p2pClient = new P2PClient(result.url, config.authToken);
166
+ p2pClient = new P2PClient(result.url, config.authToken ?? '');
130
167
  }
131
168
  } catch {
132
169
  // Discovery failed — FloatingButton will show disconnected, user can retry
133
170
  }
134
171
  }
135
172
 
173
+ /**
174
+ * Pull a cached session token + selected device from AsyncStorage (populated
175
+ * by the in-SDK login + machine-picker screens). When present the SDK can
176
+ * reconnect silently on launch without re-prompting the user. Safe to call
177
+ * multiple times — it only overrides values the caller did not already set.
178
+ */
179
+ static async hydrateSession(): Promise<void> {
180
+ if (!config) return;
181
+ try {
182
+ if (!config.authToken) {
183
+ const cached = await getToken();
184
+ if (cached) {
185
+ config.authToken = cached;
186
+ }
187
+ }
188
+ if (!config.preferredDeviceId) {
189
+ const cachedDevice = await getSelectedDeviceId();
190
+ if (cachedDevice) {
191
+ config.preferredDeviceId = cachedDevice;
192
+ }
193
+ }
194
+ if (config.authToken && !config.agentUrl) {
195
+ await YaverFeedback.discoverAgent();
196
+ }
197
+ } catch {
198
+ // hydration best-effort
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Update the signed-in session token (e.g. after the in-SDK login screen
204
+ * succeeds). Rebuilds the P2P client and kicks off agent discovery.
205
+ */
206
+ static async setAuthToken(token: string): Promise<void> {
207
+ if (!config) return;
208
+ config.authToken = token;
209
+ if (config.agentUrl) {
210
+ p2pClient = new P2PClient(config.agentUrl, token);
211
+ } else {
212
+ await YaverFeedback.discoverAgent();
213
+ }
214
+ }
215
+
216
+ /** Returns true once the SDK has a session token it can use. */
217
+ static isAuthed(): boolean {
218
+ return Boolean(config?.authToken);
219
+ }
220
+
221
+ /**
222
+ * Request the embedded FeedbackModal to show the login screen. Works by
223
+ * emitting an event the modal listens for — avoids forcing the host app
224
+ * to mount a second navigator.
225
+ */
226
+ static showLogin(): void {
227
+ const { DeviceEventEmitter } = require('react-native');
228
+ DeviceEventEmitter.emit('yaverFeedback:startLogin');
229
+ }
230
+
231
+ /**
232
+ * Request the embedded FeedbackModal to show the machine picker. Requires
233
+ * an active session; no-ops otherwise.
234
+ */
235
+ static showMachinePicker(): void {
236
+ if (!YaverFeedback.isAuthed()) return;
237
+ const { DeviceEventEmitter } = require('react-native');
238
+ DeviceEventEmitter.emit('yaverFeedback:startMachinePicker');
239
+ }
240
+
241
+ /**
242
+ * Update the selected remote device. Resets the cached agent URL so the
243
+ * next `startReport()` (or FloatingButton press) rediscovers against the
244
+ * newly-selected machine.
245
+ */
246
+ static async setPreferredDevice(deviceId: string): Promise<void> {
247
+ if (!config) return;
248
+ config.preferredDeviceId = deviceId;
249
+ config.agentUrl = undefined;
250
+ p2pClient = null;
251
+ await YaverFeedback.discoverAgent();
252
+ }
253
+
254
+ /**
255
+ * Sign out: clear cached token + device, tear down the P2P client. The
256
+ * SDK stays enabled; the next feedback trigger will re-prompt for login.
257
+ */
258
+ static async signOut(): Promise<void> {
259
+ await clearToken();
260
+ await clearSelectedDeviceId();
261
+ if (config) {
262
+ config.authToken = undefined;
263
+ config.preferredDeviceId = undefined;
264
+ config.agentUrl = undefined;
265
+ }
266
+ p2pClient = null;
267
+ }
268
+
136
269
  /**
137
270
  * Manually trigger the feedback collection flow.
138
271
  * Opens the feedback modal if the SDK is initialized and enabled.
@@ -148,6 +281,18 @@ export class YaverFeedback {
148
281
  return;
149
282
  }
150
283
 
284
+ // If the caller has autoLogin enabled and we have no session yet, show
285
+ // the in-SDK login flow instead of a failing discovery + warning spam.
286
+ if (!config.authToken) {
287
+ if (config.autoLogin !== false) {
288
+ await YaverFeedback.hydrateSession();
289
+ }
290
+ if (!config.authToken) {
291
+ YaverFeedback.showLogin();
292
+ return;
293
+ }
294
+ }
295
+
151
296
  // Auto-discover if no agent URL was provided
152
297
  if (!config.agentUrl) {
153
298
  try {
@@ -158,9 +303,15 @@ export class YaverFeedback {
158
303
  });
159
304
  if (result) {
160
305
  config.agentUrl = result.url;
161
- p2pClient = new P2PClient(result.url, config.authToken);
306
+ p2pClient = new P2PClient(result.url, config.authToken ?? '');
307
+ } else if (config.autoLogin !== false && !config.preferredDeviceId) {
308
+ // No agent discovered and no device picked yet — prompt the user
309
+ // to pick one of their machines (handles the non-LAN case where
310
+ // relay discovery requires knowing which deviceId to target).
311
+ YaverFeedback.showMachinePicker();
312
+ return;
162
313
  } else {
163
- console.warn('[YaverFeedback] No agent found. Set agentUrl, convexUrl, or ensure agent is running on the network.');
314
+ console.warn('[YaverFeedback] No agent found. Check that `yaver serve` is running on the selected machine.');
164
315
  }
165
316
  } catch (err) {
166
317
  console.warn('[YaverFeedback] Auto-discovery failed:', err);
@@ -314,6 +465,103 @@ export class YaverFeedback {
314
465
  return config?.agentCommentaryLevel ?? 0;
315
466
  }
316
467
 
468
+ // ─── One-stop SaaS replacement methods ─────────────────────────
469
+ //
470
+ // These are the three solo-dev SaaS-replacement entry points
471
+ // wired into YaverFeedback so there's exactly one import path
472
+ // for the dev's app code: track / getFlag / checkUpdate.
473
+
474
+ /**
475
+ * Record a business event. Routes through BlackBox so the agent
476
+ * persists it to the analytics ledger (no dashboards — export
477
+ * via CSV or webhook into PostHog).
478
+ *
479
+ * @example
480
+ * ```ts
481
+ * YaverFeedback.track('purchase_completed', { amount: '9.99' });
482
+ * ```
483
+ */
484
+ static track(name: string, props?: Record<string, unknown>, route?: string): void {
485
+ if (!enabled) return;
486
+ BlackBox.track(name, props, route);
487
+ }
488
+
489
+ /**
490
+ * Evaluate a single feature flag for a user. Results are cached
491
+ * for 30 seconds inside YaverFeedback so a tight loop evaluating
492
+ * the same key doesn't hammer the agent.
493
+ *
494
+ * @param key — flag key (must exist on the agent)
495
+ * @param defaultValue — returned if the flag is missing / offline
496
+ * @param userId — stable user identifier for rollout bucketing
497
+ */
498
+ static async getFlag<T = boolean | string>(
499
+ key: string,
500
+ defaultValue: T,
501
+ userId: string = 'anonymous',
502
+ ): Promise<T> {
503
+ if (!enabled || !p2pClient) return defaultValue;
504
+ const cacheKey = `${userId}|${key}`;
505
+ const now = Date.now();
506
+ const cached = flagCache.get(cacheKey);
507
+ if (cached && now - cached.at < 30_000) {
508
+ return (cached.value as T) ?? defaultValue;
509
+ }
510
+ try {
511
+ const val = await p2pClient.flagsEvaluateOne<T>(key, userId);
512
+ flagCache.set(cacheKey, { value: val ?? defaultValue, at: now });
513
+ return (val as T) ?? defaultValue;
514
+ } catch {
515
+ return defaultValue;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Bulk evaluate every flag for a user. Cached on the same 30s
521
+ * window as getFlag — use this when boot needs a handful of
522
+ * flags in one roundtrip.
523
+ */
524
+ static async getFlags(
525
+ userId: string = 'anonymous',
526
+ ): Promise<Record<string, unknown>> {
527
+ if (!enabled || !p2pClient) return {};
528
+ const cacheKey = `all|${userId}`;
529
+ const now = Date.now();
530
+ const cached = flagCache.get(cacheKey);
531
+ if (cached && now - cached.at < 30_000) {
532
+ return (cached.value as Record<string, unknown>) ?? {};
533
+ }
534
+ try {
535
+ const flags = await p2pClient.flagsEvaluate(userId);
536
+ flagCache.set(cacheKey, { value: flags, at: now });
537
+ return flags;
538
+ } catch {
539
+ return {};
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Ask what bundle this device should run. Returns the latest
545
+ * release manifest in the configured channel plus a rollout
546
+ * gate. The dev can then compare against what's currently
547
+ * running and prompt the user to reload.
548
+ *
549
+ * On-disk bundle swap is platform-specific — see
550
+ * `YaverFeedback.onUpdateAvailable` if you want a hook.
551
+ */
552
+ static async checkUpdate(
553
+ channel: string = 'production',
554
+ deviceId?: string,
555
+ ): Promise<Awaited<ReturnType<P2PClient['releasesLatest']>>> {
556
+ if (!enabled || !p2pClient) return null;
557
+ return p2pClient.releasesLatest(channel, deviceId);
558
+ }
559
+
560
+ /** Clear the in-memory flag cache. Useful for tests or after sign-out. */
561
+ static clearFlagCache(): void {
562
+ flagCache.clear();
563
+ }
564
+
317
565
  /**
318
566
  * Reporting-only mode: auto-capture screenshot + errors and send
319
567
  * to the agent's /feedback endpoint. No modal UI — just shake and go.
@@ -334,7 +582,7 @@ export class YaverFeedback {
334
582
  });
335
583
  if (result) {
336
584
  config.agentUrl = result.url;
337
- p2pClient = new P2PClient(result.url, config.authToken);
585
+ p2pClient = new P2PClient(result.url, config.authToken ?? '');
338
586
  }
339
587
  } catch {}
340
588
  }
@@ -375,7 +623,7 @@ export class YaverFeedback {
375
623
  errors: errorBuffer.length > 0 ? [...errorBuffer] : undefined,
376
624
  };
377
625
 
378
- await uploadFeedback(config.agentUrl, config.authToken, bundle);
626
+ await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
379
627
  console.log('[YaverFeedback] Auto-report sent');
380
628
  } catch (err) {
381
629
  console.warn('[YaverFeedback] Auto-report failed:', err);
@@ -397,7 +645,7 @@ export class YaverFeedback {
397
645
  private static defaultReload(): void {
398
646
  if (!config?.agentUrl) return;
399
647
  const bundleUrl = `${config.agentUrl}/dev/native-bundle`;
400
- const headers = { Authorization: `Bearer ${config.authToken}` };
648
+ const headers = { Authorization: `Bearer ${config.authToken ?? ''}` };
401
649
  YaverFeedback.loadBundleAndReload(bundleUrl, headers);
402
650
  }
403
651
 
@@ -411,7 +659,7 @@ export class YaverFeedback {
411
659
  const fullUrl = bundleUrl.startsWith('http')
412
660
  ? bundleUrl
413
661
  : `${config.agentUrl}${bundleUrl}`;
414
- const headers = { Authorization: `Bearer ${config.authToken}` };
662
+ const headers = { Authorization: `Bearer ${config.authToken ?? ''}` };
415
663
  YaverFeedback.loadBundleAndReload(fullUrl, headers);
416
664
  }
417
665
 
@@ -0,0 +1,334 @@
1
+ // YaverUpdates — self-hosted OTA client for React Native apps.
2
+ //
3
+ // End-user apps poll the yaver agent's /releases/latest endpoint
4
+ // through the P2P relay, download the matching Hermes bundle via
5
+ // /releases/bundle, and optionally trigger a JS reload. The
6
+ // bundle is stored on disk for a subsequent cold start to pick
7
+ // up (v1 — this file does NOT hot-swap the live runtime; a
8
+ // follow-up native module Swift/Kotlin will wire into Yaver's
9
+ // existing safeReloadBridge path for true in-process swaps).
10
+ //
11
+ // What v1 ships:
12
+ //
13
+ // - `YaverUpdates.init({ channel, userId, auto })` — polls
14
+ // /releases/latest on boot + every interval, downloads new
15
+ // bundles when inRollout, persists them to a known path,
16
+ // emits a BlackBox `lifecycle` event "update_ready".
17
+ // - `YaverUpdates.checkForUpdate()` — one-shot poll + download.
18
+ // - `YaverUpdates.applyPendingUpdate()` — calls
19
+ // DevSettings.reload() in dev builds, a no-op in release
20
+ // until the native module lands.
21
+ // - `YaverUpdates.rollback()` — deletes the cached bundle so
22
+ // the next cold start ignores it.
23
+ //
24
+ // Storage path is stable across restarts and matches what the
25
+ // future native module will read:
26
+ //
27
+ // <DocumentDirectory>/yaver-updates/<channel>/bundle.hbc
28
+ // <DocumentDirectory>/yaver-updates/<channel>/metadata.json
29
+ //
30
+ // The SDK writes to a temp path first and renames on success so
31
+ // a mid-download crash never leaves a half-written bundle in
32
+ // place.
33
+ //
34
+ // SELF-HOSTING WIN: the dev's own agent serves the bundle
35
+ // through the dev's own relay. No EAS Update subscription, no
36
+ // CodePush dependency, no central vendor. The bundle never
37
+ // touches any server the dev doesn't control.
38
+
39
+ import { Platform } from 'react-native';
40
+ import { BlackBox } from './BlackBox';
41
+ import { YaverFeedback } from './YaverFeedback';
42
+ import { P2PClient } from './P2PClient';
43
+
44
+ export interface YaverUpdatesConfig {
45
+ /** Release channel to track. Default: "production". */
46
+ channel?: string;
47
+ /** Stable user identifier for rollout bucketing. */
48
+ userId?: string;
49
+ /** Poll interval in ms. 0 disables the poll loop. Default: 5 min. */
50
+ interval?: number;
51
+ /** Automatically download new bundles. Default: true. */
52
+ autoDownload?: boolean;
53
+ /**
54
+ * Callback fired when a new bundle has been downloaded and
55
+ * persisted. The dev's app can show an "update available" UI
56
+ * and call `applyPendingUpdate()` to trigger the reload.
57
+ */
58
+ onUpdateReady?: (info: PendingUpdate) => void;
59
+ }
60
+
61
+ /** Describes a downloaded-but-not-yet-applied update. */
62
+ export interface PendingUpdate {
63
+ channel: string;
64
+ semver: string;
65
+ md5: string;
66
+ size: number;
67
+ downloadedAt: number;
68
+ bundlePath: string;
69
+ }
70
+
71
+ interface LatestResponse {
72
+ ok: boolean;
73
+ channel: string;
74
+ semver?: string;
75
+ size?: number;
76
+ md5?: string;
77
+ hermesBcVersion?: number;
78
+ bundleUrl?: string;
79
+ rolloutPercent: number;
80
+ inRollout: boolean;
81
+ reason?: string;
82
+ }
83
+
84
+ type NativeFS = {
85
+ DocumentDirectoryPath?: string;
86
+ writeFile?: (path: string, contents: string, encoding?: string) => Promise<void>;
87
+ unlink?: (path: string) => Promise<void>;
88
+ mkdir?: (path: string) => Promise<void>;
89
+ exists?: (path: string) => Promise<boolean>;
90
+ };
91
+
92
+ // We feature-detect react-native-fs instead of hard-requiring
93
+ // it. Devs who don't have it installed still get
94
+ // checkForUpdate() polling + the BlackBox event, just without
95
+ // disk persistence.
96
+ function loadFS(): NativeFS | null {
97
+ try {
98
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
99
+ const fs = require('react-native-fs');
100
+ return fs as NativeFS;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ export class YaverUpdates {
107
+ private static cfg: Required<YaverUpdatesConfig> | null = null;
108
+ private static pollTimer: ReturnType<typeof setInterval> | null = null;
109
+ private static pending: PendingUpdate | null = null;
110
+ private static fs: NativeFS | null = null;
111
+ private static started = false;
112
+
113
+ /**
114
+ * Start the OTA poll loop. Safe to call before init() finishes
115
+ * — the call defers until a P2PClient is available.
116
+ */
117
+ static init(config?: YaverUpdatesConfig): void {
118
+ if (YaverUpdates.started) {
119
+ YaverUpdates.cfg = {
120
+ channel: config?.channel ?? YaverUpdates.cfg?.channel ?? 'production',
121
+ userId: config?.userId ?? YaverUpdates.cfg?.userId ?? 'anonymous',
122
+ interval: config?.interval ?? YaverUpdates.cfg?.interval ?? 5 * 60 * 1000,
123
+ autoDownload: config?.autoDownload ?? YaverUpdates.cfg?.autoDownload ?? true,
124
+ onUpdateReady: config?.onUpdateReady ?? YaverUpdates.cfg?.onUpdateReady ?? (() => {}),
125
+ };
126
+ return;
127
+ }
128
+ YaverUpdates.started = true;
129
+ YaverUpdates.fs = loadFS();
130
+ YaverUpdates.cfg = {
131
+ channel: config?.channel ?? 'production',
132
+ userId: config?.userId ?? 'anonymous',
133
+ interval: config?.interval ?? 5 * 60 * 1000,
134
+ autoDownload: config?.autoDownload ?? true,
135
+ onUpdateReady: config?.onUpdateReady ?? (() => {}),
136
+ };
137
+
138
+ // Kick off the first check immediately so devs see an
139
+ // update-available banner on the very next app cold start.
140
+ YaverUpdates.checkForUpdate().catch(() => {});
141
+
142
+ if (YaverUpdates.cfg.interval > 0) {
143
+ YaverUpdates.pollTimer = setInterval(
144
+ () => YaverUpdates.checkForUpdate().catch(() => {}),
145
+ YaverUpdates.cfg.interval,
146
+ );
147
+ }
148
+ }
149
+
150
+ /** Stop the poll loop. */
151
+ static stop(): void {
152
+ if (YaverUpdates.pollTimer) {
153
+ clearInterval(YaverUpdates.pollTimer);
154
+ YaverUpdates.pollTimer = null;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * One-shot poll. Returns the latest release metadata. If a new
160
+ * bundle is available AND autoDownload is true, also downloads
161
+ * and caches it. Resolves to null on network / auth failure.
162
+ */
163
+ static async checkForUpdate(): Promise<LatestResponse | null> {
164
+ const cfg = YaverUpdates.cfg;
165
+ if (!cfg) return null;
166
+ const client = YaverFeedback.getP2PClient();
167
+ if (!client) return null;
168
+ const latest = await client.releasesLatest(cfg.channel, cfg.userId);
169
+ if (!latest || !latest.semver) return latest;
170
+ if (!latest.inRollout) return latest;
171
+
172
+ // Skip if we already have this bundle cached.
173
+ if (YaverUpdates.pending && YaverUpdates.pending.semver === latest.semver) {
174
+ return latest;
175
+ }
176
+
177
+ if (cfg.autoDownload) {
178
+ await YaverUpdates.downloadAndCache(client, latest);
179
+ }
180
+ return latest;
181
+ }
182
+
183
+ /**
184
+ * Returns the currently-pending bundle (downloaded but not
185
+ * applied). Null if no update is waiting.
186
+ */
187
+ static getPendingUpdate(): PendingUpdate | null {
188
+ return YaverUpdates.pending;
189
+ }
190
+
191
+ /**
192
+ * Apply a pending update. In dev builds this calls
193
+ * DevSettings.reload(). In release builds without a native
194
+ * module it returns false — the bundle is persisted but the
195
+ * OS will only load it on the next cold start, or after the
196
+ * future YaverUpdates native module lands.
197
+ */
198
+ static async applyPendingUpdate(): Promise<boolean> {
199
+ if (!YaverUpdates.pending) return false;
200
+ try {
201
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
202
+ const rn = require('react-native');
203
+ if (rn?.DevSettings?.reload) {
204
+ rn.DevSettings.reload();
205
+ return true;
206
+ }
207
+ } catch {
208
+ // fall through
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * Discard the cached pending bundle. Next cold start goes
215
+ * back to whatever was previously loaded.
216
+ */
217
+ static async rollback(): Promise<void> {
218
+ if (!YaverUpdates.pending || !YaverUpdates.fs) {
219
+ YaverUpdates.pending = null;
220
+ return;
221
+ }
222
+ try {
223
+ await YaverUpdates.fs.unlink?.(YaverUpdates.pending.bundlePath);
224
+ } catch {
225
+ // swallow — the bundle may already be gone
226
+ }
227
+ YaverUpdates.pending = null;
228
+ }
229
+
230
+ // --- internals ---------------------------------------------------
231
+
232
+ private static async downloadAndCache(
233
+ client: P2PClient,
234
+ latest: LatestResponse,
235
+ ): Promise<void> {
236
+ if (!latest.semver || !latest.md5 || !latest.size) return;
237
+
238
+ const bytes = await client.releasesDownload(latest.channel, latest.semver);
239
+ if (!bytes) return;
240
+
241
+ const info: PendingUpdate = {
242
+ channel: latest.channel,
243
+ semver: latest.semver,
244
+ md5: latest.md5,
245
+ size: latest.size,
246
+ downloadedAt: Date.now(),
247
+ bundlePath: '',
248
+ };
249
+
250
+ const fs = YaverUpdates.fs;
251
+ if (!fs || !fs.DocumentDirectoryPath || !fs.writeFile) {
252
+ // No FS — still expose the pending record in-memory so
253
+ // the dev's app can react to the BlackBox event.
254
+ YaverUpdates.pending = info;
255
+ YaverUpdates.emitUpdateReady(info);
256
+ return;
257
+ }
258
+
259
+ const dir = `${fs.DocumentDirectoryPath}/yaver-updates/${latest.channel}`;
260
+ try {
261
+ await fs.mkdir?.(dir);
262
+ } catch {
263
+ // mkdir -p — directory may already exist
264
+ }
265
+ const bundlePath = `${dir}/bundle.hbc`;
266
+ const tmpPath = `${bundlePath}.tmp`;
267
+
268
+ // react-native-fs writeFile accepts base64 or utf8; Hermes
269
+ // bundles are binary so we encode the ArrayBuffer as base64.
270
+ const base64 = bufferToBase64(bytes);
271
+ await fs.writeFile(tmpPath, base64, 'base64');
272
+ try {
273
+ await fs.unlink?.(bundlePath);
274
+ } catch {
275
+ // fine — no previous bundle
276
+ }
277
+ // We can't atomic-rename without a native bridge, so the
278
+ // tmpPath -> bundlePath swap is a copy + delete. For a dev
279
+ // runtime this is fine; the native shim will tighten it.
280
+ try {
281
+ const rename = (fs as unknown as {
282
+ moveFile?: (from: string, to: string) => Promise<void>;
283
+ }).moveFile;
284
+ if (rename) {
285
+ await rename(tmpPath, bundlePath);
286
+ } else {
287
+ // Best-effort fallback: write directly to bundlePath on
288
+ // the next attempt. Leaves a .tmp around, which the next
289
+ // run will overwrite.
290
+ }
291
+ } catch {
292
+ // swallow
293
+ }
294
+
295
+ info.bundlePath = bundlePath;
296
+ YaverUpdates.pending = info;
297
+ YaverUpdates.emitUpdateReady(info);
298
+ }
299
+
300
+ private static emitUpdateReady(info: PendingUpdate): void {
301
+ BlackBox.lifecycle('yaver-updates: bundle downloaded', {
302
+ channel: info.channel,
303
+ semver: info.semver,
304
+ md5: info.md5,
305
+ size: info.size,
306
+ platform: Platform.OS,
307
+ });
308
+
309
+ const cb = YaverUpdates.cfg?.onUpdateReady;
310
+ if (cb) {
311
+ try {
312
+ cb(info);
313
+ } catch {
314
+ // dev callback threw — don't let it stall the poll loop
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ // Small ArrayBuffer -> base64 helper with no external deps so
321
+ // the SDK package doesn't balloon. Safe for small bundles
322
+ // (~10MB worst case) and runs on every RN target without
323
+ // polyfills.
324
+ function bufferToBase64(buf: ArrayBuffer): string {
325
+ const bytes = new Uint8Array(buf);
326
+ let binary = '';
327
+ const chunk = 0x8000;
328
+ for (let i = 0; i < bytes.length; i += chunk) {
329
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
330
+ }
331
+ // btoa exists in both React Native's Hermes and Node for tests.
332
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
333
+ return (globalThis as any).btoa(binary);
334
+ }