yaver-feedback-react-native 0.3.0 → 0.4.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.
@@ -0,0 +1,188 @@
1
+ package io.yaver.feedback;
2
+
3
+ import android.app.Activity;
4
+ import android.content.Context;
5
+ import android.content.SharedPreferences;
6
+ import android.os.Handler;
7
+ import android.os.Looper;
8
+ import android.util.Log;
9
+
10
+ import androidx.annotation.NonNull;
11
+
12
+ import com.facebook.react.bridge.Promise;
13
+ import com.facebook.react.bridge.ReactApplicationContext;
14
+ import com.facebook.react.bridge.ReactContextBaseJavaModule;
15
+ import com.facebook.react.bridge.ReactMethod;
16
+ import com.facebook.react.bridge.ReadableMap;
17
+ import com.facebook.react.bridge.WritableMap;
18
+ import com.facebook.react.bridge.Arguments;
19
+ import com.facebook.react.ReactApplication;
20
+ import com.facebook.react.ReactInstanceManager;
21
+
22
+ import java.io.File;
23
+ import java.io.FileOutputStream;
24
+ import java.io.InputStream;
25
+ import java.net.HttpURLConnection;
26
+ import java.net.URL;
27
+ import java.util.concurrent.Executors;
28
+
29
+ /**
30
+ * Hot reload native module for the Yaver Feedback SDK (Android).
31
+ *
32
+ * Downloads a Hermes bytecode bundle from the agent, saves it to the app's
33
+ * files directory, and recreates the React Native context to load the new bundle.
34
+ *
35
+ * Supports N reloads — each reload recreates the ReactContext with the updated bundle.
36
+ */
37
+ public class YaverHotReloadModule extends ReactContextBaseJavaModule {
38
+
39
+ private static final String TAG = "YaverHotReload";
40
+ private static final String MODULE_NAME = "YaverHotReload";
41
+ private static final String BUNDLE_DIR = "yaver-hot-reload";
42
+ private static final String BUNDLE_FILE = "index.android.bundle";
43
+ private static final String PREFS_NAME = "yaver_hot_reload";
44
+ private static final String PREFS_KEY_BUNDLE = "bundle_path";
45
+
46
+ public YaverHotReloadModule(ReactApplicationContext context) {
47
+ super(context);
48
+ }
49
+
50
+ @Override
51
+ @NonNull
52
+ public String getName() {
53
+ return MODULE_NAME;
54
+ }
55
+
56
+ /**
57
+ * Download a Hermes bundle from the agent and trigger a bridge reload.
58
+ */
59
+ @ReactMethod
60
+ public void loadBundle(String urlString, ReadableMap headers, Promise promise) {
61
+ Executors.newSingleThreadExecutor().execute(() -> {
62
+ try {
63
+ URL url = new URL(urlString);
64
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
65
+ conn.setConnectTimeout(60000);
66
+ conn.setReadTimeout(60000);
67
+
68
+ // Set auth headers
69
+ if (headers != null) {
70
+ if (headers.hasKey("Authorization")) {
71
+ conn.setRequestProperty("Authorization", headers.getString("Authorization"));
72
+ }
73
+ }
74
+
75
+ int responseCode = conn.getResponseCode();
76
+ if (responseCode != 200) {
77
+ promise.reject("HTTP_ERROR", "Status " + responseCode);
78
+ return;
79
+ }
80
+
81
+ InputStream is = conn.getInputStream();
82
+ File dir = new File(getReactApplicationContext().getFilesDir(), BUNDLE_DIR);
83
+ if (!dir.exists()) dir.mkdirs();
84
+ File bundleFile = new File(dir, BUNDLE_FILE);
85
+
86
+ FileOutputStream fos = new FileOutputStream(bundleFile);
87
+ byte[] buffer = new byte[8192];
88
+ int bytesRead;
89
+ int totalBytes = 0;
90
+ while ((bytesRead = is.read(buffer)) != -1) {
91
+ fos.write(buffer, 0, bytesRead);
92
+ totalBytes += bytesRead;
93
+ }
94
+ fos.close();
95
+ is.close();
96
+ conn.disconnect();
97
+
98
+ Log.i(TAG, "saved " + totalBytes + " bytes to " + bundleFile.getAbsolutePath());
99
+
100
+ // Validate Hermes bytecode (magic bytes at offset 4)
101
+ if (totalBytes >= 12) {
102
+ java.io.RandomAccessFile raf = new java.io.RandomAccessFile(bundleFile, "r");
103
+ raf.seek(4);
104
+ int magic = Integer.reverseBytes(raf.readInt());
105
+ if (magic == 0x1F1903C1) {
106
+ int bcVersion = Integer.reverseBytes(raf.readInt());
107
+ Log.i(TAG, "Hermes bytecode BC" + bcVersion);
108
+ } else {
109
+ Log.w(TAG, "not Hermes bytecode (magic=0x" + Integer.toHexString(magic) + ")");
110
+ }
111
+ raf.close();
112
+ }
113
+
114
+ // Save bundle path to SharedPreferences for next app launch
115
+ SharedPreferences prefs = getReactApplicationContext()
116
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
117
+ prefs.edit().putString(PREFS_KEY_BUNDLE, bundleFile.getAbsolutePath()).apply();
118
+
119
+ WritableMap result = Arguments.createMap();
120
+ result.putBoolean("loaded", true);
121
+ result.putInt("size", totalBytes);
122
+ promise.resolve(result);
123
+
124
+ // Reload the bridge on the main thread
125
+ new Handler(Looper.getMainLooper()).post(() -> reloadBridge());
126
+
127
+ } catch (Exception e) {
128
+ Log.e(TAG, "download failed", e);
129
+ promise.reject("DOWNLOAD_FAILED", e.getMessage(), e);
130
+ }
131
+ });
132
+ }
133
+
134
+ @ReactMethod
135
+ public void hasBundle(Promise promise) {
136
+ File bundleFile = getSavedBundleFile(getReactApplicationContext());
137
+ promise.resolve(bundleFile != null && bundleFile.exists());
138
+ }
139
+
140
+ @ReactMethod
141
+ public void clearBundle(Promise promise) {
142
+ File dir = new File(getReactApplicationContext().getFilesDir(), BUNDLE_DIR);
143
+ if (dir.exists()) {
144
+ for (File f : dir.listFiles()) f.delete();
145
+ dir.delete();
146
+ }
147
+ SharedPreferences prefs = getReactApplicationContext()
148
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
149
+ prefs.edit().remove(PREFS_KEY_BUNDLE).apply();
150
+ promise.resolve(true);
151
+ }
152
+
153
+ /**
154
+ * Recreate the React Native context with the new bundle.
155
+ */
156
+ private void reloadBridge() {
157
+ Activity activity = getCurrentActivity();
158
+ if (activity == null) {
159
+ Log.e(TAG, "no current activity");
160
+ return;
161
+ }
162
+
163
+ if (activity.getApplication() instanceof ReactApplication) {
164
+ ReactApplication app = (ReactApplication) activity.getApplication();
165
+ ReactInstanceManager manager = app.getReactNativeHost().getReactInstanceManager();
166
+ Log.i(TAG, "recreating React context with new bundle");
167
+ manager.recreateReactContextInBackground();
168
+ } else {
169
+ Log.e(TAG, "Application does not implement ReactApplication");
170
+ }
171
+ }
172
+
173
+ // MARK: - Static helpers for Application/MainApplication
174
+
175
+ /**
176
+ * Returns the hot-reloaded bundle file if it exists.
177
+ * Call from MainApplication.getJSBundleFile() to load the hot bundle on startup.
178
+ */
179
+ public static File getSavedBundleFile(Context context) {
180
+ SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
181
+ String path = prefs.getString(PREFS_KEY_BUNDLE, null);
182
+ if (path != null) {
183
+ File f = new File(path);
184
+ if (f.exists()) return f;
185
+ }
186
+ return null;
187
+ }
188
+ }
@@ -0,0 +1,33 @@
1
+ package io.yaver.feedback;
2
+
3
+ import androidx.annotation.NonNull;
4
+
5
+ import com.facebook.react.ReactPackage;
6
+ import com.facebook.react.bridge.NativeModule;
7
+ import com.facebook.react.bridge.ReactApplicationContext;
8
+ import com.facebook.react.uimanager.ViewManager;
9
+
10
+ import java.util.ArrayList;
11
+ import java.util.Collections;
12
+ import java.util.List;
13
+
14
+ /**
15
+ * React Native package that registers the YaverHotReload native module.
16
+ * Auto-linked via the Expo config plugin.
17
+ */
18
+ public class YaverHotReloadPackage implements ReactPackage {
19
+
20
+ @NonNull
21
+ @Override
22
+ public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
23
+ List<NativeModule> modules = new ArrayList<>();
24
+ modules.add(new YaverHotReloadModule(reactContext));
25
+ return modules;
26
+ }
27
+
28
+ @NonNull
29
+ @Override
30
+ public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
31
+ return Collections.emptyList();
32
+ }
33
+ }
package/app.plugin.js CHANGED
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * Expo config plugin for yaver-feedback-react-native.
3
3
  *
