2 Commits
main ... dev

Author SHA1 Message Date
eric
f6912cff20 'ss' 2026-03-24 17:37:08 -05:00
eric
1f503f9668 's' 2026-03-24 17:35:28 -05:00
12 changed files with 772 additions and 11 deletions

7
.gitignore vendored
View File

@@ -178,6 +178,13 @@ config/wechat_chatbot.state.json
electron/resources/easytier/easytier-core.exe electron/resources/easytier/easytier-core.exe
electron/resources/easytier/_extract_tmp/ electron/resources/easytier/_extract_tmp/
# Chrome for Testing 构建产物(脚本每次生成)
electron/resources/chrome-cft/chrome-win64/
electron/resources/chrome-cft/_extract_tmp/
electron/resources/chrome-cft/_chrome-download.zip
electron/resources/chrome-cft/.chrome_cft_bundle_ok
electron/resources/chrome-cft/NOTICE-ChromeForTesting.txt
# Electron 桌面壳 # Electron 桌面壳
electron/node_modules/ electron/node_modules/
electron/dist/ electron/dist/

View File

@@ -7,8 +7,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"prepare-easytier": "node scripts/download-easytier.mjs", "prepare-easytier": "node scripts/download-easytier.mjs",
"dev": "node scripts/download-easytier.mjs && electron-vite dev", "prepare-chrome": "node scripts/download-chrome-for-testing.mjs",
"build": "node scripts/download-easytier.mjs && electron-vite build", "dev": "node scripts/download-easytier.mjs && node scripts/download-chrome-for-testing.mjs && electron-vite dev",
"build": "node scripts/download-easytier.mjs && node scripts/download-chrome-for-testing.mjs && electron-vite build",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"dist": "pnpm run build && electron-builder --win --publish never" "dist": "pnpm run build && electron-builder --win --publish never"
}, },
@@ -19,7 +20,8 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"three": "^0.183.2" "three": "^0.183.2",
"adm-zip": "^0.5.16"
}, },
"devDependencies": { "devDependencies": {
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
@@ -29,7 +31,6 @@
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"adm-zip": "^0.5.16",
"electron": "^33.2.0", "electron": "^33.2.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"electron-vite": "^2.3.0", "electron-vite": "^2.3.0",
@@ -62,6 +63,13 @@
"**/*" "**/*"
] ]
}, },
{
"from": "resources/chrome-cft",
"to": "chrome-cft",
"filter": [
"**/*"
]
},
{ {
"from": "..", "from": "..",
"to": "backend", "to": "backend",

View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
adm-zip:
specifier: ^0.5.16
version: 0.5.16
d3: d3:
specifier: ^7.9.0 specifier: ^7.9.0
version: 7.9.0 version: 7.9.0
@@ -51,9 +54,6 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.4 specifier: ^4.3.4
version: 4.7.0(vite@5.4.21(@types/node@22.19.15)) version: 4.7.0(vite@5.4.21(@types/node@22.19.15))
adm-zip:
specifier: ^0.5.16
version: 0.5.16
electron: electron:
specifier: ^33.2.0 specifier: ^33.2.0
version: 33.4.11 version: 33.4.11

View File

View File

@@ -0,0 +1,143 @@
/**
* 下载 Chrome for TestingStable / win64完整目录到 resources/chrome-cft/chrome-win64/
* 与 chrome.exe 同级的 DLL 等依赖一并保留,随安装包分发,开箱即用。
* 设置 SKIP_CHROME=1 可跳过CI 无网环境)。
*/
import fs from 'fs'
import https from 'https'
import path from 'path'
import { fileURLToPath } from 'url'
import AdmZip from 'adm-zip'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const resDir = path.join(root, 'resources', 'chrome-cft')
const CFT_JSON_URL =
'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'
const PLATFORM_KEY = 'win64'
const markerName = '.chrome_cft_bundle_ok'
if (process.env.SKIP_CHROME === '1') {
console.log('[chrome-cft] SKIP_CHROME=1跳过下载')
process.exit(0)
}
function downloadBuffer(url) {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
downloadBuffer(res.headers.location).then(resolve).catch(reject)
return
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode} ${url}`))
return
}
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => resolve(Buffer.concat(chunks)))
res.on('error', reject)
})
.on('error', reject)
})
}
function downloadToFile(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest)
https
.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close()
try {
fs.unlinkSync(dest)
} catch {
/* ignore */
}
downloadToFile(res.headers.location, dest).then(resolve).catch(reject)
return
}
if (res.statusCode !== 200) {
file.close()
reject(new Error(`HTTP ${res.statusCode}`))
return
}
res.pipe(file)
file.on('finish', () => {
file.close()
resolve()
})
})
.on('error', (e) => {
try {
file.close()
} catch {
/* ignore */
}
reject(e)
})
})
}
const chromeExe = path.join(resDir, 'chrome-win64', 'chrome.exe')
const markerPath = path.join(resDir, markerName)
fs.mkdirSync(resDir, { recursive: true })
if (fs.existsSync(chromeExe) && fs.existsSync(markerPath)) {
console.log('[chrome-cft] 已存在完整包,跳过:', chromeExe)
process.exit(0)
}
console.log('[chrome-cft] 获取元数据:', CFT_JSON_URL)
const metaBuf = await downloadBuffer(CFT_JSON_URL)
const doc = JSON.parse(metaBuf.toString('utf-8'))
const stable = doc.channels?.Stable
const list = stable?.downloads?.chrome ?? []
const hit = list.find((x) => x.platform === PLATFORM_KEY)
if (!hit?.url) {
console.error('[chrome-cft] 元数据中无', PLATFORM_KEY)
process.exit(1)
}
const zipPath = path.join(resDir, '_chrome-download.zip')
console.log('[chrome-cft] 下载(体积较大):', hit.url)
await downloadToFile(hit.url, zipPath)
console.log('[chrome-cft] 解压中…')
const zip = new AdmZip(zipPath)
const tmp = path.join(resDir, '_extract_tmp')
if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true })
fs.mkdirSync(tmp, { recursive: true })
zip.extractAllTo(tmp, true)
const inner = path.join(tmp, 'chrome-win64')
if (!fs.existsSync(path.join(inner, 'chrome.exe'))) {
console.error('[chrome-cft] zip 内未找到 chrome-win64/chrome.exe')
process.exit(1)
}
const destDir = path.join(resDir, 'chrome-win64')
if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true })
fs.cpSync(inner, destDir, { recursive: true })
fs.rmSync(tmp, { recursive: true })
try {
fs.unlinkSync(zipPath)
} catch {
/* ignore */
}
fs.writeFileSync(
path.join(resDir, 'NOTICE-ChromeForTesting.txt'),
[
'本目录包含 Chrome for TestingStable / Windows x64。',
'清单来源:' + CFT_JSON_URL,
'与 chrome.exe 同目录文件均为官方 zip 原样复制,未修改二进制。',
'',
].join('\n'),
'utf-8',
)
fs.writeFileSync(markerPath, `${stable.version}\n${new Date().toISOString()}\n`, 'utf-8')
console.log('[chrome-cft] 完成:', chromeExe)

View File

@@ -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 TestingWindows x64含完整依赖目录 */
export function getBundledChromePath(): string | null {
if (process.platform !== 'win32') return null
const rel = path.join('chrome-win64', 'chrome.exe')
if (app.isPackaged) {
const p = path.join(process.resourcesPath, 'chrome-cft', rel)
return fileExists(p) ? p : null
}
const dev = path.resolve(__dirname, '../../resources/chrome-cft', rel)
return fileExists(dev) ? dev : null
}
type CftJson = {
channels?: {
Stable?: {
version?: string
downloads?: {
chrome?: Array<{ platform: string; url: string }>
}
}
}
}
async function fetchJson(): Promise<CftJson> {
const res = await fetch(CFT_JSON_URL, { redirect: 'follow' })
if (!res.ok) {
throw new Error(`获取 Chrome for Testing 元数据失败HTTP ${res.status}`)
}
return (await res.json()) as CftJson
}
function pickChromeZipUrl(doc: CftJson, platformKey: string): { version: string; url: string } {
const stable = doc.channels?.Stable
const version = stable?.version?.trim() ?? ''
const list = stable?.downloads?.chrome ?? []
const hit = list.find((x) => x.platform === platformKey)
if (!hit?.url) {
throw new Error(`Chrome for Testing 元数据中缺少平台 ${platformKey} 的下载地址。`)
}
return { version, url: hit.url }
}
async function downloadFile(url: string, dest: string): Promise<void> {
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) {
throw new Error(`下载失败HTTP ${res.status}`)
}
if (!res.body) {
throw new Error('下载失败:无响应体')
}
await fs.promises.mkdir(path.dirname(dest), { recursive: true })
const tmp = `${dest}.part`
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(tmp))
try {
fs.renameSync(tmp, dest)
} catch {
fs.copyFileSync(tmp, dest)
fs.unlinkSync(tmp)
}
}
let inFlight: Promise<string | null> | null = null
/**
* 确保 userData 下已有 CFT Chrome若尚无则下载并解压。失败返回 null。
*/
export function ensureChromeForTestingInstalled(): Promise<string | null> {
const existing = getCftChromePathIfPresent()
if (existing) return Promise.resolve(existing)
if (!inFlight) {
inFlight = doInstall().finally(() => {
inFlight = null
})
}
return inFlight
}
async function doInstall(): Promise<string | null> {
const root = cftRoot()
const platformKey = getCftPlatformKey()
const lockPath = path.join(root, LOCK_NAME)
const zipPath = path.join(root, `chrome-${platformKey}.zip`)
const versionPath = path.join(root, 'version.txt')
try {
fs.mkdirSync(root, { recursive: true })
} catch {
/* ignore */
}
try {
fs.writeFileSync(lockPath, `${Date.now()}\n`, 'utf-8')
} catch {
/* ignore */
}
appendClientLog(
'info',
'main',
'未找到内置 Chrome 与系统安装,正从 Chrome for Testing 拉取备用副本至用户目录(体积较大)…',
)
try {
const doc = await fetchJson()
const { version, url } = pickChromeZipUrl(doc, platformKey)
appendClientLog('info', 'main', `Chrome for Testing 版本:${version}`)
await downloadFile(url, zipPath)
appendClientLog('info', 'main', '下载完成,正在解压…')
const zip = new AdmZip(zipPath)
zip.extractAllTo(root, true)
try {
fs.unlinkSync(zipPath)
} catch {
/* ignore */
}
const exe = path.join(root, expectedChromeRelative(platformKey))
if (!fileExists(exe)) {
appendClientLog('error', 'main', `解压后未找到 Chrome${exe}`)
return null
}
try {
fs.writeFileSync(versionPath, `${version}\n`, 'utf-8')
} catch {
/* ignore */
}
appendClientLog('info', 'main', `Chrome for Testing 已就绪:${exe}`)
return exe
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
appendClientLog('error', 'main', `自动安装 Chrome for Testing 失败:${msg}`)
try {
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath)
} catch {
/* ignore */
}
return null
} finally {
try {
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath)
} catch {
/* ignore */
}
}
}

View File

@@ -16,6 +16,11 @@ import {
startBundledEasyTier, startBundledEasyTier,
stopBundledEasyTier, stopBundledEasyTier,
} from './easytier' } from './easytier'
import {
applyYoutubeStudioBrowserSettings,
ensureChromeExecutable,
getYoutubeStudioBrowserSettings,
} from './youtubeExternalChrome'
import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar' import { ensureYoutubeStudioWindow } from './youtubeStudioSidecar'
import { import {
appendClientLog, appendClientLog,
@@ -487,6 +492,10 @@ if (!gotLock) {
console.error('[clientDb]', e) console.error('[clientDb]', e)
} }
void ensureChromeExecutable().catch(() => {
/* 无内置时后台拉取备用 Chrome失败不影响主流程 */
})
ipcMain.removeHandler('workspace:log-live') ipcMain.removeHandler('workspace:log-live')
ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => { ipcMain.handle('workspace:log-live', (_evt, payload: unknown) => {
if (!payload || typeof payload !== 'object') return if (!payload || typeof payload !== 'object') return
@@ -563,8 +572,25 @@ if (!gotLock) {
ipcMain.handle('easytier:get-peer-stats', () => queryEasyTierPeerStats()) ipcMain.handle('easytier:get-peer-stats', () => queryEasyTierPeerStats())
ipcMain.removeHandler('youtube-studio:ensure') ipcMain.removeHandler('youtube-studio:ensure')
ipcMain.handle('youtube-studio:ensure', (_evt, suffix: unknown) => { ipcMain.handle('youtube-studio:ensure', async (_evt, suffix: unknown) => {
ensureYoutubeStudioWindow(typeof suffix === 'string' ? suffix : undefined) 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') 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」逻辑 * 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 let studioWin: BrowserWindow | null = null
const STUDIO_ENTRY = 'https://studio.youtube.com/livestreaming' 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) 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 inject = (wc: Electron.WebContents): void => {
const u = wc.getURL() const u = wc.getURL()
if (!u.includes('studio.youtube.com')) return if (!u.includes('studio.youtube.com')) return

View File

@@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld('deskBridge', {
getEasyTierPeers: () => ipcRenderer.invoke('easytier:get-peer-stats'), getEasyTierPeers: () => ipcRenderer.invoke('easytier:get-peer-stats'),
ensureYoutubeStudio: (streamKeySuffix?: string) => ensureYoutubeStudio: (streamKeySuffix?: string) =>
ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''), ipcRenderer.invoke('youtube-studio:ensure', streamKeySuffix ?? ''),
getYoutubeStudioBrowserSettings: () => ipcRenderer.invoke('youtube-studio:get-browser-settings'),
setYoutubeStudioBrowserSettings: (payload: { mode: string }) =>
ipcRenderer.invoke('youtube-studio:set-browser-settings', payload),
}) })
contextBridge.exposeInMainWorld('appTheme', { contextBridge.exposeInMainWorld('appTheme', {

View File

@@ -156,6 +156,15 @@ export default function App() {
const [proYoutubeKey, setProYoutubeKey] = useState('') const [proYoutubeKey, setProYoutubeKey] = useState('')
const [saveProKeyLoading, setSaveProKeyLoading] = useState(false) const [saveProKeyLoading, setSaveProKeyLoading] = useState(false)
const [ytStudioBrowserMode, setYtStudioBrowserMode] = useState<'embedded' | 'external_chrome'>('embedded')
const [ytStudioChromeResolved, setYtStudioChromeResolved] = useState<string | null>(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<HTMLPreElement>(null) const logRef = useRef<HTMLPreElement>(null)
useEffect(() => { 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) => { const applyTheme = useCallback((t: AppTheme) => {
setTheme(t) setTheme(t)
void window.appTheme?.set?.(t) void window.appTheme?.set?.(t)
@@ -813,6 +837,117 @@ export default function App() {
{!emptyHint && ( {!emptyHint && (
<main className="app-main"> <main className="app-main">
{isYoutube && ytStudioDeskReady && (
<section className="card">
<h2 className="card__title">YouTube Studio / </h2>
<p className="card__desc">
使 Electron Chromium<strong> Chrome</strong>
profile Windows x64 Chrome for
Testing
</p>
<div className="card__field">
<span className="card__field-label"></span>
<div className="btn-row" style={{ flexWrap: 'wrap', gap: '12px' }}>
<label className="record-pro-toggle" style={{ cursor: 'pointer' }}>
<input
type="radio"
name="ytStudioBrowser"
checked={ytStudioBrowserMode === 'external_chrome'}
onChange={() => setYtStudioBrowserMode('external_chrome')}
/>
<span> Chrome</span>
</label>
<label className="record-pro-toggle" style={{ cursor: 'pointer' }}>
<input
type="radio"
name="ytStudioBrowser"
checked={ytStudioBrowserMode === 'embedded'}
onChange={() => setYtStudioBrowserMode('embedded')}
/>
<span></span>
</label>
</div>
</div>
{ytStudioBrowserMode === 'external_chrome' && (
<div className="card__field">
<div className="btn-row">
<button
type="button"
className="btn btn-primary"
onClick={() => {
void (async () => {
const db = window.deskBridge
if (!db?.setYoutubeStudioBrowserSettings) return
setYtStudioBrowserHint('保存中…')
const res = await db.setYoutubeStudioBrowserSettings({ mode: 'external_chrome' })
if (!res?.ok) {
setYtStudioBrowserHint(res?.error || '保存失败')
return
}
const s = await db.getYoutubeStudioBrowserSettings?.()
if (s) {
setYtStudioChromeResolved(s.resolvedChromePath)
setYtStudioUserDataDir(s.userDataDir)
setYtStudioBrowserResolution(s.chromeResolution)
}
setYtStudioBrowserHint('已保存')
})()
}}
>
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => {
const keySrc = multiChannelPro ? proYoutubeKey : youtubeText
const suffix = parseYoutubeStreamKeySuffix(keySrc)
void window.deskBridge?.ensureYoutubeStudio?.(suffix)
}}
>
</button>
</div>
<pre className="hint-line">
{ytStudioBrowserResolution === 'bundled' && ytStudioChromeResolved
? `安装包内置 Chrome${ytStudioChromeResolved}`
: ytStudioBrowserResolution === 'system' && ytStudioChromeResolved
? `将使用本机已安装的 Chrome${ytStudioChromeResolved}`
: ytStudioBrowserResolution === 'runtime' && ytStudioChromeResolved
? `用户目录备用副本:${ytStudioChromeResolved}`
: ytStudioBrowserResolution === 'missing'
? '正在准备 Chrome若开发环境未执行 prepare-chrome启动后会在后台下载备用副本。'
: '—'}
{ytStudioUserDataDir ? `\n独立登录数据目录${ytStudioUserDataDir}` : ''}
</pre>
{ytStudioBrowserHint ? <pre className="hint-line">{ytStudioBrowserHint}</pre> : null}
</div>
)}
{ytStudioBrowserMode === 'embedded' && (
<div className="card__field">
<div className="btn-row">
<button
type="button"
className="btn btn-primary"
onClick={() => {
void (async () => {
const db = window.deskBridge
if (!db?.setYoutubeStudioBrowserSettings) return
setYtStudioBrowserHint('保存中…')
const res = await db.setYoutubeStudioBrowserSettings({ mode: 'embedded' })
setYtStudioBrowserHint(res?.ok ? '已保存' : res?.error || '保存失败')
})()
}}
>
</button>
</div>
{ytStudioBrowserHint ? <pre className="hint-line">{ytStudioBrowserHint}</pre> : null}
</div>
)}
</section>
)}
{isYoutube && multiChannelPro && ( {isYoutube && multiChannelPro && (
<section className="card card--pro-head"> <section className="card card--pro-head">
<h2 className="card__title">Pro 线</h2> <h2 className="card__title">Pro 线</h2>

View File

@@ -54,6 +54,15 @@ interface Window {
onlinePeers?: number onlinePeers?: number
}> }>
ensureYoutubeStudio?: (streamKeySuffix?: string) => Promise<void> ensureYoutubeStudio?: (streamKeySuffix?: string) => Promise<void>
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?: { appTheme?: {
get?: () => Promise<string | null> get?: () => Promise<string | null>