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 },
})