's'
This commit is contained in:
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "automatic",
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic"
|
||||||
|
}
|
||||||
2
app.json
2
app.json
@@ -38,7 +38,7 @@
|
|||||||
[
|
[
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
"cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。"
|
"cameraPermission": "用于扫描 PC 端绑定二维码(baseUrl/token)。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -6,82 +6,117 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Linking,
|
|
||||||
Alert,
|
Alert,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
|
||||||
|
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types'
|
import { parseBindPayload, type BindPayload } from '@/lib/types'
|
||||||
import {
|
|
||||||
checkVpnPermissionGranted,
|
|
||||||
isExpoEasyTierVpnAvailable,
|
|
||||||
saveAndPrepareTunnel,
|
|
||||||
} from '@/lib/easyTierVpn'
|
|
||||||
import { theme } from '@/constants/theme'
|
import { theme } from '@/constants/theme'
|
||||||
|
|
||||||
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
|
function normalizeHostInput(hostInput: string): { baseUrl: string; port: number } | null {
|
||||||
const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0]
|
const s = hostInput.trim()
|
||||||
|
if (!s) return null
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(s)) {
|
||||||
|
try {
|
||||||
|
const u = new URL(s)
|
||||||
|
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80))
|
||||||
|
if (!Number.isFinite(port)) return null
|
||||||
|
return { baseUrl: u.origin, port }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noPath = s.split('/')[0]
|
||||||
|
const i = noPath.lastIndexOf(':')
|
||||||
|
if (i <= 0) return null
|
||||||
|
const host = noPath.slice(0, i).trim()
|
||||||
|
const port = Number(noPath.slice(i + 1))
|
||||||
|
if (!host || !Number.isFinite(port) || port <= 0) return null
|
||||||
|
return { baseUrl: `http://${host}:${port}`, port }
|
||||||
|
}
|
||||||
|
|
||||||
export default function BindScreen() {
|
export default function BindScreen() {
|
||||||
const { bind, setBind, clear, ready } = useBind()
|
const { bind, setBind, clear, ready } = useBind()
|
||||||
const [paste, setPaste] = useState('')
|
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
|
const [hostText, setHostText] = useState('')
|
||||||
const [scanOn, setScanOn] = useState(false)
|
const [scanOn, setScanOn] = useState(false)
|
||||||
const [perm, requestPerm] = useCameraPermissions()
|
const [perm, requestPerm] = useCameraPermissions()
|
||||||
const scannedRef = useRef(false)
|
const scannedRef = useRef(false)
|
||||||
const [etBusy, setEtBusy] = useState(false)
|
|
||||||
|
|
||||||
const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable()
|
useEffect(() => {
|
||||||
|
if (!scanOn) scannedRef.current = false
|
||||||
|
}, [scanOn])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bind?.baseUrl) {
|
||||||
|
setHostText('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const v = bind.baseUrl.replace(/^https?:\/\//i, '')
|
||||||
|
setHostText(v)
|
||||||
|
}, [bind?.baseUrl])
|
||||||
|
|
||||||
const applyPayload = useCallback(
|
const applyPayload = useCallback(
|
||||||
async (p: BindPayload | null) => {
|
async (p: BindPayload) => {
|
||||||
if (!p) {
|
|
||||||
setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await setBind(p)
|
await setBind(p)
|
||||||
setMsg(
|
setMsg('已导入绑定(含 token)。可在下方修改 IP/域名与端口后再点「保存」。')
|
||||||
etEmbedded
|
|
||||||
? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。'
|
|
||||||
: '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。'
|
|
||||||
)
|
|
||||||
setScanOn(false)
|
|
||||||
scannedRef.current = false
|
|
||||||
},
|
},
|
||||||
[etEmbedded, setBind],
|
[setBind],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onBarCode = useCallback(
|
const onBarcodeScanned = useCallback(
|
||||||
async ({ data }: { data: string }) => {
|
(result: { data: string }) => {
|
||||||
if (scannedRef.current) return
|
if (scannedRef.current) return
|
||||||
|
const p = parseBindPayload(result.data)
|
||||||
|
if (!p) {
|
||||||
|
setMsg('二维码内容不是有效的绑定 JSON(需 v1、baseUrl 等字段)。')
|
||||||
|
return
|
||||||
|
}
|
||||||
scannedRef.current = true
|
scannedRef.current = true
|
||||||
const p = parseBindPayload(data)
|
void (async () => {
|
||||||
await applyPayload(p)
|
await applyPayload(p)
|
||||||
|
setScanOn(false)
|
||||||
|
})()
|
||||||
},
|
},
|
||||||
[applyPayload],
|
[applyPayload],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPasteApply = useCallback(async () => {
|
const onPasteJson = useCallback(async () => {
|
||||||
const p = parseBindPayload(paste.trim())
|
const raw = (await Clipboard.getStringAsync()).trim()
|
||||||
await applyPayload(p)
|
const p = parseBindPayload(raw)
|
||||||
}, [paste, applyPayload])
|
if (!p) {
|
||||||
|
setMsg('剪贴板内容不是有效的绑定 JSON。')
|
||||||
const copyEtJson = useCallback(async () => {
|
return
|
||||||
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))
|
await applyPayload(p)
|
||||||
setMsg('已复制 EasyTier 组网 JSON。')
|
}, [applyPayload])
|
||||||
}, [bind])
|
|
||||||
|
const onSaveHost = useCallback(async () => {
|
||||||
|
const n = normalizeHostInput(hostText)
|
||||||
|
if (!n) {
|
||||||
|
setMsg('请输入 `ip/域名:端口`(或带 http:// 的 baseUrl)。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const p: BindPayload = { v: 1, baseUrl: n.baseUrl, port: n.port, token: bind?.token ?? '' }
|
||||||
|
await setBind(p)
|
||||||
|
setMsg('已保存绑定地址;未改动的 token 已保留。')
|
||||||
|
}, [hostText, bind?.token, setBind])
|
||||||
|
|
||||||
|
const onResetHost = useCallback(async () => {
|
||||||
|
setMsg('')
|
||||||
|
Alert.alert('清除配对', '确定要删除当前绑定地址吗?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{ text: '清除', style: 'destructive', onPress: () => void clear() },
|
||||||
|
])
|
||||||
|
}, [clear])
|
||||||
|
|
||||||
const copyFull = useCallback(async () => {
|
const copyFull = useCallback(async () => {
|
||||||
if (!bind) return
|
if (!bind) return
|
||||||
@@ -91,52 +126,49 @@ export default function BindScreen() {
|
|||||||
|
|
||||||
const testConn = useCallback(async () => {
|
const testConn = useCallback(async () => {
|
||||||
if (!bind) return
|
if (!bind) return
|
||||||
|
const url = `${bind.baseUrl}/health`
|
||||||
|
setMsg('正在测试 API 连接…')
|
||||||
|
const ac = new AbortController()
|
||||||
|
const t = setTimeout(() => ac.abort(), 15000)
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${bind.baseUrl}/health`, {
|
const r = await fetch(url, {
|
||||||
|
signal: ac.signal,
|
||||||
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
|
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
|
||||||
})
|
})
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({}))
|
||||||
|
setMsg('')
|
||||||
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
|
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert('连接失败', e instanceof Error ? e.message : String(e))
|
const isAbort = e instanceof Error && e.name === 'AbortError'
|
||||||
|
setMsg('')
|
||||||
|
Alert.alert(
|
||||||
|
isAbort ? '连接超时' : '连接失败',
|
||||||
|
isAbort
|
||||||
|
? '15 秒内无响应。请确认手机能访问绑定里的 baseUrl:端口(或域名:端口),PC 上 web2 服务已启动,且防火墙/网络路由允许(换网后可能需要重新扫码或改地址)。'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t)
|
||||||
}
|
}
|
||||||
}, [bind])
|
}, [bind])
|
||||||
|
|
||||||
const retryEmbeddedTunnel = useCallback(async () => {
|
const openScan = useCallback(async () => {
|
||||||
if (!bind || !etEmbedded) return
|
setMsg('')
|
||||||
setEtBusy(true)
|
if (Platform.OS === 'web') {
|
||||||
try {
|
setMsg('网页版请使用「从剪贴板导入 JSON」。')
|
||||||
const r = await saveAndPrepareTunnel(bind.easytier)
|
return
|
||||||
if (r.needsVpnPermission) {
|
}
|
||||||
setMsg('请在系统弹窗中允许本应用建立 VPN,授权后再点“检查 VPN 权限”。')
|
if (!perm?.granted) {
|
||||||
} else if (r.ok && r.vpnPermissionGranted) {
|
const r = await requestPerm()
|
||||||
setMsg(
|
if (!r.granted) {
|
||||||
'VPN 权限已就绪,EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。'
|
setMsg('需要相机权限才能扫码。')
|
||||||
)
|
return
|
||||||
} else {
|
|
||||||
setMsg(`组网准备结果:${JSON.stringify(r)}`)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : String(e))
|
|
||||||
} finally {
|
|
||||||
setEtBusy(false)
|
|
||||||
}
|
}
|
||||||
}, [bind, etEmbedded])
|
setScanOn(true)
|
||||||
|
}, [perm?.granted, requestPerm])
|
||||||
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) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
@@ -146,26 +178,21 @@ export default function BindScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||||
<Text style={styles.h1}>远程配对</Text>
|
<Text style={styles.h1}>远程配对</Text>
|
||||||
<Text style={styles.lead}>
|
<Text style={styles.lead}>
|
||||||
在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier
|
PC 端生成二维码后扫码导入;导入后可在下方编辑 `ip/域名:端口` 再保存,token 会保留。也可仅手动填写地址(需自行保证与 PC 上 token 一致)。
|
||||||
参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{!!msg && <Text style={styles.msgBanner}>{msg}</Text>}
|
||||||
|
|
||||||
{bind && (
|
{bind && (
|
||||||
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
|
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>当前已绑定</Text>
|
<Text style={styles.cardTitle}>当前已绑定</Text>
|
||||||
<Text style={styles.mono}>{bind.baseUrl}</Text>
|
<Text style={styles.mono}>{bind.baseUrl}</Text>
|
||||||
<Text style={styles.mutedSmall}>组网名 · {bind.easytier.networkName}</Text>
|
<Text style={styles.mutedSmall}>token:{bind.token ? '已保存' : '无'}</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}>
|
<View style={styles.row}>
|
||||||
<Pressable style={styles.btn} onPress={() => void testConn()}>
|
<Pressable style={styles.btn} onPress={() => void testConn()}>
|
||||||
<Text style={styles.btnText}>测试 API</Text>
|
<Text style={styles.btnText}>测试 API</Text>
|
||||||
@@ -182,110 +209,72 @@ export default function BindScreen() {
|
|||||||
<Text style={styles.btnText}>清除</Text>
|
<Text style={styles.btnText}>清除</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</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>
|
</LinearGradient>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={styles.h2}>扫码或导入 JSON</Text>
|
||||||
|
<Text style={styles.body}>与 PC 端「远程控制」面板中的二维码内容一致(含 baseUrl、port、token)。</Text>
|
||||||
|
<View style={styles.etRow}>
|
||||||
|
<Pressable style={styles.btnPrimary} onPress={() => void openScan()}>
|
||||||
|
<Ionicons name="qr-code-outline" size={20} color="#fff" />
|
||||||
|
<Text style={styles.btnPrimaryText}> 扫二维码</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.btnSecondary} onPress={() => void onPasteJson()}>
|
||||||
|
<Text style={styles.btnSecondaryText}>从剪贴板导入 JSON</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{scanOn && Platform.OS !== 'web' && (
|
||||||
|
<View style={styles.camWrap}>
|
||||||
|
<CameraView
|
||||||
|
style={styles.camera}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={onBarcodeScanned}
|
||||||
|
/>
|
||||||
|
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
|
||||||
|
<Text style={styles.btnText}>关闭相机</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={styles.h2}>绑定地址(可编辑)</Text>
|
||||||
|
<Text style={styles.body}>
|
||||||
|
格式:`ip/域名:端口`(如 `192.168.21.222:8001`)。保存时会保留当前已存储的 token;若仅手填地址且从未扫码,请确保 token 与 PC 一致。
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="ip/域名:端口"
|
||||||
|
placeholderTextColor={theme.muted}
|
||||||
|
value={hostText}
|
||||||
|
onChangeText={setHostText}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Pressable style={styles.btn} onPress={() => void onSaveHost()}>
|
||||||
|
<Text style={styles.btnText}>保存</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={[styles.btn, styles.btnDanger]} onPress={() => void onResetHost()}>
|
||||||
|
<Text style={styles.btnText}>删除/重置</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.block}>
|
<View style={styles.block}>
|
||||||
<Text style={styles.h2}>说明</Text>
|
<Text style={styles.h2}>说明</Text>
|
||||||
<Text style={styles.body}>
|
<Text style={styles.body}>推流、转码在 PC 侧执行;手机保存远程控制用的 baseUrl 与 token。</Text>
|
||||||
推流和 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 && (
|
{bind && (
|
||||||
<View style={styles.etActions}>
|
<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()}>
|
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
|
||||||
<Text style={styles.btnSecondaryText}>复制完整绑定 JSON</Text>
|
<Text style={styles.btnSecondaryText}>复制完整绑定 JSON</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</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>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
@@ -321,7 +310,6 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.border,
|
borderColor: theme.border,
|
||||||
},
|
},
|
||||||
btnDisabled: { opacity: 0.55 },
|
|
||||||
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
|
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
|
||||||
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
|
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
|
||||||
btnPrimary: {
|
btnPrimary: {
|
||||||
@@ -331,6 +319,8 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#3d7eff',
|
backgroundColor: '#3d7eff',
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '45%',
|
||||||
},
|
},
|
||||||
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||||
btnSecondary: {
|
btnSecondary: {
|
||||||
@@ -346,21 +336,29 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
||||||
input: {
|
input: {
|
||||||
minHeight: 120,
|
minHeight: 52,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.border,
|
borderColor: theme.border,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
color: theme.text,
|
color: theme.text,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
backgroundColor: theme.bgCard,
|
backgroundColor: theme.bgCard,
|
||||||
},
|
},
|
||||||
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' },
|
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000', marginBottom: 18 },
|
||||||
camera: { height: 280, width: '100%' },
|
camera: { height: 280, width: '100%' },
|
||||||
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
|
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
|
||||||
linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 },
|
msgBanner: {
|
||||||
link: { color: theme.accent, fontSize: 14 },
|
color: theme.success,
|
||||||
msg: { color: theme.success, fontSize: 14, marginTop: 8 },
|
fontSize: 14,
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: 14,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(61,126,255,0.12)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.border,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default function RecordScreen() {
|
|||||||
<View style={styles.fatalWrap}>
|
<View style={styles.fatalWrap}>
|
||||||
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
||||||
<Text style={styles.fatalHint}>
|
<Text style={styles.fatalHint}>
|
||||||
常见于 WiFi / EasyTier 瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。
|
常见于网络瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
|
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
|
||||||
<Text style={styles.retryBtnText}>重试加载任务列表</Text>
|
<Text style={styles.retryBtnText}>重试加载任务列表</Text>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { Platform } from 'react-native'
|
import { Platform } from 'react-native'
|
||||||
import { isExpoEasyTierVpnAvailable, saveAndPrepareTunnel } from '@/lib/easyTierVpn'
|
|
||||||
import type { BindPayload } from '@/lib/types'
|
import type { BindPayload } from '@/lib/types'
|
||||||
import { clearBindPayload, loadBindPayload, saveBindPayload } from '@/lib/storage'
|
import { clearBindPayload, loadBindPayload, saveBindPayload } from '@/lib/storage'
|
||||||
|
|
||||||
@@ -27,22 +26,13 @@ export function BindProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || !bind) return
|
if (!ready || !bind) return
|
||||||
if (Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
|
// 现在仅做 baseUrl/token 的远程控制绑定,不再在 App 内启动 EasyTier 数据面
|
||||||
void saveAndPrepareTunnel(bind.easytier).catch(() => {})
|
|
||||||
}
|
|
||||||
}, [ready, bind])
|
}, [ready, bind])
|
||||||
|
|
||||||
const setBind = useCallback(async (p: BindPayload | null) => {
|
const setBind = useCallback(async (p: BindPayload | null) => {
|
||||||
setBindState(p)
|
setBindState(p)
|
||||||
if (p) await saveBindPayload(p)
|
if (p) await saveBindPayload(p)
|
||||||
else await clearBindPayload()
|
else await clearBindPayload()
|
||||||
if (p && Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
|
|
||||||
try {
|
|
||||||
await saveAndPrepareTunnel(p.easytier)
|
|
||||||
} catch {
|
|
||||||
/* 组网内核未就绪或用户未授权 VPN 时不阻断配对 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clear = useCallback(async () => {
|
const clear = useCallback(async () => {
|
||||||
|
|||||||
37
lib/types.ts
37
lib/types.ts
@@ -1,52 +1,21 @@
|
|||||||
export type EasyTierConfig = {
|
|
||||||
networkName: string
|
|
||||||
networkSecret: string
|
|
||||||
peerUrls: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_EASYTIER_PEER_URLS = ['tcp://public.easytier.top:11010']
|
|
||||||
|
|
||||||
function normalizePeerUrls(value: unknown): string[] {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const next = Array.isArray(value) ? value.map(String) : []
|
|
||||||
const normalized = next
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => {
|
|
||||||
if (!item || seen.has(item)) return false
|
|
||||||
seen.add(item)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return normalized.length > 0 ? normalized : [...DEFAULT_EASYTIER_PEER_URLS]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
|
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
|
||||||
export type BindPayload = {
|
export type BindPayload = {
|
||||||
v: 1
|
v: 1
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
port: number
|
port: number
|
||||||
token: string
|
token?: string
|
||||||
easytier: EasyTierConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseBindPayload(raw: string): BindPayload | null {
|
export function parseBindPayload(raw: string): BindPayload | null {
|
||||||
try {
|
try {
|
||||||
const j = JSON.parse(raw) as Record<string, unknown>
|
const j = JSON.parse(raw) as Record<string, unknown>
|
||||||
if (j.v !== 1) return null
|
if (j.v !== 1) return null
|
||||||
const et = j.easytier as Record<string, unknown> | undefined
|
if (typeof j.baseUrl !== 'string') return null
|
||||||
if (!et || typeof j.baseUrl !== 'string' || typeof j.token !== 'string') return null
|
|
||||||
const networkName = String(et.networkName ?? '').trim()
|
|
||||||
const networkSecret = String(et.networkSecret ?? '').trim()
|
|
||||||
if (!networkName || !networkSecret) return null
|
|
||||||
return {
|
return {
|
||||||
v: 1,
|
v: 1,
|
||||||
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
|
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
|
||||||
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
|
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
|
||||||
token: String(j.token),
|
token: j.token == null ? '' : String(j.token),
|
||||||
easytier: {
|
|
||||||
networkName,
|
|
||||||
networkSecret,
|
|
||||||
peerUrls: normalizePeerUrls(et.peerUrls),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
#Wed Mar 25 14:32:32 CDT 2026
|
||||||
|
gradle.version=8.9
|
||||||
Reference in New Issue
Block a user