This commit is contained in:
eric
2026-03-25 12:29:03 -05:00
parent fce2c49b62
commit cc2f30b6e2
180 changed files with 20523 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

48
app.json Normal file
View File

@@ -0,0 +1,48 @@
{
"expo": {
"name": "直播录制助手",
"slug": "live-recorder-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"package": "com.douyin.recorder.remote",
"usesCleartextTraffic": true,
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"permissions": ["CAMERA"]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。"
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

72
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,72 @@
import React from 'react'
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import Colors from '@/constants/Colors'
import { useColorScheme } from '@/components/useColorScheme'
import { useClientOnlyValue } from '@/components/useClientOnlyValue'
export default function TabLayout() {
const colorScheme = useColorScheme()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
tabBarStyle: {
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
borderTopColor: 'rgba(255,255,255,0.08)',
},
headerStyle: {
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
},
headerTintColor: Colors[colorScheme].text,
headerShown: useClientOnlyValue(false, true),
}}
>
<Tabs.Screen
name="index"
options={{
title: '直播',
tabBarIcon: ({ color }) => <Ionicons name="radio-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="desk"
options={{
title: '仪表盘',
tabBarIcon: ({ color }) => <Ionicons name="grid-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="network"
options={{
title: '网络',
tabBarIcon: ({ color }) => <Ionicons name="wifi-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="bind"
options={{
title: '远程',
tabBarIcon: ({ color }) => <Ionicons name="phone-portrait-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="system"
options={{
title: '系统',
tabBarIcon: ({ color }) => <Ionicons name="speedometer-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="wechat"
options={{
href: null,
title: '微信',
tabBarIcon: ({ color }) => <Ionicons name="chatbubbles-outline" size={22} color={color} />,
}}
/>
</Tabs>
)
}

366
app/(tabs)/bind.tsx Normal file
View File

@@ -0,0 +1,366 @@
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 (
<SafeAreaView style={styles.safe}>
<Text style={styles.muted}>...</Text>
</SafeAreaView>
)
}
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}></Text>
<Text style={styles.lead}>
PC - `baseUrl``token` EasyTier
EasyTier
</Text>
{bind && (
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.mono}>{bind.baseUrl}</Text>
<Text style={styles.mutedSmall}> · {bind.easytier.networkName}</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}>
<Pressable style={styles.btn} onPress={() => void testConn()}>
<Text style={styles.btnText}> API</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnDanger]}
onPress={() => {
Alert.alert('清除配对', '确定要清除当前保存的绑定信息吗?', [
{ text: '取消', style: 'cancel' },
{ text: '清除', style: 'destructive', onPress: () => void clear() },
])
}}
>
<Text style={styles.btnText}></Text>
</Pressable>
</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>
)}
<View style={styles.block}>
<Text style={styles.h2}></Text>
<Text style={styles.body}>
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 && (
<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()}>
<Text style={styles.btnSecondaryText}> JSON</Text>
</Pressable>
</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>
</SafeAreaView>
)
}
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 },
})

300
app/(tabs)/desk.tsx Normal file
View File

