From 1f503f966820130364b4833a676bc640234a7c51 Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 24 Mar 2026 17:35:28 -0500 Subject: [PATCH] 's' --- electron/package.json | 16 +- electron/pnpm-lock.yaml | 6 +- electron/resources/chrome-cft/.gitkeep | 0 .../scripts/download-chrome-for-testing.mjs | 143 +++++++++++ .../src/main/chromeForTestingBootstrap.ts | 237 ++++++++++++++++++ electron/src/main/index.ts | 30 ++- electron/src/main/youtubeExternalChrome.ts | 180 +++++++++++++ electron/src/main/youtubeStudioSidecar.ts | 17 +- electron/src/preload/index.ts | 3 + electron/src/renderer/src/App.tsx | 135 ++++++++++ electron/src/renderer/src/vite-env.d.ts | 9 + 11 files changed, 765 insertions(+), 11 deletions(-) create mode 100644 electron/resources/chrome-cft/.gitkeep create mode 100644 electron/scripts/download-chrome-for-testing.mjs create mode 100644 electron/src/main/chromeForTestingBootstrap.ts create mode 100644 electron/src/main/youtubeExternalChrome.ts diff --git a/electron/package.json b/electron/package.json index ef10df6..59b9bb1 100644 --- a/electron/package.json +++ b/electron/package.json @@ -7,8 +7,9 @@ "type": "module", "scripts": { "prepare-easytier": "node scripts/download-easytier.mjs", - "dev": "node scripts/download-easytier.mjs && electron-vite dev", - "build": "node scripts/download-easytier.mjs && electron-vite build", + "prepare-chrome": "node scripts/download-chrome-for-testing.mjs", + "dev": "node scripts/download-easytier.mjs && node scripts/download-chrome-for-testing.mjs && electron-vite dev", + "build": "node scripts/download-easytier.mjs && node scripts/download-chrome-for-testing.mjs && electron-vite build", "preview": "electron-vite preview", "dist": "pnpm run build && electron-builder --win --publish never" }, @@ -19,7 +20,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "sql.js": "^1.14.1", - "three": "^0.183.2" + "three": "^0.183.2", + "adm-zip": "^0.5.16" }, "devDependencies": { "@types/d3": "^7.4.3", @@ -29,7 +31,6 @@ "@types/react-dom": "^18.3.1", "@types/three": "^0.183.1", "@vitejs/plugin-react": "^4.3.4", - "adm-zip": "^0.5.16", "electron": "^33.2.0", "electron-builder": "^25.1.8", "electron-vite": "^2.3.0", @@ -62,6 +63,13 @@ "**/*" ] }, + { + "from": "resources/chrome-cft", + "to": "chrome-cft", + "filter": [ + "**/*" + ] + }, { "from": "..", "to": "backend", diff --git a/electron/pnpm-lock.yaml b/electron/pnpm-lock.yaml index 44b87d8..ebf011d 100644 --- a/electron/pnpm-lock.yaml +++ b/electron/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 d3: specifier: ^7.9.0 version: 7.9.0 @@ -51,9 +54,6 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@22.19.15)) - adm-zip: - specifier: ^0.5.16 - version: 0.5.16 electron: specifier: ^33.2.0 version: 33.4.11 diff --git a/electron/resources/chrome-cft/.gitkeep b/electron/resources/chrome-cft/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/electron/scripts/download-chrome-for-testing.mjs b/electron/scripts/download-chrome-for-testing.mjs new file mode 100644 index 0000000..f984b7d --- /dev/null +++ b/electron/scripts/download-chrome-for-testing.mjs @@ -0,0 +1,143 @@ +/** + * 下载 Chrome for Testing(Stable / win64)完整目录到 resources/chrome-cft/chrome-win64/, + * 与 chrome.exe 同级的 DLL 等依赖一并保留,随安装包分发,开箱即用。 + * 设置 SKIP_CHROME=1 可跳过(CI 无网环境)。 + */ +import fs from 'fs' +import https from 'https' +import path from 'path' +import { fileURLToPath } from 'url' +import AdmZip from 'adm-zip' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '..') +const resDir = path.join(root, 'resources', 'chrome-cft') +const CFT_JSON_URL = + 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json' +const PLATFORM_KEY = 'win64' +const markerName = '.chrome_cft_bundle_ok' + +if (process.env.SKIP_CHROME === '1') { + console.log('[chrome-cft] SKIP_CHROME=1,跳过下载') + process.exit(0) +} + +function downloadBuffer(url) { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + downloadBuffer(res.headers.location).then(resolve).catch(reject) + return + } + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} ${url}`)) + return + } + const chunks = [] + res.on('data', (c) => chunks.push(c)) + res.on('end', () => resolve(Buffer.concat(chunks))) + res.on('error', reject) + }) + .on('error', reject) + }) +} + +function downloadToFile(url, dest) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest) + https + .get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + file.close() + try { + fs.unlinkSync(dest) + } catch { + /* ignore */ + } + downloadToFile(res.headers.location, dest).then(resolve).catch(reject) + return + } + if (res.statusCode !== 200) { + file.close() + reject(new Error(`HTTP ${res.statusCode}`)) + return + } + res.pipe(file) + file.on('finish', () => { + file.close() + resolve() + }) + }) + .on('error', (e) => { + try { + file.close() + } catch { + /* ignore */ + } + reject(e) + }) + }) +} + +const chromeExe = path.join(resDir, 'chrome-win64', 'chrome.exe') +const markerPath = path.join(resDir, markerName) + +fs.mkdirSync(resDir, { recursive: true }) + +if (fs.existsSync(chromeExe) && fs.existsSync(markerPath)) { + console.log('[chrome-cft] 已存在完整包,跳过:', chromeExe) + process.exit(0) +} + +console.log('[chrome-cft] 获取元数据:', CFT_JSON_URL) +const metaBuf = await downloadBuffer(CFT_JSON_URL) +const doc = JSON.parse(metaBuf.toString('utf-8')) +const stable = doc.channels?.Stable +const list = stable?.downloads?.chrome ?? [] +const hit = list.find((x) => x.platform === PLATFORM_KEY) +if (!hit?.url) { + console.error('[chrome-cft] 元数据中无', PLATFORM_KEY) + process.exit(1) +} + +const zipPath = path.join(resDir, '_chrome-download.zip') +console.log('[chrome-cft] 下载(体积较大):', hit.url) +await downloadToFile(hit.url, zipPath) + +console.log('[chrome-cft] 解压中…') +const zip = new AdmZip(zipPath) +const tmp = path.join(resDir, '_extract_tmp') +if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true }) +fs.mkdirSync(tmp, { recursive: true }) +zip.extractAllTo(tmp, true) + +const inner = path.join(tmp, 'chrome-win64') +if (!fs.existsSync(path.join(inner, 'chrome.exe'))) { + console.error('[chrome-cft] zip 内未找到 chrome-win64/chrome.exe') + process.exit(1) +} + +const destDir = path.join(resDir, 'chrome-win64') +if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true }) +fs.cpSync(inner, destDir, { recursive: true }) +fs.rmSync(tmp, { recursive: true }) + +try { + fs.unlinkSync(zipPath) +} catch { + /* ignore */ +} + +fs.writeFileSync( + path.join(resDir, 'NOTICE-ChromeForTesting.txt'), + [ + '本目录包含 Chrome for Testing(Stable / Windows x64)。', + '清单来源:' + CFT_JSON_URL, + '与 chrome.exe 同目录文件均为官方 zip 原样复制,未修改二进制。', + '', + ].join('\n'), + 'utf-8', +) +fs.writeFileSync(markerPath, `${stable.version}\n${new Date().toISOString()}\n`, 'utf-8') +console.log('[chrome-cft] 完成:', chromeExe) diff --git a/electron/src/main/chromeForTestingBootstrap.ts b/electron/src/main/chromeForTestingBootstrap.ts new file mode 100644 index 0000000..bad6a20 --- /dev/null +++ b/electron/src/main/chromeForTestingBootstrap.ts @@ -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 { + 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 { + 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 | null = null + +/** + * 确保 userData 下已有 CFT Chrome;若尚无则下载并解压。失败返回 null。 + */ +export function ensureChromeForTestingInstalled(): Promise { + const existing = getCftChromePathIfPresent() + if (existing) return Promise.resolve(existing) + + if (!inFlight) { + inFlight = doInstall().finally(() => { + inFlight = null + }) + } + return inFlight +} + +async function doInstall(): Promise { + 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 */ + } + } +} diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 89dbc6d..db6adfd 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -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 + 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') diff --git a/electron/src/main/youtubeExternalChrome.ts b/electron/src/main/youtubeExternalChrome.ts new file mode 100644 index 0000000..637d69a --- /dev/null +++ b/electron/src/main/youtubeExternalChrome.ts @@ -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 { + 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 { + 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) +} diff --git a/electron/src/main/youtubeStudioSidecar.ts b/electron/src/main/youtubeStudioSidecar.ts index df468c4..fe84b3c 100644 --- a/electron/src/main/youtubeStudioSidecar.ts +++ b/electron/src/main/youtubeStudioSidecar.ts @@ -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 { 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 diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index a1f2c0d..27648d0 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld('deskBridge', { getEasyTierPeers: () => ipcRenderer.invoke('easytier:get-peer-stats'), ensureYoutubeStudio: (streamKeySuffix?: string) => ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''), + getYoutubeStudioBrowserSettings: () => ipcRenderer.invoke('youtube-studio:get-browser-settings'), + setYoutubeStudioBrowserSettings: (payload: { mode: string }) => + ipcRenderer.invoke('youtube-studio:set-browser-settings', payload), }) contextBridge.exposeInMainWorld('appTheme', { diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 4217299..163b356 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -156,6 +156,15 @@ export default function App() { const [proYoutubeKey, setProYoutubeKey] = useState('') const [saveProKeyLoading, setSaveProKeyLoading] = useState(false) + const [ytStudioBrowserMode, setYtStudioBrowserMode] = useState<'embedded' | 'external_chrome'>('embedded') + const [ytStudioChromeResolved, setYtStudioChromeResolved] = useState(null) + const [ytStudioBrowserResolution, setYtStudioBrowserResolution] = useState< + 'bundled' | 'system' | 'runtime' | 'missing' + >('missing') + const [ytStudioUserDataDir, setYtStudioUserDataDir] = useState('') + const [ytStudioBrowserHint, setYtStudioBrowserHint] = useState('') + const [ytStudioDeskReady, setYtStudioDeskReady] = useState(false) + const logRef = useRef(null) useEffect(() => { @@ -175,6 +184,21 @@ export default function App() { }) }, []) + useEffect(() => { + const db = window.deskBridge + if (!db?.getYoutubeStudioBrowserSettings) { + setYtStudioDeskReady(false) + return + } + setYtStudioDeskReady(true) + void db.getYoutubeStudioBrowserSettings().then((s) => { + setYtStudioBrowserMode(s.mode === 'embedded' ? 'embedded' : 'external_chrome') + setYtStudioChromeResolved(s.resolvedChromePath) + setYtStudioUserDataDir(s.userDataDir) + setYtStudioBrowserResolution(s.chromeResolution) + }) + }, []) + const applyTheme = useCallback((t: AppTheme) => { setTheme(t) void window.appTheme?.set?.(t) @@ -813,6 +837,117 @@ export default function App() { {!emptyHint && (
+ {isYoutube && ytStudioDeskReady && ( +
+

YouTube Studio / 直播控制台

+

+ 内嵌窗口使用 Electron 自带 Chromium,易被识别为自动化环境。选用外部 Chrome在独立 + profile 中打开官方直播控制台,有利于降低风控。安装包已内置 Windows x64 Chrome for + Testing 完整目录,无需再下载或手动配置;仅在开发跳过打包脚本时才会向用户目录拉取备用副本。 +

+
+ 打开方式 +
+ + +
+
+ {ytStudioBrowserMode === 'external_chrome' && ( +
+
+ + +
+
+                      {ytStudioBrowserResolution === 'bundled' && ytStudioChromeResolved
+                        ? `安装包内置 Chrome:${ytStudioChromeResolved}`
+                        : ytStudioBrowserResolution === 'system' && ytStudioChromeResolved
+                          ? `将使用本机已安装的 Chrome:${ytStudioChromeResolved}`
+                          : ytStudioBrowserResolution === 'runtime' && ytStudioChromeResolved
+                            ? `用户目录备用副本:${ytStudioChromeResolved}`
+                            : ytStudioBrowserResolution === 'missing'
+                              ? '正在准备 Chrome(若开发环境未执行 prepare-chrome,启动后会在后台下载备用副本)。'
+                              : '—'}
+                      {ytStudioUserDataDir ? `\n独立登录数据目录:${ytStudioUserDataDir}` : ''}
+                    
+ {ytStudioBrowserHint ?
{ytStudioBrowserHint}
: null} +
+ )} + {ytStudioBrowserMode === 'embedded' && ( +
+
+ +
+ {ytStudioBrowserHint ?
{ytStudioBrowserHint}
: null} +
+ )} +
+ )} + {isYoutube && multiChannelPro && (

Pro 转播线路

diff --git a/electron/src/renderer/src/vite-env.d.ts b/electron/src/renderer/src/vite-env.d.ts index c0c31eb..8a5a242 100644 --- a/electron/src/renderer/src/vite-env.d.ts +++ b/electron/src/renderer/src/vite-env.d.ts @@ -54,6 +54,15 @@ interface Window { onlinePeers?: number }> ensureYoutubeStudio?: (streamKeySuffix?: string) => Promise + getYoutubeStudioBrowserSettings?: () => Promise<{ + mode: 'embedded' | 'external_chrome' + resolvedChromePath: string | null + userDataDir: string + chromeResolution: 'bundled' | 'system' | 'runtime' | 'missing' + }> + setYoutubeStudioBrowserSettings?: (payload: { + mode: 'embedded' | 'external_chrome' + }) => Promise<{ ok: boolean; error?: string }> } appTheme?: { get?: () => Promise