Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6912cff20 | ||
|
|
1f503f9668 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -178,6 +178,13 @@ config/wechat_chatbot.state.json
|
||||
electron/resources/easytier/easytier-core.exe
|
||||
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/node_modules/
|
||||
electron/dist/
|
||||
|
||||
@@ -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",
|
||||
|
||||
6
electron/pnpm-lock.yaml
generated
6
electron/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
0
electron/resources/chrome-cft/.gitkeep
Normal file
0
electron/resources/chrome-cft/.gitkeep
Normal file
143
electron/scripts/download-chrome-for-testing.mjs
Normal file
143
electron/scripts/download-chrome-for-testing.mjs
Normal file
@@ -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)
|
||||
237
electron/src/main/chromeForTestingBootstrap.ts
Normal file
237
electron/src/main/chromeForTestingBootstrap.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
180
electron/src/main/youtubeExternalChrome.ts
Normal file
180
electron/src/main/youtubeExternalChrome.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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<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)
|
||||
|
||||
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 && (
|
||||
<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 && (
|
||||
<section className="card card--pro-head">
|
||||
<h2 className="card__title">Pro 转播线路</h2>
|
||||
|
||||
9
electron/src/renderer/src/vite-env.d.ts
vendored
9
electron/src/renderer/src/vite-env.d.ts
vendored
@@ -54,6 +54,15 @@ interface Window {
|
||||
onlinePeers?: number
|
||||
}>
|
||||
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?: {
|
||||
get?: () => Promise<string | null>
|
||||
|
||||
Reference in New Issue
Block a user