@@ -0,0 +1,300 @@
import { useCallback, useEffect, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import {
View,
Text,
ScrollView,
StyleSheet,
RefreshControl,
Pressable,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import type { ClientOverview, NetworkPayload, RelayLivePayload, SystemMetricsPayload } from '@/lib/pcTypes'
import { getMultiChannelPro } from '@/lib/storage'
import { theme } from '@/constants/theme'
function fmtTs(ts: unknown): string {
if (typeof ts !== 'number') return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
function fmtDur(sec: number | null | undefined): string {
if (sec == null || Number.isNaN(sec)) return '—'
const s = Math.floor(sec)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h}h${m}m${r}s`
if (m > 0) return `${m}m${r}s`
return `${r}s`
}
function SnapshotNet({ data }: { data: unknown }) {
if (!data || typeof data !== 'object') return <Text style={styles.muted}></Text>
const d = data as Record<string, unknown>
if ('google' in d && 'douyin' in d) {
const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string }
const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string }
return (
<View style={styles.snapGrid}>
<Text style={styles.snapLine}>
Google {g?.reachable ? '可达' : '不可达'} ·{' '}
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
</Text>
<Text style={styles.snapLine}>
{y?.reachable ? '可达' : '不可达'} ·{' '}
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
</Text>
</View>
)
}
return <Text style={styles.muted}></Text>
}
function SnapshotSys({ data }: { data: unknown }) {
if (!data || typeof data !== 'object') return <Text style={styles.muted}></Text>
const d = data as Record<string, unknown>
if ('cpu_percent' in d || 'memory' in d) {
const mem = d.memory as { percent?: number } | undefined
return (
<View style={styles.snapGrid}>
<Text style={styles.snapLine}>
CPU {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'}
</Text>
<Text style={styles.snapLine}> {mem?.percent != null ? `${mem.percent.toFixed(1)}%` : '—'}</Text>
</View>
)
}
return <Text style={styles.muted}></Text>
}
export default function DeskScreen() {
const { bind } = useBind()
const [overview, setOverview] = useState<ClientOverview | null>(null)
const [proLive, setProLive] = useState<RelayLivePayload | null>(null)
const [proMode, setProMode] = useState(false)
const [netSnap, setNetSnap] = useState<NetworkPayload | null>(null)
const [sysSnap, setSysSnap] = useState<SystemMetricsPayload | null>(null)
const [err, setErr] = useState('')
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setOverview(null)
setProLive(null)
setNetSnap(null)
setSysSnap(null)
setErr('')
return
}
setErr('')
try {
const r = await apiFetch(bind, '/client/overview')
if (r.ok) setOverview((await r.json()) as ClientOverview)
else setOverview(null)
const multi = await getMultiChannelPro()
setProMode(multi)
if (multi) {
const lr = await apiFetch(bind, '/relay_pro/live_status')
if (lr.ok) setProLive((await lr.json()) as RelayLivePayload)
else setProLive(null)
} else {
setProLive(null)
}
const nr = await apiFetch(bind, '/network_status')
if (nr.ok) setNetSnap((await nr.json()) as NetworkPayload)
else setNetSnap(null)
const sr = await apiFetch(bind, '/system_metrics')
if (sr.ok) setSysSnap((await sr.json()) as SystemMetricsPayload)
else setSysSnap(null)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
}, [bind])
useFocusEffect(
useCallback(() => {
void load()
}, [load]),
)
useEffect(() => {
if (!bind || !proMode) return
const id = setInterval(() => void load(), 12000)
return () => clearInterval(id)
}, [bind, proMode, load])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
<View style={styles.head}>
<Text style={styles.h1}></Text>
<Pressable style={styles.btnSec} disabled={!bind} onPress={() => void load()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
</View>
<Text style={styles.lead}>
Pro / SQLiteApp PC FFmpeg
</Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
{!!err && bind ? <Text style={styles.err}>{err}</Text> : null}
{overview?.relayValidationErrors && overview.relayValidationErrors.length > 0 ? (
<View style={styles.warnBox}>
{overview.relayValidationErrors.map((x, i) => (
<Text key={i} style={styles.warnText}>
{x}
</Text>
))}
</View>
) : null}
{proMode && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Pro </Text>
<Text style={styles.meta}>
{proLive?.youtubeProcess ?? '—'} · {proLive?.processStatus ?? '—'}{' '}
{proLive?.processOnline ? (
<Text style={styles.tagOn}></Text>
) : (
<Text style={styles.tagOff}></Text>
)}
</Text>
{(proLive?.relayRows?.length ? proLive.relayRows : []).map((row, i) => (
<View key={`${row.channelId ?? i}`} style={styles.liveRow}>
<Text style={styles.liveTitle}>
{row.channelName || '未命名频道'} · key {row.youtubeKeySuffix || '—'}
</Text>
<Text style={styles.liveLine}> {row.anchor ?? '—'}</Text>
<Text style={styles.liveLine} numberOfLines={2}>
{row.douyinHint ?? '—'}
</Text>
<Text style={styles.liveLine} numberOfLines={2}>
{row.streamTitle ?? '—'}
</Text>
<Text style={styles.liveLine}> {fmtDur(row.durationSec)}</Text>
</View>
))}
{!proLive?.relayRows?.length ? <Text style={styles.muted}></Text> : null}
</View>
)}
<View style={styles.rowCards}>
<View style={[styles.miniCard, { flex: 1 }]}>
<Text style={styles.miniTitle}></Text>
<Text style={styles.miniMeta}> {fmtTs(netSnap?.meta?.at)}</Text>
<SnapshotNet data={netSnap as unknown} />
</View>
<View style={[styles.miniCard, { flex: 1 }]}>
<Text style={styles.miniTitle}></Text>
<Text style={styles.miniMeta}> {fmtTs(sysSnap?.ts)}</Text>
<SnapshotSys data={sysSnap as unknown} />
</View>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.muted}>
Windows
</Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 16, paddingBottom: 40 },
head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
h1: { fontSize: 20, fontWeight: '700', color: theme.text },
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(91,157,255,0.1)',
borderWidth: 1,
borderColor: 'rgba(91,157,255,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
btnSec: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
btnSecText: { color: theme.text, fontWeight: '600' },
err: { color: theme.danger, marginBottom: 10 },
warnBox: {
padding: 10,
borderRadius: 8,
backgroundColor: 'rgba(245,101,101,0.1)',
marginBottom: 12,
},
warnText: { color: theme.danger, fontSize: 13, marginBottom: 4 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(110,168,254,0.25)',
marginBottom: 12,
},
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 10 },
meta: { fontSize: 13, color: theme.muted, marginBottom: 8 },
tagOn: { color: theme.success, fontWeight: '700' },
tagOff: { color: theme.warn, fontWeight: '700' },
liveRow: {
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: theme.border,
},
liveTitle: { fontSize: 14, fontWeight: '600', color: theme.text, marginBottom: 4 },
liveLine: { fontSize: 12, color: theme.muted, marginTop: 2 },
rowCards: { flexDirection: 'row', gap: 10, marginBottom: 12 },
miniCard: {
padding: 12,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
minWidth: 0,
},
miniTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 4 },
miniMeta: { fontSize: 11, color: theme.muted, marginBottom: 8 },
snapGrid: { gap: 6 },
snapLine: { fontSize: 12, color: theme.text, lineHeight: 18 },
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
})

814
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,814 @@
import { useCallback, useMemo, useState } from 'react'
import {
View,
Text,
ScrollView,
TextInput,
Pressable,
StyleSheet,
ActivityIndicator,
Alert,
RefreshControl,
Linking,
} from 'react-native'
import { Picker } from '@react-native-picker/picker'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { useRecorder } from '@/hooks/useRecorder'
import { theme } from '@/constants/theme'
import {
douyinRoomAndHref,
parseYoutubeStreamKeySuffix,
proChannelPm2Process,
} from '@/lib/youtubeConfigUtils'
function ctrlStyle(action: string): object {
switch (action) {
case 'start':
return styles.ctrl_start
case 'stop':
return styles.ctrl_stop
case 'restart':
return styles.ctrl_restart
case 'status':
return styles.ctrl_status
default:
return {}
}
}
const variantColor: Record<string, string> = {
online: theme.success,
stopped: theme.danger,
error: theme.danger,
warn: theme.warn,
offline: theme.danger,
loading: theme.muted,
unknown: theme.accent,
empty: theme.accent,
}
function ProLiveSwitchGrid({
rows,
committedChannelKeys,
proChannelEditors,
highlightedChannelId,
loadingAction,
onSelectChannel,
onStart,
onStop,
onRestart,
}: {
rows: { channelId: string; channelName: string; douyinUrl: string; live: boolean }[]
committedChannelKeys: Record<string, string[]>
proChannelEditors: Record<string, { keys: string[]; urlText: string }>
highlightedChannelId: string
loadingAction: string | null
onSelectChannel: (channelId: string) => void
onStart: (channelId: string) => void
onStop: (channelId: string) => void
onRestart: (channelId: string) => void
}) {
const pmBusy = loadingAction !== null
return (
<View style={styles.proGrid}>
{rows.map((row) => {
const saved = committedChannelKeys[row.channelId]?.[0] ?? ''
const ed = proChannelEditors[row.channelId]
const activeKey = (saved || ed?.keys?.[0] || '').trim()
const keySuffix = parseYoutubeStreamKeySuffix(`key = ${activeKey}`)
const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '')
const stopDisabled = pmBusy || !row.live
const rowSelected = row.channelId === highlightedChannelId
return (
<View
key={row.channelId}
style={[styles.proSwitchRow, rowSelected && styles.proSwitchRowOn]}
>
<Pressable onPress={() => onSelectChannel(row.channelId)}>
<Text style={styles.proKeySuffix} numberOfLines={1}>
{keySuffix || '—'}
</Text>
<View style={[styles.proTag, row.live ? styles.proTagOn : styles.proTagOff]}>
<Text style={styles.proTagText}>{row.live ? '直播中' : '未开播'}</Text>
</View>
<Text style={styles.proMid} numberOfLines={2}>
{room} · {row.channelName}
</Text>
</Pressable>
{href ? (
<Pressable onPress={() => void Linking.openURL(href)}>
<Text style={styles.proLink}></Text>
</Pressable>
) : null}
<View style={styles.proActions}>
<Pressable
style={[styles.proBtn, styles.proBtnStart, (pmBusy || row.live) && styles.ctrlDisabled]}
disabled={pmBusy || row.live}
onPress={() => onStart(row.channelId)}
>
<Text style={styles.proBtnText}></Text>
</Pressable>
<Pressable
style={[styles.proBtn, styles.proBtnStop, stopDisabled && styles.ctrlDisabled]}
disabled={stopDisabled}
onPress={() => onStop(row.channelId)}
>
<Text style={styles.proBtnText}></Text>
</Pressable>
<Pressable
style={[styles.proBtn, styles.proBtnRestart, pmBusy && styles.ctrlDisabled]}
disabled={pmBusy}
onPress={() => onRestart(row.channelId)}
>
<Text style={styles.proBtnText}></Text>
</Pressable>
</View>
</View>
)
})}
</View>
)
}
function LiveSwitchGrid({
loadingAction,
onAction,
disabledAll,
}: {
loadingAction: string | null
onAction: (a: 'start' | 'stop' | 'restart' | 'status') => void
disabledAll?: boolean
}) {
return (
<View style={styles.grid}>
{(
[
['start', '开始直播'],
['stop', '停止直播'],
['restart', '重新开始'],
['status', '刷新状态'],
] as const
).map(([action, label]) => (
<Pressable
key={action}
style={[
styles.ctrl,
ctrlStyle(action),
loadingAction === action && styles.btnDisabled,
disabledAll && styles.ctrlDisabled,
]}
disabled={!disabledAll && loadingAction !== null && action !== 'status'}
onPress={() => {
if (disabledAll) {
Alert.alert('未绑定', '请先在「远程」页完成配对后再操作。')
return
}
if (loadingAction !== null && action !== 'status') return
void onAction(action)
}}
>
<Text style={[styles.ctrlText, action === 'status' && { color: theme.text }]}>{label}</Text>
</Pressable>
))}
</View>
)
}
export default function RecordScreen() {
const { bind } = useBind()
const r = useRecorder()
const unbound = !bind
const [refreshing, setRefreshing] = useState(false)
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
const onRefresh = useCallback(async () => {
if (!bind) return
setRefreshing(true)
await r.refreshProcessList()
setRefreshing(false)
}, [bind, r.refreshProcessList])
if (r.bootError && bind) {
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<View style={styles.fatalWrap}>
<Text style={styles.err}>{r.bootError}</Text>
<Text style={styles.fatalHint}>
WiFi / EasyTier API
</Text>
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
<Text style={styles.retryBtnText}></Text>
</Pressable>
</View>
</SafeAreaView>
)
}
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
{bind && r.transientLoadError ? (
<View style={styles.warnBanner}>
<Text style={styles.warnBannerText}>
{r.transientLoadError}
{'\n'}
2
</Text>
</View>
) : null}
{unbound && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
{bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
PC PM2 FFmpeg /
</Text>
</View>
)}
<View style={styles.toolbar}>
<View style={[styles.pickerWrap, unbound && styles.pickerDisabled]}>
<Picker
enabled={!unbound && !r.emptyHint}
selectedValue={unbound ? '' : r.currentProcess}
onValueChange={(v) => r.setCurrentProcess(String(v))}
style={styles.picker}
dropdownIconColor={theme.text}
>
{unbound || !r.entries.length ? (
<Picker.Item label="(未配对)" value="" color={theme.muted} />
) : (
r.entries.map((e) => (
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
))
)}
</Picker>
</View>
<Pressable
style={[styles.proRow, unbound && styles.pickerDisabled]}
disabled={unbound}
onPress={() => {
if (unbound) {
Alert.alert('未绑定', '配对后可切换多频道 Pro。')
return
}
void r.togglePro(!r.multiChannelPro)
}}
>
<Text style={styles.proLabel}>Pro</Text>
<Text style={[styles.proVal, r.multiChannelPro && { color: theme.accent }]}>
{r.multiChannelPro ? '开' : '关'}
</Text>
</Pressable>
</View>
<View style={[styles.pill, { borderColor: statusColor }]}>
<View style={[styles.dot, { backgroundColor: statusColor }]} />
<Text style={[styles.pillText, { color: statusColor }]}>{r.statusMeta.line}</Text>
</View>
{bind && r.emptyHint ? (
<View style={styles.emptyCard}>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.hint}>{r.emptyHint}</Text>
</View>
) : unbound ? (
<>
<View style={[styles.card, styles.cardAccent]}>
<Text style={styles.cardTitle}></Text>
<LiveSwitchGrid
loadingAction={r.loadingAction}
disabledAll
onAction={() => {}}
/>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>YouTube </Text>
<Text style={styles.hintSmall}> PC youtube.ini</Text>
<TextInput
style={[styles.area, styles.inputDisabled]}
multiline
editable={false}
value=""
placeholder="串流密钥、分辨率等"
placeholderTextColor={theme.muted}
/>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}></Text>
<TextInput
style={[styles.area, styles.inputDisabled]}
multiline
editable={false}
value=""
placeholder="配对后显示"
placeholderTextColor={theme.muted}
/>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>OBS </Text>
<Text style={styles.hintSmall}>OBS </Text>
<Text style={styles.mono}>srt://live.local:10080/live/obs</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}> · </Text>
<ScrollView style={styles.logBox} nestedScrollEnabled>
<Text style={styles.log}>{r.logText || '—'}</Text>
</ScrollView>
</View>
</>
) : (
<>
<View style={[styles.card, styles.cardAccent]}>
<Text style={styles.cardTitle}></Text>
{r.isYoutube && r.multiChannelPro && r.proLiveRows.length > 0 ? (
<ProLiveSwitchGrid
rows={r.proLiveRows}
committedChannelKeys={r.committedChannelKeys}
proChannelEditors={r.proChannelEditors}
highlightedChannelId={r.proChannelId}
loadingAction={r.loadingAction}
onSelectChannel={(cid) => r.setProChannelId(cid)}
onStart={(cid) => void r.runProChannelStart(cid)}
onStop={(cid) => void r.runPm2Action('stop', proChannelPm2Process(cid))}
onRestart={(cid) => void r.runProChannelRestart(cid)}
/>
) : r.isYoutube && r.multiChannelPro && r.proLiveRows.length === 0 ? (
<Text style={styles.hintSmall}>
{r.proChannelsList.length > 0
? '暂无已保存的线路密钥。请在「当前频道」中保存串流密钥后,对应行将显示于此。'
: '暂无频道。'}
</Text>
) : (
<LiveSwitchGrid
loadingAction={r.loadingAction}
onAction={(a) => void r.runPm2Action(a)}
/>
)}
</View>
{r.isYoutube && r.multiChannelPro && (
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
{r.proChannelsError ? (
<Text style={styles.err}>{r.proChannelsError}</Text>
) : (
<>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.proChannelId}
onValueChange={(v) => r.setProChannelId(String(v))}
style={styles.picker}
dropdownIconColor={theme.text}
>
{r.proChannelsList.map((c) => (
<Picker.Item
key={c.id}
label={`${c.name} (${c.keyPreview || '无密钥'})`}
value={c.id}
color={theme.text}
/>
))}
</Picker>
</View>
<View style={styles.row}>
<Pressable
style={[styles.btn, styles.btnSec]}
disabled={!!r.newChannelDraft || r.addChannelBusy}
onPress={() => r.setNewChannelDraft({ keyText: '' })}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnSec]}
disabled={r.proChannelsList.length <= 1 || r.deleteChannelBusyId !== null}
onPress={() => void r.deleteProChannel(r.proChannelId)}
>
<Text style={styles.btnSecText}>
{r.deleteChannelBusyId === r.proChannelId ? '…' : '删除当前频道'}
</Text>
</Pressable>
</View>
{r.newChannelDraft ? (
<View style={styles.newChBox}>
<Text style={styles.fieldLbl}></Text>
<TextInput
style={styles.inputSingle}
value={r.newChannelDraft.keyText}
onChangeText={(v) => r.setNewChannelDraft({ keyText: v })}
placeholder="粘贴密钥"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<View style={styles.row}>
<Pressable
style={[styles.btn, styles.btnSec]}
onPress={() => r.setNewChannelDraft(null)}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, r.addChannelBusy && styles.btnDisabled]}
disabled={r.addChannelBusy}
onPress={() => void r.submitNewChannelDraft()}
>
<Text style={styles.btnText}>{r.addChannelBusy ? '…' : '提交'}</Text>
</Pressable>
</View>
</View>
) : null}
</>
)}
</View>
)}
{r.isYoutube && r.multiChannelPro && !r.proChannelsError && r.proChannelsList.length > 0 && (
<>
<View style={styles.card}>
<Text style={styles.cardTitle}>YouTube 线</Text>
<TextInput
style={styles.area}
multiline
value={r.proChannelEditors[r.proChannelId]?.keys?.[0] ?? ''}
onChangeText={r.setProChannelKeyText}
placeholderTextColor={theme.muted}
/>
<Pressable
style={[
styles.btn,
(r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId) && styles.btnDisabled,
]}
disabled={r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId}
onPress={() => void r.saveProChannelKey()}
>
{r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnText}></Text>
)}
</Pressable>
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>线</Text>
<TextInput
style={styles.area}
multiline
value={r.proChannelEditors[r.proChannelId]?.urlText ?? ''}
onChangeText={r.setProChannelUrlText}
placeholderTextColor={theme.muted}
/>
<View style={styles.row}>
<Pressable style={[styles.btn, styles.btnSec]} onPress={() => void r.reloadProUrlFromServer()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnSec]}
onPress={() => void r.saveProUrlConfig()}
disabled={r.saveUrlLoading}
>
<Text style={styles.btnSecText}>{r.saveUrlLoading ? '…' : '保存地址'}</Text>
</Pressable>
</View>
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
</View>
</>
)}
{r.isYoutube && !r.multiChannelPro && (
<View style={styles.card}>
<Text style={styles.cardTitle}>YouTube </Text>
<Text style={styles.fieldLbl}></Text>
<TextInput
style={styles.inputSingle}
value={r.ytIniModel.keys[0] ?? ''}
onChangeText={(v) =>
r.setYtIniModel((m) => ({ ...m, keys: [v], activeIndex: 0 }))
}
placeholder="粘贴或编辑串流密钥"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.fieldLbl}></Text>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.ytIniModel.options.output_resolution ?? '720p'}
onValueChange={(v) =>
r.setYtIniModel((m) => ({
...m,
options: { ...m.options, output_resolution: String(v) },
}))
}
style={styles.picker}
dropdownIconColor={theme.text}
>
<Picker.Item label="720p" value="720p" color={theme.text} />
<Picker.Item label="1080p" value="1080p" color={theme.text} />
</Picker>
</View>
<Text style={styles.fieldLbl}>使 GPUNVENC</Text>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.ytIniModel.options.use_gpu ?? (r.gpuCaps?.nvenc_available ? '是' : '无')}
onValueChange={(v) =>
r.setYtIniModel((m) => ({
...m,
options: { ...m.options, use_gpu: String(v) },
}))
}
style={styles.picker}
dropdownIconColor={theme.text}
>
{r.gpuCaps?.nvenc_available ? (
<>
<Picker.Item label="是" value="是" color={theme.text} />
<Picker.Item label="否" value="否" color={theme.text} />
<Picker.Item label="无" value="无" color={theme.text} />
</>
) : (
<Picker.Item label="无(未检测到 NVENC" value="无" color={theme.text} />
)}
</Picker>
</View>
<Text style={styles.fieldLbl}></Text>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.ytIniModel.options.audio_denoise ?? '否'}
onValueChange={(v) =>
r.setYtIniModel((m) => ({
...m,
options: { ...m.options, audio_denoise: String(v) },
}))
}
style={styles.picker}
dropdownIconColor={theme.text}
>
<Picker.Item label="否" value="否" color={theme.text} />
<Picker.Item label="是" value="是" color={theme.text} />
</Picker>
</View>
<View style={styles.row}>
<Pressable style={[styles.btn, styles.btnSec]} onPress={() => void r.loadYoutubeIniModel()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, r.saveYoutubeLoading && styles.btnDisabled]}
disabled={r.saveYoutubeLoading}
onPress={() => void r.saveYoutubeIniModel()}
>
{r.saveYoutubeLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnText}></Text>
)}
</Pressable>
</View>
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && (
<Text style={styles.hintSmall}>Google PC </Text>
)}
</View>
)}
{((r.isYoutube && !r.multiChannelPro) || r.isTiktok) && (
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}></Text>
<TextInput
style={styles.area}
multiline
value={r.urlText}
onChangeText={r.setUrlText}
placeholderTextColor={theme.muted}
/>
<View style={styles.row}>
<Pressable
style={[styles.btn, styles.btnSec]}
onPress={() => void r.loadConfig('/get_url_config', r.setUrlText, r.setUrlStatus)}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, r.saveUrlLoading && styles.btnDisabled]}
disabled={r.saveUrlLoading}
onPress={() => {
r.setSaveUrlLoading(true)
void r.saveConfig('/save_url_config', r.urlText, r.setUrlStatus, () =>
r.setSaveUrlLoading(false),
)
}}
>
<Text style={styles.btnText}></Text>
</Pressable>
</View>
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
</View>
)}
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && r.isObs && (
<View style={styles.card}>
<Text style={styles.cardTitle}>OBS </Text>
<Text style={styles.hintSmall}> OBS </Text>
<Text style={styles.mono}>srt://live.local:10080/live/obs</Text>
</View>
)}
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}> 2 · </Text>
<ScrollView style={styles.logBox} nestedScrollEnabled>
<Text style={styles.log}>{r.logText || '—'}</Text>
</ScrollView>
</View>
</>
)}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
fatalWrap: { flex: 1, padding: 20, justifyContent: 'center' },
fatalHint: { fontSize: 13, color: theme.muted, lineHeight: 20, marginTop: 12, marginBottom: 20 },
retryBtn: {
alignSelf: 'flex-start',
backgroundColor: '#3d7eff',
paddingVertical: 12,
paddingHorizontal: 18,
borderRadius: 10,
},
retryBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
warnBanner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(246,173,85,0.12)',
borderWidth: 1,
borderColor: 'rgba(246,173,85,0.35)',
marginBottom: 14,
},
warnBannerText: { fontSize: 13, color: theme.warn, lineHeight: 20 },
scroll: { padding: 16, paddingBottom: 32 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(91,157,255,0.1)',
borderWidth: 1,
borderColor: 'rgba(91,157,255,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
toolbar: { marginBottom: 12 },
proGrid: { gap: 10 },
proSwitchRow: {
padding: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgCard,
gap: 8,
},
proSwitchRowOn: { borderColor: 'rgba(91,157,255,0.55)', backgroundColor: 'rgba(91,157,255,0.08)' },
proKeySuffix: { fontFamily: 'monospace', fontSize: 13, color: theme.accent, fontWeight: '600' },
proTag: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999 },
proTagOn: { backgroundColor: 'rgba(46,160,67,0.2)' },
proTagOff: { backgroundColor: 'rgba(110,118,129,0.2)' },
proTagText: { fontSize: 12, fontWeight: '600', color: theme.text },
proMid: { fontSize: 13, color: theme.text, lineHeight: 20 },
proLink: { fontSize: 12, color: theme.accent, marginTop: 6, fontWeight: '600' },
proActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
proBtn: { paddingVertical: 10, paddingHorizontal: 12, borderRadius: 8, minWidth: 72, alignItems: 'center' },
proBtnStart: { backgroundColor: '#0e7a0d' },
proBtnStop: { backgroundColor: '#d1342c' },
proBtnRestart: { backgroundColor: '#e68600' },
proBtnText: { color: '#fff', fontWeight: '700', fontSize: 12 },
newChBox: { marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: theme.border },
pickerWrap: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
overflow: 'hidden',
backgroundColor: theme.bgCard,
},
pickerDisabled: { opacity: 0.65 },
picker: { color: theme.text },
proRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
paddingVertical: 10,
paddingHorizontal: 12,
backgroundColor: theme.bgCard,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
},
proLabel: { color: theme.text, fontWeight: '600' },
proVal: { color: theme.muted, fontWeight: '600' },
pill: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
gap: 8,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 999,
borderWidth: 1,
marginBottom: 14,
},
dot: { width: 8, height: 8, borderRadius: 4 },
pillText: { fontSize: 13, fontWeight: '600' },
emptyCard: {
padding: 20,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: theme.border,
alignItems: 'center',
},
emptyTitle: { fontSize: 15, fontWeight: '600', color: theme.muted, marginBottom: 10 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: theme.border,
marginBottom: 12,
},
cardAccent: {
borderColor: 'rgba(91,157,255,0.35)',
backgroundColor: 'rgba(91,157,255,0.06)',
},
cardTitle: { fontSize: 15, fontWeight: '600', color: theme.text, marginBottom: 10 },
fieldLbl: { fontSize: 12, fontWeight: '600', color: theme.muted, marginBottom: 6, marginTop: 4 },
inputSingle: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
fontSize: 14,
marginBottom: 10,
backgroundColor: theme.bg,
},
area: {
minHeight: 100,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
fontFamily: 'monospace',
fontSize: 12,
marginBottom: 10,
backgroundColor: theme.bg,
},
inputDisabled: { opacity: 0.85 },
row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap' },
btn: {
backgroundColor: '#3d7eff',
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 10,
minWidth: 100,
alignItems: 'center',
},
btnDisabled: { opacity: 0.55 },
btnText: { color: '#fff', fontWeight: '700' },
btnSec: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border },
btnSecText: { color: theme.text, fontWeight: '600' },
hint: { color: theme.muted, fontSize: 14, lineHeight: 22, textAlign: 'center' },
hintSmall: { color: theme.muted, fontSize: 12, marginTop: 8, marginBottom: 8 },
err: { color: theme.danger, fontSize: 14 },
mono: { fontFamily: 'monospace', color: theme.accent, fontSize: 13, marginTop: 6 },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
ctrl: { flexGrow: 1, minWidth: '42%', paddingVertical: 14, borderRadius: 10, alignItems: 'center' },
ctrlDisabled: { opacity: 0.5 },
ctrl_start: { backgroundColor: '#0e7a0d' },
ctrl_stop: { backgroundColor: '#d1342c' },
ctrl_restart: { backgroundColor: '#e68600' },
ctrl_status: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border },
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
logBox: { maxHeight: 280, backgroundColor: '#1e2128', borderRadius: 10, padding: 10 },
log: { fontFamily: 'monospace', fontSize: 11, color: '#d0d4dc', lineHeight: 18 },
})

423
app/(tabs)/network.tsx Normal file
View File

@@ -0,0 +1,423 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import {
View,
Text,
ScrollView,
StyleSheet,
RefreshControl,
Pressable,
TextInput,
Switch,
Linking,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import type { NetworkPayload, SiteInfo, SpeedBlock } from '@/lib/pcTypes'
import { theme } from '@/constants/theme'
const EMPTY_SITE: SiteInfo = {
host: '',
reachable: false,
latency_ms: -1,
error: null,
resolved_ip: null,
country: null,
country_code: null,
geo_error: null,
}
const EMPTY_SPEED: SpeedBlock = {
label: '',
download_mbps: null,
download_error: null,
download_bytes_sampled: 0,
upload_mbps: null,
upload_error: null,
upload_target: null,
}
const DEFAULT_NET: NetworkPayload = {
google: EMPTY_SITE,
douyin: EMPTY_SITE,
routes: { different: null, note: '' },
speed: { overseas: EMPTY_SPEED, domestic: EMPTY_SPEED },
}
function fmtMbps(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
return `${v.toFixed(2)} Mbps`
}
type ProxyState = { enabled: boolean; http: string; socks: string }
function SiteCard({
title,
site,
speed,
loading,
variant,
onSolution,
}: {
title: string
site: SiteInfo
speed: SpeedBlock
loading: boolean
variant: 'google' | 'douyin'
onSolution?: () => void
}) {
const ok = site.reachable
let badge: ReactNode
if (loading) {
badge = <Text style={styles.badgePending}></Text>
} else if (ok) {
badge = <Text style={styles.badgeOk}>访</Text>
} else if (variant === 'google' && onSolution) {
badge = (
<Pressable onPress={onSolution}>
<Text style={styles.badgeLink}></Text>
</Pressable>
)
} else {
badge = <Text style={styles.badgeBad}>访</Text>
}
return (
<View style={styles.siteCard}>
<View style={styles.siteHead}>
<Text style={styles.siteTitle}>{title}</Text>
{badge}
</View>
<View style={[styles.kvCol, loading && styles.dim]}>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>
{loading
? '—'
: site.latency_ms != null && site.latency_ms >= 0
? `${site.latency_ms.toFixed(0)} ms`
: '—'}
</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>{loading ? '—' : fmtMbps(speed.download_mbps)}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>{loading ? '—' : fmtMbps(speed.upload_mbps)}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}> IP</Text>
<Text style={[styles.v, styles.mono]}>{loading ? '—' : site.resolved_ip ?? '—'}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>
{loading ? '—' : site.country ?? '—'}
{!loading && site.country_code ? ` (${site.country_code})` : ''}
</Text>
</View>
</View>
{!loading && (site.error || site.geo_error) ? (
<Text style={styles.siteErr}>
{[site.error, site.geo_error].filter(Boolean).join(' · ')}
</Text>
) : null}
</View>
)
}
export default function NetworkScreen() {
const { bind } = useBind()
const [data, setData] = useState<NetworkPayload | null>(null)
const [err, setErr] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [proxy, setProxy] = useState<ProxyState>({ enabled: false, http: '', socks: '' })
const [proxyMsg, setProxyMsg] = useState('')
const [savingProxy, setSavingProxy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const loadNet = useCallback(async () => {
if (!bind) {
setErr(null)
setData(null)
return
}
setErr(null)
setLoading(true)
try {
const r = await apiFetch(bind, '/network_status')
const json = (await r.json()) as NetworkPayload & { error?: string }
if (!r.ok) {
setErr(json.error || `请求失败 ${r.status}`)
setData(null)
return
}
setData(json)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setData(null)
} finally {
setLoading(false)
}
}, [bind])
const loadProxy = useCallback(async () => {
if (!bind) return
setProxyMsg('')
try {
const pr = await apiFetch(bind, '/proxy_config')
if (pr.ok) {
const p = (await pr.json()) as ProxyState
setProxy({
enabled: !!p.enabled,
http: String(p.http || ''),
socks: String(p.socks || ''),
})
}
} catch {
/* ignore */
}
}, [bind])
const saveProxy = useCallback(async () => {
if (!bind) {
setProxyMsg('请先配对')
return
}
setSavingProxy(true)
setProxyMsg('')
try {
const res = await apiFetch(bind, '/proxy_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(proxy),
})
if (!res.ok) throw new Error(String(res.status))
setProxyMsg('已保存。重启直播任务后生效。')
} catch (e) {
setProxyMsg(e instanceof Error ? e.message : String(e))
} finally {
setSavingProxy(false)
}
}, [bind, proxy])
const loadAll = useCallback(async () => {
await loadNet()
await loadProxy()
}, [loadNet, loadProxy])
useEffect(() => {
void loadAll()
}, [loadAll])
useEffect(() => {
if (!bind) return
const id = setInterval(() => void loadNet(), 45000)
return () => clearInterval(id)
}, [bind, loadNet])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await loadAll()
setRefreshing(false)
}, [loadAll])
const view = data ?? DEFAULT_NET
const openSolution = useCallback(() => {
void Linking.openURL('https://nomadro.com/')
}, [])
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}></Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC Google / </Text>
</View>
)}
<View style={styles.cardNet}>
<View style={styles.cardHead}>
<Text style={styles.cardTitle}></Text>
<Pressable style={styles.btnSec} onPress={() => void loadNet()} disabled={!bind || loading}>
<Text style={styles.btnSecText}>{loading ? '检测中…' : '重新检测'}</Text>
</Pressable>
</View>
{err && bind ? <Text style={styles.err}>{err}</Text> : null}
<View style={styles.siteRow}>
<SiteCard
title="Google海外参考"
site={view.google}
speed={view.speed.overseas}
loading={loading && !!bind}
variant="google"
onSolution={openSolution}
/>
<SiteCard
title="抖音(国内参考)"
site={view.douyin}
speed={view.speed.domestic}
loading={loading && !!bind}
variant="douyin"
/>
</View>
{view.routes?.note ? (
<Text style={styles.routesNote}>{view.routes.note}</Text>
) : null}
</View>
<View style={styles.cardProxy}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.desc}>
FFmpeg /
</Text>
<View style={styles.rowBetween}>
<Text style={styles.label}>使</Text>
<Switch
value={proxy.enabled}
disabled={!bind}
onValueChange={(v) => setProxy((p) => ({ ...p, enabled: v }))}
/>
</View>
<Text style={styles.label}>HTTP </Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxy.http}
editable={!!bind}
onChangeText={(t) => setProxy((p) => ({ ...p, http: t }))}
placeholder="http://127.0.0.1:7890"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.label}>SOCKS5</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxy.socks}
editable={!!bind}
onChangeText={(t) => setProxy((p) => ({ ...p, socks: t }))}
placeholder="socks5://127.0.0.1:7890"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<View style={styles.row}>
<Pressable
style={[styles.btnPri, (!bind || savingProxy) && styles.btnDisabled]}
disabled={!bind || savingProxy}
onPress={() => void saveProxy()}
>
<Text style={styles.btnPriText}>{savingProxy ? '保存中…' : '保存'}</Text>
</Pressable>
<Pressable style={styles.btnSec} disabled={!bind} onPress={() => void loadProxy()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
</View>
{!!proxyMsg && <Text style={proxyMsg.includes('已保存') ? styles.ok : styles.err}>{proxyMsg}</Text>}
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 16, paddingBottom: 40 },
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(124,92,255,0.08)',
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
cardNet: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.28)',
marginBottom: 14,
},
cardProxy: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(61,214,140,0.22)',
marginBottom: 14,
},
cardHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text },
btnSec: {
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 },
btnPri: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: '#3d7eff',
},
btnPriText: { color: '#fff', fontWeight: '700' },
btnDisabled: { opacity: 0.5 },
err: { color: theme.danger, marginBottom: 10, fontSize: 13 },
ok: { color: theme.success, marginTop: 8, fontSize: 13 },
siteRow: { gap: 12 },
siteCard: {
padding: 12,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
marginBottom: 4,
},
siteHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
siteTitle: { fontSize: 14, fontWeight: '600', color: theme.text, flex: 1 },
badgePending: { fontSize: 11, fontWeight: '700', color: theme.muted },
badgeOk: { fontSize: 11, fontWeight: '700', color: theme.success },
badgeBad: { fontSize: 11, fontWeight: '700', color: theme.danger },
badgeLink: { fontSize: 11, fontWeight: '700', color: theme.accent, textDecorationLine: 'underline' },
kvCol: { gap: 6 },
kvRow: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 8 },
dim: { opacity: 0.72 },
k: { fontSize: 12, color: theme.muted, fontWeight: '600', marginTop: 4 },
v: { fontSize: 13, color: theme.text },
mono: { fontFamily: 'monospace', fontSize: 12 },
siteErr: { fontSize: 11, color: theme.warn, marginTop: 8, lineHeight: 16 },
routesNote: { fontSize: 12, color: theme.muted, marginTop: 10, lineHeight: 18 },
desc: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 12 },
rowBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
input: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
marginBottom: 12,
backgroundColor: theme.bg,
},
inputDisabled: { opacity: 0.6 },
row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap', marginTop: 4 },
})

208
app/(tabs)/system.tsx Normal file
View File

@@ -0,0 +1,208 @@
import { useCallback, useEffect, useState } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import type { SystemMetricsPayload } from '@/lib/pcTypes'
import { theme } from '@/constants/theme'
function fmtBytes(n: number | undefined): string {
if (n == null || Number.isNaN(n)) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
return `${(n / 1024 ** 3).toFixed(2)} GB`
}
export default function SystemScreen() {
const { bind } = useBind()
const [data, setData] = useState<SystemMetricsPayload | null>(null)
const [err, setErr] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setData(null)
setErr(null)
return
}
try {
const res = await apiFetch(bind, '/system_metrics')
const json = (await res.json()) as SystemMetricsPayload
if (!res.ok) {
setErr(json.error || `HTTP ${res.status}`)
return
}
setErr(null)
setData(json)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
}, [bind])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (!bind) return
const id = setInterval(() => void load(), 2000)
return () => clearInterval(id)
}, [bind, load])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
const m = data?.memory
const net = data?.network
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}> Windows </Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC 2 </Text>
</View>
)}
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.desc}>
ffmpeg
</Text>
{err && bind ? (
<Text style={styles.err}>{err}</Text>
) : null}
<View style={styles.metricsGrid}>
<View style={styles.block}>
<Text style={styles.blockTitle}>CPU</Text>
<Text style={styles.big}>{data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}</Text>
<Text style={styles.sub}> {data?.cpu_count_logical ?? '—'} </Text>
</View>
<View style={styles.block}>
<Text style={styles.blockTitle}></Text>
<Text style={styles.big}>{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}</Text>
<Text style={styles.sub}>
{fmtBytes(m?.used)} / {fmtBytes(m?.total)} {fmtBytes(m?.available)}
</Text>
</View>
<View style={styles.block}>
<Text style={styles.blockTitle}></Text>
<Text style={styles.rate}>
{net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}
</Text>
<Text style={styles.rate}>
{net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}
</Text>
<Text style={styles.sub}></Text>
</View>
</View>
{data?.swap && data.swap.total != null && data.swap.total > 0 ? (
<Text style={styles.swap}>
{data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
{fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)}
</Text>
) : null}
{data?.disks && data.disks.length > 0 ? (
<View style={styles.diskSec}>
<Text style={styles.diskTitle}></Text>
{data.disks.slice(0, 8).map((d, i) => (
<Text key={i} style={styles.diskLine}>
<Text style={styles.diskMp}>{d.mountpoint}</Text> ·{' '}
{d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} · {fmtBytes(d.free)}
</Text>
))}
</View>
) : null}
{data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 ? (
<View style={styles.ffSec}>
<Text style={styles.diskTitle}>ffmpeg </Text>
{data.ffmpeg_processes.map((p, i) => (
<Text key={i} style={styles.ffLine}>
PID {p.pid} · {p.name ?? '—'} · {fmtBytes(p.rss)}
</Text>
))}
</View>
) : null}
</View>
<View style={styles.cardMuted}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.muted}> Electron </Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 16, paddingBottom: 40 },
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(124,92,255,0.08)',
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.22)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.22)',
marginBottom: 12,
},
cardMuted: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
},
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 },
desc: { fontSize: 13, color: theme.muted, marginBottom: 12, lineHeight: 20 },
err: { color: theme.danger, marginBottom: 10, fontSize: 13 },
metricsGrid: { gap: 12 },
block: {
padding: 12,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
},
blockTitle: { fontSize: 11, fontWeight: '700', color: theme.muted, marginBottom: 6 },
big: { fontSize: 22, fontWeight: '700', color: theme.text },
sub: { fontSize: 11, color: theme.muted, marginTop: 4, lineHeight: 16 },
rate: { fontSize: 15, fontWeight: '600', color: theme.accent, fontFamily: 'monospace' },
swap: { fontSize: 12, color: theme.muted, marginTop: 10, marginBottom: 8 },
diskSec: { marginTop: 8 },
diskTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 8 },
diskLine: { fontSize: 12, color: theme.muted, marginBottom: 6 },
diskMp: { fontFamily: 'monospace', color: theme.text },
ffSec: { marginTop: 12 },
ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' },
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
})

392
app/(tabs)/wechat.tsx Normal file
View File

@@ -0,0 +1,392 @@
import { useCallback, useEffect, useState } from 'react'
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
Pressable,
Image,
Linking,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import { theme } from '@/constants/theme'
type WechatConfigView = {
configured: boolean
proxyUrl: string
apiKeyMasked: string
deviceType: string
proxy: string
webhookHost: string
webhookPort: number
webhookPath: string
accountId: string
session: { wcId?: string; nickName?: string }
}
export default function WeChatScreen() {
const { bind } = useBind()
const [cfg, setCfg] = useState<WechatConfigView | null>(null)
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const [apiKeyInput, setApiKeyInput] = useState('')
const [proxyUrlInput, setProxyUrlInput] = useState('')
const [deviceType, setDeviceType] = useState('ipad')
const [proxyLine, setProxyLine] = useState('10')
const [wId, setWId] = useState('')
const [qrUrl, setQrUrl] = useState('')
const [poll, setPoll] = useState(false)
const [proxyStatus, setProxyStatus] = useState<Record<string, unknown> | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setCfg(null)
return
}
setMsg('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/config')
if (!r.ok) throw new Error(String(r.status))
const j = (await r.json()) as WechatConfigView
setCfg(j)
setProxyUrlInput(j.proxyUrl || '')
setDeviceType(j.deviceType || 'ipad')
setProxyLine(j.proxy || '10')
setApiKeyInput('')
} catch (e) {
setCfg(null)
setMsg(e instanceof Error ? e.message : String(e))
}
}, [bind])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (!poll || !wId || !bind) return
const tick = async () => {
try {
const r = await apiFetch(bind, '/chatbot/wechat/login/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wId }),
})
const j = (await r.json()) as {
status?: string
wcId?: string
nickName?: string
verifyUrl?: string
error?: string
}
if (!r.ok) {
setMsg(j.error || String(r.status))
setPoll(false)
return
}
if (j.status === 'logged_in') {
setPoll(false)
setMsg(`已登录:${j.nickName || ''}${j.wcId || ''}`)
void load()
return
}
if (j.status === 'need_verify' && j.verifyUrl) {
setMsg(`需要辅助验证:${j.verifyUrl}`)
}
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
setPoll(false)
}
}
const id = setInterval(() => void tick(), 5000)
void tick()
return () => clearInterval(id)
}, [poll, wId, bind, load])
const saveConfig = async () => {
if (!bind) return
setBusy(true)
setMsg('')
try {
const body: Record<string, string | number> = {
proxyUrl: proxyUrlInput.trim(),
deviceType,
proxy: proxyLine.trim(),
}
if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim()
const r = await apiFetch(bind, '/chatbot/wechat/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!r.ok) {
const t = await r.text()
throw new Error(t || String(r.status))
}
await load()
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const loadProxyStatus = async () => {
if (!bind) return
setBusy(true)
setMsg('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/proxy/status')
const j = await r.json()
if (!r.ok) {
setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j))
setProxyStatus(null)
return
}
setProxyStatus(j as Record<string, unknown>)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const startQr = async () => {
if (!bind) return
setBusy(true)
setMsg('')
setQrUrl('')
setWId('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/qr/start', { method: 'POST' })
const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string }
if (!r.ok) {
setMsg(j.error || String(r.status))
return
}
if (!j.wId || !j.qrCodeUrl) {
setMsg('未返回二维码')
return
}
setWId(j.wId)
setQrUrl(j.qrCodeUrl)
setPoll(true)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}>openclaw-wechat API</Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
<View style={styles.card}>
<Text style={styles.cardTitle}> Chatbot</Text>
<Text style={styles.label}>proxyUrl</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxyUrlInput}
editable={!!bind}
onChangeText={setProxyUrlInput}
placeholder="http://your-proxy:3000"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.label}>apiKey</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={apiKeyInput}
editable={!!bind}
onChangeText={setApiKeyInput}
placeholder={cfg?.apiKeyMasked ? `已保存 ${cfg.apiKeyMasked}` : 'wc_live_…'}
placeholderTextColor={theme.muted}
secureTextEntry
autoCapitalize="none"
/>
<Text style={styles.label}>deviceType</Text>
<View style={styles.row}>
<Pressable
style={[styles.chip, deviceType === 'ipad' && styles.chipOn]}
onPress={() => setDeviceType('ipad')}
disabled={!bind || busy}
>
<Text style={styles.chipText}>ipad</Text>
</Pressable>
<Pressable
style={[styles.chip, deviceType === 'mac' && styles.chipOn]}
onPress={() => setDeviceType('mac')}
disabled={!bind || busy}
>
<Text style={styles.chipText}>mac</Text>
</Pressable>
</View>
<Text style={styles.label}>proxy </Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxyLine}
editable={!!bind}
onChangeText={setProxyLine}
placeholderTextColor={theme.muted}
/>
<View style={styles.btnRow}>
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void saveConfig()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void load()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btnSec, !bind && styles.disabled]}
disabled={!bind || busy}
onPress={() => void loadProxyStatus()}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
</View>
{proxyStatus ? (
<Text style={styles.pre}>{JSON.stringify(proxyStatus, null, 2)}</Text>
) : null}
{cfg?.session?.wcId ? (
<Text style={styles.hint}>{cfg.session.nickName || '—'}{cfg.session.wcId}</Text>
) : null}
<Pressable
style={[styles.btnPri, (!bind || busy || !cfg?.configured) && styles.disabled]}
disabled={!bind || busy || !cfg?.configured}
onPress={() => void startQr()}
>
<Text style={styles.btnPriText}></Text>
</Pressable>
{!cfg?.configured && bind ? (
<Text style={styles.hint}> apiKey proxyUrl</Text>
) : null}
{qrUrl ? (
<View style={styles.qrBlock}>
<Text style={styles.hint}>使</Text>
<Image source={{ uri: qrUrl }} style={styles.qrImg} resizeMode="contain" />
<Pressable onPress={() => void Linking.openURL(qrUrl)}>
<Text style={styles.link}></Text>
</Pressable>
</View>
) : null}
{!!msg && (
<Text style={[styles.msg, msg.startsWith('已登录') ? styles.msgOk : styles.msgErr]}>{msg}</Text>
)}
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 16, paddingBottom: 40 },
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(100,149,237,0.1)',
borderWidth: 1,
borderColor: 'rgba(100,149,237,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(100,149,237,0.28)',
},
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 12 },
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
input: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
marginBottom: 12,
backgroundColor: theme.bg,
},
inputDisabled: { opacity: 0.55 },
row: { flexDirection: 'row', gap: 10, marginBottom: 12 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
chipOn: { borderColor: theme.accent, backgroundColor: 'rgba(91,157,255,0.12)' },
chipText: { color: theme.text, fontWeight: '600' },
btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 },
btnSec: {
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 },
btnPri: {
marginTop: 8,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: '#3d7eff',
alignItems: 'center',
},
btnPriText: { color: '#fff', fontWeight: '700' },
disabled: { opacity: 0.45 },
pre: {
fontFamily: 'monospace',
fontSize: 11,
color: '#c8cdd5',
marginBottom: 12,
lineHeight: 16,
},
hint: { fontSize: 13, color: theme.muted, marginTop: 8, lineHeight: 20 },
qrBlock: { marginTop: 16, alignItems: 'center' },
qrImg: { width: 224, height: 224, marginVertical: 12, backgroundColor: '#fff' },
link: { color: theme.accent, fontSize: 14, textDecorationLine: 'underline' },
msg: { marginTop: 12, fontSize: 14 },
msgOk: { color: theme.success },
msgErr: { color: theme.danger },
})

38
app/+html.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

40
app/+not-found.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});

58
app/_layout.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { BindProvider } from '@/context/BindContext';
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export const unstable_settings = {
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<BindProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</ThemeProvider>
</BindProvider>
);
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View File

@@ -0,0 +1,24 @@
import { Link, type Href } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import React from 'react';
import { Platform } from 'react-native';
export function ExternalLink(
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: Href }
) {
return (
<Link
target="_blank"
{...props}
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

45
components/Themed.tsx Normal file
View File

@@ -0,0 +1,45 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import { useColorScheme } from './useColorScheme';
import Colors from '@/constants/Colors';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme();
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,4 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = React.useState<S | C>(server);
React.useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View File

@@ -0,0 +1,6 @@
import { useColorScheme as useColorSchemeCore } from 'react-native';
export const useColorScheme = () => {
const coreScheme = useColorSchemeCore();
return coreScheme === 'unspecified' ? 'light' : coreScheme;
};

View File

@@ -0,0 +1,8 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

18
constants/Colors.ts Normal file
View File

@@ -0,0 +1,18 @@
const accent = '#5b9dff';
export default {
light: {
text: '#0f172a',
background: '#f3f5fb',
tint: '#2563eb',
tabIconDefault: '#94a3b8',
tabIconSelected: '#2563eb',
},
dark: {
text: '#e8eaed',
background: '#14161c',
tint: accent,
tabIconDefault: '#64748b',
tabIconSelected: accent,
},
};

13
constants/theme.ts Normal file
View File

@@ -0,0 +1,13 @@
/** 与桌面端 App.css 深色主题对齐 */
export const theme = {
bg: '#14161c',
bgCard: '#22262e',
bgElevated: '#282c36',
border: 'rgba(255,255,255,0.08)',
text: '#e8eaed',
muted: '#8b929e',
accent: '#5b9dff',
success: '#3dd68c',
danger: '#f56565',
warn: '#f6ad55',
}

65
context/BindContext.tsx Normal file
View File

@@ -0,0 +1,65 @@
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'
type Ctx = {
bind: BindPayload | null
ready: boolean
setBind: (p: BindPayload | null) => Promise<void>
clear: () => Promise<void>
}
const BindContext = createContext<Ctx | null>(null)
export function BindProvider({ children }: { children: React.ReactNode }) {
const [bind, setBindState] = useState<BindPayload | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
void (async () => {
const p = await loadBindPayload()
setBindState(p)
setReady(true)
})()
}, [])
useEffect(() => {
if (!ready || !bind) return
if (Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
void saveAndPrepareTunnel(bind.easytier).catch(() => {})
}
}, [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 () => {
setBindState(null)
await clearBindPayload()
}, [])
const value = useMemo(
() => ({ bind, ready, setBind, clear }),
[bind, ready, setBind, clear],
)
return <BindContext.Provider value={value}>{children}</BindContext.Provider>
}
export function useBind(): Ctx {
const c = useContext(BindContext)
if (!c) throw new Error('useBind outside BindProvider')
return c
}

1048
hooks/useRecorder.ts Normal file

File diff suppressed because it is too large Load Diff

31
lib/api.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { BindPayload } from './types'
function joinUrl(base: string, path: string): string {
const b = base.replace(/\/$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return `${b}${p}`
}
export async function apiFetch(
bind: BindPayload | null,
path: string,
init?: RequestInit,
): Promise<Response> {
if (!bind) {
return Promise.reject(new Error('尚未绑定 PC请先在「配对」扫码'))
}
const headers = new Headers(init?.headers)
if (bind.token?.trim()) {
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
}
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
}
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(bind, path, init)
if (!res.ok) {
const t = await res.text()
throw new Error(t || `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}

36
lib/easyTierVpn.ts Normal file
View File

@@ -0,0 +1,36 @@
import { requireOptionalNativeModule } from 'expo-modules-core'
export type EasyTierConfigInput = {
networkName: string
networkSecret: string
peerUrls: string[]
}
type NativeModule = {
isAvailable: () => boolean
saveAndPrepareTunnel: (configJson: string) => Promise<Record<string, unknown>>
checkVpnPermissionGranted: () => Promise<Record<string, unknown>>
}
const Native = requireOptionalNativeModule<NativeModule>('ExpoEasyTierVpn')
export function isExpoEasyTierVpnAvailable(): boolean {
try {
return Native?.isAvailable?.() === true
} catch {
return false
}
}
export async function saveAndPrepareTunnel(config: EasyTierConfigInput): Promise<Record<string, unknown>> {
if (!Native?.saveAndPrepareTunnel) {
return { ok: false, reason: 'native_module_missing' }
}
return Native.saveAndPrepareTunnel(JSON.stringify(config))
}
export async function checkVpnPermissionGranted(): Promise<boolean> {
if (!Native?.checkVpnPermissionGranted) return false
const r = await Native.checkVpnPermissionGranted()
return r?.granted === true
}

88
lib/pcTypes.ts Normal file
View File

@@ -0,0 +1,88 @@
/** 与 Electron NetworkDashboard / SystemMetrics 对齐的数据结构 */
export type SiteInfo = {
host: string
reachable: boolean
latency_ms: number
error: string | null
resolved_ip: string | null
country: string | null
country_code: string | null
geo_error: string | null
}
export type SpeedBlock = {
label: string
download_mbps: number | null
download_error: string | null
download_bytes_sampled: number
upload_mbps: number | null
upload_error: string | null
upload_target: string | null
}
export type NetworkPayload = {
google: SiteInfo
douyin: SiteInfo
routes: { different: boolean | null; note: string }
speed: { overseas: SpeedBlock; domestic: SpeedBlock }
meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
error?: string
}
export type SystemMetricsPayload = {
ts?: number
cpu_percent?: number | null
cpu_count_logical?: number
memory?: { percent?: number; used?: number; total?: number; available?: number }
swap?: { percent?: number; used?: number; total?: number }
disks?: Array<{
mountpoint?: string
percent?: number
free?: number
total?: number
}>
network?: { up_mbps?: number | null; down_mbps?: number | null }
ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
error?: string
}
export type ClientOverview = {
youtubeChannelCount?: number
youtubeChannels?: { name: string; keyPreview: string; douyinPreview?: string }[]
relayPairingPolicy?: string
relayValidationErrors?: string[]
relayNotes?: string[]
wechat?: {
configured?: boolean
nickName?: string
wcId?: string
online?: boolean
proxyError?: string | null
}
}
export type RelayLivePayload = {
youtubeProcess?: string
processStatus?: string
processOnline?: boolean
relayRows?: Array<{
channelId?: string | null
channelName?: string
anchor?: string
douyinHint?: string
streamTitle?: string
youtubeKeySuffix?: string
durationSec?: number | null
processOnline?: boolean
}>
}
export type YoutubeIniModel = {
keys: string[]
activeIndex: number
options: Record<string, string>
header: string
}
export type GpuCaps = { nvidia_detected: boolean; nvenc_available: boolean }

17
lib/scriptUtils.ts Normal file
View File

@@ -0,0 +1,17 @@
export function pm2NameFromScript(script: string): string {
const base = String(script).split(/[/\\]/).pop() || ''
const i = base.lastIndexOf('.')
return i > 0 ? base.slice(0, i) : base
}
export function isYoutubeScript(script: string): boolean {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube')
}
export function isTiktokScript(script: string): boolean {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok')
}
export function isObsScript(script: string): boolean {
return pm2NameFromScript(script).startsWith('obs')
}

39
lib/storage.ts Normal file
View File

@@ -0,0 +1,39 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { BindPayload } from './types'
const KEY = 'bind_payload_v1'
export async function loadBindPayload(): Promise<BindPayload | null> {
try {
const raw = await AsyncStorage.getItem(KEY)
if (!raw) return null
const j = JSON.parse(raw) as BindPayload
if (j.v !== 1 || !j.baseUrl) return null
return j
} catch {
return null
}
}
export async function saveBindPayload(p: BindPayload): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(p))
}
export async function clearBindPayload(): Promise<void> {
await AsyncStorage.removeItem(KEY)
}
export const STORAGE_MULTI_CHANNEL_PRO = 'recording_multi_channel_pro'
export async function getMultiChannelPro(): Promise<boolean> {
try {
const v = await AsyncStorage.getItem(STORAGE_MULTI_CHANNEL_PRO)
return v === '1'
} catch {
return false
}
}
export async function setMultiChannelPro(on: boolean): Promise<void> {
await AsyncStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, on ? '1' : '0')
}

