This commit is contained in:
eric
2026-03-25 16:14:50 -05:00
parent e884d5691e
commit e764abb32a
14 changed files with 198 additions and 235 deletions

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"java.configuration.updateBuildConfiguration": "automatic",
"java.compile.nullAnalysis.mode": "automatic"
}

View File

@@ -38,7 +38,7 @@
[ [
"expo-camera", "expo-camera",
{ {
"cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。" "cameraPermission": "用于扫描 PC 端绑定二维码(baseUrl/token)。"
} }
] ]
], ],

View File

@@ -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 baseUrlporttoken</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,
},
}) })

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -0,0 +1,2 @@
#Wed Mar 25 14:32:32 CDT 2026
gradle.version=8.9