yaver-feedback-react-native 0.8.13 → 0.9.2
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 +126 -9
- package/dist/BlackBox.js +9 -1
- package/dist/DeployPanel.js +65 -0
- package/dist/Discovery.js +13 -2
- package/dist/FeedbackModal.js +477 -66
- package/dist/MachinePickerScreen.js +14 -5
- package/dist/P2PClient.d.ts +94 -1
- package/dist/P2PClient.js +281 -2
- package/dist/ShakeDetector.js +2 -0
- package/dist/VibeChatScreen.d.ts +6 -1
- package/dist/VibeChatScreen.js +204 -1
- package/dist/YaverFeedback.d.ts +98 -0
- package/dist/YaverFeedback.js +509 -46
- package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
- package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
- package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
- package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
- package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
- package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
- package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
- package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
- package/dist/__tests__/P2PClient.test.js +2 -2
- package/dist/__tests__/ReportIdentity.test.d.ts +1 -0
- package/dist/__tests__/ReportIdentity.test.js +168 -0
- package/dist/__tests__/SDKToken.test.js +1 -1
- package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
- package/dist/__tests__/ShakeToggle.test.js +121 -0
- package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
- package/dist/__tests__/pickTargetDevice.test.js +89 -0
- package/dist/__tests__/reloadActions.test.d.ts +1 -0
- package/dist/__tests__/reloadActions.test.js +129 -0
- package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
- package/dist/__tests__/reloadActionsParity.test.js +42 -0
- package/dist/__tests__/types.test.js +5 -5
- package/dist/_core/device.d.ts +19 -9
- package/dist/_core/device.js +20 -14
- package/dist/capture.d.ts +6 -0
- package/dist/capture.js +53 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -1
- package/dist/reloadActions.d.ts +88 -0
- package/dist/reloadActions.js +200 -0
- package/dist/storeShots.d.ts +67 -0
- package/dist/storeShots.js +137 -0
- package/dist/types.d.ts +215 -3
- package/dist/voice.d.ts +61 -0
- package/dist/voice.js +246 -0
- package/package.json +14 -3
- package/src/BlackBox.ts +10 -1
- package/src/DeployPanel.tsx +74 -0
- package/src/Discovery.ts +13 -2
- package/src/FeedbackModal.tsx +563 -87
- package/src/MachinePickerScreen.tsx +12 -3
- package/src/P2PClient.ts +314 -2
- package/src/ShakeDetector.ts +1 -0
- package/src/VibeChatScreen.tsx +219 -0
- package/src/YaverFeedback.ts +498 -46
- package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
- package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
- package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
- package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
- package/src/__tests__/P2PClient.test.ts +2 -2
- package/src/__tests__/ReportIdentity.test.ts +203 -0
- package/src/__tests__/SDKToken.test.ts +1 -1
- package/src/__tests__/ShakeToggle.test.ts +153 -0
- package/src/__tests__/pickTargetDevice.test.ts +101 -0
- package/src/__tests__/reloadActions.test.ts +171 -0
- package/src/__tests__/reloadActionsParity.test.ts +49 -0
- package/src/__tests__/types.test.ts +5 -5
- package/src/_core/device.ts +20 -14
- package/src/capture.ts +51 -0
- package/src/index.ts +21 -0
- package/src/reloadActions.ts +273 -0
- package/src/storeShots.ts +189 -0
- package/src/types.ts +217 -3
- package/src/voice.ts +270 -0
|
@@ -51,7 +51,7 @@ describe('React Native SDK types', () => {
|
|
|
51
51
|
const bundle: FeedbackBundle = {
|
|
52
52
|
metadata: {
|
|
53
53
|
timestamp: '2026-03-24T12:00:00Z',
|
|
54
|
-
|
|
54
|
+
deviceInfo: {
|
|
55
55
|
platform: 'ios',
|
|
56
56
|
osVersion: '18.0',
|
|
57
57
|
model: 'iPhone 16 Pro',
|
|
@@ -68,7 +68,7 @@ describe('React Native SDK types', () => {
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
expect(bundle.metadata.timestamp).toBe('2026-03-24T12:00:00Z');
|
|
71
|
-
expect(bundle.metadata.
|
|
71
|
+
expect(bundle.metadata.deviceInfo.platform).toBe('ios');
|
|
72
72
|
expect(bundle.screenshots).toEqual([]);
|
|
73
73
|
expect(bundle.video).toBeUndefined();
|
|
74
74
|
});
|
|
@@ -77,7 +77,7 @@ describe('React Native SDK types', () => {
|
|
|
77
77
|
const bundle: FeedbackBundle = {
|
|
78
78
|
metadata: {
|
|
79
79
|
timestamp: '2026-03-24T12:00:00Z',
|
|
80
|
-
|
|
80
|
+
deviceInfo: {
|
|
81
81
|
platform: 'android',
|
|
82
82
|
osVersion: '15',
|
|
83
83
|
model: 'Pixel 9',
|
|
@@ -168,7 +168,7 @@ describe('React Native SDK types', () => {
|
|
|
168
168
|
bundle: {
|
|
169
169
|
metadata: {
|
|
170
170
|
timestamp: 'now',
|
|
171
|
-
|
|
171
|
+
deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
|
|
172
172
|
app: {},
|
|
173
173
|
},
|
|
174
174
|
screenshots: [],
|
|
@@ -187,7 +187,7 @@ describe('React Native SDK types', () => {
|
|
|
187
187
|
bundle: {
|
|
188
188
|
metadata: {
|
|
189
189
|
timestamp: 'now',
|
|
190
|
-
|
|
190
|
+
deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
|
|
191
191
|
app: {},
|
|
192
192
|
},
|
|
193
193
|
screenshots: [],
|
package/src/_core/device.ts
CHANGED
|
@@ -212,10 +212,11 @@ export function collapseDevices(devices: CoreDevice[]): CoreDevice[] {
|
|
|
212
212
|
// ── Freshness + target pick ───────────────────────────────────────────
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
* "Fresh" matches the mobile app: online + heartbeat
|
|
216
|
-
* read Convex's `isOnline` first (backend
|
|
217
|
-
* gate from the server clock), then use this helper when
|
|
218
|
-
* phone-side freshness opinion too — e.g. for auto-connect
|
|
215
|
+
* "Fresh" matches the mobile app: online + heartbeat within
|
|
216
|
+
* HEARTBEAT_STALE_MS. Clients read Convex's `isOnline` first (the backend
|
|
217
|
+
* applies the same gate from the server clock), then use this helper when
|
|
218
|
+
* they need the phone-side freshness opinion too — e.g. for auto-connect
|
|
219
|
+
* picks.
|
|
219
220
|
*/
|
|
220
221
|
export function isDeviceFresh(d: CoreDevice, now = Date.now()): boolean {
|
|
221
222
|
if (!d.isOnline) return false;
|
|
@@ -224,11 +225,20 @@ export function isDeviceFresh(d: CoreDevice, now = Date.now()): boolean {
|
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
/**
|
|
227
|
-
* Choose the best candidate for an auto-connect attempt.
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
228
|
+
* Choose the best candidate for an auto-connect attempt.
|
|
229
|
+
*
|
|
230
|
+
* An explicit `preferredDeviceId` is honoured by id ALONE, or not at all:
|
|
231
|
+
* - A missing `quicHost` is not grounds to reroute. Relay transport
|
|
232
|
+
* addresses a device by id (`<relay>/d/<deviceId>`), so the entry is
|
|
233
|
+
* still reachable off-LAN — which is precisely when quicHost is absent.
|
|
234
|
+
* - If the id is not in the list, return null. Falling through to another
|
|
235
|
+
* machine silently lands the user's fix on the wrong host: they pick the
|
|
236
|
+
* Mac mini, the commit shows up on the laptop, and nothing reports an
|
|
237
|
+
* error. Not connecting is the better failure — the caller surfaces
|
|
238
|
+
* "selected machine missing, re-select it".
|
|
239
|
+
*
|
|
240
|
+
* With no preference, fall back: fresh + quicHost → online + quicHost →
|
|
241
|
+
* first with a quicHost.
|
|
232
242
|
*/
|
|
233
243
|
export function pickTargetDevice(
|
|
234
244
|
devices: CoreDevice[],
|
|
@@ -236,11 +246,7 @@ export function pickTargetDevice(
|
|
|
236
246
|
): CoreDevice | null {
|
|
237
247
|
if (!devices.length) return null;
|
|
238
248
|
if (preferredDeviceId) {
|
|
239
|
-
|
|
240
|
-
(d) => d.deviceId === preferredDeviceId && d.quicHost,
|
|
241
|
-
);
|
|
242
|
-
if (preferred && isDeviceFresh(preferred)) return preferred;
|
|
243
|
-
if (preferred) return preferred;
|
|
249
|
+
return devices.find((d) => d.deviceId === preferredDeviceId) ?? null;
|
|
244
250
|
}
|
|
245
251
|
const fresh = devices.find((d) => isDeviceFresh(d) && d.quicHost);
|
|
246
252
|
if (fresh) return fresh;
|
package/src/capture.ts
CHANGED
|
@@ -336,3 +336,54 @@ export async function stopAudioRecording(): Promise<{ path: string; duration: nu
|
|
|
336
336
|
export function isAudioRecording(): boolean {
|
|
337
337
|
return audioRecorderActive;
|
|
338
338
|
}
|
|
339
|
+
|
|
340
|
+
// ── Voice-stream recording (raw LPCM WAV) ──────────────────────────────
|
|
341
|
+
// The voice vibe-coding path streams audio to the agent's STT WS, which
|
|
342
|
+
// expects raw 16-bit / 16 kHz mono PCM (we strip the WAV header on the
|
|
343
|
+
// way out). That's a different format from the HIGH_QUALITY m4a recorder
|
|
344
|
+
// above — a compressed .m4a can't be streamed to Deepgram/whisper — so
|
|
345
|
+
// this uses its own recording options, mirroring the Yaver app's
|
|
346
|
+
// AgentVoiceButton.
|
|
347
|
+
|
|
348
|
+
let pcmRecorderRef: any = null;
|
|
349
|
+
let pcmRecorderActive = false;
|
|
350
|
+
|
|
351
|
+
// Raw LPCM 16-bit LE, 16 kHz mono. iOS uses lpcm; Android records WAV.
|
|
352
|
+
const PCM_RECORDING_OPTIONS: any = {
|
|
353
|
+
android: { extension: '.wav', outputFormat: 2, audioEncoder: 3, sampleRate: 16000, numberOfChannels: 1, bitRate: 256000 },
|
|
354
|
+
ios: {
|
|
355
|
+
extension: '.wav', outputFormat: 'lpcm', audioQuality: 0x40, sampleRate: 16000,
|
|
356
|
+
numberOfChannels: 1, bitRate: 256000, linearPCMBitDepth: 16, linearPCMIsBigEndian: false, linearPCMIsFloat: false,
|
|
357
|
+
},
|
|
358
|
+
web: { mimeType: 'audio/wav', bitsPerSecond: 256000 },
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/** Begin a raw-PCM recording for the voice stream. */
|
|
362
|
+
export async function startPcmRecording(): Promise<void> {
|
|
363
|
+
if (pcmRecorderActive) throw new Error('[YaverFeedback] A voice recording is already in progress.');
|
|
364
|
+
const ExpoAv = loadExpoAvOrThrow();
|
|
365
|
+
const { Audio } = ExpoAv;
|
|
366
|
+
const perm = await Audio.requestPermissionsAsync();
|
|
367
|
+
if (!perm.granted) {
|
|
368
|
+
throw new Error('[YaverFeedback] Microphone permission denied. Enable it in Settings ▸ Your App ▸ Microphone.');
|
|
369
|
+
}
|
|
370
|
+
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, staysActiveInBackground: false });
|
|
371
|
+
const { recording } = await Audio.Recording.createAsync(PCM_RECORDING_OPTIONS);
|
|
372
|
+
pcmRecorderRef = recording;
|
|
373
|
+
pcmRecorderActive = true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Stop the voice recording; returns the WAV file:// URI (or null). */
|
|
377
|
+
export async function stopPcmRecording(): Promise<string | null> {
|
|
378
|
+
if (!pcmRecorderActive || !pcmRecorderRef) return null;
|
|
379
|
+
const recording = pcmRecorderRef;
|
|
380
|
+
pcmRecorderRef = null;
|
|
381
|
+
pcmRecorderActive = false;
|
|
382
|
+
try { await recording.stopAndUnloadAsync(); } catch { /* already stopped */ }
|
|
383
|
+
return typeof recording.getURI === 'function' ? recording.getURI() : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Whether a voice-stream recording is currently active. */
|
|
387
|
+
export function isPcmRecording(): boolean {
|
|
388
|
+
return pcmRecorderActive;
|
|
389
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -29,12 +29,33 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
export { YaverFeedback } from './YaverFeedback';
|
|
32
|
+
export { captureStoreScreenshots } from './storeShots';
|
|
33
|
+
export type {
|
|
34
|
+
CaptureStoreScreenshotsOptions,
|
|
35
|
+
CaptureStoreScreenshotsResult,
|
|
36
|
+
StoreShotFrame,
|
|
37
|
+
} from './storeShots';
|
|
32
38
|
export { BlackBox } from './BlackBox';
|
|
33
39
|
export { YaverUpdates } from './YaverUpdates';
|
|
34
40
|
export type { YaverUpdatesConfig, PendingUpdate } from './YaverUpdates';
|
|
35
41
|
export { initExpo } from './expo';
|
|
36
42
|
export { YaverDiscovery } from './Discovery';
|
|
37
43
|
export { P2PClient } from './P2PClient';
|
|
44
|
+
export {
|
|
45
|
+
reloadActions,
|
|
46
|
+
reloadRequest,
|
|
47
|
+
reloadFrameworkFamily,
|
|
48
|
+
describeReloadFailure,
|
|
49
|
+
RELOAD_PATH,
|
|
50
|
+
RELOAD_APP_PATH,
|
|
51
|
+
} from './reloadActions';
|
|
52
|
+
export type {
|
|
53
|
+
ReloadAction,
|
|
54
|
+
ReloadActionId,
|
|
55
|
+
ReloadActionsOptions,
|
|
56
|
+
ReloadWireMode,
|
|
57
|
+
DevServerSnapshot,
|
|
58
|
+
} from './reloadActions';
|
|
38
59
|
export { YaverConnectionScreen } from './ConnectionScreen';
|
|
39
60
|
export { YaverLoginScreen } from './LoginScreen';
|
|
40
61
|
export type { YaverLoginScreenProps } from './LoginScreen';
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// ─── Reload actions — the ONE decision seam every Yaver feedback SDK mirrors ──
|
|
2
|
+
//
|
|
3
|
+
// The in-app overlay offers the developer a way to reload the app they are
|
|
4
|
+
// looking at, without leaving it. This file answers the three questions that
|
|
5
|
+
// have to be answered IDENTICALLY on every stack, so that a bug fixed in one
|
|
6
|
+
// SDK is not still shipping in the other five:
|
|
7
|
+
//
|
|
8
|
+
// 1. WHICH actions may be shown at all (production build ⇒ none, ever)?
|
|
9
|
+
// 2. WHICH request does each action make (path + body)?
|
|
10
|
+
// 3. WHEN a reload fails, WHAT do we tell the user?
|
|
11
|
+
//
|
|
12
|
+
// It is deliberately PURE — no fetch, no DOM, no globals. That is what makes
|
|
13
|
+
// it unit-testable, and the unit test is the guard: a production build must
|
|
14
|
+
// yield an empty action list. Break `isDevBuild` and the test fails.
|
|
15
|
+
//
|
|
16
|
+
// ── Wire contract (desktop/agent/devserver_http.go) ──────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// POST /dev/reload { "mode": "fast" | "full" }
|
|
19
|
+
// fast — the framework's cheapest refresh. Flutter stdin "r" (hot
|
|
20
|
+
// reload, keeps state). Metro/Expo fast refresh. Vite/Next HMR.
|
|
21
|
+
// full — framework-level restart. Flutter stdin "R" (hot RESTART,
|
|
22
|
+
// resets state). Web lane additionally forces a warm re-export.
|
|
23
|
+
// NEVER a cache clear or a process cold-start.
|
|
24
|
+
// Absent/unknown mode is normalised to "fast" by the agent, so an old
|
|
25
|
+
// client keeps its exact old behaviour.
|
|
26
|
+
//
|
|
27
|
+
// POST /dev/reload-app { "mode": "bundle", ...identity }
|
|
28
|
+
// Rebuild the Hermes bytecode bundle on the agent and push it over the
|
|
29
|
+
// BlackBox channel. React Native only, and the ONLY action that still
|
|
30
|
+
// works when no dev server is running — which is why it stays enabled
|
|
31
|
+
// in exactly that case.
|
|
32
|
+
//
|
|
33
|
+
// ── Auth ─────────────────────────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// None of this needs a new secret. `/dev/reload` and `/dev/reload-app` are
|
|
36
|
+
// registered under `authSDKOrGuest` (desktop/agent/httpserver.go), the same
|
|
37
|
+
// middleware that already admits the bearer this SDK sends with its feedback
|
|
38
|
+
// POST. A scope-limited SDK token needs the existing `guest-reload` scope,
|
|
39
|
+
// whose path list already contains both routes — no widening, no new gate.
|
|
40
|
+
|
|
41
|
+
/** Stable identifier for each action the overlay can render. */
|
|
42
|
+
export type ReloadActionId = 'hot' | 'full' | 'rebuild';
|
|
43
|
+
|
|
44
|
+
/** Wire value of the `mode` field (or `bundle` for /dev/reload-app). */
|
|
45
|
+
export type ReloadWireMode = 'fast' | 'full' | 'bundle';
|
|
46
|
+
|
|
47
|
+
/** The part of GET /dev/status this decision depends on. */
|
|
48
|
+
export interface DevServerSnapshot {
|
|
49
|
+
/** Is a dev server process alive on the machine? */
|
|
50
|
+
running: boolean;
|
|
51
|
+
/** Is it still compiling? A reload now would race the build. */
|
|
52
|
+
building?: boolean;
|
|
53
|
+
/** Agent's framework name: expo | react-native | flutter | vite | nextjs. */
|
|
54
|
+
framework?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ReloadActionsOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Is the HOST APP a development build?
|
|
60
|
+
*
|
|
61
|
+
* There is no honest default here, so there is no default: every stack has
|
|
62
|
+
* its own signal (`__DEV__`, `kDebugMode`, `Debug.isDebugBuild`,
|
|
63
|
+
* `FLAG_DEBUGGABLE`, `#if DEBUG`) and the caller passes it. False means the
|
|
64
|
+
* list is EMPTY — a shipped app never gets a reload button.
|
|
65
|
+
*/
|
|
66
|
+
isDevBuild: boolean;
|
|
67
|
+
/** Do we have an agent connection / selected machine at all? */
|
|
68
|
+
connected: boolean;
|
|
69
|
+
/** Human label for the machine, used inside the disabled reason. */
|
|
70
|
+
machineLabel?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Offer the React Native bundle rebuild as a third action. Only the RN SDK
|
|
73
|
+
* can act on the pushed bundle, so only the RN SDK sets this.
|
|
74
|
+
*/
|
|
75
|
+
includeRebuild?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ReloadAction {
|
|
79
|
+
id: ReloadActionId;
|
|
80
|
+
/** Button label — stack-idiomatic wording lives here, not at the call site. */
|
|
81
|
+
label: string;
|
|
82
|
+
/** One line under/next to the button explaining what it actually does. */
|
|
83
|
+
hint: string;
|
|
84
|
+
mode: ReloadWireMode;
|
|
85
|
+
/** Agent path this action POSTs to. */
|
|
86
|
+
path: string;
|
|
87
|
+
enabled: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Present exactly when `enabled` is false. Names the specific blocker and
|
|
90
|
+
* the fix — never "unavailable".
|
|
91
|
+
*/
|
|
92
|
+
disabledReason?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type FrameworkFamily = 'flutter' | 'react-native' | 'web' | 'unknown';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Map the agent's framework name onto the family whose reload vocabulary we
|
|
99
|
+
* borrow. Unknown frameworks still get generic actions: the agent is the
|
|
100
|
+
* authority on what it can do, and refusing to offer a reload because we did
|
|
101
|
+
* not recognise a name would be us inventing a limit the product does not have.
|
|
102
|
+
*/
|
|
103
|
+
export function reloadFrameworkFamily(framework?: string): FrameworkFamily {
|
|
104
|
+
const f = (framework || '').trim().toLowerCase();
|
|
105
|
+
if (!f) return 'unknown';
|
|
106
|
+
if (f.indexOf('flutter') >= 0) return 'flutter';
|
|
107
|
+
if (f === 'expo' || f.indexOf('react-native') >= 0 || f.indexOf('metro') >= 0) {
|
|
108
|
+
return 'react-native';
|
|
109
|
+
}
|
|
110
|
+
if (f === 'vite' || f === 'next' || f === 'nextjs' || f === 'web' || f === 'webpack') {
|
|
111
|
+
return 'web';
|
|
112
|
+
}
|
|
113
|
+
return 'unknown';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const LABELS: Record<FrameworkFamily, { hot: [string, string]; full: [string, string] }> = {
|
|
117
|
+
flutter: {
|
|
118
|
+
hot: ['Hot Reload', 'Flutter hot reload (r) — keeps the current app state.'],
|
|
119
|
+
full: ['Hot Restart', 'Flutter hot restart (R) — restarts the app and resets state.'],
|
|
120
|
+
},
|
|
121
|
+
'react-native': {
|
|
122
|
+
hot: ['Hot Reload', 'Fast Refresh through Metro — keeps component state.'],
|
|
123
|
+
full: ['Full Reload', 'Reloads the whole JS bundle and resets state.'],
|
|
124
|
+
},
|
|
125
|
+
web: {
|
|
126
|
+
hot: ['Hot Reload', 'Hot module replacement through the dev server.'],
|
|
127
|
+
full: ['Full Reload', 'Re-exports the bundle and reloads the page.'],
|
|
128
|
+
},
|
|
129
|
+
unknown: {
|
|
130
|
+
hot: ['Hot Reload', "The dev server's cheapest refresh."],
|
|
131
|
+
full: ['Full Reload', 'Framework-level restart of the running app.'],
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Path constants — mirrored from AGENT_ENDPOINTS so a typo cannot diverge. */
|
|
136
|
+
export const RELOAD_PATH = '/dev/reload';
|
|
137
|
+
export const RELOAD_APP_PATH = '/dev/reload-app';
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The whole decision, in one pure function.
|
|
141
|
+
*
|
|
142
|
+
* Returns the ordered list the overlay should render. An EMPTY list means
|
|
143
|
+
* "render no reload UI at all" — that is the production-build answer, and it
|
|
144
|
+
* is deliberately indistinguishable from "this SDK has no reload feature",
|
|
145
|
+
* because to a shipped app it doesn't.
|
|
146
|
+
*
|
|
147
|
+
* A NON-empty list may still contain disabled entries: showing a greyed
|
|
148
|
+
* "Hot Reload — no dev server is running on primary" teaches the user what
|
|
149
|
+
* to fix. Hiding it teaches them nothing.
|
|
150
|
+
*/
|
|
151
|
+
export function reloadActions(
|
|
152
|
+
snapshot: DevServerSnapshot | null | undefined,
|
|
153
|
+
opts: ReloadActionsOptions,
|
|
154
|
+
): ReloadAction[] {
|
|
155
|
+
// 1. Production build — never, under any circumstance.
|
|
156
|
+
if (!opts.isDevBuild) return [];
|
|
157
|
+
|
|
158
|
+
const snap: DevServerSnapshot = snapshot || { running: false };
|
|
159
|
+
const family = reloadFrameworkFamily(snap.framework);
|
|
160
|
+
const labels = LABELS[family];
|
|
161
|
+
const machine = (opts.machineLabel || '').trim() || 'the selected machine';
|
|
162
|
+
|
|
163
|
+
let blocked: string | undefined;
|
|
164
|
+
if (!opts.connected) {
|
|
165
|
+
blocked = 'Not connected to a machine yet — pick one first.';
|
|
166
|
+
} else if (snap.building) {
|
|
167
|
+
blocked = 'The dev server is still building — reload works once it finishes.';
|
|
168
|
+
} else if (!snap.running) {
|
|
169
|
+
blocked =
|
|
170
|
+
`No dev server is running on ${machine}. ` +
|
|
171
|
+
'Start one from the Yaver app, or run `yaver dev start` there.';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const actions: ReloadAction[] = [
|
|
175
|
+
{
|
|
176
|
+
id: 'hot',
|
|
177
|
+
label: labels.hot[0],
|
|
178
|
+
hint: labels.hot[1],
|
|
179
|
+
mode: 'fast',
|
|
180
|
+
path: RELOAD_PATH,
|
|
181
|
+
enabled: !blocked,
|
|
182
|
+
disabledReason: blocked,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'full',
|
|
186
|
+
label: labels.full[0],
|
|
187
|
+
hint: labels.full[1],
|
|
188
|
+
mode: 'full',
|
|
189
|
+
path: RELOAD_PATH,
|
|
190
|
+
enabled: !blocked,
|
|
191
|
+
disabledReason: blocked,
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
if (opts.includeRebuild) {
|
|
196
|
+
// Deliberately NOT gated on `running`: rebuilding the bundle is exactly
|
|
197
|
+
// what you do when Metro is not up. It is gated on `connected`, because
|
|
198
|
+
// without a machine there is nothing to rebuild on.
|
|
199
|
+
const rebuildBlocked = opts.connected
|
|
200
|
+
? undefined
|
|
201
|
+
: 'Not connected to a machine yet — pick one first.';
|
|
202
|
+
actions.push({
|
|
203
|
+
id: 'rebuild',
|
|
204
|
+
label: 'Rebuild Bundle',
|
|
205
|
+
hint: 'Recompiles the Hermes bundle on the machine. Works with no dev server.',
|
|
206
|
+
mode: 'bundle',
|
|
207
|
+
path: RELOAD_APP_PATH,
|
|
208
|
+
enabled: !rebuildBlocked,
|
|
209
|
+
disabledReason: rebuildBlocked,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return actions;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The exact request an action makes. Kept next to the labels on purpose. */
|
|
217
|
+
export function reloadRequest(action: Pick<ReloadAction, 'mode' | 'path'>): {
|
|
218
|
+
method: 'POST';
|
|
219
|
+
path: string;
|
|
220
|
+
body: Record<string, unknown>;
|
|
221
|
+
} {
|
|
222
|
+
return { method: 'POST', path: action.path, body: { mode: action.mode } };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Turn a failed reload into a sentence that names the cause AND the fix.
|
|
227
|
+
*
|
|
228
|
+
* "Reload failed" is the shape of error this codebase keeps paying whole
|
|
229
|
+
* sessions for. Every branch below exists because the raw text the agent (or
|
|
230
|
+
* Go's net stack) produces is accurate and unreadable.
|
|
231
|
+
*/
|
|
232
|
+
export function describeReloadFailure(
|
|
233
|
+
status: number,
|
|
234
|
+
body: string,
|
|
235
|
+
snapshot?: DevServerSnapshot | null,
|
|
236
|
+
): string {
|
|
237
|
+
const lower = (body || '').toLowerCase();
|
|
238
|
+
const framework = (snapshot?.framework || '').trim();
|
|
239
|
+
|
|
240
|
+
if (lower.indexOf('does not support hot reload') >= 0) {
|
|
241
|
+
const name = framework || 'This dev server';
|
|
242
|
+
return `${name} cannot hot reload. Use Rebuild Bundle, or restart the dev server.`;
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
status === 503 ||
|
|
246
|
+
lower.indexOf('no dev server') >= 0 ||
|
|
247
|
+
lower.indexOf('dev server not available') >= 0
|
|
248
|
+
) {
|
|
249
|
+
return 'No dev server is running on the machine. Start one before reloading.';
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
(lower.indexOf('connection refused') >= 0 || lower.indexOf('econnrefused') >= 0) &&
|
|
253
|
+
(lower.indexOf('127.0.0.1') >= 0 || lower.indexOf('localhost') >= 0)
|
|
254
|
+
) {
|
|
255
|
+
return 'The dev server is not listening on the machine. Start it with `yaver dev start`.';
|
|
256
|
+
}
|
|
257
|
+
if (status === 401 || status === 403) {
|
|
258
|
+
return 'The machine rejected this session — sign in again, or re-pair this device.';
|
|
259
|
+
}
|
|
260
|
+
if (status === 404) {
|
|
261
|
+
return (
|
|
262
|
+
'This machine’s agent has no /dev/reload route — it is too old. ' +
|
|
263
|
+
'Update it with `npm install -g yaver-cli@latest`.'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (status >= 500) {
|
|
267
|
+
return 'The agent hit an internal error while reloading. Check `yaver logs` on the machine.';
|
|
268
|
+
}
|
|
269
|
+
if (status === 0) {
|
|
270
|
+
return 'Could not reach the machine. Check that it is online and `yaver serve` is running.';
|
|
271
|
+
}
|
|
272
|
+
return `Reload failed (HTTP ${status}).`;
|
|
273
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-device App Store screenshot capture (Engine 2).
|
|
3
|
+
*
|
|
4
|
+
* The desktop `yaver shots` flow drives a simulator with Maestro. This is
|
|
5
|
+
* the in-app counterpart: the SDK, living inside the real running app,
|
|
6
|
+
* walks the app's own routes and screenshots each one with
|
|
7
|
+
* `react-native-view-shot`, then uploads the frames to the Yaver agent
|
|
8
|
+
* (POST /shots/upload). The agent normalizes them and runs the same App
|
|
9
|
+
* Store Connect backend (upload → metadata → submit).
|
|
10
|
+
*
|
|
11
|
+
* Why this exists alongside the simulator path: it captures *real device*
|
|
12
|
+
* pixels, knows the exact route map (no heuristics), and the user is
|
|
13
|
+
* already authenticated — so it sidesteps the i18n / no-testID fragility
|
|
14
|
+
* that makes a blind simulator walk hard.
|
|
15
|
+
*
|
|
16
|
+
* The host wires it once by handing us a navigation ref (react-navigation
|
|
17
|
+
* or expo-router router) plus the ordered list of routes to visit.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { captureScreenshotBase64 } from './capture';
|
|
21
|
+
|
|
22
|
+
export interface StoreShotFrame {
|
|
23
|
+
route: string;
|
|
24
|
+
base64: string;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CaptureStoreScreenshotsOptions {
|
|
29
|
+
/** Yaver agent base URL (e.g. http://192.168.1.5:18080 or a relay URL). */
|
|
30
|
+
agentUrl: string;
|
|
31
|
+
/** Bearer token for the agent (same-user envelope). */
|
|
32
|
+
authToken: string;
|
|
33
|
+
/** Relay password — required only when agentUrl is relay-routed. */
|
|
34
|
+
relayPassword?: string;
|
|
35
|
+
|
|
36
|
+
/** App name (vault scope / job label on the agent side). */
|
|
37
|
+
app: string;
|
|
38
|
+
/** iOS bundle id; the agent falls back to app.json if omitted. */
|
|
39
|
+
bundleId?: string;
|
|
40
|
+
/** App Store localization (default en-US). */
|
|
41
|
+
locale?: string;
|
|
42
|
+
/** When true, the agent also sets metadata + attempts submit-for-review. */
|
|
43
|
+
submit?: boolean;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Navigation handle. Either a react-navigation ref (has `.navigate`) or
|
|
47
|
+
* an expo-router `router` (has `.push`/`.navigate`). We call the first
|
|
48
|
+
* available method with the route string.
|
|
49
|
+
*/
|
|
50
|
+
navigationRef?: any;
|
|
51
|
+
/** Ordered routes to visit + screenshot (e.g. ['/(tabs)/dashboard', ...]). */
|
|
52
|
+
routes: string[];
|
|
53
|
+
|
|
54
|
+
/** Optional per-route screenshot names (defaults to NN_<sanitized route>). */
|
|
55
|
+
screens?: string[];
|
|
56
|
+
/** Milliseconds to wait after navigating before capturing (default 900). */
|
|
57
|
+
settleMs?: number;
|
|
58
|
+
/** Hook the host can use to hide its own overlay before each capture. */
|
|
59
|
+
onBeforeCapture?: (route: string, index: number) => void | Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CaptureStoreScreenshotsResult {
|
|
63
|
+
ok: boolean;
|
|
64
|
+
captured: number;
|
|
65
|
+
uploaded: number;
|
|
66
|
+
submitted?: boolean;
|
|
67
|
+
staged?: boolean;
|
|
68
|
+
message?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sleep(ms: number): Promise<void> {
|
|
72
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function navigateTo(navRef: any, route: string): void {
|
|
76
|
+
if (!navRef) return;
|
|
77
|
+
// react-navigation ref
|
|
78
|
+
if (typeof navRef.navigate === 'function') {
|
|
79
|
+
navRef.navigate(route as never);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// expo-router router
|
|
83
|
+
if (typeof navRef.push === 'function') {
|
|
84
|
+
navRef.push(route);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (navRef.current && typeof navRef.current.navigate === 'function') {
|
|
88
|
+
navRef.current.navigate(route as never);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function shotName(route: string, index: number, override?: string): string {
|
|
94
|
+
if (override) return override;
|
|
95
|
+
const clean = route
|
|
96
|
+
.replace(/^\/+/, '')
|
|
97
|
+
.replace(/[()[\]/]+/g, '_')
|
|
98
|
+
.replace(/[^a-zA-Z0-9_]+/g, '')
|
|
99
|
+
.replace(/^_+|_+$/g, '');
|
|
100
|
+
const n = String(index + 1).padStart(2, '0');
|
|
101
|
+
return `${n}_${clean || 'screen'}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Walk the app's routes, screenshot each, and upload the batch to the
|
|
106
|
+
* agent. Returns a summary; never throws for a single missed route —
|
|
107
|
+
* it captures what it can and reports the count.
|
|
108
|
+
*/
|
|
109
|
+
export async function captureStoreScreenshots(
|
|
110
|
+
opts: CaptureStoreScreenshotsOptions,
|
|
111
|
+
): Promise<CaptureStoreScreenshotsResult> {
|
|
112
|
+
if (!opts.agentUrl || !opts.authToken) {
|
|
113
|
+
return { ok: false, captured: 0, uploaded: 0, message: 'agentUrl + authToken required' };
|
|
114
|
+
}
|
|
115
|
+
if (!opts.routes?.length) {
|
|
116
|
+
return { ok: false, captured: 0, uploaded: 0, message: 'no routes to capture' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const settleMs = opts.settleMs ?? 900;
|
|
120
|
+
const frames: StoreShotFrame[] = [];
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < opts.routes.length; i++) {
|
|
123
|
+
const route = opts.routes[i];
|
|
124
|
+
try {
|
|
125
|
+
navigateTo(opts.navigationRef, route);
|
|
126
|
+
await sleep(settleMs);
|
|
127
|
+
if (opts.onBeforeCapture) await opts.onBeforeCapture(route, i);
|
|
128
|
+
const shot = await captureScreenshotBase64();
|
|
129
|
+
if (shot?.base64) {
|
|
130
|
+
frames.push({
|
|
131
|
+
route: shotName(route, i, opts.screens?.[i]),
|
|
132
|
+
base64: shot.base64,
|
|
133
|
+
mimeType: shot.mimeType,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Skip a route that failed to render — keep walking.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (frames.length === 0) {
|
|
142
|
+
return { ok: false, captured: 0, uploaded: 0, message: 'captured no frames' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const headers: Record<string, string> = {
|
|
146
|
+
Authorization: `Bearer ${opts.authToken}`,
|
|
147
|
+
'Content-Type': 'application/json',
|
|
148
|
+
};
|
|
149
|
+
if (opts.relayPassword) headers['X-Relay-Password'] = opts.relayPassword;
|
|
150
|
+
|
|
151
|
+
const base = opts.agentUrl.replace(/\/$/, '');
|
|
152
|
+
try {
|
|
153
|
+
const resp = await fetch(`${base}/shots/upload`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers,
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
app: opts.app,
|
|
158
|
+
bundleId: opts.bundleId ?? '',
|
|
159
|
+
locale: opts.locale ?? 'en-US',
|
|
160
|
+
submit: !!opts.submit,
|
|
161
|
+
frames,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
const j = await resp.json().catch(() => ({}));
|
|
165
|
+
if (!resp.ok || j?.ok === false) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
captured: frames.length,
|
|
169
|
+
uploaded: 0,
|
|
170
|
+
message: j?.error || `agent /shots/upload HTTP ${resp.status}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
captured: frames.length,
|
|
176
|
+
uploaded: typeof j?.uploaded === 'number' ? j.uploaded : frames.length,
|
|
177
|
+
submitted: j?.submitted,
|
|
178
|
+
staged: j?.staged,
|
|
179
|
+
message: j?.message,
|
|
180
|
+
};
|
|
181
|
+
} catch (e: any) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
captured: frames.length,
|
|
185
|
+
uploaded: 0,
|
|
186
|
+
message: `upload failed: ${e?.message ?? 'network error'}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|