Compare commits
7 Commits
| 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": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#2D2AF6"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
@@ -19,7 +19,7 @@
|
||||
"package": "com.douyin.recorder.remote",
|
||||
"usesCleartextTraffic": true,
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#2D2AF6",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
@@ -38,7 +38,7 @@
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。"
|
||||
"cameraPermission": "用于扫描 PC 端绑定二维码(baseUrl/token)。"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { Tabs } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
@@ -8,17 +9,28 @@ import { useClientOnlyValue } from '@/components/useClientOnlyValue'
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
const dark = colorScheme === 'dark'
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
||||
tabBarInactiveTintColor: dark ? 'rgba(255,255,255,0.38)' : 'rgba(0,0,0,0.35)',
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', letterSpacing: 0.2 },
|
||||
tabBarStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: dark ? '#161a22' : '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)',
|
||||
height: Platform.OS === 'ios' ? 88 : 64,
|
||||
paddingTop: 6,
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
||||
backgroundColor: dark ? '#161a22' : '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: dark ? 0.25 : 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
},
|
||||
headerTintColor: Colors[colorScheme].text,
|
||||
headerShown: useClientOnlyValue(false, true),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -6,82 +6,117 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Linking,
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types'
|
||||
import {
|
||||
checkVpnPermissionGranted,
|
||||
isExpoEasyTierVpnAvailable,
|
||||
saveAndPrepareTunnel,
|
||||
} from '@/lib/easyTierVpn'
|
||||
import { parseBindPayload, type BindPayload } from '@/lib/types'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
|
||||
const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0]
|
||||
function normalizeHostInput(hostInput: string): { baseUrl: string; port: number } | null {
|
||||
const s = hostInput.trim()
|
||||
if (!s) return null
|
||||
|
||||
if (/^https?:\/\//i.test(s)) {
|
||||
try {
|
||||
const u = new URL(s)
|
||||
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80))
|
||||
if (!Number.isFinite(port)) return null
|
||||
return { baseUrl: u.origin, port }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const noPath = s.split('/')[0]
|
||||
const i = noPath.lastIndexOf(':')
|
||||
if (i <= 0) return null
|
||||
const host = noPath.slice(0, i).trim()
|
||||
const port = Number(noPath.slice(i + 1))
|
||||
if (!host || !Number.isFinite(port) || port <= 0) return null
|
||||
return { baseUrl: `http://${host}:${port}`, port }
|
||||
}
|
||||
|
||||
export default function BindScreen() {
|
||||
const { bind, setBind, clear, ready } = useBind()
|
||||
const [paste, setPaste] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [hostText, setHostText] = useState('')
|
||||
const [scanOn, setScanOn] = useState(false)
|
||||
const [perm, requestPerm] = useCameraPermissions()
|
||||
const scannedRef = useRef(false)
|
||||
const [etBusy, setEtBusy] = useState(false)
|
||||
|
||||
const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable()
|
||||
useEffect(() => {
|
||||
if (!scanOn) scannedRef.current = false
|
||||
}, [scanOn])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bind?.baseUrl) {
|
||||
setHostText('')
|
||||
return
|
||||
}
|
||||
const v = bind.baseUrl.replace(/^https?:\/\//i, '')
|
||||
setHostText(v)
|
||||
}, [bind?.baseUrl])
|
||||
|
||||
const applyPayload = useCallback(
|
||||
async (p: BindPayload | null) => {
|
||||
if (!p) {
|
||||
setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。')
|
||||
return
|
||||
}
|
||||
async (p: BindPayload) => {
|
||||
await setBind(p)
|
||||
setMsg(
|
||||
etEmbedded
|
||||
? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。'
|
||||
: '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。'
|
||||
)
|
||||
setScanOn(false)
|
||||
scannedRef.current = false
|
||||
setMsg('已导入绑定(含 token)。可在下方修改 IP/域名与端口后再点「保存」。')
|
||||
},
|
||||
[etEmbedded, setBind],
|
||||
[setBind],
|
||||
)
|
||||
|
||||
const onBarCode = useCallback(
|
||||
async ({ data }: { data: string }) => {
|
||||
const onBarcodeScanned = useCallback(
|
||||
(result: { data: string }) => {
|
||||
if (scannedRef.current) return
|
||||
const p = parseBindPayload(result.data)
|
||||
if (!p) {
|
||||
setMsg('二维码内容不是有效的绑定 JSON(需 v1、baseUrl 等字段)。')
|
||||
return
|
||||
}
|
||||
scannedRef.current = true
|
||||
const p = parseBindPayload(data)
|
||||
await applyPayload(p)
|
||||
void (async () => {
|
||||
await applyPayload(p)
|
||||
setScanOn(false)
|
||||
})()
|
||||
},
|
||||
[applyPayload],
|
||||
)
|
||||
|
||||
const onPasteApply = useCallback(async () => {
|
||||
const p = parseBindPayload(paste.trim())
|
||||
await applyPayload(p)
|
||||
}, [paste, applyPayload])
|
||||
|
||||
const copyEtJson = useCallback(async () => {
|
||||
if (!bind) return
|
||||
const chunk = {
|
||||
networkName: bind.easytier.networkName,
|
||||
networkSecret: bind.easytier.networkSecret,
|
||||
peerUrls: bind.easytier.peerUrls,
|
||||
const onPasteJson = useCallback(async () => {
|
||||
const raw = (await Clipboard.getStringAsync()).trim()
|
||||
const p = parseBindPayload(raw)
|
||||
if (!p) {
|
||||
setMsg('剪贴板内容不是有效的绑定 JSON。')
|
||||
return
|
||||
}
|
||||
await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2))
|
||||
setMsg('已复制 EasyTier 组网 JSON。')
|
||||
}, [bind])
|
||||
await applyPayload(p)
|
||||
}, [applyPayload])
|
||||
|
||||
const onSaveHost = useCallback(async () => {
|
||||
const n = normalizeHostInput(hostText)
|
||||
if (!n) {
|
||||
setMsg('请输入 `ip/域名:端口`(或带 http:// 的 baseUrl)。')
|
||||
return
|
||||
}
|
||||
const p: BindPayload = { v: 1, baseUrl: n.baseUrl, port: n.port, token: bind?.token ?? '' }
|
||||
await setBind(p)
|
||||
setMsg('已保存绑定地址;未改动的 token 已保留。')
|
||||
}, [hostText, bind?.token, setBind])
|
||||
|
||||
const onResetHost = useCallback(async () => {
|
||||
setMsg('')
|
||||
Alert.alert('清除配对', '确定要删除当前绑定地址吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '清除', style: 'destructive', onPress: () => void clear() },
|
||||
])
|
||||
}, [clear])
|
||||
|
||||
const copyFull = useCallback(async () => {
|
||||
if (!bind) return
|
||||
@@ -91,52 +126,49 @@ export default function BindScreen() {
|
||||
|
||||
const testConn = useCallback(async () => {
|
||||
if (!bind) return
|
||||
const url = `${bind.baseUrl}/health`
|
||||
setMsg('正在测试 API 连接…')
|
||||
const ac = new AbortController()
|
||||
const t = setTimeout(() => ac.abort(), 15000)
|
||||
try {
|
||||
const r = await fetch(`${bind.baseUrl}/health`, {
|
||||
const r = await fetch(url, {
|
||||
signal: ac.signal,
|
||||
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
|
||||
})
|
||||
const j = await r.json().catch(() => ({}))
|
||||
setMsg('')
|
||||
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
|
||||
} catch (e) {
|
||||
Alert.alert('连接失败', e instanceof Error ? e.message : String(e))
|
||||
const isAbort = e instanceof Error && e.name === 'AbortError'
|
||||
setMsg('')
|
||||
Alert.alert(
|
||||
isAbort ? '连接超时' : '连接失败',
|
||||
isAbort
|
||||
? '15 秒内无响应。请确认手机能访问绑定里的 baseUrl:端口(或域名:端口),PC 上 web2 服务已启动,且防火墙/网络路由允许(换网后可能需要重新扫码或改地址)。'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
const retryEmbeddedTunnel = useCallback(async () => {
|
||||
if (!bind || !etEmbedded) return
|
||||
setEtBusy(true)
|
||||
try {
|
||||
const r = await saveAndPrepareTunnel(bind.easytier)
|
||||
if (r.needsVpnPermission) {
|
||||
setMsg('请在系统弹窗中允许本应用建立 VPN,授权后再点“检查 VPN 权限”。')
|
||||
} else if (r.ok && r.vpnPermissionGranted) {
|
||||
setMsg(
|
||||
'VPN 权限已就绪,EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。'
|
||||
)
|
||||
} else {
|
||||
setMsg(`组网准备结果:${JSON.stringify(r)}`)
|
||||
const openScan = useCallback(async () => {
|
||||
setMsg('')
|
||||
if (Platform.OS === 'web') {
|
||||
setMsg('网页版请使用「从剪贴板导入 JSON」。')
|
||||
return
|
||||
}
|
||||
if (!perm?.granted) {
|
||||
const r = await requestPerm()
|
||||
if (!r.granted) {
|
||||
setMsg('需要相机权限才能扫码。')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setEtBusy(false)
|
||||
}
|
||||
}, [bind, etEmbedded])
|
||||
|
||||
const checkVpn = useCallback(async () => {
|
||||
if (!etEmbedded) return
|
||||
setEtBusy(true)
|
||||
try {
|
||||
const ok = await checkVpnPermissionGranted()
|
||||
setMsg(
|
||||
ok
|
||||
? 'VPN 权限已授予。若仍无法访问 API,请确认当前安装包已包含完整原生 EasyTier 数据面。'
|
||||
: '尚未授予 VPN 权限,请先在系统设置中允许本应用建立 VPN。'
|
||||
)
|
||||
} finally {
|
||||
setEtBusy(false)
|
||||
}
|
||||
}, [etEmbedded])
|
||||
setScanOn(true)
|
||||
}, [perm?.granted, requestPerm])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
@@ -146,26 +178,21 @@ export default function BindScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.h1}>远程配对</Text>
|
||||
<Text style={styles.lead}>
|
||||
在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier
|
||||
参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。
|
||||
PC 端生成二维码后扫码导入;导入后可在下方编辑 `ip/域名:端口` 再保存,token 会保留。也可仅手动填写地址(需自行保证与 PC 上 token 一致)。
|
||||
</Text>
|
||||
|
||||
{!!msg && <Text style={styles.msgBanner}>{msg}</Text>}
|
||||
|
||||
{bind && (
|
||||
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
|
||||
<Text style={styles.cardTitle}>当前已绑定</Text>
|
||||
<Text style={styles.mono}>{bind.baseUrl}</Text>
|
||||
<Text style={styles.mutedSmall}>组网名 · {bind.easytier.networkName}</Text>
|
||||
<Text style={styles.mutedSmall}>引导节点 · {bootstrapPeer}</Text>
|
||||
{bind.easytier.peerUrls.length > 1 && (
|
||||
<Text style={styles.mutedSmall}>额外引导节点 · {bind.easytier.peerUrls.length - 1} 个</Text>
|
||||
)}
|
||||
<Text style={styles.mutedSmall}>token:{bind.token ? '已保存' : '无'}</Text>
|
||||
<View style={styles.row}>
|
||||
<Pressable style={styles.btn} onPress={() => void testConn()}>
|
||||
<Text style={styles.btnText}>测试 API</Text>
|
||||
@@ -182,110 +209,72 @@ export default function BindScreen() {
|
||||
<Text style={styles.btnText}>清除</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{etEmbedded ? (
|
||||
<View style={styles.etRow}>
|
||||
<Pressable
|
||||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
||||
disabled={etBusy}
|
||||
onPress={() => void retryEmbeddedTunnel()}
|
||||
>
|
||||
<Text style={styles.btnSecondaryText}>重新应用组网参数</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
||||
disabled={etBusy}
|
||||
onPress={() => void checkVpn()}
|
||||
>
|
||||
<Text style={styles.btnSecondaryText}>检查 VPN 权限</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.mutedSmall}>
|
||||
{Platform.OS === 'ios'
|
||||
? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。'
|
||||
: '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'}
|
||||
</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
)}
|
||||
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.h2}>扫码或导入 JSON</Text>
|
||||
<Text style={styles.body}>与 PC 端「远程控制」面板中的二维码内容一致(含 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}>
|
||||
<Text style={styles.h2}>说明</Text>
|
||||
<Text style={styles.body}>
|
||||
推流和 FFmpeg 只在 PC 执行;手机只负责保存控制通道地址与 EasyTier 参数。现在桌面端会默认把
|
||||
`tcp://public.easytier.top:11010` 写进 `peerUrls`,减少没有公网 IP 时的发现失败。
|
||||
</Text>
|
||||
<Text style={styles.body}>
|
||||
当前 Android 原生模块已经能保存组网参数并申请 VPN 权限;如果你要完全在 App 内跑 EasyTier
|
||||
数据面,下一步还需要把 EasyTier Android 原生库继续接进来。
|
||||
</Text>
|
||||
<Pressable onPress={() => void Linking.openURL(ET_RELEASE)} style={styles.linkRow}>
|
||||
<Ionicons name="open-outline" size={18} color={theme.accent} />
|
||||
<Text style={styles.link}>EasyTier 官方发布与文档</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.body}>推流、转码在 PC 侧执行;手机保存远程控制用的 baseUrl 与 token。</Text>
|
||||
{bind && (
|
||||
<View style={styles.etActions}>
|
||||
<Pressable style={styles.btnSecondary} onPress={() => void copyEtJson()}>
|
||||
<Text style={styles.btnSecondaryText}>复制组网 JSON</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
|
||||
<Text style={styles.btnSecondaryText}>复制完整绑定 JSON</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.h2}>扫码</Text>
|
||||
{!scanOn ? (
|
||||
<Pressable
|
||||
style={styles.btnPrimary}
|
||||
onPress={async () => {
|
||||
const r = await requestPerm()
|
||||
if (!r.granted) {
|
||||
setMsg('需要相机权限才能扫码。')
|
||||
return
|
||||
}
|
||||
scannedRef.current = false
|
||||
setScanOn(true)
|
||||
}}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={22} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.btnPrimaryText}>打开相机扫描 PC 二维码</Text>
|
||||
</Pressable>
|
||||
) : perm?.granted ? (
|
||||
<View style={styles.camWrap}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
onBarcodeScanned={onBarCode}
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
/>
|
||||
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
|
||||
<Text style={styles.btnPrimaryText}>关闭相机</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.muted}>没有相机权限。</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.h2}>手动粘贴 JSON</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
multiline
|
||||
placeholder="粘贴 PC 端显示的绑定 JSON"
|
||||
placeholderTextColor={theme.muted}
|
||||
value={paste}
|
||||
onChangeText={setPaste}
|
||||
/>
|
||||
<Pressable style={styles.btnPrimary} onPress={() => void onPasteApply()}>
|
||||
<Text style={styles.btnPrimaryText}>解析并保存</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!!msg && <Text style={styles.msg}>{msg}</Text>}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -321,7 +310,6 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
btnDisabled: { opacity: 0.55 },
|
||||
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
|
||||
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
|
||||
btnPrimary: {
|
||||
@@ -331,6 +319,8 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#3d7eff',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 10,
|
||||
flex: 1,
|
||||
minWidth: '45%',
|
||||
},
|
||||
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
btnSecondary: {
|
||||
@@ -346,21 +336,29 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
||||
input: {
|
||||
minHeight: 120,
|
||||
minHeight: 52,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
color: theme.text,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
},
|
||||
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' },
|
||||
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000', marginBottom: 18 },
|
||||
camera: { height: 280, width: '100%' },
|
||||
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
|
||||
linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 },
|
||||
link: { color: theme.accent, fontSize: 14 },
|
||||
msg: { color: theme.success, fontSize: 14, marginTop: 8 },
|
||||
msgBanner: {
|
||||
color: theme.success,
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
marginBottom: 14,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(61,126,255,0.12)',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ function SnapshotNet({ data }: { data: unknown }) {
|
||||
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.snapLine}>
|
||||
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
国内站点 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -196,7 +196,7 @@ export default function DeskScreen() {
|
||||
</Text>
|
||||
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
抖音 {row.douyinHint ?? '—'}
|
||||
源站 {row.douyinHint ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
标题 {row.streamTitle ?? '—'}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useNavigation } from 'expo-router'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -10,10 +13,12 @@ import {
|
||||
Alert,
|
||||
RefreshControl,
|
||||
Linking,
|
||||
Switch,
|
||||
} from 'react-native'
|
||||
import { Picker } from '@react-native-picker/picker'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { useRecorder } from '@/hooks/useRecorder'
|
||||
import { theme } from '@/constants/theme'
|
||||
@@ -23,6 +28,27 @@ import {
|
||||
proChannelPm2Process,
|
||||
} from '@/lib/youtubeConfigUtils'
|
||||
|
||||
function statusTintBackground(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'online':
|
||||
return 'rgba(61, 214, 140, 0.1)'
|
||||
case 'stopped':
|
||||
case 'error':
|
||||
return 'rgba(245, 101, 101, 0.1)'
|
||||
case 'warn':
|
||||
return 'rgba(246, 173, 85, 0.12)'
|
||||
case 'offline':
|
||||
return 'rgba(139, 146, 158, 0.11)'
|
||||
case 'loading':
|
||||
return 'rgba(91, 157, 255, 0.12)'
|
||||
case 'unknown':
|
||||
case 'empty':
|
||||
return 'rgba(91, 157, 255, 0.09)'
|
||||
default:
|
||||
return 'rgba(139, 146, 158, 0.1)'
|
||||
}
|
||||
}
|
||||
|
||||
function ctrlStyle(action: string): object {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
@@ -99,7 +125,7 @@ function ProLiveSwitchGrid({
|
||||
</Pressable>
|
||||
{href ? (
|
||||
<Pressable onPress={() => void Linking.openURL(href)}>
|
||||
<Text style={styles.proLink}>在浏览器打开抖音页</Text>
|
||||
<Text style={styles.proLink}>在浏览器打开源站页</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.proActions}>
|
||||
@@ -177,12 +203,32 @@ function LiveSwitchGrid({
|
||||
}
|
||||
|
||||
export default function RecordScreen() {
|
||||
const navigation = useNavigation()
|
||||
const { bind } = useBind()
|
||||
const { session, openLogin, logout } = useAuth()
|
||||
const r = useRecorder()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
onPress={() => void (session ? logout() : openLogin())}
|
||||
style={{ paddingHorizontal: 12, paddingVertical: 6 }}
|
||||
>
|
||||
<Text style={{ color: theme.accent, fontWeight: '700', fontSize: 14 }}>
|
||||
{session?.record?.email
|
||||
? `${String(session.record.email).split('@')[0]}…`
|
||||
: '登录'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
})
|
||||
}, [navigation, session, openLogin, logout])
|
||||
const unbound = !bind
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
|
||||
const statusBg = useMemo(() => statusTintBackground(r.statusMeta.variant), [r.statusMeta.variant])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (!bind) return
|
||||
@@ -197,7 +243,7 @@ export default function RecordScreen() {
|
||||
<View style={styles.fatalWrap}>
|
||||
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
||||
<Text style={styles.fatalHint}>
|
||||
常见于 WiFi / EasyTier 瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。
|
||||
常见于网络瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。
|
||||
</Text>
|
||||
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
|
||||
<Text style={styles.retryBtnText}>重试加载任务列表</Text>
|
||||
@@ -227,11 +273,6 @@ export default function RecordScreen() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{unbound && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:在「远程」页扫码后,此处会加载任务与实时数据。</Text>
|
||||
</View>
|
||||
)}
|
||||
{bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
@@ -240,17 +281,70 @@ export default function RecordScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<View style={[styles.pickerWrap, unbound && styles.pickerDisabled]}>
|
||||
<Animated.View entering={FadeInDown.duration(420).springify()}>
|
||||
<LinearGradient
|
||||
colors={['rgba(91,157,255,0.14)', 'rgba(20,22,28,0.92)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.toolbarGradient}
|
||||
>
|
||||
<View style={styles.toolbarCard}>
|
||||
<View style={styles.toolbarRow}>
|
||||
<View style={styles.toolbarLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusChip,
|
||||
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
||||
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
||||
</View>
|
||||
<Text style={styles.statusLineText} numberOfLines={1}>
|
||||
{r.statusMeta.line}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||||
<View style={styles.proLabelRow}>
|
||||
<Text style={styles.proLabelZh}>多频道</Text>
|
||||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||||
<Text style={styles.proBadgeText}>PRO</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.proSwitchWrap}>
|
||||
<Switch
|
||||
value={r.multiChannelPro}
|
||||
disabled={unbound}
|
||||
onValueChange={(v) => {
|
||||
if (unbound) return
|
||||
void r.togglePro(v)
|
||||
}}
|
||||
trackColor={{
|
||||
false: 'rgba(255,255,255,0.12)',
|
||||
true: 'rgba(91, 157, 255, 0.45)',
|
||||
}}
|
||||
thumbColor={r.multiChannelPro ? '#7eb6ff' : '#5c6370'}
|
||||
ios_backgroundColor="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
||||
{bind ? (
|
||||
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
||||
<Picker
|
||||
enabled={!unbound && !r.emptyHint}
|
||||
selectedValue={unbound ? '' : r.currentProcess}
|
||||
enabled={!r.emptyHint}
|
||||
selectedValue={r.currentProcess}
|
||||
onValueChange={(v) => r.setCurrentProcess(String(v))}
|
||||
style={styles.picker}
|
||||
dropdownIconColor={theme.text}
|
||||
>
|
||||
{unbound || !r.entries.length ? (
|
||||
<Picker.Item label="(未配对)" value="" color={theme.muted} />
|
||||
{!r.entries.length ? (
|
||||
<Picker.Item label="(无任务)" value="" color={theme.muted} />
|
||||
) : (
|
||||
r.entries.map((e) => (
|
||||
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
|
||||
@@ -258,28 +352,7 @@ export default function RecordScreen() {
|
||||
)}
|
||||
</Picker>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.proRow, unbound && styles.pickerDisabled]}
|
||||
disabled={unbound}
|
||||
onPress={() => {
|
||||
if (unbound) {
|
||||
Alert.alert('未绑定', '配对后可切换多频道 Pro。')
|
||||
return
|
||||
}
|
||||
void r.togglePro(!r.multiChannelPro)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.proLabel}>多频道直播(Pro)</Text>
|
||||
<Text style={[styles.proVal, r.multiChannelPro && { color: theme.accent }]}>
|
||||
{r.multiChannelPro ? '开' : '关'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={[styles.pill, { borderColor: statusColor }]}>
|
||||
<View style={[styles.dot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.pillText, { color: statusColor }]}>{r.statusMeta.line}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{bind && r.emptyHint ? (
|
||||
<View style={styles.emptyCard}>
|
||||
@@ -678,7 +751,94 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 14,
|
||||
},
|
||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||
toolbar: { marginBottom: 12 },
|
||||
toolbarGradient: {
|
||||
marginBottom: 12,
|
||||
borderRadius: 16,
|
||||
padding: 1,
|
||||
},
|
||||
toolbarCard: {
|
||||
borderRadius: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.07)',
|
||||
backgroundColor: theme.bgElevated,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
toolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
},
|
||||
toolbarLeft: { flex: 1, minWidth: 0, marginRight: 6 },
|
||||
statusChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingLeft: 11,
|
||||
borderRadius: 12,
|
||||
borderLeftWidth: 3,
|
||||
borderTopWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.06)',
|
||||
borderRightColor: 'rgba(255,255,255,0.06)',
|
||||
borderBottomColor: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
statusDotOuter: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
borderWidth: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
statusDotInner: { width: 6, height: 6, borderRadius: 3 },
|
||||
statusLineText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: theme.text,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
proCluster: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
gap: 10,
|
||||
paddingLeft: 10,
|
||||
paddingVertical: 4,
|
||||
paddingRight: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(91, 157, 255, 0.06)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(91, 157, 255, 0.18)',
|
||||
},
|
||||
proClusterDisabled: { opacity: 0.45 },
|
||||
proLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
proLabelZh: { fontSize: 13, fontWeight: '600', color: theme.text },
|
||||
proBadge: {
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
proBadgeOn: {
|
||||
backgroundColor: 'rgba(91, 157, 255, 0.22)',
|
||||
borderColor: 'rgba(91, 157, 255, 0.45)',
|
||||
},
|
||||
proBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
color: theme.accent,
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
proSwitchWrap: { transform: [{ scale: 0.92 }] },
|
||||
unpairedText: { fontSize: 15, fontWeight: '600', color: theme.muted },
|
||||
proGrid: { gap: 10 },
|
||||
proSwitchRow: {
|
||||
padding: 12,
|
||||
@@ -712,32 +872,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
pickerDisabled: { opacity: 0.65 },
|
||||
picker: { color: theme.text },
|
||||
proRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
proLabel: { color: theme.text, fontWeight: '600' },
|
||||
proVal: { color: theme.muted, fontWeight: '600' },
|
||||
pill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
marginBottom: 14,
|
||||
},
|
||||
pickerRowBelow: { marginTop: 0 },
|
||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||
pillText: { fontSize: 13, fontWeight: '600' },
|
||||
emptyCard: {
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -246,7 +246,7 @@ export default function NetworkScreen() {
|
||||
|
||||
{!bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 抖音 与测速。</Text>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 国内站点 与测速。</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -268,7 +268,7 @@ export default function NetworkScreen() {
|
||||
onSolution={openSolution}
|
||||
/>
|
||||
<SiteCard
|
||||
title="抖音(国内参考)"
|
||||
title="国内站点(参考)"
|
||||
site={view.douyin}
|
||||
speed={view.speed.domestic}
|
||||
loading={loading && !!bind}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
|
||||
import { View, Text, ScrollView, StyleSheet, RefreshControl, Pressable } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { UserAgreementModal } from '@/components/UserAgreementModal'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { SystemMetricsPayload } from '@/lib/pcTypes'
|
||||
@@ -20,6 +21,7 @@ export default function SystemScreen() {
|
||||
const [data, setData] = useState<SystemMetricsPayload | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [agreementOpen, setAgreementOpen] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!bind) {
|
||||
@@ -147,8 +149,12 @@ export default function SystemScreen() {
|
||||
<View style={styles.cardMuted}>
|
||||
<Text style={styles.cardTitle}>客户端与系统集成</Text>
|
||||
<Text style={styles.muted}>开机启动、路径与 Electron 集成仅在桌面客户端可用。</Text>
|
||||
<Pressable style={styles.agreementRow} onPress={() => setAgreementOpen(true)} hitSlop={8}>
|
||||
<Text style={styles.agreementLink}>用户协议</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<UserAgreementModal visible={agreementOpen} onClose={() => setAgreementOpen(false)} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -205,4 +211,6 @@ const styles = StyleSheet.create({
|
||||
ffSec: { marginTop: 12 },
|
||||
ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' },
|
||||
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
|
||||
agreementRow: { marginTop: 10, alignSelf: 'flex-start' },
|
||||
agreementLink: { fontSize: 12, color: theme.accent, textDecorationLine: 'underline' },
|
||||
})
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { BindProvider } from '@/context/BindContext';
|
||||
import { PocketBaseAuthModal } from '@/components/PocketBaseAuthModal';
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
@@ -20,26 +22,34 @@ export const unstable_settings = {
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
/** 避免部分真机/构建环境下字体加载挂起或失败时永远停在 Expo 默认靶心启动图 */
|
||||
const SPLASH_FALLBACK_MS = 4000;
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
if (error) {
|
||||
console.warn('[expo-font]', error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
void SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
void SplashScreen.hideAsync();
|
||||
}, SPLASH_FALLBACK_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
// 不再在字体未就绪时 return null,否则 hideAsync 后会出现纯白屏且仍无界面
|
||||
return <RootLayoutNav />;
|
||||
}
|
||||
|
||||
@@ -47,12 +57,15 @@ function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<BindProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
</BindProvider>
|
||||
<AuthProvider>
|
||||
<BindProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<PocketBaseAuthModal />
|
||||
</ThemeProvider>
|
||||
</BindProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
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 { Platform } from 'react-native'
|
||||
import { isExpoEasyTierVpnAvailable, saveAndPrepareTunnel } from '@/lib/easyTierVpn'
|
||||
import type { BindPayload } from '@/lib/types'
|
||||
import { clearBindPayload, loadBindPayload, saveBindPayload } from '@/lib/storage'
|
||||
|
||||
@@ -25,24 +23,10 @@ export function BindProvider({ children }: { children: React.ReactNode }) {
|
||||
})()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !bind) return
|
||||
if (Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
|
||||
void saveAndPrepareTunnel(bind.easytier).catch(() => {})
|
||||
}
|
||||
}, [ready, bind])
|
||||
|
||||
const setBind = useCallback(async (p: BindPayload | null) => {
|
||||
setBindState(p)
|
||||
if (p) await saveBindPayload(p)
|
||||
else await clearBindPayload()
|
||||
if (p && Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
|
||||
try {
|
||||
await saveAndPrepareTunnel(p.easytier)
|
||||
} catch {
|
||||
/* 组网内核未就绪或用户未授权 VPN 时不阻断配对 */
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
|
||||
125
electron/agreement.html
Normal file
@@ -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 { Alert } from 'react-native'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch, apiJson } from '@/lib/api'
|
||||
import { reportLiveControlEvent } from '@/lib/liveTelemetry'
|
||||
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
||||
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
||||
@@ -72,6 +74,9 @@ function pickProcessForEntries(filtered: Entry[], multi: boolean): string {
|
||||
|
||||
export function useRecorder() {
|
||||
const { bind } = useBind()
|
||||
const { pbToken, ensureCanControlLive, session } = useAuth()
|
||||
const pbTokRef = useRef(pbToken)
|
||||
pbTokRef.current = pbToken
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [currentProcess, setCurrentProcess] = useState('')
|
||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||
@@ -135,7 +140,7 @@ export function useRecorder() {
|
||||
useEffect(() => {
|
||||
if (!bindKey) return
|
||||
let cancelled = false
|
||||
void apiFetch(bindRef.current!, '/host/gpu_status')
|
||||
void apiFetch(bindRef.current!, '/host/gpu_status', { pbToken: pbTokRef.current })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((j: GpuCaps) => {
|
||||
if (!cancelled)
|
||||
@@ -166,7 +171,7 @@ export function useRecorder() {
|
||||
const loadProcessMonitor = useCallback(async () => {
|
||||
const b = bindRef.current
|
||||
if (!b) throw new Error('尚未绑定 PC')
|
||||
const res = await apiFetch(b, '/process_monitor')
|
||||
const res = await apiFetch(b, '/process_monitor', { pbToken: pbTokRef.current })
|
||||
if (!res.ok) throw new Error(String(res.status))
|
||||
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
||||
const raw: Entry[] = (data.entries || []).map((e) => ({
|
||||
@@ -186,6 +191,7 @@ export function useRecorder() {
|
||||
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||
b,
|
||||
'/relay_pro/channels',
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!d.ok) {
|
||||
setProChannelsError(d.error || '无法加载 Pro 频道')
|
||||
@@ -220,7 +226,7 @@ export function useRecorder() {
|
||||
}
|
||||
setYoutubeStatus('正在将当前配置同步到多频道…')
|
||||
try {
|
||||
const res = await apiFetch(b, '/relay_pro/channels')
|
||||
const res = await apiFetch(b, '/relay_pro/channels', { pbToken: pbTokRef.current })
|
||||
const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string }
|
||||
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
||||
const firstId = d.channels[0].id
|
||||
@@ -231,6 +237,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const kj = (await keyRes.json()) as { error?: string }
|
||||
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
||||
@@ -241,6 +248,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: urlText }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const uj = (await urlRes.json()) as { error?: string }
|
||||
@@ -258,6 +266,7 @@ export function useRecorder() {
|
||||
options: ytIniModel.options,
|
||||
header: ytIniModel.header || '',
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
@@ -282,6 +291,7 @@ export function useRecorder() {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||
const data = (await res.json()) as { content?: string }
|
||||
@@ -310,6 +320,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||
@@ -330,6 +341,7 @@ export function useRecorder() {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
@@ -375,6 +387,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(modelToSave),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
@@ -404,6 +417,7 @@ export function useRecorder() {
|
||||
keys: keysOne,
|
||||
activeIndex: 0,
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
@@ -450,6 +464,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: ed.urlText }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
@@ -469,6 +484,7 @@ export function useRecorder() {
|
||||
const ur = await apiFetch(
|
||||
b,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!ur.ok) {
|
||||
setUrlStatus('读取失败')
|
||||
@@ -518,9 +534,17 @@ export function useRecorder() {
|
||||
}
|
||||
const proc = procForPm2(processOverride)
|
||||
const isStatus = action === 'status'
|
||||
if (!isStatus) setLoadingAction(action)
|
||||
if (!isStatus) {
|
||||
if (action === 'start' || action === 'restart') {
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
}
|
||||
setLoadingAction(action)
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`)
|
||||
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`, {
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
|
||||
@@ -551,6 +575,15 @@ export function useRecorder() {
|
||||
const actionLabel =
|
||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
|
||||
if (action === 'start' || action === 'restart' || action === 'stop') {
|
||||
void reportLiveControlEvent(
|
||||
bindRef.current,
|
||||
pbTokRef.current,
|
||||
session?.record?.id,
|
||||
proc,
|
||||
action,
|
||||
)
|
||||
}
|
||||
const statusProc = processOverride ?? proc
|
||||
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
||||
}
|
||||
@@ -561,18 +594,22 @@ export function useRecorder() {
|
||||
if (!isStatus) setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[bindRef, procForPm2, getEntry],
|
||||
[bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const runProChannelStart = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!channelId) return
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
const pm = proChannelPm2Process(channelId)
|
||||
setLoadingAction('start')
|
||||
try {
|
||||
const saved = await saveProChannelKeysFor(channelId)
|
||||
if (!saved) return
|
||||
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`)
|
||||
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`, {
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
const e = getEntry(pm)
|
||||
@@ -584,6 +621,7 @@ export function useRecorder() {
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
||||
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start')
|
||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
@@ -591,18 +629,22 @@ export function useRecorder() {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const runProChannelRestart = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!channelId) return
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
const pm = proChannelPm2Process(channelId)
|
||||
setLoadingAction('restart')
|
||||
try {
|
||||
const saved = await saveProChannelKeysFor(channelId)
|
||||
if (!saved) return
|
||||
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`)
|
||||
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`, {
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
const e = getEntry(pm)
|
||||
@@ -614,6 +656,7 @@ export function useRecorder() {
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
||||
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart')
|
||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
@@ -621,7 +664,7 @@ export function useRecorder() {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const submitNewChannelDraft = useCallback(async () => {
|
||||
@@ -637,6 +680,7 @@ export function useRecorder() {
|
||||
name: `频道 ${proChannelsList.length + 1}`,
|
||||
youtubeKey: key,
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||
@@ -675,6 +719,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId }),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const d = (await res.json()) as { error?: string; message?: string }
|
||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||
@@ -848,6 +893,7 @@ export function useRecorder() {
|
||||
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||
bind,
|
||||
'/relay_pro/channels',
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (cancelled) return
|
||||
if (!d.ok) {
|
||||
@@ -878,7 +924,7 @@ export function useRecorder() {
|
||||
let cancelled = false
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await apiFetch(bind, '/relay_pro/live_status')
|
||||
const r = await apiFetch(bind, '/relay_pro/live_status', { pbToken: pbTokRef.current })
|
||||
if (!r.ok || cancelled) return
|
||||
const j = (await r.json()) as ProRelayStatusPayload
|
||||
if (!cancelled) setProRelayStatus(j)
|
||||
@@ -910,6 +956,7 @@ export function useRecorder() {
|
||||
const dr = await apiFetch(
|
||||
b,
|
||||
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!dr.ok) continue
|
||||
const cd = (await dr.json()) as {
|
||||
@@ -951,6 +998,7 @@ export function useRecorder() {
|
||||
const ur = await apiFetch(
|
||||
bind,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!ur.ok || cancelled) return
|
||||
const j = (await ur.json()) as { content?: string }
|
||||
|
||||
14
lib/api.ts
@@ -6,22 +6,28 @@ function joinUrl(base: string, path: string): string {
|
||||
return `${b}${p}`
|
||||
}
|
||||
|
||||
export type ApiFetchInit = RequestInit & { pbToken?: string | null }
|
||||
|
||||
export async function apiFetch(
|
||||
bind: BindPayload | null,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
init?: ApiFetchInit,
|
||||
): Promise<Response> {
|
||||
if (!bind) {
|
||||
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
||||
}
|
||||
const headers = new Headers(init?.headers)
|
||||
const { pbToken, ...rest } = init ?? {}
|
||||
const headers = new Headers(rest.headers)
|
||||
if (bind.token?.trim()) {
|
||||
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
|
||||
}
|
||||
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
|
||||
if (pbToken?.trim()) {
|
||||
headers.set('X-PB-Token', pbToken.trim())
|
||||
}
|
||||
return fetch(joinUrl(bind.baseUrl, path), { ...rest, headers })
|
||||
}
|
||||
|
||||
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
|
||||
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: ApiFetchInit): Promise<T> {
|
||||
const res = await apiFetch(bind, path, init)
|
||||
if (!res.ok) {
|
||||
const t = await res.text()
|
||||
|
||||
29
lib/authStorage.ts
Normal file
@@ -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` 输出一致,供扫码导入 */
|
||||
export type BindPayload = {
|
||||
v: 1
|
||||
baseUrl: string
|
||||
port: number
|
||||
token: string
|
||||
easytier: EasyTierConfig
|
||||
token?: string
|
||||
}
|
||||
|
||||
export function parseBindPayload(raw: string): BindPayload | null {
|
||||
try {
|
||||
const j = JSON.parse(raw) as Record<string, unknown>
|
||||
if (j.v !== 1) return null
|
||||
const et = j.easytier as Record<string, unknown> | undefined
|
||||
if (!et || typeof j.baseUrl !== 'string' || typeof j.token !== 'string') return null
|
||||
const networkName = String(et.networkName ?? '').trim()
|
||||
const networkSecret = String(et.networkSecret ?? '').trim()
|
||||
if (!networkName || !networkSecret) return null
|
||||
if (typeof j.baseUrl !== 'string') return null
|
||||
return {
|
||||
v: 1,
|
||||
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
|
||||
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
|
||||
token: String(j.token),
|
||||
easytier: {
|
||||
networkName,
|
||||
networkSecret,
|
||||
peerUrls: normalizePeerUrls(et.peerUrls),
|
||||
},
|
||||
token: j.token == null ? '' : String(j.token),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
|
||||
42
lib/userAgreement.ts
Normal file
@@ -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
|
||||