Files
dchain/client-app/app/index.tsx
vsecoder af7223b93c feat(client): device pairing flow (v2.2.0-alpha3)
Completes PR #3 of the multi-device roadmap. Two devices of the same
identity can now be linked via a six-digit code + relay envelope
handshake. Chain-level fan-out (alpha2) and self-wipe on revoke (this
release, earlier commit) already work end-to-end.

New device — app/(auth)/pair.tsx:
  * Generates fresh X25519 keypair + six-digit code locally.
  * Displays them for transcription on the primary device.
  * Polls /relay/inbox on its own x25519 pub every 2.5s.
  * Decrypts each envelope with the session priv + sender_pub.
  * Accepts only payloads where {v=1, type=pair-handshake, code matches}.
  * On success, assembles a KeyFile (master Ed25519 from envelope,
    x25519 from session) and redirects into (app).

Primary device — app/(app)/devices.tsx:
  * "Link new device" opens a modal asking for {code, device key, name}.
  * On submit: builds+submits LINK_DEVICE tx for the new pub, then
    sends a one-shot relay envelope carrying {master_pub, master_priv,
    master_x25519_pub, code} encrypted for the new device's x25519 pub.
  * Optimistic local insert so the new row appears immediately.

Entry point — app/index.tsx:
  * Third button on welcome slide 3: "Pair". Routes to /auth/pair.
    Create / Import remain unchanged for fresh identities.

Security:
  * Master Ed25519 priv leaves the source device ONLY inside an envelope
    sealed with NaCl box for the new device's X25519 pub. The relay node
    sees only ciphertext.
  * Six-digit code (~20 bits) gates acceptance — an attacker who guesses
    both a session pub AND the code is still filtered by the X25519
    decryption itself (code match is belt-and-suspenders).
  * Envelope stays in relay mailbox until TTL — no DELETE call yet;
    idempotent on our side (saveKeyFile overwrites, session pub
    never polled after redirect).

Known trade-offs:
  * Manual transcription of a 64-char hex key is ugly. Alpha4 will
    offer a QR fallback on phones with cameras; desktop keeps typing.
  * No rate limit on the polling. Fine for a 1-minute handshake, needs
    cap-on-stale if a user leaves the screen open.
2026-04-22 16:32:34 +03:00

