yaver-feedback-react-native 0.3.0 → 0.5.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/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,14 +1,16 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.3.0",
3
+ "version": "0.5.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
- "license": "MIT",
13
+ "license": "Apache-2.0",
12
14
  "repository": {
13
15
  "type": "git",
14
16
  "url": "https://github.com/kivanccakmak/yaver.io",
@@ -20,20 +22,35 @@
20
22
  "@react-native-async-storage/async-storage": ">=1.17.0"
21
23
  },
22
24
  "peerDependenciesMeta": {
23
- "@expo/config-plugins": { "optional": true },
24
- "expo-constants": { "optional": true }
25
+ "@expo/config-plugins": {
26
+ "optional": true
27
+ },
28
+ "expo-constants": {
29
+ "optional": true
30
+ }
25
31
  },
26
32
  "devDependencies": {
27
33
  "jest": "^29.0.0",
28
34
  "@types/jest": "^29.0.0",
35
+ "ts-jest": "^29.1.0",
29
36
  "typescript": "^5.0.0"
30
37
  },
31
38
  "scripts": {
32
39
  "test": "jest"
33
40
  },
34
41
  "jest": {
35
- "preset": "react-native",
36
- "testPathPattern": "__tests__"
42
+ "preset": "ts-jest",
43
+ "testEnvironment": "node",
44
+ "testMatch": [
45
+ "**/src/__tests__/**/*.test.ts"
46
+ ]
37
47
  },
38
- "keywords": ["yaver", "feedback", "bug-report", "screen-recording", "vibe-coding", "react-native"]
48
+ "keywords": [
49
+ "yaver",
50
+ "feedback",
51
+ "bug-report",
52
+ "screen-recording",
53
+ "vibe-coding",
54
+ "react-native"
55
+ ]
39
56
  }
@@ -0,0 +1,97 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { DeviceEventEmitter, Modal } from 'react-native';
3
+ import { YaverLoginScreen } from './LoginScreen';
4
+ import { YaverMachinePickerScreen } from './MachinePickerScreen';
5
+ import { YaverFeedback } from './YaverFeedback';
6
+ import { getToken, RemoteDevice } from './auth';
7
+
8
+ /**
9
+ * Presentation layer for the SDK's auth + machine-picker modals.
10
+ *
11
+ * Mounts automatically inside `<FeedbackModal />`, so consumers of the SDK
12
+ * get in-app login with no extra wiring. It listens for events emitted by
13
+ * `YaverFeedback.showLogin()` / `showMachinePicker()`:
14
+ *
15
+ * yaverFeedback:startLogin → show login modal
16
+ * yaverFeedback:startMachinePicker → show machine picker
17
+ *
18
+ * The overlay closes itself once login/pick succeeds, then re-emits
19
+ * `yaverFeedback:startReport` so the user continues straight into the
20
+ * feedback flow they originally triggered.
21
+ */
22
+ export const AuthOverlay: React.FC = () => {
23
+ const [loginVisible, setLoginVisible] = useState(false);
24
+ const [pickerVisible, setPickerVisible] = useState(false);
25
+ const [token, setToken] = useState<string | null>(null);
26
+
27
+ useEffect(() => {
28
+ let mounted = true;
29
+ (async () => {
30
+ const cached = await getToken();
31
+ if (mounted && cached) setToken(cached);
32
+ })();
33
+
34
+ const loginSub = DeviceEventEmitter.addListener(
35
+ 'yaverFeedback:startLogin',
36
+ () => setLoginVisible(true),
37
+ );
38
+ const pickerSub = DeviceEventEmitter.addListener(
39
+ 'yaverFeedback:startMachinePicker',
40
+ async () => {
41
+ const cached = await getToken();
42
+ if (cached) setToken(cached);
43
+ if (cached) setPickerVisible(true);
44
+ },
45
+ );
46
+ return () => {
47
+ mounted = false;
48
+ loginSub.remove();
49
+ pickerSub.remove();
50
+ };
51
+ }, []);
52
+
53
+ const handleLoggedIn = async (newToken: string) => {
54
+ setToken(newToken);
55
+ await YaverFeedback.setAuthToken(newToken);
56
+ setLoginVisible(false);
57
+ setPickerVisible(true);
58
+ };
59
+
60
+ const handleDevicePicked = async (device: RemoteDevice) => {
61
+ await YaverFeedback.setPreferredDevice(device.deviceId);
62
+ setPickerVisible(false);
63
+ // Continue straight into the feedback flow the user originally triggered.
64
+ DeviceEventEmitter.emit('yaverFeedback:startReport');
65
+ };
66
+
67
+ return (
68
+ <>
69
+ <Modal
70
+ visible={loginVisible}
71
+ animationType="slide"
72
+ presentationStyle="fullScreen"
73
+ onRequestClose={() => setLoginVisible(false)}
74
+ >
75
+ <YaverLoginScreen
76
+ onLoggedIn={handleLoggedIn}
77
+ onCancel={() => setLoginVisible(false)}
78
+ />
79
+ </Modal>
80
+
81
+ <Modal
82
+ visible={pickerVisible && !!token}
83
+ animationType="slide"
84
+ presentationStyle="fullScreen"
85
+ onRequestClose={() => setPickerVisible(false)}
86
+ >
87
+ {token && (
88
+ <YaverMachinePickerScreen
89
+ token={token}
90
+ onPick={handleDevicePicked}
91
+ onCancel={() => setPickerVisible(false)}
92
+ />
93
+ )}
94
+ </Modal>
95
+ </>
96
+ );
97
+ };
package/src/BlackBox.ts CHANGED
@@ -7,7 +7,15 @@ import { YaverFeedback } from './YaverFeedback';
7
7
  * These mirror the Go BlackBoxEvent struct on the agent side.
