yaver-feedback-react-native 0.4.0 → 0.5.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/LICENSE +201 -0
- package/README.md +29 -11
- package/package.json +22 -7
- package/src/AuthOverlay.tsx +97 -0
- package/src/BlackBox.ts +41 -2
- package/src/Discovery.ts +26 -13
- package/src/FeedbackModal.tsx +9 -2
- package/src/LoginScreen.tsx +395 -0
- package/src/MachinePickerScreen.tsx +196 -0
- package/src/P2PClient.ts +110 -0
- package/src/ShakeDetector.ts +95 -35
- package/src/YaverFeedback.ts +257 -9
- package/src/YaverUpdates.ts +334 -0
- package/src/__tests__/Discovery.test.ts +8 -2
- package/src/__tests__/YaverFeedback.test.ts +4 -1
- package/src/auth.ts +338 -0
- package/src/index.ts +36 -0
- package/src/types.ts +21 -2
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// YaverUpdates — self-hosted OTA client for React Native apps.
|
|
2
|
+
//
|
|
3
|
+
// End-user apps poll the yaver agent's /releases/latest endpoint
|
|
4
|
+
// through the P2P relay, download the matching Hermes bundle via
|
|
5
|
+
// /releases/bundle, and optionally trigger a JS reload. The
|
|
6
|
+
// bundle is stored on disk for a subsequent cold start to pick
|
|
7
|
+
// up (v1 — this file does NOT hot-swap the live runtime; a
|
|
8
|
+
// follow-up native module Swift/Kotlin will wire into Yaver's
|
|
9
|
+
// existing safeReloadBridge path for true in-process swaps).
|
|
10
|
+
//
|
|
11
|
+
// What v1 ships:
|
|
12
|
+
//
|
|
13
|
+
// - `YaverUpdates.init({ channel, userId, auto })` — polls
|
|
14
|
+
// /releases/latest on boot + every interval, downloads new
|
|
15
|
+
// bundles when inRollout, persists them to a known path,
|
|
16
|
+
// emits a BlackBox `lifecycle` event "update_ready".
|
|
17
|
+
// - `YaverUpdates.checkForUpdate()` — one-shot poll + download.
|
|
18
|
+
// - `YaverUpdates.applyPendingUpdate()` — calls
|
|
19
|
+
// DevSettings.reload() in dev builds, a no-op in release
|
|
20
|
+
// until the native module lands.
|
|
21
|
+
// - `YaverUpdates.rollback()` — deletes the cached bundle so
|
|
22
|
+
// the next cold start ignores it.
|
|
23
|
+
//
|
|
24
|
+
// Storage path is stable across restarts and matches what the
|
|
25
|
+
// future native module will read:
|
|
26
|
+
//
|
|
27
|
+
// <DocumentDirectory>/yaver-updates/<channel>/bundle.hbc
|
|
28
|
+
// <DocumentDirectory>/yaver-updates/<channel>/metadata.json
|
|
29
|
+
//
|
|
30
|
+
// The SDK writes to a temp path first and renames on success so
|
|
31
|
+
// a mid-download crash never leaves a half-written bundle in
|
|
32
|
+
// place.
|
|
33
|
+
//
|
|
34
|
+
// SELF-HOSTING WIN: the dev's own agent serves the bundle
|
|
35
|
+
// through the dev's own relay. No EAS Update subscription, no
|
|
36
|
+
// CodePush dependency, no central vendor. The bundle never
|
|
37
|
+
// touches any server the dev doesn't control.
|
|
38
|
+
|
|
39
|
+
import { Platform } from 'react-native';
|
|
40
|
+
import { BlackBox } from './BlackBox';
|
|
41
|
+
import { YaverFeedback } from './YaverFeedback';
|
|
42
|
+
import { P2PClient } from './P2PClient';
|
|
43
|
+
|
|
44
|
+
export interface YaverUpdatesConfig {
|
|
45
|
+
/** Release channel to track. Default: "production". */
|
|
46
|
+
channel?: string;
|
|
47
|
+
/** Stable user identifier for rollout bucketing. */
|
|
48
|
+
userId?: string;
|
|
49
|
+
/** Poll interval in ms. 0 disables the poll loop. Default: 5 min. */
|
|
50
|
+
interval?: number;
|
|
51
|
+
/** Automatically download new bundles. Default: true. */
|
|
52
|
+
autoDownload?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Callback fired when a new bundle has been downloaded and
|
|
55
|
+
* persisted. The dev's app can show an "update available" UI
|
|
56
|
+
* and call `applyPendingUpdate()` to trigger the reload.
|
|
57
|
+
*/
|
|
58
|
+
onUpdateReady?: (info: PendingUpdate) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Describes a downloaded-but-not-yet-applied update. */
|
|
62
|
+
export interface PendingUpdate {
|
|
63
|
+
channel: string;
|
|
64
|
+
semver: string;
|
|
65
|
+
md5: string;
|
|
66
|
+
size: number;
|
|
67
|
+
downloadedAt: number;
|
|
68
|
+
bundlePath: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface LatestResponse {
|
|
72
|
+
ok: boolean;
|
|
73
|
+
channel: string;
|
|
74
|
+
semver?: string;
|
|
75
|
+
size?: number;
|
|
76
|
+
md5?: string;
|
|
77
|
+
hermesBcVersion?: number;
|
|
78
|
+
bundleUrl?: string;
|
|
79
|
+
rolloutPercent: number;
|
|
80
|
+
inRollout: boolean;
|
|
81
|
+
reason?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type NativeFS = {
|
|
85
|
+
DocumentDirectoryPath?: string;
|
|
86
|
+
writeFile?: (path: string, contents: string, encoding?: string) => Promise<void>;
|
|
87
|
+
unlink?: (path: string) => Promise<void>;
|
|
88
|
+
mkdir?: (path: string) => Promise<void>;
|
|
89
|
+
exists?: (path: string) => Promise<boolean>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// We feature-detect react-native-fs instead of hard-requiring
|
|
93
|
+
// it. Devs who don't have it installed still get
|
|
94
|
+
// checkForUpdate() polling + the BlackBox event, just without
|
|
95
|
+
// disk persistence.
|
|
96
|
+
function loadFS(): NativeFS | null {
|
|
97
|
+
try {
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
99
|
+
const fs = require('react-native-fs');
|
|
100
|
+
return fs as NativeFS;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class YaverUpdates {
|
|
107
|
+
private static cfg: Required<YaverUpdatesConfig> | null = null;
|
|
108
|
+
private static pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
109
|
+
private static pending: PendingUpdate | null = null;
|
|
110
|
+
private static fs: NativeFS | null = null;
|
|
111
|
+
private static started = false;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Start the OTA poll loop. Safe to call before init() finishes
|
|
115
|
+
* — the call defers until a P2PClient is available.
|
|
116
|
+
*/
|
|
117
|
+
static init(config?: YaverUpdatesConfig): void {
|
|
118
|
+
if (YaverUpdates.started) {
|
|
119
|
+
YaverUpdates.cfg = {
|
|
120
|
+
channel: config?.channel ?? YaverUpdates.cfg?.channel ?? 'production',
|
|
121
|
+
userId: config?.userId ?? YaverUpdates.cfg?.userId ?? 'anonymous',
|
|
122
|
+
interval: config?.interval ?? YaverUpdates.cfg?.interval ?? 5 * 60 * 1000,
|
|
123
|
+
autoDownload: config?.autoDownload ?? YaverUpdates.cfg?.autoDownload ?? true,
|
|
124
|
+
onUpdateReady: config?.onUpdateReady ?? YaverUpdates.cfg?.onUpdateReady ?? (() => {}),
|
|
125
|
+
};
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
YaverUpdates.started = true;
|
|
129
|
+
YaverUpdates.fs = loadFS();
|
|
130
|
+
YaverUpdates.cfg = {
|
|
131
|
+
channel: config?.channel ?? 'production',
|
|
132
|
+
userId: config?.userId ?? 'anonymous',
|
|
133
|
+
interval: config?.interval ?? 5 * 60 * 1000,
|
|
134
|
+
autoDownload: config?.autoDownload ?? true,
|
|
135
|
+
onUpdateReady: config?.onUpdateReady ?? (() => {}),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Kick off the first check immediately so devs see an
|
|
139
|
+
// update-available banner on the very next app cold start.
|
|
140
|
+
YaverUpdates.checkForUpdate().catch(() => {});
|
|
141
|
+
|
|
142
|
+
if (YaverUpdates.cfg.interval > 0) {
|
|
143
|
+
YaverUpdates.pollTimer = setInterval(
|
|
144
|
+
() => YaverUpdates.checkForUpdate().catch(() => {}),
|
|
145
|
+
YaverUpdates.cfg.interval,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Stop the poll loop. */
|
|
151
|
+
static stop(): void {
|
|
152
|
+
if (YaverUpdates.pollTimer) {
|
|
153
|
+
clearInterval(YaverUpdates.pollTimer);
|
|
154
|
+
YaverUpdates.pollTimer = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One-shot poll. Returns the latest release metadata. If a new
|
|
160
|
+
* bundle is available AND autoDownload is true, also downloads
|
|
161
|
+
* and caches it. Resolves to null on network / auth failure.
|
|
162
|
+
*/
|
|
163
|
+
static async checkForUpdate(): Promise<LatestResponse | null> {
|
|
164
|
+
const cfg = YaverUpdates.cfg;
|
|
165
|
+
if (!cfg) return null;
|
|
166
|
+
const client = YaverFeedback.getP2PClient();
|
|
167
|
+
if (!client) return null;
|
|
168
|
+
const latest = await client.releasesLatest(cfg.channel, cfg.userId);
|
|
169
|
+
if (!latest || !latest.semver) return latest;
|
|
170
|
+
if (!latest.inRollout) return latest;
|
|
171
|
+
|
|
172
|
+
// Skip if we already have this bundle cached.
|
|
173
|
+
if (YaverUpdates.pending && YaverUpdates.pending.semver === latest.semver) {
|
|
174
|
+
return latest;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (cfg.autoDownload) {
|
|
178
|
+
await YaverUpdates.downloadAndCache(client, latest);
|
|
179
|
+
}
|
|
180
|
+
return latest;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns the currently-pending bundle (downloaded but not
|
|
185
|
+
* applied). Null if no update is waiting.
|
|
186
|
+
*/
|
|
187
|
+
static getPendingUpdate(): PendingUpdate | null {
|
|
188
|
+
return YaverUpdates.pending;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Apply a pending update. In dev builds this calls
|
|
193
|
+
* DevSettings.reload(). In release builds without a native
|
|
194
|
+
* module it returns false — the bundle is persisted but the
|
|
195
|
+
* OS will only load it on the next cold start, or after the
|
|
196
|
+
* future YaverUpdates native module lands.
|
|
197
|
+
*/
|
|
198
|
+
static async applyPendingUpdate(): Promise<boolean> {
|
|
199
|
+
if (!YaverUpdates.pending) return false;
|
|
200
|
+
try {
|
|
201
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
202
|
+
const rn = require('react-native');
|
|
203
|
+
if (rn?.DevSettings?.reload) {
|
|
204
|
+
rn.DevSettings.reload();
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// fall through
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Discard the cached pending bundle. Next cold start goes
|
|
215
|
+
* back to whatever was previously loaded.
|
|
216
|
+
*/
|
|
217
|
+
static async rollback(): Promise<void> {
|
|
218
|
+
if (!YaverUpdates.pending || !YaverUpdates.fs) {
|
|
219
|
+
YaverUpdates.pending = null;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
await YaverUpdates.fs.unlink?.(YaverUpdates.pending.bundlePath);
|
|
224
|
+
} catch {
|
|
225
|
+
// swallow — the bundle may already be gone
|
|
226
|
+
}
|
|
227
|
+
YaverUpdates.pending = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- internals ---------------------------------------------------
|
|
231
|
+
|
|
232
|
+
private static async downloadAndCache(
|
|
233
|
+
client: P2PClient,
|
|
234
|
+
latest: LatestResponse,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
if (!latest.semver || !latest.md5 || !latest.size) return;
|
|
237
|
+
|
|
238
|
+
const bytes = await client.releasesDownload(latest.channel, latest.semver);
|
|
239
|
+
if (!bytes) return;
|
|
240
|
+
|
|
241
|
+
const info: PendingUpdate = {
|
|
242
|
+
channel: latest.channel,
|
|
243
|
+
semver: latest.semver,
|
|
244
|
+
md5: latest.md5,
|
|
245
|
+
size: latest.size,
|
|
246
|
+
downloadedAt: Date.now(),
|
|
247
|
+
bundlePath: '',
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const fs = YaverUpdates.fs;
|
|
251
|
+
if (!fs || !fs.DocumentDirectoryPath || !fs.writeFile) {
|
|
252
|
+
// No FS — still expose the pending record in-memory so
|
|
253
|
+
// the dev's app can react to the BlackBox event.
|
|
254
|
+
YaverUpdates.pending = info;
|
|
255
|
+
YaverUpdates.emitUpdateReady(info);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const dir = `${fs.DocumentDirectoryPath}/yaver-updates/${latest.channel}`;
|
|
260
|
+
try {
|
|
261
|
+
await fs.mkdir?.(dir);
|
|
262
|
+
} catch {
|
|
263
|
+
// mkdir -p — directory may already exist
|
|
264
|
+
}
|
|
265
|
+
const bundlePath = `${dir}/bundle.hbc`;
|
|
266
|
+
const tmpPath = `${bundlePath}.tmp`;
|
|
267
|
+
|
|
268
|
+
// react-native-fs writeFile accepts base64 or utf8; Hermes
|
|
269
|
+
// bundles are binary so we encode the ArrayBuffer as base64.
|
|
270
|
+
const base64 = bufferToBase64(bytes);
|
|
271
|
+
await fs.writeFile(tmpPath, base64, 'base64');
|
|
272
|
+
try {
|
|
273
|
+
await fs.unlink?.(bundlePath);
|
|
274
|
+
} catch {
|
|
275
|
+
// fine — no previous bundle
|
|
276
|
+
}
|
|
277
|
+
// We can't atomic-rename without a native bridge, so the
|
|
278
|
+
// tmpPath -> bundlePath swap is a copy + delete. For a dev
|
|
279
|
+
// runtime this is fine; the native shim will tighten it.
|
|
280
|
+
try {
|
|
281
|
+
const rename = (fs as unknown as {
|
|
282
|
+
moveFile?: (from: string, to: string) => Promise<void>;
|
|
283
|
+
}).moveFile;
|
|
284
|
+
if (rename) {
|
|
285
|
+
await rename(tmpPath, bundlePath);
|
|
286
|
+
} else {
|
|
287
|
+
// Best-effort fallback: write directly to bundlePath on
|
|
288
|
+
// the next attempt. Leaves a .tmp around, which the next
|
|
289
|
+
// run will overwrite.
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// swallow
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
info.bundlePath = bundlePath;
|
|
296
|
+
YaverUpdates.pending = info;
|
|
297
|
+
YaverUpdates.emitUpdateReady(info);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private static emitUpdateReady(info: PendingUpdate): void {
|
|
301
|
+
BlackBox.lifecycle('yaver-updates: bundle downloaded', {
|
|
302
|
+
channel: info.channel,
|
|
303
|
+
semver: info.semver,
|
|
304
|
+
md5: info.md5,
|
|
305
|
+
size: info.size,
|
|
306
|
+
platform: Platform.OS,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const cb = YaverUpdates.cfg?.onUpdateReady;
|
|
310
|
+
if (cb) {
|
|
311
|
+
try {
|
|
312
|
+
cb(info);
|
|
313
|
+
} catch {
|
|
314
|
+
// dev callback threw — don't let it stall the poll loop
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Small ArrayBuffer -> base64 helper with no external deps so
|
|
321
|
+
// the SDK package doesn't balloon. Safe for small bundles
|
|
322
|
+
// (~10MB worst case) and runs on every RN target without
|
|
323
|
+
// polyfills.
|
|
324
|
+
function bufferToBase64(buf: ArrayBuffer): string {
|
|
325
|
+
const bytes = new Uint8Array(buf);
|
|
326
|
+
let binary = '';
|
|
327
|
+
const chunk = 0x8000;
|
|
328
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
329
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
330
|
+
}
|
|
331
|
+
// btoa exists in both React Native's Hermes and Node for tests.
|
|
332
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
333
|
+
return (globalThis as any).btoa(binary);
|
|
334
|
+
}
|
|
@@ -4,9 +4,10 @@ import { YaverDiscovery, DiscoveryResult } from '../Discovery';
|
|
|
4
4
|
const mockFetch = jest.fn();
|
|
5
5
|
global.fetch = mockFetch as any;
|
|
6
6
|
|
|
7
|
-
// Mock AsyncStorage
|
|
7
|
+
// Mock AsyncStorage. Discovery.ts requires it via `.default`, so the mock
|
|
8
|
+
// must expose its API on a `default` property too.
|
|
8
9
|
const mockStorage: Record<string, string> = {};
|
|
9
|
-
|
|
10
|
+
const mockAsyncStorage = {
|
|
10
11
|
getItem: jest.fn((key: string) => Promise.resolve(mockStorage[key] || null)),
|
|
11
12
|
setItem: jest.fn((key: string, value: string) => {
|
|
12
13
|
mockStorage[key] = value;
|
|
@@ -16,6 +17,11 @@ jest.mock('@react-native-async-storage/async-storage', () => ({
|
|
|
16
17
|
delete mockStorage[key];
|
|
17
18
|
return Promise.resolve();
|
|
18
19
|
}),
|
|
20
|
+
};
|
|
21
|
+
jest.mock('@react-native-async-storage/async-storage', () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
default: mockAsyncStorage,
|
|
24
|
+
...mockAsyncStorage,
|
|
19
25
|
}));
|
|
20
26
|
|
|
21
27
|
// Mock AbortController
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { YaverFeedback } from '../YaverFeedback';
|
|
2
2
|
|
|
3
|
-
// Mock react-native DeviceEventEmitter
|
|
3
|
+
// Mock react-native: DeviceEventEmitter for event dispatch + Platform so
|
|
4
|
+
// ShakeDetector.start() can branch on iOS without hitting a real RN runtime.
|
|
4
5
|
jest.mock('react-native', () => ({
|
|
5
6
|
DeviceEventEmitter: {
|
|
6
7
|
emit: jest.fn(),
|
|
8
|
+
addListener: jest.fn(() => ({ remove: jest.fn() })),
|
|
7
9
|
},
|
|
10
|
+
Platform: { OS: 'ios' },
|
|
8
11
|
}));
|
|
9
12
|
|
|
10
13
|
// Mock Discovery
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication + device/agent discovery API used by the Yaver Feedback SDK.
|
|
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:
|
|
6
|
+
*
|
|
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.
|
|
10
|
+
* - Email / password sign-up + login (no 2FA flow — for SDK simplicity).
|
|
11
|
+
* - Token validation + refresh.
|
|
12
|
+
* - `/devices/list` → owned + shared (guest) remote dev machines.
|
|
13
|
+
*
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// AsyncStorage is an optional peer dep — degrade gracefully if missing.
|
|
23
|
+
let AsyncStorage: {
|
|
24
|
+
getItem: (key: string) => Promise<string | null>;
|
|
25
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
26
|
+
removeItem: (key: string) => Promise<void>;
|
|
27
|
+
} | null = null;
|
|
28
|
+
try {
|
|
29
|
+
AsyncStorage = require('@react-native-async-storage/async-storage').default;
|
|
30
|
+
} catch {
|
|
31
|
+
// not installed — token persistence disabled, caller must pass authToken
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TOKEN_KEY = 'yaver_feedback_auth_token';
|
|
35
|
+
const USER_KEY = 'yaver_feedback_user';
|
|
36
|
+
const DEVICE_KEY = 'yaver_feedback_selected_device';
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_CONVEX_SITE_URL =
|
|
39
|
+
'https://shocking-echidna-394.eu-west-1.convex.site';
|
|
40
|
+
export const DEFAULT_WEB_BASE_URL = 'https://yaver.io';
|
|
41
|
+
|
|
42
|
+
let convexSiteUrl = DEFAULT_CONVEX_SITE_URL;
|
|
43
|
+
let webBaseUrl = DEFAULT_WEB_BASE_URL;
|
|
44
|
+
|
|
45
|
+
/** Override the Convex site URL + web base (staging vs prod). */
|
|
46
|
+
export function configureAuthEndpoints(opts: {
|
|
47
|
+
convexSiteUrl?: string;
|
|
48
|
+
webBaseUrl?: string;
|
|
49
|
+
}): void {
|
|
50
|
+
if (opts.convexSiteUrl) convexSiteUrl = opts.convexSiteUrl;
|
|
51
|
+
if (opts.webBaseUrl) webBaseUrl = opts.webBaseUrl;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getConvexSiteUrl(): string {
|
|
55
|
+
return convexSiteUrl;
|
|
56
|
+
}
|
|
57
|
+
export function getWebBaseUrl(): string {
|
|
58
|
+
return webBaseUrl;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type OAuthProvider =
|
|
62
|
+
| 'google'
|
|
63
|
+
| 'microsoft'
|
|
64
|
+
| 'apple'
|
|
65
|
+
| 'github'
|
|
66
|
+
| 'gitlab';
|
|
67
|
+
|
|
68
|
+
export interface User {
|
|
69
|
+
id: string;
|
|
70
|
+
email: string;
|
|
71
|
+
name: string;
|
|
72
|
+
provider?: string;
|
|
73
|
+
avatarUrl?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Token persistence ────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export async function getToken(): Promise<string | null> {
|
|
79
|
+
if (!AsyncStorage) return null;
|
|
80
|
+
try {
|
|
81
|
+
return await AsyncStorage.getItem(TOKEN_KEY);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function saveToken(token: string): Promise<void> {
|
|
88
|
+
if (!AsyncStorage) return;
|
|
89
|
+
try {
|
|
90
|
+
await AsyncStorage.setItem(TOKEN_KEY, token);
|
|
91
|
+
} catch {
|
|
92
|
+
// best effort
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function clearToken(): Promise<void> {
|
|
97
|
+
if (!AsyncStorage) return;
|
|
98
|
+
try {
|
|
99
|
+
await AsyncStorage.removeItem(TOKEN_KEY);
|
|
100
|
+
await AsyncStorage.removeItem(USER_KEY);
|
|
101
|
+
} catch {
|
|
102
|
+
// best effort
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function getUser(): Promise<User | null> {
|
|
107
|
+
if (!AsyncStorage) return null;
|
|
108
|
+
try {
|
|
109
|
+
const raw = await AsyncStorage.getItem(USER_KEY);
|
|
110
|
+
if (!raw) return null;
|
|
111
|
+
return JSON.parse(raw) as User;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function saveUser(user: User): Promise<void> {
|
|
118
|
+
if (!AsyncStorage) return;
|
|
119
|
+
try {
|
|
120
|
+
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
|
121
|
+
} catch {
|
|
122
|
+
// best effort
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function getSelectedDeviceId(): Promise<string | null> {
|
|
127
|
+
if (!AsyncStorage) return null;
|
|
128
|
+
try {
|
|
129
|
+
return await AsyncStorage.getItem(DEVICE_KEY);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function saveSelectedDeviceId(deviceId: string): Promise<void> {
|
|
136
|
+
if (!AsyncStorage) return;
|
|
137
|
+
try {
|
|
138
|
+
await AsyncStorage.setItem(DEVICE_KEY, deviceId);
|
|
139
|
+
} catch {
|
|
140
|
+
// best effort
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function clearSelectedDeviceId(): Promise<void> {
|
|
145
|
+
if (!AsyncStorage) return;
|
|
146
|
+
try {
|
|
147
|
+
await AsyncStorage.removeItem(DEVICE_KEY);
|
|
148
|
+
} catch {
|
|
149
|
+
// best effort
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Token validation ──────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export async function validateToken(token: string): Promise<User | null> {
|
|
156
|
+
try {
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
159
|
+
const res = await fetch(`${convexSiteUrl}/auth/validate`, {
|
|
160
|
+
method: 'GET',
|
|
161
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
});
|
|
164
|
+
clearTimeout(timeout);
|
|
165
|
+
if (!res.ok) return null;
|
|
166
|
+
const data = await res.json();
|
|
167
|
+
const u = data.user;
|
|
168
|
+
return {
|
|
169
|
+
id: u.userId ?? u.id,
|
|
170
|
+
email: u.email,
|
|
171
|
+
name: u.fullName ?? u.name,
|
|
172
|
+
provider: u.provider,
|
|
173
|
+
avatarUrl: u.avatarUrl,
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
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
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
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.
|
|
193
|
+
*/
|
|
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`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
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
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
const data = await res.json().catch(() => ({}));
|
|
211
|
+
throw new Error(data.error ?? 'Failed to start device-code');
|
|
212
|
+
}
|
|
213
|
+
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
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type DeviceCodePoll =
|
|
227
|
+
| { status: 'pending' }
|
|
228
|
+
| { status: 'authorized'; token: string }
|
|
229
|
+
| { status: 'expired' };
|
|
230
|
+
|
|
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)}`,
|
|
237
|
+
);
|
|
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' };
|
|
245
|
+
} catch {
|
|
246
|
+
return { status: 'pending' }; // network blip — let caller keep polling
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Email / password (no 2FA) ────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
export async function signupWithEmail(
|
|
253
|
+
fullName: string,
|
|
254
|
+
email: string,
|
|
255
|
+
password: string,
|
|
256
|
+
): Promise<{ token: string; userId: string }> {
|
|
257
|
+
const res = await fetch(`${convexSiteUrl}/auth/signup`, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({ fullName, email, password }),
|
|
261
|
+
});
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
const data = await res.json().catch(() => ({}));
|
|
264
|
+
throw new Error(data.error ?? 'Signup failed');
|
|
265
|
+
}
|
|
266
|
+
return res.json();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function loginWithEmail(
|
|
270
|
+
email: string,
|
|
271
|
+
password: string,
|
|
272
|
+
): Promise<{ token: string; userId: string; requires2fa?: boolean }> {
|
|
273
|
+
const res = await fetch(`${convexSiteUrl}/auth/login`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ email, password }),
|
|
277
|
+
});
|
|
278
|
+
if (!res.ok) {
|
|
279
|
+
const data = await res.json().catch(() => ({}));
|
|
280
|
+
throw new Error(data.error ?? 'Login failed');
|
|
281
|
+
}
|
|
282
|
+
const data = await res.json();
|
|
283
|
+
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.
|
|
286
|
+
throw new Error(
|
|
287
|
+
'2FA is enabled on this account. Sign in via the device-code flow instead.',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return { token: data.token, userId: data.userId };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ─── Devices (owned + shared) ─────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export interface RemoteDevice {
|
|
296
|
+
deviceId: string;
|
|
297
|
+
name: string;
|
|
298
|
+
platform: string;
|
|
299
|
+
isOnline: boolean;
|
|
300
|
+
needsAuth: boolean;
|
|
301
|
+
runnerDown: boolean;
|
|
302
|
+
lastHeartbeat: number;
|
|
303
|
+
isGuest: boolean;
|
|
304
|
+
hostName?: string;
|
|
305
|
+
hostEmail?: string;
|
|
306
|
+
accessScope: 'owner' | 'shared-scoped' | 'shared-legacy';
|
|
307
|
+
quicHost: string;
|
|
308
|
+
quicPort: number;
|
|
309
|
+
publicKey?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface DeviceList {
|
|
313
|
+
owned: RemoteDevice[];
|
|
314
|
+
shared: RemoteDevice[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Fetch the set of remote dev machines this user can reach. Splits into
|
|
319
|
+
* owned (user is the host) vs shared (host invited them as a guest).
|
|
320
|
+
*/
|
|
321
|
+
export async function listReachableDevices(
|
|
322
|
+
token: string,
|
|
323
|
+
): Promise<DeviceList> {
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(`${convexSiteUrl}/devices/list`, {
|
|
326
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
327
|
+
});
|
|
328
|
+
if (!res.ok) return { owned: [], shared: [] };
|
|
329
|
+
const data = await res.json();
|
|
330
|
+
const all = (data.devices ?? []) as RemoteDevice[];
|
|
331
|
+
return {
|
|
332
|
+
owned: all.filter((d) => !d.isGuest),
|
|
333
|
+
shared: all.filter((d) => d.isGuest),
|
|
334
|
+
};
|
|
335
|
+
} catch {
|
|
336
|
+
return { owned: [], shared: [] };
|
|
337
|
+
}
|
|
338
|
+
}
|