From cf9ea8a83b76eb5f5b771b4af6e60893d71542d5 Mon Sep 17 00:00:00 2001
From: Wxw-Gu
Date: Mon, 14 Sep 2026 15:59:08 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20macOS=20=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E5=8F=91=E9=80=81=E6=B6=88=E6=81=AF=E8=83=BD=E5=8A=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
参考 wechat_chatter PR #36:
https://github.com/yincongcyincong/wechat_chatter/pull/36
补充 WeChat 4.1.11.53 的 GetService / CdnManager 地址,
并适配绑定后的发送能力检测逻辑。
---
scripts/prepare-wechat-chatter-runtime.cjs | 264 +++++++++++++++--
.../personal-wechat-capability-service.ts | 26 +-
.../personal-wechat-runtime-manager.ts | 277 ++++++++++++++++--
.../services/personal-wechat-send-service.ts | 63 ++--
.../chat/PersonalWechatSendDialog.tsx | 35 +--
.../chat/PersonalWechatSetupGuide.tsx | 70 ++---
.../reports/ScheduledReportsWorkspace.tsx | 13 +-
.../settings/pages/PersonalWechatSendPage.tsx | 24 +-
src/shared/personal-wechat.ts | 2 +-
.../personal-wechat-send-dialog.test.tsx | 6 +-
...personal-wechat-capability-service.test.ts | 17 +-
.../unit/personal-wechat-send-service.test.ts | 29 +-
.../unit/scheduled-report-api-service.test.ts | 2 +-
tests/unit/scheduled-report-service.test.ts | 2 +-
14 files changed, 659 insertions(+), 171 deletions(-)
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: '请先完成微信消息能力检测'
}