367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
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 (
|
||
<SafeAreaView style={styles.safe}>
|
||
<Text style={styles.muted}>加载中...</Text>
|
||
</SafeAreaView>
|
||
)
|
||
}
|
||
|
||
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
|
||
|
||
return (
|
||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||
<Text style={styles.h1}>远程配对</Text>
|
||
<Text style={styles.lead}>
|
||
在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier
|
||
参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。
|
||
</Text>
|
||
|
||
{bind && (
|
||
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
|
||
<Text style={styles.cardTitle}>当前已绑定</Text>
|
||
<Text style={styles.mono}>{bind.baseUrl}</Text>
|
||
<Text style={styles.mutedSmall}>组网名 · {bind.easytier.networkName}</Text>
|
||
<Text style={styles.mutedSmall}>引导节点 · {bootstrapPeer}</Text>
|
||
{bind.easytier.peerUrls.length > 1 && (
|
||
<Text style={styles.mutedSmall}>额外引导节点 · {bind.easytier.peerUrls.length - 1} 个</Text>
|
||
)}
|
||
<View style={styles.row}>
|
||
<Pressable style={styles.btn} onPress={() => void testConn()}>
|
||
<Text style={styles.btnText}>测试 API</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btn, styles.btnDanger]}
|
||
onPress={() => {
|
||
Alert.alert('清除配对', '确定要清除当前保存的绑定信息吗?', [
|
||
{ text: '取消', style: 'cancel' },
|
||
{ text: '清除', style: 'destructive', onPress: () => void clear() },
|
||
])
|
||
}}
|
||
>
|
||
<Text style={styles.btnText}>清除</Text>
|
||
</Pressable>
|
||
</View>
|
||
{etEmbedded ? (
|
||
<View style={styles.etRow}>
|
||
<Pressable
|
||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
||
disabled={etBusy}
|
||
onPress={() => void retryEmbeddedTunnel()}
|
||
>
|
||
<Text style={styles.btnSecondaryText}>重新应用组网参数</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
||
disabled={etBusy}
|
||
onPress={() => void checkVpn()}
|
||
>
|
||
<Text style={styles.btnSecondaryText}>检查 VPN 权限</Text>
|
||
</Pressable>
|
||
</View>
|
||
) : (
|
||
<Text style={styles.mutedSmall}>
|
||
{Platform.OS === 'ios'
|
||
? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。'
|
||
: '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'}
|
||
</Text>
|
||
)}
|
||
</LinearGradient>
|
||
)}
|
||
|
||
<View style={styles.block}>
|
||
<Text style={styles.h2}>说明</Text>
|
||
<Text style={styles.body}>
|
||
推流和 FFmpeg 只在 PC 执行;手机只负责保存控制通道地址与 EasyTier 参数。现在桌面端会默认把
|
||
`tcp://public.easytier.top:11010` 写进 `peerUrls`,减少没有公网 IP 时的发现失败。
|
||
</Text>
|
||
<Text style={styles.body}>
|
||
当前 Android 原生模块已经能保存组网参数并申请 VPN 权限;如果你要完全在 App 内跑 EasyTier
|
||
数据面,下一步还需要把 EasyTier Android 原生库继续接进来。
|
||
</Text>
|
||
<Pressable onPress={() => void Linking.openURL(ET_RELEASE)} style={styles.linkRow}>
|
||
<Ionicons name="open-outline" size={18} color={theme.accent} />
|
||
<Text style={styles.link}>EasyTier 官方发布与文档</Text>
|
||
</Pressable>
|
||
{bind && (
|
||
<View style={styles.etActions}>
|
||
<Pressable style={styles.btnSecondary} onPress={() => void copyEtJson()}>
|
||
<Text style={styles.btnSecondaryText}>复制组网 JSON</Text>
|
||
</Pressable>
|
||
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
|
||
<Text style={styles.btnSecondaryText}>复制完整绑定 JSON</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.block}>
|
||
<Text style={styles.h2}>扫码</Text>
|
||
{!scanOn ? (
|
||
<Pressable
|
||
style={styles.btnPrimary}
|
||
onPress={async () => {
|
||
const r = await requestPerm()
|
||
if (!r.granted) {
|
||
setMsg('需要相机权限才能扫码。')
|
||
return
|
||
}
|
||
scannedRef.current = false
|
||
setScanOn(true)
|
||
}}
|
||
>
|
||
<Ionicons name="qr-code-outline" size={22} color="#fff" style={{ marginRight: 8 }} />
|
||
<Text style={styles.btnPrimaryText}>打开相机扫描 PC 二维码</Text>
|
||
</Pressable>
|
||
) : perm?.granted ? (
|
||
<View style={styles.camWrap}>
|
||
<CameraView
|
||
style={styles.camera}
|
||
facing="back"
|
||
onBarcodeScanned={onBarCode}
|
||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||
/>
|
||
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
|
||
<Text style={styles.btnPrimaryText}>关闭相机</Text>
|
||
</Pressable>
|
||
</View>
|
||
) : (
|
||
<Text style={styles.muted}>没有相机权限。</Text>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.block}>
|
||
<Text style={styles.h2}>手动粘贴 JSON</Text>
|
||
<TextInput
|
||
style={styles.input}
|
||
multiline
|
||
placeholder="粘贴 PC 端显示的绑定 JSON"
|
||
placeholderTextColor={theme.muted}
|
||
value={paste}
|
||
onChangeText={setPaste}
|
||
/>
|
||
<Pressable style={styles.btnPrimary} onPress={() => void onPasteApply()}>
|
||
<Text style={styles.btnPrimaryText}>解析并保存</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
{!!msg && <Text style={styles.msg}>{msg}</Text>}
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
)
|
||
}
|
||
|
||
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 },
|
||
})
|