yaver-feedback-react-native 0.2.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/README.md +690 -0
- package/app.plugin.js +70 -0
- package/package.json +39 -0
- package/src/BlackBox.ts +317 -0
- package/src/ConnectionScreen.tsx +400 -0
- package/src/Discovery.ts +223 -0
- package/src/FeedbackModal.tsx +678 -0
- package/src/FixReport.tsx +313 -0
- package/src/FloatingButton.tsx +860 -0
- package/src/P2PClient.ts +303 -0
- package/src/ShakeDetector.ts +57 -0
- package/src/YaverFeedback.ts +345 -0
- package/src/__tests__/Discovery.test.ts +187 -0
- package/src/__tests__/P2PClient.test.ts +218 -0
- package/src/__tests__/SDKToken.test.ts +268 -0
- package/src/__tests__/YaverFeedback.test.ts +189 -0
- package/src/__tests__/types.test.ts +247 -0
- package/src/capture.ts +84 -0
- package/src/expo.ts +62 -0
- package/src/index.ts +54 -0
- package/src/types.ts +251 -0
- package/src/upload.ts +74 -0
package/src/P2PClient.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import { FeedbackBundle, TestSession, VoiceCapability } from './types';
|
|
3
|
+
|
|
4
|
+
export interface FeedbackEvent {
|
|
5
|
+
type: string;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
data: any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Lightweight P2P HTTP client for communicating with a Yaver agent.
|
|
12
|
+
*
|
|
13
|
+
* Reuses the same endpoint patterns as the main upload module but adds
|
|
14
|
+
* support for streaming feedback, listing builds, and triggering builds.
|
|
15
|
+
*/
|
|
16
|
+
export class P2PClient {
|
|
17
|
+
private baseUrl: string;
|
|
18
|
+
private authToken: string;
|
|
19
|
+
|
|
20
|
+
constructor(baseUrl: string, authToken: string) {
|
|
21
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
22
|
+
this.authToken = authToken;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Update the base URL (e.g. after re-discovery). */
|
|
26
|
+
setBaseUrl(url: string): void {
|
|
27
|
+
this.baseUrl = url.replace(/\/$/, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Update the auth token. */
|
|
31
|
+
setAuthToken(token: string): void {
|
|
32
|
+
this.authToken = token;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Health check — returns true if the agent is reachable. */
|
|
36
|
+
async health(): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
40
|
+
|
|
41
|
+
const response = await fetch(`${this.baseUrl}/health`, {
|
|
42
|
+
method: 'GET',
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
clearTimeout(timeoutId);
|
|
47
|
+
return response.ok;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get agent info (hostname, version, platform). */
|
|
54
|
+
async info(): Promise<{ hostname: string; version: string; platform: string }> {
|
|
55
|
+
const response = await this.request('GET', '/health');
|
|
56
|
+
const data = await response.json();
|
|
57
|
+
return {
|
|
58
|
+
hostname: data.hostname ?? data.name ?? 'Unknown',
|
|
59
|
+
version: data.version ?? 'unknown',
|
|
60
|
+
platform: data.platform ?? 'unknown',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Upload a feedback bundle via multipart POST.
|
|
66
|
+
* @returns The feedback report ID from the agent.
|
|
67
|
+
*/
|
|
68
|
+
async uploadFeedback(bundle: FeedbackBundle): Promise<string> {
|
|
69
|
+
const formData = new FormData();
|
|
70
|
+
|
|
71
|
+
formData.append('metadata', JSON.stringify(bundle.metadata));
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < bundle.screenshots.length; i++) {
|
|
74
|
+
const path = bundle.screenshots[i];
|
|
75
|
+
formData.append(`screenshot_${i}`, {
|
|
76
|
+
uri: Platform.OS === 'android' ? `file://${path}` : path,
|
|
77
|
+
type: 'image/png',
|
|
78
|
+
name: `screenshot_${i}.png`,
|
|
79
|
+
} as any);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (bundle.audio) {
|
|
83
|
+
formData.append('audio', {
|
|
84
|
+
uri: Platform.OS === 'android' ? `file://${bundle.audio}` : bundle.audio,
|
|
85
|
+
type: 'audio/m4a',
|
|
86
|
+
name: 'voice_note.m4a',
|
|
87
|
+
} as any);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (bundle.video) {
|
|
91
|
+
formData.append('video', {
|
|
92
|
+
uri: Platform.OS === 'android' ? `file://${bundle.video}` : bundle.video,
|
|
93
|
+
type: 'video/mp4',
|
|
94
|
+
name: 'screen_recording.mp4',
|
|
95
|
+
} as any);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const response = await fetch(`${this.baseUrl}/feedback`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
102
|
+
},
|
|
103
|
+
body: formData,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const text = await response.text().catch(() => '');
|
|
108
|
+
throw new Error(`[P2PClient] Upload failed (${response.status}): ${text}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = await response.json();
|
|
112
|
+
return result.id ?? result.reportId ?? 'unknown';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stream feedback events to the agent in live mode.
|
|
117
|
+
* Sends each event as a JSON POST to `/feedback/stream`.
|
|
118
|
+
*/
|
|
119
|
+
async streamFeedback(events: AsyncIterable<FeedbackEvent>): Promise<void> {
|
|
120
|
+
for await (const event of events) {
|
|
121
|
+
const response = await fetch(`${this.baseUrl}/feedback/stream`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify(event),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
const text = await response.text().catch(() => '');
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[P2PClient] Stream event failed (${response.status}): ${text}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** List available builds from the agent. */
|
|
140
|
+
async listBuilds(): Promise<any[]> {
|
|
141
|
+
const response = await this.request('GET', '/builds');
|
|
142
|
+
const data = await response.json();
|
|
143
|
+
return data.builds ?? data ?? [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Start a build for the given platform. */
|
|
147
|
+
async startBuild(platform: string): Promise<any> {
|
|
148
|
+
const response = await fetch(`${this.baseUrl}/builds`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {
|
|
151
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
152
|
+
'Content-Type': 'application/json',
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify({ platform }),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
const text = await response.text().catch(() => '');
|
|
159
|
+
throw new Error(`[P2PClient] Start build failed (${response.status}): ${text}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return response.json();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get voice capability info from the agent.
|
|
167
|
+
* voiceInputEnabled is always true — mobile can always record and send audio.
|
|
168
|
+
* s2sProvider/sttProvider indicate whether transcription is available.
|
|
169
|
+
*/
|
|
170
|
+
async voiceStatus(): Promise<VoiceCapability> {
|
|
171
|
+
const response = await this.request('GET', '/voice/status');
|
|
172
|
+
const data = await response.json();
|
|
173
|
+
return {
|
|
174
|
+
voiceInputEnabled: data.voiceInputEnabled ?? true,
|
|
175
|
+
s2sProvider: data.s2sProvider ?? undefined,
|
|
176
|
+
s2sReady: data.s2sReady ?? false,
|
|
177
|
+
sttProvider: data.sttProvider ?? undefined,
|
|
178
|
+
sttReady: data.sttReady ?? false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Send voice audio to the agent for transcription.
|
|
184
|
+
* Works with any configured STT or S2S provider on the agent.
|
|
185
|
+
* If no provider is configured, audio is saved for manual review.
|
|
186
|
+
* @returns Transcribed text (or empty string if no provider available).
|
|
187
|
+
*/
|
|
188
|
+
async transcribeVoice(audioUri: string): Promise<{ text: string; provider: string; audioFile?: string }> {
|
|
189
|
+
const formData = new FormData();
|
|
190
|
+
formData.append('audio', {
|
|
191
|
+
uri: Platform.OS === 'android' ? `file://${audioUri}` : audioUri,
|
|
192
|
+
type: 'audio/wav',
|
|
193
|
+
name: 'voice_input.wav',
|
|
194
|
+
} as any);
|
|
195
|
+
|
|
196
|
+
const response = await fetch(`${this.baseUrl}/voice/transcribe`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: {
|
|
199
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
200
|
+
},
|
|
201
|
+
body: formData,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const text = await response.text().catch(() => '');
|
|
206
|
+
throw new Error(`[P2PClient] Voice transcribe failed (${response.status}): ${text}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const result = await response.json();
|
|
210
|
+
return {
|
|
211
|
+
text: result.text ?? '',
|
|
212
|
+
provider: result.provider ?? 'none',
|
|
213
|
+
audioFile: result.audioFile,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Get the download URL for a build artifact. */
|
|
218
|
+
getArtifactUrl(buildId: string): string {
|
|
219
|
+
return `${this.baseUrl}/builds/${buildId}/artifact`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Start an autonomous test session.
|
|
224
|
+
* The agent reads the codebase for context, then navigates the app
|
|
225
|
+
* on the connected device/emulator, catches exceptions via BlackBox,
|
|
226
|
+
* writes fixes, and hot reloads — all without committing.
|
|
227
|
+
*/
|
|
228
|
+
async startTestSession(): Promise<{ sessionId: string }> {
|
|
229
|
+
const response = await fetch(`${this.baseUrl}/test-app/start`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: {
|
|
232
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
233
|
+
'Content-Type': 'application/json',
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify({ source: 'feedback-sdk' }),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
const text = await response.text().catch(() => '');
|
|
240
|
+
throw new Error(`[P2PClient] Start test session failed (${response.status}): ${text}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return response.json();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Stop a running test session. */
|
|
247
|
+
async stopTestSession(): Promise<void> {
|
|
248
|
+
await fetch(`${this.baseUrl}/test-app/stop`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { Authorization: `Bearer ${this.authToken}` },
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Get the current test session status and list of fixes. */
|
|
255
|
+
async getTestSession(): Promise<TestSession> {
|
|
256
|
+
const response = await this.request('GET', '/test-app/status');
|
|
257
|
+
return response.json();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Rotate the SDK token. The old token stays valid for 5 minutes (grace period).
|
|
262
|
+
* After rotation, the client automatically uses the new token.
|
|
263
|
+
* @returns The new token and its expiry time.
|
|
264
|
+
*/
|
|
265
|
+
async rotateToken(): Promise<{ token: string; expiresAt: number }> {
|
|
266
|
+
const response = await fetch(`${this.baseUrl}/sdk/token/rotate`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: {
|
|
269
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
270
|
+
'Content-Type': 'application/json',
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
const text = await response.text().catch(() => '');
|
|
276
|
+
throw new Error(`[P2PClient] Token rotation failed (${response.status}): ${text}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const result = await response.json();
|
|
280
|
+
// Auto-update to new token
|
|
281
|
+
this.authToken = result.token;
|
|
282
|
+
return { token: result.token, expiresAt: result.expiresAt };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Internal helper for authenticated GET/POST requests. */
|
|
286
|
+
private async request(method: string, path: string): Promise<Response> {
|
|
287
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
288
|
+
method,
|
|
289
|
+
headers: {
|
|
290
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (!response.ok) {
|
|
295
|
+
const text = await response.text().catch(() => '');
|
|
296
|
+
throw new Error(
|
|
297
|
+
`[P2PClient] ${method} ${path} failed (${response.status}): ${text}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return response;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DeviceEventEmitter, Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
const SHAKE_THRESHOLD = 1.5; // g-force threshold
|
|
4
|
+
const SHAKE_TIMEOUT_MS = 1000; // minimum time between shakes
|
|
5
|
+
const SHAKE_REQUIRED_EVENTS = 3; // number of threshold-exceeding events to trigger
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detects device shake gestures.
|
|
9
|
+
*
|
|
10
|
+
* On iOS, listens to the native 'shakeEvent' emitted by RCTDeviceEventEmitter.
|
|
11
|
+
* On Android, uses accelerometer data with threshold-based detection as a fallback.
|
|
12
|
+
*/
|
|
13
|
+
export class ShakeDetector {
|
|
14
|
+
private subscription: any = null;
|
|
15
|
+
private lastShakeTime = 0;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Start listening for shake gestures.
|
|
19
|
+
* @param onShake - Callback invoked when a shake is detected.
|
|
20
|
+
*/
|
|
21
|
+
start(onShake: () => void): void {
|
|
22
|
+
this.stop();
|
|
23
|
+
|
|
24
|
+
// React Native emits a 'shakeEvent' on iOS when the device is shaken
|
|
25
|
+
// (available via DeviceEventEmitter in debug builds)
|
|
26
|
+
if (Platform.OS === 'ios') {
|
|
27
|
+
this.subscription = DeviceEventEmitter.addListener('shakeEvent', () => {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (now - this.lastShakeTime > SHAKE_TIMEOUT_MS) {
|
|
30
|
+
this.lastShakeTime = now;
|
|
31
|
+
onShake();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Android: listen for accelerometer-based shake detection
|
|
38
|
+
// Uses the same DeviceEventEmitter pattern — if a native module or
|
|
39
|
+
// react-native-shake is installed, it will emit 'ShakeEvent'.
|
|
40
|
+
// Otherwise this is a no-op (manual trigger or floating button can be used).
|
|
41
|
+
this.subscription = DeviceEventEmitter.addListener('ShakeEvent', () => {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
if (now - this.lastShakeTime > SHAKE_TIMEOUT_MS) {
|
|
44
|
+
this.lastShakeTime = now;
|
|
45
|
+
onShake();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Stop listening for shake gestures. */
|
|
51
|
+
stop(): void {
|
|
52
|
+
if (this.subscription) {
|
|
53
|
+
this.subscription.remove();
|
|
54
|
+
this.subscription = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { FeedbackConfig, CapturedError } from './types';
|
|
2
|
+
import { YaverDiscovery } from './Discovery';
|
|
3
|
+
import { BlackBox } from './BlackBox';
|
|
4
|
+
import { P2PClient } from './P2PClient';
|
|
5
|
+
import { ShakeDetector } from './ShakeDetector';
|
|
6
|
+
|
|
7
|
+
let config: FeedbackConfig | null = null;
|
|
8
|
+
let enabled = false;
|
|
9
|
+
let p2pClient: P2PClient | null = null;
|
|
10
|
+
let shakeDetector: ShakeDetector | null = null;
|
|
11
|
+
|
|
12
|
+
/** Ring buffer of captured errors. */
|
|
13
|
+
let errorBuffer: CapturedError[] = [];
|
|
14
|
+
let maxErrors = 5;
|
|
15
|
+
|
|
16
|
+
/** Track whether BlackBox was running before disable (to restart on enable). */
|
|
17
|
+
let blackBoxWasStreaming = false;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Main entry point for the Yaver Feedback SDK.
|
|
21
|
+
* Call `YaverFeedback.init()` once at app startup.
|
|
22
|
+
*/
|
|
23
|
+
export class YaverFeedback {
|
|
24
|
+
/**
|
|
25
|
+
* Initialize the feedback SDK with the given configuration.
|
|
26
|
+
* Typically called in your app's root component or entry file.
|
|
27
|
+
*
|
|
28
|
+
* If no `agentUrl` is provided, the SDK will attempt auto-discovery
|
|
29
|
+
* via `YaverDiscovery` on the first `startReport()` call.
|
|
30
|
+
*/
|
|
31
|
+
static init(cfg: FeedbackConfig): void {
|
|
32
|
+
config = {
|
|
33
|
+
trigger: 'shake',
|
|
34
|
+
maxRecordingDuration: 120,
|
|
35
|
+
feedbackMode: 'batch',
|
|
36
|
+
agentCommentaryLevel: 0,
|
|
37
|
+
...cfg,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Default: enabled only in dev mode
|
|
41
|
+
if (cfg.enabled !== undefined) {
|
|
42
|
+
enabled = cfg.enabled;
|
|
43
|
+
} else {
|
|
44
|
+
enabled = typeof __DEV__ !== 'undefined' ? __DEV__ : false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Create P2P client if we have a URL
|
|
48
|
+
if (config.agentUrl) {
|
|
49
|
+
p2pClient = new P2PClient(config.agentUrl, config.authToken);
|
|
50
|
+
} else {
|
|
51
|
+
p2pClient = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Set up error capture buffer size
|
|
55
|
+
maxErrors = cfg.maxCapturedErrors ?? 5;
|
|
56
|
+
errorBuffer = [];
|
|
57
|
+
|
|
58
|
+
// Wire up shake detection when trigger is 'shake'
|
|
59
|
+
if (shakeDetector) {
|
|
60
|
+
shakeDetector.stop();
|
|
61
|
+
shakeDetector = null;
|
|
62
|
+
}
|
|
63
|
+
if (enabled && config.trigger === 'shake') {
|
|
64
|
+
shakeDetector = new ShakeDetector();
|
|
65
|
+
shakeDetector.start(() => {
|
|
66
|
+
if (config?.reportingOnly) {
|
|
67
|
+
YaverFeedback.sendAutoReport();
|
|
68
|
+
} else {
|
|
69
|
+
YaverFeedback.startReport();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// NOTE: We intentionally do NOT hook ErrorUtils.setGlobalHandler().
|
|
75
|
+
// Sentry, Crashlytics, Bugsnag, and other tools all compete for that
|
|
76
|
+
// single slot. Hijacking it would break whichever tool the developer
|
|
77
|
+
// already has installed, depending on init order.
|
|
78
|
+
//
|
|
79
|
+
// Instead, developers use:
|
|
80
|
+
// - YaverFeedback.attachError(err) in catch blocks
|
|
81
|
+
// - YaverFeedback.wrapErrorHandler(existingHandler) to create a
|
|
82
|
+
// pass-through wrapper they insert into their own error chain
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Manually trigger the feedback collection flow.
|
|
87
|
+
* Opens the feedback modal if the SDK is initialized and enabled.
|
|
88
|
+
*
|
|
89
|
+
* If no agentUrl was configured, runs auto-discovery first.
|
|
90
|
+
*/
|
|
91
|
+
static async startReport(): Promise<void> {
|
|
92
|
+
if (!config) {
|
|
93
|
+
console.warn('[YaverFeedback] SDK not initialized. Call YaverFeedback.init() first.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!enabled) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Auto-discover if no agent URL was provided
|
|
101
|
+
if (!config.agentUrl) {
|
|
102
|
+
try {
|
|
103
|
+
const result = await YaverDiscovery.discover({
|
|
104
|
+
convexUrl: config.convexUrl,
|
|
105
|
+
authToken: config.authToken,
|
|
106
|
+
preferredDeviceId: config.preferredDeviceId,
|
|
107
|
+
});
|
|
108
|
+
if (result) {
|
|
109
|
+
config.agentUrl = result.url;
|
|
110
|
+
p2pClient = new P2PClient(result.url, config.authToken);
|
|
111
|
+
} else {
|
|
112
|
+
console.warn('[YaverFeedback] No agent found. Set agentUrl, convexUrl, or ensure agent is running on the network.');
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn('[YaverFeedback] Auto-discovery failed:', err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Emit event that the FeedbackModal listens for
|
|
120
|
+
const { DeviceEventEmitter } = require('react-native');
|
|
121
|
+
DeviceEventEmitter.emit('yaverFeedback:startReport');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Returns true if the SDK has been initialized. */
|
|
125
|
+
static isInitialized(): boolean {
|
|
126
|
+
return config !== null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Enable or disable the entire feedback SDK at runtime.
|
|
131
|
+
*
|
|
132
|
+
* **Disable (false):**
|
|
133
|
+
* - Stops BlackBox streaming (flushes remaining events first)
|
|
134
|
+
* - Restores console.log/warn/error if wrapped
|
|
135
|
+
* - Clears error buffer
|
|
136
|
+
* - All methods become no-ops (attachError, wrapErrorHandler still safe to call but do nothing)
|
|
137
|
+
* - P2P client is kept alive (no reconnection cost on re-enable)
|
|
138
|
+
*
|
|
139
|
+
* **Enable (true):**
|
|
140
|
+
* - Restarts BlackBox streaming if it was running before disable
|
|
141
|
+
* - Error buffer starts collecting again
|
|
142
|
+
* - All methods become active
|
|
143
|
+
*/
|
|
144
|
+
static setEnabled(value: boolean): void {
|
|
145
|
+
if (enabled === value) return; // No-op if already in desired state
|
|
146
|
+
|
|
147
|
+
if (!value) {
|
|
148
|
+
// === DISABLE ===
|
|
149
|
+
blackBoxWasStreaming = BlackBox.isStreaming;
|
|
150
|
+
if (BlackBox.isStreaming) {
|
|
151
|
+
BlackBox.stop(); // flush + stop timer + unwrap console
|
|
152
|
+
}
|
|
153
|
+
BlackBox.unwrapConsole(); // ensure console is restored even if BlackBox wasn't started
|
|
154
|
+
errorBuffer = [];
|
|
155
|
+
if (shakeDetector) {
|
|
156
|
+
shakeDetector.stop();
|
|
157
|
+
shakeDetector = null;
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// === ENABLE ===
|
|
161
|
+
if (blackBoxWasStreaming) {
|
|
162
|
+
BlackBox.start(); // restart with previous config
|
|
163
|
+
}
|
|
164
|
+
// Restart shake detector if trigger is 'shake'
|
|
165
|
+
if (config?.trigger === 'shake' && !shakeDetector) {
|
|
166
|
+
shakeDetector = new ShakeDetector();
|
|
167
|
+
shakeDetector.start(() => {
|
|
168
|
+
if (config?.reportingOnly) {
|
|
169
|
+
YaverFeedback.sendAutoReport();
|
|
170
|
+
} else {
|
|
171
|
+
YaverFeedback.startReport();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
enabled = value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Returns whether the SDK is currently enabled. */
|
|
181
|
+
static isEnabled(): boolean {
|
|
182
|
+
return enabled;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Returns the current config, or null if not initialized. */
|
|
186
|
+
static getConfig(): FeedbackConfig | null {
|
|
187
|
+
return config;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Manually attach an error with optional metadata.
|
|
192
|
+
* Use this in catch blocks to give the agent extra context.
|
|
193
|
+
*/
|
|
194
|
+
static attachError(error: Error, metadata?: Record<string, unknown>): void {
|
|
195
|
+
if (!enabled) return; // No-op when disabled
|
|
196
|
+
const captured: CapturedError = {
|
|
197
|
+
message: error.message,
|
|
198
|
+
stack: (error.stack ?? '').split('\n').filter((l: string) => l.trim()),
|
|
199
|
+
isFatal: false,
|
|
200
|
+
timestamp: Date.now(),
|
|
201
|
+
metadata,
|
|
202
|
+
};
|
|
203
|
+
errorBuffer.push(captured);
|
|
204
|
+
if (errorBuffer.length > maxErrors) {
|
|
205
|
+
errorBuffer.shift();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Returns the current captured errors buffer.
|
|
211
|
+
* Called internally when building a FeedbackBundle.
|
|
212
|
+
*/
|
|
213
|
+
static getCapturedErrors(): CapturedError[] {
|
|
214
|
+
return [...errorBuffer];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Clears the captured errors buffer. */
|
|
218
|
+
static clearCapturedErrors(): void {
|
|
219
|
+
errorBuffer = [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Returns a pass-through error handler that records the error in Yaver's
|
|
224
|
+
* buffer and then calls `next`. Use this to insert Yaver into your
|
|
225
|
+
* existing error handler chain without replacing anything.
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* // Works alongside Sentry, Crashlytics, or any other tool:
|
|
229
|
+
* const originalHandler = ErrorUtils.getGlobalHandler();
|
|
230
|
+
* ErrorUtils.setGlobalHandler(
|
|
231
|
+
* YaverFeedback.wrapErrorHandler(originalHandler)
|
|
232
|
+
* );
|
|
233
|
+
* // Sentry can still be initialized after this — it will wrap our
|
|
234
|
+
* // wrapper, and the chain stays intact.
|
|
235
|
+
*/
|
|
236
|
+
static wrapErrorHandler(
|
|
237
|
+
next?: ((error: Error, isFatal?: boolean) => void) | null,
|
|
238
|
+
): (error: Error, isFatal?: boolean) => void {
|
|
239
|
+
return (error: Error, isFatal?: boolean) => {
|
|
240
|
+
YaverFeedback.attachError(error);
|
|
241
|
+
if (errorBuffer.length > 0) {
|
|
242
|
+
errorBuffer[errorBuffer.length - 1].isFatal = isFatal ?? false;
|
|
243
|
+
}
|
|
244
|
+
next?.(error, isFatal);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Returns the P2P client instance.
|
|
250
|
+
* Available after init if agentUrl is set, or after first successful discovery.
|
|
251
|
+
*/
|
|
252
|
+
static getP2PClient(): P2PClient | null {
|
|
253
|
+
return p2pClient;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Returns the current feedback mode. */
|
|
257
|
+
static getFeedbackMode(): 'live' | 'narrated' | 'batch' {
|
|
258
|
+
return config?.feedbackMode ?? 'batch';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Returns the agent commentary level (0-10). */
|
|
262
|
+
static getCommentaryLevel(): number {
|
|
263
|
+
return config?.agentCommentaryLevel ?? 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Reporting-only mode: auto-capture screenshot + errors and send
|
|
268
|
+
* to the agent's /feedback endpoint. No modal UI — just shake and go.
|
|
269
|
+
*
|
|
270
|
+
* This is triggered by shake when `reportingOnly: true` is set.
|
|
271
|
+
* The agent receives the report via the same P2P channel and logs it.
|
|
272
|
+
*/
|
|
273
|
+
static async sendAutoReport(): Promise<void> {
|
|
274
|
+
if (!config || !enabled) return;
|
|
275
|
+
|
|
276
|
+
// Resolve agent URL if needed
|
|
277
|
+
if (!config.agentUrl) {
|
|
278
|
+
try {
|
|
279
|
+
const result = await YaverDiscovery.discover({
|
|
280
|
+
convexUrl: config.convexUrl,
|
|
281
|
+
authToken: config.authToken,
|
|
282
|
+
preferredDeviceId: config.preferredDeviceId,
|
|
283
|
+
});
|
|
284
|
+
if (result) {
|
|
285
|
+
config.agentUrl = result.url;
|
|
286
|
+
p2pClient = new P2PClient(result.url, config.authToken);
|
|
287
|
+
}
|
|
288
|
+
} catch {}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!config.agentUrl) {
|
|
292
|
+
console.warn('[YaverFeedback] No agent URL — cannot send auto report.');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const { Platform, Dimensions } = require('react-native');
|
|
298
|
+
const { captureScreenshot } = require('./capture');
|
|
299
|
+
const { uploadFeedback } = require('./upload');
|
|
300
|
+
const { width, height } = Dimensions.get('window');
|
|
301
|
+
|
|
302
|
+
// Auto-capture screenshot
|
|
303
|
+
let screenshotPath: string | undefined;
|
|
304
|
+
try {
|
|
305
|
+
screenshotPath = await captureScreenshot();
|
|
306
|
+
} catch {
|
|
307
|
+
// Screenshot capture may fail (e.g. no view ref) — continue without it
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const bundle = {
|
|
311
|
+
metadata: {
|
|
312
|
+
timestamp: new Date().toISOString(),
|
|
313
|
+
device: {
|
|
314
|
+
platform: Platform.OS,
|
|
315
|
+
osVersion: String(Platform.Version),
|
|
316
|
+
model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
|
|
317
|
+
screenWidth: width,
|
|
318
|
+
screenHeight: height,
|
|
319
|
+
},
|
|
320
|
+
app: {},
|
|
321
|
+
userNote: '[Auto-report via shake]',
|
|
322
|
+
},
|
|
323
|
+
screenshots: screenshotPath ? [screenshotPath] : [],
|
|
324
|
+
errors: errorBuffer.length > 0 ? [...errorBuffer] : undefined,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
await uploadFeedback(config.agentUrl, config.authToken, bundle);
|
|
328
|
+
console.log('[YaverFeedback] Auto-report sent');
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.warn('[YaverFeedback] Auto-report failed:', err);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Tear down the SDK (stop shake detector, clear state). */
|
|
335
|
+
static destroy(): void {
|
|
336
|
+
if (shakeDetector) {
|
|
337
|
+
shakeDetector.stop();
|
|
338
|
+
shakeDetector = null;
|
|
339
|
+
}
|
|
340
|
+
enabled = false;
|
|
341
|
+
config = null;
|
|
342
|
+
p2pClient = null;
|
|
343
|
+
errorBuffer = [];
|
|
344
|
+
}
|
|
345
|
+
}
|