Compare commits
7 Commits
main
...
9f8b02ea4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f8b02ea4f | ||
|
|
3bc1599763 | ||
|
|
aba40c8cb7 | ||
|
|
f16a8a9255 | ||
|
|
fe051d390d | ||
|
|
e764abb32a | ||
|
|
e884d5691e |
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "automatic",
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic"
|
||||||
|
}
|
||||||
6
app.json
@@ -10,7 +10,7 @@
|
|||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#2D2AF6"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"package": "com.douyin.recorder.remote",
|
"package": "com.douyin.recorder.remote",
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#2D2AF6",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
[
|
[
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
"cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。"
|
"cameraPermission": "用于扫描 PC 端绑定二维码(baseUrl/token)。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Platform } from 'react-native'
|
||||||
import { Tabs } from 'expo-router'
|
import { Tabs } from 'expo-router'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
|
||||||
@@ -8,17 +9,28 @@ import { useClientOnlyValue } from '@/components/useClientOnlyValue'
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
|
const dark = colorScheme === 'dark'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
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: {
|
tabBarStyle: {
|
||||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
backgroundColor: dark ? '#161a22' : '#fff',
|
||||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
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: {
|
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,
|
headerTintColor: Colors[colorScheme].text,
|
||||||
headerShown: useClientOnlyValue(false, true),
|
headerShown: useClientOnlyValue(false, true),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -6,82 +6,117 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Linking,
|
|
||||||
Alert,
|
Alert,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
|
||||||
|
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types'
|
import { parseBindPayload, type BindPayload } from '@/lib/types'
|
||||||
import {
|
|
||||||
checkVpnPermissionGranted,
|
|
||||||
isExpoEasyTierVpnAvailable,
|
|
||||||
saveAndPrepareTunnel,
|
|
||||||
} from '@/lib/easyTierVpn'
|
|
||||||
import { theme } from '@/constants/theme'
|
import { theme } from '@/constants/theme'
|
||||||
|
|
||||||
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
|
function normalizeHostInput(hostInput: string): { baseUrl: string; port: number } | null {
|
||||||
const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0]
|
const s = hostInput.trim()
|
||||||
|
if (!s) return null
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(s)) {
|
||||||
|
try {
|
||||||
|
const u = new URL(s)
|
||||||
|
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80))
|
||||||
|
if (!Number.isFinite(port)) return null
|
||||||
|
return { baseUrl: u.origin, port }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noPath = s.split('/')[0]
|
||||||
|
const i = noPath.lastIndexOf(':')
|
||||||
|
if (i <= 0) return null
|
||||||
|
const host = noPath.slice(0, i).trim()
|
||||||
|
const port = Number(noPath.slice(i + 1))
|
||||||
|
if (!host || !Number.isFinite(port) || port <= 0) return null
|
||||||
|
return { baseUrl: `http://${host}:${port}`, port }
|
||||||
|
}
|
||||||
|
|
||||||
export default function BindScreen() {
|
export default function BindScreen() {
|
||||||
const { bind, setBind, clear, ready } = useBind()
|
const { bind, setBind, clear, ready } = useBind()
|
||||||
const [paste, setPaste] = useState('')
|
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
|
const [hostText, setHostText] = useState('')
|
||||||
const [scanOn, setScanOn] = useState(false)
|
const [scanOn, setScanOn] = useState(false)
|
||||||
const [perm, requestPerm] = useCameraPermissions()
|
const [perm, requestPerm] = useCameraPermissions()
|
||||||
const scannedRef = useRef(false)
|
const scannedRef = useRef(false)
|
||||||
const [etBusy, setEtBusy] = useState(false)
|
|
||||||
|
|
||||||
const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable()
|
useEffect(() => {
|
||||||
|
if (!scanOn) scannedRef.current = false
|
||||||
|
}, [scanOn])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bind?.baseUrl) {
|
||||||
|
setHostText('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const v = bind.baseUrl.replace(/^https?:\/\//i, '')
|
||||||
|
setHostText(v)
|
||||||
|
}, [bind?.baseUrl])
|
||||||
|
|
||||||
const applyPayload = useCallback(
|
const applyPayload = useCallback(
|
||||||
async (p: BindPayload | null) => {
|
async (p: BindPayload) => {
|
||||||
if (!p) {
|
|
||||||
setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await setBind(p)
|
await setBind(p)
|
||||||
setMsg(
|
setMsg('已导入绑定(含 token)。可在下方修改 IP/域名与端口后再点「保存」。')
|
||||||
etEmbedded
|
|
||||||
? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。'
|
|
||||||
: '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。'
|
|
||||||
)
|
|
||||||
setScanOn(false)
|
|
||||||
scannedRef.current = false
|
|
||||||
},
|
},
|
||||||
[etEmbedded, setBind],
|
[setBind],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onBarCode = useCallback(
|
const onBarcodeScanned = useCallback(
|
||||||
async ({ data }: { data: string }) => {
|
(result: { data: string }) => {
|
||||||
if (scannedRef.current) return
|
if (scannedRef.current) return
|
||||||
|
const p = parseBindPayload(result.data)
|
||||||
|
if (!p) {
|
||||||
|
setMsg('二维码内容不是有效的绑定 JSON(需 v1、baseUrl 等字段)。')
|
||||||
|
return
|
||||||
|
}
|
||||||
scannedRef.current = true
|
scannedRef.current = true
|
||||||
const p = parseBindPayload(data)
|
void (async () => {
|
||||||
await applyPayload(p)
|
await applyPayload(p)
|
||||||
|
setScanOn(false)
|
||||||
|
})()
|
||||||
},
|
},
|
||||||
[applyPayload],
|
[applyPayload],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPasteApply = useCallback(async () => {
|
const onPasteJson = useCallback(async () => {
|
||||||
const p = parseBindPayload(paste.trim())
|
const raw = (await Clipboard.getStringAsync()).trim()
|
||||||
await applyPayload(p)
|
const p = parseBindPayload(raw)
|
||||||
}, [paste, applyPayload])
|
if (!p) {
|
||||||
|
setMsg('剪贴板内容不是有效的绑定 JSON。')
|
||||||
const copyEtJson = useCallback(async () => {
|
return
|
||||||
if (!bind) return
|
|
||||||
const chunk = {
|
|
||||||
networkName: bind.easytier.networkName,
|
|
||||||
networkSecret: bind.easytier.networkSecret,
|
|
||||||
peerUrls: bind.easytier.peerUrls,
|
|
||||||
}
|
}
|
||||||
await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2))
|
await applyPayload(p)
|
||||||
setMsg('已复制 EasyTier 组网 JSON。')
|
}, [applyPayload])
|
||||||
}, [bind])
|
|
||||||
|
const onSaveHost = useCallback(async () => {
|
||||||
|
const n = normalizeHostInput(hostText)
|
||||||
|
if (!n) {
|
||||||
|
setMsg('请输入 `ip/域名:端口`(或带 http:// 的 baseUrl)。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const p: BindPayload = { v: 1, baseUrl: n.baseUrl, port: n.port, token: bind?.token ?? '' }
|
||||||
|
await setBind(p)
|
||||||
|
setMsg('已保存绑定地址;未改动的 token 已保留。')
|
||||||
|
}, [hostText, bind?.token, setBind])
|
||||||
|
|
||||||
|
const onResetHost = useCallback(async () => {
|
||||||
|
setMsg('')
|
||||||
|
Alert.alert('清除配对', '确定要删除当前绑定地址吗?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{ text: '清除', style: 'destructive', onPress: () => void clear() },
|
||||||
|
])
|
||||||
|
}, [clear])
|
||||||
|
|
||||||
const copyFull = useCallback(async () => {
|
const copyFull = useCallback(async () => {
|
||||||
if (!bind) return
|
if (!bind) return
|
||||||
@@ -91,52 +126,49 @@ export default function BindScreen() {
|
|||||||
|
|
||||||
const testConn = useCallback(async () => {
|
const testConn = useCallback(async () => {
|
||||||
if (!bind) return
|
if (!bind) return
|
||||||
|
const url = `${bind.baseUrl}/health`
|
||||||
|
setMsg('正在测试 API 连接…')
|
||||||
|
const ac = new AbortController()
|
||||||
|
const t = setTimeout(() => ac.abort(), 15000)
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${bind.baseUrl}/health`, {
|
const r = await fetch(url, {
|
||||||
|
signal: ac.signal,
|
||||||
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
|
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
|
||||||
})
|
})
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({}))
|
||||||
|
setMsg('')
|
||||||
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
|
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert('连接失败', e instanceof Error ? e.message : String(e))
|
const isAbort = e instanceof Error && e.name === 'AbortError'
|
||||||
|
setMsg('')
|
||||||
|
Alert.alert(
|
||||||
|
isAbort ? '连接超时' : '连接失败',
|
||||||
|
isAbort
|
||||||
|
? '15 秒内无响应。请确认手机能访问绑定里的 baseUrl:端口(或域名:端口),PC 上 web2 服务已启动,且防火墙/网络路由允许(换网后可能需要重新扫码或改地址)。'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t)
|
||||||
}
|
}
|
||||||
}, [bind])
|
}, [bind])
|
||||||
|
|
||||||
const retryEmbeddedTunnel = useCallback(async () => {
|
const openScan = useCallback(async () => {
|
||||||
if (!bind || !etEmbedded) return
|
setMsg('')
|
||||||
setEtBusy(true)
|
if (Platform.OS === 'web') {
|
||||||
try {
|
setMsg('网页版请使用「从剪贴板导入 JSON」。')
|
||||||
const r = await saveAndPrepareTunnel(bind.easytier)
|
return
|
||||||
if (r.needsVpnPermission) {
|
}
|
||||||
setMsg('请在系统弹窗中允许本应用建立 VPN,授权后再点“检查 VPN 权限”。')
|
if (!perm?.granted) {
|
||||||
} else if (r.ok && r.vpnPermissionGranted) {
|
const r = await requestPerm()
|
||||||
setMsg(
|
if (!r.granted) {
|
||||||
'VPN 权限已就绪,EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。'
|
setMsg('需要相机权限才能扫码。')
|
||||||
)
|
return
|
||||||
} else {
|
|
||||||
setMsg(`组网准备结果:${JSON.stringify(r)}`)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : String(e))
|
|
||||||
} finally {
|
|
||||||
setEtBusy(false)
|
|
||||||
}
|
}
|
||||||
}, [bind, etEmbedded])
|
setScanOn(true)
|
||||||
|
}, [perm?.granted, requestPerm])
|
||||||
const checkVpn = useCallback(async () => {
|
|
||||||
if (!etEmbedded) return
|
|
||||||
setEtBusy(true)
|
|
||||||
try {
|
|
||||||
const ok = await checkVpnPermissionGranted()
|
|
||||||
setMsg(
|
|
||||||
ok
|
|
||||||
? 'VPN 权限已授予。若仍无法访问 API,请确认当前安装包已包含完整原生 EasyTier 数据面。'
|
|
||||||
: '尚未授予 VPN 权限,请先在系统设置中允许本应用建立 VPN。'
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setEtBusy(false)
|
|
||||||
}
|
|
||||||
}, [etEmbedded])
|
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
@@ -146,26 +178,21 @@ export default function BindScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||||
<Text style={styles.h1}>远程配对</Text>
|
<Text style={styles.h1}>远程配对</Text>
|
||||||
<Text style={styles.lead}>
|
<Text style={styles.lead}>
|
||||||
在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier
|
PC 端生成二维码后扫码导入;导入后可在下方编辑 `ip/域名:端口` 再保存,token 会保留。也可仅手动填写地址(需自行保证与 PC 上 token 一致)。
|
||||||
参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{!!msg && <Text style={styles.msgBanner}>{msg}</Text>}
|
||||||
|
|
||||||
{bind && (
|
{bind && (
|
||||||
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
|
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>当前已绑定</Text>
|
<Text style={styles.cardTitle}>当前已绑定</Text>
|
||||||
<Text style={styles.mono}>{bind.baseUrl}</Text>
|
<Text style={styles.mono}>{bind.baseUrl}</Text>
|
||||||
<Text style={styles.mutedSmall}>组网名 · {bind.easytier.networkName}</Text>
|
<Text style={styles.mutedSmall}>token:{bind.token ? '已保存' : '无'}</Text>
|
||||||
<Text style={styles.mutedSmall}>引导节点 · {bootstrapPeer}</Text>
|
|
||||||
{bind.easytier.peerUrls.length > 1 && (
|
|
||||||
<Text style={styles.mutedSmall}>额外引导节点 · {bind.easytier.peerUrls.length - 1} 个</Text>
|
|
||||||
)}
|
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Pressable style={styles.btn} onPress={() => void testConn()}>
|
<Pressable style={styles.btn} onPress={() => void testConn()}>
|
||||||
<Text style={styles.btnText}>测试 API</Text>
|
<Text style={styles.btnText}>测试 API</Text>
|
||||||
@@ -182,110 +209,72 @@ export default function BindScreen() {
|
|||||||
<Text style={styles.btnText}>清除</Text>
|
<Text style={styles.btnText}>清除</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
{etEmbedded ? (
|
|
||||||
<View style={styles.etRow}>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
|
||||||
disabled={etBusy}
|
|
||||||
onPress={() => void retryEmbeddedTunnel()}
|
|
||||||
>
|
|
||||||
<Text style={styles.btnSecondaryText}>重新应用组网参数</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
|
||||||
disabled={etBusy}
|
|
||||||
onPress={() => void checkVpn()}
|
|
||||||
>
|
|
||||||
<Text style={styles.btnSecondaryText}>检查 VPN 权限</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.mutedSmall}>
|
|
||||||
{Platform.OS === 'ios'
|
|
||||||
? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。'
|
|
||||||
: '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={styles.h2}>扫码或导入 JSON</Text>
|
||||||
|
<Text style={styles.body}>与 PC 端「远程控制」面板中的二维码内容一致(含 baseUrl、port、token)。</Text>
|
||||||
|
<View style={styles.etRow}>
|
||||||
|
<Pressable style={styles.btnPrimary} onPress={() => void openScan()}>
|
||||||
|
<Ionicons name="qr-code-outline" size={20} color="#fff" />
|
||||||
|
<Text style={styles.btnPrimaryText}> 扫二维码</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.btnSecondary} onPress={() => void onPasteJson()}>
|
||||||
|
<Text style={styles.btnSecondaryText}>从剪贴板导入 JSON</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{scanOn && Platform.OS !== 'web' && (
|
||||||
|
<View style={styles.camWrap}>
|
||||||
|
<CameraView
|
||||||
|
style={styles.camera}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={onBarcodeScanned}
|
||||||
|
/>
|
||||||
|
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
|
||||||
|
<Text style={styles.btnText}>关闭相机</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={styles.h2}>绑定地址(可编辑)</Text>
|
||||||
|
<Text style={styles.body}>
|
||||||
|
格式:`ip/域名:端口`(如 `192.168.21.222:8001`)。保存时会保留当前已存储的 token;若仅手填地址且从未扫码,请确保 token 与 PC 一致。
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="ip/域名:端口"
|
||||||
|
placeholderTextColor={theme.muted}
|
||||||
|
value={hostText}
|
||||||
|
onChangeText={setHostText}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Pressable style={styles.btn} onPress={() => void onSaveHost()}>
|
||||||
|
<Text style={styles.btnText}>保存</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={[styles.btn, styles.btnDanger]} onPress={() => void onResetHost()}>
|
||||||
|
<Text style={styles.btnText}>删除/重置</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.block}>
|
<View style={styles.block}>
|
||||||
<Text style={styles.h2}>说明</Text>
|
<Text style={styles.h2}>说明</Text>
|
||||||
<Text style={styles.body}>
|
<Text style={styles.body}>推流、转码在 PC 侧执行;手机保存远程控制用的 baseUrl 与 token。</Text>
|
||||||
推流和 FFmpeg 只在 PC 执行;手机只负责保存控制通道地址与 EasyTier 参数。现在桌面端会默认把
|
|
||||||
`tcp://public.easytier.top:11010` 写进 `peerUrls`,减少没有公网 IP 时的发现失败。
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.body}>
|
|
||||||
当前 Android 原生模块已经能保存组网参数并申请 VPN 权限;如果你要完全在 App 内跑 EasyTier
|
|
||||||
数据面,下一步还需要把 EasyTier Android 原生库继续接进来。
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={() => void Linking.openURL(ET_RELEASE)} style={styles.linkRow}>
|
|
||||||
<Ionicons name="open-outline" size={18} color={theme.accent} />
|
|
||||||
<Text style={styles.link}>EasyTier 官方发布与文档</Text>
|
|
||||||
</Pressable>
|
|
||||||
{bind && (
|
{bind && (
|
||||||
<View style={styles.etActions}>
|
<View style={styles.etActions}>
|
||||||
<Pressable style={styles.btnSecondary} onPress={() => void copyEtJson()}>
|
|
||||||
<Text style={styles.btnSecondaryText}>复制组网 JSON</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
|
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
|
||||||
<Text style={styles.btnSecondaryText}>复制完整绑定 JSON</Text>
|
<Text style={styles.btnSecondaryText}>复制完整绑定 JSON</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.block}>
|
|
||||||
<Text style={styles.h2}>扫码</Text>
|
|
||||||
{!scanOn ? (
|
|
||||||
<Pressable
|
|
||||||
style={styles.btnPrimary}
|
|
||||||
onPress={async () => {
|
|
||||||
const r = await requestPerm()
|
|
||||||
if (!r.granted) {
|
|
||||||
setMsg('需要相机权限才能扫码。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scannedRef.current = false
|
|
||||||
setScanOn(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="qr-code-outline" size={22} color="#fff" style={{ marginRight: 8 }} />
|
|
||||||
<Text style={styles.btnPrimaryText}>打开相机扫描 PC 二维码</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : perm?.granted ? (
|
|
||||||
<View style={styles.camWrap}>
|
|
||||||
<CameraView
|
|
||||||
style={styles.camera}
|
|
||||||
facing="back"
|
|
||||||
onBarcodeScanned={onBarCode}
|
|
||||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
|
||||||
/>
|
|
||||||
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
|
|
||||||
<Text style={styles.btnPrimaryText}>关闭相机</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.muted}>没有相机权限。</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.block}>
|
|
||||||
<Text style={styles.h2}>手动粘贴 JSON</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
multiline
|
|
||||||
placeholder="粘贴 PC 端显示的绑定 JSON"
|
|
||||||
placeholderTextColor={theme.muted}
|
|
||||||
value={paste}
|
|
||||||
onChangeText={setPaste}
|
|
||||||
/>
|
|
||||||
<Pressable style={styles.btnPrimary} onPress={() => void onPasteApply()}>
|
|
||||||
<Text style={styles.btnPrimaryText}>解析并保存</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{!!msg && <Text style={styles.msg}>{msg}</Text>}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
@@ -321,7 +310,6 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.border,
|
borderColor: theme.border,
|
||||||
},
|
},
|
||||||
btnDisabled: { opacity: 0.55 },
|
|
||||||
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
|
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
|
||||||
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
|
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
|
||||||
btnPrimary: {
|
btnPrimary: {
|
||||||
@@ -331,6 +319,8 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#3d7eff',
|
backgroundColor: '#3d7eff',
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '45%',
|
||||||
},
|
},
|
||||||
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||||
btnSecondary: {
|
btnSecondary: {
|
||||||
@@ -346,21 +336,29 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
||||||
input: {
|
input: {
|
||||||
minHeight: 120,
|
minHeight: 52,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.border,
|
borderColor: theme.border,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
color: theme.text,
|
color: theme.text,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
backgroundColor: theme.bgCard,
|
backgroundColor: theme.bgCard,
|
||||||
},
|
},
|
||||||
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' },
|
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000', marginBottom: 18 },
|
||||||
camera: { height: 280, width: '100%' },
|
camera: { height: 280, width: '100%' },
|
||||||
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
|
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
|
||||||
linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 },
|
msgBanner: {
|
||||||
link: { color: theme.accent, fontSize: 14 },
|
color: theme.success,
|
||||||
msg: { color: theme.success, fontSize: 14, marginTop: 8 },
|
fontSize: 14,
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: 14,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(61,126,255,0.12)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.border,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 ?? '—'}
|
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.snapLine}>
|
<Text style={styles.snapLine}>
|
||||||
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
国内站点 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -196,7 +196,7 @@ export default function DeskScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
||||||
<Text style={styles.liveLine} numberOfLines={2}>
|
<Text style={styles.liveLine} numberOfLines={2}>
|
||||||
抖音 {row.douyinHint ?? '—'}
|
源站 {row.douyinHint ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.liveLine} numberOfLines={2}>
|
<Text style={styles.liveLine} numberOfLines={2}>
|
||||||
标题 {row.streamTitle ?? '—'}
|
标题 {row.streamTitle ?? '—'}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -10,10 +13,12 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
Linking,
|
Linking,
|
||||||
|
Switch,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { Picker } from '@react-native-picker/picker'
|
import { Picker } from '@react-native-picker/picker'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { useRecorder } from '@/hooks/useRecorder'
|
import { useRecorder } from '@/hooks/useRecorder'
|
||||||
import { theme } from '@/constants/theme'
|
import { theme } from '@/constants/theme'
|
||||||
@@ -23,6 +28,27 @@ import {
|
|||||||
proChannelPm2Process,
|
proChannelPm2Process,
|
||||||
} from '@/lib/youtubeConfigUtils'
|
} 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 {
|
function ctrlStyle(action: string): object {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
@@ -99,7 +125,7 @@ function ProLiveSwitchGrid({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
{href ? (
|
{href ? (
|
||||||
<Pressable onPress={() => void Linking.openURL(href)}>
|
<Pressable onPress={() => void Linking.openURL(href)}>
|
||||||
<Text style={styles.proLink}>在浏览器打开抖音页</Text>
|
<Text style={styles.proLink}>在浏览器打开源站页</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.proActions}>
|
<View style={styles.proActions}>
|
||||||
@@ -177,12 +203,32 @@ function LiveSwitchGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RecordScreen() {
|
export default function RecordScreen() {
|
||||||
|
const navigation = useNavigation()
|
||||||
const { bind } = useBind()
|
const { bind } = useBind()
|
||||||
|
const { session, openLogin, logout } = useAuth()
|
||||||
const r = useRecorder()
|
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 unbound = !bind
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
|
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 () => {
|
const onRefresh = useCallback(async () => {
|
||||||
if (!bind) return
|
if (!bind) return
|
||||||
@@ -197,7 +243,7 @@ export default function RecordScreen() {
|
|||||||
<View style={styles.fatalWrap}>
|
<View style={styles.fatalWrap}>
|
||||||
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
||||||
<Text style={styles.fatalHint}>
|
<Text style={styles.fatalHint}>
|
||||||
常见于 WiFi / EasyTier 瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。
|
常见于网络瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
|
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
|
||||||
<Text style={styles.retryBtnText}>重试加载任务列表</Text>
|
<Text style={styles.retryBtnText}>重试加载任务列表</Text>
|
||||||
@@ -227,11 +273,6 @@ export default function RecordScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{unbound && (
|
|
||||||
<View style={styles.banner}>
|
|
||||||
<Text style={styles.bannerText}>未绑定 PC:在「远程」页扫码后,此处会加载任务与实时数据。</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{bind && (
|
{bind && (
|
||||||
<View style={styles.banner}>
|
<View style={styles.banner}>
|
||||||
<Text style={styles.bannerText}>
|
<Text style={styles.bannerText}>
|
||||||
@@ -240,17 +281,70 @@ export default function RecordScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.toolbar}>
|
<Animated.View entering={FadeInDown.duration(420).springify()}>
|
||||||
<View style={[styles.pickerWrap, unbound && styles.pickerDisabled]}>
|
<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
|
<Picker
|
||||||
enabled={!unbound && !r.emptyHint}
|
enabled={!r.emptyHint}
|
||||||
selectedValue={unbound ? '' : r.currentProcess}
|
selectedValue={r.currentProcess}
|
||||||
onValueChange={(v) => r.setCurrentProcess(String(v))}
|
onValueChange={(v) => r.setCurrentProcess(String(v))}
|
||||||
style={styles.picker}
|
style={styles.picker}
|
||||||
dropdownIconColor={theme.text}
|
dropdownIconColor={theme.text}
|
||||||
>
|
>
|
||||||
{unbound || !r.entries.length ? (
|
{!r.entries.length ? (
|
||||||
<Picker.Item label="(未配对)" value="" color={theme.muted} />
|
<Picker.Item label="(无任务)" value="" color={theme.muted} />
|
||||||
) : (
|
) : (
|
||||||
r.entries.map((e) => (
|
r.entries.map((e) => (
|
||||||
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
|
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
|
||||||
@@ -258,28 +352,7 @@ export default function RecordScreen() {
|
|||||||
)}
|
)}
|
||||||
</Picker>
|
</Picker>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
) : null}
|
||||||
style={[styles.proRow, unbound && styles.pickerDisabled]}
|
|
||||||
disabled={unbound}
|
|
||||||
onPress={() => {
|
|
||||||
if (unbound) {
|
|
||||||
Alert.alert('未绑定', '配对后可切换多频道 Pro。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void r.togglePro(!r.multiChannelPro)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={styles.proLabel}>多频道直播(Pro)</Text>
|
|
||||||
<Text style={[styles.proVal, r.multiChannelPro && { color: theme.accent }]}>
|
|
||||||
{r.multiChannelPro ? '开' : '关'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.pill, { borderColor: statusColor }]}>
|
|
||||||
<View style={[styles.dot, { backgroundColor: statusColor }]} />
|
|
||||||
<Text style={[styles.pillText, { color: statusColor }]}>{r.statusMeta.line}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{bind && r.emptyHint ? (
|
{bind && r.emptyHint ? (
|
||||||
<View style={styles.emptyCard}>
|
<View style={styles.emptyCard}>
|
||||||
@@ -678,7 +751,94 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
},
|
},
|
||||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
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 },
|
proGrid: { gap: 10 },
|
||||||
proSwitchRow: {
|
proSwitchRow: {
|
||||||
padding: 12,
|
padding: 12,
|
||||||
@@ -712,32 +872,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
pickerDisabled: { opacity: 0.65 },
|
pickerDisabled: { opacity: 0.65 },
|
||||||
picker: { color: theme.text },
|
picker: { color: theme.text },
|
||||||
proRow: {
|
pickerRowBelow: { marginTop: 0 },
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginTop: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
backgroundColor: theme.bgCard,
|
|
||||||
borderRadius: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.border,
|
|
||||||
},
|
|
||||||
proLabel: { color: theme.text, fontWeight: '600' },
|
|
||||||
proVal: { color: theme.muted, fontWeight: '600' },
|
|
||||||
pill: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
gap: 8,
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: 1,
|
|
||||||
marginBottom: 14,
|
|
||||||
},
|
|
||||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
pillText: { fontSize: 13, fontWeight: '600' },
|
|
||||||
emptyCard: {
|
emptyCard: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export default function NetworkScreen() {
|
|||||||
|
|
||||||
{!bind && (
|
{!bind && (
|
||||||
<View style={styles.banner}>
|
<View style={styles.banner}>
|
||||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 抖音 与测速。</Text>
|
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 国内站点 与测速。</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export default function NetworkScreen() {
|
|||||||
onSolution={openSolution}
|
onSolution={openSolution}
|
||||||
/>
|
/>
|
||||||
<SiteCard
|
<SiteCard
|
||||||
title="抖音(国内参考)"
|
title="国内站点(参考)"
|
||||||
site={view.douyin}
|
site={view.douyin}
|
||||||
speed={view.speed.domestic}
|
speed={view.speed.domestic}
|
||||||
loading={loading && !!bind}
|
loading={loading && !!bind}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
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 { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import { UserAgreementModal } from '@/components/UserAgreementModal'
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import type { SystemMetricsPayload } from '@/lib/pcTypes'
|
import type { SystemMetricsPayload } from '@/lib/pcTypes'
|
||||||
@@ -20,6 +21,7 @@ export default function SystemScreen() {
|
|||||||
const [data, setData] = useState<SystemMetricsPayload | null>(null)
|
const [data, setData] = useState<SystemMetricsPayload | null>(null)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [agreementOpen, setAgreementOpen] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!bind) {
|
if (!bind) {
|
||||||
@@ -147,8 +149,12 @@ export default function SystemScreen() {
|
|||||||
<View style={styles.cardMuted}>
|
<View style={styles.cardMuted}>
|
||||||
<Text style={styles.cardTitle}>客户端与系统集成</Text>
|
<Text style={styles.cardTitle}>客户端与系统集成</Text>
|
||||||
<Text style={styles.muted}>开机启动、路径与 Electron 集成仅在桌面客户端可用。</Text>
|
<Text style={styles.muted}>开机启动、路径与 Electron 集成仅在桌面客户端可用。</Text>
|
||||||
|
<Pressable style={styles.agreementRow} onPress={() => setAgreementOpen(true)} hitSlop={8}>
|
||||||
|
<Text style={styles.agreementLink}>用户协议</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<UserAgreementModal visible={agreementOpen} onClose={() => setAgreementOpen(false)} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -205,4 +211,6 @@ const styles = StyleSheet.create({
|
|||||||
ffSec: { marginTop: 12 },
|
ffSec: { marginTop: 12 },
|
||||||
ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' },
|
ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' },
|
||||||
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
|
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
|
||||||
|
agreementRow: { marginTop: 10, alignSelf: 'flex-start' },
|
||||||
|
agreementLink: { fontSize: 12, color: theme.accent, textDecorationLine: 'underline' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { useEffect } from 'react';
|
|||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
import { BindProvider } from '@/context/BindContext';
|
import { BindProvider } from '@/context/BindContext';
|
||||||
|
import { PocketBaseAuthModal } from '@/components/PocketBaseAuthModal';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Catch any errors thrown by the Layout component.
|
// 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.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
/** 避免部分真机/构建环境下字体加载挂起或失败时永远停在 Expo 默认靶心启动图 */
|
||||||
|
const SPLASH_FALLBACK_MS = 4000;
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded, error] = useFonts({
|
const [loaded, error] = useFonts({
|
||||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
console.warn('[expo-font]', error);
|
||||||
|
}
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
SplashScreen.hideAsync();
|
void SplashScreen.hideAsync();
|
||||||
}
|
}
|
||||||
}, [loaded]);
|
}, [loaded]);
|
||||||
|
|
||||||
if (!loaded) {
|
useEffect(() => {
|
||||||
return null;
|
const t = setTimeout(() => {
|
||||||
}
|
void SplashScreen.hideAsync();
|
||||||
|
}, SPLASH_FALLBACK_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 不再在字体未就绪时 return null,否则 hideAsync 后会出现纯白屏且仍无界面
|
||||||
return <RootLayoutNav />;
|
return <RootLayoutNav />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +57,15 @@ function RootLayoutNav() {
|
|||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BindProvider>
|
<AuthProvider>
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<BindProvider>
|
||||||
<Stack>
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack>
|
||||||
</Stack>
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
</ThemeProvider>
|
</Stack>
|
||||||
</BindProvider>
|
<PocketBaseAuthModal />
|
||||||
|
</ThemeProvider>
|
||||||
|
</BindProvider>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 438 KiB |
135
components/PocketBaseAuthModal.tsx
Normal 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 },
|
||||||
|
})
|
||||||
136
components/UserAgreementModal.tsx
Normal 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
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { Platform } from 'react-native'
|
|
||||||
import { isExpoEasyTierVpnAvailable, saveAndPrepareTunnel } from '@/lib/easyTierVpn'
|
|
||||||
import type { BindPayload } from '@/lib/types'
|
import type { BindPayload } from '@/lib/types'
|
||||||
import { clearBindPayload, loadBindPayload, saveBindPayload } from '@/lib/storage'
|
import { clearBindPayload, loadBindPayload, saveBindPayload } from '@/lib/storage'
|
||||||
|
|
||||||
@@ -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) => {
|
const setBind = useCallback(async (p: BindPayload | null) => {
|
||||||
setBindState(p)
|
setBindState(p)
|
||||||
if (p) await saveBindPayload(p)
|
if (p) await saveBindPayload(p)
|
||||||
else await clearBindPayload()
|
else await clearBindPayload()
|
||||||
if (p && Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
|
|
||||||
try {
|
|
||||||
await saveAndPrepareTunnel(p.easytier)
|
|
||||||
} catch {
|
|
||||||
/* 组网内核未就绪或用户未授权 VPN 时不阻断配对 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clear = useCallback(async () => {
|
const clear = useCallback(async () => {
|
||||||
|
|||||||
125
electron/agreement.html
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('agreementApi', {
|
||||||
|
accept: () => ipcRenderer.send('agreement-accept'),
|
||||||
|
refuse: () => ipcRenderer.send('agreement-refuse'),
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Alert } from 'react-native'
|
import { Alert } from 'react-native'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { apiFetch, apiJson } from '@/lib/api'
|
import { apiFetch, apiJson } from '@/lib/api'
|
||||||
|
import { reportLiveControlEvent } from '@/lib/liveTelemetry'
|
||||||
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
||||||
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
||||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
||||||
@@ -72,6 +74,9 @@ function pickProcessForEntries(filtered: Entry[], multi: boolean): string {
|
|||||||
|
|
||||||
export function useRecorder() {
|
export function useRecorder() {
|
||||||
const { bind } = useBind()
|
const { bind } = useBind()
|
||||||
|
const { pbToken, ensureCanControlLive, session } = useAuth()
|
||||||
|
const pbTokRef = useRef(pbToken)
|
||||||
|
pbTokRef.current = pbToken
|
||||||
const [entries, setEntries] = useState<Entry[]>([])
|
const [entries, setEntries] = useState<Entry[]>([])
|
||||||
const [currentProcess, setCurrentProcess] = useState('')
|
const [currentProcess, setCurrentProcess] = useState('')
|
||||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||||
@@ -135,7 +140,7 @@ export function useRecorder() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bindKey) return
|
if (!bindKey) return
|
||||||
let cancelled = false
|
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((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||||
.then((j: GpuCaps) => {
|
.then((j: GpuCaps) => {
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
@@ -166,7 +171,7 @@ export function useRecorder() {
|
|||||||
const loadProcessMonitor = useCallback(async () => {
|
const loadProcessMonitor = useCallback(async () => {
|
||||||
const b = bindRef.current
|
const b = bindRef.current
|
||||||
if (!b) throw new Error('尚未绑定 PC')
|
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))
|
if (!res.ok) throw new Error(String(res.status))
|
||||||
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
||||||
const raw: Entry[] = (data.entries || []).map((e) => ({
|
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 }>(
|
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||||
b,
|
b,
|
||||||
'/relay_pro/channels',
|
'/relay_pro/channels',
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
setProChannelsError(d.error || '无法加载 Pro 频道')
|
setProChannelsError(d.error || '无法加载 Pro 频道')
|
||||||
@@ -220,7 +226,7 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
setYoutubeStatus('正在将当前配置同步到多频道…')
|
setYoutubeStatus('正在将当前配置同步到多频道…')
|
||||||
try {
|
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 }
|
const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string }
|
||||||
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
||||||
const firstId = d.channels[0].id
|
const firstId = d.channels[0].id
|
||||||
@@ -231,6 +237,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const kj = (await keyRes.json()) as { error?: string }
|
const kj = (await keyRes.json()) as { error?: string }
|
||||||
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
||||||
@@ -241,6 +248,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: urlText }),
|
body: JSON.stringify({ content: urlText }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const uj = (await urlRes.json()) as { error?: string }
|
const uj = (await urlRes.json()) as { error?: string }
|
||||||
@@ -258,6 +266,7 @@ export function useRecorder() {
|
|||||||
options: ytIniModel.options,
|
options: ytIniModel.options,
|
||||||
header: ytIniModel.header || '',
|
header: ytIniModel.header || '',
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -282,6 +291,7 @@ export function useRecorder() {
|
|||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||||
const data = (await res.json()) as { content?: string }
|
const data = (await res.json()) as { content?: string }
|
||||||
@@ -310,6 +320,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ content }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||||
@@ -330,6 +341,7 @@ export function useRecorder() {
|
|||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
@@ -375,6 +387,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(modelToSave),
|
body: JSON.stringify(modelToSave),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
@@ -404,6 +417,7 @@ export function useRecorder() {
|
|||||||
keys: keysOne,
|
keys: keysOne,
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
@@ -450,6 +464,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: ed.urlText }),
|
body: JSON.stringify({ content: ed.urlText }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
@@ -469,6 +484,7 @@ export function useRecorder() {
|
|||||||
const ur = await apiFetch(
|
const ur = await apiFetch(
|
||||||
b,
|
b,
|
||||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!ur.ok) {
|
if (!ur.ok) {
|
||||||
setUrlStatus('读取失败')
|
setUrlStatus('读取失败')
|
||||||
@@ -518,9 +534,17 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
const proc = procForPm2(processOverride)
|
const proc = procForPm2(processOverride)
|
||||||
const isStatus = action === 'status'
|
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 {
|
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('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
|
|
||||||
@@ -551,6 +575,15 @@ export function useRecorder() {
|
|||||||
const actionLabel =
|
const actionLabel =
|
||||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||||
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
|
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
|
const statusProc = processOverride ?? proc
|
||||||
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
||||||
}
|
}
|
||||||
@@ -561,18 +594,22 @@ export function useRecorder() {
|
|||||||
if (!isStatus) setLoadingAction(null)
|
if (!isStatus) setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bindRef, procForPm2, getEntry],
|
[bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const runProChannelStart = useCallback(
|
const runProChannelStart = useCallback(
|
||||||
async (channelId: string) => {
|
async (channelId: string) => {
|
||||||
if (!channelId) return
|
if (!channelId) return
|
||||||
|
const allowed = await ensureCanControlLive()
|
||||||
|
if (!allowed) return
|
||||||
const pm = proChannelPm2Process(channelId)
|
const pm = proChannelPm2Process(channelId)
|
||||||
setLoadingAction('start')
|
setLoadingAction('start')
|
||||||
try {
|
try {
|
||||||
const saved = await saveProChannelKeysFor(channelId)
|
const saved = await saveProChannelKeysFor(channelId)
|
||||||
if (!saved) return
|
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('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
const e = getEntry(pm)
|
const e = getEntry(pm)
|
||||||
@@ -584,6 +621,7 @@ export function useRecorder() {
|
|||||||
.filter((x): x is string => typeof x === 'string')
|
.filter((x): x is string => typeof x === 'string')
|
||||||
.join('\n') || '已完成'
|
.join('\n') || '已完成'
|
||||||
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
||||||
|
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start')
|
||||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||||
@@ -591,18 +629,22 @@ export function useRecorder() {
|
|||||||
setLoadingAction(null)
|
setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const runProChannelRestart = useCallback(
|
const runProChannelRestart = useCallback(
|
||||||
async (channelId: string) => {
|
async (channelId: string) => {
|
||||||
if (!channelId) return
|
if (!channelId) return
|
||||||
|
const allowed = await ensureCanControlLive()
|
||||||
|
if (!allowed) return
|
||||||
const pm = proChannelPm2Process(channelId)
|
const pm = proChannelPm2Process(channelId)
|
||||||
setLoadingAction('restart')
|
setLoadingAction('restart')
|
||||||
try {
|
try {
|
||||||
const saved = await saveProChannelKeysFor(channelId)
|
const saved = await saveProChannelKeysFor(channelId)
|
||||||
if (!saved) return
|
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('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
const e = getEntry(pm)
|
const e = getEntry(pm)
|
||||||
@@ -614,6 +656,7 @@ export function useRecorder() {
|
|||||||
.filter((x): x is string => typeof x === 'string')
|
.filter((x): x is string => typeof x === 'string')
|
||||||
.join('\n') || '已完成'
|
.join('\n') || '已完成'
|
||||||
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
||||||
|
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart')
|
||||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||||
@@ -621,7 +664,7 @@ export function useRecorder() {
|
|||||||
setLoadingAction(null)
|
setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitNewChannelDraft = useCallback(async () => {
|
const submitNewChannelDraft = useCallback(async () => {
|
||||||
@@ -637,6 +680,7 @@ export function useRecorder() {
|
|||||||
name: `频道 ${proChannelsList.length + 1}`,
|
name: `频道 ${proChannelsList.length + 1}`,
|
||||||
youtubeKey: key,
|
youtubeKey: key,
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
||||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||||
@@ -675,6 +719,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ channelId }),
|
body: JSON.stringify({ channelId }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const d = (await res.json()) as { error?: string; message?: string }
|
const d = (await res.json()) as { error?: string; message?: string }
|
||||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
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 }>(
|
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||||
bind,
|
bind,
|
||||||
'/relay_pro/channels',
|
'/relay_pro/channels',
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
@@ -878,7 +924,7 @@ export function useRecorder() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
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
|
if (!r.ok || cancelled) return
|
||||||
const j = (await r.json()) as ProRelayStatusPayload
|
const j = (await r.json()) as ProRelayStatusPayload
|
||||||
if (!cancelled) setProRelayStatus(j)
|
if (!cancelled) setProRelayStatus(j)
|
||||||
@@ -910,6 +956,7 @@ export function useRecorder() {
|
|||||||
const dr = await apiFetch(
|
const dr = await apiFetch(
|
||||||
b,
|
b,
|
||||||
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!dr.ok) continue
|
if (!dr.ok) continue
|
||||||
const cd = (await dr.json()) as {
|
const cd = (await dr.json()) as {
|
||||||
@@ -951,6 +998,7 @@ export function useRecorder() {
|
|||||||
const ur = await apiFetch(
|
const ur = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!ur.ok || cancelled) return
|
if (!ur.ok || cancelled) return
|
||||||
const j = (await ur.json()) as { content?: string }
|
const j = (await ur.json()) as { content?: string }
|
||||||
|
|||||||
14
lib/api.ts
@@ -6,22 +6,28 @@ function joinUrl(base: string, path: string): string {
|
|||||||
return `${b}${p}`
|
return `${b}${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiFetchInit = RequestInit & { pbToken?: string | null }
|
||||||
|
|
||||||
export async function apiFetch(
|
export async function apiFetch(
|
||||||
bind: BindPayload | null,
|
bind: BindPayload | null,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: ApiFetchInit,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
if (!bind) {
|
if (!bind) {
|
||||||
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
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()) {
|
if (bind.token?.trim()) {
|
||||||
headers.set('Authorization', `Bearer ${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)
|
const res = await apiFetch(bind, path, init)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text()
|
const t = await res.text()
|
||||||
|
|||||||
29
lib/authStorage.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
lib/types.ts
@@ -1,52 +1,21 @@
|
|||||||
export type EasyTierConfig = {
|
|
||||||
networkName: string
|
|
||||||
networkSecret: string
|
|
||||||
peerUrls: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_EASYTIER_PEER_URLS = ['tcp://public.easytier.top:11010']
|
|
||||||
|
|
||||||
function normalizePeerUrls(value: unknown): string[] {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const next = Array.isArray(value) ? value.map(String) : []
|
|
||||||
const normalized = next
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => {
|
|
||||||
if (!item || seen.has(item)) return false
|
|
||||||
seen.add(item)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return normalized.length > 0 ? normalized : [...DEFAULT_EASYTIER_PEER_URLS]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
|
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
|
||||||
export type BindPayload = {
|
export type BindPayload = {
|
||||||
v: 1
|
v: 1
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
port: number
|
port: number
|
||||||
token: string
|
token?: string
|
||||||
easytier: EasyTierConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseBindPayload(raw: string): BindPayload | null {
|
export function parseBindPayload(raw: string): BindPayload | null {
|
||||||
try {
|
try {
|
||||||
const j = JSON.parse(raw) as Record<string, unknown>
|
const j = JSON.parse(raw) as Record<string, unknown>
|
||||||
if (j.v !== 1) return null
|
if (j.v !== 1) return null
|
||||||
const et = j.easytier as Record<string, unknown> | undefined
|
if (typeof j.baseUrl !== 'string') return null
|
||||||
if (!et || typeof j.baseUrl !== 'string' || typeof j.token !== 'string') return null
|
|
||||||
const networkName = String(et.networkName ?? '').trim()
|
|
||||||
const networkSecret = String(et.networkSecret ?? '').trim()
|
|
||||||
if (!networkName || !networkSecret) return null
|
|
||||||
return {
|
return {
|
||||||
v: 1,
|
v: 1,
|
||||||
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
|
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
|
||||||
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
|
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
|
||||||
token: String(j.token),
|
token: j.token == null ? '' : String(j.token),
|
||||||
easytier: {
|
|
||||||
networkName,
|
|
||||||
networkSecret,
|
|
||||||
peerUrls: normalizePeerUrls(et.peerUrls),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
42
lib/userAgreement.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/** 用户使用协议(与 electron/agreement.html 内容保持一致) */
|
||||||
|
export const USER_AGREEMENT_MARKDOWN = `# 用户使用协议
|
||||||
|
|
||||||
|
欢迎使用本软件(以下简称「本软件」)。在使用本软件前,请您仔细阅读并理解本协议。
|
||||||
|
|
||||||
|
## 1. 软件性质说明
|
||||||
|
|
||||||
|
本软件为通用的视频处理与推流工具,支持用户对自有或合法授权的视频源进行处理与分发。本软件不提供任何第三方平台内容的解析、抓取或分发服务。
|
||||||
|
|
||||||
|
## 2. 用户责任
|
||||||
|
|
||||||
|
用户在使用本软件过程中,应确保:
|
||||||
|
|
||||||
|
* 所使用的视频源具有合法使用权或授权;
|
||||||
|
* 不侵犯任何第三方的著作权、邻接权或其他合法权益;
|
||||||
|
* 不违反相关法律法规及平台规则。
|
||||||
|
|
||||||
|
由用户上传、处理、推流的所有内容,均由用户自行承担全部法律责任。
|
||||||
|
|
||||||
|
## 3. 禁止行为
|
||||||
|
|
||||||
|
用户不得利用本软件从事以下行为:
|
||||||
|
|
||||||
|
* 未经授权抓取、转播、传播第三方平台内容;
|
||||||
|
* 侵犯他人版权、肖像权、隐私权;
|
||||||
|
* 从事违法违规内容传播。
|
||||||
|
|
||||||
|
## 4. 责任限制
|
||||||
|
|
||||||
|
本软件仅提供技术工具服务,不参与用户内容的获取、编辑或分发过程。
|
||||||
|
因用户行为导致的任何法律责任或纠纷,均由用户自行承担,与本软件无关。
|
||||||
|
|
||||||
|
## 5. 权限与服务
|
||||||
|
|
||||||
|
部分功能属于付费服务(如 Pro/VIP),用户购买后仅获得使用权限,不涉及软件所有权转移。
|
||||||
|
|
||||||
|
## 6. 协议变更
|
||||||
|
|
||||||
|
本软件有权根据业务发展对本协议进行调整,并通过软件或网站进行公告。
|
||||||
|
|
||||||
|
如您不同意本协议,请立即停止使用本软件。
|
||||||
|
`
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
o/bundleLibRuntimeToDirDebug
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
o/bundleLibRuntimeToDirDebug
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
o/classes
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
o/classes
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
aarFormatVersion=1.0
|
|
||||||
aarMetadataVersion=1.0
|
|
||||||
minCompileSdk=1
|
|
||||||
minCompileSdkExtension=0
|
|
||||||
minAndroidGradlePluginVersion=1.0.0
|
|
||||||
coreLibraryDesugaringEnabled=false
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
#Wed Mar 25 12:15:20 CDT 2026
|
|
||||||
@@ -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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\mobile\modules\expo-easytier-vpn\android\build\generated\res\resValues\debug"/></dataSet><mergedItems/></merger>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<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:.*:<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:.*:<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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<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:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\Users\admin\Desktop\d2yapp\modules\expo-easytier-vpn\android\src\debug\jniLibs"/></dataSet></merger>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<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:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\Users\admin\Desktop\d2yapp\modules\expo-easytier-vpn\android\src\debug\shaders"/></dataSet></merger>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
R_DEF: Internal format may change without notice
|
|
||||||
local
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0 Warning/Error
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
expo.modules.easytier
|
|
||||||