diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0be1c0c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic", + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/app.json b/app.json index c106650..f39fb45 100644 --- a/app.json +++ b/app.json @@ -38,7 +38,7 @@ [ "expo-camera", { - "cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。" + "cameraPermission": "用于扫描 PC 端绑定二维码(baseUrl/token)。" } ] ], diff --git a/app/(tabs)/bind.tsx b/app/(tabs)/bind.tsx index d10f797..4dd0ff1 100644 --- a/app/(tabs)/bind.tsx +++ b/app/(tabs)/bind.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { View, Text, @@ -6,82 +6,117 @@ import { Pressable, ScrollView, StyleSheet, - Linking, Alert, Platform, } from 'react-native' -import { CameraView, useCameraPermissions } from 'expo-camera' 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 { 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 { parseBindPayload, type BindPayload } from '@/lib/types' import { theme } from '@/constants/theme' -const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases' -const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0] +function normalizeHostInput(hostInput: string): { baseUrl: string; port: number } | null { + 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() { const { bind, setBind, clear, ready } = useBind() - const [paste, setPaste] = useState('') const [msg, setMsg] = useState('') + const [hostText, setHostText] = 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() + 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( - async (p: BindPayload | null) => { - if (!p) { - setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。') - return - } + async (p: BindPayload) => { await setBind(p) - setMsg( - etEmbedded - ? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。' - : '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。' - ) - setScanOn(false) - scannedRef.current = false + setMsg('已导入绑定(含 token)。可在下方修改 IP/域名与端口后再点「保存」。') }, - [etEmbedded, setBind], + [setBind], ) - const onBarCode = useCallback( - async ({ data }: { data: string }) => { + const onBarcodeScanned = useCallback( + (result: { data: string }) => { if (scannedRef.current) return + const p = parseBindPayload(result.data) + if (!p) { + setMsg('二维码内容不是有效的绑定 JSON(需 v1、baseUrl 等字段)。') + return + } scannedRef.current = true - const p = parseBindPayload(data) - await applyPayload(p) + void (async () => { + await applyPayload(p) + setScanOn(false) + })() }, [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, + const onPasteJson = useCallback(async () => { + const raw = (await Clipboard.getStringAsync()).trim() + const p = parseBindPayload(raw) + if (!p) { + setMsg('剪贴板内容不是有效的绑定 JSON。') + return } - await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2)) - setMsg('已复制 EasyTier 组网 JSON。') - }, [bind]) + await applyPayload(p) + }, [applyPayload]) + + 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 () => { if (!bind) return @@ -91,52 +126,49 @@ export default function BindScreen() { const testConn = useCallback(async () => { if (!bind) return + const url = `${bind.baseUrl}/health` + setMsg('正在测试 API 连接…') + const ac = new AbortController() + const t = setTimeout(() => ac.abort(), 15000) try { - const r = await fetch(`${bind.baseUrl}/health`, { + const r = await fetch(url, { + signal: ac.signal, headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined, }) const j = await r.json().catch(() => ({})) + setMsg('') Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`) } 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]) - 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)}`) + const openScan = useCallback(async () => { + setMsg('') + if (Platform.OS === 'web') { + setMsg('网页版请使用「从剪贴板导入 JSON」。') + return + } + if (!perm?.granted) { + const r = await requestPerm() + if (!r.granted) { + setMsg('需要相机权限才能扫码。') + return } - } 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]) + setScanOn(true) + }, [perm?.granted, requestPerm]) if (!ready) { return ( @@ -146,26 +178,21 @@ export default function BindScreen() { ) } - const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER - return ( 远程配对 - 在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier - 参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。 + PC 端生成二维码后扫码导入;导入后可在下方编辑 `ip/域名:端口` 再保存,token 会保留。也可仅手动填写地址(需自行保证与 PC 上 token 一致)。 + {!!msg && {msg}} + {bind && ( 当前已绑定 {bind.baseUrl} - 组网名 · {bind.easytier.networkName} - 引导节点 · {bootstrapPeer} - {bind.easytier.peerUrls.length > 1 && ( - 额外引导节点 · {bind.easytier.peerUrls.length - 1} 个 - )} + token:{bind.token ? '已保存' : '无'} void testConn()}> 测试 API @@ -182,110 +209,72 @@ export default function BindScreen() { 清除 - {etEmbedded ? ( - - void retryEmbeddedTunnel()} - > - 重新应用组网参数 - - void checkVpn()} - > - 检查 VPN 权限 - - - ) : ( - - {Platform.OS === 'ios' - ? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。' - : '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'} - - )} )} + + 扫码或导入 JSON + 与 PC 端「远程控制」面板中的二维码内容一致(含 baseUrl、port、token)。 + + void openScan()}> + + 扫二维码 + + void onPasteJson()}> + 从剪贴板导入 JSON + + + + + {scanOn && Platform.OS !== 'web' && ( + + + setScanOn(false)}> + 关闭相机 + + + )} + + + 绑定地址(可编辑) + + 格式:`ip/域名:端口`(如 `192.168.21.222:8001`)。保存时会保留当前已存储的 token;若仅手填地址且从未扫码,请确保 token 与 PC 一致。 + + + + void onSaveHost()}> + 保存 + + void onResetHost()}> + 删除/重置 + + + + 说明 - - 推流和 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 官方发布与文档 - + 推流、转码在 PC 侧执行;手机保存远程控制用的 baseUrl 与 token。 {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}} ) @@ -321,7 +310,6 @@ const styles = StyleSheet.create({ 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: { @@ -331,6 +319,8 @@ const styles = StyleSheet.create({ backgroundColor: '#3d7eff', paddingVertical: 14, borderRadius: 10, + flex: 1, + minWidth: '45%', }, btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 }, btnSecondary: { @@ -346,21 +336,29 @@ const styles = StyleSheet.create({ }, btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 }, input: { - minHeight: 120, + minHeight: 52, borderWidth: 1, borderColor: theme.border, borderRadius: 10, padding: 12, color: theme.text, fontFamily: 'monospace', - fontSize: 12, + fontSize: 14, marginBottom: 12, backgroundColor: theme.bgCard, }, - camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' }, + camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000', marginBottom: 18 }, 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 }, + msgBanner: { + color: theme.success, + fontSize: 14, + lineHeight: 22, + marginBottom: 14, + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(61,126,255,0.12)', + borderWidth: 1, + borderColor: theme.border, + }, }) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 26fef85..34c20ac 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -197,7 +197,7 @@ export default function RecordScreen() { 无法连接:{r.bootError} - 常见于 WiFi / EasyTier 瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。 + 常见于网络瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。 void r.refreshProcessList()}> 重试加载任务列表 diff --git a/context/BindContext.tsx b/context/BindContext.tsx index ff9a2fd..8ad630b 100644 --- a/context/BindContext.tsx +++ b/context/BindContext.tsx @@ -1,6 +1,5 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { Platform } from 'react-native' -import { isExpoEasyTierVpnAvailable, saveAndPrepareTunnel } from '@/lib/easyTierVpn' import type { BindPayload } from '@/lib/types' import { clearBindPayload, loadBindPayload, saveBindPayload } from '@/lib/storage' @@ -27,22 +26,13 @@ export function BindProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!ready || !bind) return - if (Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) { - void saveAndPrepareTunnel(bind.easytier).catch(() => {}) - } + // 现在仅做 baseUrl/token 的远程控制绑定,不再在 App 内启动 EasyTier 数据面 }, [ready, bind]) const setBind = useCallback(async (p: BindPayload | null) => { setBindState(p) if (p) await saveBindPayload(p) else await clearBindPayload() - if (p && Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) { - try { - await saveAndPrepareTunnel(p.easytier) - } catch { - /* 组网内核未就绪或用户未授权 VPN 时不阻断配对 */ - } - } }, []) const clear = useCallback(async () => { diff --git a/lib/types.ts b/lib/types.ts index 99ef973..57b2feb 100644 --- a/lib/types.ts +++ b/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() - 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` 输出一致,供扫码导入 */ export type BindPayload = { v: 1 baseUrl: string port: number - token: string - easytier: EasyTierConfig + token?: string } export function parseBindPayload(raw: string): BindPayload | null { try { const j = JSON.parse(raw) as Record if (j.v !== 1) return null - const et = j.easytier as Record | undefined - 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 + if (typeof j.baseUrl !== 'string') return null return { v: 1, baseUrl: String(j.baseUrl).replace(/\/$/, ''), port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001, - token: String(j.token), - easytier: { - networkName, - networkSecret, - peerUrls: normalizePeerUrls(et.peerUrls), - }, + token: j.token == null ? '' : String(j.token), } } catch { return null diff --git a/modules/expo-easytier-vpn/android/.gradle/8.9/checksums/checksums.lock b/modules/expo-easytier-vpn/android/.gradle/8.9/checksums/checksums.lock new file mode 100644 index 0000000..9407155 Binary files /dev/null and b/modules/expo-easytier-vpn/android/.gradle/8.9/checksums/checksums.lock differ diff --git a/modules/expo-easytier-vpn/android/.gradle/8.9/dependencies-accessors/gc.properties b/modules/expo-easytier-vpn/android/.gradle/8.9/dependencies-accessors/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/modules/expo-easytier-vpn/android/.gradle/8.9/fileChanges/last-build.bin b/modules/expo-easytier-vpn/android/.gradle/8.9/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/modules/expo-easytier-vpn/android/.gradle/8.9/fileChanges/last-build.bin differ diff --git a/modules/expo-easytier-vpn/android/.gradle/8.9/fileHashes/fileHashes.lock b/modules/expo-easytier-vpn/android/.gradle/8.9/fileHashes/fileHashes.lock new file mode 100644 index 0000000..33d7a82 Binary files /dev/null and b/modules/expo-easytier-vpn/android/.gradle/8.9/fileHashes/fileHashes.lock differ diff --git a/modules/expo-easytier-vpn/android/.gradle/8.9/gc.properties b/modules/expo-easytier-vpn/android/.gradle/8.9/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/modules/expo-easytier-vpn/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/modules/expo-easytier-vpn/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..22bc6e6 Binary files /dev/null and b/modules/expo-easytier-vpn/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/modules/expo-easytier-vpn/android/.gradle/buildOutputCleanup/cache.properties b/modules/expo-easytier-vpn/android/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..aa88cf2 --- /dev/null +++ b/modules/expo-easytier-vpn/android/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Wed Mar 25 14:32:32 CDT 2026 +gradle.version=8.9 diff --git a/modules/expo-easytier-vpn/android/.gradle/vcs-1/gc.properties b/modules/expo-easytier-vpn/android/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29