's'
This commit is contained in:
@@ -6,10 +6,8 @@
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare-easytier": "node scripts/download-easytier.mjs",
|
||||
"prepare-chrome": "node scripts/download-chrome-for-testing.mjs",
|
||||
"dev": "node scripts/download-easytier.mjs && node scripts/download-chrome-for-testing.mjs && electron-vite dev",
|
||||
"build": "node scripts/download-easytier.mjs && node scripts/download-chrome-for-testing.mjs && electron-vite build",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"dist": "pnpm run build && electron-builder --win --publish never"
|
||||
},
|
||||
@@ -20,8 +18,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sql.js": "^1.14.1",
|
||||
"three": "^0.183.2",
|
||||
"adm-zip": "^0.5.16"
|
||||
"three": "^0.183.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3": "^7.4.3",
|
||||
@@ -56,20 +53,6 @@
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "resources/easytier",
|
||||
"to": "easytier",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "resources/chrome-cft",
|
||||
"to": "chrome-cft",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "..",
|
||||
"to": "backend",
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* 下载 Chrome for Testing(Stable / win64)完整目录到 resources/chrome-cft/chrome-win64/,
|
||||
* 与 chrome.exe 同级的 DLL 等依赖一并保留,随安装包分发,开箱即用。
|
||||
* 设置 SKIP_CHROME=1 可跳过(CI 无网环境)。
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import https from 'https'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import AdmZip from 'adm-zip'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const resDir = path.join(root, 'resources', 'chrome-cft')
|
||||
const CFT_JSON_URL =
|
||||
'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'
|
||||
const PLATFORM_KEY = 'win64'
|
||||
const markerName = '.chrome_cft_bundle_ok'
|
||||
|
||||
if (process.env.SKIP_CHROME === '1') {
|
||||
console.log('[chrome-cft] SKIP_CHROME=1,跳过下载')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
function downloadBuffer(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
downloadBuffer(res.headers.location).then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode} ${url}`))
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
res.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function downloadToFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(dest)
|
||||
https
|
||||
.get(url, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
file.close()
|
||||
try {
|
||||
fs.unlinkSync(dest)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
downloadToFile(res.headers.location, dest).then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
file.close()
|
||||
reject(new Error(`HTTP ${res.statusCode}`))
|
||||
return
|
||||
}
|
||||
res.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
.on('error', (e) => {
|
||||
try {
|
||||
file.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(e)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const chromeExe = path.join(resDir, 'chrome-win64', 'chrome.exe')
|
||||
const markerPath = path.join(resDir, markerName)
|
||||
|
||||
fs.mkdirSync(resDir, { recursive: true })
|
||||
|
||||
if (fs.existsSync(chromeExe) && fs.existsSync(markerPath)) {
|
||||
console.log('[chrome-cft] 已存在完整包,跳过:', chromeExe)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log('[chrome-cft] 获取元数据:', CFT_JSON_URL)
|
||||
const metaBuf = await downloadBuffer(CFT_JSON_URL)
|
||||
const doc = JSON.parse(metaBuf.toString('utf-8'))
|
||||
const stable = doc.channels?.Stable
|
||||
const list = stable?.downloads?.chrome ?? []
|
||||
const hit = list.find((x) => x.platform === PLATFORM_KEY)
|
||||
if (!hit?.url) {
|
||||
console.error('[chrome-cft] 元数据中无', PLATFORM_KEY)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const zipPath = path.join(resDir, '_chrome-download.zip')
|
||||
console.log('[chrome-cft] 下载(体积较大):', hit.url)
|
||||
await downloadToFile(hit.url, zipPath)
|
||||
|
||||
console.log('[chrome-cft] 解压中…')
|
||||
const zip = new AdmZip(zipPath)
|
||||
const tmp = path.join(resDir, '_extract_tmp')
|
||||
if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true })
|
||||
fs.mkdirSync(tmp, { recursive: true })
|
||||
zip.extractAllTo(tmp, true)
|
||||
|
||||
const inner = path.join(tmp, 'chrome-win64')
|
||||
if (!fs.existsSync(path.join(inner, 'chrome.exe'))) {
|
||||
console.error('[chrome-cft] zip 内未找到 chrome-win64/chrome.exe')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const destDir = path.join(resDir, 'chrome-win64')
|
||||
if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true })
|
||||
fs.cpSync(inner, destDir, { recursive: true })
|
||||
fs.rmSync(tmp, { recursive: true })
|
||||
|
||||
try {
|
||||
fs.unlinkSync(zipPath)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(resDir, 'NOTICE-ChromeForTesting.txt'),
|
||||
[
|
||||
'本目录包含 Chrome for Testing(Stable / Windows x64)。',
|
||||
'清单来源:' + CFT_JSON_URL,
|
||||
'与 chrome.exe 同目录文件均为官方 zip 原样复制,未修改二进制。',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
)
|
||||
fs.writeFileSync(markerPath, `${stable.version}\n${new Date().toISOString()}\n`, 'utf-8')
|
||||
console.log('[chrome-cft] 完成:', chromeExe)
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 下载官方 EasyTier Windows x64 发行包并解压到 resources/easytier/
|
||||
* 须复制 exe 同目录下全部 DLL 等依赖,否则 easytier-core 会以 0xC0000135(3221225781)退出。
|
||||
* 设置 SKIP_EASYTIER=1 可跳过(CI 无网环境)
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import https from 'https'
|
||||
import { fileURLToPath } from 'url'
|
||||
import AdmZip from 'adm-zip'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const resDir = path.join(root, 'resources', 'easytier')
|
||||
const DEFAULT_URL =
|
||||
process.env.EASYTIER_DOWNLOAD_URL ||
|
||||
'https://github.com/EasyTier/EasyTier/releases/download/v2.4.5/easytier-windows-x86_64-v2.4.5.zip'
|
||||
const exeName = 'easytier-core.exe'
|
||||
const bundleMarker = '.easytier_bundle_ok'
|
||||
|
||||
if (process.env.SKIP_EASYTIER === '1') {
|
||||
console.log('[easytier] SKIP_EASYTIER=1,跳过下载')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
function download(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
download(res.headers.location).then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode} ${url}`))
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
res.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function findExe(dir, name) {
|
||||
if (!fs.existsSync(dir)) return null
|
||||
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, f.name)
|
||||
if (f.isDirectory()) {
|
||||
const r = findExe(p, name)
|
||||
if (r) return r
|
||||
} else if (f.name === name) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fs.mkdirSync(resDir, { recursive: true })
|
||||
const outExe = path.join(resDir, exeName)
|
||||
const markerPath = path.join(resDir, bundleMarker)
|
||||
if (fs.existsSync(outExe) && fs.existsSync(markerPath)) {
|
||||
console.log('[easytier] 已存在完整包,跳过:', outExe)
|
||||
process.exit(0)
|
||||
}
|
||||
if (fs.existsSync(outExe) && !fs.existsSync(markerPath)) {
|
||||
console.log('[easytier] 检测到旧版仅复制了 exe,将重新下载并解压全部依赖文件…')
|
||||
}
|
||||
|
||||
console.log('[easytier] 下载:', DEFAULT_URL)
|
||||
const buf = await download(DEFAULT_URL)
|
||||
const zip = new AdmZip(buf)
|
||||
const tmp = path.join(resDir, '_extract_tmp')
|
||||
if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true })
|
||||
fs.mkdirSync(tmp, { recursive: true })
|
||||
zip.extractAllTo(tmp, true)
|
||||
const found = findExe(tmp, exeName)
|
||||
if (!found) {
|
||||
console.error('[easytier] zip 内未找到', exeName)
|
||||
process.exit(1)
|
||||
}
|
||||
const srcDir = path.dirname(found)
|
||||
for (const name of fs.readdirSync(srcDir)) {
|
||||
const from = path.join(srcDir, name)
|
||||
const to = path.join(resDir, name)
|
||||
if (!fs.statSync(from).isFile()) continue
|
||||
fs.copyFileSync(from, to)
|
||||
}
|
||||
fs.rmSync(tmp, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(resDir, 'NOTICE-EasyTier.txt'),
|
||||
[
|
||||
'EasyTier 以 LGPL-3.0 授权发布。',
|
||||
'源码: https://github.com/EasyTier/EasyTier',
|
||||
'本程序随官方 GitHub Release 附带 easytier-core.exe 及同目录依赖,未修改二进制。',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
)
|
||||
fs.writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf-8')
|
||||
console.log('[easytier] 完成(已复制 zip 内同目录全部文件):', outExe)
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* 1) 安装包内置:构建时 scripts/download-chrome-for-testing.mjs 解压至 resources/chrome-cft/(随 extraResources 分发)
|
||||
* 2) 兜底:无内置时从官方源下载至 userData/cft-chrome/
|
||||
*/
|
||||
import { app } from 'electron'
|
||||
import fs, { createWriteStream } from 'fs'
|
||||
import path from 'path'
|
||||
import { Readable } from 'stream'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import { fileURLToPath } from 'url'
|
||||
import AdmZip from 'adm-zip'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
import { appendClientLog } from './clientDb'
|
||||
|
||||
const CFT_JSON_URL =
|
||||
'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'
|
||||
|
||||
const LOCK_NAME = 'cft-chrome.lock'
|
||||
|
||||
function cftRoot(): string {
|
||||
return path.join(app.getPath('userData'), 'cft-chrome')
|
||||
}
|
||||
|
||||
function fileExists(p: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(p) && fs.statSync(p).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 与官方 zip 内顶层目录名一致,如 chrome-win64、chrome-mac-arm64 */
|
||||
export function getCftPlatformKey(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return process.arch === 'ia32' ? 'win32' : 'win64'
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return process.arch === 'arm64' ? 'mac-arm64' : 'mac-x64'
|
||||
}
|
||||
return 'linux64'
|
||||
}
|
||||
|
||||
function extractFolderName(platformKey: string): string {
|
||||
return `chrome-${platformKey}`
|
||||
}
|
||||
|
||||
/** 解压完成后可执行文件相对 cftRoot 的路径(按官方包结构) */
|
||||
function expectedChromeRelative(platformKey: string): string {
|
||||
const folder = extractFolderName(platformKey)
|
||||
if (platformKey === 'win32' || platformKey === 'win64') {
|
||||
return path.join(folder, 'chrome.exe')
|
||||
}
|
||||
if (platformKey === 'linux64') {
|
||||
return path.join(extractFolderName(platformKey), 'chrome')
|
||||
}
|
||||
if (platformKey === 'mac-arm64') {
|
||||
return path.join(
|
||||
'chrome-mac-arm64',
|
||||
'Google Chrome for Testing.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'Google Chrome for Testing',
|
||||
)
|
||||
}
|
||||
if (platformKey === 'mac-x64') {
|
||||
return path.join(
|
||||
'chrome-mac-x64',
|
||||
'Google Chrome for Testing.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'Google Chrome for Testing',
|
||||
)
|
||||
}
|
||||
return path.join(folder, 'chrome.exe')
|
||||
}
|
||||
|
||||
export function getCftChromePathIfPresent(): string | null {
|
||||
const root = cftRoot()
|
||||
const rel = expectedChromeRelative(getCftPlatformKey())
|
||||
const full = path.join(root, rel)
|
||||
return fileExists(full) ? full : null
|
||||
}
|
||||
|
||||
/** 安装包/开发目录随包分发的 Chrome for Testing(Windows x64),含完整依赖目录 */
|
||||
export function getBundledChromePath(): string | null {
|
||||
if (process.platform !== 'win32') return null
|
||||
const rel = path.join('chrome-win64', 'chrome.exe')
|
||||
if (app.isPackaged) {
|
||||
const p = path.join(process.resourcesPath, 'chrome-cft', rel)
|
||||
return fileExists(p) ? p : null
|
||||
}
|
||||
const dev = path.resolve(__dirname, '../../resources/chrome-cft', rel)
|
||||
return fileExists(dev) ? dev : null
|
||||
}
|
||||
|
||||
type CftJson = {
|
||||
channels?: {
|
||||
Stable?: {
|
||||
version?: string
|
||||
downloads?: {
|
||||
chrome?: Array<{ platform: string; url: string }>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(): Promise<CftJson> {
|
||||
const res = await fetch(CFT_JSON_URL, { redirect: 'follow' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`获取 Chrome for Testing 元数据失败:HTTP ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as CftJson
|
||||
}
|
||||
|
||||
function pickChromeZipUrl(doc: CftJson, platformKey: string): { version: string; url: string } {
|
||||
const stable = doc.channels?.Stable
|
||||
const version = stable?.version?.trim() ?? ''
|
||||
const list = stable?.downloads?.chrome ?? []
|
||||
const hit = list.find((x) => x.platform === platformKey)
|
||||
if (!hit?.url) {
|
||||
throw new Error(`Chrome for Testing 元数据中缺少平台 ${platformKey} 的下载地址。`)
|
||||
}
|
||||
return { version, url: hit.url }
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const res = await fetch(url, { redirect: 'follow' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`下载失败:HTTP ${res.status}`)
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new Error('下载失败:无响应体')
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(dest), { recursive: true })
|
||||
const tmp = `${dest}.part`
|
||||
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(tmp))
|
||||
try {
|
||||
fs.renameSync(tmp, dest)
|
||||
} catch {
|
||||
fs.copyFileSync(tmp, dest)
|
||||
fs.unlinkSync(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
let inFlight: Promise<string | null> | null = null
|
||||
|
||||
/**
|
||||
* 确保 userData 下已有 CFT Chrome;若尚无则下载并解压。失败返回 null。
|
||||
*/
|
||||
export function ensureChromeForTestingInstalled(): Promise<string | null> {
|
||||
const existing = getCftChromePathIfPresent()
|
||||
if (existing) return Promise.resolve(existing)
|
||||
|
||||
if (!inFlight) {
|
||||
inFlight = doInstall().finally(() => {
|
||||
inFlight = null
|
||||
})
|
||||
}
|
||||
return inFlight
|
||||
}
|
||||
|
||||
async function doInstall(): Promise<string | null> {
|
||||
const root = cftRoot()
|
||||
const platformKey = getCftPlatformKey()
|
||||
const lockPath = path.join(root, LOCK_NAME)
|
||||
const zipPath = path.join(root, `chrome-${platformKey}.zip`)
|
||||
const versionPath = path.join(root, 'version.txt')
|
||||
|
||||
try {
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(lockPath, `${Date.now()}\n`, 'utf-8')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
appendClientLog(
|
||||
'info',
|
||||
'main',
|
||||
'未找到内置 Chrome 与系统安装,正从 Chrome for Testing 拉取备用副本至用户目录(体积较大)…',
|
||||
)
|
||||
|
||||
try {
|
||||
const doc = await fetchJson()
|
||||
const { version, url } = pickChromeZipUrl(doc, platformKey)
|
||||
appendClientLog('info', 'main', `Chrome for Testing 版本:${version}`)
|
||||
|
||||
await downloadFile(url, zipPath)
|
||||
|
||||
appendClientLog('info', 'main', '下载完成,正在解压…')
|
||||
const zip = new AdmZip(zipPath)
|
||||
zip.extractAllTo(root, true)
|
||||
|
||||
try {
|
||||
fs.unlinkSync(zipPath)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const exe = path.join(root, expectedChromeRelative(platformKey))
|
||||
if (!fileExists(exe)) {
|
||||
appendClientLog('error', 'main', `解压后未找到 Chrome:${exe}`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(versionPath, `${version}\n`, 'utf-8')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
appendClientLog('info', 'main', `Chrome for Testing 已就绪:${exe}`)
|
||||
return exe
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
appendClientLog('error', 'main', `自动安装 Chrome for Testing 失败:${msg}`)
|
||||
try {
|
||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* 内置 EasyTier(Windows):随应用分发 easytier-core.exe,自动生成组网密钥,无需用户单独安装。
|
||||
* 二进制来源:https://github.com/EasyTier/EasyTier (LGPL-3.0)
|
||||
*/
|
||||
import { app } from 'electron'
|
||||
import { execFile, spawn, type ChildProcess } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export type EasyTierNetworkConfig = {
|
||||
networkName: string
|
||||
networkSecret: string
|
||||
peerUrls: string[]
|
||||
}
|
||||
|
||||
let easytierChild: ChildProcess | null = null
|
||||
let easytierLastError = ''
|
||||
|
||||
/** Windows 进程退出码(含 NTSTATUS)可读说明 */
|
||||
function formatWindowsExitHint(code: number): string {
|
||||
const u = code >>> 0
|
||||
if (u === 0xc0000135) {
|
||||
return ' — 缺少 DLL(常见:官方 zip 内依赖未与 exe 同目录;开发环境请删除 electron/resources/easytier 后重新执行 pnpm run prepare-easytier)。也可能是未安装 VC++ 运行库。'
|
||||
}
|
||||
if (u === 0xc000007b) {
|
||||
return ' — 无效映像(32/64 位与系统不符或文件损坏)。'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function getBundledEasyTierExePath(): string | null {
|
||||
if (process.platform !== 'win32') return null
|
||||
const exe = 'easytier-core.exe'
|
||||
if (app.isPackaged) {
|
||||
const p = path.join(process.resourcesPath, 'easytier', exe)
|
||||
return fs.existsSync(p) ? p : null
|
||||
}
|
||||
const dev = path.resolve(__dirname, '../../resources/easytier', exe)
|
||||
return fs.existsSync(dev) ? dev : null
|
||||
}
|
||||
|
||||
/** 与 easytier-core 同目录分发的 CLI,用于 peer list 等工作台统计 */
|
||||
export function getBundledEasyTierCliPath(): string | null {
|
||||
if (process.platform !== 'win32') return null
|
||||
const exe = 'easytier-cli.exe'
|
||||
if (app.isPackaged) {
|
||||
const p = path.join(process.resourcesPath, 'easytier', exe)
|
||||
return fs.existsSync(p) ? p : null
|
||||
}
|
||||
const dev = path.resolve(__dirname, '../../resources/easytier', exe)
|
||||
return fs.existsSync(dev) ? dev : null
|
||||
}
|
||||
|
||||
export type EasyTierPeerStats = {
|
||||
ok: boolean
|
||||
error?: string
|
||||
totalPeers?: number
|
||||
onlinePeers?: number
|
||||
raw?: string
|
||||
}
|
||||
|
||||
function parsePeerListOutput(text: string): { totalPeers: number; onlinePeers: number } {
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
const ipRe = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g
|
||||
let withIp = 0
|
||||
let online = 0
|
||||
for (const line of lines) {
|
||||
if (/^[-=│├└┌┐┘┬┴┼]+$/.test(line)) continue
|
||||
if (/^peer\s|^id\s|^hostname\s/i.test(line) && line.length < 100) continue
|
||||
const ips = line.match(ipRe)
|
||||
if (!ips) continue
|
||||
withIp += 1
|
||||
const low = line.toLowerCase()
|
||||
if (!/offline|unreachable|disconnected|lost|timeout/.test(low)) online += 1
|
||||
}
|
||||
if (withIp === 0) {
|
||||
const guess = lines.filter((l) => l.length > 12 && !/^error|^usage/i.test(l)).length
|
||||
const n = Math.max(0, guess > 0 ? guess - 1 : 0)
|
||||
return { totalPeers: n, onlinePeers: n }
|
||||
}
|
||||
return { totalPeers: withIp, onlinePeers: online > 0 ? online : withIp }
|
||||
}
|
||||
|
||||
/** 调用 easytier-cli peer list;需 easytier-core 已运行且 RPC 端口可达 */
|
||||
export async function queryEasyTierPeerStats(): Promise<EasyTierPeerStats> {
|
||||
const cli = getBundledEasyTierCliPath()
|
||||
if (!cli) return { ok: false, error: '未找到内置 easytier-cli.exe' }
|
||||
if (!getEasyTierRuntimeState().running) {
|
||||
return { ok: false, error: 'easytier-core 未运行', totalPeers: 0, onlinePeers: 0 }
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(cli, ['peer', 'list'], {
|
||||
cwd: path.dirname(cli),
|
||||
timeout: 5000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
})
|
||||
const raw = `${stdout || ''}${stderr || ''}`
|
||||
const { totalPeers, onlinePeers } = parsePeerListOutput(raw)
|
||||
return { ok: true, totalPeers, onlinePeers, raw: raw.slice(0, 6000) }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return { ok: false, error: msg, totalPeers: 0, onlinePeers: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export function readEasyTierNetworkName(backendRoot: string): string {
|
||||
const p = path.join(backendRoot, 'config', 'easytier_network.json')
|
||||
if (!fs.existsSync(p)) return ''
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
|
||||
return String(raw.networkName ?? '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOrCreateNetworkConfig(backendRoot: string): EasyTierNetworkConfig {
|
||||
const p = path.join(backendRoot, 'config', 'easytier_network.json')
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true })
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
|
||||
return {
|
||||
networkName: String(raw.networkName ?? ''),
|
||||
networkSecret: String(raw.networkSecret ?? ''),
|
||||
peerUrls: Array.isArray(raw.peerUrls) ? raw.peerUrls.map(String) : [],
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
const cfg: EasyTierNetworkConfig = {
|
||||
networkName: `lr_${crypto.randomBytes(6).toString('hex')}`,
|
||||
networkSecret: crypto.randomBytes(24).toString('hex'),
|
||||
peerUrls: [],
|
||||
}
|
||||
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf-8')
|
||||
return cfg
|
||||
}
|
||||
|
||||
export function getEasyTierRuntimeState(): {
|
||||
bundled: boolean
|
||||
running: boolean
|
||||
lastError: string
|
||||
} {
|
||||
const bundled = !!getBundledEasyTierExePath()
|
||||
const running = !!easytierChild && !easytierChild.killed
|
||||
return { bundled, running, lastError: easytierLastError }
|
||||
}
|
||||
|
||||
export function startBundledEasyTier(backendRoot: string): void {
|
||||
easytierLastError = ''
|
||||
const exe = getBundledEasyTierExePath()
|
||||
if (!exe) {
|
||||
easytierLastError = '未找到内置 easytier-core.exe(开发环境可执行 pnpm run prepare-easytier)'
|
||||
return
|
||||
}
|
||||
if (easytierChild && !easytierChild.killed) {
|
||||
return
|
||||
}
|
||||
const cfg = loadOrCreateNetworkConfig(backendRoot)
|
||||
const args = ['-d', '--network-name', cfg.networkName, '--network-secret', cfg.networkSecret]
|
||||
for (const u of cfg.peerUrls) {
|
||||
const t = u.trim()
|
||||
if (t) args.push('-p', t)
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = spawn(exe, args, {
|
||||
cwd: path.dirname(exe),
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
})
|
||||
} catch (e) {
|
||||
easytierLastError = e instanceof Error ? e.message : String(e)
|
||||
easytierChild = null
|
||||
return
|
||||
}
|
||||
easytierChild = child
|
||||
const appendErr = (chunk: Buffer): void => {
|
||||
const s = chunk.toString()
|
||||
if (s.length < 2000) easytierLastError = (easytierLastError + s).slice(-2000)
|
||||
}
|
||||
child.stderr?.on('data', appendErr)
|
||||
child.stdout?.on('data', appendErr)
|
||||
child.on('error', (err) => {
|
||||
easytierLastError = err.message
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
const hint = formatWindowsExitHint(code)
|
||||
easytierLastError = `easytier-core 已退出(代码 ${code})${hint}${easytierLastError ? `\n${easytierLastError}` : ''}`
|
||||
}
|
||||
easytierChild = null
|
||||
})
|
||||
}
|
||||
|
||||
export function stopBundledEasyTier(): void {
|
||||
if (!easytierChild || easytierChild.killed) {
|
||||
easytierChild = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/PID', String(easytierChild.pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
} else {
|
||||
easytierChild.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
easytierChild = null
|
||||
}
|
||||
@@ -8,14 +8,6 @@ import crypto from 'crypto'
|
||||
import { networkInterfaces } from 'os'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import {
|
||||
getEasyTierRuntimeState,
|
||||
loadOrCreateNetworkConfig,
|
||||
queryEasyTierPeerStats,
|
||||
readEasyTierNetworkName,
|
||||
startBundledEasyTier,
|
||||
stopBundledEasyTier,
|
||||
} from './easytier'
|
||||
import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar'
|
||||
import {
|
||||
appendClientLog,
|
||||
@@ -45,7 +37,7 @@ const __dirname = path.dirname(__filename)
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const PORT = Number(process.env.WEB2_PORT || 8001)
|
||||
/** uvicorn 监听地址;0.0.0.0 时建议配置 config/web2_api_token.txt 供远程/EasyTier 访问 */
|
||||
/** uvicorn 监听地址;0.0.0.0 时建议配置 config/web2_api_token.txt 供远程访问 */
|
||||
let web2Host = '127.0.0.1'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
@@ -57,48 +49,32 @@ let _pythonExe: string | null = null
|
||||
let pendingNav: string | null = null
|
||||
|
||||
/**
|
||||
* 供扫码绑定 baseUrl:优先虚拟网卡(EasyTier 等),其次 10.x,再补充 Wi‑Fi/以太网等局域网 IPv4,
|
||||
* 避免仅有虚拟网时列表为空、用户无处选 IP。
|
||||
* 供扫码绑定 baseUrl:优先 Wi‑Fi/以太网等局域网 IPv4,排除常见虚拟网卡名。
|
||||
*/
|
||||
function collectEasyTierCandidateIPs(): { address: string; name: string }[] {
|
||||
function collectLanCandidateIPs(): { address: string; name: string }[] {
|
||||
const nets = networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const out: { address: string; name: string }[] = []
|
||||
const nameMatch = /easytier|wintun|tun|tap|netbird|wireguard|tailscale|zerotier|sing-tun|nordlynx/i
|
||||
const preferred: { address: string; name: string }[] = []
|
||||
const other: { address: string; name: string }[] = []
|
||||
|
||||
const preferredRe = /(wlan|wi-?fi|wifi|wireless)/i
|
||||
const excludedRe = /(easytier|wintun|tap|tun|netbird|wireguard|tailscale|zerotier|sing-tun|nordlynx|docker|vmware|virtualbox|vbox|venet|virtual ethernet|\bve\b)/i
|
||||
|
||||
for (const [name, addrs] of Object.entries(nets)) {
|
||||
if (!addrs) continue
|
||||
for (const a of addrs) {
|
||||
if (a.family !== 'IPv4' || a.internal) continue
|
||||
if (nameMatch.test(name) && !seen.has(a.address)) {
|
||||
seen.add(a.address)
|
||||
out.push({ address: a.address, name })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.length === 0) {
|
||||
for (const [name, addrs] of Object.entries(nets)) {
|
||||
if (!addrs) continue
|
||||
for (const a of addrs) {
|
||||
if (a.family !== 'IPv4' || a.internal) continue
|
||||
if (a.address.startsWith('10.') && !seen.has(a.address)) {
|
||||
seen.add(a.address)
|
||||
out.push({ address: a.address, name: `${name}(10.x,可能含 EasyTier)` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, addrs] of Object.entries(nets)) {
|
||||
if (!addrs) continue
|
||||
if (excludedRe.test(name)) continue
|
||||
for (const a of addrs) {
|
||||
if (a.family !== 'IPv4' || a.internal) continue
|
||||
if (a.address === '127.0.0.1') continue
|
||||
if (seen.has(a.address)) continue
|
||||
seen.add(a.address)
|
||||
out.push({ address: a.address, name: `${name}(局域网)` })
|
||||
const item = { address: a.address, name: name || 'network' }
|
||||
if (preferredRe.test(name)) preferred.push(item)
|
||||
else other.push(item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
if (preferred.length) return preferred
|
||||
return other
|
||||
}
|
||||
|
||||
async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
@@ -562,9 +538,6 @@ if (!gotLock) {
|
||||
return { ok: true as const }
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('easytier:get-peer-stats')
|
||||
ipcMain.handle('easytier:get-peer-stats', () => queryEasyTierPeerStats())
|
||||
|
||||
ipcMain.removeHandler('youtube-studio:ensure')
|
||||
ipcMain.handle('youtube-studio:ensure', async (_evt, suffix: unknown) => {
|
||||
await ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined)
|
||||
@@ -584,28 +557,19 @@ if (!gotLock) {
|
||||
ipcMain.removeHandler('remote:get-status')
|
||||
ipcMain.handle('remote:get-status', () => {
|
||||
const bindHost = resolveWeb2Host()
|
||||
const et = getEasyTierRuntimeState()
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const easytierNetworkName = readEasyTierNetworkName(br)
|
||||
return {
|
||||
bindHost,
|
||||
remoteEnabled: bindHost === '0.0.0.0',
|
||||
port: PORT,
|
||||
hasToken: !!resolveWeb2ApiTokenForChild(),
|
||||
candidates: collectEasyTierCandidateIPs(),
|
||||
candidates: collectLanCandidateIPs(),
|
||||
envHostOverride: !!process.env.WEB2_HOST?.trim(),
|
||||
easytierBundled: et.bundled,
|
||||
easytierRunning: et.running,
|
||||
easytierLastError: et.lastError,
|
||||
easytierNetworkName,
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('remote:get-bind-payload')
|
||||
ipcMain.handle('remote:get-bind-payload', (_evt, selectedIp: unknown) => {
|
||||
if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const cfg = loadOrCreateNetworkConfig(br)
|
||||
const token = resolveWeb2ApiTokenForChild() ?? ''
|
||||
const ip = selectedIp.trim()
|
||||
return {
|
||||
@@ -613,11 +577,6 @@ if (!gotLock) {
|
||||
baseUrl: `http://${ip}:${PORT}`,
|
||||
port: PORT,
|
||||
token,
|
||||
easytier: {
|
||||
networkName: cfg.networkName,
|
||||
networkSecret: cfg.networkSecret,
|
||||
peerUrls: cfg.peerUrls,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -640,7 +599,6 @@ if (!gotLock) {
|
||||
fs.mkdirSync(cfgDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(cfgDir, 'web2_bind_host.txt'), enabled ? '0.0.0.0' : '127.0.0.1', 'utf-8')
|
||||
const r = await restartWeb2Backend()
|
||||
if (r.ok && enabled) startBundledEasyTier(br)
|
||||
return r
|
||||
})
|
||||
|
||||
@@ -652,7 +610,6 @@ if (!gotLock) {
|
||||
const tok = crypto.randomBytes(24).toString('hex')
|
||||
fs.writeFileSync(path.join(cfgDir, 'web2_api_token.txt'), tok, 'utf-8')
|
||||
const r = await restartWeb2Backend()
|
||||
if (r.ok) startBundledEasyTier(br)
|
||||
return r
|
||||
})
|
||||
|
||||
@@ -686,8 +643,6 @@ if (!gotLock) {
|
||||
return
|
||||
}
|
||||
|
||||
startBundledEasyTier(backendRoot)
|
||||
|
||||
appendClientLog('info', 'main', '本地服务已连接,准备打开界面')
|
||||
|
||||
await createWindow()
|
||||
@@ -698,7 +653,6 @@ if (!gotLock) {
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
stopBundledEasyTier()
|
||||
killPythonTree()
|
||||
app.quit()
|
||||
})
|
||||
@@ -706,7 +660,6 @@ if (!gotLock) {
|
||||
app.on('before-quit', () => {
|
||||
appendClientLog('info', 'main', '应用退出')
|
||||
flushClientDb()
|
||||
stopBundledEasyTier()
|
||||
killPythonTree()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,9 +25,8 @@ contextBridge.exposeInMainWorld('workspace', {
|
||||
getDashboard: () => ipcRenderer.invoke('workspace:get-dashboard'),
|
||||
})
|
||||
|
||||
/** 工作台 EasyTier;YouTube 后台在系统浏览器打开 */
|
||||
/** YouTube 后台在系统浏览器打开 */
|
||||
contextBridge.exposeInMainWorld('deskBridge', {
|
||||
getEasyTierPeers: () => ipcRenderer.invoke('easytier:get-peer-stats'),
|
||||
ensureYoutubeStudio: (streamKeySuffix?: string) =>
|
||||
ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''),
|
||||
})
|
||||
|
||||
@@ -9,10 +9,6 @@ type RemoteStatus = {
|
||||
hasToken: boolean
|
||||
candidates: { address: string; name: string }[]
|
||||
envHostOverride: boolean
|
||||
easytierBundled: boolean
|
||||
easytierRunning: boolean
|
||||
easytierLastError: string
|
||||
easytierNetworkName: string
|
||||
}
|
||||
|
||||
type RemoteBindingApi = {
|
||||
@@ -22,7 +18,7 @@ type RemoteBindingApi = {
|
||||
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
/** React Native 扫码:JSON 含 baseUrl、token、easytier(组网名/密钥/可选 peer) */
|
||||
/** 远程控制:候选 IP、绑定 JSON、二维码(供手机扫码);手机端仍可改地址后保存 */
|
||||
export default function RemoteBinding() {
|
||||
const rb = (typeof window !== 'undefined'
|
||||
? (window as Window & { remoteBinding?: RemoteBindingApi }).remoteBinding
|
||||
@@ -32,12 +28,8 @@ export default function RemoteBinding() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [selectedIp, setSelectedIp] = useState('')
|
||||
const [qrImg, setQrImg] = useState<string | null>(null)
|
||||
const [bindPayload, setBindPayload] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
const pickBindIp = useCallback((): string => {
|
||||
return selectedIp.trim()
|
||||
}, [selectedIp])
|
||||
const [payloadJson, setPayloadJson] = useState('')
|
||||
const [qrDataUrl, setQrDataUrl] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!rb?.getStatus) {
|
||||
@@ -66,7 +58,6 @@ export default function RemoteBinding() {
|
||||
|
||||
const autoEnableRemoteOnce = useRef(false)
|
||||
|
||||
/** 默认开启局域网/虚拟网访问,且界面不允许关闭 */
|
||||
useEffect(() => {
|
||||
if (!rb?.setRemoteEnabled || !status) return
|
||||
if (status.envHostOverride) return
|
||||
@@ -91,40 +82,51 @@ export default function RemoteBinding() {
|
||||
}, [rb, status?.remoteEnabled, status?.envHostOverride, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.candidates?.length) return
|
||||
setSelectedIp((prev) => {
|
||||
if (prev && status.candidates.some((c) => c.address === prev)) return prev
|
||||
return status.candidates[0].address
|
||||
})
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.remoteEnabled || !rb?.getBindPayload) {
|
||||
setBindPayload(null)
|
||||
const list = status?.candidates ?? []
|
||||
if (!list.length) {
|
||||
setSelectedIp('')
|
||||
return
|
||||
}
|
||||
const ip = pickBindIp()
|
||||
if (!ip) {
|
||||
setBindPayload(null)
|
||||
setSelectedIp((prev) => {
|
||||
if (prev && list.some((c) => c.address === prev)) return prev
|
||||
return list[0].address
|
||||
})
|
||||
}, [status?.candidates])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rb?.getBindPayload || !selectedIp.trim()) {
|
||||
setPayloadJson('')
|
||||
setQrDataUrl('')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void rb.getBindPayload(ip).then((p) => {
|
||||
if (!cancelled) setBindPayload(p)
|
||||
})
|
||||
void (async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const p = await rb.getBindPayload(selectedIp.trim())
|
||||
if (cancelled || !p) {
|
||||
setPayloadJson('')
|
||||
setQrDataUrl('')
|
||||
return
|
||||
}
|
||||
const json = JSON.stringify(p)
|
||||
setPayloadJson(json)
|
||||
const url = await QRCode.toDataURL(json, { width: 220, margin: 1, errorCorrectionLevel: 'M' })
|
||||
if (!cancelled) setQrDataUrl(url)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setPayloadJson('')
|
||||
setQrDataUrl('')
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [status?.remoteEnabled, rb, status?.hasToken, pickBindIp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bindPayload) {
|
||||
setQrImg(null)
|
||||
return
|
||||
}
|
||||
const json = JSON.stringify(bindPayload)
|
||||
void QRCode.toDataURL(json, { margin: 2, width: 224 }).then(setQrImg).catch(() => setQrImg(null))
|
||||
}, [bindPayload])
|
||||
}, [rb, selectedIp, status?.port])
|
||||
|
||||
const onGenerateToken = async () => {
|
||||
if (!rb?.generateToken) return
|
||||
@@ -143,30 +145,6 @@ export default function RemoteBinding() {
|
||||
}
|
||||
}
|
||||
|
||||
const onGenerateQr = async () => {
|
||||
if (!rb?.getBindPayload) return
|
||||
if (!status?.remoteEnabled) {
|
||||
setMsg('请等待局域网 / 虚拟网访问已开启并生效后再试。')
|
||||
return
|
||||
}
|
||||
const ip = pickBindIp()
|
||||
if (!ip) {
|
||||
const hasList = !!status.candidates?.length
|
||||
setMsg(hasList ? '请从上方选择本机可访问的 IP。' : '未检测到本机局域网 IP,请检查网卡与虚拟网。')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const p = await rb.getBindPayload(ip)
|
||||
setBindPayload(p)
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!rb?.getStatus) {
|
||||
return (
|
||||
<section className="card card--remote">
|
||||
@@ -176,6 +154,7 @@ export default function RemoteBinding() {
|
||||
}
|
||||
|
||||
const s = status
|
||||
const candidates = s?.candidates ?? []
|
||||
|
||||
return (
|
||||
<section className="card card--remote" aria-label="远程控制">
|
||||
@@ -186,84 +165,52 @@ export default function RemoteBinding() {
|
||||
)}
|
||||
|
||||
<div className="remote-row">
|
||||
<label className="remote-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="remote-toggle-input"
|
||||
checked={!!status?.remoteEnabled}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
<span>允许局域网 / 虚拟网访问(监听 0.0.0.0,默认开启且不可关闭)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => void onGenerateToken()}>
|
||||
生成 API 密钥
|
||||
</button>
|
||||
<span className={`remote-token-badge ${status?.hasToken ? 'on' : 'off'}`}>
|
||||
{status?.hasToken ? '已配置密钥' : '未配置密钥(远程时强烈建议)'}
|
||||
<span
|
||||
className={`remote-token-badge ${status?.hasToken ? 'on' : 'off'}`}
|
||||
title="API 是否已配置 token(config/web2_api_token.txt 或环境变量)"
|
||||
>
|
||||
API Token:{status?.hasToken ? '已配置' : '未配置'}
|
||||
</span>
|
||||
<button type="button" className="btn btn--sm" disabled={busy} onClick={() => void onGenerateToken()}>
|
||||
生成 / 轮换 Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<label className="field-label" htmlFor="remoteEtIp">
|
||||
本机候选 IP(虚拟网 / 局域网)
|
||||
<label htmlFor="remote-bind-ip" className="remote-toggle-label">
|
||||
绑定到本机地址
|
||||
</label>
|
||||
<select
|
||||
id="remoteEtIp"
|
||||
className="select-process"
|
||||
id="remote-bind-ip"
|
||||
className="wc-proxy-param"
|
||||
value={selectedIp}
|
||||
disabled={busy || !candidates.length}
|
||||
onChange={(e) => setSelectedIp(e.target.value)}
|
||||
disabled={!status?.candidates?.length}
|
||||
>
|
||||
{(status?.candidates?.length
|
||||
? status.candidates
|
||||
: [{ address: '', name: '(暂无候选 IP)' }]
|
||||
).map((c) => (
|
||||
<option key={c.address || 'none'} value={c.address} disabled={!c.address}>
|
||||
{c.address ? `${c.address} — ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
{!candidates.length ? (
|
||||
<option value="">未检测到候选地址</option>
|
||||
) : (
|
||||
candidates.map((c) => (
|
||||
<option key={c.address} value={c.address}>
|
||||
{c.address} {c.name ? `(${c.name})` : ''}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<span className="remote-qr-hint">端口:{status?.port ?? '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="remote-row">
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void onGenerateQr()}>
|
||||
生成绑定二维码
|
||||
</button>
|
||||
<span className="remote-qr-hint">供 React Native 扫码;需已开启「允许远程」并填写可访问的 IP。</span>
|
||||
</div>
|
||||
|
||||
{!s?.remoteEnabled && (
|
||||
<p className="remote-qr-hint">正在或等待开启局域网访问;生效后虚拟网内手机可访问本机 API 并扫码绑定。</p>
|
||||
)}
|
||||
|
||||
{s?.remoteEnabled && bindPayload && qrImg && (
|
||||
{payloadJson && qrDataUrl && (
|
||||
<div className="remote-qr-block">
|
||||
<p className="remote-qr-hint">
|
||||
React Native 扫码解析 JSON:含 <code className="remote-code">baseUrl</code>、<code className="remote-code">token</code>、
|
||||
<code className="remote-code">easytier</code>(与内置客户端使用相同组网名与密钥)。勿外传。
|
||||
</p>
|
||||
<img src={qrImg} alt="绑定信息二维码" className="remote-qr-img" width={224} height={224} />
|
||||
<p className="remote-qr-hint">用手机 App 扫描下方二维码导入绑定;导入后可在 App 内修改 IP/域名与端口再保存。</p>
|
||||
<img src={qrDataUrl} alt="绑定二维码" className="remote-qr-img" width={236} height={236} />
|
||||
<pre className="remote-qr-json" tabIndex={0}>
|
||||
{JSON.stringify(bindPayload, null, 2)}
|
||||
{payloadJson}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void navigator.clipboard.writeText(JSON.stringify(bindPayload))}
|
||||
>
|
||||
复制 JSON
|
||||
</button>
|
||||
<p className="remote-qr-hint">内容与二维码一致(v1 JSON,含 baseUrl / port / token)。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s?.remoteEnabled && !bindPayload && (
|
||||
<p className="remote-qr-hint">选择局域网 IP 后点击「生成绑定二维码」。</p>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<p className="remote-error" role="alert">
|
||||
{msg}
|
||||
|
||||
@@ -30,13 +30,6 @@ type ClientOverview = {
|
||||
}
|
||||
}
|
||||
|
||||
type EasyTierPeerPayload = {
|
||||
ok?: boolean
|
||||
error?: string
|
||||
totalPeers?: number
|
||||
onlinePeers?: number
|
||||
}
|
||||
|
||||
type RelayLivePayload = {
|
||||
youtubeProcess?: string
|
||||
processStatus?: string
|
||||
@@ -115,7 +108,6 @@ export default function WorkspaceDashboard() {
|
||||
const ws = getWorkspace()
|
||||
const [data, setData] = useState<WorkspaceDbSnapshot | null>(null)
|
||||
const [overview, setOverview] = useState<ClientOverview | null>(null)
|
||||
const [peerStats, setPeerStats] = useState<EasyTierPeerPayload | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [proMode, setProMode] = useState(false)
|
||||
const [proLive, setProLive] = useState<RelayLivePayload | null>(null)
|
||||
@@ -129,13 +121,7 @@ export default function WorkspaceDashboard() {
|
||||
try {
|
||||
const d = (await ws.getDashboard()) as WorkspaceDbSnapshot
|
||||
setData(d)
|
||||
const desk = (
|
||||
window as Window & {
|
||||
deskBridge?: { getEasyTierPeers?: () => Promise<EasyTierPeerPayload> }
|
||||
}
|
||||
).deskBridge
|
||||
let ov: ClientOverview | null = null
|
||||
let et: EasyTierPeerPayload | null = null
|
||||
try {
|
||||
const r = await apiFetch(apiUrl('/client/overview'))
|
||||
if (r.ok) {
|
||||
@@ -145,14 +131,8 @@ export default function WorkspaceDashboard() {
|
||||
} catch {
|
||||
setOverview(null)
|
||||
}
|
||||
try {
|
||||
et = (await desk?.getEasyTierPeers?.()) ?? null
|
||||
setPeerStats(et)
|
||||
} catch {
|
||||
setPeerStats(null)
|
||||
}
|
||||
if (ov) {
|
||||
saveWorkspaceSnapshot('client_overview', { ...ov, easyTierPeers: et })
|
||||
saveWorkspaceSnapshot('client_overview', ov)
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
@@ -210,7 +190,7 @@ export default function WorkspaceDashboard() {
|
||||
channels={overview?.youtubeChannels ?? []}
|
||||
relayLive={proMode ? proLive : null}
|
||||
recentLiveEventCount={data?.liveEvents?.length ?? 0}
|
||||
peerOnline={peerStats?.onlinePeers ?? 0}
|
||||
peerOnline={0}
|
||||
/>
|
||||
</div>
|
||||
<div className="ws-head">
|
||||
|
||||
10
electron/src/renderer/src/vite-env.d.ts
vendored
10
electron/src/renderer/src/vite-env.d.ts
vendored
@@ -25,10 +25,6 @@ interface Window {
|
||||
hasToken: boolean
|
||||
candidates: { address: string; name: string }[]
|
||||
envHostOverride: boolean
|
||||
easytierBundled: boolean
|
||||
easytierRunning: boolean
|
||||
easytierLastError: string
|
||||
easytierNetworkName: string
|
||||
}>
|
||||
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
@@ -47,12 +43,6 @@ interface Window {
|
||||
}>
|
||||
}
|
||||
deskBridge?: {
|
||||
getEasyTierPeers?: () => Promise<{
|
||||
ok?: boolean
|
||||
error?: string
|
||||
totalPeers?: number
|
||||
onlinePeers?: number
|
||||
}>
|
||||
ensureYoutubeStudio?: (streamKeySuffix?: string) => Promise<void>
|
||||
}
|
||||
appTheme?: {
|
||||
|
||||
1
electron/tsconfig.node.tsbuildinfo
Normal file
1
electron/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user