4
- * Adds required native permissions for the feedback SDK:
5
- * - iOS: Camera + Microphone usage descriptions
6
- * - Android: CAMERA + RECORD_AUDIO permissions
4
+ * Adds:
5
+ * - iOS/Android permissions for camera + microphone (feedback screenshots/voice)
6
+ * - iOS: YaverHotReload native module for Hermes bundle hot reload
7
+ * - AppDelegate hook to load hot-reloaded bundles on startup
7
8
  *
8
9
  * Usage in app.json:
9
10
  * { "expo": { "plugins": ["yaver-feedback-react-native"] } }
10
11
  */
12
+ // Resolve @expo/config-plugins from the host project's node_modules
13
+ // (not from the SDK's directory, which may be symlinked)
14
+ const configPluginsPath = require.resolve("@expo/config-plugins", {
15
+ paths: [process.cwd()],
16
+ });
11
17
  const {
12
18
  withInfoPlist,
13
19
  withAndroidManifest,
20
+ withXcodeProject,
21
+ withAppDelegate,
22
+ withMainApplication,
23
+ withDangerousMod,
14
24
  createRunOncePlugin,
15
- } = require("@expo/config-plugins");
25
+ } = require(configPluginsPath);
26
+ const path = require("path");
27
+ const fs = require("fs");
16
28
 
