7 Commits
main ... vip

Author SHA1 Message Date
eric
9f8b02ea4f 's' 2026-04-05 01:06:05 -05:00
eric
3bc1599763 's' 2026-03-26 01:59:04 -05:00
eric
aba40c8cb7 's' 2026-03-26 00:48:14 -05:00
eric
f16a8a9255 's' 2026-03-25 16:21:20 -05:00
eric
fe051d390d 's' 2026-03-25 16:18:38 -05:00
eric
e764abb32a 's' 2026-03-25 16:14:50 -05:00
eric
e884d5691e 's' 2026-03-25 14:06:27 -05:00
185 changed files with 2607 additions and 611 deletions

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

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

View File

@@ -10,7 +10,7 @@
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#2D2AF6"
},
"ios": {
"supportsTablet": true
@@ -19,7 +19,7 @@
"package": "com.douyin.recorder.remote",
"usesCleartextTraffic": true,
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"backgroundColor": "#2D2AF6",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
@@ -38,7 +38,7 @@
[
"expo-camera",
{
"cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。"
"cameraPermission": "用于扫描 PC 端绑定二维码(baseUrl/token)。"
}
]
],

View File

@@ -1,4 +1,5 @@
import React from 'react'
import { Platform } from 'react-native'
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
@@ -8,17 +9,28 @@ import { useClientOnlyValue } from '@/components/useClientOnlyValue'
export default function TabLayout() {
const colorScheme = useColorScheme()
const dark = colorScheme === 'dark'
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
tabBarInactiveTintColor: dark ? 'rgba(255,255,255,0.38)' : 'rgba(0,0,0,0.35)',
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', letterSpacing: 0.2 },
tabBarStyle: {
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
borderTopColor: 'rgba(255,255,255,0.08)',
backgroundColor: dark ? '#161a22' : '#fff',
borderTopWidth: 1,
borderTopColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)',
height: Platform.OS === 'ios' ? 88 : 64,
paddingTop: 6,
},
headerStyle: {
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
backgroundColor: dark ? '#161a22' : '#fff',
shadowColor: '#000',
shadowOpacity: dark ? 0.25 : 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
headerTintColor: Colors[colorScheme].text,
headerShown: useClientOnlyValue(false, true),

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
View,
Text,
@@ -6,82 +6,117 @@ import {
Pressable,
ScrollView,
StyleSheet,
Linking,
Alert,
Platform,
} from 'react-native'
import { CameraView, useCameraPermissions } from 'expo-camera'
import * as Clipboard from 'expo-clipboard'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { Ionicons } from '@expo/vector-icons'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useBind } from '@/context/BindContext'
import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types'
import {
checkVpnPermissionGranted,
isExpoEasyTierVpnAvailable,
saveAndPrepareTunnel,
} from '@/lib/easyTierVpn'
import { parseBindPayload, type BindPayload } from '@/lib/types'
import { theme } from '@/constants/theme'
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0]
function normalizeHostInput(hostInput: string): { baseUrl: string; port: number } | null {
const s = hostInput.trim()
if (!s) return null
if (/^https?:\/\//i.test(s)) {
try {
const u = new URL(s)
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80))
if (!Number.isFinite(port)) return null
return { baseUrl: u.origin, port }
} catch {
return null
}
}
const noPath = s.split('/')[0]
const i = noPath.lastIndexOf(':')
if (i <= 0) return null
const host = noPath.slice(0, i).trim()
const port = Number(noPath.slice(i + 1))
if (!host || !Number.isFinite(port) || port <= 0) return null
return { baseUrl: `http://${host}:${port}`, port }
}
export default function BindScreen() {
const { bind, setBind, clear, ready } = useBind()
const [paste, setPaste] = useState('')
const [msg, setMsg] = useState('')
const [hostText, setHostText] = useState('')
const [scanOn, setScanOn] = useState(false)
const [perm, requestPerm] = useCameraPermissions()
const scannedRef = useRef(false)
const [etBusy, setEtBusy] = useState(false)
const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable()
useEffect(() => {
if (!scanOn) scannedRef.current = false
}, [scanOn])
useEffect(() => {
if (!bind?.baseUrl) {
setHostText('')
return
}
const v = bind.baseUrl.replace(/^https?:\/\//i, '')
setHostText(v)
}, [bind?.baseUrl])
const applyPayload = useCallback(
async (p: BindPayload | null) => {
if (!p) {
setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。')
return
}
async (p: BindPayload) => {
await setBind(p)
setMsg(
etEmbedded
? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。'
: '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。'
)
setScanOn(false)
scannedRef.current = false
setMsg('已导入绑定(含 token。可在下方修改 IP/域名与端口后再点「保存」。')
},
[etEmbedded, setBind],
[setBind],
)
const onBarCode = useCallback(
async ({ data }: { data: string }) => {
const onBarcodeScanned = useCallback(
(result: { data: string }) => {
if (scannedRef.current) return
const p = parseBindPayload(result.data)
if (!p) {
setMsg('二维码内容不是有效的绑定 JSON需 v1、baseUrl 等字段)。')
return
}
scannedRef.current = true
const p = parseBindPayload(data)
await applyPayload(p)
void (async () => {
await applyPayload(p)
setScanOn(false)
})()
},
[applyPayload],
)
const onPasteApply = useCallback(async () => {
const p = parseBindPayload(paste.trim())
await applyPayload(p)
}, [paste, applyPayload])
const copyEtJson = useCallback(async () => {
if (!bind) return
const chunk = {
networkName: bind.easytier.networkName,
networkSecret: bind.easytier.networkSecret,
peerUrls: bind.easytier.peerUrls,
const onPasteJson = useCallback(async () => {
const raw = (await Clipboard.getStringAsync()).trim()
const p = parseBindPayload(raw)
if (!p) {
setMsg('剪贴板内容不是有效的绑定 JSON。')
return
}
await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2))
setMsg('已复制 EasyTier 组网 JSON。')
}, [bind])
await applyPayload(p)
}, [applyPayload])
const onSaveHost = useCallback(async () => {
const n = normalizeHostInput(hostText)
if (!n) {
setMsg('请输入 `ip/域名:端口`(或带 http:// 的 baseUrl。')
return
}
const p: BindPayload = { v: 1, baseUrl: n.baseUrl, port: n.port, token: bind?.token ?? '' }
await setBind(p)
setMsg('已保存绑定地址;未改动的 token 已保留。')
}, [hostText, bind?.token, setBind])
const onResetHost = useCallback(async () => {
setMsg('')
Alert.alert('清除配对', '确定要删除当前绑定地址吗?', [
{ text: '取消', style: 'cancel' },
{ text: '清除', style: 'destructive', onPress: () => void clear() },
])
}, [clear])
const copyFull = useCallback(async () => {
if (!bind) return
@@ -91,52 +126,49 @@ export default function BindScreen() {
const testConn = useCallback(async () => {
if (!bind) return
const url = `${bind.baseUrl}/health`
setMsg('正在测试 API 连接…')
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 15000)
try {
const r = await fetch(`${bind.baseUrl}/health`, {
const r = await fetch(url, {
signal: ac.signal,
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
})
const j = await r.json().catch(() => ({}))
setMsg('')
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
} catch (e) {
Alert.alert('连接失败', e instanceof Error ? e.message : String(e))
const isAbort = e instanceof Error && e.name === 'AbortError'
setMsg('')
Alert.alert(
isAbort ? '连接超时' : '连接失败',
isAbort
? '15 秒内无响应。请确认手机能访问绑定里的 baseUrl:端口(或域名:端口PC 上 web2 服务已启动,且防火墙/网络路由允许(换网后可能需要重新扫码或改地址)。'
: e instanceof Error
? e.message
: String(e),
)
} finally {
clearTimeout(t)
}
}, [bind])
const retryEmbeddedTunnel = useCallback(async () => {
if (!bind || !etEmbedded) return
setEtBusy(true)
try {
const r = await saveAndPrepareTunnel(bind.easytier)
if (r.needsVpnPermission) {
setMsg('请在系统弹窗中允许本应用建立 VPN授权后再点“检查 VPN 权限”。')
} else if (r.ok && r.vpnPermissionGranted) {
setMsg(
'VPN 权限已就绪EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。'
)
} else {
setMsg(`组网准备结果:${JSON.stringify(r)}`)
const openScan = useCallback(async () => {
setMsg('')
if (Platform.OS === 'web') {
setMsg('网页版请使用「从剪贴板导入 JSON」。')
return
}
if (!perm?.granted) {
const r = await requestPerm()
if (!r.granted) {
setMsg('需要相机权限才能扫码。')
return
}
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setEtBusy(false)
}
}, [bind, etEmbedded])
const checkVpn = useCallback(async () => {
if (!etEmbedded) return
setEtBusy(true)
try {
const ok = await checkVpnPermissionGranted()
setMsg(
ok
? 'VPN 权限已授予。若仍无法访问 API请确认当前安装包已包含完整原生 EasyTier 数据面。'
: '尚未授予 VPN 权限,请先在系统设置中允许本应用建立 VPN。'
)
} finally {
setEtBusy(false)
}
}, [etEmbedded])
setScanOn(true)
}, [perm?.granted, requestPerm])
if (!ready) {
return (
@@ -146,26 +178,21 @@ export default function BindScreen() {
)
}
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
return (
<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
PC `ip/域名:端口` token PC token
</Text>
{!!msg && <Text style={styles.msgBanner}>{msg}</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>
)}
<Text style={styles.mutedSmall}>token{bind.token ? '已保存' : '无'}</Text>
<View style={styles.row}>
<Pressable style={styles.btn} onPress={() => void testConn()}>
<Text style={styles.btnText}> API</Text>
@@ -182,110 +209,72 @@ export default function BindScreen() {
<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}> JSON</Text>
<Text style={styles.body}> PC baseUrlporttoken</Text>
<View style={styles.etRow}>
<Pressable style={styles.btnPrimary} onPress={() => void openScan()}>
<Ionicons name="qr-code-outline" size={20} color="#fff" />
<Text style={styles.btnPrimaryText}> </Text>
</Pressable>
<Pressable style={styles.btnSecondary} onPress={() => void onPasteJson()}>
<Text style={styles.btnSecondaryText}> JSON</Text>
</Pressable>
</View>
</View>
{scanOn && Platform.OS !== 'web' && (
<View style={styles.camWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={onBarcodeScanned}
/>
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
<Text style={styles.btnText}></Text>
</Pressable>
</View>
)}
<View style={styles.block}>
<Text style={styles.h2}></Text>
<Text style={styles.body}>
`ip/域名:端口` `192.168.21.222:8001` token token PC
</Text>
<TextInput
style={styles.input}
placeholder="ip/域名:端口"
placeholderTextColor={theme.muted}
value={hostText}
onChangeText={setHostText}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.row}>
<Pressable style={styles.btn} onPress={() => void onSaveHost()}>
<Text style={styles.btnText}></Text>
</Pressable>
<Pressable style={[styles.btn, styles.btnDanger]} onPress={() => void onResetHost()}>
<Text style={styles.btnText}>/</Text>
</Pressable>
</View>
</View>
<View style={styles.block}>
<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>
<Text style={styles.body}> PC baseUrl token</Text>
{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>
)
@@ -321,7 +310,6 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: theme.border,
},
btnDisabled: { opacity: 0.55 },
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
btnPrimary: {
@@ -331,6 +319,8 @@ const styles = StyleSheet.create({
backgroundColor: '#3d7eff',
paddingVertical: 14,
borderRadius: 10,
flex: 1,
minWidth: '45%',
},
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
btnSecondary: {
@@ -346,21 +336,29 @@ const styles = StyleSheet.create({
},
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
input: {
minHeight: 120,
minHeight: 52,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 12,
color: theme.text,
fontFamily: 'monospace',
fontSize: 12,
fontSize: 14,
marginBottom: 12,
backgroundColor: theme.bgCard,
},
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' },
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000', marginBottom: 18 },
camera: { height: 280, width: '100%' },
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 },
link: { color: theme.accent, fontSize: 14 },
msg: { color: theme.success, fontSize: 14, marginTop: 8 },
msgBanner: {
color: theme.success,
fontSize: 14,
lineHeight: 22,
marginBottom: 14,
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(61,126,255,0.12)',
borderWidth: 1,
borderColor: theme.border,
},
})

View File

@@ -49,7 +49,7 @@ function SnapshotNet({ data }: { data: unknown }) {
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
</Text>
<Text style={styles.snapLine}>
{y?.reachable ? '可达' : '不可达'} ·{' '}
{y?.reachable ? '可达' : '不可达'} ·{' '}
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
</Text>
</View>
@@ -196,7 +196,7 @@ export default function DeskScreen() {
</Text>
<Text style={styles.liveLine}> {row.anchor ?? '—'}</Text>
<Text style={styles.liveLine} numberOfLines={2}>
{row.douyinHint ?? '—'}
{row.douyinHint ?? '—'}
</Text>
<Text style={styles.liveLine} numberOfLines={2}>
{row.streamTitle ?? '—'}

View File

@@ -1,4 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
import { useNavigation } from 'expo-router'
import { LinearGradient } from 'expo-linear-gradient'
import Animated, { FadeInDown } from 'react-native-reanimated'
import {
View,
Text,
@@ -10,10 +13,12 @@ import {
Alert,
RefreshControl,
Linking,
Switch,
} from 'react-native'
import { Picker } from '@react-native-picker/picker'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useAuth } from '@/context/AuthContext'
import { useBind } from '@/context/BindContext'
import { useRecorder } from '@/hooks/useRecorder'
import { theme } from '@/constants/theme'
@@ -23,6 +28,27 @@ import {
proChannelPm2Process,
} from '@/lib/youtubeConfigUtils'
function statusTintBackground(variant: string): string {
switch (variant) {
case 'online':
return 'rgba(61, 214, 140, 0.1)'
case 'stopped':
case 'error':
return 'rgba(245, 101, 101, 0.1)'
case 'warn':
return 'rgba(246, 173, 85, 0.12)'
case 'offline':
return 'rgba(139, 146, 158, 0.11)'
case 'loading':
return 'rgba(91, 157, 255, 0.12)'
case 'unknown':
case 'empty':
return 'rgba(91, 157, 255, 0.09)'
default:
return 'rgba(139, 146, 158, 0.1)'
}
}
function ctrlStyle(action: string): object {
switch (action) {
case 'start':
@@ -99,7 +125,7 @@ function ProLiveSwitchGrid({
</Pressable>
{href ? (
<Pressable onPress={() => void Linking.openURL(href)}>
<Text style={styles.proLink}></Text>
<Text style={styles.proLink}></Text>
</Pressable>
) : null}
<View style={styles.proActions}>
@@ -177,12 +203,32 @@ function LiveSwitchGrid({
}
export default function RecordScreen() {
const navigation = useNavigation()
const { bind } = useBind()
const { session, openLogin, logout } = useAuth()
const r = useRecorder()
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<Pressable
onPress={() => void (session ? logout() : openLogin())}
style={{ paddingHorizontal: 12, paddingVertical: 6 }}
>
<Text style={{ color: theme.accent, fontWeight: '700', fontSize: 14 }}>
{session?.record?.email
? `${String(session.record.email).split('@')[0]}`
: '登录'}
</Text>
</Pressable>
),
})
}, [navigation, session, openLogin, logout])
const unbound = !bind
const [refreshing, setRefreshing] = useState(false)
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
const statusBg = useMemo(() => statusTintBackground(r.statusMeta.variant), [r.statusMeta.variant])
const onRefresh = useCallback(async () => {
if (!bind) return
@@ -197,7 +243,7 @@ export default function RecordScreen() {
<View style={styles.fatalWrap}>
<Text style={styles.err}>{r.bootError}</Text>
<Text style={styles.fatalHint}>
WiFi / EasyTier API
API
</Text>
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
<Text style={styles.retryBtnText}></Text>
@@ -227,11 +273,6 @@ export default function RecordScreen() {
</View>
) : null}
{unbound && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
{bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
@@ -240,17 +281,70 @@ export default function RecordScreen() {
</View>
)}
<View style={styles.toolbar}>
<View style={[styles.pickerWrap, unbound && styles.pickerDisabled]}>
<Animated.View entering={FadeInDown.duration(420).springify()}>
<LinearGradient
colors={['rgba(91,157,255,0.14)', 'rgba(20,22,28,0.92)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.toolbarGradient}
>
<View style={styles.toolbarCard}>
<View style={styles.toolbarRow}>
<View style={styles.toolbarLeft}>
<View
style={[
styles.statusChip,
{ borderLeftColor: statusColor, backgroundColor: statusBg },
]}
>
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
</View>
<Text style={styles.statusLineText} numberOfLines={1}>
{r.statusMeta.line}
</Text>
</View>
</View>
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
<View style={styles.proLabelRow}>
<Text style={styles.proLabelZh}></Text>
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
<Text style={styles.proBadgeText}>PRO</Text>
</View>
</View>
<View style={styles.proSwitchWrap}>
<Switch
value={r.multiChannelPro}
disabled={unbound}
onValueChange={(v) => {
if (unbound) return
void r.togglePro(v)
}}
trackColor={{
false: 'rgba(255,255,255,0.12)',
true: 'rgba(91, 157, 255, 0.45)',
}}
thumbColor={r.multiChannelPro ? '#7eb6ff' : '#5c6370'}
ios_backgroundColor="rgba(255,255,255,0.1)"
/>
</View>
</View>
</View>
</View>
</LinearGradient>
</Animated.View>
{bind ? (
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
<Picker
enabled={!unbound && !r.emptyHint}
selectedValue={unbound ? '' : r.currentProcess}
enabled={!r.emptyHint}
selectedValue={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.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} />
@@ -258,28 +352,7 @@ export default function RecordScreen() {
)}
</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>
) : null}
{bind && r.emptyHint ? (
<View style={styles.emptyCard}>
@@ -678,7 +751,94 @@ const styles = StyleSheet.create({
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
toolbar: { marginBottom: 12 },
toolbarGradient: {
marginBottom: 12,
borderRadius: 16,
padding: 1,
},
toolbarCard: {
borderRadius: 15,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.07)',
backgroundColor: theme.bgElevated,
paddingVertical: 12,
paddingHorizontal: 12,
},
toolbarRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
},
toolbarLeft: { flex: 1, minWidth: 0, marginRight: 6 },
statusChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 10,
paddingHorizontal: 12,
paddingLeft: 11,
borderRadius: 12,
borderLeftWidth: 3,
borderTopWidth: 1,
borderRightWidth: 1,
borderBottomWidth: 1,
borderTopColor: 'rgba(255,255,255,0.06)',
borderRightColor: 'rgba(255,255,255,0.06)',
borderBottomColor: 'rgba(255,255,255,0.06)',
},
statusDotOuter: {
width: 14,
height: 14,
borderRadius: 7,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
statusDotInner: { width: 6, height: 6, borderRadius: 3 },
statusLineText: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: theme.text,
letterSpacing: 0.2,
},
proCluster: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
gap: 10,
paddingLeft: 10,
paddingVertical: 4,
paddingRight: 4,
borderRadius: 12,
backgroundColor: 'rgba(91, 157, 255, 0.06)',
borderWidth: 1,
borderColor: 'rgba(91, 157, 255, 0.18)',
},
proClusterDisabled: { opacity: 0.45 },
proLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
proLabelZh: { fontSize: 13, fontWeight: '600', color: theme.text },
proBadge: {
paddingHorizontal: 7,
paddingVertical: 3,
borderRadius: 6,
backgroundColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
proBadgeOn: {
backgroundColor: 'rgba(91, 157, 255, 0.22)',
borderColor: 'rgba(91, 157, 255, 0.45)',
},
proBadgeText: {
fontSize: 10,
fontWeight: '800',
color: theme.accent,
letterSpacing: 0.6,
},
proSwitchWrap: { transform: [{ scale: 0.92 }] },
unpairedText: { fontSize: 15, fontWeight: '600', color: theme.muted },
proGrid: { gap: 10 },
proSwitchRow: {
padding: 12,
@@ -712,32 +872,8 @@ const styles = StyleSheet.create({
},
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,
},
pickerRowBelow: { marginTop: 0 },
dot: { width: 8, height: 8, borderRadius: 4 },
pillText: { fontSize: 13, fontWeight: '600' },
emptyCard: {
padding: 20,
borderRadius: 12,

View File

@@ -246,7 +246,7 @@ export default function NetworkScreen() {
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC Google / </Text>
<Text style={styles.bannerText}> PC Google / </Text>
</View>
)}
@@ -268,7 +268,7 @@ export default function NetworkScreen() {
onSolution={openSolution}
/>
<SiteCard
title="抖音(国内参考)"
title="国内站点(参考)"
site={view.douyin}
speed={view.speed.domestic}
loading={loading && !!bind}

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
import { View, Text, ScrollView, StyleSheet, RefreshControl, Pressable } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { UserAgreementModal } from '@/components/UserAgreementModal'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import type { SystemMetricsPayload } from '@/lib/pcTypes'
@@ -20,6 +21,7 @@ export default function SystemScreen() {
const [data, setData] = useState<SystemMetricsPayload | null>(null)
const [err, setErr] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [agreementOpen, setAgreementOpen] = useState(false)
const load = useCallback(async () => {
if (!bind) {
@@ -147,8 +149,12 @@ export default function SystemScreen() {
<View style={styles.cardMuted}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.muted}> Electron </Text>
<Pressable style={styles.agreementRow} onPress={() => setAgreementOpen(true)} hitSlop={8}>
<Text style={styles.agreementLink}></Text>
</Pressable>
</View>
</ScrollView>
<UserAgreementModal visible={agreementOpen} onClose={() => setAgreementOpen(false)} />
</SafeAreaView>
)
}
@@ -205,4 +211,6 @@ const styles = StyleSheet.create({
ffSec: { marginTop: 12 },
ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' },
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
agreementRow: { marginTop: 10, alignSelf: 'flex-start' },
agreementLink: { fontSize: 12, color: theme.accent, textDecorationLine: 'underline' },
})

View File

@@ -6,7 +6,9 @@ import { useEffect } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { AuthProvider } from '@/context/AuthContext';
import { BindProvider } from '@/context/BindContext';
import { PocketBaseAuthModal } from '@/components/PocketBaseAuthModal';
export {
// Catch any errors thrown by the Layout component.
@@ -20,26 +22,34 @@ export const unstable_settings = {
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
/** 避免部分真机/构建环境下字体加载挂起或失败时永远停在 Expo 默认靶心启动图 */
const SPLASH_FALLBACK_MS = 4000;
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;
if (error) {
console.warn('[expo-font]', error);
}
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
void SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
useEffect(() => {
const t = setTimeout(() => {
void SplashScreen.hideAsync();
}, SPLASH_FALLBACK_MS);
return () => clearTimeout(t);
}, []);
// 不再在字体未就绪时 return null否则 hideAsync 后会出现纯白屏且仍无界面
return <RootLayoutNav />;
}
@@ -47,12 +57,15 @@ function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<BindProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</ThemeProvider>
</BindProvider>
<AuthProvider>
<BindProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<PocketBaseAuthModal />
</ThemeProvider>
</BindProvider>
</AuthProvider>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 438 KiB

View File

@@ -0,0 +1,135 @@
import { useState } from 'react'
import {
ActivityIndicator,
KeyboardAvoidingView,
Linking,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native'
import { useAuth } from '@/context/AuthContext'
import { theme } from '@/constants/theme'
const VIP_URL = 'https://vip.hackrobot.cn/'
export function PocketBaseAuthModal() {
const { loginVisible, closeLogin, login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const onSubmit = async () => {
setErr(null)
setBusy(true)
try {
await login(email.trim(), password)
setPassword('')
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<Modal visible={loginVisible} animationType="fade" transparent onRequestClose={closeLogin}>
<KeyboardAvoidingView
style={styles.backdrop}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Pressable style={styles.scrim} onPress={closeLogin} />
<View style={styles.sheet}>
<Text style={styles.title}></Text>
<Text style={styles.sub}>
使退
</Text>
{err ? <Text style={styles.err}>{err}</Text> : null}
<Text style={styles.lbl}></Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
placeholderTextColor={theme.muted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
<Text style={styles.lbl}></Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="••••••••"
placeholderTextColor={theme.muted}
secureTextEntry
/>
<Pressable
style={[styles.btn, busy && styles.btnDisabled]}
disabled={busy}
onPress={() => void onSubmit()}
>
{busy ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnText}></Text>
)}
</Pressable>
<View style={styles.row}>
<Pressable onPress={() => void Linking.openURL(VIP_URL)}>
<Text style={styles.link}></Text>
</Pressable>
<Text style={styles.sep}>·</Text>
<Pressable onPress={closeLogin}>
<Text style={styles.linkMuted}></Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
)
}
const styles = StyleSheet.create({
backdrop: { flex: 1, justifyContent: 'center', padding: 20 },
scrim: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.55)' },
sheet: {
borderRadius: 16,
padding: 20,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: theme.border,
},
title: { fontSize: 18, fontWeight: '700', color: theme.text, marginBottom: 8 },
sub: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 14 },
err: { color: theme.danger, fontSize: 13, marginBottom: 10 },
lbl: { fontSize: 12, fontWeight: '600', color: theme.muted, marginBottom: 6 },
input: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 12,
color: theme.text,
marginBottom: 12,
backgroundColor: theme.bg,
},
btn: {
backgroundColor: theme.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 4,
},
btnDisabled: { opacity: 0.6 },
btnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
row: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 16, gap: 8 },
link: { color: theme.accent, fontWeight: '600', fontSize: 14 },
linkMuted: { color: theme.muted, fontSize: 14 },
sep: { color: theme.muted },
})

View File

@@ -0,0 +1,136 @@
import type { ReactNode } from 'react'
import { Modal, View, Text, ScrollView, Pressable, StyleSheet } from 'react-native'
import { USER_AGREEMENT_MARKDOWN } from '@/lib/userAgreement'
import { theme } from '@/constants/theme'
function renderAgreementBody(md: string) {
const lines = md.split('\n')
const out: ReactNode[] = []
lines.forEach((line, i) => {
if (line.startsWith('# ')) {
out.push(
<Text key={i} style={styles.h1}>
{line.slice(2)}
</Text>
)
return
}
if (line.startsWith('## ')) {
out.push(
<Text key={i} style={styles.h2}>
{line.slice(3)}
</Text>
)
return
}
if (line.startsWith('* ')) {
out.push(
<Text key={i} style={styles.li}>
{line.slice(2)}
</Text>
)
return
}
if (line.trim() === '') {
out.push(<View key={i} style={styles.gap} />)
return
}
out.push(
<Text key={i} style={styles.p}>
{line}
</Text>
)
})
return out
}
type Props = {
visible: boolean
onClose: () => void
}
const AGREEMENT_BODY = USER_AGREEMENT_MARKDOWN.replace(/^# 用户使用协议\n+/, '')
export function UserAgreementModal({ visible, onClose }: Props) {
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.sheet}>
<Text style={styles.sheetTitle}>使</Text>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollInner}>
{renderAgreementBody(AGREEMENT_BODY)}
</ScrollView>
<Pressable style={styles.closeBtn} onPress={onClose}>
<Text style={styles.closeBtnText}></Text>
</Pressable>
</View>
</View>
</Modal>
)
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.55)',
justifyContent: 'center',
padding: 16,
},
sheet: {
maxHeight: '88%',
borderRadius: 14,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.22)',
paddingTop: 14,
paddingHorizontal: 14,
paddingBottom: 12,
},
sheetTitle: {
fontSize: 17,
fontWeight: '700',
color: theme.text,
marginBottom: 10,
textAlign: 'center',
},
scroll: { flexGrow: 0 },
scrollInner: { paddingBottom: 8 },
h1: {
fontSize: 17,
fontWeight: '700',
color: theme.text,
marginBottom: 8,
},
h2: {
fontSize: 15,
fontWeight: '600',
color: theme.text,
marginTop: 10,
marginBottom: 6,
},
p: {
fontSize: 13,
color: theme.muted,
lineHeight: 21,
marginBottom: 4,
},
li: {
fontSize: 13,
color: theme.muted,
lineHeight: 21,
marginBottom: 4,
paddingLeft: 4,
},
gap: { height: 6 },
closeBtn: {
marginTop: 10,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: theme.bgElevated,
alignItems: 'center',
borderWidth: 1,
borderColor: theme.border,
},
closeBtnText: { fontSize: 15, fontWeight: '600', color: theme.accent },
})

166
context/AuthContext.tsx Normal file
View File

@@ -0,0 +1,166 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Alert } from 'react-native'
import { clearPbSession, loadPbSession, savePbSession, type StoredPbSession } from '@/lib/authStorage'
import {
isLiveAllowed,
pbAuthRefresh,
pbAuthWithPassword,
type PbUserRecord,
} from '@/lib/pocketbase'
type Ctx = {
session: StoredPbSession | null
ready: boolean
loginVisible: boolean
openLogin: () => void
closeLogin: () => void
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
refreshSession: () => Promise<boolean>
/** 用于远程 API 请求头 */
pbToken: string | null
/** 开始/重启直播前调用;未登录则弹出登录;无权限则 false */
ensureCanControlLive: () => Promise<boolean>
}
const AuthContext = createContext<Ctx | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<StoredPbSession | null>(null)
const [ready, setReady] = useState(false)
const [loginVisible, setLoginVisible] = useState(false)
const pendingGate = useRef<((ok: boolean) => void) | null>(null)
useEffect(() => {
void (async () => {
const s = await loadPbSession()
if (s) {
try {
const r = await pbAuthRefresh(s.token)
const next: StoredPbSession = { token: r.token, record: r.record }
setSession(next)
await savePbSession(next)
} catch {
await clearPbSession()
setSession(null)
}
}
setReady(true)
})()
}, [])
const openLogin = useCallback(() => setLoginVisible(true), [])
const closeLogin = useCallback(() => {
setLoginVisible(false)
const p = pendingGate.current
pendingGate.current = null
p?.(false)
}, [])
const login = useCallback(async (email: string, password: string) => {
const r = await pbAuthWithPassword(email, password)
if (!isLiveAllowed(r.record)) {
throw new Error('该账号暂无直播权限,请联系管理员')
}
const next: StoredPbSession = { token: r.token, record: r.record }
setSession(next)
await savePbSession(next)
setLoginVisible(false)
const p = pendingGate.current
pendingGate.current = null
p?.(true)
}, [])
const logout = useCallback(async () => {
await clearPbSession()
setSession(null)
}, [])
const refreshSession = useCallback(async () => {
if (!session?.token) return false
try {
const r = await pbAuthRefresh(session.token)
const next: StoredPbSession = { token: r.token, record: r.record }
setSession(next)
await savePbSession(next)
return isLiveAllowed(r.record)
} catch {
await clearPbSession()
setSession(null)
return false
}
}, [session?.token])
const ensureCanControlLive = useCallback(async (): Promise<boolean> => {
if (session?.token) {
try {
const r = await pbAuthRefresh(session.token)
const next: StoredPbSession = { token: r.token, record: r.record }
setSession(next)
await savePbSession(next)
if (!isLiveAllowed(r.record)) {
Alert.alert('无权限', '账号已被管理员停用远程直播权限。')
return false
}
return true
} catch {
await clearPbSession()
setSession(null)
}
}
return new Promise<boolean>((resolve) => {
pendingGate.current = (ok) => resolve(ok)
setLoginVisible(true)
})
}, [session?.token])
const pbToken = session?.token ?? null
const value = useMemo(
() => ({
session,
ready,
loginVisible,
openLogin,
closeLogin,
login,
logout,
refreshSession,
pbToken,
ensureCanControlLive,
}),
[
session,
ready,
loginVisible,
openLogin,
closeLogin,
login,
logout,
refreshSession,
pbToken,
ensureCanControlLive,
],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): Ctx {
const c = useContext(AuthContext)
if (!c) throw new Error('useAuth outside AuthProvider')
return c
}
export function useAuthOptional(): Ctx | null {
return useContext(AuthContext)
}

View File

@@ -1,6 +1,4 @@
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'
@@ -25,24 +23,10 @@ export function BindProvider({ children }: { children: React.ReactNode }) {
})()
}, [])
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 () => {

125
electron/agreement.html Normal file
View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用户使用协议</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
font-size: 13px;
line-height: 1.55;
color: #e8e6f2;
background: #14121a;
display: flex;
flex-direction: column;
height: 100vh;
}
h1 {
font-size: 17px;
font-weight: 700;
margin: 0 0 12px;
color: #f2f0fa;
}
h2 {
font-size: 14px;
font-weight: 600;
margin: 14px 0 8px;
color: #e8e6f2;
}
p {
margin: 0 0 8px;
color: #a39bb8;
}
ul {
margin: 0 0 10px;
padding-left: 18px;
color: #a39bb8;
}
li { margin-bottom: 4px; }
.scroll {
flex: 1;
overflow: auto;
padding: 16px 18px 12px;
}
.footer {
flex-shrink: 0;
display: flex;
gap: 10px;
padding: 12px 16px 16px;
border-top: 1px solid rgba(124, 92, 255, 0.25);
background: #1a1722;
}
button {
flex: 1;
padding: 11px 14px;
border-radius: 8px;
border: 1px solid rgba(124, 92, 255, 0.35);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.primary {
background: linear-gradient(135deg, #5c4dff, #3d7eff);
color: #fff;
border: none;
}
.ghost {
background: transparent;
color: #c4bdd9;
}
</style>
</head>
<body>
<div class="scroll">
<h1>用户使用协议</h1>
<p>欢迎使用本软件(以下简称「本软件」)。在使用本软件前,请您仔细阅读并理解本协议。</p>
<h2>1. 软件性质说明</h2>
<p>本软件为通用的视频处理与推流工具,支持用户对自有或合法授权的视频源进行处理与分发。本软件不提供任何第三方平台内容的解析、抓取或分发服务。</p>
<h2>2. 用户责任</h2>
<p>用户在使用本软件过程中,应确保:</p>
<ul>
<li>所使用的视频源具有合法使用权或授权;</li>
<li>不侵犯任何第三方的著作权、邻接权或其他合法权益;</li>
<li>不违反相关法律法规及平台规则。</li>
</ul>
<p>由用户上传、处理、推流的所有内容,均由用户自行承担全部法律责任。</p>
<h2>3. 禁止行为</h2>
<p>用户不得利用本软件从事以下行为:</p>
<ul>
<li>未经授权抓取、转播、传播第三方平台内容;</li>
<li>侵犯他人版权、肖像权、隐私权;</li>
<li>从事违法违规内容传播。</li>
</ul>
<h2>4. 责任限制</h2>
<p>本软件仅提供技术工具服务,不参与用户内容的获取、编辑或分发过程。</p>
<p>因用户行为导致的任何法律责任或纠纷,均由用户自行承担,与本软件无关。</p>
<h2>5. 权限与服务</h2>
<p>部分功能属于付费服务(如 Pro/VIP用户购买后仅获得使用权限不涉及软件所有权转移。</p>
<h2>6. 协议变更</h2>
<p>本软件有权根据业务发展对本协议进行调整,并通过软件或网站进行公告。</p>
<p>如您不同意本协议,请立即停止使用本软件。</p>
</div>
<div class="footer">
<button type="button" class="ghost" id="refuse">不同意并退出</button>
<button type="button" class="primary" id="accept">同意并继续</button>
</div>
<script>
const api = window.agreementApi
document.getElementById('accept').addEventListener('click', () => {
if (api) api.accept()
})
document.getElementById('refuse').addEventListener('click', () => {
if (api) api.refuse()
})
</script>
</body>
</html>

91
electron/main.js Normal file
View File

@@ -0,0 +1,91 @@
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')
const AGREEMENT_FILE = 'user-agreement-accepted.json'
function agreementPath() {
return path.join(app.getPath('userData'), AGREEMENT_FILE)
}
function hasAcceptedAgreement() {
try {
return fs.existsSync(agreementPath())
} catch {
return false
}
}
function markAgreementAccepted() {
fs.writeFileSync(
agreementPath(),
JSON.stringify({ v: 1, acceptedAt: Date.now() }, null, 2),
'utf8'
)
}
/** 占位主窗口;合并到实际桌面端时请替换为真实 loadURL / loadFile */
function createMainWindow() {
const win = new BrowserWindow({
width: 960,
height: 640,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
})
win.loadURL('about:blank')
return win
}
function showAgreementGate(onAccepted) {
const win = new BrowserWindow({
width: 520,
height: 680,
show: false,
resizable: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
win.loadFile(path.join(__dirname, 'agreement.html'))
win.once('ready-to-show', () => win.show())
ipcMain.once('agreement-accept', () => {
markAgreementAccepted()
ipcMain.removeAllListeners('agreement-refuse')
win.close()
onAccepted()
})
ipcMain.once('agreement-refuse', () => {
ipcMain.removeAllListeners('agreement-accept')
app.quit()
})
win.on('closed', () => {
if (!hasAcceptedAgreement()) {
app.quit()
}
})
}
app.whenReady().then(() => {
if (!hasAcceptedAgreement()) {
showAgreementGate(() => createMainWindow())
} else {
createMainWindow()
}
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

913
electron/package-lock.json generated Normal file
View File

@@ -0,0 +1,913 @@
{
"name": "d2y-electron-shell",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "d2y-electron-shell",
"version": "1.0.0",
"dependencies": {
"mobile": "file:.."
},
"devDependencies": {
"electron": "^33.4.1"
}
},
"..": {
"name": "mobile",
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-picker/picker": "^2.11.1",
"@react-navigation/native": "^7.1.33",
"expo": "~55.0.8",
"expo-camera": "~17.0.9",
"expo-clipboard": "~8.0.7",
"expo-constants": "~55.0.9",
"expo-font": "~55.0.4",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~55.0.8",
"expo-modules-core": "~55.0.17",
"expo-router": "~55.0.7",
"expo-splash-screen": "~55.0.12",
"expo-status-bar": "~55.0.4",
"expo-symbols": "~55.0.5",
"expo-web-browser": "~55.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.7.2"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~5.9.2"
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"progress": "^2.0.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"global-agent": "^3.0.0"
}
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
"dev": true,
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "*",
"@types/keyv": "^3.1.4",
"@types/node": "*",
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.6.0"
}
},
"node_modules/cacheable-request": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
"dev": true,
"license": "MIT",
"dependencies": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
"http-cache-semantics": "^4.0.0",
"keyv": "^4.0.0",
"lowercase-keys": "^2.0.0",
"normalize-url": "^6.0.1",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decompress-response/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/electron": {
"version": "33.4.11",
"resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz",
"integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^20.9.0",
"extract-zip": "^2.0.1"
},
"bin": {
"electron": "cli.js"
},
"engines": {
"node": ">= 12.20.55"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/global-agent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"define-properties": "^1.2.1",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/got": {
"version": "11.8.6",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
"p-cancelable": "^2.0.0",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=10.19.0"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.0.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC",
"optional": true
},
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mobile": {
"resolved": "..",
"link": true
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"dev": true,
"license": "MIT"
},
"node_modules/responselike": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lowercase-keys": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"type-fest": "^0.13.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
}
}
}

15
electron/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "d2y-electron-shell",
"private": true,
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^33.4.1"
},
"dependencies": {
"mobile": "file:.."
}
}

6
electron/preload.js Normal file
View File

@@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('agreementApi', {
accept: () => ipcRenderer.send('agreement-accept'),
refuse: () => ipcRenderer.send('agreement-refuse'),
})

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert } from 'react-native'
import { useAuth } from '@/context/AuthContext'
import { useBind } from '@/context/BindContext'
import { apiFetch, apiJson } from '@/lib/api'
import { reportLiveControlEvent } from '@/lib/liveTelemetry'
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
@@ -72,6 +74,9 @@ function pickProcessForEntries(filtered: Entry[], multi: boolean): string {
export function useRecorder() {
const { bind } = useBind()
const { pbToken, ensureCanControlLive, session } = useAuth()
const pbTokRef = useRef(pbToken)
pbTokRef.current = pbToken
const [entries, setEntries] = useState<Entry[]>([])
const [currentProcess, setCurrentProcess] = useState('')
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
@@ -135,7 +140,7 @@ export function useRecorder() {
useEffect(() => {
if (!bindKey) return
let cancelled = false
void apiFetch(bindRef.current!, '/host/gpu_status')
void apiFetch(bindRef.current!, '/host/gpu_status', { pbToken: pbTokRef.current })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((j: GpuCaps) => {
if (!cancelled)
@@ -166,7 +171,7 @@ export function useRecorder() {
const loadProcessMonitor = useCallback(async () => {
const b = bindRef.current
if (!b) throw new Error('尚未绑定 PC')
const res = await apiFetch(b, '/process_monitor')
const res = await apiFetch(b, '/process_monitor', { pbToken: pbTokRef.current })
if (!res.ok) throw new Error(String(res.status))
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
const raw: Entry[] = (data.entries || []).map((e) => ({
@@ -186,6 +191,7 @@ export function useRecorder() {
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
b,
'/relay_pro/channels',
{ pbToken: pbTokRef.current },
)
if (!d.ok) {
setProChannelsError(d.error || '无法加载 Pro 频道')
@@ -220,7 +226,7 @@ export function useRecorder() {
}
setYoutubeStatus('正在将当前配置同步到多频道…')
try {
const res = await apiFetch(b, '/relay_pro/channels')
const res = await apiFetch(b, '/relay_pro/channels', { pbToken: pbTokRef.current })
const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string }
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
const firstId = d.channels[0].id
@@ -231,6 +237,7 @@ export function useRecorder() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
pbToken: pbTokRef.current,
})
const kj = (await keyRes.json()) as { error?: string }
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
@@ -241,6 +248,7 @@ export function useRecorder() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: urlText }),
pbToken: pbTokRef.current,
},
)
const uj = (await urlRes.json()) as { error?: string }
@@ -258,6 +266,7 @@ export function useRecorder() {
options: ytIniModel.options,
header: ytIniModel.header || '',
}),
pbToken: pbTokRef.current,
},
)
} catch {
@@ -282,6 +291,7 @@ export function useRecorder() {
const res = await apiFetch(
bind,
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
{ pbToken: pbTokRef.current },
)
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
const data = (await res.json()) as { content?: string }
@@ -310,6 +320,7 @@ export function useRecorder() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
pbToken: pbTokRef.current,
},
)
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
@@ -330,6 +341,7 @@ export function useRecorder() {
const res = await apiFetch(
bind,
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
{ pbToken: pbTokRef.current },
)
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
@@ -375,6 +387,7 @@ export function useRecorder() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(modelToSave),
pbToken: pbTokRef.current,
},
)
const data = (await res.json()) as { error?: string; message?: string }
@@ -404,6 +417,7 @@ export function useRecorder() {
keys: keysOne,
activeIndex: 0,
}),
pbToken: pbTokRef.current,
})
const data = (await res.json()) as { error?: string; message?: string }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
@@ -450,6 +464,7 @@ export function useRecorder() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: ed.urlText }),
pbToken: pbTokRef.current,
},
)
const data = (await res.json()) as { error?: string; message?: string }
@@ -469,6 +484,7 @@ export function useRecorder() {
const ur = await apiFetch(
b,
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
{ pbToken: pbTokRef.current },
)
if (!ur.ok) {
setUrlStatus('读取失败')
@@ -518,9 +534,17 @@ export function useRecorder() {
}
const proc = procForPm2(processOverride)
const isStatus = action === 'status'
if (!isStatus) setLoadingAction(action)
if (!isStatus) {
if (action === 'start' || action === 'restart') {
const allowed = await ensureCanControlLive()
if (!allowed) return
}
setLoadingAction(action)
}
try {
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`)
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`, {
pbToken: pbTokRef.current,
})
if (!res.ok) throw new Error('无法连接到本机服务')
const data = (await res.json()) as Record<string, unknown>
@@ -551,6 +575,15 @@ export function useRecorder() {
const actionLabel =
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
if (action === 'start' || action === 'restart' || action === 'stop') {
void reportLiveControlEvent(
bindRef.current,
pbTokRef.current,
session?.record?.id,
proc,
action,
)
}
const statusProc = processOverride ?? proc
setTimeout(() => void runPm2Action('status', statusProc), 1500)
}
@@ -561,18 +594,22 @@ export function useRecorder() {
if (!isStatus) setLoadingAction(null)
}
},
[bindRef, procForPm2, getEntry],
[bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id],
)
const runProChannelStart = useCallback(
async (channelId: string) => {
if (!channelId) return
const allowed = await ensureCanControlLive()
if (!allowed) return
const pm = proChannelPm2Process(channelId)
setLoadingAction('start')
try {
const saved = await saveProChannelKeysFor(channelId)
if (!saved) return
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`)
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`, {
pbToken: pbTokRef.current,
})
if (!res.ok) throw new Error('无法连接到本机服务')
const data = (await res.json()) as Record<string, unknown>
const e = getEntry(pm)
@@ -584,6 +621,7 @@ export function useRecorder() {
.filter((x): x is string => typeof x === 'string')
.join('\n') || '已完成'
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start')
setTimeout(() => void runPm2Action('status', pm), 1500)
} catch (err) {
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
@@ -591,18 +629,22 @@ export function useRecorder() {
setLoadingAction(null)
}
},
[getEntry, runPm2Action, saveProChannelKeysFor],
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
)
const runProChannelRestart = useCallback(
async (channelId: string) => {
if (!channelId) return
const allowed = await ensureCanControlLive()
if (!allowed) return
const pm = proChannelPm2Process(channelId)
setLoadingAction('restart')
try {
const saved = await saveProChannelKeysFor(channelId)
if (!saved) return
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`)
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`, {
pbToken: pbTokRef.current,
})
if (!res.ok) throw new Error('无法连接到本机服务')
const data = (await res.json()) as Record<string, unknown>
const e = getEntry(pm)
@@ -614,6 +656,7 @@ export function useRecorder() {
.filter((x): x is string => typeof x === 'string')
.join('\n') || '已完成'
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart')
setTimeout(() => void runPm2Action('status', pm), 1500)
} catch (err) {
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
@@ -621,7 +664,7 @@ export function useRecorder() {
setLoadingAction(null)
}
},
[getEntry, runPm2Action, saveProChannelKeysFor],
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
)
const submitNewChannelDraft = useCallback(async () => {
@@ -637,6 +680,7 @@ export function useRecorder() {
name: `频道 ${proChannelsList.length + 1}`,
youtubeKey: key,
}),
pbToken: pbTokRef.current,
})
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
@@ -675,6 +719,7 @@ export function useRecorder() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId }),
pbToken: pbTokRef.current,
})
const d = (await res.json()) as { error?: string; message?: string }
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
@@ -848,6 +893,7 @@ export function useRecorder() {
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
bind,
'/relay_pro/channels',
{ pbToken: pbTokRef.current },
)
if (cancelled) return
if (!d.ok) {
@@ -878,7 +924,7 @@ export function useRecorder() {
let cancelled = false
const poll = async () => {
try {
const r = await apiFetch(bind, '/relay_pro/live_status')
const r = await apiFetch(bind, '/relay_pro/live_status', { pbToken: pbTokRef.current })
if (!r.ok || cancelled) return
const j = (await r.json()) as ProRelayStatusPayload
if (!cancelled) setProRelayStatus(j)
@@ -910,6 +956,7 @@ export function useRecorder() {
const dr = await apiFetch(
b,
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
{ pbToken: pbTokRef.current },
)
if (!dr.ok) continue
const cd = (await dr.json()) as {
@@ -951,6 +998,7 @@ export function useRecorder() {
const ur = await apiFetch(
bind,
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
{ pbToken: pbTokRef.current },
)
if (!ur.ok || cancelled) return
const j = (await ur.json()) as { content?: string }

View File

@@ -6,22 +6,28 @@ function joinUrl(base: string, path: string): string {
return `${b}${p}`
}
export type ApiFetchInit = RequestInit & { pbToken?: string | null }
export async function apiFetch(
bind: BindPayload | null,
path: string,
init?: RequestInit,
init?: ApiFetchInit,
): Promise<Response> {
if (!bind) {
return Promise.reject(new Error('尚未绑定 PC请先在「配对」扫码'))
}
const headers = new Headers(init?.headers)
const { pbToken, ...rest } = init ?? {}
const headers = new Headers(rest.headers)
if (bind.token?.trim()) {
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
}
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
if (pbToken?.trim()) {
headers.set('X-PB-Token', pbToken.trim())
}
return fetch(joinUrl(bind.baseUrl, path), { ...rest, headers })
}
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: ApiFetchInit): Promise<T> {
const res = await apiFetch(bind, path, init)
if (!res.ok) {
const t = await res.text()

29
lib/authStorage.ts Normal file
View File

@@ -0,0 +1,29 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { PbUserRecord } from './pocketbase'
const KEY = 'pb_auth_session_v1'
export type StoredPbSession = {
token: string
record: PbUserRecord
}
export async function loadPbSession(): Promise<StoredPbSession | null> {
try {
const raw = await AsyncStorage.getItem(KEY)
if (!raw) return null
const j = JSON.parse(raw) as StoredPbSession
if (!j?.token || !j?.record?.id) return null
return j
} catch {
return null
}
}
export async function savePbSession(s: StoredPbSession): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(s))
}
export async function clearPbSession(): Promise<void> {
await AsyncStorage.removeItem(KEY)
}

View File

@@ -1,36 +0,0 @@
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
}

21
lib/liveTelemetry.ts Normal file
View File

@@ -0,0 +1,21 @@
import { apiFetch } from '@/lib/api'
import type { BindPayload } from '@/lib/types'
import { pbCreateLiveSession } from '@/lib/pocketbase'
export async function reportLiveControlEvent(
bind: BindPayload | null,
pbToken: string | null,
userId: string | undefined,
process: string,
event: 'start' | 'restart' | 'stop',
): Promise<void> {
if (!bind || !pbToken?.trim() || !userId) return
let machine: Record<string, unknown> = {}
try {
const r = await apiFetch(bind, '/host/machine_summary', { pbToken: pbToken.trim() })
if (r.ok) machine = (await r.json()) as Record<string, unknown>
} catch {
/* 忽略 */
}
await pbCreateLiveSession(pbToken.trim(), userId, { process, event, machine })
}

99
lib/pocketbase.ts Normal file
View File

@@ -0,0 +1,99 @@
/** PocketBase REST与 payjsapi 同源实例 https://pocketbase.hackrobot.cn */
export const POCKETBASE_URL = 'https://pocketbase.hackrobot.cn'
export type PbUserRecord = {
id: string
email?: string
name?: string
live_allowed?: boolean
[k: string]: unknown
}
export type PbAuthResponse = {
token: string
record: PbUserRecord
}
function joinPb(path: string): string {
const b = POCKETBASE_URL.replace(/\/$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return `${b}${p}`
}
export async function pbAuthWithPassword(email: string, password: string): Promise<PbAuthResponse> {
const res = await fetch(joinPb('/api/collections/users/auth-with-password'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: email.trim(), password }),
})
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const msg =
typeof data.message === 'string'
? data.message
: typeof data.data === 'object' && data.data && typeof (data.data as { message?: string }).message === 'string'
? (data.data as { message: string }).message
: `登录失败 (${res.status})`
throw new Error(msg)
}
const token = String(data.token ?? '')
const record = data.record as PbUserRecord | undefined
if (!token || !record?.id) throw new Error('登录响应异常')
return { token, record }
}
export async function pbAuthRefresh(token: string): Promise<PbAuthResponse> {
const res = await fetch(joinPb('/api/collections/users/auth-refresh'), {
method: 'POST',
headers: { Authorization: `Bearer ${token.trim()}` },
})
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
throw new Error(typeof data.message === 'string' ? data.message : `刷新失败 (${res.status})`)
}
const newToken = String(data.token ?? '')
const record = data.record as PbUserRecord | undefined
if (!newToken || !record?.id) throw new Error('刷新响应异常')
return { token: newToken, record }
}
export function isLiveAllowed(record: PbUserRecord | null | undefined): boolean {
if (!record) return false
return record.live_allowed !== false
}
export type LiveSessionPayload = {
process: string
event: 'start' | 'restart' | 'stop'
machine: Record<string, unknown>
}
/** 需在 PocketBase 新建集合 `live_sessions`,见项目内说明 */
export async function pbCreateLiveSession(
token: string,
userId: string,
payload: LiveSessionPayload,
): Promise<void> {
const res = await fetch(joinPb('/api/collections/live_sessions/records'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token.trim()}`,
},
body: JSON.stringify({
user: userId,
process: payload.process,
event: payload.event,
machine: payload.machine,
}),
})
if (!res.ok) {
const t = await res.text()
if (res.status === 404) {
console.warn('[live_sessions] 集合未创建或规则未放行:', t)
return
}
console.warn('[live_sessions] 写入失败:', res.status, t)
}
}

View File

@@ -1,52 +1,21 @@
export type EasyTierConfig = {
networkName: string
networkSecret: string
peerUrls: string[]
}
export const DEFAULT_EASYTIER_PEER_URLS = ['tcp://public.easytier.top:11010']
function normalizePeerUrls(value: unknown): string[] {
const seen = new Set<string>()
const next = Array.isArray(value) ? value.map(String) : []
const normalized = next
.map((item) => item.trim())
.filter((item) => {
if (!item || seen.has(item)) return false
seen.add(item)
return true
})
return normalized.length > 0 ? normalized : [...DEFAULT_EASYTIER_PEER_URLS]
}
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
export type BindPayload = {
v: 1
baseUrl: string
port: number
token: string
easytier: EasyTierConfig
token?: string
}
export function parseBindPayload(raw: string): BindPayload | null {
try {
const j = JSON.parse(raw) as Record<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
if (typeof j.baseUrl !== 'string') return null
return {
v: 1,
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
token: String(j.token),
easytier: {
networkName,
networkSecret,
peerUrls: normalizePeerUrls(et.peerUrls),
},
token: j.token == null ? '' : String(j.token),
}
} catch {
return null

42
lib/userAgreement.ts Normal file
View File

@@ -0,0 +1,42 @@
/** 用户使用协议(与 electron/agreement.html 内容保持一致) */
export const USER_AGREEMENT_MARKDOWN = `# 用户使用协议
欢迎使用本软件(以下简称「本软件」)。在使用本软件前,请您仔细阅读并理解本协议。
## 1. 软件性质说明
本软件为通用的视频处理与推流工具,支持用户对自有或合法授权的视频源进行处理与分发。本软件不提供任何第三方平台内容的解析、抓取或分发服务。
## 2. 用户责任
用户在使用本软件过程中,应确保:
* 所使用的视频源具有合法使用权或授权;
* 不侵犯任何第三方的著作权、邻接权或其他合法权益;
* 不违反相关法律法规及平台规则。
由用户上传、处理、推流的所有内容,均由用户自行承担全部法律责任。
## 3. 禁止行为
用户不得利用本软件从事以下行为:
* 未经授权抓取、转播、传播第三方平台内容;
* 侵犯他人版权、肖像权、隐私权;
* 从事违法违规内容传播。
## 4. 责任限制
本软件仅提供技术工具服务,不参与用户内容的获取、编辑或分发过程。
因用户行为导致的任何法律责任或纠纷,均由用户自行承担,与本软件无关。
## 5. 权限与服务
部分功能属于付费服务(如 Pro/VIP用户购买后仅获得使用权限不涉及软件所有权转移。
## 6. 协议变更
本软件有权根据业务发展对本协议进行调整,并通过软件或网站进行公告。
如您不同意本协议,请立即停止使用本软件。
`

View File

@@ -1,19 +0,0 @@
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

@@ -1,10 +0,0 @@
/**
* 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

@@ -1,9 +0,0 @@
<?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

@@ -1,18 +0,0 @@
{
"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

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

View File

@@ -1,2 +0,0 @@
<?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:\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:\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:\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:\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:\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:\mobile\modules\expo-easytier-vpn\android\build\generated\res\resValues\debug"/></dataSet><mergedItems/></merger>

View File

@@ -1,2 +0,0 @@
<?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:\Users\admin\Desktop\d2yapp\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:\Users\admin\Desktop\d2yapp\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:\Users\admin\Desktop\d2yapp\modules\expo-easytier-vpn\android\build\intermediates\shader_assets\debug\compileDebugShaders\out"/></dataSet></merger>

View File

@@ -1,2 +0,0 @@
<?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:\Users\admin\Desktop\d2yapp\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:\Users\admin\Desktop\d2yapp\modules\expo-easytier-vpn\android\src\debug\jniLibs"/></dataSet></merger>

View File

@@ -1,2 +0,0 @@
<?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:\Users\admin\Desktop\d2yapp\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:\Users\admin\Desktop\d2yapp\modules\expo-easytier-vpn\android\src\debug\shaders"/></dataSet></merger>

View File

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

View File

@@ -1,11 +0,0 @@
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:\mobile\modules\expo-easytier-vpn\android\src\main\AndroidManifest.xml:2:3-65
7-->C:\mobile\modules\expo-easytier-vpn\android\src\main\AndroidManifest.xml:2:20-62
8
9</manifest>

View File

@@ -1,9 +0,0 @@
<?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