yaver-feedback-react-native 0.7.11 → 0.7.13

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.
@@ -42,6 +42,9 @@ public class YaverHotReloadModule extends ReactContextBaseJavaModule {
42
42
  private static final String BUNDLE_FILE = "index.android.bundle";
43
43
  private static final String PREFS_NAME = "yaver_hot_reload";
44
44
  private static final String PREFS_KEY_BUNDLE = "bundle_path";
45
+ private static final String PREFS_KEY_BOOT_ATTEMPTS = "boot_attempts";
46
+ private static final String PREFS_KEY_BUNDLE_MTIME = "bundle_mtime";
47
+ private static final int MAX_BOOT_ATTEMPTS = 3;
45
48
 
46
49
  public YaverHotReloadModule(ReactApplicationContext context) {
47
50
  super(context);
@@ -173,16 +176,72 @@ public class YaverHotReloadModule extends ReactContextBaseJavaModule {
173
176
  // MARK: - Static helpers for Application/MainApplication
174
177
 
175
178
  /**
176
- * Returns the hot-reloaded bundle file if it exists.
177
- * Call from MainApplication.getJSBundleFile() to load the hot bundle on startup.
179
+ * Returns the hot-reloaded bundle file if it exists AND has not
180
+ * crashed on boot {@link #MAX_BOOT_ATTEMPTS} times in a row.
181
+ * Call from MainApplication.getJSBundleFile() to load the hot bundle
182
+ * on startup.
183
+ *
184
+ * Safety net for the vibe-coding loop: if a pushed bundle crashes
185
+ * on boot, without this guard the saved bundle persists across
186
+ * cold starts and bricks the app. Counter increments on each cold
187
+ * start, resets on {@link #markBootSuccessful(Context)} (called
188
+ * from MainApplication's ReactInstanceEventListener after JS
189
+ * context init, and a 10-s fallback timer). If the counter hits
190
+ * {@link #MAX_BOOT_ATTEMPTS}, delete the saved bundle and return
191
+ * null so MainApplication falls back to the APK-bundled bundle.
192
+ * See YaverHotReload.swift for the matching iOS implementation.
178
193
  */
179
194
  public static File getSavedBundleFile(Context context) {
180
195
  SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
181
196
  String path = prefs.getString(PREFS_KEY_BUNDLE, null);
182
- if (path != null) {
183
- File f = new File(path);
184
- if (f.exists()) return f;
197
+ if (path == null) return null;
198
+ File f = new File(path);
199
+ if (!f.exists()) return null;
200
+
201
+ long currentMtime = f.lastModified();
202
+ long lastMtime = prefs.getLong(PREFS_KEY_BUNDLE_MTIME, 0);
203
+ SharedPreferences.Editor editor = prefs.edit();
204
+
205
+ // Fresh bundle since the counter was last reset → start over.
206
+ if (currentMtime != lastMtime) {
207
+ editor.putInt(PREFS_KEY_BOOT_ATTEMPTS, 0);
208
+ editor.putLong(PREFS_KEY_BUNDLE_MTIME, currentMtime);
185
209
  }
186
- return null;
210
+
211
+ int attempts = prefs.getInt(PREFS_KEY_BOOT_ATTEMPTS, 0);
212
+ if (attempts >= MAX_BOOT_ATTEMPTS) {
213
+ Log.w(TAG, "hot bundle failed " + attempts + " consecutive boot attempts — reverting to APK-bundled bundle.");
214
+ File dir = new File(context.getFilesDir(), BUNDLE_DIR);
215
+ if (dir.exists()) {
216
+ File[] list = dir.listFiles();
217
+ if (list != null) for (File child : list) child.delete();
218
+ dir.delete();
219
+ }
220
+ editor.remove(PREFS_KEY_BOOT_ATTEMPTS)
221
+ .remove(PREFS_KEY_BUNDLE_MTIME)
222
+ .remove(PREFS_KEY_BUNDLE)
223
+ .apply();
224
+ return null;
225
+ }
226
+
227
+ // Pre-increment: this boot counts as a failure unless the JS
228
+ // side reaches context-initialized and calls markBootSuccessful.
229
+ editor.putInt(PREFS_KEY_BOOT_ATTEMPTS, attempts + 1).apply();
230
+ Log.i(TAG, "loading hot bundle (boot attempt " + (attempts + 1) + "/" + MAX_BOOT_ATTEMPTS + ")");
231
+ return f;
232
+ }
233
+
234
+ /**
235
+ * Clear the boot-attempt counter. MainApplication should call this
236
+ * from a ReactInstanceEventListener after JS context init, AND via
237
+ * a 10-s fallback Handler in case the listener never fires.
238
+ */
239
+ public static void markBootSuccessful(Context context) {
240
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
241
+ int attempts = prefs.getInt(PREFS_KEY_BOOT_ATTEMPTS, 0);
242
+ if (attempts > 0) {
243
+ Log.i(TAG, "boot confirmed successful — reset boot-attempt counter.");
244
+ }
245
+ prefs.edit().putInt(PREFS_KEY_BOOT_ATTEMPTS, 0).apply();
187
246
  }
188
247
  }
package/app.plugin.js CHANGED
@@ -70,16 +70,24 @@ function withYaverFeedbackAndroid(config) {
70
70
  }
71
71
 
72
72
  /**
73
- * Copy YaverHotReload native module files into the iOS project directory.
74
- * Uses withDangerousMod to copy files during prebuild. The files are
75
- * automatically picked up by Xcode since they're in the project directory.
73
+ * Copy YaverHotReload native module files into the iOS project directory
74
+ * AND register them in project.pbxproj so Xcode actually compiles them.
75
+ *
76
+ * The file-copy step uses withDangerousMod; the project registration
77
+ * uses withXcodeProject. Both are needed — if we only copy the files
78
+ * they sit in the filesystem unreferenced, Xcode ignores them, the
79
+ * final .ipa has no `YaverHotReload` class, and the agent's
80
+ * `reload_bundle` broadcast silently fails to load a new Hermes bundle.
81
+ * This is a bug we've hit repeatedly; keep both steps wired.
76
82
  */
77
83
  function withYaverHotReloadNativeModule(config) {
78
- return withDangerousMod(config, [
84
+ // Step 1 — copy source files onto disk.
85
+ config = withDangerousMod(config, [
79
86
  "ios",
80
87
  (config) => {
81
88
  const sdkIosDir = path.resolve(__dirname, "ios");
82
- const appName = config.modRequest.projectName || "SFMG";
89
+ const appName = config.modRequest.projectName;
90
+ if (!appName) return config;
83
91
  const targetDir = path.join(
84
92
  config.modRequest.platformProjectRoot,
85
93
  appName
@@ -89,7 +97,10 @@ function withYaverHotReloadNativeModule(config) {
89
97
  for (const fileName of filesToCopy) {
90
98
  const src = path.join(sdkIosDir, fileName);
91
99
  const dst = path.join(targetDir, fileName);
92
- if (fs.existsSync(src) && !fs.existsSync(dst)) {
100
+ if (fs.existsSync(src)) {
101
+ // Always overwrite so bumping the SDK version actually
102
+ // picks up the new native code on the next prebuild
103
+ // instead of silently keeping a stale copy.
93
104
  fs.copyFileSync(src, dst);
94
105
  }
95
106
  }
@@ -97,6 +108,53 @@ function withYaverHotReloadNativeModule(config) {
97
108
  return config;
98
109
  },
99
110
  ]);
111
+
112
+ // Step 2 — register the files in project.pbxproj so Xcode compiles
113
+ // them into the app target. Without this, the files exist on disk
114
+ // but are invisible to the build system.
115
+ config = withXcodeProject(config, (config) => {
116
+ const proj = config.modResults;
117
+ const appName = config.modRequest.projectName;
118
+ if (!appName) return config;
119
+
120
+ // Look up the group key for the app's source folder (same group
121
+ // that holds AppDelegate.swift). Expo's naming is consistent —
122
+ // projectName === group name in the tree.
123
+ const groupKey =
124
+ proj.findPBXGroupKey({ name: appName }) ||
125
+ proj.findPBXGroupKey({ path: appName });
126
+ if (!groupKey) return config;
127
+
128
+ // Target UUID — getFirstTarget is the app target in a standard
129
+ // Expo project. For multi-target projects, users can opt out via
130
+ // enableHotReload: false.
131
+ const target = proj.getFirstTarget();
132
+ if (!target || !target.uuid) return config;
133
+
134
+ const filesToAdd = ["YaverHotReload.swift", "YaverHotReload.m"];
135
+ for (const fileName of filesToAdd) {
136
+ const relPath = `${appName}/${fileName}`;
137
+ // addSourceFile registers PBXFileReference + PBXBuildFile, adds
138
+ // the file to the group, and wires it into the target's
139
+ // Sources build phase — which is exactly what we need. It's
140
+ // idempotent in practice because the sdk-level copyFileSync
141
+ // step always writes the file, and addSourceFile is a no-op if
142
+ // the reference already exists.
143
+ if (!proj.hasFile || !proj.hasFile(relPath)) {
144
+ try {
145
+ proj.addSourceFile(relPath, { target: target.uuid }, groupKey);
146
+ } catch (e) {
147
+ // If a duplicate slips through, xcode-lib throws; safe to
148
+ // ignore since the file already being registered is the
149
+ // desired state.
150
+ }
151
+ }
152
+ }
153
+
154
+ return config;
155
+ });
156
+
157
+ return config;
100
158
  }
101
159
 
102
160
  /**
@@ -146,6 +204,20 @@ function withYaverAppDelegateHook(config) {
146
204
  name: Notification.Name("YaverHotReloadBundle"),
147
205
  object: nil
148
206
  )
207
+ // Crash-revert safety net: clear the boot-attempt counter once
208
+ // RN renders its first frame, OR after 10 s of uptime — whichever
209
+ // fires first. If neither fires (bundle crashes before render),
210
+ // YaverHotReload.bundleURL() will eventually revert to the
211
+ // TestFlight-installed bundle after 3 failed boots. See
212
+ // YaverHotReload.swift for the full state machine.
213
+ NotificationCenter.default.addObserver(
214
+ forName: NSNotification.Name(rawValue: "RCTContentDidAppearNotification"),
215
+ object: nil,
216
+ queue: .main
217
+ ) { _ in YaverHotReload.markBootSuccessful() }
218
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
219
+ YaverHotReload.markBootSuccessful()
220
+ }
149
221
  }
150
222
 
151
223
  @objc private func yaverHandleHotReload(_ notification: Notification) {
@@ -297,6 +369,44 @@ function withYaverAndroidHotReload(config) {
297
369
  contents.slice(methodStart);
298
370
  }
299
371
 
372
+ // Crash-revert safety net: clear the boot-attempt counter once
373
+ // the React context initializes (bundle loaded successfully), AND
374
+ // via a 10-s fallback Handler in case that listener never fires
375
+ // (e.g. infinite loop in root component). If neither fires,
376
+ // YaverHotReloadModule.getSavedBundleFile() reverts to the
377
+ // APK-bundled bundle after 3 failed cold starts. Parity with
378
+ // YaverHotReload.swift on iOS.
379
+ if (contents.includes("onCreate()") && !contents.includes("yaverHotReloadBootListener")) {
380
+ const onCreateIdx = contents.indexOf("onCreate()");
381
+ const braceIdx = contents.indexOf("{", onCreateIdx);
382
+ const insertionPoint = braceIdx + 1;
383
+ const bootGuard = `
384
+ // Yaver Feedback SDK hot-reload crash-revert safety net
385
+ final android.content.Context yaverHotReloadCtx = getApplicationContext();
386
+ new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(
387
+ new Runnable() {
388
+ @Override public void run() {
389
+ io.yaver.feedback.YaverHotReloadModule.markBootSuccessful(yaverHotReloadCtx);
390
+ }
391
+ },
392
+ 10000
393
+ );
394
+ try {
395
+ com.facebook.react.ReactInstanceManager yaverRim = getReactNativeHost().getReactInstanceManager();
396
+ yaverRim.addReactInstanceEventListener(new com.facebook.react.ReactInstanceEventListener() {
397
+ @Override public void onReactContextInitialized(com.facebook.react.bridge.ReactContext ctx) {
398
+ io.yaver.feedback.YaverHotReloadModule.markBootSuccessful(yaverHotReloadCtx);
399
+ }
400
+ });
401
+ } catch (Throwable yaverHotReloadBootListener) {
402
+ // Older/newer RN versions may not expose ReactInstanceEventListener
403
+ // exactly like this; the 10-s fallback above still covers us.
404
+ }
405
+ `;
406
+ contents =
407
+ contents.slice(0, insertionPoint) + bootGuard + contents.slice(insertionPoint);
408
+ }
409
+
300
410
  config.modResults.contents = contents;
301
411
  return config;
302
412
  });
@@ -308,10 +418,15 @@ function withYaverFeedback(config, props) {
308
418
  config = withYaverFeedbackIOS(config);
309
419
  config = withYaverFeedbackAndroid(config);
310
420
 
311
- // Hot reload native module is opt-in via enableHotReload: true
312
- // Skip by default apps running inside Yaver container get hot reload
313
- // via YaverBundleLoader, standalone dev builds use DevSettings.reload()
314
- const enableHotReload = props?.enableHotReload === true;
421
+ // Hot reload native module is ON by default — it's the SDK's whole
422
+ // point. TestFlight / Play Store standalone builds have no Metro
423
+ // dev server, so without the YaverHotReload native module the
424
+ // agent's `reload_bundle` broadcast silently no-ops: the SDK falls
425
+ // through to DevSettings.reload() which does nothing in Release
426
+ // builds. Apps that specifically don't want the plugin mutating
427
+ // their AppDelegate / MainApplication can opt out with
428
+ // ["yaver-feedback-react-native", { "enableHotReload": false }]
429
+ const enableHotReload = props?.enableHotReload !== false;
315
430
  if (enableHotReload) {
316
431
  config = withYaverHotReloadNativeModule(config);
317
432
  config = withYaverAppDelegateHook(config);
@@ -43,6 +43,10 @@ const PROVIDER_THEME = {
43
43
  github: { ion: 'logo-github', letter: 'G', fg: '#FFFFFF', bg: 'transparent' },
44
44
  gitlab: { ion: 'logo-gitlab', letter: 'G', fg: '#FC6D26', bg: 'transparent' },
45
45
  microsoft: { ion: 'logo-microsoft', letter: 'M', fg: '#00A4EF', bg: 'transparent' },
46
+ // Email is the universal fallback option — render a mail glyph so
47
+ // it visually matches the OAuth rows instead of being a naked
48
+ // label in a row of iconed buttons.
49
+ email: { ion: 'mail-outline', letter: '@', fg: '#E0E0E0', bg: 'transparent' },
46
50
  };
47
51
  const ProviderLogo = ({ provider, }) => {
48
52
  const theme = PROVIDER_THEME[provider] ?? PROVIDER_THEME.apple;
@@ -194,7 +198,10 @@ const YaverLoginScreen = ({ onLoggedIn, onCancel, }) => {
194
198
  styles.button,
195
199
  pressed && styles.buttonPressed,
196
200
  ]} onPress={() => setShowEmailForm(true)} disabled={busyProvider !== null}>
197
- <react_native_1.Text style={styles.buttonText}>Continue with Email</react_native_1.Text>
201
+ <react_native_1.View style={styles.buttonContent}>
202
+ <ProviderLogo provider="email"/>
203
+ <react_native_1.Text style={[styles.buttonText, styles.buttonTextWithIcon]}>Continue with Email</react_native_1.Text>
204
+ </react_native_1.View>
198
205
  </react_native_1.Pressable>) : (<>
199
206
  <react_native_1.View style={styles.divider}>
200
207
  <react_native_1.View style={styles.dividerLine}/>
@@ -104,9 +104,68 @@ class YaverHotReload: NSObject {
104
104
  .appendingPathComponent(bundleFile)
105
105
  }
106
106
 
107
- /// Returns the hot-reloaded bundle URL if one exists on disk.
107
+ /// Returns the hot-reloaded bundle URL if one exists AND hasn't
108
+ /// crashed on boot N times in a row.
109
+ ///
110
+ /// Vibe-coding loop: a developer pushes dozens of Hermes bundles over
111
+ /// the course of a session; eventually one of them crashes on boot
112
+ /// (missing native module, syntax error, TurboModule assert). Without
113
+ /// this guard, the crashing bundle persists across cold starts and
114
+ /// bricks the app — user has to delete + reinstall from TestFlight.
115
+ ///
116
+ /// Guard: on each cold-start bundleURL() call, increment a boot
117
+ /// counter. Reset it on first successful render (RCTContentDidAppear)
118
+ /// or after 10 s of uptime. If the counter hits kMaxBootAttempts
119
+ /// without being reset, the bundle has crashed every boot so far —
120
+ /// delete it and return nil so the app falls back to the
121
+ /// TestFlight-installed bundle.
122
+ ///
123
+ /// Bundle mtime is tracked separately: pushing a NEW bundle resets
124
+ /// the counter so each pushed bundle gets its own 3 attempts.
108
125
  @objc static func bundleURL() -> URL? {
109
126
  let path = savedBundlePath()
110
- return FileManager.default.fileExists(atPath: path.path) ? path : nil
127
+ guard FileManager.default.fileExists(atPath: path.path) else { return nil }
128
+
129
+ let defaults = UserDefaults.standard
130
+ let attrs = try? FileManager.default.attributesOfItem(atPath: path.path)
131
+ let currentMtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
132
+ let lastMtime = defaults.double(forKey: kKeyBundleMtime)
133
+
134
+ // Fresh bundle pushed since the counter was last reset → start over.
135
+ if currentMtime != lastMtime {
136
+ defaults.set(0, forKey: kKeyBootAttempts)
137
+ defaults.set(currentMtime, forKey: kKeyBundleMtime)
138
+ }
139
+
140
+ let attempts = defaults.integer(forKey: kKeyBootAttempts)
141
+ if attempts >= kMaxBootAttempts {
142
+ NSLog("[YaverHotReload] hot bundle failed %d consecutive boot attempts — reverting to app-bundled TestFlight bundle.", attempts)
143
+ let dir = path.deletingLastPathComponent()
144
+ try? FileManager.default.removeItem(at: dir)
145
+ defaults.removeObject(forKey: kKeyBootAttempts)
146
+ defaults.removeObject(forKey: kKeyBundleMtime)
147
+ return nil
148
+ }
149
+
150
+ // Pre-increment: this boot will count as a failure unless the JS
151
+ // side reaches first render and calls markBootSuccessful().
152
+ defaults.set(attempts + 1, forKey: kKeyBootAttempts)
153
+ NSLog("[YaverHotReload] loading hot bundle (boot attempt %d/%d)", attempts + 1, kMaxBootAttempts)
154
+ return path
111
155
  }
156
+
157
+ /// Clear the boot-attempt counter. Call this from AppDelegate after
158
+ /// first successful RN render (RCTContentDidAppearNotification) or
159
+ /// after a short uptime safety timer — whichever fires first.
160
+ @objc static func markBootSuccessful() {
161
+ let defaults = UserDefaults.standard
162
+ if defaults.integer(forKey: kKeyBootAttempts) > 0 {
163
+ NSLog("[YaverHotReload] boot confirmed successful — reset boot-attempt counter.")
164
+ }
165
+ defaults.set(0, forKey: kKeyBootAttempts)
166
+ }
167
+
168
+ static let kKeyBootAttempts = "yaverHotReloadBootAttempts"
169
+ static let kKeyBundleMtime = "yaverHotReloadBundleMtime"
170
+ static let kMaxBootAttempts = 3
112
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
4
4
  "description": "Visual feedback SDK for Yaver — bug reports, screen recording, voice annotations, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -42,15 +42,21 @@ type ProviderTheme = {
42
42
  bg: string;
43
43
  };
44
44
 
45
- const PROVIDER_THEME: Record<OAuthProvider | 'apple', ProviderTheme> = {
45
+ type LoginOption = OAuthProvider | 'apple' | 'email';
46
+
47
+ const PROVIDER_THEME: Record<LoginOption, ProviderTheme> = {
46
48
  apple: { ion: 'logo-apple', letter: '', fg: '#FFFFFF', bg: 'transparent' },
47
49
  google: { ion: 'logo-google', letter: 'G', fg: '#EA4335', bg: 'transparent' },
48
50
  github: { ion: 'logo-github', letter: 'G', fg: '#FFFFFF', bg: 'transparent' },
49
51
  gitlab: { ion: 'logo-gitlab', letter: 'G', fg: '#FC6D26', bg: 'transparent' },
50
52
  microsoft: { ion: 'logo-microsoft', letter: 'M', fg: '#00A4EF', bg: 'transparent' },
53
+ // Email is the universal fallback option — render a mail glyph so
54
+ // it visually matches the OAuth rows instead of being a naked
55
+ // label in a row of iconed buttons.
56
+ email: { ion: 'mail-outline', letter: '@', fg: '#E0E0E0', bg: 'transparent' },
51
57
  };
52
58
 
53
- const ProviderLogo: React.FC<{ provider: OAuthProvider | 'apple' }> = ({
59
+ const ProviderLogo: React.FC<{ provider: LoginOption }> = ({
54
60
  provider,
55
61
  }) => {
56
62
  const theme = PROVIDER_THEME[provider] ?? PROVIDER_THEME.apple;
@@ -263,7 +269,10 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
263
269
  onPress={() => setShowEmailForm(true)}
264
270
  disabled={busyProvider !== null}
265
271
  >
266
- <Text style={styles.buttonText}>Continue with Email</Text>
272
+ <View style={styles.buttonContent}>
273
+ <ProviderLogo provider="email" />
274
+ <Text style={[styles.buttonText, styles.buttonTextWithIcon]}>Continue with Email</Text>
275
+ </View>
267
276
  </Pressable>
268
277
  ) : (
269
278
  <>