From 6cbf9af6622bcdf121e02ece4c4a8830cb143316 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Mon, 31 Aug 2026 10:55:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20mac=E8=AF=AD=E9=9F=B3=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?silk=E6=A0=BC=E5=BC=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 44 +++ .../services/personal-wechat-send-service.ts | 59 +++- ...rsonal-wechat-voice-environment-service.ts | 274 ++++++++++++++++++ src/preload/index.d.ts | 8 + src/preload/index.ts | 13 + .../VoiceEncodingEnvironmentSection.tsx | 184 ++++++++++++ .../settings/pages/TextToSpeechPage.tsx | 3 + .../src/styles/settings-text-to-speech.scss | 122 ++++++++ src/shared/personal-wechat-voice-runtime.ts | 37 +++ tests/component/text-to-speech-page.test.tsx | 53 +++- tests/e2e/visual.spec.ts | 36 +++ tests/integration/preload-contract.test.ts | 8 + ...l-wechat-voice-environment-service.test.ts | 139 +++++++++ tests/unit/security-and-errors.test.ts | 2 +- 14 files changed, 970 insertions(+), 12 deletions(-) create mode 100644 src/main/services/personal-wechat-voice-environment-service.ts create mode 100644 src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx create mode 100644 src/shared/personal-wechat-voice-runtime.ts create mode 100644 tests/unit/personal-wechat-voice-environment-service.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index cc8c29d..5f23b73 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -115,6 +115,7 @@ import { personalWechatSendService } from './services/personal-wechat-send-servi import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service' import { scheduledReportService } from './services/scheduled-report-service' import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager' +import { personalWechatVoiceEnvironmentService } from './services/personal-wechat-voice-environment-service' import type { PersonalWechatSendRequest } from '../shared/personal-wechat' import type { ScheduledReportCreateInput, @@ -1860,6 +1861,48 @@ app.whenReady().then(async () => { ipcMain.handle('wechat-personal:checkStatus', (_, port?: string) => personalWechatSendService.checkWindowsStatus(port) ) + ipcMain.handle('wechat-personal:checkVoiceEnvironment', () => + personalWechatVoiceEnvironmentService.check() + ) + ipcMain.handle('wechat-personal:installPilk', async () => { + const result = await personalWechatVoiceEnvironmentService.installPilk() + if (!result.success || process.platform !== 'darwin') return result + + const senderStatus = await personalWechatSendService.getStatus() + if (!senderStatus.oneBotPid) return result + try { + const restartedStatus = await personalWechatSendService.restartRuntime() + return { + ...result, + restarted: restartedStatus.state !== 'error', + ...(restartedStatus.state === 'error' + ? { restartError: restartedStatus.error || restartedStatus.message } + : {}) + } + } catch (error) { + return { + ...result, + restarted: false, + restartError: error instanceof Error ? error.message : String(error) + } + } + }) + ipcMain.handle('wechat-personal:openVoicePythonDownload', async () => { + try { + await shell.openExternal('https://www.python.org/downloads/macos/') + return { success: true } + } catch { + return { success: false, error: '无法打开 Python 下载页面' } + } + }) + ipcMain.handle('wechat-personal:openVoiceFfmpegDownload', async () => { + try { + await shell.openExternal('https://brew.sh/') + return { success: true } + } catch { + return { success: false, error: '无法打开 FFmpeg 安装页面' } + } + }) ipcMain.handle('wechat-personal:getRuntimeStatus', () => personalWechatRuntimeManager.getStatus()) ipcMain.handle('wechat-personal:downloadRuntime', () => personalWechatRuntimeManager.download()) ipcMain.handle('wechat-personal:cancelRuntimeDownload', () => ({ @@ -1886,6 +1929,7 @@ app.whenReady().then(async () => { const self = await chat.getSelfAccountInfoAsync() fromId = String(self?.wxid || '').trim() } catch { + // The sender can still operate without an account wxid for non-voice messages. } return personalWechatSendService.send(fromId ? { ...request, fromId } : request) }) diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index 4dfb11a..1b18097 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -254,7 +254,7 @@ export function buildPersonalWechatOneBotRequest( } } -function buildRuntimePath(): string { +export function buildPersonalWechatRuntimePath(): string { const existing = String(process.env['PATH'] || '') const bundledFfmpeg = String(ffmpegStaticPath || '') .replace('app.asar', 'app.asar.unpacked') @@ -263,12 +263,25 @@ function buildRuntimePath(): string { return [dirname(bundledFfmpeg), existing].filter(Boolean).join(delimiter) } -function buildRuntimePythonPath(runtimeRoot: string): string { +export function buildPersonalWechatRuntimePythonPath(runtimeRoot: string): string { return [join(runtimeRoot, 'python'), String(process.env['PYTHONPATH'] || '')] .filter(Boolean) .join(delimiter) } +/** + * Build the same environment used when TraceMemo starts OneBot. + * Keep this in one place so runtime checks cannot accidentally inspect a + * different Python or ffmpeg than the sender process. + */ +export function buildPersonalWechatRuntimeEnvironment(runtimeRoot?: string): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: buildPersonalWechatRuntimePath(), + ...(runtimeRoot ? { PYTHONPATH: buildPersonalWechatRuntimePythonPath(runtimeRoot) } : {}) + } +} + function bundledFfmpegExecutable(): string { const bundledFfmpeg = String(ffmpegStaticPath || '') .replace('app.asar', 'app.asar.unpacked') @@ -282,7 +295,7 @@ async function convertAudioToPcm(audioData: Buffer): Promise { bundledFfmpegExecutable(), ['-v', 'error', '-i', 'pipe:0', '-f', 's16le', '-ar', '16000', '-ac', '1', 'pipe:1'], { - env: { ...process.env, PATH: buildRuntimePath() }, + env: buildPersonalWechatRuntimeEnvironment(), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } @@ -1224,6 +1237,13 @@ export class PersonalWechatSendService { this.lastError = '' } + /** Restart the existing OneBot runtime after an environment change. */ + async restartRuntime(): Promise { + if (process.platform === 'win32') return this.getWindowsStatus() + await this.terminate(true) + return this.rebind() + } + private async ensureRunning(): Promise { const currentStatus = await this.getStatus() if (currentStatus.state === 'online' || currentStatus.state === 'hook_not_ready') { @@ -1375,6 +1395,7 @@ export class PersonalWechatSendService { try { unlinkSync(temporaryVoicePath) } catch { + // Temporary files are best-effort cleanup only. } } } @@ -1386,6 +1407,32 @@ export class PersonalWechatSendService { return preflight.status } + try { + const { personalWechatVoiceEnvironmentService } = + await import('./personal-wechat-voice-environment-service') + const environment = await personalWechatVoiceEnvironmentService.check() + if (!environment.pilk.ready) { + appLogger.write({ + level: 'warn', + scope: 'VoiceRuntime', + message: 'WARNING: pilk unavailable, OneBot may fallback to go-silk.', + details: { + python: environment.python.executable, + pythonReady: environment.python.ready, + ffmpegReady: environment.ffmpeg.ready, + runtimeReady: environment.runtimeReady + } + }) + } + } catch (error) { + appLogger.write({ + level: 'warn', + scope: 'VoiceRuntime', + message: 'Voice encoding environment check failed before OneBot start', + details: { error: error instanceof Error ? error.message : String(error) } + }) + } + const pid = preflight.status.wechatPid const imagePath = findWechatImagePath() this.lastError = '' @@ -1405,11 +1452,7 @@ export class PersonalWechatSendService { detached: true, stdio: 'ignore', windowsHide: true, - env: { - ...process.env, - PATH: buildRuntimePath(), - PYTHONPATH: buildRuntimePythonPath(preflight.runtime.root) - } + env: buildPersonalWechatRuntimeEnvironment(preflight.runtime.root) } ) this.child = child diff --git a/src/main/services/personal-wechat-voice-environment-service.ts b/src/main/services/personal-wechat-voice-environment-service.ts new file mode 100644 index 0000000..77823cb --- /dev/null +++ b/src/main/services/personal-wechat-voice-environment-service.ts @@ -0,0 +1,274 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { isAbsolute } from 'path' +import type { + PersonalWechatVoiceEncodingEnvironment, + PersonalWechatVoiceEncodingEnvironmentResult, + PersonalWechatVoiceRuntimeComponent +} from '../../shared/personal-wechat-voice-runtime' +import { PERSONAL_WECHAT_PILK_VERSION } from '../../shared/personal-wechat-voice-runtime' +import { + buildPersonalWechatRuntimeEnvironment, + findPersonalWechatRuntime +} from './personal-wechat-send-service' +import type { RuntimeLayout } from './personal-wechat-send-service' +import { appLogger } from '../app-logger' + +const execFileAsync = promisify(execFile) +const COMMAND_TIMEOUT_MS = 10_000 +const INSTALL_TIMEOUT_MS = 120_000 + +const PYTHON_PROBE = + 'import json,sys; print(json.dumps({"executable":sys.executable,"version":".".join(map(str,sys.version_info[:3]))}))' +const PILK_PROBE = + 'import json,pilk; print(json.dumps({"version":getattr(pilk,"__version__", ""),"path":getattr(pilk,"__file__", "")}))' + +interface CommandOptions { + env: NodeJS.ProcessEnv + timeout?: number +} + +type CommandRunner = ( + executable: string, + args: string[], + options: CommandOptions +) => Promise<{ stdout: string; stderr: string }> + +interface PersonalWechatVoiceEnvironmentServiceOptions { + platform?: NodeJS.Platform + architecture?: string + findRuntime?: () => RuntimeLayout | null + buildEnvironment?: (runtimeRoot?: string) => NodeJS.ProcessEnv + runCommand?: CommandRunner + now?: () => Date +} + +function blankComponent(): PersonalWechatVoiceRuntimeComponent { + return { ready: false } +} + +function commandError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.replace(/\s+/g, ' ').trim().slice(0, 500) || '命令执行失败' +} + +function logEnvironmentLine(message: string, details?: Record): void { + appLogger.write({ level: 'info', scope: 'VoiceRuntime', message, details }) +} + +function logEnvironmentWarning(message: string, details?: Record): void { + appLogger.write({ level: 'warn', scope: 'VoiceRuntime', message, details }) +} + +async function runCommand( + executable: string, + args: string[], + options: CommandOptions +): Promise<{ stdout: string; stderr: string }> { + return execFileAsync(executable, args, { + env: options.env, + timeout: options.timeout || COMMAND_TIMEOUT_MS, + maxBuffer: 64 * 1024, + windowsHide: true + }) as Promise<{ stdout: string; stderr: string }> +} + +async function findCommandPath( + command: string, + env: NodeJS.ProcessEnv, + runner: CommandRunner = runCommand +): Promise { + try { + const { stdout } = await runner('/usr/bin/which', [command], { env }) + const path = stdout.trim().split(/\r?\n/)[0] + return path && isAbsolute(path) ? path : undefined + } catch { + return undefined + } +} + +function unsupportedEnvironment(): PersonalWechatVoiceEncodingEnvironment { + return { + state: 'unsupported', + ready: false, + runtimeReady: false, + python: blankComponent(), + pilk: blankComponent(), + ffmpeg: blankComponent(), + encoder: 'unavailable', + message: '语音编码环境检查当前仅支持 Apple Silicon Mac' + } +} + +export class PersonalWechatVoiceEnvironmentService { + private readonly platform: NodeJS.Platform + private readonly architecture: string + private readonly findRuntime: () => RuntimeLayout | null + private readonly buildEnvironment: (runtimeRoot?: string) => NodeJS.ProcessEnv + private readonly runCommand: CommandRunner + private readonly now: () => Date + + constructor(options: PersonalWechatVoiceEnvironmentServiceOptions = {}) { + this.platform = options.platform || process.platform + this.architecture = options.architecture || process.arch + this.findRuntime = options.findRuntime || findPersonalWechatRuntime + this.buildEnvironment = options.buildEnvironment || buildPersonalWechatRuntimeEnvironment + this.runCommand = options.runCommand || runCommand + this.now = options.now || (() => new Date()) + } + + async check(): Promise { + if (this.platform !== 'darwin' || this.architecture !== 'arm64') { + return unsupportedEnvironment() + } + + logEnvironmentLine('Checking voice encoding environment') + const runtime = this.findRuntime() + const environment = this.buildEnvironment(runtime?.root) + const python = blankComponent() + const pilk = blankComponent() + const ffmpeg = blankComponent() + + let pythonExecutable = '' + try { + const result = await this.runCommand('python3', ['-c', PYTHON_PROBE], { env: environment }) + const payload = JSON.parse(result.stdout.trim()) as { + executable?: string + version?: string + } + pythonExecutable = String(payload.executable || '').trim() + const version = String(payload.version || '').trim() + if (!pythonExecutable || !version) throw new Error('Python 未返回可执行路径或版本') + python.ready = true + python.executable = pythonExecutable + python.version = version + logEnvironmentLine(`Python: ${pythonExecutable}`) + logEnvironmentLine(`Python version: ${version}`) + } catch (error) { + python.error = commandError(error) + logEnvironmentWarning('Python: unavailable', { error: python.error }) + } + + if (pythonExecutable) { + try { + const result = await this.runCommand(pythonExecutable, ['-c', PILK_PROBE], { + env: environment + }) + const payload = JSON.parse(result.stdout.trim()) as { version?: string; path?: string } + const version = String(payload.version || '').trim() + const path = String(payload.path || '').trim() + if (!version || !path) throw new Error('pilk 未返回版本或安装路径') + pilk.version = version + pilk.path = path + pilk.ready = version === PERSONAL_WECHAT_PILK_VERSION + if (!pilk.ready) { + pilk.error = `版本不匹配,需要 ${PERSONAL_WECHAT_PILK_VERSION}` + } + logEnvironmentLine(`pilk: ${pilk.ready ? 'available' : 'incompatible'}`, { + version, + path + }) + logEnvironmentLine(`pilk version: ${version}`) + } catch (error) { + pilk.error = commandError(error) + logEnvironmentWarning('pilk: unavailable', { error: pilk.error }) + } + } else { + pilk.error = 'Python 未找到,无法检查 pilk' + } + + try { + const [result, executable] = await Promise.all([ + this.runCommand('ffmpeg', ['-version'], { env: environment }), + findCommandPath('ffmpeg', environment, this.runCommand) + ]) + const firstLine = result.stdout.split(/\r?\n/).find((line) => line.trim()) || '' + const version = firstLine.match(/^ffmpeg version\s+([^\s]+)/i)?.[1] || firstLine.trim() + if (!version) throw new Error('ffmpeg 未返回版本') + ffmpeg.ready = true + ffmpeg.version = version + ffmpeg.executable = executable || 'ffmpeg' + logEnvironmentLine('ffmpeg: available', { + executable: ffmpeg.executable, + version + }) + } catch (error) { + ffmpeg.error = commandError(error) + logEnvironmentWarning('ffmpeg: unavailable', { error: ffmpeg.error }) + } + + const ready = Boolean(runtime && python.ready && pilk.ready && ffmpeg.ready) + const result: PersonalWechatVoiceEncodingEnvironment = { + state: ready ? 'ready' : 'incomplete', + ready, + checkedAt: this.now().toISOString(), + runtimeReady: Boolean(runtime), + ...(runtime ? { runtimeRoot: runtime.root } : {}), + python, + pilk, + ffmpeg, + encoder: pilk.ready + ? 'pilk' + : runtime && python.ready && ffmpeg.ready + ? 'go-silk' + : 'unavailable', + message: ready + ? '语音编码环境正常,可以使用 pilk 编码' + : runtime + ? '语音编码环境不完整,OneBot 可能回退到 go-silk' + : '微信发送组件尚未安装,请先准备 OneBot 运行时' + } + logEnvironmentLine( + ready ? 'Voice encoding environment is ready' : 'Voice encoding environment is NOT ready', + { runtimeReady: result.runtimeReady, encoder: result.encoder } + ) + logEnvironmentLine(`Encoder: ${result.encoder}`) + return result + } + + async installPilk(): Promise { + const before = await this.check() + const pythonExecutable = before.python.executable + if (!before.python.ready || !pythonExecutable) { + return { + success: false, + environment: before, + error: '未找到 TraceMemo 实际使用的 Python,无法安装 pilk' + } + } + + logEnvironmentLine(`Installing pilk ${PERSONAL_WECHAT_PILK_VERSION}`, { + python: pythonExecutable + }) + try { + await this.runCommand( + pythonExecutable, + ['-m', 'pip', 'install', '--user', `pilk==${PERSONAL_WECHAT_PILK_VERSION}`], + { + env: this.buildEnvironment(before.runtimeRoot || undefined), + timeout: INSTALL_TIMEOUT_MS + } + ) + } catch (error) { + const message = commandError(error) + logEnvironmentWarning('pilk installation failed', { error: message }) + return { + success: false, + environment: await this.check(), + error: `pilk 安装失败:${message}` + } + } + + const after = await this.check() + if (!after.pilk.ready) { + const message = after.pilk.error || '安装后仍无法 import pilk' + logEnvironmentWarning('pilk installation did not produce a usable module', { + error: message + }) + return { success: false, environment: after, error: message } + } + return { success: true, environment: after } + } +} + +export const personalWechatVoiceEnvironmentService = new PersonalWechatVoiceEnvironmentService() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a699a1f..6c8b30f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -74,6 +74,10 @@ import type { PersonalWechatRuntimeProgressEvent, PersonalWechatRuntimeStatus } from '../shared/personal-wechat-runtime' +import type { + PersonalWechatVoiceEncodingEnvironment, + PersonalWechatVoiceEncodingEnvironmentResult +} from '../shared/personal-wechat-voice-runtime' import type { AppLogEntry } from '../shared/app-log' import type { AppUpdateCheckResult, @@ -601,6 +605,10 @@ declare global { getPersonalWechatKeepOneBotProcess: () => Promise setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise checkPersonalWechatSenderStatus: (port?: string) => Promise + checkPersonalWechatVoiceEncodingEnvironment: () => Promise + installPersonalWechatPilk: () => Promise + openPersonalWechatVoicePythonDownload: () => Promise<{ success: boolean; error?: string }> + openPersonalWechatVoiceFfmpegDownload: () => Promise<{ success: boolean; error?: string }> getPersonalWechatRuntimeStatus: () => Promise downloadPersonalWechatRuntime: () => Promise cancelPersonalWechatRuntimeDownload: () => Promise<{ success: boolean }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 35bc438..82d55c9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -46,6 +46,10 @@ import type { PersonalWechatRuntimeProgressEvent, PersonalWechatRuntimeStatus } from '../shared/personal-wechat-runtime' +import type { + PersonalWechatVoiceEncodingEnvironment, + PersonalWechatVoiceEncodingEnvironmentResult +} from '../shared/personal-wechat-voice-runtime' import type { AppLogEntry } from '../shared/app-log' import type { AppUpdateState } from '../shared/app-update' import type { CacheSummary } from '../shared/cache' @@ -371,6 +375,15 @@ const api = { ipcRenderer.invoke('wechat-personal:setKeepProcess', keep), checkPersonalWechatSenderStatus: (port?: string): Promise => ipcRenderer.invoke('wechat-personal:checkStatus', port), + checkPersonalWechatVoiceEncodingEnvironment: + (): Promise => + ipcRenderer.invoke('wechat-personal:checkVoiceEnvironment'), + installPersonalWechatPilk: (): Promise => + ipcRenderer.invoke('wechat-personal:installPilk'), + openPersonalWechatVoicePythonDownload: (): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('wechat-personal:openVoicePythonDownload'), + openPersonalWechatVoiceFfmpegDownload: (): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('wechat-personal:openVoiceFfmpegDownload'), getPersonalWechatRuntimeStatus: (): Promise => ipcRenderer.invoke('wechat-personal:getRuntimeStatus'), downloadPersonalWechatRuntime: (): Promise => diff --git a/src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx b/src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx new file mode 100644 index 0000000..d04ab22 --- /dev/null +++ b/src/renderer/src/features/settings/components/VoiceEncodingEnvironmentSection.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' +import type { PersonalWechatVoiceEncodingEnvironment } from '../../../../../shared/personal-wechat-voice-runtime' +import { Button } from '../../../components/ui' +import { isMac } from '../../../utils/runtime-environment' + +function componentDetail( + component: PersonalWechatVoiceEncodingEnvironment['python'], + kind: 'python' | 'pilk' | 'ffmpeg' +): string { + if (!component.ready) { + const error = component.error || '' + if (/ENOENT|not found|未找到/i.test(error)) return '未找到' + if (kind === 'pilk' && /ModuleNotFoundError|No module named|未安装/i.test(error)) { + return '未安装' + } + return error || '未找到' + } + return [component.version, component.executable || component.path].filter(Boolean).join(' · ') +} + +function statusLabel(environment: PersonalWechatVoiceEncodingEnvironment | null): string { + if (!environment) return '尚未检查' + if (environment.state === 'ready') return '✓ 编码环境正常' + if (environment.state === 'unsupported') return '当前系统不支持' + if (environment.state === 'error') return '检查失败' + return '⚠ 编码环境不完整' +} + +function componentMark(ready: boolean): string { + return ready ? '✓' : '✗' +} + +export function VoiceEncodingEnvironmentSection({ + onNotice +}: { + onNotice: (message: string) => void +}): React.ReactElement | null { + const [environment, setEnvironment] = useState( + null + ) + const [checking, setChecking] = useState(false) + const [installing, setInstalling] = useState(false) + + if (!isMac) return null + + const checkEnvironment = async (): Promise => { + const check = window.api.checkPersonalWechatVoiceEncodingEnvironment + if (typeof check !== 'function') { + onNotice('当前版本暂不支持语音编码环境检查') + return + } + setChecking(true) + try { + setEnvironment(await check()) + } catch (error) { + onNotice(error instanceof Error ? error.message : '语音编码环境检查失败') + } finally { + setChecking(false) + } + } + + const installPilk = async (): Promise => { + const install = window.api.installPersonalWechatPilk + if (typeof install !== 'function' || installing) { + if (typeof install !== 'function') onNotice('当前版本暂不支持 pilk 安装') + return + } + setInstalling(true) + try { + const result = await install() + setEnvironment(result.environment) + if (!result.success) { + onNotice(result.error || 'pilk 安装失败') + return + } + if (!result.environment.ready) { + onNotice( + result.environment.message || 'pilk 已安装,但仍有语音编码环境未就绪。请重新检查。' + ) + } else if (result.restarted) { + onNotice('语音编码环境已修复,语音发送服务已重启') + } else if (result.restartError) { + onNotice(`语音编码环境已修复,但语音发送服务重启失败:${result.restartError}`) + } else { + onNotice('语音编码环境已修复。重新启动语音发送服务后生效。') + } + } catch (error) { + onNotice(error instanceof Error ? error.message : 'pilk 安装失败') + } finally { + setInstalling(false) + } + } + + const openPythonDownload = async (): Promise => { + const open = window.api.openPersonalWechatVoicePythonDownload + if (typeof open !== 'function') return onNotice('当前版本暂不支持打开 Python 下载页面') + const result = await open() + if (!result.success) onNotice(result.error || '无法打开 Python 下载页面') + } + + const openFfmpegDownload = async (): Promise => { + const open = window.api.openPersonalWechatVoiceFfmpegDownload + if (typeof open !== 'function') return onNotice('当前版本暂不支持打开 FFmpeg 安装页面') + const result = await open() + if (!result.success) onNotice(result.error || '无法打开 FFmpeg 安装页面') + } + + const pythonMissing = Boolean(environment && !environment.python.ready) + const pilkMissing = Boolean(environment && !environment.pilk.ready && environment.python.ready) + const ffmpegMissing = Boolean(environment && !environment.ffmpeg.ready) + const status = environment?.state || 'unknown' + + return ( +
+
+
+ 微信语音发送 +

