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