This commit is contained in:
eric
2026-03-25 16:14:20 -05:00
parent 5996df8702
commit b003f3507c
11 changed files with 91 additions and 950 deletions

View File

@@ -6,10 +6,8 @@
"main": "./out/main/index.js", "main": "./out/main/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"prepare-easytier": "node scripts/download-easytier.mjs", "dev": "electron-vite dev",
"prepare-chrome": "node scripts/download-chrome-for-testing.mjs", "build": "electron-vite build",
"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",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"dist": "pnpm run build && electron-builder --win --publish never" "dist": "pnpm run build && electron-builder --win --publish never"
}, },
@@ -20,8 +18,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"three": "^0.183.2", "three": "^0.183.2"
"adm-zip": "^0.5.16"
}, },
"devDependencies": { "devDependencies": {
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
@@ -56,20 +53,6 @@
"package.json" "package.json"
], ],
"extraResources": [ "extraResources": [
{
"from": "resources/easytier",
"to": "easytier",
"filter": [
"**/*"
]
},
{
"from": "resources/chrome-cft",
"to": "chrome-cft",
"filter": [
"**/*"
]
},
{ {
"from": "..", "from": "..",
"to": "backend", "to": "backend",

View File

@@ -1,143 +0,0 @@
/**
* 下载 Chrome for TestingStable / 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 TestingStable / 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)

View File

@@ -1,103 +0,0 @@
/**
* 下载官方 EasyTier Windows x64 发行包并解压到 resources/easytier/
* 须复制 exe 同目录下全部 DLL 等依赖,否则 easytier-core 会以 0xC00001353221225781退出。
* 设置 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)

View File

@@ -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 TestingWindows 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 */
}
}
}

View File

@@ -1,229 +0,0 @@
/**
* 内置 EasyTierWindows随应用分发 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
}

View File

@@ -8,14 +8,6 @@ import crypto from 'crypto'
import { networkInterfaces } from 'os' import { networkInterfaces } from 'os'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import {
getEasyTierRuntimeState,
loadOrCreateNetworkConfig,
queryEasyTierPeerStats,
readEasyTierNetworkName,
startBundledEasyTier,
stopBundledEasyTier,
} from './easytier'
import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar' import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar'
import { import {
appendClientLog, appendClientLog,
@@ -45,7 +37,7 @@ const __dirname = path.dirname(__filename)
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const PORT = Number(process.env.WEB2_PORT || 8001) 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 web2Host = '127.0.0.1'
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
@@ -57,48 +49,32 @@ let _pythonExe: string | null = null
let pendingNav: string | null = null let pendingNav: string | null = null
/** /**
* 供扫码绑定 baseUrl优先虚拟网卡EasyTier 等),其次 10.x再补充 WiFi/以太网等局域网 IPv4 * 供扫码绑定 baseUrl优先 WiFi/以太网等局域网 IPv4排除常见虚拟网卡名。
* 避免仅有虚拟网时列表为空、用户无处选 IP。
*/ */
function collectEasyTierCandidateIPs(): { address: string; name: string }[] { function collectLanCandidateIPs(): { address: string; name: string }[] {
const nets = networkInterfaces() const nets = networkInterfaces()
const seen = new Set<string>() const seen = new Set<string>()
const out: { address: string; name: string }[] = [] const preferred: { address: string; name: string }[] = []
const nameMatch = /easytier|wintun|tun|tap|netbird|wireguard|tailscale|zerotier|sing-tun|nordlynx/i 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)) { for (const [name, addrs] of Object.entries(nets)) {
if (!addrs) continue if (!addrs) continue
for (const a of addrs) { if (excludedRe.test(name)) continue
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
for (const a of addrs) { for (const a of addrs) {
if (a.family !== 'IPv4' || a.internal) continue if (a.family !== 'IPv4' || a.internal) continue
if (a.address === '127.0.0.1') continue if (a.address === '127.0.0.1') continue
if (seen.has(a.address)) continue if (seen.has(a.address)) continue
seen.add(a.address) 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 }> { async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> {
@@ -562,9 +538,6 @@ if (!gotLock) {
return { ok: true as const } return { ok: true as const }
}) })
ipcMain.removeHandler('easytier:get-peer-stats')
ipcMain.handle('easytier:get-peer-stats', () => queryEasyTierPeerStats())
ipcMain.removeHandler('youtube-studio:ensure') ipcMain.removeHandler('youtube-studio:ensure')
ipcMain.handle('youtube-studio:ensure', async (_evt, suffix: unknown) => { ipcMain.handle('youtube-studio:ensure', async (_evt, suffix: unknown) => {
await ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined) await ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined)
@@ -584,28 +557,19 @@ if (!gotLock) {
ipcMain.removeHandler('remote:get-status') ipcMain.removeHandler('remote:get-status')
ipcMain.handle('remote:get-status', () => { ipcMain.handle('remote:get-status', () => {
const bindHost = resolveWeb2Host() const bindHost = resolveWeb2Host()
const et = getEasyTierRuntimeState()
const br = _backendRoot ?? getBackendRoot()
const easytierNetworkName = readEasyTierNetworkName(br)
return { return {
bindHost, bindHost,
remoteEnabled: bindHost === '0.0.0.0', remoteEnabled: bindHost === '0.0.0.0',
port: PORT, port: PORT,
hasToken: !!resolveWeb2ApiTokenForChild(), hasToken: !!resolveWeb2ApiTokenForChild(),
candidates: collectEasyTierCandidateIPs(), candidates: collectLanCandidateIPs(),
envHostOverride: !!process.env.WEB2_HOST?.trim(), envHostOverride: !!process.env.WEB2_HOST?.trim(),
easytierBundled: et.bundled,
easytierRunning: et.running,
easytierLastError: et.lastError,
easytierNetworkName,
} }
}) })
ipcMain.removeHandler('remote:get-bind-payload') ipcMain.removeHandler('remote:get-bind-payload')
ipcMain.handle('remote:get-bind-payload', (_evt, selectedIp: unknown) => { ipcMain.handle('remote:get-bind-payload', (_evt, selectedIp: unknown) => {
if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null
const br = _backendRoot ?? getBackendRoot()
const cfg = loadOrCreateNetworkConfig(br)
const token = resolveWeb2ApiTokenForChild() ?? '' const token = resolveWeb2ApiTokenForChild() ?? ''
const ip = selectedIp.trim() const ip = selectedIp.trim()
return { return {
@@ -613,11 +577,6 @@ if (!gotLock) {
baseUrl: `http://${ip}:${PORT}`, baseUrl: `http://${ip}:${PORT}`,
port: PORT, port: PORT,
token, token,
easytier: {
networkName: cfg.networkName,
networkSecret: cfg.networkSecret,
peerUrls: cfg.peerUrls,
},
} }
}) })
@@ -640,7 +599,6 @@ if (!gotLock) {
fs.mkdirSync(cfgDir, { recursive: true }) 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') fs.writeFileSync(path.join(cfgDir, 'web2_bind_host.txt'), enabled ? '0.0.0.0' : '127.0.0.1', 'utf-8')
const r = await restartWeb2Backend() const r = await restartWeb2Backend()
if (r.ok && enabled) startBundledEasyTier(br)
return r return r
}) })
@@ -652,7 +610,6 @@ if (!gotLock) {
const tok = crypto.randomBytes(24).toString('hex') const tok = crypto.randomBytes(24).toString('hex')
fs.writeFileSync(path.join(cfgDir, 'web2_api_token.txt'), tok, 'utf-8') fs.writeFileSync(path.join(cfgDir, 'web2_api_token.txt'), tok, 'utf-8')
const r = await restartWeb2Backend() const r = await restartWeb2Backend()
if (r.ok) startBundledEasyTier(br)
return r return r
}) })
@@ -686,8 +643,6 @@ if (!gotLock) {
return return
} }
startBundledEasyTier(backendRoot)
appendClientLog('info', 'main', '本地服务已连接,准备打开界面') appendClientLog('info', 'main', '本地服务已连接,准备打开界面')
await createWindow() await createWindow()
@@ -698,7 +653,6 @@ if (!gotLock) {
}) })
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
stopBundledEasyTier()
killPythonTree() killPythonTree()
app.quit() app.quit()
}) })
@@ -706,7 +660,6 @@ if (!gotLock) {
app.on('before-quit', () => { app.on('before-quit', () => {
appendClientLog('info', 'main', '应用退出') appendClientLog('info', 'main', '应用退出')
flushClientDb() flushClientDb()
stopBundledEasyTier()
killPythonTree() killPythonTree()
}) })
} }

