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
|
-
*
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
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);
|
package/dist/LoginScreen.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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',
|
package/ios/YaverHotReload.swift
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/LoginScreen.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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',
|