diff --git a/scripts/prepare-wechat-chatter-runtime.cjs b/scripts/prepare-wechat-chatter-runtime.cjs index c196f87..4940e05 100644 --- a/scripts/prepare-wechat-chatter-runtime.cjs +++ b/scripts/prepare-wechat-chatter-runtime.cjs @@ -9,7 +9,7 @@ * onebot/script.js. See docs/third-party/wechat-chatter/NOTICE.md. */ -/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-require-imports */ const { execFileSync } = require('node:child_process') const fs = require('node:fs') const os = require('node:os') @@ -84,31 +84,6 @@ for (const required of [executable, script, config]) { if (!fs.existsSync(required)) throw new Error(`运行时文件缺失:${required}`) } -const pythonPackages = path.join(outputDir, 'python') -const pilkPackage = path.join(pythonPackages, 'pilk') -if (!fs.existsSync(pilkPackage)) { - console.log('[wechat-personal] 安装项目内语音编码器 pilk') - fs.mkdirSync(pythonPackages, { recursive: true }) - try { - execFileSync( - 'python3', - [ - '-m', - 'pip', - 'install', - '--disable-pip-version-check', - '--no-compile', - '--target', - pythonPackages, - 'pilk==0.2.4' - ], - { stdio: 'inherit' } - ) - } catch { - console.warn('[wechat-personal] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器') - } -} - function patchPerSendPayload(scriptPath) { let source = fs.readFileSync(scriptPath, 'utf8') if (source.includes('var activeTriggerX1Payload = ptr(0);')) return diff --git a/src/main/index.ts b/src/main/index.ts index 56e1194..2b22007 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1857,13 +1857,15 @@ app.on('before-quit', (event) => { void (async () => { agentHubService.stop() - personalWechatSendService.stop() flushBootstrapCacheWritesSync() const [, nativeCallsDrained] = await Promise.all([ apiServer.stop().catch(() => undefined), chat.closeChatDbForQuit().catch(() => false), voiceRecognition?.dispose().catch(() => undefined), - knowledgeSearchService?.dispose().catch(() => undefined) + knowledgeSearchService?.dispose().catch(() => undefined), + personalWechatSendService.terminate().catch((error) => { + console.warn('[Shutdown] personal WeChat sender cleanup failed:', error) + }) ]) if (!nativeCallsDrained) { console.warn('[Shutdown] WCDB async calls did not fully drain before quit') diff --git a/src/main/services/ai-search-pipeline-service.ts b/src/main/services/ai-search-pipeline-service.ts index f91f326..b66deec 100644 --- a/src/main/services/ai-search-pipeline-service.ts +++ b/src/main/services/ai-search-pipeline-service.ts @@ -1057,7 +1057,8 @@ export class AiSearchPipelineService { return result.evidence.map((item): AiSearchPipelineEvidence => { const rawConversationId = String(item.conversationId || '').trim() const contact = - contactsById.get(rawConversationId) || contactsById.get(rawConversationId.toLocaleLowerCase()) + contactsById.get(rawConversationId) || + contactsById.get(rawConversationId.toLocaleLowerCase()) return { ...item, conversationId: contact?.md5 || rawConversationId, diff --git a/src/main/services/personal-wechat-runtime-manager.ts b/src/main/services/personal-wechat-runtime-manager.ts index fa11935..37e66ca 100644 --- a/src/main/services/personal-wechat-runtime-manager.ts +++ b/src/main/services/personal-wechat-runtime-manager.ts @@ -265,27 +265,6 @@ export class PersonalWechatRuntimeManager { addModifiedWorkNotice(script) await chmod(executable, 0o755) - const pythonPackages = join(stagedDirectory, 'python') - await mkdir(pythonPackages, { recursive: true }) - try { - await execFileAsync('/usr/bin/env', [ - 'python3', - '-m', - 'pip', - 'install', - '--disable-pip-version-check', - '--no-compile', - '--target', - pythonPackages, - 'pilk==0.2.4' - ]) - } catch (error) { - console.warn( - '[PersonalWechatRuntime] pilk 安装失败,将使用 OneBot 内置的 Go SILK 编码器:', - error - ) - } - for (const required of [ executable, script, diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index 6ba6878..1ee2138 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -372,20 +372,29 @@ async function readOneBotProcessInfo(): Promise { async function terminateOneBot(info: OneBotProcessInfo): Promise { if (!/(^|\/)onebot(?:\s|$)/.test(info.command)) return + await terminateProcess(info.pid) +} + +async function terminateProcess(pid: number): Promise { try { - process.kill(info.pid, 'SIGTERM') + process.kill(pid, 'SIGTERM') } catch { return } const startedAt = Date.now() while (Date.now() - startedAt < STOP_TIMEOUT_MS) { try { - process.kill(info.pid, 0) + process.kill(pid, 0) await new Promise((resolve) => setTimeout(resolve, 100)) } catch { return } } + try { + process.kill(pid, 'SIGKILL') + } catch { + // The process exited between the last liveness check and the forced stop. + } } async function requestWithTimeout( @@ -605,18 +614,11 @@ export class PersonalWechatSendService { return status } - stop(): void { - const child = this.child - this.child = null - this.startPromise = null - // A second Attach to the same WeChat process is unstable. Leave OneBot alive; - // it monitors the WeChat PID and exits when that process ends. - if (child && child.exitCode === null) child.unref() - } - async terminate(): Promise { + const trackedPid = this.child?.pid const oneBot = await readOneBotProcessInfo() if (oneBot) await terminateOneBot(oneBot) + if (trackedPid && trackedPid !== oneBot?.pid) await terminateProcess(trackedPid) this.child = null this.startPromise = null this.lastError = '' diff --git a/tests/unit/security-and-errors.test.ts b/tests/unit/security-and-errors.test.ts index e4aea7e..5e82dc1 100644 --- a/tests/unit/security-and-errors.test.ts +++ b/tests/unit/security-and-errors.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, readFileSync, rmSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { join, resolve } from 'path' import { afterAll, describe, expect, it, vi } from 'vitest' import { classifyStickerHttpFailure } from '../../src/shared/sticker' @@ -51,3 +51,42 @@ describe('sticker HTTP failures', () => { expect(classifyStickerHttpFailure(429, 'https://fixture.invalid/a').code).toBe('rate_limited') }) }) + +describe('personal WeChat runtime security invariants', () => { + it('waits for sender termination during application shutdown', () => { + const mainSource = readFileSync(resolve('src/main/index.ts'), 'utf8') + const shutdownStart = mainSource.indexOf("app.on('before-quit'") + const shutdownEnd = mainSource.indexOf('function showMainWindow', shutdownStart) + const shutdownSource = mainSource.slice(shutdownStart, shutdownEnd) + + expect(shutdownStart).toBeGreaterThanOrEqual(0) + expect(shutdownEnd).toBeGreaterThan(shutdownStart) + expect(shutdownSource).toContain('await Promise.all([') + expect(shutdownSource).toContain('personalWechatSendService.terminate()') + expect(shutdownSource).not.toContain('personalWechatSendService.stop()') + + const senderSource = readFileSync( + resolve('src/main/services/personal-wechat-send-service.ts'), + 'utf8' + ) + expect(senderSource).toContain("process.kill(pid, 'SIGTERM')") + expect(senderSource).toContain("process.kill(pid, 'SIGKILL')") + expect(senderSource).toContain('const trackedPid = this.child?.pid') + }) + + it('does not install Python packages while preparing the sender runtime', () => { + const runtimeManagerSource = readFileSync( + resolve('src/main/services/personal-wechat-runtime-manager.ts'), + 'utf8' + ) + const preparationScriptSource = readFileSync( + resolve('scripts/prepare-wechat-chatter-runtime.cjs'), + 'utf8' + ) + + for (const source of [runtimeManagerSource, preparationScriptSource]) { + expect(source).not.toContain('pilk==') + expect(source).not.toMatch(/['"]pip['"]/) + } + }) +})