525 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Onboarding — 3-слайдовый pager перед auth-экранами.
*
* Slide 1 — "Why DChain": value-proposition, 3 пункта с иконками.
* Slide 2 — "How it works": выбор релей-ноды (public paid vs свой node),
* ссылка на Gitea, + node URL input с live ping.
* Slide 3 — "Your keys": кнопки Create / Import.
*
* Если `keyFile` в store уже есть (bootstrap из RootLayout загрузил) —
* делаем <Redirect /> в (app), чтобы пользователь не видел вообще никакого
* мелькания onboarding'а. До загрузки `booted === false` root показывает
* чёрный экран.
*/
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
View, Text, TextInput, Pressable, ScrollView,
Alert, ActivityIndicator, Linking, Dimensions,
useWindowDimensions,
} from 'react-native';
import { router, Redirect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { saveSettings } from '@/lib/storage';
import { setNodeUrl, getNetStats } from '@/lib/api';
const { width: SCREEN_W } = Dimensions.get('window');
const GITEA_URL = 'https://git.vsecoder.vodka/vsecoder/dchain';
export default function WelcomeScreen() {
const insets = useSafeAreaInsets();
const { height: SCREEN_H } = useWindowDimensions();
const keyFile = useStore(s => s.keyFile);
const booted = useStore(s => s.booted);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const scrollRef = useRef<ScrollView>(null);
const [page, setPage] = useState(0);
const [nodeInput, setNodeInput] = useState('');
const [scanning, setScanning] = useState(false);
const [checking, setChecking] = useState(false);
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
const [permission, requestPermission] = useCameraPermissions();
useEffect(() => { setNodeInput(settings.nodeUrl); }, [settings.nodeUrl]);
// ВСЕ hooks должны быть объявлены ДО любого early-return, иначе
// React на следующем render'е посчитает разное число hooks и выкинет
// "Rendered fewer hooks than expected". useCallback ниже — тоже hook.
const applyNode = useCallback(async (url: string) => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
setChecking(true);
setNodeOk(null);
setNodeUrl(clean);
try {
await getNetStats();
setNodeOk(true);
const next = { ...settings, nodeUrl: clean };
setSettings(next);
await saveSettings(next);
} catch {
setNodeOk(false);
} finally {
setChecking(false);
}
}, [settings, setSettings]);
const onQrScanned = useCallback(({ data }: { data: string }) => {
setScanning(false);
let url = data.trim();
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
setNodeInput(url);
applyNode(url);
}, [applyNode]);
// Bootstrap ещё не закончился — ничего не рендерим, RootLayout покажет
// чёрный экран (single source of truth для splash-state'а).
if (!booted) return null;
// Ключи уже загружены — сразу в main app, без мелькания onboarding'а.
if (keyFile) return <Redirect href={'/(app)/chats' as never} />;
const openScanner = async () => {
if (!permission?.granted) {
const { granted } = await requestPermission();
if (!granted) {
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
return;
}
}
setScanning(true);
};
const goToPage = (p: number) => {
scrollRef.current?.scrollTo({ x: p * SCREEN_W, animated: true });
setPage(p);
};
if (scanning) {
return (
<View style={{ flex: 1, backgroundColor: '#000' }}>
<CameraView
style={{ flex: 1 }}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={onQrScanned}
/>
<View style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
alignItems: 'center', justifyContent: 'center',
}}>
<View style={{ width: 240, height: 240, borderWidth: 2, borderColor: '#fff', borderRadius: 16 }} />
<Text style={{ color: '#fff', marginTop: 20, opacity: 0.8 }}>
Point at a DChain node QR code
</Text>
</View>
<Pressable
onPress={() => setScanning(false)}
style={{
position: 'absolute', top: 56, left: 16,
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
paddingHorizontal: 16, paddingVertical: 8,
}}
>
<Text style={{ color: '#fff', fontSize: 16 }}> Cancel</Text>
</Pressable>
</View>
);
}
const statusColor = nodeOk === true ? '#3ba55d' : nodeOk === false ? '#f4212e' : '#8b8b8b';
// Высота footer'а (dots + inset) — резервируем под неё снизу каждого
// слайда, чтобы CTA-кнопки оказывались прямо над индикатором страниц,
// а не залезали под него.
const FOOTER_H = Math.max(insets.bottom, 20) + 8 + 12 + 7; // = padBottom + padTop + dot
const PAGE_H = SCREEN_H - FOOTER_H;
return (
<View style={{ flex: 1, backgroundColor: '#000000' }}>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={e => {
const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W);
setPage(p);
}}
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
>
{/* ───────── Slide 1: Why DChain ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 60,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', marginBottom: 36 }}>
<View
style={{
width: 88, height: 88, borderRadius: 24,
backgroundColor: '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
marginBottom: 14,
}}
>
<Ionicons name="chatbubbles" size={44} color="#ffffff" />
</View>
<Text style={{ color: '#ffffff', fontSize: 30, fontWeight: '800', letterSpacing: -0.8 }}>
DChain
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 14, lineHeight: 20, marginTop: 6 }}>
A messenger that belongs to you.
</Text>
</View>
<FeatureRow
icon="lock-closed"
title="End-to-end encryption"
text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
/>
<FeatureRow
icon="key"
title="Your keys, your account"
text="No phone, email, or server passwords. Keys never leave your device."
/>
<FeatureRow
icon="git-network"
title="Decentralised"
text="Anyone can run a node. No single point of failure or censorship."
/>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTAPrimary label="Continue" onPress={() => goToPage(1)} />
</View>
</View>
{/* ───────── Slide 2: How it works ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 40,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
How it works
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
Messages travel through a relay node in encrypted form.
Pick a public one or run your own.
</Text>
<OptionCard
icon="globe"
title="Public node"
text="Quick and easy — community-hosted relay, small fee per delivered message."
/>
<OptionCard
icon="hardware-chip"
title="Self-hosted"
text="Maximum control. Source is open — spin up your own in five minutes."
/>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2, marginTop: 20, marginBottom: 8,
}}>
Node URL
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View
style={{
flex: 1, flexDirection: 'row', alignItems: 'center',
backgroundColor: '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 12, paddingHorizontal: 12, gap: 8,
}}
>
<View style={{ width: 7, height: 7, borderRadius: 3.5, backgroundColor: statusColor }} />
<TextInput
value={nodeInput}
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
onEndEditing={() => applyNode(nodeInput)}
onSubmitEditing={() => applyNode(nodeInput)}
placeholder="http://192.168.1.10:8080"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
style={{ flex: 1, color: '#ffffff', fontSize: 14, paddingVertical: 12 }}
/>
{checking
? <ActivityIndicator size="small" color="#8b8b8b" />
: nodeOk === true
? <Ionicons name="checkmark" size={16} color="#3ba55d" />
: nodeOk === false
? <Ionicons name="close" size={16} color="#f4212e" />
: null}
</View>
<Pressable
onPress={openScanner}
style={({ pressed }) => ({
width: 48, alignItems: 'center', justifyContent: 'center',
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 12,
})}
>
<Ionicons name="qr-code-outline" size={22} color="#ffffff" />
</Pressable>
</View>
{nodeOk === false && (
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
Cannot reach node check URL and that the node is running
</Text>
)}
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Source"
icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/>
<CTAPrimary label="Continue" onPress={() => goToPage(2)} />
</View>
</View>
{/* ───────── Slide 3: Your keys ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 60,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', marginBottom: 36 }}>
<View
style={{
width: 88, height: 88, borderRadius: 24,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}
>
<Ionicons name="key" size={44} color="#1d9bf0" />
</View>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
Your account
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
Generate a fresh keypair or import an existing one.
Keys stay on this device only.
</Text>
</View>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
}}>
<CTASecondary
label="Pair"
icon="link"
onPress={() => router.push('/(auth)/pair' as never)}
/>
<CTASecondary
label="Import"
onPress={() => router.push('/(auth)/import' as never)}
/>
<CTAPrimary
label="Create account"
onPress={() => router.push('/(auth)/create' as never)}
/>
</View>
</View>
</ScrollView>
{/* Footer: dots-only pager indicator. CTA-кнопки теперь inline
на каждом слайде, чтобы выглядели как полноценные кнопки, а не
мелкий "Далее" в углу. */}
<View style={{
paddingHorizontal: 28,
paddingBottom: Math.max(insets.bottom, 20) + 8,
paddingTop: 12,
flexDirection: 'row',
alignItems: 'center', justifyContent: 'center',
gap: 6,
}}>
{[0, 1, 2].map(i => (
<Pressable
key={i}
onPress={() => goToPage(i)}
hitSlop={8}
style={{
width: page === i ? 22 : 7,
height: 7,
borderRadius: 3.5,
backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a',
}}
/>
))}
</View>
</View>
);
}
// ───────── helper components ─────────
/**
* Primary CTA button — синий pill. Натуральная ширина (hugs content),
* `numberOfLines={1}` на лейбле чтобы текст не переносился. Фон
* применяется через inner View, а не напрямую на Pressable — это
* обходит редкие RN-баги, когда backgroundColor на Pressable не
* рендерится пока кнопка не нажата.
*/
function CTAPrimary({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}>
<View
style={{
height: 46,
paddingHorizontal: 22,
borderRadius: 999,
backgroundColor: '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
}}
>
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}
>
{label}
</Text>
</View>
</Pressable>
);
}
/** Secondary CTA — тёмный pill с border'ом, optional icon слева. */
function CTASecondary({
label, icon, onPress,
}: {
label: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
onPress: () => void;
}) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}>
<View
style={{
height: 46,
paddingHorizontal: 18,
borderRadius: 999,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
gap: 6,
}}
>
{icon && <Ionicons name={icon} size={15} color="#ffffff" />}
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}
>
{label}
</Text>
</View>
</Pressable>
);
}
function FeatureRow({
icon, title, text,
}: { icon: React.ComponentProps<typeof Ionicons>['name']; title: string; text: string }) {
return (
<View style={{ flexDirection: 'row', marginBottom: 20, gap: 14 }}>
<View
style={{
width: 40, height: 40, borderRadius: 12,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color="#1d9bf0" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 3 }}>
{title}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
{text}
</Text>
</View>
</View>
);
}
function OptionCard({
icon, title, text, actionLabel, onAction,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
text: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<View
style={{
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 14, padding: 14, marginBottom: 10,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<Ionicons name={icon} size={18} color="#1d9bf0" />
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}>
{title}
</Text>
</View>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
{text}
</Text>
{actionLabel && onAction && (
<Pressable onPress={onAction} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}>
<Text style={{ color: '#1d9bf0', fontSize: 13, fontWeight: '600' }}>
{actionLabel}
</Text>
</Pressable>
)}
</View>
);
}