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.
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java +188 -0
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadPackage.java +33 -0
- package/app.plugin.js +263 -5
- package/ios/YaverHotReload.m +16 -0
- package/ios/YaverHotReload.swift +112 -0
- package/package.json +3 -1
- package/src/Discovery.ts +125 -5
- package/src/FloatingButton.tsx +19 -7
- package/src/YaverFeedback.ts +110 -9
|
@@ -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
|
|
5
|
-
* - iOS
|
|
6
|
-
* -
|
|
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(
|
|
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
|
-
|
|
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
|
+
"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
|
-
|
|
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
|
|
128
|
-
|
|
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
|
|
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
|
|
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 {
|
package/src/FloatingButton.tsx
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
153
|
+
} else {
|
|
142
154
|
const controller = new AbortController();
|
|
143
155
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
144
|
-
const resp = await fetch(`${
|
|
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
|
-
}, [
|
|
170
|
+
}, [agentUrlProp, healthCheckInterval, resolvedAgentUrl]);
|
|
159
171
|
|
|
160
172
|
const panResponder = useRef(
|
|
161
173
|
PanResponder.create({
|
package/src/YaverFeedback.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|