yaver-feedback-react-native 0.7.10 → 0.7.12

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,98 @@ 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 pbx = config.modResults;
117
+ const appName = config.modRequest.projectName;
118
+ if (!appName) return config;
119
+
120
+ // Ensure the PBX group we'll attach files to exists (it's the
121
+ // app's main Sources group — same one that holds AppDelegate.swift).
122
+ const group = pbx.pbxGroupByName(appName);
123
+ if (!group) return config;
124
+
125
+ // Find the group UUID by walking the pbxGroup section.
126
+ const groupSection = pbx.pbxGroupSection();
127
+ let groupUuid = null;
128
+ for (const uuid of Object.keys(groupSection)) {
129
+ if (uuid.endsWith("_comment")) continue;
130
+ if (groupSection[uuid] === group) {
131
+ groupUuid = uuid;
132
+ break;
133
+ }
134
+ }
135
+ if (!groupUuid) return config;
136
+
137
+ // Locate the app's "Sources" build phase so we can attach
138
+ // YaverHotReload.swift as a compile unit. AppDelegate.swift is
139
+ // already there — find its PBXBuildFile to get the build phase UUID.
140
+ const nativeTargetSection = pbx.pbxNativeTargetSection();
141
+ let sourcesBuildPhase = null;
142
+ for (const uuid of Object.keys(nativeTargetSection)) {
143
+ if (uuid.endsWith("_comment")) continue;
144
+ const target = nativeTargetSection[uuid];
145
+ if (target.name !== appName && target.productReference_comment !== appName) continue;
146
+ for (const phase of target.buildPhases || []) {
147
+ const phaseComment = (phase.comment || "").toLowerCase();
148
+ if (phaseComment.includes("sources")) {
149
+ sourcesBuildPhase = phase.value;
150
+ break;
151
+ }
152
+ }
153
+ if (sourcesBuildPhase) break;
154
+ }
155
+
156
+ const swiftName = "YaverHotReload.swift";
157
+ const objcName = "YaverHotReload.m";
158
+
159
+ // addSourceFile registers the file as a PBXFileReference, adds a
160
+ // PBXBuildFile entry, drops it into the pbx group, and adds it to
161
+ // the sources build phase — exactly what we need.
162
+ if (!pbxHasFile(pbx, swiftName)) {
163
+ pbx.addSourceFile(
164
+ `${appName}/${swiftName}`,
165
+ { target: findTargetUuidByName(pbx, appName) },
166
+ groupUuid,
167
+ );
168
+ }
169
+ if (!pbxHasFile(pbx, objcName)) {
170
+ pbx.addSourceFile(
171
+ `${appName}/${objcName}`,
172
+ { target: findTargetUuidByName(pbx, appName) },
173
+ groupUuid,
174
+ );
175
+ }
176
+
177
+ return config;
178
+ });
179
+
180
+ return config;
181
+ }
182
+
183
+ function pbxHasFile(pbx, basename) {
184
+ const refs = pbx.pbxFileReferenceSection();
185
+ for (const uuid of Object.keys(refs)) {
186
+ if (uuid.endsWith("_comment")) continue;
187
+ const ref = refs[uuid];
188
+ if (!ref || typeof ref !== "object") continue;
189
+ const pathValue = (ref.path || "").replace(/"/g, "");
190
+ if (pathValue.endsWith(basename)) return true;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ function findTargetUuidByName(pbx, appName) {
196
+ const section = pbx.pbxNativeTargetSection();
197
+ for (const uuid of Object.keys(section)) {
198
+ if (uuid.endsWith("_comment")) continue;
199
+ const target = section[uuid];
200
+ if (target && target.name === appName) return uuid;
201
+ }
202
+ return undefined;
100
203
  }
101
204
 
102
205
  /**
@@ -146,6 +249,20 @@ function withYaverAppDelegateHook(config) {
146
249
  name: Notification.Name("YaverHotReloadBundle"),
147
250
  object: nil
148
251
  )
252
+ // Crash-revert safety net: clear the boot-attempt counter once
253
+ // RN renders its first frame, OR after 10 s of uptime — whichever
254
+ // fires first. If neither fires (bundle crashes before render),
255
+ // YaverHotReload.bundleURL() will eventually revert to the
256
+ // TestFlight-installed bundle after 3 failed boots. See
257
+ // YaverHotReload.swift for the full state machine.
258
+ NotificationCenter.default.addObserver(
259
+ forName: NSNotification.Name(rawValue: "RCTContentDidAppearNotification"),
260
+ object: nil,
261
+ queue: .main
262
+ ) { _ in YaverHotReload.markBootSuccessful() }
263
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
264
+ YaverHotReload.markBootSuccessful()
265
+ }
149
266
  }
150
267
 
151
268
  @objc private func yaverHandleHotReload(_ notification: Notification) {
@@ -297,6 +414,44 @@ function withYaverAndroidHotReload(config) {
297
414
  contents.slice(methodStart);
298
415
  }
299
416
 
417
+ // Crash-revert safety net: clear the boot-attempt counter once
418
+ // the React context initializes (bundle loaded successfully), AND
419
+ // via a 10-s fallback Handler in case that listener never fires
420
+ // (e.g. infinite loop in root component). If neither fires,
421
+ // YaverHotReloadModule.getSavedBundleFile() reverts to the
422
+ // APK-bundled bundle after 3 failed cold starts. Parity with
423
+ // YaverHotReload.swift on iOS.
424
+ if (contents.includes("onCreate()") && !contents.includes("yaverHotReloadBootListener")) {
425
+ const onCreateIdx = contents.indexOf("onCreate()");
426
+ const braceIdx = contents.indexOf("{", onCreateIdx);
427
+ const insertionPoint = braceIdx + 1;
428
+ const bootGuard = `
429
+ // Yaver Feedback SDK hot-reload crash-revert safety net
430
+ final android.content.Context yaverHotReloadCtx = getApplicationContext();
431
+ new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(
432
+ new Runnable() {
433
+ @Override public void run() {
434
+ io.yaver.feedback.YaverHotReloadModule.markBootSuccessful(yaverHotReloadCtx);
435
+ }
436
+ },
437
+ 10000
438
+ );
439
+ try {
440
+ com.facebook.react.ReactInstanceManager yaverRim = getReactNativeHost().getReactInstanceManager();
441
+ yaverRim.addReactInstanceEventListener(new com.facebook.react.ReactInstanceEventListener() {
442
+ @Override public void onReactContextInitialized(com.facebook.react.bridge.ReactContext ctx) {
443
+ io.yaver.feedback.YaverHotReloadModule.markBootSuccessful(yaverHotReloadCtx);
444
+ }
445
+ });
446
+ } catch (Throwable yaverHotReloadBootListener) {
447
+ // Older/newer RN versions may not expose ReactInstanceEventListener
448
+ // exactly like this; the 10-s fallback above still covers us.
449
+ }
450
+ `;
451
+ contents =
452
+ contents.slice(0, insertionPoint) + bootGuard + contents.slice(insertionPoint);
453
+ }
454
+
300
455
  config.modResults.contents = contents;
301
456
  return config;
302
457
  });
@@ -308,10 +463,15 @@ function withYaverFeedback(config, props) {
308
463
  config = withYaverFeedbackIOS(config);
309
464
  config = withYaverFeedbackAndroid(config);
310
465
 
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;
466
+ // Hot reload native module is ON by default — it's the SDK's whole
467
+ // point. TestFlight / Play Store standalone builds have no Metro
468
+ // dev server, so without the YaverHotReload native module the
469
+ // agent's `reload_bundle` broadcast silently no-ops: the SDK falls
470
+ // through to DevSettings.reload() which does nothing in Release
471
+ // builds. Apps that specifically don't want the plugin mutating
472
+ // their AppDelegate / MainApplication can opt out with
473
+ // ["yaver-feedback-react-native", { "enableHotReload": false }]
474
+ const enableHotReload = props?.enableHotReload !== false;
315
475
  if (enableHotReload) {
316
476
  config = withYaverHotReloadNativeModule(config);
317
477
  config = withYaverAppDelegateHook(config);
@@ -37,6 +37,55 @@ exports.YaverLoginScreen = void 0;
37
37
  const react_1 = __importStar(require("react"));
38
38
  const react_native_1 = require("react-native");
39
39
  const auth_1 = require("./auth");
40
+ const PROVIDER_THEME = {
41
+ apple: { ion: 'logo-apple', letter: '', fg: '#FFFFFF', bg: 'transparent' },
42
+ google: { ion: 'logo-google', letter: 'G', fg: '#EA4335', bg: 'transparent' },
43
+ github: { ion: 'logo-github', letter: 'G', fg: '#FFFFFF', bg: 'transparent' },
44
+ gitlab: { ion: 'logo-gitlab', letter: 'G', fg: '#FC6D26', bg: 'transparent' },
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' },
50
+ };
51
+ const ProviderLogo = ({ provider, }) => {
52
+ const theme = PROVIDER_THEME[provider] ?? PROVIDER_THEME.apple;
53
+ // Soft-require @expo/vector-icons so the SDK works in bare-RN apps
54
+ // that don't ship Expo. If present, render the brand glyph; if
55
+ // not, render a coloured monogram fallback.
56
+ let Ionicons = null;
57
+ try {
58
+ const mod = require('@expo/vector-icons');
59
+ Ionicons = mod?.Ionicons ?? null;
60
+ }
61
+ catch {
62
+ // package not installed — use letter fallback
63
+ }
64
+ if (Ionicons) {
65
+ return (<Ionicons name={theme.ion} size={18} color={theme.fg} style={iconStyles.icon}/>);
66
+ }
67
+ if (!theme.letter) {
68
+ return null;
69
+ }
70
+ return (<react_native_1.View style={[iconStyles.fallback, { backgroundColor: theme.fg + '22' }]}>
71
+ <react_native_1.Text style={[iconStyles.fallbackText, { color: theme.fg }]}>{theme.letter}</react_native_1.Text>
72
+ </react_native_1.View>);
73
+ };
74
+ const iconStyles = react_native_1.StyleSheet.create({
75
+ icon: { marginRight: 12 },
76
+ fallback: {
77
+ width: 22,
78
+ height: 22,
79
+ borderRadius: 11,
80
+ alignItems: 'center',
81
+ justifyContent: 'center',
82
+ marginRight: 12,
83
+ },
84
+ fallbackText: {
85
+ fontSize: 12,
86
+ fontWeight: '700',
87
+ },
88
+ });
40
89
  /**
41
90
  * Full-screen in-SDK login. Mirrors the Yaver mobile app login UX: native
42
91
  * Apple Sign-In on iOS, in-app browser OAuth for Google/GitHub/GitLab/
@@ -120,7 +169,10 @@ const YaverLoginScreen = ({ onLoggedIn, onCancel, }) => {
120
169
  pressed && styles.buttonPressed,
121
170
  busyProvider === id && { opacity: 0.6 },
122
171
  ]} onPress={onPress} disabled={busyProvider !== null}>
123
- {busyProvider === id ? (<react_native_1.ActivityIndicator color="#e0e0e0"/>) : (<react_native_1.Text style={styles.buttonText}>{label}</react_native_1.Text>)}
172
+ {busyProvider === id ? (<react_native_1.ActivityIndicator color="#e0e0e0"/>) : (<react_native_1.View style={styles.buttonContent}>
173
+ <ProviderLogo provider={id}/>
174
+ <react_native_1.Text style={[styles.buttonText, styles.buttonTextWithIcon]}>{label}</react_native_1.Text>
175
+ </react_native_1.View>)}
124
176
  </react_native_1.Pressable>);
125
177
  return (<react_native_1.SafeAreaView style={styles.safeArea}>
126
178
  <react_native_1.KeyboardAvoidingView style={{ flex: 1 }} behavior={react_native_1.Platform.OS === 'ios' ? 'padding' : undefined}>
@@ -146,7 +198,10 @@ const YaverLoginScreen = ({ onLoggedIn, onCancel, }) => {
146
198
  styles.button,
147
199
  pressed && styles.buttonPressed,
148
200
  ]} onPress={() => setShowEmailForm(true)} disabled={busyProvider !== null}>
149
- <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>
150
205
  </react_native_1.Pressable>) : (<>
151
206
  <react_native_1.View style={styles.divider}>
152
207
  <react_native_1.View style={styles.dividerLine}/>
@@ -211,6 +266,14 @@ const styles = react_native_1.StyleSheet.create({
211
266
  },
212
267
  buttonPressed: { opacity: 0.7 },
213
268
  buttonText: { color: '#e0e0e0', fontSize: 15, fontWeight: '600' },
269
+ buttonContent: {
270
+ flexDirection: 'row',
271
+ alignItems: 'center',
272
+ justifyContent: 'center',
273
+ },
274
+ buttonTextWithIcon: {
275
+ // No extra spacing — ProviderLogo provides its own marginRight.
276
+ },
214
277
  divider: {
215
278
  flexDirection: 'row',
216
279
  alignItems: 'center',
@@ -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.10",
3
+ "version": "0.7.12",
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",
@@ -23,6 +23,94 @@ import {
23
23
  type OAuthProvider,
24
24
  } from './auth';
25
25
 
26
+ // ── ProviderLogo ─────────────────────────────────────────────────────
27
+ //
28
+ // Mirrors the Yaver mobile app's login screen — Apple, Google, GitHub,
29
+ // GitLab, Microsoft each get their brand mark next to the label.
30
+ // Mobile uses `Ionicons` from `@expo/vector-icons`. We soft-require
31
+ // the same package so apps that have it (every Expo app does) get
32
+ // real logos automatically; bare-RN apps without the package fall
33
+ // back to a single-letter monogram in a tinted circle.
34
+ //
35
+ // Each entry holds: Ionicons name, fallback letter, brand colour,
36
+ // optional Apple-on-light invert (Apple's wordmark needs the
37
+ // alternate "logo-apple-appstore" / dark variant on dark backgrounds).
38
+ type ProviderTheme = {
39
+ ion: string;
40
+ letter: string;
41
+ fg: string;
42
+ bg: string;
43
+ };
44
+
45
+ type LoginOption = OAuthProvider | 'apple' | 'email';
46
+
47
+ const PROVIDER_THEME: Record<LoginOption, ProviderTheme> = {
48
+ apple: { ion: 'logo-apple', letter: '', fg: '#FFFFFF', bg: 'transparent' },
49
+ google: { ion: 'logo-google', letter: 'G', fg: '#EA4335', bg: 'transparent' },
50
+ github: { ion: 'logo-github', letter: 'G', fg: '#FFFFFF', bg: 'transparent' },
51
+ gitlab: { ion: 'logo-gitlab', letter: 'G', fg: '#FC6D26', bg: 'transparent' },
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' },
57
+ };
58
+
59
+ const ProviderLogo: React.FC<{ provider: LoginOption }> = ({
60
+ provider,
61
+ }) => {
62
+ const theme = PROVIDER_THEME[provider] ?? PROVIDER_THEME.apple;
63
+ // Soft-require @expo/vector-icons so the SDK works in bare-RN apps
64
+ // that don't ship Expo. If present, render the brand glyph; if
65
+ // not, render a coloured monogram fallback.
66
+ let Ionicons: React.ComponentType<{
67
+ name: string;
68
+ size?: number;
69
+ color?: string;
70
+ style?: object;
71
+ }> | null = null;
72
+ try {
73
+ const mod = require('@expo/vector-icons');
74
+ Ionicons = mod?.Ionicons ?? null;
75
+ } catch {
76
+ // package not installed — use letter fallback
77
+ }
78
+ if (Ionicons) {
79
+ return (
80
+ <Ionicons
81
+ name={theme.ion}
82
+ size={18}
83
+ color={theme.fg}
84
+ style={iconStyles.icon}
85
+ />
86
+ );
87
+ }
88
+ if (!theme.letter) {
89
+ return null;
90
+ }
91
+ return (
92
+ <View style={[iconStyles.fallback, { backgroundColor: theme.fg + '22' }]}>
93
+ <Text style={[iconStyles.fallbackText, { color: theme.fg }]}>{theme.letter}</Text>
94
+ </View>
95
+ );
96
+ };
97
+
98
+ const iconStyles = StyleSheet.create({
99
+ icon: { marginRight: 12 },
100
+ fallback: {
101
+ width: 22,
102
+ height: 22,
103
+ borderRadius: 11,
104
+ alignItems: 'center',
105
+ justifyContent: 'center',
106
+ marginRight: 12,
107
+ },
108
+ fallbackText: {
109
+ fontSize: 12,
110
+ fontWeight: '700',
111
+ },
112
+ });
113
+
26
114
  export interface YaverLoginScreenProps {
27
115
  /** Invoked once a session token is issued and the user is loaded. */
28
116
  onLoggedIn: (token: string) => void;
@@ -125,7 +213,10 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
125
213
  {busyProvider === id ? (
126
214
  <ActivityIndicator color="#e0e0e0" />
127
215
  ) : (
128
- <Text style={styles.buttonText}>{label}</Text>
216
+ <View style={styles.buttonContent}>
217
+ <ProviderLogo provider={id} />
218
+ <Text style={[styles.buttonText, styles.buttonTextWithIcon]}>{label}</Text>
219
+ </View>
129
220
  )}
130
221
  </Pressable>
131
222
  );
@@ -178,7 +269,10 @@ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
178
269
  onPress={() => setShowEmailForm(true)}
179
270
  disabled={busyProvider !== null}
180
271
  >
181
- <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>
182
276
  </Pressable>
183
277
  ) : (
184
278
  <>
@@ -295,6 +389,14 @@ const styles = StyleSheet.create({
295
389
  },
296
390
  buttonPressed: { opacity: 0.7 },
297
391
  buttonText: { color: '#e0e0e0', fontSize: 15, fontWeight: '600' },
392
+ buttonContent: {
393
+ flexDirection: 'row',
394
+ alignItems: 'center',
395
+ justifyContent: 'center',
396
+ },
397
+ buttonTextWithIcon: {
398
+ // No extra spacing — ProviderLogo provides its own marginRight.
399
+ },
298
400
  divider: {
299
401
  flexDirection: 'row',
300
402
  alignItems: 'center',