's'
This commit is contained in:
31
lib/api.ts
Normal file
31
lib/api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { BindPayload } from './types'
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
const b = base.replace(/\/$/, '')
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${b}${p}`
|
||||
}
|
||||
|
||||
export async function apiFetch(
|
||||
bind: BindPayload | null,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
if (!bind) {
|
||||
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
||||
}
|
||||
const headers = new Headers(init?.headers)
|
||||
if (bind.token?.trim()) {
|
||||
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
|
||||
}
|
||||
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
|
||||
}
|
||||
|
||||
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await apiFetch(bind, path, init)
|
||||
if (!res.ok) {
|
||||
const t = await res.text()
|
||||
throw new Error(t || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
36
lib/easyTierVpn.ts
Normal file
36
lib/easyTierVpn.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { requireOptionalNativeModule } from 'expo-modules-core'
|
||||
|
||||
export type EasyTierConfigInput = {
|
||||
networkName: string
|
||||
networkSecret: string
|
||||
peerUrls: string[]
|
||||
}
|
||||
|
||||
type NativeModule = {
|
||||
isAvailable: () => boolean
|
||||
saveAndPrepareTunnel: (configJson: string) => Promise<Record<string, unknown>>
|
||||
checkVpnPermissionGranted: () => Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const Native = requireOptionalNativeModule<NativeModule>('ExpoEasyTierVpn')
|
||||
|
||||
export function isExpoEasyTierVpnAvailable(): boolean {
|
||||
try {
|
||||
return Native?.isAvailable?.() === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAndPrepareTunnel(config: EasyTierConfigInput): Promise<Record<string, unknown>> {
|
||||
if (!Native?.saveAndPrepareTunnel) {
|
||||
return { ok: false, reason: 'native_module_missing' }
|
||||
}
|
||||
return Native.saveAndPrepareTunnel(JSON.stringify(config))
|
||||
}
|
||||
|
||||
export async function checkVpnPermissionGranted(): Promise<boolean> {
|
||||
if (!Native?.checkVpnPermissionGranted) return false
|
||||
const r = await Native.checkVpnPermissionGranted()
|
||||
return r?.granted === true
|
||||
}
|
||||
88
lib/pcTypes.ts
Normal file
88
lib/pcTypes.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/** 与 Electron NetworkDashboard / SystemMetrics 对齐的数据结构 */
|
||||
|
||||
export type SiteInfo = {
|
||||
host: string
|
||||
reachable: boolean
|
||||
latency_ms: number
|
||||
error: string | null
|
||||
resolved_ip: string | null
|
||||
country: string | null
|
||||
country_code: string | null
|
||||
geo_error: string | null
|
||||
}
|
||||
|
||||
export type SpeedBlock = {
|
||||
label: string
|
||||
download_mbps: number | null
|
||||
download_error: string | null
|
||||
download_bytes_sampled: number
|
||||
upload_mbps: number | null
|
||||
upload_error: string | null
|
||||
upload_target: string | null
|
||||
}
|
||||
|
||||
export type NetworkPayload = {
|
||||
google: SiteInfo
|
||||
douyin: SiteInfo
|
||||
routes: { different: boolean | null; note: string }
|
||||
speed: { overseas: SpeedBlock; domestic: SpeedBlock }
|
||||
meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SystemMetricsPayload = {
|
||||
ts?: number
|
||||
cpu_percent?: number | null
|
||||
cpu_count_logical?: number
|
||||
memory?: { percent?: number; used?: number; total?: number; available?: number }
|
||||
swap?: { percent?: number; used?: number; total?: number }
|
||||
disks?: Array<{
|
||||
mountpoint?: string
|
||||
percent?: number
|
||||
free?: number
|
||||
total?: number
|
||||
}>
|
||||
network?: { up_mbps?: number | null; down_mbps?: number | null }
|
||||
ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ClientOverview = {
|
||||
youtubeChannelCount?: number
|
||||
youtubeChannels?: { name: string; keyPreview: string; douyinPreview?: string }[]
|
||||
relayPairingPolicy?: string
|
||||
relayValidationErrors?: string[]
|
||||
relayNotes?: string[]
|
||||
wechat?: {
|
||||
configured?: boolean
|
||||
nickName?: string
|
||||
wcId?: string
|
||||
online?: boolean
|
||||
proxyError?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type RelayLivePayload = {
|
||||
youtubeProcess?: string
|
||||
processStatus?: string
|
||||
processOnline?: boolean
|
||||
relayRows?: Array<{
|
||||
channelId?: string | null
|
||||
channelName?: string
|
||||
anchor?: string
|
||||
douyinHint?: string
|
||||
streamTitle?: string
|
||||
youtubeKeySuffix?: string
|
||||
durationSec?: number | null
|
||||
processOnline?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export type YoutubeIniModel = {
|
||||
keys: string[]
|
||||
activeIndex: number
|
||||
options: Record<string, string>
|
||||
header: string
|
||||
}
|
||||
|
||||
export type GpuCaps = { nvidia_detected: boolean; nvenc_available: boolean }
|
||||
17
lib/scriptUtils.ts
Normal file
17
lib/scriptUtils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function pm2NameFromScript(script: string): string {
|
||||
const base = String(script).split(/[/\\]/).pop() || ''
|
||||
const i = base.lastIndexOf('.')
|
||||
return i > 0 ? base.slice(0, i) : base
|
||||
}
|
||||
|
||||
export function isYoutubeScript(script: string): boolean {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube')
|
||||
}
|
||||
|
||||
export function isTiktokScript(script: string): boolean {
|
||||
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok')
|
||||
}
|
||||
|
||||
export function isObsScript(script: string): boolean {
|
||||
return pm2NameFromScript(script).startsWith('obs')
|
||||
}
|
||||
39
lib/storage.ts
Normal file
39
lib/storage.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { BindPayload } from './types'
|
||||
|
||||
const KEY = 'bind_payload_v1'
|
||||
|
||||
export async function loadBindPayload(): Promise<BindPayload | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(KEY)
|
||||
if (!raw) return null
|
||||
const j = JSON.parse(raw) as BindPayload
|
||||
if (j.v !== 1 || !j.baseUrl) return null
|
||||
return j
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBindPayload(p: BindPayload): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(p))
|
||||
}
|
||||
|
||||
export async function clearBindPayload(): Promise<void> {
|
||||
await AsyncStorage.removeItem(KEY)
|
||||
}
|
||||
|
||||
export const STORAGE_MULTI_CHANNEL_PRO = 'recording_multi_channel_pro'
|
||||
|
||||
export async function getMultiChannelPro(): Promise<boolean> {
|
||||
try {
|
||||
const v = await AsyncStorage.getItem(STORAGE_MULTI_CHANNEL_PRO)
|
||||
return v === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMultiChannelPro(on: boolean): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, on ? '1' : '0')
|
||||
}
|
||||
54
lib/types.ts
Normal file
54
lib/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type EasyTierConfig = {
|
||||
networkName: string
|
||||
networkSecret: string
|
||||
peerUrls: string[]
|
||||
}
|
||||
|
||||
export const DEFAULT_EASYTIER_PEER_URLS = ['tcp://public.easytier.top:11010']
|
||||
|
||||
function normalizePeerUrls(value: unknown): string[] {
|
||||
const seen = new Set<string>()
|
||||
const next = Array.isArray(value) ? value.map(String) : []
|
||||
const normalized = next
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => {
|
||||
if (!item || seen.has(item)) return false
|
||||
seen.add(item)
|
||||
return true
|
||||
})
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_EASYTIER_PEER_URLS]
|
||||
}
|
||||
|
||||
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
|
||||
export type BindPayload = {
|
||||
v: 1
|
||||
baseUrl: string
|
||||
port: number
|
||||
token: string
|
||||
easytier: EasyTierConfig
|
||||
}
|
||||
|
||||
export function parseBindPayload(raw: string): BindPayload | null {
|
||||
try {
|
||||
const j = JSON.parse(raw) as Record<string, unknown>
|
||||
if (j.v !== 1) return null
|
||||
const et = j.easytier as Record<string, unknown> | undefined
|
||||
if (!et || typeof j.baseUrl !== 'string' || typeof j.token !== 'string') return null
|
||||
const networkName = String(et.networkName ?? '').trim()
|
||||
const networkSecret = String(et.networkSecret ?? '').trim()
|
||||
if (!networkName || !networkSecret) return null
|
||||
return {
|
||||
v: 1,
|
||||
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
|
||||
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
|
||||
token: String(j.token),
|
||||
easytier: {
|
||||
networkName,
|
||||
networkSecret,
|
||||
peerUrls: normalizePeerUrls(et.peerUrls),
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
71
lib/youtubeConfigUtils.ts
Normal file
71
lib/youtubeConfigUtils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/** 与 electron youtubeConfigUtils 对齐(远程控制仅用于展示/解析) */
|
||||
|
||||
export function parseYoutubeStreamKeySuffix(iniContent: string): string {
|
||||
for (const line of iniContent.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (t.startsWith('#') || !t) continue
|
||||
const m = t.match(/^(?:key|stream_key)\s*=\s*(.+)$/i)
|
||||
if (m) {
|
||||
const v = m[1].trim().split(/[#;]/)[0].trim()
|
||||
if (v && !v.startsWith('rtmp')) return v.length > 8 ? v.slice(-8) : v
|
||||
if (v.includes('live2/')) {
|
||||
const parts = v.split('/')
|
||||
const last = parts[parts.length - 1] || ''
|
||||
return last.length > 8 ? last.slice(-8) : last
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function extractDouyinLiveUrls(urlIni: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const line of urlIni.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) continue
|
||||
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue
|
||||
const full = t.match(/(https?:\/\/live\.douyin\.com\/[a-zA-Z0-9_-]+)/i)
|
||||
if (full) {
|
||||
out.push(full[1].replace(/^http:\/\//i, 'https://'))
|
||||
continue
|
||||
}
|
||||
const rel = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)
|
||||
if (rel) {
|
||||
out.push(`https://live.douyin.com/${rel[1]}`)
|
||||
continue
|
||||
}
|
||||
const n = t.match(/(\d{8,})/)
|
||||
if (n) out.push(`https://live.douyin.com/${n[1]}`)
|
||||
}
|
||||
return [...new Set(out)].slice(0, 16)
|
||||
}
|
||||
|
||||
export function extractDouyinRoomHints(urlIni: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const line of urlIni.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('#')) continue
|
||||
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue
|
||||
const m = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)
|
||||
if (m) {
|
||||
out.push(m[1])
|
||||
continue
|
||||
}
|
||||
const n = t.match(/(\d{8,})/)
|
||||
out.push(n ? n[1] : t.slice(0, 96))
|
||||
}
|
||||
return [...new Set(out)].slice(0, 12)
|
||||
}
|
||||
|
||||
export function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { room: string; href: string } {
|
||||
const combined = `${urlIni}\n${douyinUrl ?? ''}`
|
||||
const urls = extractDouyinLiveUrls(combined)
|
||||
const hints = extractDouyinRoomHints(combined)
|
||||
const room = hints[0] || '—'
|
||||
const href = urls[0] || (hints[0] ? `https://live.douyin.com/${hints[0]}` : '')
|
||||
return { room, href }
|
||||
}
|
||||
|
||||
export function proChannelPm2Process(channelId: string): string {
|
||||
return `youtube2__${channelId}`
|
||||
}
|
||||
Reference in New Issue
Block a user