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/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/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
6
electron/pnpm-lock.yaml
generated
6
electron/pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
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,
|
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')
|
||||||
|
|||||||
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」逻辑),
|
* 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
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user