17
29
  const pkg = require("./package.json");
18
30
 
@@ -57,9 +69,255 @@ function withYaverFeedbackAndroid(config) {
57
69
  });
58
70
  }
59
71
 
60
- function withYaverFeedback(config) {
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.
76
+ */
77
+ function withYaverHotReloadNativeModule(config) {
78
+ return withDangerousMod(config, [
79
+ "ios",
80
+ (config) => {
81
+ const sdkIosDir = path.resolve(__dirname, "ios");
82
+ const appName = config.modRequest.projectName || "SFMG";
83
+ const targetDir = path.join(
84
+ config.modRequest.platformProjectRoot,
85
+ appName
86
+ );
87
+
88
+ const filesToCopy = ["YaverHotReload.swift", "YaverHotReload.m"];
89
+ for (const fileName of filesToCopy) {
90
+ const src = path.join(sdkIosDir, fileName);
91
+ const dst = path.join(targetDir, fileName);
92
+ if (fs.existsSync(src) && !fs.existsSync(dst)) {
93
+ fs.copyFileSync(src, dst);
94
+ }
95
+ }
96
+
97
+ return config;
98
+ },
99
+ ]);
100
+ }
101
+
102
+ /**
103
+ * Patch AppDelegate to:
104
+ * 1. Return hot-reloaded bundle URL on startup (so reloaded bundle persists)
105
+ * 2. Handle YaverHotReloadBundle notification to recreate the RN bridge
106
+ * with the new bundle (enables N reloads without app restart)
107
+ *
108
+ * Uses the same pattern as Yaver's own AppDelegate: tear down old bridge,
109
+ * create new ExpoReactNativeFactory with overrideBundleURL, startReactNative.
110
+ */
111
+ function withYaverAppDelegateHook(config) {
112
+ return withAppDelegate(config, (config) => {
113
+ const contents = config.modResults.contents;
114
+
115
+ // Only patch if not already patched
116
+ if (contents.includes("YaverHotReload")) {
117
+ return config;
118
+ }
119
+
120
+ // For Swift AppDelegate (Expo SDK 50+)
121
+ let patched = contents;
122
+
123
+ // 1. Hook bundleURL() to return hot bundle on startup
124
+ if (patched.includes("func bundleURL()")) {
125
+ patched = patched.replace(
126
+ /func bundleURL\(\) -> URL\? \{/,
127
+ `func bundleURL() -> URL? {
128
+ // Yaver Feedback SDK: load hot-reloaded bundle if available
129
+ if let yaverBundle = YaverHotReload.bundleURL() { return yaverBundle }`
130
+ );
131
+ }
132
+
133
+ // 2. Add reload notification handler and bridge recreation logic
134
+ // Insert before the closing brace of the class
135
+ const classCloseIndex = patched.lastIndexOf("}");
136
+ if (classCloseIndex > 0) {
137
+ const reloadHandler = `
138
+ // MARK: - Yaver Feedback SDK Hot Reload
139
+
140
+ private var yaverIsReloading = false
141
+
142
+ private func setupYaverHotReload() {
143
+ NotificationCenter.default.addObserver(
144
+ self,
145
+ selector: #selector(yaverHandleHotReload(_:)),
146
+ name: Notification.Name("YaverHotReloadBundle"),
147
+ object: nil
148
+ )
149
+ }
150
+
151
+ @objc private func yaverHandleHotReload(_ notification: Notification) {
152
+ guard !yaverIsReloading else { return }
153
+ yaverIsReloading = true
154
+
155
+ guard let bundlePath = notification.userInfo?["bundlePath"] as? String else {
156
+ yaverIsReloading = false
157
+ return
158
+ }
159
+
160
+ let bundleURL = URL(fileURLWithPath: bundlePath)
161
+ guard FileManager.default.fileExists(atPath: bundlePath) else {
162
+ NSLog("[YaverHotReload] bundle not found at %@", bundlePath)
163
+ yaverIsReloading = false
164
+ return
165
+ }
166
+
167
+ NSLog("[YaverHotReload] reloading bridge with %@", bundlePath)
168
+
169
+ guard let window = self.window else {
170
+ yaverIsReloading = false
171
+ return
172
+ }
173
+
174
+ // Show loading placeholder
175
+ let placeholder = UIView(frame: window.bounds)
176
+ placeholder.backgroundColor = .black
177
+ let spinner = UIActivityIndicatorView(style: .large)
178
+ spinner.color = .white
179
+ spinner.center = placeholder.center
180
+ spinner.startAnimating()
181
+ placeholder.addSubview(spinner)
182
+ window.rootViewController?.view = placeholder
183
+
184
+ // Brief delay for old bridge to tear down
185
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
186
+ guard let self = self else { return }
187
+
188
+ let delegate = ReactNativeDelegate()
189
+ delegate.overrideBundleURL = bundleURL
190
+ delegate.dependencyProvider = RCTAppDependencyProvider()
191
+
192
+ let factory = ExpoReactNativeFactory(delegate: delegate)
193
+ self.reactNativeDelegate = delegate
194
+ self.reactNativeFactory = factory
195
+ self.bindReactNativeFactory(factory)
196
+
197
+ factory.startReactNative(
198
+ withModuleName: "main",
199
+ in: window,
200
+ launchOptions: nil
201
+ )
202
+ self.yaverIsReloading = false
203
+ NSLog("[YaverHotReload] bridge recreated successfully")
204
+ }
205
+ }
206
+ `;
207
+
208
+ patched =
209
+ patched.slice(0, classCloseIndex) +
210
+ reloadHandler +
211
+ patched.slice(classCloseIndex);
212
+ }
213
+
214
+ // 3. Call setupYaverHotReload() in didFinishLaunchingWithOptions
215
+ if (patched.includes("super.application(application, didFinishLaunchingWithOptions:")) {
216
+ patched = patched.replace(
217
+ "super.application(application, didFinishLaunchingWithOptions:",
218
+ "setupYaverHotReload()\n return super.application(application, didFinishLaunchingWithOptions:"
219
+ );
220
+ // Remove the duplicate "return" if the original already had one
221
+ patched = patched.replace("return setupYaverHotReload()", "setupYaverHotReload()");
222
+ }
223
+
224
+ config.modResults.contents = patched;
225
+ return config;
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Copy Android native module source files and register the package.
231
+ * Also patches MainApplication to use hot-reloaded bundle on startup.
232
+ */
233
+ function withYaverAndroidHotReload(config) {
234
+ // Copy Java source files
235
+ config = withDangerousMod(config, [
236
+ "android",
237
+ (config) => {
238
+ const sdkAndroidDir = path.resolve(__dirname, "android", "src", "main", "java", "io", "yaver", "feedback");
239
+ const targetDir = path.join(
240
+ config.modRequest.platformProjectRoot,
241
+ "app", "src", "main", "java", "io", "yaver", "feedback"
242
+ );
243
+
244
+ if (fs.existsSync(sdkAndroidDir)) {
245
+ fs.mkdirSync(targetDir, { recursive: true });
246
+ for (const file of fs.readdirSync(sdkAndroidDir)) {
247
+ fs.copyFileSync(
248
+ path.join(sdkAndroidDir, file),
249
+ path.join(targetDir, file)
250
+ );
251
+ }
252
+ }
253
+ return config;
254
+ },
255
+ ]);
256
+
257
+ // Patch MainApplication to register the package and use hot bundle
258
+ config = withMainApplication(config, (config) => {
259
+ let contents = config.modResults.contents;
260
+
261
+ if (contents.includes("YaverHotReload")) {
262
+ return config;
263
+ }
264
+
265
+ // Add import
266
+ contents = contents.replace(
267
+ "import com.facebook.react.ReactApplication",
268
+ "import com.facebook.react.ReactApplication;\nimport io.yaver.feedback.YaverHotReloadPackage;\nimport io.yaver.feedback.YaverHotReloadModule;"
269
+ );
270
+
271
+ // Register package in getPackages()
272
+ if (contents.includes("packages.add(")) {
273
+ // Find the last packages.add() and add ours after
274
+ const lastAdd = contents.lastIndexOf("packages.add(");
275
+ const lineEnd = contents.indexOf("\n", lastAdd);
276
+ contents =
277
+ contents.slice(0, lineEnd + 1) +
278
+ " packages.add(new YaverHotReloadPackage());\n" +
279
+ contents.slice(lineEnd + 1);
280
+ }
281
+
282
+ // Override getJSBundleFile to check for hot bundle
283
+ if (contents.includes("getJSMainModuleName()") && !contents.includes("getJSBundleFile")) {
284
+ const mainModuleIdx = contents.indexOf("getJSMainModuleName()");
285
+ const methodStart = contents.lastIndexOf("@Override", mainModuleIdx);
286
+ contents =
287
+ contents.slice(0, methodStart) +
288
+ `@Override
289
+ protected String getJSBundleFile() {
290
+ // Yaver Feedback SDK: load hot-reloaded bundle if available
291
+ java.io.File hotBundle = YaverHotReloadModule.getSavedBundleFile(getApplicationContext());
292
+ if (hotBundle != null) return hotBundle.getAbsolutePath();
293
+ return super.getJSBundleFile();
294
+ }
295
+
296
+ ` +
297
+ contents.slice(methodStart);
298
+ }
299
+
300
+ config.modResults.contents = contents;
301
+ return config;
302
+ });
303
+
304
+ return config;
305
+ }
306
+
307
+ function withYaverFeedback(config, props) {
61
308
  config = withYaverFeedbackIOS(config);
62
309
  config = withYaverFeedbackAndroid(config);
310
+
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;
315
+ if (enableHotReload) {
316
+ config = withYaverHotReloadNativeModule(config);
317
+ config = withYaverAppDelegateHook(config);
318
+ config = withYaverAndroidHotReload(config);
319
+ }
320
+
63
321
  return config;
64
322
  }
65
323
 
@@ -0,0 +1,16 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(YaverHotReload, NSObject)
4
+
5
+ RCT_EXTERN_METHOD(loadBundle:(NSString *)urlString
6
+ headers:(NSDictionary *)headers
7
+ resolver:(RCTPromiseResolveBlock)resolve
8
+ rejecter:(RCTPromiseRejectBlock)reject)
9
+
10
+ RCT_EXTERN_METHOD(hasBundle:(RCTPromiseResolveBlock)resolve
11
+ rejecter:(RCTPromiseRejectBlock)reject)
12
+
13
+ RCT_EXTERN_METHOD(clearBundle:(RCTPromiseResolveBlock)resolve
14
+ rejecter:(RCTPromiseRejectBlock)reject)
15
+
16
+ @end
@@ -0,0 +1,112 @@
1
+ import Foundation
2
+ import React
3
+ import UIKit
4
+
5
+ /**
6
+ * Hot reload native module for the Yaver Feedback SDK.
7
+ *
8
+ * Downloads a Hermes bytecode bundle from the agent, saves it to Documents,
9
+ * and posts a notification that the AppDelegate handler (injected by the Expo
10
+ * config plugin) catches to recreate the RN bridge with the new bundle.
11
+ *
12
+ * Supports N reloads — each reload tears down the old bridge and creates
13
+ * a fresh one pointing to the updated bundle file.
14
+ */
15
+ @objc(YaverHotReload)
16
+ class YaverHotReload: NSObject {
17
+
18
+ static let bundleDir = "yaver-hot-reload"
19
+ static let bundleFile = "main.jsbundle"
20
+ static let reloadNotification = Notification.Name("YaverHotReloadBundle")
21
+
22
+ override static func requiresMainQueueSetup() -> Bool { return true }
23
+
24
+ /// Download a Hermes bundle from the agent and trigger a bridge reload.
25
+ @objc func loadBundle(_ urlString: String,
26
+ headers: NSDictionary?,
27
+ resolver resolve: @escaping RCTPromiseResolveBlock,
28
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
29
+ guard let bundleURL = URL(string: urlString) else {
30
+ reject("INVALID_URL", "Invalid bundle URL", nil); return
31
+ }
32
+
33
+ var request = URLRequest(url: bundleURL)
34
+ request.timeoutInterval = 60
35
+ if let headers = headers as? [String: String] {
36
+ for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
37
+ }
38
+
39
+ NSLog("[YaverHotReload] downloading bundle from %@...", urlString)
40
+ URLSession.shared.dataTask(with: request) { data, response, error in
41
+ if let error = error {
42
+ reject("DOWNLOAD_FAILED", error.localizedDescription, error); return
43
+ }
44
+ guard let data = data, data.count > 0 else {
45
+ reject("EMPTY_BUNDLE", "Empty bundle response", nil); return
46
+ }
47
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
48
+ let code = (response as? HTTPURLResponse)?.statusCode ?? 0
49
+ reject("HTTP_ERROR", "Status \(code)", nil); return
50
+ }
51
+
52
+ // Basic Hermes bytecode validation
53
+ if data.count >= 12 {
54
+ let magic: UInt32 = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
55
+ if magic == 0x1F1903C1 {
56
+ let bcVersion: UInt32 = data.withUnsafeBytes { $0.load(fromByteOffset: 8, as: UInt32.self) }
57
+ NSLog("[YaverHotReload] Hermes bytecode BC%d, %d bytes", bcVersion, data.count)
58
+ } else {
59
+ NSLog("[YaverHotReload] WARNING: not Hermes bytecode (magic=0x%08X)", magic)
60
+ }
61
+ }
62
+
63
+ do {
64
+ let savePath = YaverHotReload.savedBundlePath()
65
+ let dir = savePath.deletingLastPathComponent()
66
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
67
+ try data.write(to: savePath, options: .atomic)
68
+
69
+ NSLog("[YaverHotReload] saved %d bytes, posting reload notification", data.count)
70
+ resolve(["loaded": true, "size": data.count])
71
+
72
+ // Post notification — AppDelegate handler recreates the bridge
73
+ DispatchQueue.main.async {
74
+ NotificationCenter.default.post(
75
+ name: YaverHotReload.reloadNotification,
76
+ object: nil,
77
+ userInfo: ["bundlePath": savePath.path]
78
+ )
79
+ }
80
+ } catch {
81
+ reject("SAVE_FAILED", error.localizedDescription, error)
82
+ }
83
+ }.resume()
84
+ }
85
+
86
+ @objc func hasBundle(_ resolve: @escaping RCTPromiseResolveBlock,
87
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
88
+ resolve(FileManager.default.fileExists(atPath: YaverHotReload.savedBundlePath().path))
89
+ }
90
+
91
+ @objc func clearBundle(_ resolve: @escaping RCTPromiseResolveBlock,
92
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
93
+ let dir = YaverHotReload.savedBundlePath().deletingLastPathComponent()
94
+ try? FileManager.default.removeItem(at: dir)
95
+ resolve(true)
96
+ }
97
+
98
+ // MARK: - Static helpers
99
+
100
+ static func savedBundlePath() -> URL {
101
+ let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
102
+ return docs
103
+ .appendingPathComponent(bundleDir, isDirectory: true)
104
+ .appendingPathComponent(bundleFile)
105
+ }
106
+
107
+ /// Returns the hot-reloaded bundle URL if one exists on disk.
108
+ @objc static func bundleURL() -> URL? {
109
+ let path = savedBundlePath()
110
+ return FileManager.default.fileExists(atPath: path.path) ? path : nil
111
+ }
112
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Visual feedback SDK for Yaver — shake-to-report, screen recording, voice annotations for vibe coding",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
8
8
  "src/",
9
+ "ios/",
10
+ "android/",
9
11
  "app.plugin.js"
10
12
  ],
11
13
  "license": "MIT",
package/src/Discovery.ts CHANGED
@@ -1,4 +1,14 @@
1
- import AsyncStorage from '@react-native-async-storage/async-storage';
1
+ // AsyncStorage is an optional peer dep — gracefully degrade if missing
2
+ let AsyncStorage: {
3
+ getItem: (key: string) => Promise<string | null>;
4
+ setItem: (key: string, value: string) => Promise<void>;
5
+ removeItem: (key: string) => Promise<void>;
6
+ } | null = null;
7
+ try {
8
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
9
+ } catch {
10
+ // Not installed — discovery caching disabled, auto-discovery still works
11
+ }
2
12
 
3
13
  const STORAGE_KEY = 'yaver_feedback_agent';
4
14
  const DEFAULT_PORT = 18080;
@@ -123,9 +133,116 @@ export class YaverDiscovery {
123
133
 
124
134
  if (!target?.quicHost) return null;
125
135
 
136
+ // Try direct connection first (same LAN)
126
137
  const port = target.httpPort ?? DEFAULT_PORT;
127
- const url = `http://${target.quicHost}:${port}`;
128
- return await YaverDiscovery.probe(url);
138
+ const directUrl = `http://${target.quicHost}:${port}`;
139
+ const directResult = await YaverDiscovery.probe(directUrl);
140
+ if (directResult) return directResult;
141
+
142
+ // Direct connection failed — try via HTTP relay (off-LAN)
143
+ const relayResult = await YaverDiscovery.discoverViaRelay(
144
+ base, authToken, target.deviceId,
145
+ );
146
+ if (relayResult) return relayResult;
147
+
148
+ return null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Discover agent via relay HTTP proxy.
156
+ * Fetches relay server list from Convex platformConfig, then probes
157
+ * `{relayHttpUrl}/d/{deviceId}/health` to reach the agent over the internet.
158
+ */
159
+ static async discoverViaRelay(
160
+ convexUrl: string,
161
+ authToken: string,
162
+ deviceId: string,
163
+ ): Promise<DiscoveryResult | null> {
164
+ try {
165
+ // Fetch relay server list from user settings first, then platform config
166
+ const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
167
+ headers: { Authorization: `Bearer ${authToken}` },
168
+ });
169
+ let relayUrl: string | undefined;
170
+ let relayPassword: string | undefined;
171
+
172
+ if (settingsRes.ok) {
173
+ const settingsData = await settingsRes.json();
174
+ relayUrl = settingsData.relayUrl;
175
+ relayPassword = settingsData.relayPassword;
176
+ }
177
+
178
+ // If no user-level relay, fetch platform relay servers
179
+ if (!relayUrl) {
180
+ const configRes = await fetch(`${convexUrl}/platform-config?key=relay_servers`);
181
+ if (configRes.ok) {
182
+ const configData = await configRes.json();
183
+ const servers = typeof configData.value === 'string'
184
+ ? JSON.parse(configData.value)
185
+ : configData.value;
186
+ if (Array.isArray(servers) && servers.length > 0) {
187
+ // Pick the first (highest priority) relay with an httpUrl
188
+ const relay = servers.find((s: { httpUrl?: string }) => s.httpUrl);
189
+ if (relay) {
190
+ relayUrl = relay.httpUrl;
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ if (!relayUrl) return null;
197
+
198
+ // Probe agent through relay: {relayHttpUrl}/d/{deviceId}/health
199
+ const relayBase = `${relayUrl.replace(/\/$/, '')}/d/${deviceId}`;
200
+ const result = await YaverDiscovery.probeWithHeaders(relayBase, {
201
+ 'X-Relay-Password': relayPassword || '',
202
+ });
203
+ return result;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Probe with extra headers (e.g. relay password).
211
+ */
212
+ static async probeWithHeaders(
213
+ url: string,
214
+ headers: Record<string, string>,
215
+ ): Promise<DiscoveryResult | null> {
216
+ const base = url.replace(/\/$/, '');
217
+ const start = Date.now();
218
+
219
+ try {
220
+ const controller = new AbortController();
221
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS + 3000); // relay adds latency
222
+
223
+ const response = await fetch(`${base}/health`, {
224
+ method: 'GET',
225
+ headers,
226
+ signal: controller.signal,
227
+ });
228
+
229
+ clearTimeout(timeoutId);
230
+
231
+ if (!response.ok) return null;
232
+
233
+ const latency = Date.now() - start;
234
+ let hostname = 'Unknown';
235
+ let version = 'unknown';
236
+
237
+ try {
238
+ const data = await response.json();
239
+ hostname = data.hostname ?? data.name ?? 'Unknown';
240
+ version = data.version ?? 'unknown';
241
+ } catch {
242
+ // Health endpoint might return plain text
243
+ }
244
+
245
+ return { url: base, hostname, version, latency };
129
246
  } catch {
130
247
  return null;
131
248
  }
@@ -185,8 +302,9 @@ export class YaverDiscovery {
185
302
  return result;
186
303
  }
187
304
 
188
- /** Get the cached agent connection from AsyncStorage. */
305
+ /** Get the cached agent connection from storage. */
189
306
  static async getStored(): Promise<{ url: string; hostname: string } | null> {
307
+ if (!AsyncStorage) return null;
190
308
  try {
191
309
  const raw = await AsyncStorage.getItem(STORAGE_KEY);
192
310
  if (!raw) return null;
@@ -200,8 +318,9 @@ export class YaverDiscovery {
200
318
  }
201
319
  }
202
320
 
203
- /** Store a successful discovery result in AsyncStorage. */
321
+ /** Store a successful discovery result. */
204
322
  static async store(result: DiscoveryResult): Promise<void> {
323
+ if (!AsyncStorage) return;
205
324
  try {
206
325
  await AsyncStorage.setItem(
207
326
  STORAGE_KEY,
@@ -214,6 +333,7 @@ export class YaverDiscovery {
214
333
 
215
334
  /** Clear the stored agent connection. */
216
335
  static async clear(): Promise<void> {
336
+ if (!AsyncStorage) return;
217
337
  try {
218
338
  await AsyncStorage.removeItem(STORAGE_KEY);
219
339
  } catch {
@@ -119,9 +119,13 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
119
119
  const testPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
120
120
  const outputScrollRef = useRef<ScrollView>(null);
121
121
 
122
- // Resolve agent URL and token
122
+ // Resolve agent URL and token — re-read from config each render
123
+ // because discoverAgent() may set agentUrl asynchronously after init.
124
+ const [resolvedAgentUrl, setResolvedAgentUrl] = useState<string | undefined>(
125
+ agentUrlProp || YaverFeedback.getConfig()?.agentUrl,
126
+ );
123
127
  const config = YaverFeedback.getConfig();
124
- const agentUrl = agentUrlProp || config?.agentUrl;
128
+ const agentUrl = resolvedAgentUrl;
125
129
  const authToken = authTokenProp || config?.authToken;
126
130
  const panelBg = panelBackgroundColor || config?.panelBackgroundColor || DEFAULT_PANEL_BG;
127
131
 
@@ -129,19 +133,27 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
129
133
  setOutput((prev) => [...prev.slice(-20), line]);
130
134
  }, []);
131
135
 
132
- // Connection health polling
136
+ // Connection health polling — also picks up agentUrl from config when
137
+ // it becomes available after background discovery completes.
133
138
  useEffect(() => {
134
- if (!healthCheckInterval || !agentUrl) return;
139
+ if (!healthCheckInterval) return;
135
140
 
136
141
  const check = async () => {
142
+ // Re-read config in case discoverAgent() resolved since last check
143
+ const latestUrl = agentUrlProp || YaverFeedback.getConfig()?.agentUrl;
144
+ if (latestUrl && latestUrl !== resolvedAgentUrl) {
145
+ setResolvedAgentUrl(latestUrl);
146
+ }
147
+ if (!latestUrl) return;
148
+
137
149
  try {
138
150
  const client = YaverFeedback.getP2PClient();
139
151
  if (client) {
140
152
  setIsConnected(await client.health());
141
- } else if (agentUrl) {
153
+ } else {
142
154
  const controller = new AbortController();
143
155
  const timeout = setTimeout(() => controller.abort(), 3000);
144
- const resp = await fetch(`${agentUrl.replace(/\/$/, '')}/health`, {
156
+ const resp = await fetch(`${latestUrl.replace(/\/$/, '')}/health`, {
145
157
  signal: controller.signal,
146
158
  });
147
159
  clearTimeout(timeout);
@@ -155,7 +167,7 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
155
167
  check();
156
168
  const interval = setInterval(check, healthCheckInterval);
157
169
  return () => clearInterval(interval);
158
- }, [agentUrl, healthCheckInterval]);
170
+ }, [agentUrlProp, healthCheckInterval, resolvedAgentUrl]);
159
171
 
160
172
  const panResponder = useRef(
161
173
  PanResponder.create({
@@ -49,6 +49,10 @@ export class YaverFeedback {
49
49
  p2pClient = new P2PClient(config.agentUrl, config.authToken);
50
50
  } else {
51
51
  p2pClient = null;
52
+ // Auto-discover agent in the background when convexUrl or LAN is available
53
+ if (enabled) {
54
+ YaverFeedback.discoverAgent();
55
+ }
52
56
  }
53
57
 
54
58
  // Set up error capture buffer size
@@ -80,21 +84,15 @@ export class YaverFeedback {
80
84
  if (cfg.onReload) {
81
85
  cfg.onReload();
82
86
  } else {
83
- // Default: try DevSettings.reload() in dev mode
84
- try {
85
- const { DevSettings } = require('react-native');
86
- if (typeof DevSettings?.reload === 'function') {
87
- DevSettings.reload();
88
- }
89
- } catch {
90
- // Not in dev mode or DevSettings unavailable
91
- }
87
+ YaverFeedback.defaultReload();
92
88
  }
93
89
  } else if (cmd.command === 'reload_bundle' && cmd.data) {
94
90
  const bundleUrl = cmd.data.bundleUrl as string;
95
91
  const assetsUrl = cmd.data.assetsUrl as string | undefined;
96
92
  if (cfg.onReloadBundle) {
97
93
  cfg.onReloadBundle(bundleUrl, assetsUrl);
94
+ } else {
95
+ YaverFeedback.defaultReloadBundle(bundleUrl, assetsUrl);
98
96
  }
99
97
  }
100
98
  });
@@ -111,6 +109,30 @@ export class YaverFeedback {
111
109
  // pass-through wrapper they insert into their own error chain
112
110
  }
113
111
 
112
+ /**
113
+ * Run agent discovery in the background.
114
+ * Called automatically from init() when no agentUrl is provided.
115
+ * Sets config.agentUrl and creates P2PClient on success.
116
+ */
117
+ static async discoverAgent(): Promise<void> {
118
+ if (!config || !enabled) return;
119
+ if (config.agentUrl) return; // already have a URL
120
+
121
+ try {
122
+ const result = await YaverDiscovery.discover({
123
+ convexUrl: config.convexUrl,
124
+ authToken: config.authToken,
125
+ preferredDeviceId: config.preferredDeviceId,
126
+ });
127
+ if (result && config) {
128
+ config.agentUrl = result.url;
129
+ p2pClient = new P2PClient(result.url, config.authToken);
130
+ }
131
+ } catch {
132
+ // Discovery failed — FloatingButton will show disconnected, user can retry
133
+ }
134
+ }
135
+
114
136
  /**
115
137
  * Manually trigger the feedback collection flow.
116
138
  * Opens the feedback modal if the SDK is initialized and enabled.
@@ -360,6 +382,85 @@ export class YaverFeedback {
360
382
  }
361
383
  }
362
384
 
385
+ /**
386
+ * Default reload handler. Tries three strategies in order:
387
+ *
388
+ * 1. **YaverBundleLoader** — running inside Yaver's native container.
389
+ * Pulls fresh Hermes bundle from agent and reloads the RN bridge.
390
+ *
391
+ * 2. **YaverHotReload** — standalone app with feedback SDK's native module
392
+ * (added via Expo config plugin). Downloads Hermes bundle from agent,
393
+ * saves to Documents, and reloads the RN bridge.
394
+ *
395
+ * 3. **DevSettings.reload()** — standalone dev build connected to Metro.
396
+ */
397
+ private static defaultReload(): void {
398
+ if (!config?.agentUrl) return;
399
+ const bundleUrl = `${config.agentUrl}/dev/native-bundle`;
400
+ const headers = { Authorization: `Bearer ${config.authToken}` };
401
+ YaverFeedback.loadBundleAndReload(bundleUrl, headers);
402
+ }
403
+
404
+ /**
405
+ * Default reload_bundle handler. Receives a compiled Hermes bundle URL
406
+ * from the agent and loads it via the best available native mechanism.
407
+ */
408
+ private static defaultReloadBundle(bundleUrl: string, _assetsUrl?: string): void {
409
+ if (!config?.agentUrl) return;
410
+
411
+ const fullUrl = bundleUrl.startsWith('http')
412
+ ? bundleUrl
413
+ : `${config.agentUrl}${bundleUrl}`;
414
+ const headers = { Authorization: `Bearer ${config.authToken}` };
415
+ YaverFeedback.loadBundleAndReload(fullUrl, headers);
416
+ }
417
+
418
+ /**
419
+ * Core bundle reload logic. Tries native loaders in order:
420
+ *
421
+ * 1. YaverBundleLoader (Yaver container — full validation + bridge reload)
422
+ * 2. YaverHotReload (SDK's own native module — download + bridge reload)
423
+ * 3. DevSettings.reload() (Metro dev server fallback)
424
+ */
425
+ private static loadBundleAndReload(
426
+ bundleUrl: string,
427
+ headers: Record<string, string>,
428
+ ): void {
429
+ const { NativeModules } = require('react-native');
430
+
431
+ // Strategy 1: YaverBundleLoader (running inside Yaver container)
432
+ if (NativeModules.YaverBundleLoader) {
433
+ NativeModules.YaverBundleLoader.loadBundle(bundleUrl, 'main', headers)
434
+ .catch((err: Error) => {
435
+ console.warn('[YaverFeedback] YaverBundleLoader reload failed:', err);
436
+ });
437
+ return;
438
+ }
439
+
440
+ // Strategy 2: YaverHotReload (SDK's native module, added by Expo config plugin)
441
+ if (NativeModules.YaverHotReload) {
442
+ NativeModules.YaverHotReload.loadBundle(bundleUrl, headers)
443
+ .catch((err: Error) => {
444
+ console.warn('[YaverFeedback] YaverHotReload reload failed:', err);
445
+ });
446
+ return;
447
+ }
448
+
449
+ // Strategy 3: DevSettings.reload() for Metro dev builds
450
+ console.warn(
451
+ '[YaverFeedback] No native bundle loader available. ' +
452
+ 'Add "yaver-feedback-react-native" to your app.json plugins to enable hot reload.',
453
+ );
454
+ try {
455
+ const { DevSettings } = require('react-native');
456
+ if (typeof DevSettings?.reload === 'function') {
457
+ DevSettings.reload();
458
+ }
459
+ } catch {
460
+ // Not in dev mode
461
+ }
462
+ }
463
+
363
464
  /** Tear down the SDK (stop shake detector, clear state). */
364
465
  static destroy(): void {
365
466
  if (shakeDetector) {