diff --git a/scripts/prepare-wechat-chatter-runtime.cjs b/scripts/prepare-wechat-chatter-runtime.cjs index 74d07de..31ec50a 100644 --- a/scripts/prepare-wechat-chatter-runtime.cjs +++ b/scripts/prepare-wechat-chatter-runtime.cjs @@ -86,41 +86,91 @@ for (const required of [executable, script, config]) { function patchPerSendPayload(scriptPath) { let source = fs.readFileSync(scriptPath, 'utf8') - if (source.includes('var activeTriggerX1Payload = ptr(0);')) return + if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return - const declarations = 'var triggerX1Payload;\nvar triggerX0;' - const patchedDeclarations = - 'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;' - const originalSend = ` const payloadData = hexToByteArray(payloadHex); - triggerX1Payload.writeByteArray(payloadData); - triggerX1Payload.add(0x18).writePointer(info.cgiAddr); - triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0)); - triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));` - const patchedSend = ` const payloadData = hexToByteArray(payloadHex); + const activeSend = ` const payloadData = hexToByteArray(payloadHex); activeTriggerX1Payload = Memory.alloc(payloadData.length); activeTriggerX1Payload.writeByteArray(payloadData); activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr); activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0)); activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));` + const upstreamSend = ` const payloadData = hexToByteArray(payloadHex); + triggerX1Payload.writeByteArray(payloadData); + triggerX1Payload.add(0x18).writePointer(info.cgiAddr); + triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0)); + triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));` + if (!source.includes(activeSend)) throw new Error('无法定位 wechat_chatter 连续发送补丁位置') + source = source + .replace( + 'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', + 'var triggerX1Payload;\nvar triggerX0;' + ) + .replace(activeSend, upstreamSend) + .replace( + ' MMStartTask(triggerX0, activeTriggerX1Payload);', + ' MMStartTask(triggerX0, triggerX1Payload);' + ) + .replace( + ' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', + ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);' + ) + .replace( + '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', + '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn' + ) + fs.writeFileSync(scriptPath, source) + console.log('[wechat-personal] 已恢复原生发送 payload 布局') +} - if (!source.includes(declarations) || !source.includes(originalSend)) { - throw new Error('无法定位 wechat_chatter 连续发送补丁位置') +function patchSendContextCapture(scriptPath, strict = true) { + let source = fs.readFileSync(scriptPath, 'utf8') + if (source.includes('function isLikelySendContext(')) return + const original = `function AttachSendFunc() { + Interceptor.attach(sendFuncAddr.add(0x10), { + onEnter: function (args) { + + if (triggerX1Payload) { + return + } + + triggerX0 = this.context.x0; + triggerX1Payload = this.context.x1; + console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); + } + }) +}` + const patched = `function isLikelySendContext(candidateX0, candidateX1) { + try { + if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false; + var manager = readPointerIfReadable(candidateX0.add(0x18)); + var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18))); + console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi); + return !manager.equals(ptr(0)); + } catch (e) { + console.error("[debug] StartTask candidate inspect failed: " + e); + return false; + } +} + +function AttachSendFunc() { + Interceptor.attach(sendFuncAddr.add(0x10), { + onEnter: function (args) { + if (triggerX1Payload) return; + var candidateX0 = this.context.x0; + var candidateX1 = this.context.x1; + if (!isLikelySendContext(candidateX0, candidateX1)) return; + triggerX0 = candidateX0; + triggerX1Payload = candidateX1; + console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); + } + }) +}` + if (!source.includes(original)) { + if (strict) throw new Error('无法定位 StartTask 上下文 Hook') + return } - source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend) - source = source.replace( - ' MMStartTask(triggerX0, triggerX1Payload);', - ' MMStartTask(triggerX0, activeTriggerX1Payload);' - ) - source = source.replace( - ' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', - ' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);' - ) - source = source.replace( - '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn', - '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn' - ) + source = source.replace(original, patched) fs.writeFileSync(scriptPath, source) - console.log('[wechat-personal] 已应用逐条发送 payload 隔离补丁') } function patchVoiceAudioBuffer(scriptPath) { @@ -170,6 +220,163 @@ function patchImageHookReadiness(scriptPath) { console.log('[wechat-personal] 已应用图片 Hook 状态补丁') } +// Backport of wechat_chatter PR #36 by @Leslielu: +// https://github.com/yincongcyincong/wechat_chatter/pull/36 +// TraceMemo adds the verified macOS WeChat 4.1.11.53 addresses. +// macOS WeChat 4.1.11.53, located and verified by TraceMemo +function patchCdnColdStart(scriptPath) { + let source = fs.readFileSync(scriptPath, 'utf8') + if (source.includes('function resolveCdnManager()')) return + + const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); + cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});` + const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); + cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}}); + // 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为) + {{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}} + {{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}` + if (!source.includes(initAddresses)) throw new Error('下载的微信版本配置与当前应用不兼容') + source = source.replace(initAddresses, patchedInitAddresses) + + const downloadChunkEnd = `} + +function fillUploadX1AndStart` + const resolver = `} + +// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器 +// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。 +function resolveCdnManager() { + if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) { + return ptr(0); + } + try { + // libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。 + var strDefault = Memory.alloc(24); + strDefault.writeUtf8String("default"); + strDefault.add(0x17).writeU8(7); + + var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']); + var svc = getService(strDefault); + if (!isReadablePointer(svc)) { + console.error("[!] GetService(\\"default\\") 返回不可读: " + svc); + return ptr(0); + } + var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']); + var ctx = getCtx(svc); + if (!isReadablePointer(ctx)) { + console.error("[!] CdnManager getter 返回不可读: " + ctx); + return ptr(0); + } + var mgr = readPointerIfReadable(ctx.add(0x40)); + if (!isReadablePointer(mgr)) { + console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx); + return ptr(0); + } + return mgr; + } catch (e) { + console.error("[!] resolveCdnManager 异常: " + e); + return ptr(0); + } +} + +function ensureCdnManagerX0() { + if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) { + uploadGlobalX0 = downloadGlobalX0; + console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0); + } + if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) { + downloadGlobalX0 = uploadGlobalX0; + console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0); + } + if (uploadGlobalX0.equals(ptr(0))) { + var mgr = resolveCdnManager(); + if (!mgr.equals(ptr(0))) { + uploadGlobalX0 = mgr; + if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { + downloadGlobalX0 = mgr; + } + console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr); + } + } + return !uploadGlobalX0.equals(ptr(0)); +} + +function fillUploadX1AndStart` + if (!source.includes(downloadChunkEnd)) throw new Error('无法定位 wechat_chatter 媒体上传逻辑') + source = source.replace(downloadChunkEnd, resolver) + + const declarations = 'var uploadImageAddr;\n' + const patchedDeclarations = + 'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n' + if (!source.includes(declarations)) throw new Error('无法定位 wechat_chatter 媒体地址声明') + source = source.replace(declarations, patchedDeclarations) + + const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { + if (uploadGlobalX0.equals(ptr(0))) {` + const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { + if (uploadGlobalX0.equals(ptr(0))) { + ensureCdnManagerX0(); + } + if (uploadGlobalX0.equals(ptr(0))) {` + if (!source.includes(uploadGuard)) throw new Error('无法定位 wechat_chatter 媒体上传入口') + source = source.replace(uploadGuard, patchedUploadGuard) + + const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { + if (uploadGlobalX0.equals(ptr(0))) {` + const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { + if (uploadGlobalX0.equals(ptr(0))) { + ensureCdnManagerX0(); + } + if (uploadGlobalX0.equals(ptr(0))) {` + if (!source.includes(voiceGuard)) throw new Error('无法定位 wechat_chatter 语音上传入口') + source = source.replace(voiceGuard, patchedVoiceGuard) + + const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;` + const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0; + if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) { + downloadGlobalX0 = capturedUploadX0; + console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0); + }` + if (!source.includes(uploadHook)) throw new Error('无法定位 wechat_chatter 图片 Hook') + source = source.replace(uploadHook, patchedUploadHook) + + const downloadHook = ` downloadGlobalX0 = this.context.x0;` + const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0; + if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) { + uploadGlobalX0 = downloadGlobalX0; + console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0); + }` + if (!source.includes(downloadHook)) throw new Error('无法定位 wechat_chatter 下载 Hook') + source = source.replace(downloadHook, patchedDownloadHook) + + const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { + if (!downloadGlobalX0) {` + const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { + if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { + ensureCdnManagerX0(); + } + if (!downloadGlobalX0) {` + if (!source.includes(downloadGuard)) throw new Error('无法定位 wechat_chatter 媒体下载入口') + source = source.replace(downloadGuard, patchedDownloadGuard) + + fs.writeFileSync(scriptPath, source) + console.log('[wechat-personal] 已应用 CdnManager 冷启动解析补丁') +} + +function patchCdnColdStartConfig(configPath) { + const source = fs.readFileSync(configPath, 'utf8') + let config + try { + config = JSON.parse(source) + } catch { + throw new Error('4.1.11.53 版本配置不是有效 JSON') + } + config.cdnGetServiceAddr = '0x50a15d0' + config.cdnManagerGetterAddr = '0x5259290' + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + console.log('[wechat-personal] 已写入 4.1.11.53 CdnManager 地址') +} + function patchWechatCoreModuleBase(scriptPath) { let source = fs.readFileSync(scriptPath, 'utf8') if (source.includes('WeChat core module base:')) return @@ -207,7 +414,7 @@ function addModifiedWorkNotice(scriptPath) { * Runtime version: v0.0.18 * License: GNU General Public License version 3 (GPL-3.0) * Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers, - * and image Hook readiness logging. + * CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging. * These modifications are not provided by the upstream author. */ @@ -219,8 +426,11 @@ function addModifiedWorkNotice(scriptPath) { patchWechatCoreModuleBase(script) patchPerSendPayload(script) +patchSendContextCapture(script) patchVoiceAudioBuffer(script) patchImageHookReadiness(script) +patchCdnColdStartConfig(config) +patchCdnColdStart(script) addModifiedWorkNotice(script) fs.chmodSync(executable, 0o755) console.log('[wechat-personal] 运行时准备完成') diff --git a/src/main/services/personal-wechat-capability-service.ts b/src/main/services/personal-wechat-capability-service.ts index 9e3be55..f843a2c 100644 --- a/src/main/services/personal-wechat-capability-service.ts +++ b/src/main/services/personal-wechat-capability-service.ts @@ -26,7 +26,7 @@ export class PersonalWechatCapabilityService { image: Boolean(senderStatus.canSendImage), voice: Boolean(senderStatus.canSendVoice) } - const status = this.mapState(senderStatus, capabilities.image) + const status = this.mapState(senderStatus) const ready = status === 'ready' return { supported: status !== 'unsupported', @@ -40,8 +40,8 @@ export class PersonalWechatCapabilityService { : senderStatus.message || (status === 'needs_binding' || status === 'unconfigured' ? '请先绑定个人微信' - : status === 'needs_verification' - ? '请先完成微信消息能力检测' + : status === 'initializing' + ? '正在初始化微信发送能力' : status === 'ready' ? '个人微信已准备好发送日报' : senderStatus.error || '个人微信发送能力异常'), @@ -49,32 +49,34 @@ export class PersonalWechatCapabilityService { } } - private mapState( - senderStatus: PersonalWechatSenderStatus, - canSendImage: boolean - ): PersonalWechatSendCapabilityState { + private mapState(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapabilityState { if (senderStatus.platform === 'win32') { if (senderStatus.canSend) return 'ready' if (!senderStatus.endpoint) return 'unconfigured' if (senderStatus.state === 'error' && senderStatus.endpointReady) return 'error' - return 'needs_verification' + return 'initializing' } if (senderStatus.platform !== 'darwin' || senderStatus.state === 'unsupported_platform') { return 'unsupported' } if (senderStatus.state === 'error') return 'error' - const hasBinding = Boolean(senderStatus.boundWechatPid) - if (!hasBinding) { + const hasCurrentBinding = Boolean( + senderStatus.endpointReady && + senderStatus.attachReady && + senderStatus.wechatPid && + senderStatus.boundWechatPid === senderStatus.wechatPid + ) + if (!hasCurrentBinding) { return senderStatus.runtimeReady ? 'needs_binding' : 'unconfigured' } - if (canSendImage) return 'ready' + if (senderStatus.canSend) return 'ready' if ( senderStatus.state === 'hook_not_ready' || senderStatus.state === 'online' || senderStatus.state === 'starting' || senderStatus.state === 'stopped' ) { - return 'needs_verification' + return 'initializing' } return 'error' } diff --git a/src/main/services/personal-wechat-runtime-manager.ts b/src/main/services/personal-wechat-runtime-manager.ts index 55a3759..aaf59c9 100644 --- a/src/main/services/personal-wechat-runtime-manager.ts +++ b/src/main/services/personal-wechat-runtime-manager.ts @@ -21,39 +21,78 @@ const ARCHIVE_SHA256 = 'ee1e11bccef7cec1cf944cd8b2ac3fadaadb9376ba24cd823e340914 function patchPerSendPayload(scriptPath: string): void { let source = readFileSync(scriptPath, 'utf8') - if (source.includes('var activeTriggerX1Payload = ptr(0);')) return + if (!source.includes('var activeTriggerX1Payload = ptr(0);')) return - const declarations = 'var triggerX1Payload;\nvar triggerX0;' - const patchedDeclarations = - 'var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;' - const originalSend = ` const payloadData = hexToByteArray(payloadHex); - triggerX1Payload.writeByteArray(payloadData); - triggerX1Payload.add(0x18).writePointer(info.cgiAddr); - triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0)); - triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));` - const patchedSend = ` const payloadData = hexToByteArray(payloadHex); + const activeSend = ` const payloadData = hexToByteArray(payloadHex); activeTriggerX1Payload = Memory.alloc(payloadData.length); activeTriggerX1Payload.writeByteArray(payloadData); activeTriggerX1Payload.add(0x18).writePointer(info.cgiAddr); activeTriggerX1Payload.add(0xb8).writePointer(activeTriggerX1Payload.add(0xc0)); activeTriggerX1Payload.add(0x190).writePointer(activeTriggerX1Payload.add(0x198));` - - if (!source.includes(declarations) || !source.includes(originalSend)) { + const upstreamSend = ` const payloadData = hexToByteArray(payloadHex); + triggerX1Payload.writeByteArray(payloadData); + triggerX1Payload.add(0x18).writePointer(info.cgiAddr); + triggerX1Payload.add(0xb8).writePointer(triggerX1Payload.add(0xc0)); + triggerX1Payload.add(0x190).writePointer(triggerX1Payload.add(0x198));` + if (!source.includes(activeSend)) { throw new Error('下载的发送组件与当前应用不兼容') } - source = source.replace(declarations, patchedDeclarations).replace(originalSend, patchedSend) - source = source.replace( - ' MMStartTask(triggerX0, triggerX1Payload);', - ' MMStartTask(triggerX0, activeTriggerX1Payload);' - ) - source = source.replace( - ' } catch (e) {\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', - ' } catch (e) {\n activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);' - ) - source = source.replace( - '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn', - '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn' - ) + source = source.replace('var triggerX1Payload;\nvar activeTriggerX1Payload = ptr(0);\nvar triggerX0;', 'var triggerX1Payload;\nvar triggerX0;') + .replace(activeSend, upstreamSend) + .replace(' MMStartTask(triggerX0, activeTriggerX1Payload);', ' MMStartTask(triggerX0, triggerX1Payload);') + .replace(' activeTriggerX1Payload = ptr(0);\n console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);', ' console.error("[!] Error trigger " + msgType + " MMStartTask: " + e);') + .replace('\t\t\t\tpendingSendMsgType = "";\n\t\t\t\tactiveTriggerX1Payload = ptr(0);\n\t\t\t\treturn', '\t\t\t\tpendingSendMsgType = "";\n\t\t\t\treturn') + writeFileSync(scriptPath, source) +} + +function patchSendContextCapture(scriptPath: string, strict = true): void { + let source = readFileSync(scriptPath, 'utf8') + if (source.includes('function isLikelySendContext(')) return + const original = `function AttachSendFunc() { + Interceptor.attach(sendFuncAddr.add(0x10), { + onEnter: function (args) { + + if (triggerX1Payload) { + return + } + + triggerX0 = this.context.x0; + triggerX1Payload = this.context.x1; + console.log(\`[+] 捕获到 StartTask 调用,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); + } + }) +}` + const patched = `function isLikelySendContext(candidateX0, candidateX1) { + try { + if (!isReadablePointer(candidateX0) || !isReadablePointer(candidateX1)) return false; + var manager = readPointerIfReadable(candidateX0.add(0x18)); + var cgi = readUtf8StringIfReadable(readPointerIfReadable(candidateX1.add(0x18))); + console.log("[debug] StartTask candidate x0=" + candidateX0 + " x1=" + candidateX1 + " x0+0x18=" + manager + " cgi=" + cgi); + return !manager.equals(ptr(0)); + } catch (e) { + console.error("[debug] StartTask candidate inspect failed: " + e); + return false; + } +} + +function AttachSendFunc() { + Interceptor.attach(sendFuncAddr.add(0x10), { + onEnter: function (args) { + if (triggerX1Payload) return; + var candidateX0 = this.context.x0; + var candidateX1 = this.context.x1; + if (!isLikelySendContext(candidateX0, candidateX1)) return; + triggerX0 = candidateX0; + triggerX1Payload = candidateX1; + console.log(\`[+] 捕获到有效 StartTask 上下文,X0:\${triggerX0}, Payload: \${triggerX1Payload}\`); + } + }) +}` + if (!source.includes(original)) { + if (strict) throw new Error('下载的微信版本与当前应用不兼容') + return + } + source = source.replace(original, patched) writeFileSync(scriptPath, source) } @@ -105,6 +144,181 @@ function patchImageHookReadiness(scriptPath: string): void { writeFileSync(scriptPath, source) } +function patchCdnColdStart(scriptPath: string, strict = true): void { + let source = readFileSync(scriptPath, 'utf8') + if (source.includes('function resolveCdnManager()')) return + + const initAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); + cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}});` + const patchedInitAddresses = ` uploadImageAddr = baseAddr.add({{.uploadImageAddr}}); + cndOnCompleteAddr = baseAddr.add({{.cndOnCompleteAddr}}); + // 冷启动 CdnManager 解析(旧版本缺少可选键时保持 hook 捕获行为) + {{if .cdnGetServiceAddr}}cdnGetServiceAddr = baseAddr.add({{.cdnGetServiceAddr}});{{end}} + {{if .cdnManagerGetterAddr}}cdnManagerGetterAddr = baseAddr.add({{.cdnManagerGetterAddr}});{{end}}` + if (!source.includes(initAddresses)) { + if (strict) throw new Error('下载的微信版本配置与当前应用不兼容') + return + } + source = source.replace(initAddresses, patchedInitAddresses) + + const downloadChunkEnd = `} + +function fillUploadX1AndStart` + const resolver = `} + +// 上传和下载共用同一个 mars::cdn::CdnManager。冷启动时通过服务定位器 +// 取得 [ctx + 0x40],避免必须先手动发送图片才能让 Hook 捕获上下文。 +function resolveCdnManager() { + if (cdnGetServiceAddr.equals(ptr(0)) || cdnManagerGetterAddr.equals(ptr(0))) { + return ptr(0); + } + try { + // libc++ SSO 短字符串:数据在 +0,长度写在 +0x17。 + var strDefault = Memory.alloc(24); + strDefault.writeUtf8String("default"); + strDefault.add(0x17).writeU8(7); + + var getService = new NativeFunction(cdnGetServiceAddr, 'pointer', ['pointer']); + var svc = getService(strDefault); + if (!isReadablePointer(svc)) { + console.error("[!] GetService(\\"default\\") 返回不可读: " + svc); + return ptr(0); + } + var getCtx = new NativeFunction(cdnManagerGetterAddr, 'pointer', ['pointer']); + var ctx = getCtx(svc); + if (!isReadablePointer(ctx)) { + console.error("[!] CdnManager getter 返回不可读: " + ctx); + return ptr(0); + } + var mgr = readPointerIfReadable(ctx.add(0x40)); + if (!isReadablePointer(mgr)) { + console.error("[!] ctx+0x40 管理器指针不可读: ctx=" + ctx); + return ptr(0); + } + return mgr; + } catch (e) { + console.error("[!] resolveCdnManager 异常: " + e); + return ptr(0); + } +} + +function ensureCdnManagerX0() { + if (uploadGlobalX0.equals(ptr(0)) && downloadGlobalX0 && !downloadGlobalX0.equals(ptr(0))) { + uploadGlobalX0 = downloadGlobalX0; + console.log("[+] downloadGlobalX0 回填 uploadGlobalX0: " + uploadGlobalX0); + } + if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !uploadGlobalX0.equals(ptr(0))) { + downloadGlobalX0 = uploadGlobalX0; + console.log("[+] uploadGlobalX0 回填 downloadGlobalX0: " + downloadGlobalX0); + } + if (uploadGlobalX0.equals(ptr(0))) { + var mgr = resolveCdnManager(); + if (!mgr.equals(ptr(0))) { + uploadGlobalX0 = mgr; + if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { + downloadGlobalX0 = mgr; + } + console.log("[+] 冷启动服务定位器解析 CdnManager: " + mgr); + } + } + return !uploadGlobalX0.equals(ptr(0)); +} + +function fillUploadX1AndStart` + if (!source.includes(downloadChunkEnd)) { + if (strict) throw new Error('无法定位 wechat_chatter 媒体上传逻辑') + return + } + source = source.replace(downloadChunkEnd, resolver) + + const declarations = 'var uploadImageAddr;\n' + const patchedDeclarations = + 'var uploadImageAddr;\nvar cdnGetServiceAddr = ptr(0);\nvar cdnManagerGetterAddr = ptr(0);\n' + if (!source.includes(declarations)) { + if (strict) throw new Error('无法定位 wechat_chatter 媒体地址声明') + return + } + source = source.replace(declarations, patchedDeclarations) + + const uploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { + if (uploadGlobalX0.equals(ptr(0))) {` + const patchedUploadGuard = `function fillUploadX1AndStart(idAddr, pathAddr, x1Buffer, receiver, md5, filePath, payloadHex) { + if (uploadGlobalX0.equals(ptr(0))) { + ensureCdnManagerX0(); + } + if (uploadGlobalX0.equals(ptr(0))) {` + if (!source.includes(uploadGuard)) { + if (strict) throw new Error('无法定位 wechat_chatter 媒体上传入口') + return + } + source = source.replace(uploadGuard, patchedUploadGuard) + + const voiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { + if (uploadGlobalX0.equals(ptr(0))) {` + const patchedVoiceGuard = `function triggerUploadVoice(receiver, voicePath, payloadHex, audioDataHex, durationMs) { + if (uploadGlobalX0.equals(ptr(0))) { + ensureCdnManagerX0(); + } + if (uploadGlobalX0.equals(ptr(0))) {` + if (!source.includes(voiceGuard)) { + if (strict) throw new Error('无法定位 wechat_chatter 语音上传入口') + return + } + source = source.replace(voiceGuard, patchedVoiceGuard) + + const uploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0;` + const patchedUploadHook = `\t\t\tuploadGlobalX0 = capturedUploadX0; + if ((!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) && !capturedUploadX0.equals(ptr(0))) { + downloadGlobalX0 = capturedUploadX0; + console.log("[+] 上传hook回填 downloadGlobalX0: " + downloadGlobalX0); + }` + if (!source.includes(uploadHook)) { + if (strict) throw new Error('无法定位 wechat_chatter 图片 Hook') + return + } + source = source.replace(uploadHook, patchedUploadHook) + + const downloadHook = ` downloadGlobalX0 = this.context.x0;` + const patchedDownloadHook = ` downloadGlobalX0 = this.context.x0; + if (uploadGlobalX0.equals(ptr(0)) && !downloadGlobalX0.equals(ptr(0))) { + uploadGlobalX0 = downloadGlobalX0; + console.log("[+] 下载hook回填 uploadGlobalX0: " + uploadGlobalX0); + }` + if (!source.includes(downloadHook)) { + if (strict) throw new Error('无法定位 wechat_chatter 下载 Hook') + return + } + source = source.replace(downloadHook, patchedDownloadHook) + + const downloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { + if (!downloadGlobalX0) {` + const patchedDownloadGuard = `function triggerDownload(receiver, cdnUrl, aesKey, filePath, fileType) { + if (!downloadGlobalX0 || downloadGlobalX0.equals(ptr(0))) { + ensureCdnManagerX0(); + } + if (!downloadGlobalX0) {` + if (!source.includes(downloadGuard)) { + if (strict) throw new Error('无法定位 wechat_chatter 媒体下载入口') + return + } + source = source.replace(downloadGuard, patchedDownloadGuard) + + writeFileSync(scriptPath, source) +} + +function patchCdnColdStartConfig(configPath: string, strict = true): void { + let config: Record + try { + config = JSON.parse(readFileSync(configPath, 'utf8')) as Record + } catch { + if (strict) throw new Error('4.1.11.53 版本配置不是有效 JSON') + return + } + config.cdnGetServiceAddr = '0x50a15d0' + config.cdnManagerGetterAddr = '0x5259290' + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) +} + function patchWechatCoreModuleBase(scriptPath: string): void { let source = readFileSync(scriptPath, 'utf8') if (source.includes('WeChat core module base:')) return @@ -140,7 +354,7 @@ function addModifiedWorkNotice(scriptPath: string): void { * Runtime version: v0.0.18 * License: GNU General Public License version 3 (GPL-3.0) * Changes: WeChat module discovery, per-send payload isolation, dynamic voice upload buffers, - * and image Hook readiness logging. + * CdnManager cold-start resolution, media hook backfill, and image Hook readiness logging. * These modifications are not provided by the upstream author. */ @@ -187,7 +401,14 @@ export class PersonalWechatRuntimeManager { if (runtime) { try { // Apply compatibility fixes to runtimes installed before this version. + patchWechatCoreModuleBase(join(runtime.workingDirectory, 'script.js')) + patchPerSendPayload(join(runtime.workingDirectory, 'script.js')) + patchSendContextCapture(join(runtime.workingDirectory, 'script.js'), false) patchVoiceAudioBuffer(join(runtime.workingDirectory, 'script.js'), false) + patchImageHookReadiness(join(runtime.workingDirectory, 'script.js')) + patchCdnColdStart(join(runtime.workingDirectory, 'script.js'), false) + addModifiedWorkNotice(join(runtime.workingDirectory, 'script.js')) + patchCdnColdStartConfig(join(runtime.root, 'wechat_version', '4_1_11_53_mac.json'), false) } catch { // Status discovery should remain available even if an old runtime is read-only. } @@ -291,10 +512,14 @@ export class PersonalWechatRuntimeManager { const executable = join(stagedDirectory, 'onebot', 'onebot') const script = join(stagedDirectory, 'onebot', 'script.js') + const configPath = join(stagedDirectory, 'wechat_version', '4_1_11_53_mac.json') + patchCdnColdStartConfig(configPath) patchWechatCoreModuleBase(script) patchPerSendPayload(script) + patchSendContextCapture(script) patchVoiceAudioBuffer(script) patchImageHookReadiness(script) + patchCdnColdStart(script) addModifiedWorkNotice(script) await chmod(executable, 0o755) diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index 5cb325d..e309c45 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -161,7 +161,10 @@ export function parsePersonalWechatHookLog(log: string): { } else if (text.includes('triggerX0 或 triggerX1Payload 尚未初始化')) { readiness = 'initializing' error = '微信发送能力尚未就绪,消息没有发出' - } else if (text.includes('捕获到 StartTask 调用')) { + } else if ( + text.includes('捕获到有效 StartTask 上下文') || + text.includes('捕获到 StartTask 调用') + ) { readiness = 'ready' textHookReady = true error = undefined @@ -200,6 +203,27 @@ interface PreflightResult { runtime?: RuntimeLayout } +export function deriveMacSendCapabilities(input: { + attached: boolean + baseAddressReady: boolean + textHookInstalled: boolean + textHookReady: boolean + imageHookInstalled: boolean + imagePathBound: boolean +}): Pick { + const baseReady = input.attached && input.baseAddressReady && input.textHookInstalled + const canSendText = baseReady && input.textHookReady + const mediaUploadReady = canSendText && input.imageHookInstalled + const canSendImage = mediaUploadReady && input.imagePathBound + const canSendVoice = mediaUploadReady + return { + canSend: canSendText || canSendImage || canSendVoice, + canSendText, + canSendImage, + canSendVoice + } +} + function toConfigFileName(version: string): string { return `${version.replace(/\./g, '_')}_mac.json` } @@ -991,24 +1015,28 @@ export class PersonalWechatSendService { ...(hook.error ? { error: hook.error } : {}) } } - const baseReady = hook.attached && Boolean(hook.baseAddress) && hook.textHookInstalled - const canSendText = baseReady && hook.textHookReady const imagePathBound = Boolean(oneBot?.imagePath) - const canSendImage = baseReady && hook.imageHookReady && imagePathBound - const canSendVoice = baseReady && hook.imageHookReady + // All send types use the captured native task context. Media no longer + // waits for a real image upload event because the runtime resolves the + // CDN manager during cold start. + const capabilities = deriveMacSendCapabilities({ + attached: hook.attached, + baseAddressReady: Boolean(hook.baseAddress), + textHookInstalled: hook.textHookInstalled, + textHookReady: hook.textHookReady, + imageHookInstalled: hook.imageHookInstalled, + imagePathBound + }) return { ...common, - state: canSendText || canSendImage || canSendVoice ? 'online' : 'hook_not_ready', - canSend: canSendText || canSendImage || canSendVoice, - canSendText, - canSendImage, - canSendVoice, + state: capabilities.canSend ? 'online' : 'hook_not_ready', + ...capabilities, message: - hook.imageHookReady && preflight.status.imagePath && !imagePathBound + hook.imageHookInstalled && preflight.status.imagePath && !imagePathBound ? 'OneBot 尚未绑定微信图片目录,请点击“绑定微信”' - : canSendText || canSendImage || canSendVoice - ? '个人微信已绑定,可使用已初始化的消息类型' - : '个人微信已绑定,发送前请先在微信中手动初始化对应消息类型', + : capabilities.canSend + ? '个人微信发送能力已就绪' + : '个人微信已绑定,正在初始化发送能力…', ...(hook.error ? { error: hook.error } : {}) } } @@ -1146,12 +1174,7 @@ export class PersonalWechatSendService { ? status.canSendVoice : status.canSendImage if (!typeReady) { - const guidance = - request.type === 'text' - ? '请先在微信中给任意好友手动发送一条文字,再重新检测' - : request.type === 'voice' - ? '语音复用媒体上传能力,请先在微信中手动发送一张普通图片,再重新检测' - : '请先在微信中给任意好友手动发送一张普通图片,再重新检测' + const guidance = status.message || '个人微信发送能力正在初始化,请稍后重试' return { success: false, status, error: status.error || guidance } } diff --git a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx index 5235ab3..d8a9936 100644 --- a/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx +++ b/src/renderer/src/components/chat/PersonalWechatSendDialog.tsx @@ -19,10 +19,7 @@ import { Switch } from '../ui' import { isMac, isWindows } from '../../utils/runtime-environment' -import { - PersonalWechatChatComposer, - type ChatMessage -} from './PersonalWechatChatComposer' +import { PersonalWechatChatComposer, type ChatMessage } from './PersonalWechatChatComposer' import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide' import { PersonalWechatVoiceDiagnosticDialog } from './PersonalWechatVoiceDiagnosticDialog' import { PersonalWechatWindowsSendDialog } from './PersonalWechatWindowsSendDialog' @@ -86,7 +83,6 @@ function PersonalWechatMacSendDialog({ const [binding, setBinding] = useState(false) const [runtimeBusy, setRuntimeBusy] = useState(false) const [sendBusy, setSendBusy] = useState(false) - const [detectionAttempted, setDetectionAttempted] = useState(false) // 状态可能来自之前的 OneBot 进程或日志;发送入口只信任当前语音能力状态。 const [sessionBound, setSessionBound] = useState(false) const [messages, setMessages] = useState([]) @@ -144,6 +140,21 @@ function PersonalWechatMacSendDialog({ return unsubscribe }, [refreshStatus]) + useEffect(() => { + if (!senderStatus || senderStatus.canSend || senderStatus.state === 'error') return undefined + const bound = + senderStatus.state === 'online' || + Boolean( + senderStatus.wechatPid && + senderStatus.boundWechatPid === senderStatus.wechatPid && + senderStatus.attachReady && + senderStatus.baseAddressReady + ) + if (!bound) return undefined + const timer = window.setInterval(() => void refreshStatus(), 1_000) + return () => window.clearInterval(timer) + }, [refreshStatus, senderStatus]) + useEffect(() => { if (!isMac) return undefined let active = true @@ -193,7 +204,6 @@ function PersonalWechatMacSendDialog({ const handleBind = async (): Promise => { if (binding) return setBinding(true) - setDetectionAttempted(false) setSendError(null) try { const nextStatus = await window.api.rebindPersonalWechatSender() @@ -207,7 +217,7 @@ function PersonalWechatMacSendDialog({ nextStatus.baseAddressReady ) ) - if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message) + if (nextStatus.state === 'error' && nextStatus.message) setSendError(nextStatus.message) } catch (error) { setSendError(error instanceof Error ? error.message : String(error)) } finally { @@ -215,11 +225,6 @@ function PersonalWechatMacSendDialog({ } } - const handleDetect = async (): Promise => { - setDetectionAttempted(true) - await refreshStatus() - } - const handleClose = (): void => { if (isBusy || closingRef.current) return closingRef.current = true @@ -235,9 +240,7 @@ function PersonalWechatMacSendDialog({ onOpenTextToSpeechSettings() } - const handleSend = async ( - filePath: string - ): Promise<{ success: boolean; error?: string }> => { + const handleSend = async (filePath: string): Promise<{ success: boolean; error?: string }> => { setSendBusy(true) setSendError(null) try { @@ -364,8 +367,6 @@ function PersonalWechatMacSendDialog({ sessionBound={sessionBound} onDownloadRuntime={() => void handleDownloadRuntime()} onBind={() => void handleBind()} - detectionAttempted={detectionAttempted} - onDetect={() => void handleDetect()} onStartSending={() => undefined} onOpenTextToSpeechSettings={handleOpenSettings} /> diff --git a/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx b/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx index 6eb193f..3f8ce06 100644 --- a/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx +++ b/src/renderer/src/components/chat/PersonalWechatSetupGuide.tsx @@ -15,18 +15,16 @@ interface PersonalWechatSetupGuideProps { runtimeBusy: boolean binding: boolean detecting: boolean - detectionAttempted: boolean sessionBound: boolean onDownloadRuntime: () => void onBind: () => void - onDetect: () => void onStartSending: () => void onOpenTextToSpeechSettings?: () => void } -function capabilityLabel(ready: boolean, detectionAttempted: boolean): string { +function capabilityLabel(ready: boolean, initializing: boolean): string { if (ready) return '已就绪' - return detectionAttempted ? '未检测' : '待检测' + return initializing ? '初始化中' : '未就绪' } function diagnosticValue(value: unknown): string { @@ -47,9 +45,7 @@ function bindingHint(status: PersonalWechatSenderStatus): string { } function isWechatBound(status: PersonalWechatSenderStatus): boolean { - if (status.state === 'online') return true return Boolean( - status.state === 'hook_not_ready' && status.wechatPid && status.boundWechatPid === status.wechatPid && status.attachReady && @@ -64,11 +60,9 @@ export function PersonalWechatSetupGuide({ runtimeBusy, binding, detecting, - detectionAttempted, sessionBound, onDownloadRuntime, onBind, - onDetect, onStartSending, onOpenTextToSpeechSettings }: PersonalWechatSetupGuideProps): React.ReactElement { @@ -78,9 +72,8 @@ export function PersonalWechatSetupGuide({ const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading' const connected = sessionBound && (senderStatus ? isWechatBound(senderStatus) : false) const canSendVoice = Boolean(senderStatus?.canSendVoice) - const detectedVoiceReady = detectionAttempted && canSendVoice - const verificationComplete = connected && detectedVoiceReady - const allReady = verificationComplete + const initializing = connected && !canSendVoice && senderStatus?.state !== 'error' + const allReady = connected && Boolean(senderStatus?.canSend) const progress = runtimeProgress || runtimeStatus const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100))) @@ -181,44 +174,38 @@ export function PersonalWechatSetupGuide({ -
  • - {verificationComplete ? '✓' : '3'} +
  • + {allReady ? '✓' : '3'}
    - 验证消息能力 -

    请打开微信,可以给任意好友发送一张图片,完成一次能力初始化。

    -

    完成后点击“重新检测”。

    - {connected && !verificationComplete && ( - - )} - {detectionAttempted && !detecting && !verificationComplete && ( + 初始化发送能力 +

    绑定微信后,TraceMemo 会自动完成文字、图片和语音发送所需的初始化。

    + {initializing && (

    - 暂未检测到语音发送能力,请确认微信已完成初始化后再次检测。 + {detecting ? '正在初始化发送能力…' : '正在等待发送运行时完成初始化…'} +

    + )} + {senderStatus?.state === 'error' && ( +

    + {senderStatus.message || '发送能力初始化失败,请重新绑定。'}

    )}
  • -
  • +
  • {allReady ? '✓' : '4'}
    能力检测
    - {[['语音消息', detectedVoiceReady]].map(([label, ready]) => ( + {[ + ['文字消息', Boolean(senderStatus?.canSendText)], + ['图片消息', Boolean(senderStatus?.canSendImage)], + ['语音消息', canSendVoice] + ].map(([label, ready]) => ( {ready ? '✓' : '−'} {label} - {capabilityLabel(Boolean(ready), detectionAttempted)} + {capabilityLabel(Boolean(ready), initializing)} ))}
    @@ -229,15 +216,10 @@ export function PersonalWechatSetupGuide({ 开始发送 - ) : verificationComplete ? ( - <> -

    - 语音能力还未就绪,请在微信中完成一次媒体消息初始化后重新检测。 -

    - - + ) : initializing ? ( +

    + 发送能力准备完成后会自动进入已就绪状态。 +

    ) : null}
  • diff --git a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx index 401910b..77a8db0 100644 --- a/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx +++ b/src/renderer/src/components/reports/ScheduledReportsWorkspace.tsx @@ -113,8 +113,8 @@ const capabilityCopy = (capability: PersonalWechatSendCapability | null): string case 'needs_binding': case 'unconfigured': return '请先绑定个人微信' - case 'needs_verification': - return '请先完成微信消息能力检测' + case 'initializing': + return '微信发送能力正在初始化' default: return '微信发送能力异常' } @@ -598,8 +598,9 @@ export function ScheduledReportsWorkspace({ const [editingTask, setEditingTask] = React.useState(null) const [saving, setSaving] = React.useState(false) const [deletingTask, setDeletingTask] = React.useState(null) - const [retryingExecution, setRetryingExecution] = - React.useState(null) + const [retryingExecution, setRetryingExecution] = React.useState( + null + ) const [busyTaskId, setBusyTaskId] = React.useState(null) const load = React.useCallback(async (): Promise => { @@ -917,11 +918,11 @@ export function ScheduledReportsWorkspace({ {(capability?.status === 'needs_binding' || capability?.status === 'unconfigured' || - capability?.status === 'needs_verification' || + capability?.status === 'initializing' || capability?.status === 'error' || capabilityError) && ( )} diff --git a/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx index ee4e62b..a4ea596 100644 --- a/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx +++ b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx @@ -39,7 +39,7 @@ const capabilityLabel: Record = unsupported: '暂不支持', unconfigured: '尚未配置', needs_binding: '需要绑定', - needs_verification: '需要检测', + initializing: '初始化中', ready: '已就绪', error: '异常' } @@ -81,7 +81,6 @@ export function PersonalWechatSendPage({ const [runtimeBusy, setRuntimeBusy] = useState(false) const [binding, setBinding] = useState(false) const [detecting, setDetecting] = useState(false) - const [detectionAttempted, setDetectionAttempted] = useState(false) const [error, setError] = useState('') const [windowsSenderStatus, setWindowsSenderStatus] = useState( null @@ -129,6 +128,15 @@ export function PersonalWechatSendPage({ return () => unsubscribe?.() }, [refresh]) + useEffect(() => { + if (isWindows || !senderStatus || senderStatus.canSend || senderStatus.state === 'error') { + return undefined + } + if (!boundToCurrentWechat(senderStatus)) return undefined + const timer = window.setInterval(() => void refresh(), 1_000) + return () => window.clearInterval(timer) + }, [refresh, senderStatus]) + useEffect(() => { if (!isWindows) return let active = true @@ -215,13 +223,12 @@ export function PersonalWechatSendPage({ if (binding) return setBinding(true) setError('') - setDetectionAttempted(false) try { const nextStatus = await window.api.rebindPersonalWechatSender() setSenderStatus(nextStatus) const nextCapability = await window.api.getPersonalWechatSendCapability() setCapability(nextCapability) - if (nextStatus.state !== 'online' && nextStatus.message) setError(nextStatus.message) + if (nextStatus.state === 'error' && nextStatus.message) setError(nextStatus.message) } catch (reason) { setError(reason instanceof Error ? reason.message : '个人微信绑定失败') } finally { @@ -236,7 +243,6 @@ export function PersonalWechatSendPage({ } if (detecting) return setDetecting(true) - setDetectionAttempted(true) try { await refresh() } finally { @@ -324,7 +330,7 @@ export function PersonalWechatSendPage({ : windowsSenderStatus?.state === 'error' && windowsSenderStatus.endpointReady ? 'error' : windowsPortInput.trim() - ? 'needs_verification' + ? 'initializing' : 'unconfigured' : status const pageReady = isWindows ? Boolean(windowsSenderStatus?.canSend) : ready @@ -374,7 +380,7 @@ export function PersonalWechatSendPage({

    @@ -574,7 +580,7 @@ export function PersonalWechatSendPage({ ) : null} -

    配置与检测

    +

    配置状态

    void downloadRuntime()} onBind={() => void bindWechat()} - onDetect={() => void detectCapability()} onStartSending={() => onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。') } diff --git a/src/shared/personal-wechat.ts b/src/shared/personal-wechat.ts index de46724..c6dd8d4 100644 --- a/src/shared/personal-wechat.ts +++ b/src/shared/personal-wechat.ts @@ -49,7 +49,7 @@ export type PersonalWechatSendCapabilityState = | 'unsupported' | 'unconfigured' | 'needs_binding' - | 'needs_verification' + | 'initializing' | 'ready' | 'error' diff --git a/tests/component/personal-wechat-send-dialog.test.tsx b/tests/component/personal-wechat-send-dialog.test.tsx index 30942e6..eb90765 100644 --- a/tests/component/personal-wechat-send-dialog.test.tsx +++ b/tests/component/personal-wechat-send-dialog.test.tsx @@ -169,9 +169,9 @@ describe('PersonalWechatSendDialog', () => { it('keeps the setup guide voice-only when voice capability is unavailable', async () => { getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false }) renderDialog({ onOpenPersonalWechatSettings: vi.fn() }) - expect(await screen.findByText('验证消息能力')).toBeInTheDocument() + expect(await screen.findByText('初始化发送能力')).toBeInTheDocument() expect(screen.getByText('语音消息')).toBeInTheDocument() - expect(screen.queryByText('文字消息')).not.toBeInTheDocument() - expect(screen.queryByText('图片和语音消息')).not.toBeInTheDocument() + expect(screen.getByText('文字消息')).toBeInTheDocument() + expect(screen.getByText('图片消息')).toBeInTheDocument() }) }) diff --git a/tests/unit/personal-wechat-capability-service.test.ts b/tests/unit/personal-wechat-capability-service.test.ts index f71f964..f0eeeb0 100644 --- a/tests/unit/personal-wechat-capability-service.test.ts +++ b/tests/unit/personal-wechat-capability-service.test.ts @@ -11,6 +11,7 @@ const status = ( arch: 'arm64', sipDisabled: true, wechatRunning: true, + wechatPid: 123, endpoint: '127.0.0.1:58080', endpointReady: true, runtimeReady: true, @@ -34,10 +35,22 @@ describe('PersonalWechatCapabilityService', () => { ['unsupported', status({ platform: 'linux' })], ['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })], ['needs_binding', status({ runtimeReady: true, boundWechatPid: undefined })], - ['needs_verification', status({ boundWechatPid: 123 })], + ['needs_binding', status({ runtimeReady: true, boundWechatPid: 999 })], + ['initializing', status({ boundWechatPid: 123 })], ['ready', status({ state: 'online', boundWechatPid: 123, canSendImage: true, canSend: true })], + [ + 'ready', + status({ + state: 'online', + boundWechatPid: 123, + canSend: true, + canSendText: true, + canSendImage: false, + canSendVoice: true + }) + ], ['error', status({ state: 'error', boundWechatPid: 123, error: 'hook failed' })], - ['needs_verification', status({ platform: 'win32', endpoint: '127.0.0.1:4567' })], + ['initializing', status({ platform: 'win32', endpoint: '127.0.0.1:4567' })], [ 'ready', status({ diff --git a/tests/unit/personal-wechat-send-service.test.ts b/tests/unit/personal-wechat-send-service.test.ts index 544fc3d..7bf3152 100644 --- a/tests/unit/personal-wechat-send-service.test.ts +++ b/tests/unit/personal-wechat-send-service.test.ts @@ -29,6 +29,7 @@ import { parseWindowsLoginStatus, parseWindowsHookResponse, parsePersonalWechatHookLog, + deriveMacSendCapabilities, prepareWindowsImageFile, WindowsHookHttpError } from '../../src/main/services/personal-wechat-send-service' @@ -328,6 +329,32 @@ describe('personal WeChat runtime discovery', () => { }) describe('personal WeChat hook diagnostics', () => { + it('marks media ready from initialized hooks without an upload capture event', () => { + expect( + deriveMacSendCapabilities({ + attached: true, + baseAddressReady: true, + textHookInstalled: true, + textHookReady: true, + imageHookInstalled: true, + imagePathBound: true + }) + ).toEqual({ canSend: true, canSendText: true, canSendImage: true, canSendVoice: true }) + }) + + it('keeps sending unavailable until the native send context is captured', () => { + expect( + deriveMacSendCapabilities({ + attached: true, + baseAddressReady: true, + textHookInstalled: true, + textHookReady: false, + imageHookInstalled: true, + imagePathBound: true + }).canSend + ).toBe(false) + }) + it('does not treat a listening service as hook-ready after req2buf scan failure', () => { expect( parsePersonalWechatHookLog( @@ -350,7 +377,7 @@ describe('personal WeChat hook diagnostics', () => { ).toMatchObject({ readiness: 'initializing', textHookInstalled: true }) expect( parsePersonalWechatHookLog( - JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' }) + JSON.stringify({ payload: '[+] 捕获到有效 StartTask 上下文,X0:0x1, Payload: 0x2' }) ) ).toMatchObject({ readiness: 'ready', textHookReady: true }) }) diff --git a/tests/unit/scheduled-report-api-service.test.ts b/tests/unit/scheduled-report-api-service.test.ts index 31347da..b9eed52 100644 --- a/tests/unit/scheduled-report-api-service.test.ts +++ b/tests/unit/scheduled-report-api-service.test.ts @@ -122,7 +122,7 @@ describe('ScheduledReportApiService', () => { ) }) - it.each(['unsupported', 'unconfigured', 'needs_binding', 'needs_verification', 'error'] as const)( + it.each(['unsupported', 'unconfigured', 'needs_binding', 'initializing', 'error'] as const)( 'allows creation and leaves a send warning for capability state %s', async (status) => { const { api, service } = makeApi({ capability: capability(status) }) diff --git a/tests/unit/scheduled-report-service.test.ts b/tests/unit/scheduled-report-service.test.ts index 302a4b4..6336eff 100644 --- a/tests/unit/scheduled-report-service.test.ts +++ b/tests/unit/scheduled-report-service.test.ts @@ -29,7 +29,7 @@ const capability: PersonalWechatSendCapability = { const unavailableCapability: PersonalWechatSendCapability = { ...capability, ready: false, - status: 'needs_verification', + status: 'initializing', capabilities: { text: false, image: false, voice: false }, message: '请先完成微信消息能力检测' }