This commit is contained in:
eric
2026-03-24 17:35:28 -05:00
parent b77a8cadb2
commit 1f503f9668
11 changed files with 765 additions and 11 deletions

View File

@@ -0,0 +1,237 @@
/**
* 1) 安装包内置:构建时 scripts/download-chrome-for-testing.mjs 解压至 resources/chrome-cft/(随 extraResources 分发)
* 2) 兜底:无内置时从官方源下载至 userData/cft-chrome/
*/
import { app } from 'electron'
import fs, { createWriteStream } from 'fs'
import path from 'path'
import { Readable } from 'stream'
import { pipeline } from 'stream/promises'
import { fileURLToPath } from 'url'
import AdmZip from 'adm-zip'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
import { appendClientLog } from './clientDb'
const CFT_JSON_URL =
'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'
const LOCK_NAME = 'cft-chrome.lock'
function cftRoot(): string {
return path.join(app.getPath('userData'), 'cft-chrome')
}
function fileExists(p: string): boolean {
try {
return fs.existsSync(p) && fs.statSync(p).isFile()
} catch {
return false
}
}
/** 与官方 zip 内顶层目录名一致,如 chrome-win64、chrome-mac-arm64 */
export function getCftPlatformKey(): string {
if (process.platform === 'win32') {
return process.arch === 'ia32' ? 'win32' : 'win64'
}
if (process.platform === 'darwin') {
return process.arch === 'arm64' ? 'mac-arm64' : 'mac-x64'
}
return 'linux64'
}
function extractFolderName(platformKey: string): string {
return `chrome-${platformKey}`
}
/** 解压完成后可执行文件相对 cftRoot 的路径(按官方包结构) */
function expectedChromeRelative(platformKey: string): string {
const folder = extractFolderName(platformKey)
if (platformKey === 'win32' || platformKey === 'win64') {
return path.join(folder, 'chrome.exe')
}
if (platformKey === 'linux64') {
return path.join(extractFolderName(platformKey), 'chrome')
}
if (platformKey === 'mac-arm64') {
return path.join(
'chrome-mac-arm64',
'Google Chrome for Testing.app',
'Contents',
'MacOS',
'Google Chrome for Testing',
)
}
if (platformKey === 'mac-x64') {
return path.join(
'chrome-mac-x64',
'Google Chrome for Testing.app',
'Contents',
'MacOS',
'Google Chrome for Testing',
)
}
return path.join(folder, 'chrome.exe')
}
export function getCftChromePathIfPresent(): string | null {
const root = cftRoot()
const rel = expectedChromeRelative(getCftPlatformKey())
const full = path.join(root, rel)
return fileExists(full) ? full : null
}
/** 安装包/开发目录随包分发的 Chrome for Testing(Windows x64),含完整依赖目录 */
export function getBundledChromePath(): string | null {
if (process.platform !== 'win32') return null
const rel = path.join('chrome-win64', 'chrome.exe')
if (app.isPackaged) {
const p = path.join(process.resourcesPath, 'chrome-cft', rel)
return fileExists(p) ? p : null
}
const dev = path.resolve(__dirname, '../../resources/chrome-cft', rel)
return fileExists(dev) ? dev : null
}
type CftJson = {
channels?: {
Stable?: {
version?: string
downloads?: {
chrome?: Array<{ platform: string; url: string }>
}
}
}
}
async function fetchJson(): Promise<CftJson> {
const res = await fetch(CFT_JSON_URL, { redirect: 'follow' })
if (!res.ok) {
throw new Error(`获取 Chrome for Testing 元数据失败:HTTP ${res.status}`)
}
return (await res.json()) as CftJson
}
function pickChromeZipUrl(doc: CftJson, platformKey: string): { version: string; url: string } {
const stable = doc.channels?.Stable
const version = stable?.version?.trim() ?? ''
const list = stable?.downloads?.chrome ?? []
const hit = list.find((x) => x.platform === platformKey)
if (!hit?.url) {
throw new Error(`Chrome for Testing 元数据中缺少平台 ${platformKey} 的下载地址。`)
}
return { version, url: hit.url }
}
async function downloadFile(url: string, dest: string): Promise<void> {
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) {
throw new Error(`下载失败:HTTP ${res.status}`)
}
if (!res.body) {
throw new Error('下载失败:无响应体')
}
await fs.promises.mkdir(path.dirname(dest), { recursive: true })
const tmp = `${dest}.part`
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(tmp))
try {
fs.renameSync(tmp, dest)
} catch {
fs.copyFileSync(tmp, dest)
fs.unlinkSync(tmp)
}
}
let inFlight: Promise<string | null> | null = null
/**
* 确保 userData 下已有 CFT Chrome;若尚无则下载并解压。失败返回 null。
*/
export function ensureChromeForTestingInstalled(): Promise<string | null> {
const existing = getCftChromePathIfPresent()
if (existing) return Promise.resolve(existing)
if (!inFlight) {
inFlight = doInstall().finally(() => {
inFlight = null
})
}
return inFlight
}
async function doInstall(): Promise<string | null> {
const root = cftRoot()
const platformKey = getCftPlatformKey()
const lockPath = path.join(root, LOCK_NAME)
const zipPath = path.join(root, `chrome-${platformKey}.zip`)
const versionPath = path.join(root, 'version.txt')
try {
fs.mkdirSync(root, { recursive: true })
} catch {
/* ignore */
}
try {
fs.writeFileSync(lockPath, `${Date.now()}\n`, 'utf-8')
} catch {
/* ignore */
}
appendClientLog(
'info',
'main',
'未找到内置 Chrome 与系统安装,正从 Chrome for Testing 拉取备用副本至用户目录(体积较大)…',
)
try {
const doc = await fetchJson()
const { version, url } = pickChromeZipUrl(doc, platformKey)
appendClientLog('info', 'main', `Chrome for Testing 版本:${version}`)
await downloadFile(url, zipPath)
appendClientLog('info', 'main', '下载完成,正在解压…')
const zip = new AdmZip(zipPath)
zip.extractAllTo(root, true)
try {
fs.unlinkSync(zipPath)
} catch {
/* ignore */
}
const exe = path.join(root, expectedChromeRelative(platformKey))
if (!fileExists(exe)) {
appendClientLog('error', 'main', `解压后未找到 Chrome:${exe}`)
return null
}
try {
fs.writeFileSync(versionPath, `${version}\n`, 'utf-8')
} catch {
/* ignore */
}
appendClientLog('info', 'main', `Chrome for Testing 已就绪:${exe}`)
return exe
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
appendClientLog('error', 'main', `自动安装 Chrome for Testing 失败:${msg}`)
try {
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath)
} catch {
/* ignore */
}
return null
} finally {
try {
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath)
} catch {
/* ignore */
}
}
}

