yaver-feedback-react-native 0.5.4 → 0.6.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.
@@ -1,4 +1,20 @@
1
- import { DeviceEventEmitter, Platform } from 'react-native';
1
+ import { DeviceEventEmitter, NativeModules, Platform } from 'react-native';
2
+
3
+ // Detects that the surrounding runtime is Yaver's super-host bridge. The
4
+ // YaverInfo native module is only registered inside Yaver's container
5
+ // (see mobile/ios/Yaver/YaverInfo.{swift,m} and the Android equivalent),
6
+ // so it is undefined in a standalone third-party app. When the SDK runs
7
+ // inside Yaver we yield shake handling to Yaver's native
8
+ // ShakeDetectingWindow — the user should only ever see the 2-button
9
+ // "Reload / Back to Yaver" overlay, never a FeedbackModal popped from
10
+ // inside the guest bundle.
11
+ function isRunningInsideYaverHost(): boolean {
12
+ try {
13
+ return !!(NativeModules as any)?.YaverInfo;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
2
18
 
3
19
  const SHAKE_TIMEOUT_MS = 1000; // minimum time between shakes
4
20
  const ACCEL_THRESHOLD_G = 1.8; // peak g-force that qualifies as a shake event
@@ -32,6 +48,11 @@ export class ShakeDetector {
32
48
 
33
49
  start(onShake: () => void): void {
34
50
  this.stop();
51
+ // When the app is loaded inside Yaver's super-host (Hermes push),
52
+ // Yaver owns the shake gesture and shows its own "Reload / Back to
53
+ // Yaver" overlay. We must not also fire the guest-side callback,
54
+ // otherwise the user gets both UIs at once.
55
+ if (isRunningInsideYaverHost()) return;
35
56
  this.subscribeDevMenu(onShake);
36
57
  this.subscribeAccelerometer(onShake);
37
58
  }
@@ -1,3 +1,4 @@
1
+ import { NativeModules } from 'react-native';
1
2
  import { FeedbackConfig, CapturedError } from './types';
2
3
  import { YaverDiscovery } from './Discovery';
3
4
  import { BlackBox } from './BlackBox';
@@ -12,6 +13,29 @@ import {
12
13
  DEFAULT_CONVEX_SITE_URL,
13
14
  } from './auth';
14
15
 
16
+ // Is this JS runtime the Yaver mobile app's super-host bridge? The
17
+ // YaverInfo native module is only registered inside Yaver's container
18
+ // (mobile/ios/Yaver/YaverInfo.{swift,m} + Android counterpart); a
19
+ // standalone app bundled by its own developer has no such module.
20
+ // When the SDK is loaded through Yaver's Hermes-push guest runtime we
21
+ // deliberately no-op every public entry point — Yaver owns the shake
22
+ // gesture ("Reload / Back to Yaver" overlay), the feedback capture
23
+ // flow, and the BlackBox streaming; running a second copy from inside
24
+ // the guest just produces duplicate UIs and double P2P sessions.
25
+ function isRunningInsideYaverHost(): boolean {
26
+ try {
27
+ return !!(NativeModules as any)?.YaverInfo;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ // Suppresses SDK activation when inside Yaver super-host. Callers of
34
+ // YaverFeedback.init / startReport / startBatchRecording / … early-out
35
+ // by checking this first so the SDK's side effects (accelerometer,
36
+ // BlackBox HTTP, SSE command channel, FeedbackModal mount) never start.
37
+ const YAVER_HOST_SUPPRESS = isRunningInsideYaverHost();
38
+
15
39
  let config: FeedbackConfig | null = null;
16
40
  let enabled = false;
17
41
  let p2pClient: P2PClient | null = null;
@@ -44,6 +68,11 @@ export class YaverFeedback {
44
68
  * via `YaverDiscovery` on the first `startReport()` call.
45
69
  */
46
70
  static init(cfg: FeedbackConfig): void {
71
+ if (YAVER_HOST_SUPPRESS) {
72
+ // Running inside Yaver's super-host — yield to Yaver's native UX.
73
+ enabled = false;
74
+ return;
75
+ }
47
76
  config = {
48
77
  trigger: 'shake',
49
78
  maxRecordingDuration: 120,
package/src/auth.ts CHANGED
@@ -1,22 +1,19 @@
1
1
  /**
2
2
  * Authentication + device/agent discovery API used by the Yaver Feedback SDK.
3
3
  *
4
- * This module is a trimmed SDK-local port of mobile/src/lib/auth.ts. It only
5
- * covers what the embedded login/machine-picker flow needs:
4
+ * Mirrors mobile/src/lib/auth.ts:
6
5
  *
7
- * - Device-code login (`POST /auth/device-code` + `GET /auth/device-code/poll`)
8
- * so users can sign in via any OAuth provider (apple/google/github/gitlab/
9
- * microsoft) on yaver.io without requiring deep-link wiring in the host app.
6
+ * - Native Apple Sign-In (`POST /auth/apple-native`) on iOS via
7
+ * `expo-apple-authentication`.
8
+ * - In-app browser OAuth for Google/Microsoft/GitHub/GitLab via
9
+ * `expo-web-browser`'s `openAuthSessionAsync` — same callback URL
10
+ * (`yaver://oauth-callback`) the Yaver mobile app uses.
10
11
  * - Email / password sign-up + login (no 2FA flow — for SDK simplicity).
11
- * - Token validation + refresh.
12
+ * - Token validation.
12
13
  * - `/devices/list` → owned + shared (guest) remote dev machines.
13
14
  *
14
- * All calls target the public Yaver Convex site URL by default; callers may
15
- * override via `init()` config to point at staging.
16
- *
17
- * Token persistence uses `@react-native-async-storage/async-storage` (already
18
- * a peer dep). SecureStore is intentionally avoided to keep the SDK portable
19
- * to any RN host app.
15
+ * Mobile-only. A web equivalent will ship as a separate `yaver-web-feedback`
16
+ * package; do not import this module from a browser bundle.
20
17
  */
21
18
 
22
19
  // AsyncStorage is an optional peer dep — degrade gracefully if missing.
@@ -31,6 +28,41 @@ try {
31
28
  // not installed — token persistence disabled, caller must pass authToken
32
29
  }
33
30
 
31
+ // Optional peer deps used by native sign-in and in-app browser OAuth. When
32
+ // missing the SDK still works — Apple falls back to in-app browser OAuth, and
33
+ // providers without expo-web-browser surface a clear error.
34
+ type WebBrowserModule = {
35
+ openAuthSessionAsync: (
36
+ url: string,
37
+ redirectUrl: string,
38
+ options?: { showInRecents?: boolean; preferEphemeralSession?: boolean },
39
+ ) => Promise<{ type: string; url?: string }>;
40
+ maybeCompleteAuthSession: () => void;
41
+ };
42
+ type AppleAuthModule = {
43
+ isAvailableAsync: () => Promise<boolean>;
44
+ signInAsync: (opts: { requestedScopes: number[] }) => Promise<{
45
+ identityToken: string | null;
46
+ fullName?: { givenName?: string | null; familyName?: string | null } | null;
47
+ }>;
48
+ AppleAuthenticationScope: { FULL_NAME: number; EMAIL: number };
49
+ };
50
+
51
+ let WebBrowser: WebBrowserModule | null = null;
52
+ try {
53
+ WebBrowser = require('expo-web-browser');
54
+ WebBrowser?.maybeCompleteAuthSession();
55
+ } catch {
56
+ // optional
57
+ }
58
+
59
+ let AppleAuth: AppleAuthModule | null = null;
60
+ try {
61
+ AppleAuth = require('expo-apple-authentication');
62
+ } catch {
63
+ // optional — Apple sign-in falls back to in-app browser OAuth
64
+ }
65
+
34
66
  const TOKEN_KEY = 'yaver_feedback_auth_token';
35
67
  const USER_KEY = 'yaver_feedback_user';
36
68
  const DEVICE_KEY = 'yaver_feedback_selected_device';
@@ -177,74 +209,119 @@ export async function validateToken(token: string): Promise<User | null> {
177
209
  }
178
210
  }
179
211
 
180
- // ─── Device-code flow (for OAuth via web) ─────────────────────────────
181
-
182
- export interface DeviceCodeStart {
183
- userCode: string;
184
- deviceCode: string;
185
- expiresAt: number;
186
- verificationUrl: string;
187
- }
212
+ // ─── Native Apple Sign-In ─────────────────────────────────────────────
188
213
 
189
214
  /**
190
- * Start a device-code flow. The user opens `verificationUrl`, signs in with
191
- * any OAuth provider on yaver.io, and the SDK polls `pollDeviceCode` until
192
- * a session token is issued.
215
+ * Sign in with Apple using the native ASAuthorization flow. Requires
216
+ * `expo-apple-authentication` installed and the host app's bundle to have
217
+ * the "Sign in with Apple" capability enabled. iOS only.
218
+ *
219
+ * Throws `cancelled` if the user dismisses the sheet.
193
220
  */
194
- export async function startDeviceCode(opts?: {
195
- machineName?: string;
196
- platform?: string;
197
- preferredProvider?: OAuthProvider;
198
- }): Promise<DeviceCodeStart> {
199
- const res = await fetch(`${convexSiteUrl}/auth/device-code`, {
221
+ export async function signInWithApple(): Promise<{ token: string; userId: string }> {
222
+ if (!AppleAuth) {
223
+ throw new Error(
224
+ 'expo-apple-authentication is not installed. Add it as a peer dep to enable native Apple Sign-In.',
225
+ );
226
+ }
227
+ const available = await AppleAuth.isAvailableAsync();
228
+ if (!available) {
229
+ throw new Error('Apple Sign-In is not available on this device');
230
+ }
231
+
232
+ let credential;
233
+ try {
234
+ credential = await AppleAuth.signInAsync({
235
+ requestedScopes: [
236
+ AppleAuth.AppleAuthenticationScope.FULL_NAME,
237
+ AppleAuth.AppleAuthenticationScope.EMAIL,
238
+ ],
239
+ });
240
+ } catch (err) {
241
+ if ((err as { code?: string } | null)?.code === 'ERR_REQUEST_CANCELED') {
242
+ throw new Error('cancelled');
243
+ }
244
+ throw err;
245
+ }
246
+
247
+ if (!credential.identityToken) {
248
+ throw new Error('Apple did not return an identity token');
249
+ }
250
+
251
+ const fullName =
252
+ [credential.fullName?.givenName, credential.fullName?.familyName]
253
+ .filter(Boolean)
254
+ .join(' ') || undefined;
255
+
256
+ const res = await fetch(`${convexSiteUrl}/auth/apple-native`, {
200
257
  method: 'POST',
201
258
  headers: { 'Content-Type': 'application/json' },
202
- body: JSON.stringify({
203
- machineName: opts?.machineName,
204
- platform: opts?.platform,
205
- preferredProvider: opts?.preferredProvider,
206
- environment: 'feedback-sdk',
207
- }),
259
+ body: JSON.stringify({ identityToken: credential.identityToken, fullName }),
208
260
  });
209
261
  if (!res.ok) {
210
- const data = await res.json().catch(() => ({}));
211
- throw new Error(data.error ?? 'Failed to start device-code');
262
+ const body = await res.text().catch(() => '');
263
+ throw new Error(body || 'Apple sign-in failed');
212
264
  }
213
265
  const data = await res.json();
214
- const params = new URLSearchParams({ code: data.userCode });
215
- if (opts?.preferredProvider) {
216
- params.set('preferredProvider', opts.preferredProvider);
217
- }
218
- return {
219
- userCode: data.userCode,
220
- deviceCode: data.deviceCode,
221
- expiresAt: data.expiresAt,
222
- verificationUrl: `${webBaseUrl}/auth/device?${params.toString()}`,
223
- };
266
+ return { token: data.token, userId: data.userId };
224
267
  }
225
268
 
226
- export type DeviceCodePoll =
227
- | { status: 'pending' }
228
- | { status: 'authorized'; token: string }
229
- | { status: 'expired' };
269
+ // ─── In-app browser OAuth (Google / GitHub / GitLab / Microsoft) ──────
230
270
 
231
- export async function pollDeviceCode(
232
- deviceCode: string,
233
- ): Promise<DeviceCodePoll> {
234
- try {
235
- const res = await fetch(
236
- `${convexSiteUrl}/auth/device-code/poll?device_code=${encodeURIComponent(deviceCode)}`,
271
+ /**
272
+ * Default OAuth redirect — the same callback the Yaver mobile app uses
273
+ * (`yaver://oauth-callback`). `WebBrowser.openAuthSessionAsync` intercepts
274
+ * this redirect inside the auth session, so the host app does not need to
275
+ * register the scheme on iOS. On Android, add an `<intent-filter>` for
276
+ * `yaver://oauth-callback` in the host app's AndroidManifest.xml.
277
+ */
278
+ export const DEFAULT_OAUTH_REDIRECT = 'yaver://oauth-callback';
279
+
280
+ /**
281
+ * Sign in through the in-app browser via yaver.io. Opens
282
+ * `https://yaver.io/api/auth/oauth/<provider>?client=mobile`, the user picks
283
+ * an OAuth provider, and the web callback redirects back to
284
+ * `yaver://oauth-callback?token=...` which `openAuthSessionAsync` captures
285
+ * inside the auth session. No deep-link wiring required on iOS.
286
+ *
287
+ * Throws `cancelled` if the user dismisses the browser.
288
+ */
289
+ export async function signInWithOAuth(
290
+ provider: OAuthProvider,
291
+ opts?: { redirectUrl?: string; preferEphemeralSession?: boolean },
292
+ ): Promise<{ token: string }> {
293
+ if (!WebBrowser) {
294
+ throw new Error(
295
+ 'expo-web-browser is not installed. Add it as a peer dep to enable OAuth sign-in.',
237
296
  );
238
- if (!res.ok) return { status: 'expired' };
239
- const data = await res.json();
240
- if (data.status === 'authorized' && typeof data.token === 'string') {
241
- return { status: 'authorized', token: data.token };
242
- }
243
- if (data.status === 'pending') return { status: 'pending' };
244
- return { status: 'expired' };
297
+ }
298
+ const redirectUrl = opts?.redirectUrl ?? DEFAULT_OAUTH_REDIRECT;
299
+ const params = new URLSearchParams({ client: 'mobile' });
300
+ const authUrl = `${webBaseUrl}/api/auth/oauth/${provider}?${params.toString()}`;
301
+
302
+ const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUrl, {
303
+ preferEphemeralSession: opts?.preferEphemeralSession ?? false,
304
+ showInRecents: true,
305
+ });
306
+
307
+ if (result.type !== 'success' || !result.url) {
308
+ throw new Error('cancelled');
309
+ }
310
+
311
+ let token: string | null = null;
312
+ try {
313
+ const parsed = new URL(result.url);
314
+ token = parsed.searchParams.get('token');
245
315
  } catch {
246
- return { status: 'pending' }; // network blip let caller keep polling
316
+ // best-effort fallback for odd schemes
317
+ const match = result.url.match(/[?&]token=([^&]+)/);
318
+ if (match) token = decodeURIComponent(match[1]);
319
+ }
320
+
321
+ if (!token) {
322
+ throw new Error('OAuth callback did not include a token');
247
323
  }
324
+ return { token };
248
325
  }
249
326
 
250
327
  // ─── Email / password (no 2FA) ────────────────────────────────────────
@@ -281,10 +358,10 @@ export async function loginWithEmail(
281
358
  }
282
359
  const data = await res.json();
283
360
  if (data?.requires2fa) {
284
- // SDK login surface does not handle 2FA — direct the user to complete
285
- // sign-in through the web flow (device-code) which supports it.
361
+ // SDK login surface does not handle 2FA — direct the user to sign in
362
+ // through one of the OAuth providers, which complete 2FA on the web.
286
363
  throw new Error(
287
- '2FA is enabled on this account. Sign in via the device-code flow instead.',
364
+ '2FA is enabled on this account. Sign in with Apple/Google/GitHub/GitLab/Microsoft instead.',
288
365
  );
289
366
  }
290
367
  return { token: data.token, userId: data.userId };
package/src/index.ts CHANGED
@@ -52,19 +52,18 @@ export {
52
52
  saveSelectedDeviceId,
53
53
  clearSelectedDeviceId,
54
54
  validateToken,
55
- startDeviceCode,
56
- pollDeviceCode,
55
+ signInWithApple,
56
+ signInWithOAuth,
57
57
  signupWithEmail,
58
58
  loginWithEmail,
59
59
  listReachableDevices,
60
60
  DEFAULT_CONVEX_SITE_URL,
61
61
  DEFAULT_WEB_BASE_URL,
62
+ DEFAULT_OAUTH_REDIRECT,
62
63
  } from './auth';
63
64
  export type {
64
65
  OAuthProvider,
65
66
  User,
66
- DeviceCodeStart,
67
- DeviceCodePoll,
68
67
  RemoteDevice,
69
68
  DeviceList,
70
69
  } from './auth';
package/src/types.ts CHANGED
@@ -4,7 +4,8 @@ export interface FeedbackConfig {
4
4
  /**
5
5
  * Auth token for the Yaver agent. Optional in 0.5+: if omitted, the SDK
6
6
  * will hydrate one from AsyncStorage or show its in-app login screen
7
- * (device-code / email / OAuth) the first time the user triggers feedback.
7
+ * (Apple native / OAuth in-app browser / email) the first time the user
8
+ * triggers feedback.
8
9
  */
9
10
  authToken?: string;
10
11
  /**