语音编码环境

+

检查 TraceMemo 实际使用的 Python、pilk 和 FFmpeg,避免发送后才发现语音无法播放。

+
+ +
+ +
+ {statusLabel(environment)} + {environment?.message ? {environment.message} : null} +
+ + {environment ? ( +
+
+ Python {componentMark(environment.python.ready)} + {componentDetail(environment.python, 'python')} + {pythonMissing ? ( + + ) : null} +
+
+ + pilk {environment.pilk.version || '0.2.4'} {componentMark(environment.pilk.ready)} + + {componentDetail(environment.pilk, 'pilk')} + {pilkMissing ? ( + + ) : null} +
+
+ ffmpeg {componentMark(environment.ffmpeg.ready)} + {componentDetail(environment.ffmpeg, 'ffmpeg')} + {ffmpegMissing ? ( + + ) : null} +
+
+ ) : ( +

点击“检查编码环境”后查看实际运行环境。

+ )} +
+ ) +} diff --git a/src/renderer/src/features/settings/pages/TextToSpeechPage.tsx b/src/renderer/src/features/settings/pages/TextToSpeechPage.tsx index 147c15a..8aa1ac7 100644 --- a/src/renderer/src/features/settings/pages/TextToSpeechPage.tsx +++ b/src/renderer/src/features/settings/pages/TextToSpeechPage.tsx @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue } from '../../../components/ui' +import { VoiceEncodingEnvironmentSection } from '../components/VoiceEncodingEnvironmentSection' const VOICE_PAGE_SIZE = 24 @@ -432,6 +433,8 @@ export function TextToSpeechPage({ + +

API 设置

diff --git a/src/renderer/src/styles/settings-text-to-speech.scss b/src/renderer/src/styles/settings-text-to-speech.scss index dfab3d5..4d9135e 100644 --- a/src/renderer/src/styles/settings-text-to-speech.scss +++ b/src/renderer/src/styles/settings-text-to-speech.scss @@ -96,6 +96,123 @@ } } +.voice-encoding-environment { + display: grid; + gap: 15px; + margin-bottom: 28px; + + &.status-ready { + border-color: color-mix(in srgb, var(--wxex-success) 38%, var(--wxex-border)); + } + + &.status-incomplete, + &.status-error { + border-color: color-mix(in srgb, var(--wxex-warning) 42%, var(--wxex-border)); + } +} + +.voice-encoding-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + + h2 { + margin: 0; + color: var(--wxex-text-primary); + font-size: 16px; + } + + p { + max-width: 620px; + margin: 5px 0 0; + color: var(--wxex-text-secondary); + font-size: 11px; + line-height: 17px; + } +} + +.voice-encoding-status { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + padding: 9px 11px; + border-radius: var(--wxex-radius-sm); + background: var(--wxex-bg-main); + + strong { + color: var(--wxex-text-primary); + font-size: 12px; + } + + span { + color: var(--wxex-text-muted); + font-size: 11px; + } + + .status-ready & strong { + color: var(--wxex-success); + } + + .status-incomplete & strong, + .status-error & strong { + color: var(--wxex-warning); + } +} + +.voice-encoding-checks { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; +} + +.voice-encoding-check { + min-width: 0; + display: grid; + gap: 4px; + align-content: start; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-sm); + background: var(--wxex-bg-elevated); + padding: 10px 11px; + + > span { + color: var(--wxex-text-primary); + font-size: 12px; + font-weight: 700; + } + + &.is-ready > span { + color: var(--wxex-success); + } + + &.is-missing > span { + color: var(--wxex-text-primary); + } + + small { + min-height: 17px; + overflow: hidden; + color: var(--wxex-text-muted); + font-size: 10px; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; + } + + button { + justify-self: start; + margin-top: 2px; + } +} + +.voice-encoding-empty { + margin: 0; + color: var(--wxex-text-muted); + font-size: 11px; +} + .tts-auto-update-guide { display: grid; gap: 6px; @@ -628,12 +745,17 @@ .tts-voice-grid { grid-template-columns: 1fr; } + + .voice-encoding-checks { + grid-template-columns: 1fr; + } } @media (max-width: 720px) { .tts-api-heading, .tts-voice-heading, .tts-api-footer, + .voice-encoding-heading, .tts-usage-guide-heading { align-items: stretch; flex-direction: column; diff --git a/src/shared/personal-wechat-voice-runtime.ts b/src/shared/personal-wechat-voice-runtime.ts new file mode 100644 index 0000000..b1059ad --- /dev/null +++ b/src/shared/personal-wechat-voice-runtime.ts @@ -0,0 +1,37 @@ +export const PERSONAL_WECHAT_PILK_VERSION = '0.2.4' + +export type PersonalWechatVoiceEncodingEnvironmentState = + | 'unknown' + | 'ready' + | 'incomplete' + | 'unsupported' + | 'error' + +export interface PersonalWechatVoiceRuntimeComponent { + ready: boolean + version?: string + executable?: string + path?: string + error?: string +} + +export interface PersonalWechatVoiceEncodingEnvironment { + state: PersonalWechatVoiceEncodingEnvironmentState + ready: boolean + checkedAt?: string + runtimeReady: boolean + runtimeRoot?: string + python: PersonalWechatVoiceRuntimeComponent + pilk: PersonalWechatVoiceRuntimeComponent + ffmpeg: PersonalWechatVoiceRuntimeComponent + encoder: 'pilk' | 'go-silk' | 'unavailable' + message: string +} + +export interface PersonalWechatVoiceEncodingEnvironmentResult { + success: boolean + environment: PersonalWechatVoiceEncodingEnvironment + error?: string + restarted?: boolean + restartError?: string +} diff --git a/tests/component/text-to-speech-page.test.tsx b/tests/component/text-to-speech-page.test.tsx index ab81fec..f430f5a 100644 --- a/tests/component/text-to-speech-page.test.tsx +++ b/tests/component/text-to-speech-page.test.tsx @@ -14,6 +14,10 @@ const getSettings = vi.fn() const saveSettings = vi.fn() const listVoices = vi.fn() const openApiKeys = vi.fn() +const checkVoiceEncodingEnvironment = vi.fn() +const installPilk = vi.fn() +const openPythonDownload = vi.fn() +const openFfmpegDownload = vi.fn() const response = { success: true, @@ -70,13 +74,21 @@ describe('TextToSpeechPage', () => { } })) openApiKeys.mockReset().mockResolvedValue({ success: true }) + checkVoiceEncodingEnvironment.mockReset() + installPilk.mockReset() + openPythonDownload.mockReset().mockResolvedValue({ success: true }) + openFfmpegDownload.mockReset().mockResolvedValue({ success: true }) Object.defineProperty(window, 'api', { configurable: true, value: { getTextToSpeechSettings: getSettings, saveTextToSpeechSettings: saveSettings, listTextToSpeechVoices: listVoices, - openFishAudioApiKeys: openApiKeys + openFishAudioApiKeys: openApiKeys, + checkPersonalWechatVoiceEncodingEnvironment: checkVoiceEncodingEnvironment, + installPersonalWechatPilk: installPilk, + openPersonalWechatVoicePythonDownload: openPythonDownload, + openPersonalWechatVoiceFfmpegDownload: openFfmpegDownload } }) }) @@ -136,11 +148,46 @@ describe('TextToSpeechPage', () => { await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true })) }) - it('keeps the speech page free of personal WeChat runtime controls', async () => { + it('shows the macOS voice encoding environment entry without exposing OneBot controls', async () => { render() expect(await screen.findByText('API 设置')).toBeVisible() - expect(screen.queryByText(/微信|OneBot/i)).not.toBeInTheDocument() + expect(screen.getByText('语音编码环境')).toBeVisible() + expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument() expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument() }) + + it('checks the actual encoding environment and offers pilk repair', async () => { + const onNotice = vi.fn() + const incompleteEnvironment = { + state: 'incomplete' as const, + ready: false, + runtimeReady: true, + python: { ready: true, executable: '/usr/local/bin/python3', version: '3.12.11' }, + pilk: { ready: false, error: '未安装' }, + ffmpeg: { ready: true, executable: '/runtime/ffmpeg', version: '6.1' }, + encoder: 'go-silk' as const, + message: '语音编码环境不完整,OneBot 可能回退到 go-silk' + } + const readyEnvironment = { + ...incompleteEnvironment, + state: 'ready' as const, + ready: true, + pilk: { ready: true, version: '0.2.4', path: '/user/pilk/__init__.py' }, + encoder: 'pilk' as const, + message: '语音编码环境正常,可以使用 pilk 编码' + } + checkVoiceEncodingEnvironment.mockResolvedValue(incompleteEnvironment) + installPilk.mockResolvedValue({ success: true, environment: readyEnvironment }) + + render() + fireEvent.click(await screen.findByRole('button', { name: '检查编码环境' })) + expect(await screen.findByText('⚠ 编码环境不完整')).toBeVisible() + expect(screen.getByRole('button', { name: '安装 pilk' })).toBeEnabled() + + fireEvent.click(screen.getByRole('button', { name: '安装 pilk' })) + await waitFor(() => expect(installPilk).toHaveBeenCalledTimes(1)) + expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。重新启动语音发送服务后生效。') + expect(await screen.findByText('✓ 编码环境正常')).toBeVisible() + }) }) diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts index 2bc9e20..2493462 100644 --- a/tests/e2e/visual.spec.ts +++ b/tests/e2e/visual.spec.ts @@ -739,6 +739,42 @@ for (const appearanceTheme of ['light', 'dark'] as const) { }) } +for (const appearanceTheme of ['light', 'dark'] as const) { + test(`SETTINGS-06A voice encoding environment ${appearanceTheme} visual @visual`, async () => { + const fixture = await launchTestApp({ now: visualNow, appearanceTheme }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize(visualViewport) + await fixture.page + .getByRole('navigation', { name: '一级导航' }) + .getByRole('button', { name: '设置' }) + .click() + await fixture.page.getByRole('button', { name: '文字转语音' }).click() + const environment = fixture.page.locator('.voice-encoding-environment') + await environment.scrollIntoViewIfNeeded() + await expect(environment.getByRole('heading', { name: '语音编码环境' })).toBeVisible() + await expect(environment.getByRole('button', { name: '检查编码环境' })).toBeVisible() + await expect(environment.getByRole('status')).toContainText('尚未检查') + await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme) + expect( + await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + expect(pageErrors).toEqual([]) + await clearScreenshotFocus(fixture.page) + await expect(environment).toHaveScreenshot( + `settings-text-to-speech-voice-environment-${appearanceTheme}.png`, + { + animations: 'disabled', + caret: 'hide' + } + ) + } finally { + await fixture.close() + } + }) +} + for (const appearanceTheme of ['light', 'dark'] as const) { test(`SETTINGS-07 voice recognition controls ${appearanceTheme} visual @visual`, async () => { const fixture = await launchTestApp({ now: visualNow, appearanceTheme }) diff --git a/tests/integration/preload-contract.test.ts b/tests/integration/preload-contract.test.ts index 34571e8..466f545 100644 --- a/tests/integration/preload-contract.test.ts +++ b/tests/integration/preload-contract.test.ts @@ -98,6 +98,14 @@ describe('preload IPC contract', () => { await api.getPersonalWechatSenderStatus() expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getStatus') + await api.checkPersonalWechatVoiceEncodingEnvironment() + expect(invoke).toHaveBeenLastCalledWith('wechat-personal:checkVoiceEnvironment') + await api.installPersonalWechatPilk() + expect(invoke).toHaveBeenLastCalledWith('wechat-personal:installPilk') + await api.openPersonalWechatVoicePythonDownload() + expect(invoke).toHaveBeenLastCalledWith('wechat-personal:openVoicePythonDownload') + await api.openPersonalWechatVoiceFfmpegDownload() + expect(invoke).toHaveBeenLastCalledWith('wechat-personal:openVoiceFfmpegDownload') await api.checkPersonalWechatSenderStatus('4567') expect(invoke).toHaveBeenLastCalledWith('wechat-personal:checkStatus', '4567') await api.rebindPersonalWechatSender() diff --git a/tests/unit/personal-wechat-voice-environment-service.test.ts b/tests/unit/personal-wechat-voice-environment-service.test.ts new file mode 100644 index 0000000..739e092 --- /dev/null +++ b/tests/unit/personal-wechat-voice-environment-service.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { findRuntime, buildEnvironment, writeLog } = vi.hoisted(() => ({ + findRuntime: vi.fn(), + buildEnvironment: vi.fn(), + writeLog: vi.fn() +})) + +vi.mock('../../src/main/services/personal-wechat-send-service', () => ({ + findPersonalWechatRuntime: findRuntime, + buildPersonalWechatRuntimeEnvironment: buildEnvironment +})) +vi.mock('../../src/main/app-logger', () => ({ appLogger: { write: writeLog } })) + +import { PersonalWechatVoiceEnvironmentService } from '../../src/main/services/personal-wechat-voice-environment-service' + +const runtime = { + root: '/runtime', + executable: '/runtime/onebot/onebot', + workingDirectory: '/runtime/onebot', + configDirectory: '/runtime/wechat_version', + logPath: '/runtime/onebot/log/macos.log' +} + +describe('PersonalWechatVoiceEnvironmentService', () => { + let pilkAvailable = true + const commands: Array<{ executable: string; args: string[]; env: NodeJS.ProcessEnv }> = [] + + beforeEach(() => { + pilkAvailable = true + commands.length = 0 + findRuntime.mockReset().mockReturnValue(runtime) + buildEnvironment.mockReset().mockReturnValue({ + PATH: '/runtime/bin', + PYTHONPATH: '/runtime/python' + }) + writeLog.mockReset() + }) + + function createService(): PersonalWechatVoiceEnvironmentService { + return new PersonalWechatVoiceEnvironmentService({ + platform: 'darwin', + architecture: 'arm64', + findRuntime, + buildEnvironment, + now: () => new Date('2026-08-31T00:00:00.000Z'), + runCommand: async (executable, args, options) => { + commands.push({ executable, args, env: options.env }) + if (executable === 'python3') { + return { + stdout: JSON.stringify({ executable: '/runtime/python3', version: '3.12.11' }), + stderr: '' + } + } + if (executable === '/runtime/python3' && args[0] === '-c') { + if (!pilkAvailable) throw new Error("ModuleNotFoundError: No module named 'pilk'") + return { + stdout: JSON.stringify({ version: '0.2.4', path: '/runtime/python/pilk/__init__.py' }), + stderr: '' + } + } + if (executable === '/usr/bin/which') { + return { stdout: '/runtime/bin/ffmpeg\n', stderr: '' } + } + if (executable === 'ffmpeg') { + return { stdout: 'ffmpeg version 7.0 fixture\n', stderr: '' } + } + if (executable === '/runtime/python3' && args[0] === '-m') { + pilkAvailable = true + return { stdout: 'installed\n', stderr: '' } + } + throw new Error(`unexpected command: ${executable} ${args.join(' ')}`) + } + }) + } + + it('checks pilk with the Python and environment used by OneBot', async () => { + const environment = await createService().check() + + expect(environment).toMatchObject({ + state: 'ready', + ready: true, + runtimeReady: true, + encoder: 'pilk', + python: { ready: true, executable: '/runtime/python3', version: '3.12.11' }, + pilk: { ready: true, version: '0.2.4', path: '/runtime/python/pilk/__init__.py' }, + ffmpeg: { ready: true, executable: '/runtime/bin/ffmpeg', version: '7.0' } + }) + expect(commands.find((command) => command.executable === 'python3')?.env).toEqual({ + PATH: '/runtime/bin', + PYTHONPATH: '/runtime/python' + }) + expect(commands.some((command) => command.executable === '/runtime/python3')).toBe(true) + expect(writeLog).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'VoiceRuntime', + message: 'Voice encoding environment is ready' + }) + ) + }) + + it('reports go-silk fallback and installs the exact pilk version with the detected Python', async () => { + pilkAvailable = false + const service = createService() + const before = await service.check() + + expect(before).toMatchObject({ state: 'incomplete', ready: false, encoder: 'go-silk' }) + expect(before.pilk.ready).toBe(false) + + const result = await service.installPilk() + + expect(result.success).toBe(true) + expect(result.environment.pilk).toMatchObject({ ready: true, version: '0.2.4' }) + expect(commands).toContainEqual( + expect.objectContaining({ + executable: '/runtime/python3', + args: ['-m', 'pip', 'install', '--user', 'pilk==0.2.4'] + }) + ) + }) + + it('keeps the UI contract explicit when Python cannot be started', async () => { + const service = new PersonalWechatVoiceEnvironmentService({ + platform: 'darwin', + architecture: 'arm64', + findRuntime, + buildEnvironment, + runCommand: async () => { + throw new Error('spawn python3 ENOENT') + } + }) + + const environment = await service.check() + + expect(environment).toMatchObject({ state: 'incomplete', ready: false, encoder: 'unavailable' }) + expect(environment.python).toMatchObject({ ready: false }) + expect(environment.pilk).toMatchObject({ ready: false, error: 'Python 未找到,无法检查 pilk' }) + }) +}) diff --git a/tests/unit/security-and-errors.test.ts b/tests/unit/security-and-errors.test.ts index 08e9386..183f3c5 100644 --- a/tests/unit/security-and-errors.test.ts +++ b/tests/unit/security-and-errors.test.ts @@ -95,6 +95,6 @@ describe('personal WeChat runtime security invariants', () => { resolve('src/main/services/personal-wechat-send-service.ts'), 'utf8' ) - expect(senderSource).toContain('buildRuntimePythonPath(preflight.runtime.root)') + expect(senderSource).toContain('buildPersonalWechatRuntimeEnvironment(preflight.runtime.root)') }) })