yaver-feedback-react-native 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/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,395 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TextInput,
|
|
6
|
+
TouchableOpacity,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
ActivityIndicator,
|
|
9
|
+
SafeAreaView,
|
|
10
|
+
ScrollView,
|
|
11
|
+
Linking,
|
|
12
|
+
Platform,
|
|
13
|
+
} from 'react-native';
|
|
14
|
+
import {
|
|
15
|
+
loginWithEmail,
|
|
16
|
+
signupWithEmail,
|
|
17
|
+
pollDeviceCode,
|
|
18
|
+
startDeviceCode,
|
|
19
|
+
validateToken,
|
|
20
|
+
saveToken,
|
|
21
|
+
saveUser,
|
|
22
|
+
OAuthProvider,
|
|
23
|
+
DeviceCodeStart,
|
|
24
|
+
} from './auth';
|
|
25
|
+
|
|
26
|
+
export interface YaverLoginScreenProps {
|
|
27
|
+
/** Invoked once a session token is issued and the user is loaded. */
|
|
28
|
+
onLoggedIn: (token: string) => void;
|
|
29
|
+
/** Optional cancel button shown in header. */
|
|
30
|
+
onCancel?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Mode = 'device' | 'email';
|
|
34
|
+
|
|
35
|
+
const PROVIDERS: { id: OAuthProvider; label: string; emoji: string }[] = [
|
|
36
|
+
{ id: 'apple', label: 'Apple', emoji: '' },
|
|
37
|
+
{ id: 'google', label: 'Google', emoji: 'G' },
|
|
38
|
+
{ id: 'github', label: 'GitHub', emoji: '' },
|
|
39
|
+
{ id: 'gitlab', label: 'GitLab', emoji: '' },
|
|
40
|
+
{ id: 'microsoft', label: 'Microsoft', emoji: 'M' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Full-screen in-SDK login. Device-code is the default flow — users sign in
|
|
45
|
+
* with any OAuth provider (Apple/Google/GitHub/GitLab/Microsoft) or email on
|
|
46
|
+
* yaver.io and the SDK polls for the issued session token. Email/password is
|
|
47
|
+
* available as an inline fallback for headless environments.
|
|
48
|
+
*/
|
|
49
|
+
export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
|
|
50
|
+
onLoggedIn,
|
|
51
|
+
onCancel,
|
|
52
|
+
}) => {
|
|
53
|
+
const [mode, setMode] = useState<Mode>('device');
|
|
54
|
+
|
|
55
|
+
const [code, setCode] = useState<DeviceCodeStart | null>(null);
|
|
56
|
+
const [codeError, setCodeError] = useState<string | null>(null);
|
|
57
|
+
const [starting, setStarting] = useState(false);
|
|
58
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
59
|
+
const expiredTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
60
|
+
|
|
61
|
+
const [emailMode, setEmailMode] = useState<'login' | 'signup'>('login');
|
|
62
|
+
const [fullName, setFullName] = useState('');
|
|
63
|
+
const [email, setEmail] = useState('');
|
|
64
|
+
const [password, setPassword] = useState('');
|
|
65
|
+
const [emailBusy, setEmailBusy] = useState(false);
|
|
66
|
+
const [emailError, setEmailError] = useState<string | null>(null);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (mode === 'device' && !code && !starting) {
|
|
70
|
+
void beginDeviceCode(undefined);
|
|
71
|
+
}
|
|
72
|
+
return () => {
|
|
73
|
+
stopPolling();
|
|
74
|
+
};
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [mode]);
|
|
77
|
+
|
|
78
|
+
const stopPolling = () => {
|
|
79
|
+
if (pollRef.current) {
|
|
80
|
+
clearInterval(pollRef.current);
|
|
81
|
+
pollRef.current = null;
|
|
82
|
+
}
|
|
83
|
+
if (expiredTimerRef.current) {
|
|
84
|
+
clearTimeout(expiredTimerRef.current);
|
|
85
|
+
expiredTimerRef.current = null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const beginDeviceCode = async (preferredProvider?: OAuthProvider) => {
|
|
90
|
+
setStarting(true);
|
|
91
|
+
setCodeError(null);
|
|
92
|
+
stopPolling();
|
|
93
|
+
try {
|
|
94
|
+
const result = await startDeviceCode({
|
|
95
|
+
platform: Platform.OS,
|
|
96
|
+
machineName: `feedback-sdk-${Platform.OS}`,
|
|
97
|
+
preferredProvider,
|
|
98
|
+
});
|
|
99
|
+
setCode(result);
|
|
100
|
+
|
|
101
|
+
pollRef.current = setInterval(async () => {
|
|
102
|
+
const poll = await pollDeviceCode(result.deviceCode);
|
|
103
|
+
if (poll.status === 'authorized') {
|
|
104
|
+
stopPolling();
|
|
105
|
+
const user = await validateToken(poll.token);
|
|
106
|
+
await saveToken(poll.token);
|
|
107
|
+
if (user) await saveUser(user);
|
|
108
|
+
onLoggedIn(poll.token);
|
|
109
|
+
} else if (poll.status === 'expired') {
|
|
110
|
+
stopPolling();
|
|
111
|
+
setCodeError('Kod doldu — tekrar başlat.');
|
|
112
|
+
setCode(null);
|
|
113
|
+
}
|
|
114
|
+
}, 3_000);
|
|
115
|
+
|
|
116
|
+
expiredTimerRef.current = setTimeout(() => {
|
|
117
|
+
stopPolling();
|
|
118
|
+
setCode(null);
|
|
119
|
+
setCodeError('Kod süresi doldu.');
|
|
120
|
+
}, Math.max(0, result.expiresAt - Date.now()));
|
|
121
|
+
} catch (err) {
|
|
122
|
+
setCodeError(err instanceof Error ? err.message : String(err));
|
|
123
|
+
} finally {
|
|
124
|
+
setStarting(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const openVerification = () => {
|
|
129
|
+
if (!code) return;
|
|
130
|
+
Linking.openURL(code.verificationUrl).catch(() => {
|
|
131
|
+
setCodeError('Tarayıcı açılamadı — URL’yi elle aç.');
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleEmailSubmit = async () => {
|
|
136
|
+
setEmailError(null);
|
|
137
|
+
if (!email.trim() || !password) {
|
|
138
|
+
setEmailError('E-posta ve parola zorunlu.');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setEmailBusy(true);
|
|
142
|
+
try {
|
|
143
|
+
const result =
|
|
144
|
+
emailMode === 'signup'
|
|
145
|
+
? await signupWithEmail(fullName.trim() || email.trim(), email.trim(), password)
|
|
146
|
+
: await loginWithEmail(email.trim(), password);
|
|
147
|
+
const user = await validateToken(result.token);
|
|
148
|
+
await saveToken(result.token);
|
|
149
|
+
if (user) await saveUser(user);
|
|
150
|
+
onLoggedIn(result.token);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
setEmailError(err instanceof Error ? err.message : String(err));
|
|
153
|
+
} finally {
|
|
154
|
+
setEmailBusy(false);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<SafeAreaView style={styles.container}>
|
|
160
|
+
<ScrollView
|
|
161
|
+
contentContainerStyle={styles.content}
|
|
162
|
+
keyboardShouldPersistTaps="handled"
|
|
163
|
+
>
|
|
164
|
+
<View style={styles.header}>
|
|
165
|
+
<Text style={styles.title}>Yaver Girişi</Text>
|
|
166
|
+
{onCancel && (
|
|
167
|
+
<TouchableOpacity onPress={onCancel} style={styles.cancel}>
|
|
168
|
+
<Text style={styles.cancelText}>İptal</Text>
|
|
169
|
+
</TouchableOpacity>
|
|
170
|
+
)}
|
|
171
|
+
</View>
|
|
172
|
+
|
|
173
|
+
<View style={styles.tabRow}>
|
|
174
|
+
<TouchableOpacity
|
|
175
|
+
style={[styles.tab, mode === 'device' && styles.tabActive]}
|
|
176
|
+
onPress={() => setMode('device')}
|
|
177
|
+
>
|
|
178
|
+
<Text
|
|
179
|
+
style={[styles.tabText, mode === 'device' && styles.tabTextActive]}
|
|
180
|
+
>
|
|
181
|
+
Hızlı Giriş (OAuth)
|
|
182
|
+
</Text>
|
|
183
|
+
</TouchableOpacity>
|
|
184
|
+
<TouchableOpacity
|
|
185
|
+
style={[styles.tab, mode === 'email' && styles.tabActive]}
|
|
186
|
+
onPress={() => setMode('email')}
|
|
187
|
+
>
|
|
188
|
+
<Text
|
|
189
|
+
style={[styles.tabText, mode === 'email' && styles.tabTextActive]}
|
|
190
|
+
>
|
|
191
|
+
E-posta
|
|
192
|
+
</Text>
|
|
193
|
+
</TouchableOpacity>
|
|
194
|
+
</View>
|
|
195
|
+
|
|
196
|
+
{mode === 'device' && (
|
|
197
|
+
<View style={styles.section}>
|
|
198
|
+
{starting ? (
|
|
199
|
+
<ActivityIndicator color="#6366f1" style={{ marginVertical: 40 }} />
|
|
200
|
+
) : code ? (
|
|
201
|
+
<>
|
|
202
|
+
<Text style={styles.hint}>
|
|
203
|
+
Tarayıcıda yaver.io/auth/device aç ve aşağıdaki kodu gir.
|
|
204
|
+
Sağlayıcıyla giriş yaptığında buraya otomatik dönecek.
|
|
205
|
+
</Text>
|
|
206
|
+
<View style={styles.codeBox}>
|
|
207
|
+
<Text style={styles.codeText}>{code.userCode}</Text>
|
|
208
|
+
</View>
|
|
209
|
+
<TouchableOpacity style={styles.primaryButton} onPress={openVerification}>
|
|
210
|
+
<Text style={styles.primaryButtonText}>Tarayıcıda Aç</Text>
|
|
211
|
+
</TouchableOpacity>
|
|
212
|
+
|
|
213
|
+
<Text style={[styles.hint, { marginTop: 20 }]}>
|
|
214
|
+
Sağlayıcıyı önceden seçmek istersen:
|
|
215
|
+
</Text>
|
|
216
|
+
<View style={styles.providerRow}>
|
|
217
|
+
{PROVIDERS.map((p) => (
|
|
218
|
+
<TouchableOpacity
|
|
219
|
+
key={p.id}
|
|
220
|
+
style={styles.providerButton}
|
|
221
|
+
onPress={() => beginDeviceCode(p.id)}
|
|
222
|
+
>
|
|
223
|
+
<Text style={styles.providerButtonText}>{p.label}</Text>
|
|
224
|
+
</TouchableOpacity>
|
|
225
|
+
))}
|
|
226
|
+
</View>
|
|
227
|
+
</>
|
|
228
|
+
) : (
|
|
229
|
+
<TouchableOpacity
|
|
230
|
+
style={styles.primaryButton}
|
|
231
|
+
onPress={() => beginDeviceCode(undefined)}
|
|
232
|
+
>
|
|
233
|
+
<Text style={styles.primaryButtonText}>Tekrar Başlat</Text>
|
|
234
|
+
</TouchableOpacity>
|
|
235
|
+
)}
|
|
236
|
+
{codeError && <Text style={styles.error}>{codeError}</Text>}
|
|
237
|
+
</View>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{mode === 'email' && (
|
|
241
|
+
<View style={styles.section}>
|
|
242
|
+
<View style={styles.tabRow}>
|
|
243
|
+
<TouchableOpacity
|
|
244
|
+
style={[styles.subTab, emailMode === 'login' && styles.subTabActive]}
|
|
245
|
+
onPress={() => setEmailMode('login')}
|
|
246
|
+
>
|
|
247
|
+
<Text style={styles.subTabText}>Giriş</Text>
|
|
248
|
+
</TouchableOpacity>
|
|
249
|
+
<TouchableOpacity
|
|
250
|
+
style={[styles.subTab, emailMode === 'signup' && styles.subTabActive]}
|
|
251
|
+
onPress={() => setEmailMode('signup')}
|
|
252
|
+
>
|
|
253
|
+
<Text style={styles.subTabText}>Kayıt</Text>
|
|
254
|
+
</TouchableOpacity>
|
|
255
|
+
</View>
|
|
256
|
+
|
|
257
|
+
{emailMode === 'signup' && (
|
|
258
|
+
<>
|
|
259
|
+
<Text style={styles.label}>Ad Soyad</Text>
|
|
260
|
+
<TextInput
|
|
261
|
+
style={styles.input}
|
|
262
|
+
value={fullName}
|
|
263
|
+
onChangeText={setFullName}
|
|
264
|
+
placeholder="Adın Soyadın"
|
|
265
|
+
placeholderTextColor="#666"
|
|
266
|
+
autoCapitalize="words"
|
|
267
|
+
/>
|
|
268
|
+
</>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
<Text style={styles.label}>E-posta</Text>
|
|
272
|
+
<TextInput
|
|
273
|
+
style={styles.input}
|
|
274
|
+
value={email}
|
|
275
|
+
onChangeText={setEmail}
|
|
276
|
+
placeholder="you@example.com"
|
|
277
|
+
placeholderTextColor="#666"
|
|
278
|
+
keyboardType="email-address"
|
|
279
|
+
autoCapitalize="none"
|
|
280
|
+
autoCorrect={false}
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<Text style={styles.label}>Parola</Text>
|
|
284
|
+
<TextInput
|
|
285
|
+
style={styles.input}
|
|
286
|
+
value={password}
|
|
287
|
+
onChangeText={setPassword}
|
|
288
|
+
placeholder="••••••••"
|
|
289
|
+
placeholderTextColor="#666"
|
|
290
|
+
secureTextEntry
|
|
291
|
+
autoCapitalize="none"
|
|
292
|
+
/>
|
|
293
|
+
|
|
294
|
+
<TouchableOpacity
|
|
295
|
+
style={styles.primaryButton}
|
|
296
|
+
onPress={handleEmailSubmit}
|
|
297
|
+
disabled={emailBusy}
|
|
298
|
+
>
|
|
299
|
+
{emailBusy ? (
|
|
300
|
+
<ActivityIndicator color="#fff" />
|
|
301
|
+
) : (
|
|
302
|
+
<Text style={styles.primaryButtonText}>
|
|
303
|
+
{emailMode === 'signup' ? 'Kayıt Ol' : 'Giriş Yap'}
|
|
304
|
+
</Text>
|
|
305
|
+
)}
|
|
306
|
+
</TouchableOpacity>
|
|
307
|
+
|
|
308
|
+
{emailError && <Text style={styles.error}>{emailError}</Text>}
|
|
309
|
+
</View>
|
|
310
|
+
)}
|
|
311
|
+
</ScrollView>
|
|
312
|
+
</SafeAreaView>
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const styles = StyleSheet.create({
|
|
317
|
+
container: { flex: 1, backgroundColor: '#1a1a2e' },
|
|
318
|
+
content: { padding: 24, paddingTop: 16 },
|
|
319
|
+
header: {
|
|
320
|
+
flexDirection: 'row',
|
|
321
|
+
alignItems: 'center',
|
|
322
|
+
justifyContent: 'space-between',
|
|
323
|
+
marginBottom: 20,
|
|
324
|
+
},
|
|
325
|
+
title: { fontSize: 22, fontWeight: '700', color: '#e0e0e0' },
|
|
326
|
+
cancel: { padding: 8 },
|
|
327
|
+
cancelText: { color: '#9ca3af', fontSize: 14 },
|
|
328
|
+
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
|
|
329
|
+
tab: {
|
|
330
|
+
flex: 1,
|
|
331
|
+
paddingVertical: 12,
|
|
332
|
+
borderRadius: 10,
|
|
333
|
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
334
|
+
alignItems: 'center',
|
|
335
|
+
},
|
|
336
|
+
tabActive: { backgroundColor: 'rgba(99,102,241,0.25)' },
|
|
337
|
+
tabText: { color: '#9ca3af', fontSize: 14, fontWeight: '600' },
|
|
338
|
+
tabTextActive: { color: '#e0e0e0' },
|
|
339
|
+
subTab: {
|
|
340
|
+
flex: 1,
|
|
341
|
+
paddingVertical: 8,
|
|
342
|
+
borderRadius: 8,
|
|
343
|
+
alignItems: 'center',
|
|
344
|
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
345
|
+
},
|
|
346
|
+
subTabActive: { backgroundColor: 'rgba(99,102,241,0.2)' },
|
|
347
|
+
subTabText: { color: '#e0e0e0', fontSize: 13 },
|
|
348
|
+
section: { marginTop: 4 },
|
|
349
|
+
hint: { color: '#9ca3af', fontSize: 13, lineHeight: 18 },
|
|
350
|
+
codeBox: {
|
|
351
|
+
backgroundColor: 'rgba(99,102,241,0.15)',
|
|
352
|
+
borderWidth: 1,
|
|
353
|
+
borderColor: 'rgba(99,102,241,0.35)',
|
|
354
|
+
borderRadius: 14,
|
|
355
|
+
paddingVertical: 24,
|
|
356
|
+
alignItems: 'center',
|
|
357
|
+
marginTop: 16,
|
|
358
|
+
marginBottom: 16,
|
|
359
|
+
},
|
|
360
|
+
codeText: {
|
|
361
|
+
color: '#e0e7ff',
|
|
362
|
+
fontSize: 36,
|
|
363
|
+
fontWeight: '800',
|
|
364
|
+
letterSpacing: 6,
|
|
365
|
+
fontVariant: ['tabular-nums'],
|
|
366
|
+
},
|
|
367
|
+
primaryButton: {
|
|
368
|
+
backgroundColor: '#6366f1',
|
|
369
|
+
borderRadius: 12,
|
|
370
|
+
paddingVertical: 14,
|
|
371
|
+
alignItems: 'center',
|
|
372
|
+
marginTop: 8,
|
|
373
|
+
},
|
|
374
|
+
primaryButtonText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
|
375
|
+
providerRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
|
|
376
|
+
providerButton: {
|
|
377
|
+
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
378
|
+
borderRadius: 10,
|
|
379
|
+
paddingVertical: 10,
|
|
380
|
+
paddingHorizontal: 14,
|
|
381
|
+
},
|
|
382
|
+
providerButtonText: { color: '#e0e0e0', fontSize: 13, fontWeight: '600' },
|
|
383
|
+
label: { color: '#9ca3af', fontSize: 12, marginTop: 14, marginBottom: 6 },
|
|
384
|
+
input: {
|
|
385
|
+
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
386
|
+
borderWidth: 1,
|
|
387
|
+
borderColor: 'rgba(255,255,255,0.15)',
|
|
388
|
+
borderRadius: 10,
|
|
389
|
+
paddingHorizontal: 14,
|
|
390
|
+
paddingVertical: 12,
|
|
391
|
+
color: '#e0e0e0',
|
|
392
|
+
fontSize: 15,
|
|
393
|
+
},
|
|
394
|
+
error: { color: '#ef4444', fontSize: 13, marginTop: 12 },
|
|
395
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
ActivityIndicator,
|
|
8
|
+
SafeAreaView,
|
|
9
|
+
ScrollView,
|
|
10
|
+
RefreshControl,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import {
|
|
13
|
+
DeviceList,
|
|
14
|
+
RemoteDevice,
|
|
15
|
+
listReachableDevices,
|
|
16
|
+
saveSelectedDeviceId,
|
|
17
|
+
} from './auth';
|
|
18
|
+
|
|
19
|
+
export interface YaverMachinePickerProps {
|
|
20
|
+
token: string;
|
|
21
|
+
/** Currently-selected deviceId (from config / cache) — highlighted. */
|
|
22
|
+
currentDeviceId?: string;
|
|
23
|
+
onPick: (device: RemoteDevice) => void;
|
|
24
|
+
onCancel?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* List of remote dev machines the signed-in user can reach. Split into
|
|
29
|
+
* - Owned machines (user is the host)
|
|
30
|
+
* - Shared machines (host invited them as a guest)
|
|
31
|
+
*
|
|
32
|
+
* Tapping a device persists it to AsyncStorage and invokes `onPick`. The
|
|
33
|
+
* SDK then uses that device's deviceId for agent discovery (LAN probe +
|
|
34
|
+
* relay fallback through Convex).
|
|
35
|
+
*/
|
|
36
|
+
export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
|
|
37
|
+
token,
|
|
38
|
+
currentDeviceId,
|
|
39
|
+
onPick,
|
|
40
|
+
onCancel,
|
|
41
|
+
}) => {
|
|
42
|
+
const [loading, setLoading] = useState(true);
|
|
43
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const [list, setList] = useState<DeviceList>({ owned: [], shared: [] });
|
|
46
|
+
|
|
47
|
+
const load = useCallback(async (silent = false) => {
|
|
48
|
+
if (!silent) setLoading(true);
|
|
49
|
+
setError(null);
|
|
50
|
+
try {
|
|
51
|
+
const result = await listReachableDevices(token);
|
|
52
|
+
setList(result);
|
|
53
|
+
if (result.owned.length === 0 && result.shared.length === 0) {
|
|
54
|
+
setError('Hiç makine bulunamadı — önce bir makinede `yaver auth` + `yaver serve` çalıştır.');
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
setRefreshing(false);
|
|
61
|
+
}
|
|
62
|
+
}, [token]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
void load();
|
|
66
|
+
}, [load]);
|
|
67
|
+
|
|
68
|
+
const handlePick = async (device: RemoteDevice) => {
|
|
69
|
+
await saveSelectedDeviceId(device.deviceId);
|
|
70
|
+
onPick(device);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const renderDevice = (device: RemoteDevice) => {
|
|
74
|
+
const selected = device.deviceId === currentDeviceId;
|
|
75
|
+
const stale = Date.now() - device.lastHeartbeat > 60_000;
|
|
76
|
+
const healthColor = !device.isOnline
|
|
77
|
+
? '#ef4444'
|
|
78
|
+
: device.needsAuth || device.runnerDown || stale
|
|
79
|
+
? '#f59e0b'
|
|
80
|
+
: '#22c55e';
|
|
81
|
+
return (
|
|
82
|
+
<TouchableOpacity
|
|
83
|
+
key={device.deviceId}
|
|
84
|
+
style={[styles.deviceRow, selected && styles.deviceSelected]}
|
|
85
|
+
onPress={() => handlePick(device)}
|
|
86
|
+
>
|
|
87
|
+
<View style={[styles.health, { backgroundColor: healthColor }]} />
|
|
88
|
+
<View style={{ flex: 1 }}>
|
|
89
|
+
<Text style={styles.deviceName}>{device.name || device.deviceId}</Text>
|
|
90
|
+
<Text style={styles.deviceMeta}>
|
|
91
|
+
{device.platform}
|
|
92
|
+
{device.isGuest && device.hostEmail ? ` • ${device.hostEmail}` : ''}
|
|
93
|
+
{device.accessScope === 'shared-scoped' ? ' • paylaşılan' : ''}
|
|
94
|
+
</Text>
|
|
95
|
+
</View>
|
|
96
|
+
{selected && <Text style={styles.selectedBadge}>seçili</Text>}
|
|
97
|
+
</TouchableOpacity>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<SafeAreaView style={styles.container}>
|
|
103
|
+
<View style={styles.header}>
|
|
104
|
+
<Text style={styles.title}>Makine Seç</Text>
|
|
105
|
+
{onCancel && (
|
|
106
|
+
<TouchableOpacity onPress={onCancel} style={styles.cancel}>
|
|
107
|
+
<Text style={styles.cancelText}>Kapat</Text>
|
|
108
|
+
</TouchableOpacity>
|
|
109
|
+
)}
|
|
110
|
+
</View>
|
|
111
|
+
|
|
112
|
+
<ScrollView
|
|
113
|
+
contentContainerStyle={styles.content}
|
|
114
|
+
refreshControl={
|
|
115
|
+
<RefreshControl
|
|
116
|
+
refreshing={refreshing}
|
|
117
|
+
onRefresh={() => {
|
|
118
|
+
setRefreshing(true);
|
|
119
|
+
void load(true);
|
|
120
|
+
}}
|
|
121
|
+
tintColor="#6366f1"
|
|
122
|
+
/>
|
|
123
|
+
}
|
|
124
|
+
>
|
|
125
|
+
{loading ? (
|
|
126
|
+
<ActivityIndicator color="#6366f1" style={{ marginTop: 60 }} />
|
|
127
|
+
) : (
|
|
128
|
+
<>
|
|
129
|
+
{list.owned.length > 0 && (
|
|
130
|
+
<View style={styles.section}>
|
|
131
|
+
<Text style={styles.sectionTitle}>Kendi makinelerim</Text>
|
|
132
|
+
{list.owned.map(renderDevice)}
|
|
133
|
+
</View>
|
|
134
|
+
)}
|
|
135
|
+
{list.shared.length > 0 && (
|
|
136
|
+
<View style={styles.section}>
|
|
137
|
+
<Text style={styles.sectionTitle}>Paylaşılan (guest)</Text>
|
|
138
|
+
{list.shared.map(renderDevice)}
|
|
139
|
+
</View>
|
|
140
|
+
)}
|
|
141
|
+
{error && <Text style={styles.error}>{error}</Text>}
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
</ScrollView>
|
|
145
|
+
</SafeAreaView>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const styles = StyleSheet.create({
|
|
150
|
+
container: { flex: 1, backgroundColor: '#1a1a2e' },
|
|
151
|
+
header: {
|
|
152
|
+
flexDirection: 'row',
|
|
153
|
+
alignItems: 'center',
|
|
154
|
+
justifyContent: 'space-between',
|
|
155
|
+
paddingHorizontal: 20,
|
|
156
|
+
paddingVertical: 16,
|
|
157
|
+
},
|
|
158
|
+
title: { color: '#e0e0e0', fontSize: 20, fontWeight: '700' },
|
|
159
|
+
cancel: { padding: 8 },
|
|
160
|
+
cancelText: { color: '#9ca3af', fontSize: 14 },
|
|
161
|
+
content: { padding: 20, paddingTop: 0 },
|
|
162
|
+
section: { marginBottom: 24 },
|
|
163
|
+
sectionTitle: {
|
|
164
|
+
color: '#9ca3af',
|
|
165
|
+
fontSize: 12,
|
|
166
|
+
fontWeight: '600',
|
|
167
|
+
textTransform: 'uppercase',
|
|
168
|
+
letterSpacing: 1,
|
|
169
|
+
marginBottom: 10,
|
|
170
|
+
},
|
|
171
|
+
deviceRow: {
|
|
172
|
+
flexDirection: 'row',
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
gap: 12,
|
|
175
|
+
padding: 14,
|
|
176
|
+
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
177
|
+
borderRadius: 12,
|
|
178
|
+
marginBottom: 8,
|
|
179
|
+
borderWidth: 1,
|
|
180
|
+
borderColor: 'transparent',
|
|
181
|
+
},
|
|
182
|
+
deviceSelected: {
|
|
183
|
+
borderColor: 'rgba(99,102,241,0.5)',
|
|
184
|
+
backgroundColor: 'rgba(99,102,241,0.15)',
|
|
185
|
+
},
|
|
186
|
+
health: { width: 10, height: 10, borderRadius: 5 },
|
|
187
|
+
deviceName: { color: '#e0e0e0', fontSize: 15, fontWeight: '600' },
|
|
188
|
+
deviceMeta: { color: '#9ca3af', fontSize: 12, marginTop: 2 },
|
|
189
|
+
selectedBadge: {
|
|
190
|
+
color: '#a5b4fc',
|
|
191
|
+
fontSize: 11,
|
|
192
|
+
fontWeight: '700',
|
|
193
|
+
textTransform: 'uppercase',
|
|
194
|
+
},
|
|
195
|
+
error: { color: '#ef4444', fontSize: 13, marginTop: 16, textAlign: 'center' },
|
|
196
|
+
});
|
package/src/P2PClient.ts
CHANGED
|
@@ -308,6 +308,116 @@ export class P2PClient {
|
|
|
308
308
|
return { token: result.token, expiresAt: result.expiresAt };
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
// ─── Feature flags (F1) ──────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Evaluate every flag for a userId. Hits /flags/eval which uses
|
|
315
|
+
* SHA256 bucketing against rolloutPercent — stable per user per
|
|
316
|
+
* flag. Results are the dev's source of truth; the SDK caches
|
|
317
|
+
* for 30s in getFlagsCached().
|
|
318
|
+
*/
|
|
319
|
+
async flagsEvaluate(userId: string = 'anonymous'): Promise<Record<string, unknown>> {
|
|
320
|
+
const res = await fetch(
|
|
321
|
+
`${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}`,
|
|
322
|
+
{ headers: { Authorization: `Bearer ${this.authToken}` } },
|
|
323
|
+
);
|
|
324
|
+
if (!res.ok) return {};
|
|
325
|
+
const data = await res.json();
|
|
326
|
+
return data.flags ?? {};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Evaluate a single flag by key — shortcut when you only need one. */
|
|
330
|
+
async flagsEvaluateOne<T = unknown>(
|
|
331
|
+
key: string,
|
|
332
|
+
userId: string = 'anonymous',
|
|
333
|
+
): Promise<T | undefined> {
|
|
334
|
+
const res = await fetch(
|
|
335
|
+
`${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}&flag=${encodeURIComponent(key)}`,
|
|
336
|
+
{ headers: { Authorization: `Bearer ${this.authToken}` } },
|
|
337
|
+
);
|
|
338
|
+
if (!res.ok) return undefined;
|
|
339
|
+
const data = await res.json();
|
|
340
|
+
return data.value as T;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Releases (R1) ───────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Ask what bundle this device should run. Returns the latest
|
|
347
|
+
* release in the channel plus a rollout gate. The mobile app
|
|
348
|
+
* uses this on cold start to decide whether to download a new
|
|
349
|
+
* bundle from /releases/bundle.
|
|
350
|
+
*/
|
|
351
|
+
async releasesLatest(
|
|
352
|
+
channel: string = 'production',
|
|
353
|
+
deviceId?: string,
|
|
354
|
+
): Promise<{
|
|
355
|
+
ok: boolean;
|
|
356
|
+
channel: string;
|
|
357
|
+
semver?: string;
|
|
358
|
+
size?: number;
|
|
359
|
+
md5?: string;
|
|
360
|
+
hermesBcVersion?: number;
|
|
361
|
+
bundleUrl?: string;
|
|
362
|
+
rolloutPercent: number;
|
|
363
|
+
inRollout: boolean;
|
|
364
|
+
reason?: string;
|
|
365
|
+
} | null> {
|
|
366
|
+
const params = new URLSearchParams({ channel });
|
|
367
|
+
if (deviceId) params.set('device', deviceId);
|
|
368
|
+
const res = await fetch(`${this.baseUrl}/releases/latest?${params.toString()}`, {
|
|
369
|
+
headers: { Authorization: `Bearer ${this.authToken}` },
|
|
370
|
+
});
|
|
371
|
+
if (!res.ok) return null;
|
|
372
|
+
return res.json();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Download a specific bundle as raw bytes. */
|
|
376
|
+
async releasesDownload(
|
|
377
|
+
channel: string,
|
|
378
|
+
semver: string,
|
|
379
|
+
): Promise<ArrayBuffer | null> {
|
|
380
|
+
const params = new URLSearchParams({ channel, semver });
|
|
381
|
+
const res = await fetch(`${this.baseUrl}/releases/bundle?${params.toString()}`, {
|
|
382
|
+
headers: { Authorization: `Bearer ${this.authToken}` },
|
|
383
|
+
});
|
|
384
|
+
if (!res.ok) return null;
|
|
385
|
+
return res.arrayBuffer();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── Analytics ingest (A1 — direct POST path) ───────────────────
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Fire-and-forget track event. Most callers should use
|
|
392
|
+
* `BlackBox.track()` which fans through the streaming channel;
|
|
393
|
+
* this method is the fallback for surfaces without a live SSE.
|
|
394
|
+
*/
|
|
395
|
+
async analyticsIngest(
|
|
396
|
+
name: string,
|
|
397
|
+
props?: Record<string, string>,
|
|
398
|
+
opts?: { deviceId?: string; route?: string; timestamp?: number },
|
|
399
|
+
): Promise<boolean> {
|
|
400
|
+
try {
|
|
401
|
+
const res = await fetch(`${this.baseUrl}/analytics/ingest`, {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: {
|
|
404
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
405
|
+
'Content-Type': 'application/json',
|
|
406
|
+
},
|
|
407
|
+
body: JSON.stringify({
|
|
408
|
+
name,
|
|
409
|
+
props,
|
|
410
|
+
deviceId: opts?.deviceId,
|
|
411
|
+
route: opts?.route,
|
|
412
|
+
timestamp: opts?.timestamp ?? Date.now(),
|
|
413
|
+
}),
|
|
414
|
+
});
|
|
415
|
+
return res.ok;
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
311
421
|
/** Internal helper for authenticated GET/POST requests. */
|
|
312
422
|
private async request(method: string, path: string): Promise<Response> {
|
|
313
423
|
const response = await fetch(`${this.baseUrl}${path}`, {
|