54
lib/types.ts Normal file
View File

@@ -0,0 +1,54 @@
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` 输出一致,供扫码导入 */
export type BindPayload = {
v: 1
baseUrl: string
port: number
token: string
easytier: EasyTierConfig
}
export function parseBindPayload(raw: string): BindPayload | null {
try {
const j = JSON.parse(raw) as Record<string, unknown>
if (j.v !== 1) return null
const et = j.easytier as Record<string, unknown> | 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
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),
},
}
} catch {
return null
}
}

71
lib/youtubeConfigUtils.ts Normal file
View File

@@ -0,0 +1,71 @@
/** 与 electron youtubeConfigUtils 对齐(远程控制仅用于展示/解析) */
export function parseYoutubeStreamKeySuffix(iniContent: string): string {
for (const line of iniContent.split(/\r?\n/)) {
const t = line.trim()
if (t.startsWith('#') || !t) continue
const m = t.match(/^(?:key|stream_key)\s*=\s*(.+)$/i)
if (m) {
const v = m[1].trim().split(/[#;]/)[0].trim()
if (v && !v.startsWith('rtmp')) return v.length > 8 ? v.slice(-8) : v
if (v.includes('live2/')) {
const parts = v.split('/')
const last = parts[parts.length - 1] || ''
return last.length > 8 ? last.slice(-8) : last
}
}
}
return ''
}
export function extractDouyinLiveUrls(urlIni: string): string[] {
const out: string[] = []
for (const line of urlIni.split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue
const full = t.match(/(https?:\/\/live\.douyin\.com\/[a-zA-Z0-9_-]+)/i)
if (full) {
out.push(full[1].replace(/^http:\/\//i, 'https://'))
continue
}
const rel = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)
if (rel) {
out.push(`https://live.douyin.com/${rel[1]}`)
continue
}
const n = t.match(/(\d{8,})/)
if (n) out.push(`https://live.douyin.com/${n[1]}`)
}
return [...new Set(out)].slice(0, 16)
}
export function extractDouyinRoomHints(urlIni: string): string[] {
const out: string[] = []
for (const line of urlIni.split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue
const m = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)
if (m) {
out.push(m[1])
continue
}
const n = t.match(/(\d{8,})/)
out.push(n ? n[1] : t.slice(0, 96))
}
return [...new Set(out)].slice(0, 12)
}
export function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { room: string; href: string } {
const combined = `${urlIni}\n${douyinUrl ?? ''}`
const urls = extractDouyinLiveUrls(combined)
const hints = extractDouyinRoomHints(combined)
const room = hints[0] || '—'
const href = urls[0] || (hints[0] ? `https://live.douyin.com/${hints[0]}` : '')
return { room, href }
}
export function proChannelPm2Process(channelId: string): string {
return `youtube2__${channelId}`
}

