yaver-feedback-react-native 0.8.6 → 0.8.8

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Visual feedback SDK for Yaver. Lets testers and developers shake their phone and then keep a small quick-access icon on screen for hot reload, vibing, and screenshot & fix flows straight to a Yaver agent running on a dev machine.
4
4
 
5
+ Haptics ownership rule: when your app is paired with Yaver, app-level haptics should belong to Yaver Feedback alone. If the host app also fires its own `expo-haptics` calls, you risk duplicated tactile feedback and a larger native-module crash surface during guest/runtime flows.
6
+
5
7
  ## Installation
6
8
 
7
9
  ```bash
@@ -16,7 +18,7 @@ Manual fallback:
16
18
  npm install yaver-feedback-react-native
17
19
  ```
18
20
 
19
- > **Mobile only.** This SDK targets React Native (iOS + Android). A `yaver-feedback-web` package exists for browser apps but currently expects a bring-your-own auth token the equivalent in-app sign-in UX (Apple / Google / GitHub / GitLab / Microsoft / email) for the web SDK will land in a future release. Open an issue if you need it sooner.
21
+ > **Mobile only.** This SDK targets React Native (iOS + Android). For browser apps use `yaver-feedback-web`, which now has its own popup OAuth, email auth, login modal, and device picker. The React Native and web SDKs share the same account/device model, but use platform-specific auth UX.
20
22
 
21
23
  ### Peer dependencies
22
24
 
