Files
2026-03-25 12:29:03 -05:00

367 lines
14 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.
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 },
})