'init'
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
This commit is contained in:
224
electron/src/main/appHost.ts
Normal file
224
electron/src/main/appHost.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Electron app 模块:开机启动、跳转列表、关于面板、日志路径、角标、优雅重启、指标等。
|
||||
* 文档:https://www.electronjs.org/docs/latest/api/app
|
||||
*/
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const VALID_NAV = new Set(['desk', 'record', 'network', 'proxy', 'system', 'wechat'])
|
||||
|
||||
export function parseNavFromArgv(argv: string[]): string | null {
|
||||
for (const a of argv) {
|
||||
const m = /^--nav=(.+)$/.exec(a)
|
||||
if (!m) continue
|
||||
const id = m[1].trim()
|
||||
if (VALID_NAV.has(id)) return id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 须在 app.ready 之前调用(Windows Toast / 任务栏分组) */
|
||||
export function setAppUserModelIdEarly(): void {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
app.setAppUserModelId('com.douyin.recorder.console')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageJsonField(field: 'description' | 'version'): string {
|
||||
try {
|
||||
const candidates = [
|
||||
path.join(app.getAppPath(), 'package.json'),
|
||||
path.join(process.cwd(), 'package.json'),
|
||||
]
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
const j = JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>
|
||||
const v = j[field]
|
||||
if (typeof v === 'string') return v
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** ready 后立即调用 */
|
||||
export function applyAppHostBootstrap(getBackendRoot: () => string): void {
|
||||
try {
|
||||
const logDir = path.join(app.getPath('userData'), 'logs')
|
||||
app.setAppLogsPath(logDir)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const ver = app.getVersion()
|
||||
const desc = readPackageJsonField('description') || '直播录制与转播控制台'
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: app.getName(),
|
||||
applicationVersion: ver,
|
||||
copyright: `Copyright © ${new Date().getFullYear()}`,
|
||||
credits: desc,
|
||||
})
|
||||
|
||||
if (process.env.WEB2_SECURE_DNS === '1') {
|
||||
try {
|
||||
app.configureHostResolver({ secureDnsMode: 'automatic' })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const proxy = process.env.WEB2_ELECTRON_GLOBAL_PROXY?.trim()
|
||||
if (proxy) {
|
||||
try {
|
||||
void app.setProxy({ proxyRules: proxy })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
app.setUserTasks([
|
||||
{
|
||||
program: process.execPath,
|
||||
arguments: '--nav=desk',
|
||||
title: '工作台',
|
||||
description: '打开工作台仪表盘',
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0,
|
||||
},
|
||||
{
|
||||
program: process.execPath,
|
||||
arguments: '--nav=record',
|
||||
title: '录制',
|
||||
description: '打开录制与转播控制',
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0,
|
||||
},
|
||||
{
|
||||
program: process.execPath,
|
||||
arguments: '--nav=network',
|
||||
title: '网络',
|
||||
description: '网络与远程绑定',
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAppHostIpcHandlers(getBackendRoot: () => string): void {
|
||||
ipcMain.removeHandler('app:get-login-item')
|
||||
ipcMain.handle('app:get-login-item', () => {
|
||||
try {
|
||||
return app.getLoginItemSettings()
|
||||
} catch {
|
||||
return { openAtLogin: false }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:set-login-item')
|
||||
ipcMain.handle('app:set-login-item', (_evt, openAtLogin: unknown) => {
|
||||
if (typeof openAtLogin !== 'boolean') return { ok: false as const, error: '参数无效' }
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin,
|
||||
path: process.execPath,
|
||||
args: [],
|
||||
})
|
||||
return { ok: true as const }
|
||||
} catch (e) {
|
||||
return { ok: false as const, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:relaunch')
|
||||
ipcMain.handle('app:relaunch', () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:get-metrics')
|
||||
ipcMain.handle('app:get-metrics', () => {
|
||||
try {
|
||||
return app.getAppMetrics()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:show-about')
|
||||
ipcMain.handle('app:show-about', () => {
|
||||
try {
|
||||
app.showAboutPanel()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:set-badge')
|
||||
ipcMain.handle('app:set-badge', (_evt, count: unknown) => {
|
||||
const n = typeof count === 'number' && Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
|
||||
try {
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
app.setBadgeCount(n)
|
||||
}
|
||||
} catch {
|
||||
/* Windows 无 dock 角标,忽略 */
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:get-paths')
|
||||
ipcMain.handle('app:get-paths', () => {
|
||||
const br = getBackendRoot()
|
||||
return {
|
||||
userData: app.getPath('userData'),
|
||||
logs: app.getPath('logs'),
|
||||
temp: app.getPath('temp'),
|
||||
backendRoot: br,
|
||||
backendConfig: path.join(br, 'config'),
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:add-recent-config')
|
||||
ipcMain.handle('app:add-recent-config', () => {
|
||||
const cfg = path.join(getBackendRoot(), 'config')
|
||||
try {
|
||||
if (fs.existsSync(cfg)) {
|
||||
app.addRecentDocument(cfg)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:open-logs-folder')
|
||||
ipcMain.handle('app:open-logs-folder', async () => {
|
||||
const p = app.getPath('logs')
|
||||
try {
|
||||
await fs.promises.mkdir(p, { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const err = await shell.openPath(p)
|
||||
if (err) throw new Error(err)
|
||||
})
|
||||
}
|
||||
|
||||
export function sendNavToWindow(win: BrowserWindow, nav: string): void {
|
||||
if (!VALID_NAV.has(nav)) return
|
||||
try {
|
||||
win.webContents.send('app:nav', nav)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
400
electron/src/main/clientDb.ts
Normal file
400
electron/src/main/clientDb.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 客户端本地 SQLite(sql.js):直播事件、用户操作、运行日志、数据快照。
|
||||
* 数据库文件:userData/client-data.sqlite
|
||||
*/
|
||||
import { createRequire } from 'module'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import initSqlJs, { type Database } from 'sql.js'
|
||||
|
||||
let db: Database | null = null
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export function getDbPath(): string {
|
||||
return path.join(app.getPath('userData'), 'client-data.sqlite')
|
||||
}
|
||||
|
||||
function schedulePersist(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null
|
||||
flushClientDb()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
export function flushClientDb(): void {
|
||||
if (!db) return
|
||||
try {
|
||||
const buf = Buffer.from(db.export())
|
||||
fs.mkdirSync(path.dirname(getDbPath()), { recursive: true })
|
||||
fs.writeFileSync(getDbPath(), buf)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrations(): void {
|
||||
if (!db) return
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS live_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
process TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
status TEXT,
|
||||
message TEXT,
|
||||
detail_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
nav TEXT,
|
||||
detail_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS client_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
source TEXT,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS data_snapshots (
|
||||
key TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_ts ON live_events(ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_ts ON user_actions(ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_ts ON client_logs(ts DESC);
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS relay_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
youtube_key TEXT NOT NULL DEFAULT '',
|
||||
douyin_url TEXT NOT NULL DEFAULT '',
|
||||
url_list_text TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
export async function initClientDb(): Promise<void> {
|
||||
const require = createRequire(import.meta.url)
|
||||
// sql.js exports "." and "./dist/*" only — not package.json
|
||||
const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm')
|
||||
const SQL = await initSqlJs({ locateFile: () => wasmPath })
|
||||
const dbPath = getDbPath()
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const fileBuf = fs.readFileSync(dbPath)
|
||||
db = new SQL.Database(fileBuf)
|
||||
} else {
|
||||
db = new SQL.Database()
|
||||
}
|
||||
runMigrations()
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function appendLiveEvent(payload: {
|
||||
eventType: string
|
||||
process?: string
|
||||
status?: string
|
||||
message?: string
|
||||
detail?: unknown
|
||||
}): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null
|
||||
db.run(
|
||||
`INSERT INTO live_events (ts, process, event_type, status, message, detail_json) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
ts,
|
||||
payload.process ?? null,
|
||||
payload.eventType,
|
||||
payload.status ?? null,
|
||||
payload.message ?? null,
|
||||
detailJson,
|
||||
],
|
||||
)
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function appendUserAction(payload: { action: string; nav?: string; detail?: unknown }): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
const detailJson = payload.detail != null ? JSON.stringify(payload.detail) : null
|
||||
db.run(`INSERT INTO user_actions (ts, action, nav, detail_json) VALUES (?, ?, ?, ?)`, [
|
||||
ts,
|
||||
payload.action,
|
||||
payload.nav ?? null,
|
||||
detailJson,
|
||||
])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function appendClientLog(level: string, source: string, message: string): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
db.run(`INSERT INTO client_logs (ts, level, source, message) VALUES (?, ?, ?, ?)`, [
|
||||
ts,
|
||||
level,
|
||||
source,
|
||||
message,
|
||||
])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export function saveDataSnapshot(key: string, json: unknown): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
const text = JSON.stringify(json)
|
||||
db.run(`INSERT OR REPLACE INTO data_snapshots (key, json, ts) VALUES (?, ?, ?)`, [key, text, ts])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
export type DashboardRow = {
|
||||
id: number
|
||||
ts: number
|
||||
process?: string | null
|
||||
event_type?: string
|
||||
status?: string | null
|
||||
message?: string | null
|
||||
detail_json?: string | null
|
||||
action?: string
|
||||
nav?: string | null
|
||||
level?: string
|
||||
source?: string | null
|
||||
}
|
||||
|
||||
function execToRows(sql: string): DashboardRow[] {
|
||||
if (!db) return []
|
||||
try {
|
||||
const res = db.exec(sql)
|
||||
if (!res.length) return []
|
||||
const { columns, values } = res[0]
|
||||
return values.map((row) => {
|
||||
const o: Record<string, unknown> = {}
|
||||
columns.forEach((c, i) => {
|
||||
o[c] = row[i]
|
||||
})
|
||||
return o as DashboardRow
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getDashboardSnapshot(): {
|
||||
liveEvents: DashboardRow[]
|
||||
userActions: DashboardRow[]
|
||||
clientLogs: DashboardRow[]
|
||||
snapshots: Record<string, { ts: number; data: unknown }>
|
||||
} {
|
||||
const empty = {
|
||||
liveEvents: [] as DashboardRow[],
|
||||
userActions: [] as DashboardRow[],
|
||||
clientLogs: [] as DashboardRow[],
|
||||
snapshots: {} as Record<string, { ts: number; data: unknown }>,
|
||||
}
|
||||
if (!db) return empty
|
||||
|
||||
try {
|
||||
empty.liveEvents = execToRows(
|
||||
`SELECT id, ts, process, event_type, status, message, detail_json FROM live_events ORDER BY id DESC LIMIT 80`,
|
||||
)
|
||||
empty.userActions = execToRows(
|
||||
`SELECT id, ts, action, nav, detail_json FROM user_actions ORDER BY id DESC LIMIT 80`,
|
||||
)
|
||||
empty.clientLogs = execToRows(
|
||||
`SELECT id, ts, level, source, message FROM client_logs ORDER BY id DESC LIMIT 120`,
|
||||
)
|
||||
|
||||
const snapRes = db.exec(`SELECT key, json, ts FROM data_snapshots`)
|
||||
if (snapRes.length) {
|
||||
const { columns, values } = snapRes[0]
|
||||
for (const row of values) {
|
||||
const o: Record<string, unknown> = {}
|
||||
columns.forEach((c, i) => {
|
||||
o[c] = row[i]
|
||||
})
|
||||
const key = String(o.key ?? '')
|
||||
const json = String(o.json ?? '')
|
||||
const ts = Number(o.ts ?? 0)
|
||||
try {
|
||||
empty.snapshots[key] = { ts, data: JSON.parse(json) as unknown }
|
||||
} catch {
|
||||
empty.snapshots[key] = { ts, data: json }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
// —— 应用设置(主题等)——
|
||||
|
||||
export function getAppSetting(key: string): string | null {
|
||||
if (!db) return null
|
||||
try {
|
||||
const r = db.exec(`SELECT value FROM app_settings WHERE key = ${JSON.stringify(key)}`)
|
||||
if (!r.length || !r[0].values.length) return null
|
||||
return String(r[0].values[0][0] ?? '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppSetting(key: string, value: string): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
db.run(`INSERT OR REPLACE INTO app_settings (key, value, ts) VALUES (?, ?, ?)`, [key, value, ts])
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
// —— Pro 转播线路:SQLite 为源,导出到项目 config/ ——
|
||||
|
||||
function relayChannelCount(): number {
|
||||
if (!db) return 0
|
||||
try {
|
||||
const r = db.exec(`SELECT COUNT(*) AS c FROM relay_channels`)
|
||||
if (!r.length || !r[0].values.length) return 0
|
||||
return Number(r[0].values[0][0] ?? 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function seedDefaultRelayChannel(): void {
|
||||
if (!db) return
|
||||
const ts = Date.now()
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO relay_channels (id, name, youtube_key, douyin_url, url_list_text, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
'ch_default',
|
||||
'默认线路',
|
||||
'',
|
||||
'',
|
||||
'# 软件已自动创建本文件。每行一个抖音直播间地址,例如:\n# https://live.douyin.com/123456789\n\n',
|
||||
0,
|
||||
ts,
|
||||
],
|
||||
)
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
function safeChannelFileId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default'
|
||||
}
|
||||
|
||||
function importRelayFromYoutubeJson(configDir: string): boolean {
|
||||
if (!db) return false
|
||||
const p = path.join(configDir, 'youtube_channels.json')
|
||||
if (!fs.existsSync(p)) return false
|
||||
let raw: { channels?: unknown[] }
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(p, 'utf-8')) as { channels?: unknown[] }
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const arr = raw.channels
|
||||
if (!Array.isArray(arr) || arr.length === 0) return false
|
||||
db.run(`DELETE FROM relay_channels`)
|
||||
const relayPro = path.join(configDir, 'relay_pro')
|
||||
let order = 0
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const o = item as Record<string, unknown>
|
||||
const id = String(o.id ?? o.name ?? `ch_${order}`).trim() || `ch_${order}`
|
||||
const name = String(o.name ?? '线路').trim() || '线路'
|
||||
const yk = String(o.youtubeKey ?? o.key ?? '').trim()
|
||||
const du = String(o.douyinUrl ?? o.douyin_url ?? '').trim()
|
||||
let urlText = ''
|
||||
const up = path.join(relayPro, `url_config.${safeChannelFileId(id)}.ini`)
|
||||
if (fs.existsSync(up)) {
|
||||
try {
|
||||
urlText = fs.readFileSync(up, 'utf-8')
|
||||
} catch {
|
||||
urlText = ''
|
||||
}
|
||||
}
|
||||
const ts = Date.now()
|
||||
db.run(
|
||||
`INSERT INTO relay_channels (id, name, youtube_key, douyin_url, url_list_text, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, name, yk, du, urlText, order, ts],
|
||||
)
|
||||
order += 1
|
||||
}
|
||||
schedulePersist()
|
||||
return order > 0
|
||||
}
|
||||
|
||||
export function exportRelayChannelsToConfigDir(configDir: string): void {
|
||||
if (!db) return
|
||||
const relayPro = path.join(configDir, 'relay_pro')
|
||||
fs.mkdirSync(relayPro, { recursive: true })
|
||||
let r: ReturnType<Database['exec']>
|
||||
try {
|
||||
r = db.exec(
|
||||
`SELECT id, name, youtube_key, douyin_url, url_list_text, sort_order FROM relay_channels ORDER BY sort_order, id`,
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!r.length || !r[0].values.length) return
|
||||
const cols = r[0].columns
|
||||
const rows = r[0].values
|
||||
const channels: Record<string, unknown>[] = []
|
||||
for (const row of rows) {
|
||||
const rec: Record<string, unknown> = {}
|
||||
cols.forEach((c, i) => {
|
||||
rec[c] = row[i]
|
||||
})
|
||||
const id = String(rec.id ?? '')
|
||||
const name = String(rec.name ?? '')
|
||||
const youtubeKey = String(rec.youtube_key ?? '')
|
||||
const douyinUrl = String(rec.douyin_url ?? '')
|
||||
const urlList = String(rec.url_list_text ?? '')
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
douyinUrl,
|
||||
youtubeKey,
|
||||
})
|
||||
const uf = path.join(relayPro, `url_config.${safeChannelFileId(id)}.ini`)
|
||||
fs.writeFileSync(uf, urlList || '# 每行一个抖音直播间地址\n', 'utf-8')
|
||||
}
|
||||
const doc = {
|
||||
version: 1,
|
||||
autoManaged: true,
|
||||
channels,
|
||||
}
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(configDir, 'youtube_channels.json'), JSON.stringify(doc, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/** 启动时:SQLite 优先;若为空则从磁盘 JSON 导入;仍空则种子;最后导出到 config/ */
|
||||
export function syncRelayChannelsWithBackendConfig(configDir: string): void {
|
||||
if (!db) return
|
||||
const abs = path.resolve(configDir)
|
||||
if (relayChannelCount() === 0) {
|
||||
if (!importRelayFromYoutubeJson(abs)) {
|
||||
seedDefaultRelayChannel()
|
||||
}
|
||||
}
|
||||
exportRelayChannelsToConfigDir(abs)
|
||||
}
|
||||
|
||||
/** 从磁盘重新读入 SQLite(在通过 Python API 保存文件后调用) */
|
||||
export function importRelayChannelsFromConfigDir(configDir: string): void {
|
||||
if (!db) return
|
||||
const abs = path.resolve(configDir)
|
||||
importRelayFromYoutubeJson(abs)
|
||||
}
|
||||
229
electron/src/main/easytier.ts
Normal file
229
electron/src/main/easytier.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 内置 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
|
||||
}
|
||||
706
electron/src/main/index.ts
Normal file
706
electron/src/main/index.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||
import { execFile, spawn, ChildProcessWithoutNullStreams } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
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,
|
||||
appendLiveEvent,
|
||||
appendUserAction,
|
||||
flushClientDb,
|
||||
getAppSetting,
|
||||
getDashboardSnapshot,
|
||||
importRelayChannelsFromConfigDir,
|
||||
initClientDb,
|
||||
saveDataSnapshot,
|
||||
setAppSetting,
|
||||
syncRelayChannelsWithBackendConfig,
|
||||
} from './clientDb'
|
||||
import {
|
||||
applyAppHostBootstrap,
|
||||
parseNavFromArgv,
|
||||
registerAppHostIpcHandlers,
|
||||
sendNavToWindow,
|
||||
setAppUserModelIdEarly,
|
||||
} from './appHost'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
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 访问 */
|
||||
let web2Host = '127.0.0.1'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let pythonChild: ChildProcessWithoutNullStreams | null = null
|
||||
let stderrBuf = ''
|
||||
let _backendRoot: string | null = null
|
||||
let _pythonExe: string | null = null
|
||||
/** 自启动参数 --nav= 传入的页面,首屏或二次实例时下发渲染进程 */
|
||||
let pendingNav: string | null = null
|
||||
|
||||
/**
|
||||
* 供扫码绑定 baseUrl:优先虚拟网卡(EasyTier 等),其次 10.x,再补充 Wi‑Fi/以太网等局域网 IPv4,
|
||||
* 避免仅有虚拟网时列表为空、用户无处选 IP。
|
||||
*/
|
||||
function collectEasyTierCandidateIPs(): { 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
|
||||
|
||||
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
|
||||
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}(局域网)` })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function restartWeb2Backend(): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const py = _pythonExe ?? resolvePythonExe(br)
|
||||
web2Host = resolveWeb2Host()
|
||||
await killPythonProcessForRestart()
|
||||
startPythonBackend(br, py)
|
||||
try {
|
||||
await waitForHealth(90000)
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换监听地址 / 轮换 API 密钥时必须等旧 uvicorn 完全退出并释放端口。
|
||||
* Windows 上若仅 spawn taskkill 不等待,旧进程仍占用 8001,新进程启动失败,但 /health 仍由旧进程响应,
|
||||
* waitForHealth 误判成功,随后旧进程被杀死 → 前端 ERR_CONNECTION_REFUSED。
|
||||
*/
|
||||
async function killPythonProcessForRestart(): Promise<void> {
|
||||
if (!pythonChild || pythonChild.killed) {
|
||||
pythonChild = null
|
||||
return
|
||||
}
|
||||
const child = pythonChild
|
||||
const pid = child.pid
|
||||
pythonChild = null
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
timeout: 25000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
} catch {
|
||||
/* 进程已退出等 */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
} else {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const t = setTimeout(() => resolve(), 10000)
|
||||
child.once('close', () => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
|
||||
function getBackendRoot(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'backend')
|
||||
}
|
||||
// dev: electron/out/main -> 仓库根(上三级)
|
||||
return path.resolve(__dirname, '../../..')
|
||||
}
|
||||
|
||||
function resolveWeb2Host(): string {
|
||||
const e = process.env.WEB2_HOST?.trim()
|
||||
if (e) return e
|
||||
const br = getBackendRoot()
|
||||
const p = path.join(br, 'config', 'web2_bind_host.txt')
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim()
|
||||
if (line) return line
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return '127.0.0.1'
|
||||
}
|
||||
|
||||
function resolveWeb2ApiTokenForChild(): string | undefined {
|
||||
const e = process.env.WEB2_API_TOKEN?.trim()
|
||||
if (e) return e
|
||||
const br = getBackendRoot()
|
||||
const p = path.join(br, 'config', 'web2_api_token.txt')
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
const line = fs.readFileSync(p, 'utf-8').trim().split('\n')[0]?.trim()
|
||||
if (line) return line
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolvePythonExe(backendRoot: string): string {
|
||||
if (process.env.PYTHON_EXE && fs.existsSync(process.env.PYTHON_EXE)) {
|
||||
return process.env.PYTHON_EXE
|
||||
}
|
||||
const embedded = path.join(backendRoot, 'python', 'python.exe')
|
||||
if (fs.existsSync(embedded)) return embedded
|
||||
return process.platform === 'win32' ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
function stderrTail(): string {
|
||||
const s = stderrBuf.trim()
|
||||
if (!s) {
|
||||
return '若您使用的是完整安装包仍无法启动,请尝试重启电脑后再次打开本软件,或联系技术支持获取帮助。'
|
||||
}
|
||||
return s.slice(-4000)
|
||||
}
|
||||
|
||||
/** 懒人包:把自带的 ffmpeg / 便携 Python 放进 PATH,用户无需自行安装 */
|
||||
function buildChildEnv(backendRoot: string): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env } as NodeJS.ProcessEnv
|
||||
const sep = path.delimiter
|
||||
const prepend: string[] = []
|
||||
|
||||
const ffmpegDirs = [
|
||||
path.join(backendRoot, 'tools', 'ffmpeg'),
|
||||
path.join(backendRoot, 'tools', 'ffmpeg', 'bin'),
|
||||
]
|
||||
for (const dir of ffmpegDirs) {
|
||||
if (fs.existsSync(dir)) prepend.push(dir)
|
||||
}
|
||||
|
||||
const portablePython = path.join(backendRoot, 'python')
|
||||
if (fs.existsSync(portablePython)) {
|
||||
prepend.push(portablePython)
|
||||
const scripts = path.join(portablePython, 'Scripts')
|
||||
if (fs.existsSync(scripts)) prepend.push(scripts)
|
||||
env.PYTHONHOME = portablePython
|
||||
}
|
||||
|
||||
if (prepend.length && env.PATH) {
|
||||
env.PATH = `${prepend.join(sep)}${sep}${env.PATH}`
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** 开发模式:等 Vite 监听后再 loadURL,避免先打开窗口却加载失败导致黑屏 */
|
||||
function waitForDevServer(devUrl: string, timeoutMs = 60000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(devUrl)
|
||||
} catch {
|
||||
reject(new Error(`无效的 ELECTRON_RENDERER_URL: ${devUrl}`))
|
||||
return
|
||||
}
|
||||
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80
|
||||
const start = Date.now()
|
||||
const tryOnce = (): void => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
reject(
|
||||
new Error(
|
||||
`开发服务器在 ${timeoutMs / 1000}s 内未响应(${devUrl})。请先保持 pnpm dev / electron-vite dev 运行,或检查端口是否被占用。`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
const req = http.get(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port,
|
||||
path: '/',
|
||||
timeout: 2500,
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
resolve()
|
||||
},
|
||||
)
|
||||
req.on('error', () => {
|
||||
req.destroy()
|
||||
setTimeout(tryOnce, 350)
|
||||
})
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
setTimeout(tryOnce, 350)
|
||||
})
|
||||
}
|
||||
tryOnce()
|
||||
})
|
||||
}
|
||||
|
||||
function healthCheckHost(): string {
|
||||
return web2Host === '0.0.0.0' ? '127.0.0.1' : web2Host
|
||||
}
|
||||
|
||||
function waitForHealth(timeoutMs = 120000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now()
|
||||
const tick = (): void => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(iv)
|
||||
reject(new Error(`本地服务在 ${timeoutMs / 1000} 秒内未就绪。\n\n${stderrTail()}`))
|
||||
return
|
||||
}
|
||||
const req = http.get(`http://${healthCheckHost()}:${PORT}/health`, (res) => {
|
||||
res.resume()
|
||||
if (res.statusCode === 200) {
|
||||
clearInterval(iv)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
req.on('error', () => req.destroy())
|
||||
}
|
||||
const iv = setInterval(tick, 400)
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
function startPythonBackend(backendRoot: string, pythonExe: string): void {
|
||||
stderrBuf = ''
|
||||
const apiTok = resolveWeb2ApiTokenForChild()
|
||||
if (web2Host === '0.0.0.0' && !apiTok) {
|
||||
console.warn(
|
||||
'[直播录制助手] web2 监听 0.0.0.0 但未设置 WEB2_API_TOKEN 或 config/web2_api_token.txt,API 无鉴权,请勿暴露到公网。',
|
||||
)
|
||||
}
|
||||
const env = {
|
||||
...buildChildEnv(backendRoot),
|
||||
PYTHONUNBUFFERED: '1',
|
||||
WEB2_SUPERVISOR: 'builtin',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
...(apiTok ? { WEB2_API_TOKEN: apiTok } : {}),
|
||||
}
|
||||
const args = ['-m', 'uvicorn', 'web2:app', '--host', web2Host, '--port', String(PORT)]
|
||||
pythonChild = spawn(pythonExe, args, {
|
||||
cwd: backendRoot,
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
pythonChild.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString()
|
||||
})
|
||||
pythonChild.stdout?.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString()
|
||||
})
|
||||
pythonChild.on('error', (err: Error) => {
|
||||
stderrBuf += `\nspawn error: ${err.message}`
|
||||
})
|
||||
pythonChild.on('close', (code) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
if (code !== 0 && code !== null) {
|
||||
dialog.showErrorBox('直播录制助手', `软件内部服务已停止运行。\n\n${stderrTail()}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function killPythonTree(): void {
|
||||
if (!pythonChild || pythonChild.killed) return
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/PID', String(pythonChild.pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
} else {
|
||||
pythonChild.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pythonChild = null
|
||||
}
|
||||
|
||||
function resolvePreload(): string {
|
||||
const base = path.join(__dirname, '../preload')
|
||||
const js = path.join(base, 'index.js')
|
||||
const mjs = path.join(base, 'index.mjs')
|
||||
// 构建产物为 CJS 的 index.js;若目录里残留旧版 index.mjs,须优先使用 index.js
|
||||
if (fs.existsSync(js)) return js
|
||||
if (fs.existsSync(mjs)) return mjs
|
||||
return js
|
||||
}
|
||||
|
||||
async function createWindow(): Promise<void> {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1020,
|
||||
height: 720,
|
||||
minWidth: 720,
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
backgroundColor: '#14161c',
|
||||
title: '直播录制助手',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: resolvePreload(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
mainWindow.once('ready-to-show', () => mainWindow?.show())
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
appendClientLog('info', 'main', '渲染界面已加载')
|
||||
if (pendingNav && mainWindow && !mainWindow.isDestroyed()) {
|
||||
sendNavToWindow(mainWindow, pendingNav)
|
||||
pendingNav = null
|
||||
}
|
||||
})
|
||||
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
try {
|
||||
await waitForDevServer(devUrl)
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
|
||||
mainWindow.destroy()
|
||||
mainWindow = null
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
if (process.env.NODE_ENV === 'development' || devUrl.includes('localhost')) {
|
||||
mainWindow?.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
try {
|
||||
await mainWindow.loadURL(devUrl)
|
||||
break
|
||||
} catch {
|
||||
if (i === 7) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`无法加载开发页面:${devUrl}\n请确认 Vite 已启动且端口未被占用。`,
|
||||
)
|
||||
mainWindow.destroy()
|
||||
mainWindow = null
|
||||
return
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('直播录制助手', e instanceof Error ? e.message : String(e))
|
||||
mainWindow.destroy()
|
||||
mainWindow = null
|
||||
return
|
||||
}
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
setAppUserModelIdEarly()
|
||||
pendingNav = parseNavFromArgv(process.argv)
|
||||
|
||||
app.on('second-instance', (_evt, commandLine) => {
|
||||
const argv = Array.isArray(commandLine)
|
||||
? commandLine
|
||||
: typeof commandLine === 'string'
|
||||
? commandLine.split(/\s+/).filter(Boolean)
|
||||
: [String(commandLine)]
|
||||
const nav = parseNavFromArgv(argv)
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
if (nav) sendNavToWindow(mainWindow, nav)
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
applyAppHostBootstrap(getBackendRoot)
|
||||
registerAppHostIpcHandlers(getBackendRoot)
|
||||
try {
|
||||
await initClientDb()
|
||||
try {
|
||||
syncRelayChannelsWithBackendConfig(path.join(getBackendRoot(), 'config'))
|
||||
} catch (e) {
|
||||
console.error('[relay sync]', e)
|
||||
}
|
||||
appendClientLog('info', 'main', '客户端主进程就绪')
|
||||
} catch (e) {
|
||||
console.error('[clientDb]', e)
|
||||
}
|
||||
|
||||
ipcMain.removeHandler('workspace:log-live')
|
||||
ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
const eventType = typeof p.eventType === 'string' ? p.eventType : 'unknown'
|
||||
appendLiveEvent({
|
||||
eventType,
|
||||
process: typeof p.process === 'string' ? p.process : undefined,
|
||||
status: typeof p.status === 'string' ? p.status : undefined,
|
||||
message: typeof p.message === 'string' ? p.message : undefined,
|
||||
detail: p.detail,
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:log-user')
|
||||
ipcMain.handle('workspace:log-user', (_evt, payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
const action = typeof p.action === 'string' ? p.action : 'unknown'
|
||||
appendUserAction({
|
||||
action,
|
||||
nav: typeof p.nav === 'string' ? p.nav : undefined,
|
||||
detail: p.detail,
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:log-client')
|
||||
ipcMain.handle('workspace:log-client', (_evt, payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
const level = typeof p.level === 'string' ? p.level : 'info'
|
||||
const source = typeof p.source === 'string' ? p.source : 'renderer'
|
||||
const message = typeof p.message === 'string' ? p.message : ''
|
||||
if (message) appendClientLog(level, source, message)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:save-snapshot')
|
||||
ipcMain.handle('workspace:save-snapshot', (_evt, key: unknown, data: unknown) => {
|
||||
if (typeof key !== 'string' || !key.trim()) return
|
||||
saveDataSnapshot(key.trim(), data)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('workspace:get-dashboard')
|
||||
ipcMain.handle('workspace:get-dashboard', () => getDashboardSnapshot())
|
||||
|
||||
ipcMain.removeHandler('app:get-setting')
|
||||
ipcMain.handle('app:get-setting', (_evt, key: unknown) => {
|
||||
if (typeof key !== 'string' || !key.trim()) return null
|
||||
return getAppSetting(key.trim())
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('app:set-setting')
|
||||
ipcMain.handle('app:set-setting', (_evt, key: unknown, value: unknown) => {
|
||||
if (typeof key !== 'string' || !key.trim()) return
|
||||
if (typeof value !== 'string') return
|
||||
setAppSetting(key.trim(), value)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('relay:sync-config')
|
||||
ipcMain.handle('relay:sync-config', () => {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
syncRelayChannelsWithBackendConfig(path.join(br, 'config'))
|
||||
return { ok: true as const }
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('relay:import-from-config')
|
||||
ipcMain.handle('relay:import-from-config', () => {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
importRelayChannelsFromConfigDir(path.join(br, 'config'))
|
||||
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', (_evt, suffix: unknown) => {
|
||||
ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('open-external')
|
||||
ipcMain.handle('open-external', async (_evt, url: unknown) => {
|
||||
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
|
||||
throw new Error('无效的链接地址')
|
||||
}
|
||||
await shell.openExternal(url)
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('web2:get-api-token')
|
||||
ipcMain.handle('web2:get-api-token', () => resolveWeb2ApiTokenForChild() ?? null)
|
||||
|
||||
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(),
|
||||
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 {
|
||||
v: 1 as const,
|
||||
baseUrl: `http://${ip}:${PORT}`,
|
||||
port: PORT,
|
||||
token,
|
||||
easytier: {
|
||||
networkName: cfg.networkName,
|
||||
networkSecret: cfg.networkSecret,
|
||||
peerUrls: cfg.peerUrls,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('remote:set-enabled')
|
||||
ipcMain.handle('remote:set-enabled', async (_evt, enabled: unknown) => {
|
||||
if (process.env.WEB2_HOST?.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: '已设置环境变量 WEB2_HOST,请改用环境变量或删除后再用界面开关。',
|
||||
}
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { ok: false, error: '参数无效' }
|
||||
}
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const cfgDir = path.join(br, 'config')
|
||||
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
|
||||
})
|
||||
|
||||
ipcMain.removeHandler('remote:generate-token')
|
||||
ipcMain.handle('remote:generate-token', async () => {
|
||||
const br = _backendRoot ?? getBackendRoot()
|
||||
const cfgDir = path.join(br, 'config')
|
||||
fs.mkdirSync(cfgDir, { recursive: true })
|
||||
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
|
||||
})
|
||||
|
||||
web2Host = resolveWeb2Host()
|
||||
|
||||
const backendRoot = getBackendRoot()
|
||||
const pythonExe = resolvePythonExe(backendRoot)
|
||||
_backendRoot = backendRoot
|
||||
_pythonExe = pythonExe
|
||||
|
||||
if (!fs.existsSync(path.join(backendRoot, 'web2.py'))) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`未找到程序必要文件,当前安装可能不完整。\n\n路径:${backendRoot}\n\n开发调试:请在项目根目录保留 web2.py,并在 electron 目录执行 pnpm dev。`,
|
||||
)
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
startPythonBackend(backendRoot, pythonExe)
|
||||
|
||||
try {
|
||||
await waitForHealth()
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(
|
||||
'直播录制助手',
|
||||
`软件未能正常启动。\n\n${e instanceof Error ? e.message : String(e)}`,
|
||||
)
|
||||
killPythonTree()
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
startBundledEasyTier(backendRoot)
|
||||
|
||||
appendClientLog('info', 'main', '本地服务已连接,准备打开界面')
|
||||
|
||||
await createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
stopBundledEasyTier()
|
||||
killPythonTree()
|
||||
app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
appendClientLog('info', 'main', '应用退出')
|
||||
flushClientDb()
|
||||
stopBundledEasyTier()
|
||||
killPythonTree()
|
||||
})
|
||||
}
|
||||
120
electron/src/main/youtubeStudioSidecar.ts
Normal file
120
electron/src/main/youtubeStudioSidecar.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* YouTube Studio 直播后台侧窗:自动点击 Dismiss(对齐 GreasyFork「YouTube Studio Auto Dismiss」逻辑),
|
||||
* 并可检测页面文本是否包含串流密钥后缀以便与本地配置对应。
|
||||
*/
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
let studioWin: BrowserWindow | null = null
|
||||
|
||||
const STUDIO_ENTRY = 'https://studio.youtube.com/livestreaming'
|
||||
|
||||
/** 与油猴脚本等价的定时查找 Dismiss 按钮并点击(含 Shadow DOM) */
|
||||
const AUTO_DISMISS_JS = `
|
||||
(function(){
|
||||
if (window.__douyinRecorderYtDismiss) return;
|
||||
window.__douyinRecorderYtDismiss = true;
|
||||
function isVisible(el) {
|
||||
if (!el) return false;
|
||||
try {
|
||||
var s = window.getComputedStyle(el);
|
||||
return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
function findDismiss(root) {
|
||||
root = root || document;
|
||||
var buttons = root.querySelectorAll('button');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
var el = buttons[i];
|
||||
try {
|
||||
var text = (el.innerText || '').trim();
|
||||
if (text === 'Dismiss' && isVisible(el)) return el;
|
||||
if (el.shadowRoot) {
|
||||
var x = findDismiss(el.shadowRoot);
|
||||
if (x) return x;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function clickEl(el) {
|
||||
if (!el) return;
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
function tick() {
|
||||
var b = findDismiss();
|
||||
if (b) clickEl(b);
|
||||
}
|
||||
if (document.readyState === 'complete') tick();
|
||||
else window.addEventListener('load', tick);
|
||||
setInterval(tick, 5000);
|
||||
})();
|
||||
true;
|
||||
`
|
||||
|
||||
const CHECK_KEY_JS = (suffix: string) => `
|
||||
(function(){
|
||||
var s = (${JSON.stringify(suffix)} || '').trim();
|
||||
if (!s) return { ok: true, hasSuffix: true };
|
||||
try {
|
||||
var t = document.body ? document.body.innerText : '';
|
||||
return { ok: true, hasSuffix: t.indexOf(s) >= 0 };
|
||||
} catch (e) {
|
||||
return { ok: false, hasSuffix: false };
|
||||
}
|
||||
})();
|
||||
`
|
||||
|
||||
export function ensureYoutubeStudioWindow(streamKeySuffix: string | undefined): void {
|
||||
const suffix = (streamKeySuffix || '').trim().slice(-12)
|
||||
|
||||
const inject = (wc: Electron.WebContents): void => {
|
||||
const u = wc.getURL()
|
||||
if (!u.includes('studio.youtube.com')) return
|
||||
void wc.executeJavaScript(AUTO_DISMISS_JS, true).catch(() => {})
|
||||
if (suffix) {
|
||||
void wc.executeJavaScript(CHECK_KEY_JS(suffix), true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (studioWin && !studioWin.isDestroyed()) {
|
||||
studioWin.show()
|
||||
studioWin.focus()
|
||||
const wc = studioWin.webContents
|
||||
const cur = wc.getURL()
|
||||
if (cur.includes('studio.youtube.com')) {
|
||||
inject(wc)
|
||||
} else {
|
||||
wc.once('did-finish-load', () => inject(wc))
|
||||
void wc.loadURL(STUDIO_ENTRY)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
studioWin = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
minWidth: 800,
|
||||
minHeight: 560,
|
||||
show: true,
|
||||
title: 'YouTube Studio 直播',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
/* 需登录 Google;sandbox 过严时可能导致 OAuth/ Cookie 异常 */
|
||||
sandbox: false,
|
||||
partition: 'persist:yt-studio',
|
||||
},
|
||||
})
|
||||
|
||||
if (app.isPackaged) {
|
||||
studioWin.removeMenu()
|
||||
}
|
||||
|
||||
const wc = studioWin.webContents
|
||||
wc.once('did-finish-load', () => inject(wc))
|
||||
void wc.loadURL(STUDIO_ENTRY)
|
||||
|
||||
studioWin.on('closed', () => {
|
||||
studioWin = null
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user