8
8
  */
9
9
  export interface BlackBoxEvent {
10
- type: 'log' | 'error' | 'navigation' | 'lifecycle' | 'network' | 'state' | 'render';
10
+ type:
11
+ | 'log'
12
+ | 'error'
13
+ | 'navigation'
14
+ | 'lifecycle'
15
+ | 'network'
16
+ | 'state'
17
+ | 'render'
18
+ | 'track';
11
19
  level?: 'info' | 'warn' | 'error';
12
20
  message: string;
13
21
  timestamp: number;
@@ -94,7 +102,7 @@ export class BlackBox {
94
102
  }
95
103
 
96
104
  BlackBox.baseUrl = feedbackConfig.agentUrl.replace(/\/$/, '');
97
- BlackBox.authToken = feedbackConfig.authToken;
105
+ BlackBox.authToken = feedbackConfig.authToken ?? null;
98
106
  BlackBox.deviceId = config?.deviceId ?? BlackBox.generateDeviceId();
99
107
  BlackBox.appName = config?.appName ?? '';
100
108
  BlackBox.flushInterval = config?.flushInterval ?? 2000;
@@ -153,6 +161,37 @@ export class BlackBox {
153
161
  BlackBox.push({ type: 'log', level: 'error', message, timestamp: Date.now(), source, metadata });
154
162
  }
155
163
 
164
+ // ─── Track events ────────────────────────────────────────────────
165
+ //
166
+ // Business-event ingest. Routed into the agent's analytics ledger
167
+ // via the same BlackBox stream so the dev doesn't pay Mixpanel
168
+ // / Amplitude for "the user tapped Purchase." Zero dashboards in
169
+ // yaver; export to CSV / webhook into PostHog if you want charts.
170
+
171
+ /**
172
+ * Record a business event. Fires into the analytics ledger on
173
+ * the agent — see GET /analytics/events.csv.
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * BlackBox.track('purchase_completed', {
178
+ * amount: '9.99',
179
+ * currency: 'USD',
180
+ * plan: 'pro',
181
+ * });
182
+ * ```
183
+ */
184
+ static track(name: string, props?: Record<string, unknown>, route?: string): void {
185
+ if (!name) return;
186
+ BlackBox.push({
187
+ type: 'track',
188
+ message: name,
189
+ timestamp: Date.now(),
190
+ route,
191
+ metadata: props,
192
+ });
193
+ }
194
+
156
195
  // ─── Errors ──────────────────────────────────────────────────────
157
196
 
158
197
  /** Record a caught error with stack trace. Also adds to the feedback error buffer. */