@@ -618,6 +620,7 @@ YaverFeedback.init({
618
620
  // Optional
619
621
  agentUrl: 'http://192.168.1.10:18080', // Agent URL (auto-discovered if omitted)
620
622
  trigger: 'shake', // 'shake' | 'floating-button' | 'manual'
623
+ disableShakeGesture: false, // Non-default: disable shake + promote quick icon to 'always'
621
624
  enabled: true, // Default: __DEV__ (auto-disabled in production)
622
625
  maxRecordingDuration: 120, // Max recording duration in seconds (default: 120)
623
626
  feedbackMode: 'batch', // 'live' | 'narrated' | 'batch' (default: 'batch')
@@ -662,6 +665,26 @@ SDK yields shake handling to Yaver's native overlay — see the
662
665
  YaverFeedback.init({ authToken, trigger: 'shake' });
663
666
  ```
664
667
 
668
+ ### No-shake build option
669
+
670
+ If another surface owns motion / haptics and you want the SDK without shake detection, enable the non-default `disableShakeGesture` option:
671
+
672
+ ```typescript
673
+ YaverFeedback.init({
674
+ authToken,
675
+ trigger: 'shake',
676
+ disableShakeGesture: true,
677
+ });
678
+ ```
679
+
680
+ Effects:
681
+
682
+ - `ShakeDetector` is not started.
683
+ - If `quickIcon` was unset / `'auto'`, the SDK promotes it to `'always'`.
684
+ - The user opens feedback through the draggable quick icon or your own manual trigger.
685
+
686
+ This is useful when Yaver Feedback should remain the only haptic/shake owner and the host app should not bind its own motion-triggered entry point.
687
+
665
688
  ### Floating Button
666
689
 
667
690
  A small draggable "Y" button overlays the app. Tap to open the feedback modal.
@@ -121,8 +121,8 @@ const YaverMachinePickerScreen = ({ token, currentDeviceId, onPick, onCancel, })
121
121
  // yellows from phone↔backend clock skew around the 89-90 s mark.
122
122
  //
123
123
  // `runnerDown` intentionally does NOT flip the dot. That flag
124
- // tracks whether the AI runner (claude-code, aider, ...) is
125
- // healthy — a separate concern from "can I reach this machine?"
124
+ // tracks whether the AI runner (claude-code / codex / opencode)
125
+ // is healthy — a separate concern from "can I reach this machine?"
126
126
  // Mobile app surfaces runner issues via a separate badge, not
127
127
  // this dot. Picker's job is reachability, nothing more.
128
128
  const effectivelyReachable = probe?.reachable === true;
@@ -1,4 +1,4 @@
1
- import { FeedbackBundle, RunnerBrowserAuthSession, TestSession, VoiceCapability } from './types';
1
+ import { CapabilitySnapshot, FeedbackBundle, IncidentEvent, OperationState, RunnerBrowserAuthSession, TestSession, VoiceCapability } from './types';
2
2
  export interface FeedbackEvent {
3
3
  type: string;
4
4
  timestamp: string;
@@ -47,6 +47,23 @@ export declare class P2PClient {
47
47
  startRunnerBrowserAuth(runner: string): Promise<RunnerBrowserAuthSession>;
48
48
  getRunnerBrowserAuthStatus(sessionId: string): Promise<RunnerBrowserAuthSession>;
49
49
  cancelRunnerBrowserAuth(sessionId: string): Promise<void>;
50
+ capabilitySnapshot(): Promise<CapabilitySnapshot | null>;
51
+ incidents(opts?: {
52
+ category?: string;
53
+ severity?: string;
54
+ code?: string;
55
+ deviceId?: string;
56
+ projectPath?: string;
57
+ includeResolved?: boolean;
58
+ limit?: number;
59
+ }): Promise<IncidentEvent[]>;
60
+ operations(opts?: {
61
+ kind?: string;
62
+ status?: string;
63
+ deviceId?: string;
64
+ projectPath?: string;
65
+ limit?: number;
66
+ }): Promise<OperationState[]>;
50
67
  /** Health check — returns true if the agent is reachable. */
51
68
  health(): Promise<boolean>;
52
69
  /** Get agent info (hostname, version, platform). */
package/dist/P2PClient.js CHANGED
@@ -155,6 +155,68 @@ class P2PClient {
155
155
  }
156
156
  catch { /* best-effort */ }
157
157
  }
158
+ async capabilitySnapshot() {
159
+ try {
160
+ const resp = await fetch(`${this.baseUrl}/capabilities/snapshot`, { headers: this.authHeaders() });
161
+ if (!resp.ok)
162
+ return null;
163
+ const data = await resp.json().catch(() => ({}));
164
+ return (data.snapshot ?? null);
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ async incidents(opts = {}) {
171
+ try {
172
+ const url = new URL(`${this.baseUrl}/incidents`);
173
+ if (opts.category)
174
+ url.searchParams.set('category', opts.category);
175
+ if (opts.severity)
176
+ url.searchParams.set('severity', opts.severity);
177
+ if (opts.code)
178
+ url.searchParams.set('code', opts.code);
179
+ if (opts.deviceId)
180
+ url.searchParams.set('device', opts.deviceId);
181
+ if (opts.projectPath)
182
+ url.searchParams.set('projectPath', opts.projectPath);
183
+ if (opts.includeResolved)
184
+ url.searchParams.set('includeResolved', '1');
185
+ if (typeof opts.limit === 'number')
186
+ url.searchParams.set('limit', String(opts.limit));
187
+ const resp = await fetch(url.toString(), { headers: this.authHeaders() });
188
+ if (!resp.ok)
189
+ return [];
190
+ const data = await resp.json().catch(() => ({}));
191
+ return Array.isArray(data.incidents) ? data.incidents : [];
192
+ }
193
+ catch {
194
+ return [];
195
+ }
196
+ }
197
+ async operations(opts = {}) {
198
+ try {
199
+ const url = new URL(`${this.baseUrl}/operations`);
200
+ if (opts.kind)
201
+ url.searchParams.set('kind', opts.kind);
202
+ if (opts.status)
203
+ url.searchParams.set('status', opts.status);
204
+ if (opts.deviceId)
205
+ url.searchParams.set('device', opts.deviceId);
206
+ if (opts.projectPath)
207
+ url.searchParams.set('projectPath', opts.projectPath);
208
+ if (typeof opts.limit === 'number')
209
+ url.searchParams.set('limit', String(opts.limit));
210
+ const resp = await fetch(url.toString(), { headers: this.authHeaders() });
211
+ if (!resp.ok)
212
+ return [];
213
+ const data = await resp.json().catch(() => ({}));
214
+ return Array.isArray(data.operations) ? data.operations : [];
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
158
220
  /** Health check — returns true if the agent is reachable. */
159
221
  async health() {
160
222
  try {
@@ -113,6 +113,9 @@ class YaverFeedback {
113
113
  autoLogin: true,
114
114
  ...cfg,
115
115
  };
116
+ if (config.disableShakeGesture && (!config.quickIcon || config.quickIcon === 'auto')) {
117
+ config.quickIcon = 'always';
118
+ }
116
119
  firstShakeFired = false;
117
120
  // Route the in-SDK login screen to prod yaver.io by default; callers may
118
121
  // override for staging via authConvexSiteUrl / authWebBaseUrl.
@@ -129,12 +132,18 @@ class YaverFeedback {
129
132
  if (!config.convexUrl) {
130
133
  config.convexUrl = cfg.authConvexSiteUrl ?? auth_1.DEFAULT_CONVEX_SITE_URL;
131
134
  }
132
- // Default: enabled only in dev mode
135
+ // Default: enabled. Pre-0.8.8 the SDK only enabled shake in dev
136
+ // builds (`__DEV__`), but apps that bundle the SDK explicitly *want*
137
+ // shake to work in TestFlight / Play Store builds — that's the
138
+ // primary use case (a tester finds a bug in a release build and
139
+ // shakes to report it). Dev builds get shake too. Apps that want
140
+ // to disable shake pass `enabled: false` (or
141
+ // `disableShakeGesture: true` for finer-grained control).
133
142
  if (cfg.enabled !== undefined) {
134
143
  enabled = cfg.enabled;
135
144
  }
136
145
  else {
137
- enabled = typeof __DEV__ !== 'undefined' ? __DEV__ : false;
146
+ enabled = !cfg.disableShakeGesture;
138
147
  }
139
148
  // Hydrate cached auth token + preferred device from AsyncStorage so the
140
149
  // SDK reconnects silently on subsequent launches. If autoLogin is false
@@ -165,7 +174,7 @@ class YaverFeedback {
165
174
  shakeDetector.stop();
166
175
  shakeDetector = null;
167
176
  }
168
- if (enabled && config.trigger === 'shake') {
177
+ if (enabled && config.trigger === 'shake' && !config.disableShakeGesture) {
169
178
  shakeDetector = new ShakeDetector_1.ShakeDetector();
170
179
  shakeDetector.start(() => {
171
180
  YaverFeedback.notifyShake();
@@ -226,18 +235,40 @@ class YaverFeedback {
226
235
  });
227
236
  }
228
237
  });
229
- // NOTE: BlackBox.start() is intentionally NOT auto-called here.
230
- // An earlier version (0.7.6) did auto-start it, and when the
231
- // agent was in bootstrap / needs-auth mode which can happen
232
- // any time after `yaver serve` restarts before the user pairs
233
- // the SSE channel retried with exponential backoff on 401s,
234
- // producing a tight loop of string concatenation + JSON parse
235
- // that tripped a Hermes rope-string SIGSEGV on iOS 18.3.1
236
- // during any other JS-thread regex work (e.g. react-native-
237
- // view-shot's internal string handling during Screenshot &
238
- // Fix). Host apps call BlackBox.start() explicitly once they
239
- // know the agent URL + token are valid (SFMG does this inside
240
- // its YaverFeedbackWidget after auth).
238
+ // BlackBox auto-start (0.8.8+).
239
+ //
240
+ // 0.7.6 auto-started BlackBox immediately, which produced a
241
+ // Hermes rope-string SIGSEGV on iOS 18.3.1 when the agent was in
242
+ // bootstrap / needs-auth mode: the SSE channel retried with
243
+ // exponential backoff on 401s, generating a tight string-concat
244
+ // + JSON-parse loop that collided with react-native-view-shot's
245
+ // internal string handling during Screenshot & Fix. We rolled it
246
+ // back to manual-start (host calls BlackBox.start() after auth).
247
+ //
248
+ // The fix that lets us auto-start safely now:
249
+ // 1. Defer the start by 500ms so init() returns, the JS bridge
250
+ // settles, and any first-launch auth-token round trip on
251
+ // another thread completes before SSE opens.
252
+ // 2. Only start when we have BOTH an agentUrl AND an authToken
253
+ // — without the token, the connect() call would 401 and we'd
254
+ // reproduce the original loop.
255
+ // 3. Caller can opt out with cfg.autoStartBlackBox = false.
256
+ //
257
+ // SFMG used to call BlackBox.start() inside YaverFeedbackWidget
258
+ // after auth — that path still works (start() is idempotent), so
259
+ // upgrading SDK without removing the manual call is safe.
260
+ if (cfg.autoStartBlackBox !== false) {
261
+ setTimeout(() => {
262
+ if (config?.agentUrl && (config?.authToken || p2pAuthToken)) {
263
+ try {
264
+ BlackBox_1.BlackBox.start();
265
+ }
266
+ catch (err) {
267
+ console.warn('[YaverFeedback] BlackBox auto-start failed:', err);
268
+ }
269
+ }
270
+ }, 500);
271
+ }
241
272
  }
242
273
  // NOTE: We intentionally do NOT hook ErrorUtils.setGlobalHandler().
243
274
  // Sentry, Crashlytics, Bugsnag, and other tools all compete for that
@@ -552,7 +583,7 @@ class YaverFeedback {
552
583
  BlackBox_1.BlackBox.start(); // restart with previous config
553
584
  }
554
585
  // Restart shake detector if trigger is 'shake'
555
- if (config?.trigger === 'shake' && !shakeDetector) {
586
+ if (config?.trigger === 'shake' && !config?.disableShakeGesture && !shakeDetector) {
556
587
  shakeDetector = new ShakeDetector_1.ShakeDetector();
557
588
  shakeDetector.start(() => {
558
589
  YaverFeedback.notifyShake();
@@ -29,10 +29,10 @@ describe('auth device listing', () => {
29
29
  platform: 'linux',
30
30
  isOnline: true,
31
31
  isGuest: true,
32
- hostName: 'Kivanc Cakmak',
33
- hostEmail: 'kivanc.cakmak@icloud.com',
32
+ hostName: 'Host User',
33
+ hostEmail: 'host@example.com',
34
34
  accessScope: 'shared-scoped',
35
- quicHost: '157.180.114.179',
35
+ quicHost: '198.51.100.20',
36
36
  quicPort: 18080,
37
37
  lastHeartbeat: 456,
38
38
  },
@@ -49,7 +49,7 @@ describe('auth device listing', () => {
49
49
  expect(result.shared[0]).toMatchObject({
50
50
  deviceId: 'guest-1',
51
51
  isGuest: true,
52
- hostEmail: 'kivanc.cakmak@icloud.com',
52
+ hostEmail: 'host@example.com',
53
53
  accessScope: 'shared-scoped',
54
54
  });
55
55
  });
@@ -64,10 +64,10 @@ describe('auth device listing', () => {
64
64
  platform: 'linux',
65
65
  isOnline: true,
66
66
  isGuest: true,
67
- hostName: 'Kivanc Cakmak',
68
- hostEmail: 'kivanc.cakmak@icloud.com',
67
+ hostName: 'Host User',
68
+ hostEmail: 'host@example.com',
69
69
  accessScope: 'shared-scoped',
70
- quicHost: '157.180.114.179',
70
+ quicHost: '198.51.100.20',
71
71
  quicPort: 18080,
72
72
  lastHeartbeat: 789,
73
73
  },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ // Hot-reload command-channel coverage for BlackBox.
3
+ //
4
+ // The agent uses BlackBox's SSE channel as a back-channel to push
5
+ // "reload" / "reload_bundle" commands into a running guest app
6
+ // (see desktop/agent/blackbox.go::BroadcastCommand). This test
7
+ // pins the SDK's contract:
8
+ //
9
+ // 1. onCommand(handler) registers a handler and returns an
10
+ // unsubscribe function that actually unsubscribes.
11
+ // 2. start() opens the SSE command-stream against the agent
12
+ // with the proper URL + headers.
13
+ // 3. When the SSE stream delivers a JSON message of the form
14
+ // {type:"command", command:{command:"reload", data:...}},
15
+ // the registered handler fires with the inner command.
16
+ // 4. start() also schedules the periodic flush — proving start
17
+ // is idempotent over multiple calls.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ const BlackBox_1 = require("../BlackBox");
20
+ const YaverFeedback_1 = require("../YaverFeedback");
21
+ jest.mock('react-native', () => ({
22
+ Platform: { OS: 'ios' },
23
+ }));
24
+ // Drive Date.now / setTimeout deterministically.
25
+ jest.useFakeTimers();
26
+ const mockFetch = jest.fn();
27
+ global.fetch = mockFetch;
28
+ class MockAbortController {
29
+ constructor() {
30
+ this.signal = { aborted: false };
31
+ this.abort = jest.fn(() => {
32
+ this.signal.aborted = true;
33
+ });
34
+ }
35
+ }
36
+ global.AbortController = MockAbortController;
37
+ // Helper to build an SSE streaming body.
38
+ function sseBody(messages) {
39
+ const enc = new TextEncoder();
40
+ const chunks = messages.map((m) => `data: ${JSON.stringify(m)}\n\n`);
41
+ return new ReadableStream({
42
+ start(controller) {
43
+ for (const c of chunks)
44
+ controller.enqueue(enc.encode(c));
45
+ controller.close();
46
+ },
47
+ });
48
+ }
49
+ beforeEach(() => {
50
+ jest.clearAllMocks();
51
+ // Default: every POST (events) and the SSE GET both succeed.
52
+ mockFetch.mockImplementation((url) => {
53
+ if (url.includes('/blackbox/command-stream')) {
54
+ return Promise.resolve({
55
+ ok: true,
56
+ body: sseBody([]),
57
+ });
58
+ }
59
+ return Promise.resolve({
60
+ ok: true,
61
+ json: () => Promise.resolve({}),
62
+ });
63
+ });
64
+ YaverFeedback_1.YaverFeedback.init({
65
+ agentUrl: 'http://localhost:18080',
66
+ authToken: 'tok',
67
+ });
68
+ });
69
+ afterEach(() => {
70
+ BlackBox_1.BlackBox.stop();
71
+ });
72
+ describe('BlackBox hot-reload command channel', () => {
73
+ it('onCommand returns an unsubscribe that removes the handler', () => {
74
+ const a = jest.fn();
75
+ const b = jest.fn();
76
+ const offA = BlackBox_1.BlackBox.onCommand(a);
77
+ const offB = BlackBox_1.BlackBox.onCommand(b);
78
+ // Both subscribed — calling internal dispatch directly via the
79
+ // public surface isn't easy without SSE mockery, but
80
+ // deregistering should still leave only `b` registered after
81
+ // offA(), then no handlers after offB().
82
+ offA();
83
+ offB();
84
+ // Re-subscribe and confirm subscription returns a fresh unsub.
85
+ const offC = BlackBox_1.BlackBox.onCommand(jest.fn());
86
+ expect(typeof offC).toBe('function');
87
+ offC();
88
+ });
89
+ it('start() opens SSE against /blackbox/command-stream with bearer + device headers', async () => {
90
+ BlackBox_1.BlackBox.start({ deviceId: 'dev-abc', appName: 'sfmg' });
91
+ // Allow the async fetch in connectSSE to fire.
92
+ await Promise.resolve();
93
+ await Promise.resolve();
94
+ const sseCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/blackbox/command-stream'));
95
+ expect(sseCall).toBeDefined();
96
+ const [url, init] = sseCall;
97
+ expect(String(url)).toContain('http://localhost:18080/blackbox/command-stream');
98
+ expect(String(url)).toContain('device=dev-abc');
99
+ expect(init.headers).toEqual(expect.objectContaining({
100
+ Authorization: 'Bearer tok',
101
+ Accept: 'text/event-stream',
102
+ 'X-Device-ID': 'dev-abc',
103
+ 'X-App-Name': 'sfmg',
104
+ }));
105
+ });
106
+ it('dispatches a {type:"command", command:{command:"reload"}} SSE frame to handlers', async () => {
107
+ // SSE body delivers one reload command, then closes.
108
+ mockFetch.mockImplementation((url) => {
109
+ if (url.includes('/blackbox/command-stream')) {
110
+ return Promise.resolve({
111
+ ok: true,
112
+ body: sseBody([
113
+ { type: 'command', command: { command: 'reload', data: {} } },
114
+ ]),
115
+ });
116
+ }
117
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
118
+ });
119
+ const seen = [];
120
+ BlackBox_1.BlackBox.onCommand((cmd) => seen.push(cmd));
121
+ BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
122
+ // Drain the microtask + reader queue. The reader is async but
123
+ // the body is fully buffered + the stream closes immediately,
124
+ // so a few microtask flushes are enough.
125
+ for (let i = 0; i < 10; i++)
126
+ await Promise.resolve();
127
+ expect(seen).toEqual([
128
+ expect.objectContaining({ command: 'reload', data: {} }),
129
+ ]);
130
+ });
131
+ it('dispatches a reload_bundle command (carries bundleUrl payload)', async () => {
132
+ mockFetch.mockImplementation((url) => {
133
+ if (url.includes('/blackbox/command-stream')) {
134
+ return Promise.resolve({
135
+ ok: true,
136
+ body: sseBody([
137
+ {
138
+ type: 'command',
139
+ command: {
140
+ command: 'reload_bundle',
141
+ data: { bundleUrl: '/dev/native-bundle' },
142
+ },
143
+ },
144
+ ]),
145
+ });
146
+ }
147
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
148
+ });
149
+ const seen = [];
150
+ BlackBox_1.BlackBox.onCommand((cmd) => seen.push(cmd));
151
+ BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
152
+ for (let i = 0; i < 10; i++)
153
+ await Promise.resolve();
154
+ expect(seen).toEqual([
155
+ expect.objectContaining({
156
+ command: 'reload_bundle',
157
+ data: expect.objectContaining({ bundleUrl: '/dev/native-bundle' }),
158
+ }),
159
+ ]);
160
+ });
161
+ it('handler exception does not break sibling handlers', async () => {
162
+ mockFetch.mockImplementation((url) => {
163
+ if (url.includes('/blackbox/command-stream')) {
164
+ return Promise.resolve({
165
+ ok: true,
166
+ body: sseBody([
167
+ { type: 'command', command: { command: 'reload' } },
168
+ ]),
169
+ });
170
+ }
171
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
172
+ });
173
+ const sibling = jest.fn();
174
+ BlackBox_1.BlackBox.onCommand(() => {
175
+ throw new Error('caller bug — should not break the dispatch loop');
176
+ });
177
+ BlackBox_1.BlackBox.onCommand(sibling);
178
+ BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
179
+ for (let i = 0; i < 10; i++)
180
+ await Promise.resolve();
181
+ expect(sibling).toHaveBeenCalledWith(expect.objectContaining({ command: 'reload' }));
182
+ });
183
+ it('ignores non-command SSE frames (events/log/whatever)', async () => {
184
+ mockFetch.mockImplementation((url) => {
185
+ if (url.includes('/blackbox/command-stream')) {
186
+ return Promise.resolve({
187
+ ok: true,
188
+ body: sseBody([
189
+ { type: 'log', logLine: 'hi' },
190
+ { type: 'lifecycle', message: 'started' },
191
+ { ping: 1 },
192
+ ]),
193
+ });
194
+ }
195
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
196
+ });
197
+ const handler = jest.fn();
198
+ BlackBox_1.BlackBox.onCommand(handler);
199
+ BlackBox_1.BlackBox.start({ deviceId: 'dev-1', appName: 'sfmg' });
200
+ for (let i = 0; i < 10; i++)
201
+ await Promise.resolve();
202
+ expect(handler).not.toHaveBeenCalled();
203
+ });
204
+ });
@@ -195,5 +195,61 @@ describe('P2PClient', () => {
195
195
  message: 'Reload request acknowledged. Agent is rebuilding the bundle.',
196
196
  }));
197
197
  });
198
+ it('dev mode hits /dev/reload with bearer auth', async () => {
199
+ mockFetch.mockResolvedValue({
200
+ ok: true,
201
+ json: () => Promise.resolve({ ok: true }),
202
+ });
203
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
204
+ await client.reloadApp('dev');
205
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:18080/dev/reload', expect.objectContaining({
206
+ method: 'POST',
207
+ headers: expect.objectContaining({ Authorization: 'Bearer tok' }),
208
+ }));
209
+ });
210
+ it('bundle mode hits /dev/reload-app with mode + projectName in body', async () => {
211
+ mockFetch.mockResolvedValue({
212
+ ok: true,
213
+ json: () => Promise.resolve({ ok: true }),
214
+ });
215
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
216
+ await client.reloadApp('bundle', { projectName: 'sfmg' });
217
+ const [url, init] = mockFetch.mock.calls[0];
218
+ expect(url).toBe('http://localhost:18080/dev/reload-app');
219
+ expect(init.method).toBe('POST');
220
+ expect(JSON.parse(init.body)).toEqual(expect.objectContaining({ mode: 'bundle', projectName: 'sfmg' }));
221
+ expect(init.headers).toEqual(expect.objectContaining({
222
+ Authorization: 'Bearer tok',
223
+ 'Content-Type': 'application/json',
224
+ }));
225
+ });
226
+ it('dev mode falls back to /dev/reload-app when /dev/reload 4xxs', async () => {
227
+ // First call: /dev/reload 404. Second: /dev/reload-app 200.
228
+ mockFetch
229
+ .mockResolvedValueOnce({ ok: false, status: 404, json: () => Promise.resolve({}) })
230
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
231
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
232
+ const result = await client.reloadApp('dev', { projectName: 'sfmg' });
233
+ expect(mockFetch).toHaveBeenCalledTimes(2);
234
+ expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:18080/dev/reload');
235
+ expect(mockFetch.mock.calls[1][0]).toBe('http://localhost:18080/dev/reload-app');
236
+ expect(result.ok).toBe(true);
237
+ });
238
+ it('surfaces nativeChangesDetected so the host can prompt for rebuild', async () => {
239
+ mockFetch.mockResolvedValue({
240
+ ok: true,
241
+ json: () => Promise.resolve({
242
+ ok: true,
243
+ nativeChangesDetected: true,
244
+ changeClass: 'native_required',
245
+ }),
246
+ });
247
+ const client = new P2PClient_1.P2PClient('http://localhost:18080', 'tok');
248
+ const result = await client.reloadApp('dev');
249
+ expect(result.nativeChangesDetected).toBe(true);
250
+ expect(result.changeClass).toBe('native_required');
251
+ // Caller-visible message must distinguish native-required from JS-only.
252
+ expect(result.message).toMatch(/native|rebuild/i);
253
+ });
198
254
  });
199
255
  });
@@ -76,6 +76,15 @@ describe('YaverFeedback', () => {
76
76
  expect(cfg.maxRecordingDuration).toBe(60);
77
77
  expect(cfg.strictNativeAuth).toBe(true);
78
78
  });
79
+ it('promotes quick icon to always when shake is disabled', () => {
80
+ YaverFeedback_1.YaverFeedback.init({
81
+ authToken: 'tok',
82
+ disableShakeGesture: true,
83
+ });
84
+ const cfg = YaverFeedback_1.YaverFeedback.getConfig();
85
+ expect(cfg.disableShakeGesture).toBe(true);
86
+ expect(cfg.quickIcon).toBe('always');
87
+ });
79
88
  it('with enabled=false sets enabled to false', () => {
80
89
  YaverFeedback_1.YaverFeedback.init({
81
90
  authToken: 'tok',
@@ -17,11 +17,13 @@ describe('React Native SDK types', () => {
17
17
  authToken: 'tok',
18
18
  agentUrl: 'http://192.168.1.10:18080',
19
19
  trigger: 'shake',
20
+ disableShakeGesture: true,
20
21
  enabled: true,
21
22
  maxRecordingDuration: 60,
22
23
  strictNativeAuth: true,
23
24
  };
24
25
  expect(config.trigger).toBe('shake');
26
+ expect(config.disableShakeGesture).toBe(true);
25
27
  expect(config.strictNativeAuth).toBe(true);
26
28
  });
27
29
  it('accepts all trigger types', () => {
@@ -27,9 +27,13 @@ export declare const DEFAULT_BEACON_UDP_PORT = 19837;
27
27
  /**
28
28
  * How old an agent's last heartbeat can be before the device is
29
29
  * considered offline. Mirrors `backend/convex/devices.ts` so
30
- * Convex + every client agree on the same threshold.
30
+ * Convex + every client agree on the same threshold. The agent
31
+ * heartbeats every 5 min (see `desktop/agent/main.go::heartbeatLoop`),
32
+ * so 6 min tolerates one missed beat + 60 s of jitter without
33
+ * flapping. Sub-minute death detection comes from the P2P bus, not
34
+ * this threshold.
31
35
  */
32
- export declare const HEARTBEAT_STALE_MS = 90000;
36
+ export declare const HEARTBEAT_STALE_MS = 360000;
33
37
  /**
34
38
  * How long after the last UDP beacon an agent is still considered
35
39
  * "locally present". Re-broadcast interval is 3 s, so 10 s covers
@@ -51,9 +51,13 @@ exports.DEFAULT_BEACON_UDP_PORT = 19837;
51
51
  /**
52
52
  * How old an agent's last heartbeat can be before the device is
53
53
  * considered offline. Mirrors `backend/convex/devices.ts` so
54
- * Convex + every client agree on the same threshold.
54
+ * Convex + every client agree on the same threshold. The agent
55
+ * heartbeats every 5 min (see `desktop/agent/main.go::heartbeatLoop`),
56
+ * so 6 min tolerates one missed beat + 60 s of jitter without
57
+ * flapping. Sub-minute death detection comes from the P2P bus, not
58
+ * this threshold.
55
59
  */
56
- exports.HEARTBEAT_STALE_MS = 90000;
60
+ exports.HEARTBEAT_STALE_MS = 360000;
57
61
  /**
58
62
  * How long after the last UDP beacon an agent is still considered
59
63
  * "locally present". Re-broadcast interval is 3 s, so 10 s covers