'init'
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled

This commit is contained in:
eric
2026-03-24 14:46:41 -05:00
parent c5a1a9a404
commit b77a8cadb2
164 changed files with 57060 additions and 58 deletions

View 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 */
}
}

View 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)
}

View 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
View 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()
})
}

View 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
})
}