View File

@@ -25,9 +25,8 @@ contextBridge.exposeInMainWorld('workspace', {
getDashboard: () => ipcRenderer.invoke('workspace:get-dashboard'), getDashboard: () => ipcRenderer.invoke('workspace:get-dashboard'),
}) })
/** 工作台 EasyTierYouTube 后台在系统浏览器打开 */ /** YouTube 后台在系统浏览器打开 */
contextBridge.exposeInMainWorld('deskBridge', { contextBridge.exposeInMainWorld('deskBridge', {
getEasyTierPeers: () => ipcRenderer.invoke('easytier:get-peer-stats'),
ensureYoutubeStudio: (streamKeySuffix?: string) => ensureYoutubeStudio: (streamKeySuffix?: string) =>
ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''), ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''),
}) })

View File

@@ -9,10 +9,6 @@ type RemoteStatus = {
hasToken: boolean hasToken: boolean
candidates: { address: string; name: string }[] candidates: { address: string; name: string }[]
envHostOverride: boolean envHostOverride: boolean
easytierBundled: boolean
easytierRunning: boolean
easytierLastError: string
easytierNetworkName: string
} }
type RemoteBindingApi = { type RemoteBindingApi = {
@@ -22,7 +18,7 @@ type RemoteBindingApi = {
getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null> getBindPayload: (selectedIp: string) => Promise<Record<string, unknown> | null>
} }
/** React Native 扫码JSON 含 baseUrl、token、easytier组网名/密钥/可选 peer */ /** 远程控制:候选 IP、绑定 JSON、二维码供手机扫码手机端仍可改地址后保存 */
export default function RemoteBinding() { export default function RemoteBinding() {
const rb = (typeof window !== 'undefined' const rb = (typeof window !== 'undefined'
? (window as Window & { remoteBinding?: RemoteBindingApi }).remoteBinding ? (window as Window & { remoteBinding?: RemoteBindingApi }).remoteBinding
@@ -32,12 +28,8 @@ export default function RemoteBinding() {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const [selectedIp, setSelectedIp] = useState('') const [selectedIp, setSelectedIp] = useState('')
const [qrImg, setQrImg] = useState<string | null>(null) const [payloadJson, setPayloadJson] = useState('')
const [bindPayload, setBindPayload] = useState<Record<string, unknown> | null>(null) const [qrDataUrl, setQrDataUrl] = useState('')
const pickBindIp = useCallback((): string => {
return selectedIp.trim()
}, [selectedIp])
const load = useCallback(async () => { const load = useCallback(async () => {
if (!rb?.getStatus) { if (!rb?.getStatus) {
@@ -66,7 +58,6 @@ export default function RemoteBinding() {
const autoEnableRemoteOnce = useRef(false) const autoEnableRemoteOnce = useRef(false)
/** 默认开启局域网/虚拟网访问,且界面不允许关闭 */
useEffect(() => { useEffect(() => {
if (!rb?.setRemoteEnabled || !status) return if (!rb?.setRemoteEnabled || !status) return
if (status.envHostOverride) return if (status.envHostOverride) return
@@ -91,40 +82,51 @@ export default function RemoteBinding() {
}, [rb, status?.remoteEnabled, status?.envHostOverride, load]) }, [rb, status?.remoteEnabled, status?.envHostOverride, load])
useEffect(() => { useEffect(() => {
if (!status?.candidates?.length) return const list = status?.candidates ?? []
setSelectedIp((prev) => { if (!list.length) {
if (prev && status.candidates.some((c) => c.address === prev)) return prev setSelectedIp('')
return status.candidates[0].address
})
}, [status])
useEffect(() => {
if (!status?.remoteEnabled || !rb?.getBindPayload) {
setBindPayload(null)
return return
} }
const ip = pickBindIp() setSelectedIp((prev) => {
if (!ip) { if (prev && list.some((c) => c.address === prev)) return prev
setBindPayload(null) return list[0].address
})
}, [status?.candidates])
useEffect(() => {
if (!rb?.getBindPayload || !selectedIp.trim()) {
setPayloadJson('')
setQrDataUrl('')
return return
} }
let cancelled = false let cancelled = false
void rb.getBindPayload(ip).then((p) => { void (async () => {
if (!cancelled) setBindPayload(p) 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 () => { return () => {
cancelled = true cancelled = true
} }
}, [status?.remoteEnabled, rb, status?.hasToken, pickBindIp]) }, [rb, selectedIp, status?.port])
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])
const onGenerateToken = async () => { const onGenerateToken = async () => {
if (!rb?.generateToken) return 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) { if (!rb?.getStatus) {
return ( return (
<section className="card card--remote"> <section className="card card--remote">
@@ -176,6 +154,7 @@ export default function RemoteBinding() {
} }
const s = status const s = status
const candidates = s?.candidates ?? []
return ( return (
<section className="card card--remote" aria-label="远程控制"> <section className="card card--remote" aria-label="远程控制">
@@ -186,84 +165,52 @@ export default function RemoteBinding() {
)} )}
<div className="remote-row"> <div className="remote-row">
<label className="remote-toggle-label"> <span
<input className={`remote-token-badge ${status?.hasToken ? 'on' : 'off'}`}
type="checkbox" title="API 是否已配置 tokenconfig/web2_api_token.txt 或环境变量)"
className="remote-toggle-input" >
checked={!!status?.remoteEnabled} API Token{status?.hasToken ? '已配置' : '未配置'}
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> </span>
<button type="button" className="btn btn--sm" disabled={busy} onClick={() => void onGenerateToken()}>
/ Token
</button>
</div> </div>
<div className="remote-row"> <div className="remote-row">
<label className="field-label" htmlFor="remoteEtIp"> <label htmlFor="remote-bind-ip" className="remote-toggle-label">
IP /
</label> </label>
<select <select
id="remoteEtIp" id="remote-bind-ip"
className="select-process" className="wc-proxy-param"
value={selectedIp} value={selectedIp}
disabled={busy || !candidates.length}
onChange={(e) => setSelectedIp(e.target.value)} onChange={(e) => setSelectedIp(e.target.value)}
disabled={!status?.candidates?.length}
> >
{(status?.candidates?.length {!candidates.length ? (
? status.candidates <option value=""></option>
: [{ address: '', name: '(暂无候选 IP' }] ) : (
).map((c) => ( candidates.map((c) => (
<option key={c.address || 'none'} value={c.address} disabled={!c.address}> <option key={c.address} value={c.address}>
{c.address ? `${c.address}${c.name}` : c.name} {c.address} {c.name ? `${c.name}` : ''}
</option> </option>
))} ))
)}
</select> </select>
<span className="remote-qr-hint">{status?.port ?? '—'}</span>
</div> </div>
<div className="remote-row"> {payloadJson && qrDataUrl && (
<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 && (
<div className="remote-qr-block"> <div className="remote-qr-block">
<p className="remote-qr-hint"> <p className="remote-qr-hint"> App App IP/</p>
React Native JSON <code className="remote-code">baseUrl</code><code className="remote-code">token</code> <img src={qrDataUrl} alt="绑定二维码" className="remote-qr-img" width={236} height={236} />
<code className="remote-code">easytier</code>使
</p>
<img src={qrImg} alt="绑定信息二维码" className="remote-qr-img" width={224} height={224} />
<pre className="remote-qr-json" tabIndex={0}> <pre className="remote-qr-json" tabIndex={0}>
{JSON.stringify(bindPayload, null, 2)} {payloadJson}
</pre> </pre>
<button <p className="remote-qr-hint">v1 JSON baseUrl / port / token</p>
type="button"
className="btn btn-secondary"
onClick={() => void navigator.clipboard.writeText(JSON.stringify(bindPayload))}
>
JSON
</button>
</div> </div>
)} )}
{s?.remoteEnabled && !bindPayload && (
<p className="remote-qr-hint"> IP </p>
)}
{msg && ( {msg && (
<p className="remote-error" role="alert"> <p className="remote-error" role="alert">
{msg} {msg}

View File

@@ -30,13 +30,6 @@ type ClientOverview = {
} }
} }
type EasyTierPeerPayload = {
ok?: boolean
error?: string
totalPeers?: number
onlinePeers?: number
}
type RelayLivePayload = { type RelayLivePayload = {
youtubeProcess?: string youtubeProcess?: string
processStatus?: string processStatus?: string
@@ -115,7 +108,6 @@ export default function WorkspaceDashboard() {
const ws = getWorkspace() const ws = getWorkspace()
const [data, setData] = useState<WorkspaceDbSnapshot | null>(null) const [data, setData] = useState<WorkspaceDbSnapshot | null>(null)
const [overview, setOverview] = useState<ClientOverview | null>(null) const [overview, setOverview] = useState<ClientOverview | null>(null)
const [peerStats, setPeerStats] = useState<EasyTierPeerPayload | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [proMode, setProMode] = useState(false) const [proMode, setProMode] = useState(false)
const [proLive, setProLive] = useState<RelayLivePayload | null>(null) const [proLive, setProLive] = useState<RelayLivePayload | null>(null)
@@ -129,13 +121,7 @@ export default function WorkspaceDashboard() {
try { try {
const d = (await ws.getDashboard()) as WorkspaceDbSnapshot const d = (await ws.getDashboard()) as WorkspaceDbSnapshot
setData(d) setData(d)
const desk = (
window as Window & {
deskBridge?: { getEasyTierPeers?: () => Promise<EasyTierPeerPayload> }
}
).deskBridge
let ov: ClientOverview | null = null let ov: ClientOverview | null = null
let et: EasyTierPeerPayload | null = null
try { try {
const r = await apiFetch(apiUrl('/client/overview')) const r = await apiFetch(apiUrl('/client/overview'))
if (r.ok) { if (r.ok) {
@@ -145,14 +131,8 @@ export default function WorkspaceDashboard() {
} catch { } catch {
setOverview(null) setOverview(null)
} }
try {
et = (await desk?.getEasyTierPeers?.()) ?? null
setPeerStats(et)
} catch {
setPeerStats(null)
}
if (ov) { if (ov) {
saveWorkspaceSnapshot('client_overview', { ...ov, easyTierPeers: et }) saveWorkspaceSnapshot('client_overview', ov)
} }
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : String(e)) setErr(e instanceof Error ? e.message : String(e))
@@ -210,7 +190,7 @@ export default function WorkspaceDashboard() {
channels={overview?.youtubeChannels ?? []} channels={overview?.youtubeChannels ?? []}
relayLive={proMode ? proLive : null} relayLive={proMode ? proLive : null}
recentLiveEventCount={data?.liveEvents?.length ?? 0} recentLiveEventCount={data?.liveEvents?.length ?? 0}
peerOnline={peerStats?.onlinePeers ?? 0} peerOnline={0}
/> />
</div> </div>
<div className="ws-head"> <div className="ws-head">

View File

@@ -25,10 +25,6 @@ interface Window {
hasToken: boolean hasToken: boolean
candidates: { address: string; name: string }[] candidates: { address: string; name: string }[]
envHostOverride: boolean envHostOverride: boolean
easytierBundled: boolean
easytierRunning: boolean
easytierLastError: string
easytierNetworkName: string
}> }>
setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }> setRemoteEnabled: (enabled: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
generateToken: () => Promise<{ ok: true } | { ok: false; error: string }> generateToken: () => Promise<{ ok: true } | { ok: false; error: string }>
@@ -47,12 +43,6 @@ interface Window {
}> }>
} }
deskBridge?: { deskBridge?: {
getEasyTierPeers?: () => Promise<{
ok?: boolean
error?: string
totalPeers?: number
onlinePeers?: number
}>
ensureYoutubeStudio?: (streamKeySuffix?: string) => Promise<void> ensureYoutubeStudio?: (streamKeySuffix?: string) => Promise<void>
} }
appTheme?: { appTheme?: {

File diff suppressed because one or more lines are too long