View File

@@ -0,0 +1,19 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.easytier'
version = '1.0.0'
android {
namespace "expo.modules.easytier"
defaultConfig {
versionCode 1
versionName "1.0.0"
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
}

View File

@@ -0,0 +1 @@
o/bundleLibRuntimeToDirDebug

View File

@@ -0,0 +1,10 @@
/**
* Automatically generated file. DO NOT MODIFY
*/
package expo.modules.easytier;
public final class BuildConfig {
public static final boolean DEBUG = Boolean.parseBoolean("true");
public static final String LIBRARY_PACKAGE_NAME = "expo.modules.easytier";
public static final String BUILD_TYPE = "debug";
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="expo.modules.easytier" >
<uses-sdk android:minSdkVersion="24" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,18 @@
{
"version": 3,
"artifactType": {
"type": "AAPT_FRIENDLY_MERGED_MANIFESTS",
"kind": "Directory"
},
"applicationId": "expo.modules.easytier",
"variantName": "debug",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"outputFile": "AndroidManifest.xml"
}
],
"elementType": "File"
}

View File

@@ -0,0 +1,6 @@
aarFormatVersion=1.0
aarMetadataVersion=1.0
minCompileSdk=1
minCompileSdkExtension=0
minAndroidGradlePluginVersion=1.0.0
coreLibraryDesugaringEnabled=false

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\build\generated\res\resValues\debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\build\generated\res\resValues\debug"/></dataSet><mergedItems/></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\build\intermediates\shader_assets\debug\compileDebugShaders\out"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\jniLibs"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\shaders"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
R_DEF: Internal format may change without notice
local

View File

@@ -0,0 +1,11 @@
1<?xml version="1.0" encoding="utf-8"?>
2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3 package="expo.modules.easytier" >
4
5 <uses-sdk android:minSdkVersion="24" />
6
7 <uses-permission android:name="android.permission.INTERNET" />
7-->C:\project\mobile\modules\expo-easytier-vpn\android\src\main\AndroidManifest.xml:2:3-65
7-->C:\project\mobile\modules\expo-easytier-vpn\android\src\main\AndroidManifest.xml:2:20-62
8
9</manifest>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="expo.modules.easytier" >
<uses-sdk android:minSdkVersion="24" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

Some files were not shown because too many files have changed in this diff Show More