import { useCallback, useRef, useState } from 'react' import { View, Text, TextInput, Pressable, ScrollView, StyleSheet, Linking, Alert, Platform, } from 'react-native' import { CameraView, useCameraPermissions } from 'expo-camera' import * as Clipboard from 'expo-clipboard' import { LinearGradient } from 'expo-linear-gradient' import { SafeAreaView } from 'react-native-safe-area-context' import { Ionicons } from '@expo/vector-icons' import { useBind } from '@/context/BindContext' import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types' import { checkVpnPermissionGranted, isExpoEasyTierVpnAvailable, saveAndPrepareTunnel, } from '@/lib/easyTierVpn' import { theme } from '@/constants/theme' const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases' const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0] export default function BindScreen() { const { bind, setBind, clear, ready } = useBind() const [paste, setPaste] = useState('') const [msg, setMsg] = useState('') const [scanOn, setScanOn] = useState(false) const [perm, requestPerm] = useCameraPermissions() const scannedRef = useRef(false) const [etBusy, setEtBusy] = useState(false) const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable() const applyPayload = useCallback( async (p: BindPayload | null) => { if (!p) { setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。') return } await setBind(p) setMsg( etEmbedded ? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。' : '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。' ) setScanOn(false) scannedRef.current = false }, [etEmbedded, setBind], ) const onBarCode = useCallback( async ({ data }: { data: string }) => { if (scannedRef.current) return scannedRef.current = true const p = parseBindPayload(data) await applyPayload(p) }, [applyPayload], ) const onPasteApply = useCallback(async () => { const p = parseBindPayload(paste.trim()) await applyPayload(p) }, [paste, applyPayload]) const copyEtJson = useCallback(async () => { if (!bind) return const chunk = { networkName: bind.easytier.networkName, networkSecret: bind.easytier.networkSecret, peerUrls: bind.easytier.peerUrls, } await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2)) setMsg('已复制 EasyTier 组网 JSON。') }, [bind]) const copyFull = useCallback(async () => { if (!bind) return await Clipboard.setStringAsync(JSON.stringify(bind, null, 2)) setMsg('已复制完整绑定 JSON。') }, [bind]) const testConn = useCallback(async () => { if (!bind) return try { const r = await fetch(`${bind.baseUrl}/health`, { headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined, }) const j = await r.json().catch(() => ({})) Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`) } catch (e) { Alert.alert('连接失败', e instanceof Error ? e.message : String(e)) } }, [bind]) const retryEmbeddedTunnel = useCallback(async () => { if (!bind || !etEmbedded) return setEtBusy(true) try { const r = await saveAndPrepareTunnel(bind.easytier) if (r.needsVpnPermission) { setMsg('请在系统弹窗中允许本应用建立 VPN,授权后再点“检查 VPN 权限”。') } else if (r.ok && r.vpnPermissionGranted) { setMsg( 'VPN 权限已就绪,EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。' ) } else { setMsg(`组网准备结果:${JSON.stringify(r)}`) } } catch (e) { setMsg(e instanceof Error ? e.message : String(e)) } finally { setEtBusy(false) } }, [bind, etEmbedded]) const checkVpn = useCallback(async () => { if (!etEmbedded) return setEtBusy(true) try { const ok = await checkVpnPermissionGranted() setMsg( ok ? 'VPN 权限已授予。若仍无法访问 API,请确认当前安装包已包含完整原生 EasyTier 数据面。' : '尚未授予 VPN 权限,请先在系统设置中允许本应用建立 VPN。' ) } finally { setEtBusy(false) } }, [etEmbedded]) if (!ready) { return ( 加载中... ) } const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER return ( 远程配对 在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier 参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。 {bind && ( 当前已绑定 {bind.baseUrl} 组网名 · {bind.easytier.networkName} 引导节点 · {bootstrapPeer} {bind.easytier.peerUrls.length > 1 && ( 额外引导节点 · {bind.easytier.peerUrls.length - 1} 个 )} void testConn()}> 测试 API { Alert.alert('清除配对', '确定要清除当前保存的绑定信息吗?', [ { text: '取消', style: 'cancel' }, { text: '清除', style: 'destructive', onPress: () => void clear() }, ]) }} > 清除 {etEmbedded ? ( void retryEmbeddedTunnel()} > 重新应用组网参数 void checkVpn()} > 检查 VPN 权限 ) : ( {Platform.OS === 'ios' ? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。' : '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'} )} )} 说明 推流和 FFmpeg 只在 PC 执行;手机只负责保存控制通道地址与 EasyTier 参数。现在桌面端会默认把 `tcp://public.easytier.top:11010` 写进 `peerUrls`,减少没有公网 IP 时的发现失败。 当前 Android 原生模块已经能保存组网参数并申请 VPN 权限;如果你要完全在 App 内跑 EasyTier 数据面,下一步还需要把 EasyTier Android 原生库继续接进来。 void Linking.openURL(ET_RELEASE)} style={styles.linkRow}> EasyTier 官方发布与文档 {bind && ( void copyEtJson()}> 复制组网 JSON void copyFull()}> 复制完整绑定 JSON )} 扫码 {!scanOn ? ( { const r = await requestPerm() if (!r.granted) { setMsg('需要相机权限才能扫码。') return } scannedRef.current = false setScanOn(true) }} > 打开相机扫描 PC 二维码 ) : perm?.granted ? ( setScanOn(false)}> 关闭相机 ) : ( 没有相机权限。 )} 手动粘贴 JSON void onPasteApply()}> 解析并保存 {!!msg && {msg}} ) } const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: theme.bg }, scroll: { padding: 20, paddingBottom: 40 }, h1: { fontSize: 22, fontWeight: '700', color: theme.text, marginBottom: 8 }, lead: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 16 }, h2: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 }, body: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 10 }, block: { marginBottom: 22 }, card: { borderRadius: 12, padding: 16, marginBottom: 18, borderWidth: 1, borderColor: theme.border, }, cardTitle: { fontSize: 13, fontWeight: '600', color: theme.accent, marginBottom: 8 }, mono: { fontFamily: 'monospace', fontSize: 13, color: theme.text, marginBottom: 6 }, muted: { color: theme.muted, fontSize: 14 }, mutedSmall: { color: theme.muted, fontSize: 12, marginTop: 6 }, row: { flexDirection: 'row', gap: 10, marginTop: 12 }, etRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 }, etActions: { gap: 10, marginTop: 12 }, btn: { backgroundColor: theme.bgElevated, paddingVertical: 10, paddingHorizontal: 14, borderRadius: 8, borderWidth: 1, borderColor: theme.border, }, btnDisabled: { opacity: 0.55 }, btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' }, btnText: { color: theme.text, fontWeight: '600', fontSize: 14 }, btnPrimary: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: '#3d7eff', paddingVertical: 14, borderRadius: 10, }, btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 }, btnSecondary: { borderWidth: 1, borderColor: theme.border, borderRadius: 10, paddingVertical: 12, paddingHorizontal: 12, alignItems: 'center', backgroundColor: theme.bgCard, flex: 1, minWidth: '45%', }, btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 }, input: { minHeight: 120, borderWidth: 1, borderColor: theme.border, borderRadius: 10, padding: 12, color: theme.text, fontFamily: 'monospace', fontSize: 12, marginBottom: 12, backgroundColor: theme.bgCard, }, camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' }, camera: { height: 280, width: '100%' }, camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' }, linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 }, link: { color: theme.accent, fontSize: 14 }, msg: { color: theme.success, fontSize: 14, marginTop: 8 }, })