View File

@@ -16,6 +16,11 @@ import {
startBundledEasyTier,
stopBundledEasyTier,
} from './easytier'
import {
applyYoutubeStudioBrowserSettings,
ensureChromeExecutable,
getYoutubeStudioBrowserSettings,
} from './youtubeExternalChrome'
import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar'
import {
appendClientLog,
@@ -487,6 +492,10 @@ if (!gotLock) {
console.error('[clientDb]', e)
}
void ensureChromeExecutable().catch(() => {
/* 无内置时后台拉取备用 Chrome;失败不影响主流程 */
})
ipcMain.removeHandler('workspace:log-live')
ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => {
if (!payload || typeof payload !== 'object') return
@@ -563,8 +572,25 @@ if (!gotLock) {
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.handle('youtube-studio:ensure', async (_evt, suffix: unknown) => {
await ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined)
})
ipcMain.removeHandler('youtube-studio:get-browser-settings')
ipcMain.handle('youtube-studio:get-browser-settings', () => getYoutubeStudioBrowserSettings())
ipcMain.removeHandler('youtube-studio:set-browser-settings')
ipcMain.handle('youtube-studio:set-browser-settings', (_evt, payload: unknown) => {
if (!payload || typeof payload !== 'object') {
return { ok: false as const, error: '参数无效' }
}
const p = payload as Record<string, unknown>
const mode = p.mode
if (mode !== 'embedded' && mode !== 'external_chrome') {
return { ok: false as const, error: '模式无效' }
}
applyYoutubeStudioBrowserSettings(mode)
return { ok: true as const }
})
ipcMain.removeHandler('open-external')

View File

@@ -0,0 +1,180 @@
/**
* 使用真实 Chrome(非 Electron Chromium)打开 YouTube Studio / 直播控制台。
* 优先使用安装包内置 Chrome for Testing;其次本机 Google Chrome;最后才下载至 userData。
*/
import { app } from 'electron'
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
import { appendClientLog, getAppSetting, setAppSetting } from './clientDb'
import {
ensureChromeForTestingInstalled,
getBundledChromePath,
getCftChromePathIfPresent,
} from './chromeForTestingBootstrap'
/** 直播控制台(与侧窗一致) */
export const YOUTUBE_STUDIO_LIVE_URL = 'https://studio.youtube.com/livestreaming'
const SETTING_BROWSER = 'youtube_studio_browser'
export type YoutubeStudioBrowserMode = 'embedded' | 'external_chrome'
export function getYoutubeStudioBrowserMode(): YoutubeStudioBrowserMode {
const v = getAppSetting(SETTING_BROWSER)?.trim()
if (v === 'embedded' || v === 'external_chrome') return v
return process.platform === 'win32' ? 'external_chrome' : 'embedded'
}
/** Chrome 专用用户数据目录(登录态与 Electron 隔离) */
export function getChromeUserDataDirForYoutube(): string {
return path.join(app.getPath('userData'), 'chrome-profile-youtube-studio')
}
function fileExists(p: string): boolean {
try {
return fs.existsSync(p) && fs.statSync(p).isFile()
} catch {
return false
}
}
function windowsInstallCandidates(): string[] {
const local = process.env.LOCALAPPDATA
const pf = process.env['ProgramFiles']
const pf86 = process.env['ProgramFiles(x86)']
const list: string[] = []
if (local) {
list.push(path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'))
}
if (pf) {
list.push(path.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'))
}
if (pf86) {
list.push(path.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'))
}
return list
}
function macChromePath(): string | null {
const p = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
return fileExists(p) ? p : null
}
/**
* 同步解析已存在的 Chrome:内置包 → 系统安装 → userData 已下载副本。不含网络。
*/
export function resolveChromeExecutableSync(): string | null {
const bundled = getBundledChromePath()
if (bundled) return bundled
if (process.platform === 'win32') {
for (const p of windowsInstallCandidates()) {
if (fileExists(p)) return p
}
const cft = getCftChromePathIfPresent()
if (cft) return cft
return null
}
if (process.platform === 'darwin') {
const sys = macChromePath()
if (sys) return sys
return getCftChromePathIfPresent()
}
const linuxNames = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']
for (const name of linuxNames) {
const common = `/usr/bin/${name}`
if (fileExists(common)) return common
}
const cft = getCftChromePathIfPresent()
if (cft) return cft
return null
}
/** 确保有可用的 Chrome:先同步路径,否则触发自动下载 CFT 至 userData。 */
export async function ensureChromeExecutable(): Promise<string | null> {
const sync = resolveChromeExecutableSync()
if (sync) return sync
return ensureChromeForTestingInstalled()
}
export type LaunchYoutubeChromeResult = { ok: true } | { ok: false; error: string }
/**
* 启动外部 Chrome 并打开 YouTube Studio 直播页;进程与主应用分离(detached)。
*/
export async function launchYoutubeStudioExternalChrome(
targetUrl: string = YOUTUBE_STUDIO_LIVE_URL,
): Promise<LaunchYoutubeChromeResult> {
const exe = await ensureChromeExecutable()
if (!exe) {
return {
ok: false,
error:
'无法启动 Chrome:未找到内置浏览器且下载备用副本失败。请检查网络或重新执行构建脚本 prepare-chrome。',
}
}
const userDataDir = getChromeUserDataDirForYoutube()
try {
fs.mkdirSync(userDataDir, { recursive: true })
} catch {
/* ignore */
}
const args = [
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--no-default-browser-check',
targetUrl,
]
try {
const child = spawn(exe, args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
})
child.unref()
if (child.pid === undefined) {
return { ok: false, error: 'Chrome 未能启动(无进程号)。' }
}
appendClientLog('info', 'main', `已用外部 Chrome 打开 YouTube Studio(${exe})`)
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
export type ChromeResolutionHint = 'bundled' | 'system' | 'runtime' | 'missing'
function resolutionHint(resolved: string | null): ChromeResolutionHint {
if (!resolved) return 'missing'
const b = getBundledChromePath()
if (b && path.normalize(resolved) === path.normalize(b)) return 'bundled'
const cftBase = path.normalize(path.join(app.getPath('userData'), 'cft-chrome'))
if (path.normalize(resolved).startsWith(cftBase)) return 'runtime'
return 'system'
}
export function getYoutubeStudioBrowserSettings(): {
mode: YoutubeStudioBrowserMode
resolvedChromePath: string | null
userDataDir: string
chromeResolution: ChromeResolutionHint
} {
const resolved = resolveChromeExecutableSync()
return {
mode: getYoutubeStudioBrowserMode(),
resolvedChromePath: resolved,
userDataDir: getChromeUserDataDirForYoutube(),
chromeResolution: resolutionHint(resolved),
}
}
export function applyYoutubeStudioBrowserSettings(mode: YoutubeStudioBrowserMode): void {
setAppSetting(SETTING_BROWSER, mode)
}

View File

@@ -2,7 +2,12 @@
* YouTube Studio 直播后台侧窗:自动点击 Dismiss(对齐 GreasyFork「YouTube Studio Auto Dismiss」逻辑),
* 并可检测页面文本是否包含串流密钥后缀以便与本地配置对应。
*/
import { BrowserWindow, app } from 'electron'
import { BrowserWindow, app, dialog } from 'electron'
import {
getYoutubeStudioBrowserMode,
launchYoutubeStudioExternalChrome,
} from './youtubeExternalChrome'
let studioWin: BrowserWindow | null = null
const STUDIO_ENTRY = 'https://studio.youtube.com/livestreaming'
@@ -63,9 +68,17 @@ const CHECK_KEY_JS = (suffix: string) => `
})();
`
export function ensureYoutubeStudioWindow(streamKeySuffix: string | undefined): void {
export async function ensureYoutubeStudioWindow(streamKeySuffix: string | undefined): Promise<void> {
const suffix = (streamKeySuffix || '').trim().slice(-12)
if (getYoutubeStudioBrowserMode() === 'external_chrome') {
const r = await launchYoutubeStudioExternalChrome(STUDIO_ENTRY)
if (!r.ok) {
dialog.showErrorBox('YouTube Studio(外部 Chrome)', r.error)
}
return
}
const inject = (wc: Electron.WebContents): void => {
const u = wc.getURL()
if (!u.includes('studio.youtube.com')) return