feat: 优化 macOS 微信发送消息能力

参考 wechat_chatter PR #36:
https://github.com/yincongcyincong/wechat_chatter/pull/36

补充 WeChat 4.1.11.53 的 GetService / CdnManager 地址,
并适配绑定后的发送能力检测逻辑。
This commit is contained in:
Wxw-Gu
2026-09-14 15:59:08 +08:00
parent 5efae86fcf
commit cf9ea8a83b
14 changed files with 659 additions and 171 deletions
+237 -27
View File
@@ -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] 运行时准备完成')
@@ -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'
}
@@ -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<string, unknown>
try {
config = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
} 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)
@@ -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<PersonalWechatSenderStatus, 'canSend' | 'canSendText' | 'canSendImage' | 'canSendVoice'> {
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 }
}
@@ -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<ChatMessage[]>([])
@@ -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<void> => {
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<void> => {
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}
/>
@@ -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({
</div>
</li>
<li
className={verificationComplete ? 'is-complete' : connected ? 'is-current' : 'is-pending'}
>
<span className="personal-wechat-step-number">{verificationComplete ? '✓' : '3'}</span>
<li className={allReady ? 'is-complete' : connected ? 'is-current' : 'is-pending'}>
<span className="personal-wechat-step-number">{allReady ? '✓' : '3'}</span>
<div className="personal-wechat-step-content">
<strong>验证消息能力</strong>
<p>请打开微信,可以给任意好友发送一张图片,完成一次能力初始化。</p>
<p className="personal-wechat-step-hint">完成后点击“重新检测”。</p>
{connected && !verificationComplete && (
<Button
size="sm"
variant="outline"
onClick={onDetect}
disabled={detecting || binding}
>
{detecting ? '正在检测…' : '重新检测'}
</Button>
)}
{detectionAttempted && !detecting && !verificationComplete && (
<strong>初始化发送能力</strong>
<p>绑定微信后,TraceMemo 会自动完成文字、图片和语音发送所需的初始化。</p>
{initializing && (
<p className="personal-wechat-step-hint" role="status">
暂未检测到语音发送能力,请确认微信已完成初始化后再次检测。
{detecting ? '正在初始化发送能力…' : '正在等待发送运行时完成初始化…'}
</p>
)}
{senderStatus?.state === 'error' && (
<p className="personal-wechat-step-hint" role="status">
{senderStatus.message || '发送能力初始化失败,请重新绑定。'}
</p>
)}
</div>
</li>
<li
className={allReady ? 'is-complete' : verificationComplete ? 'is-current' : 'is-pending'}
>
<li className={allReady ? 'is-complete' : initializing ? 'is-current' : 'is-pending'}>
<span className="personal-wechat-step-number">{allReady ? '✓' : '4'}</span>
<div className="personal-wechat-step-content">
<strong>能力检测</strong>
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
{[['语音消息', detectedVoiceReady]].map(([label, ready]) => (
{[
['文字消息', Boolean(senderStatus?.canSendText)],
['图片消息', Boolean(senderStatus?.canSendImage)],
['语音消息', canSendVoice]
].map(([label, ready]) => (
<span key={String(label)} className={ready ? 'is-ready' : ''}>
<b aria-hidden>{ready ? '✓' : '−'}</b>
{label}
<small>{capabilityLabel(Boolean(ready), detectionAttempted)}</small>
<small>{capabilityLabel(Boolean(ready), initializing)}</small>
</span>
))}
</div>
@@ -229,15 +216,10 @@ export function PersonalWechatSetupGuide({
开始发送
</Button>
</>
) : verificationComplete ? (
<>
<p className="personal-wechat-step-hint">
语音能力还未就绪,请在微信中完成一次媒体消息初始化后重新检测。
</p>
<Button size="sm" variant="outline" onClick={onDetect} disabled={detecting}>
{detecting ? '正在检测…' : '重新检测能力'}
</Button>
</>
) : initializing ? (
<p className="personal-wechat-step-hint" role="status">
发送能力准备完成后会自动进入已就绪状态。
</p>
) : null}
</div>
</li>
@@ -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<ScheduledReportTask | null>(null)
const [saving, setSaving] = React.useState(false)
const [deletingTask, setDeletingTask] = React.useState<ScheduledReportTask | null>(null)
const [retryingExecution, setRetryingExecution] =
React.useState<ScheduledReportExecution | null>(null)
const [retryingExecution, setRetryingExecution] = React.useState<ScheduledReportExecution | null>(
null
)
const [busyTaskId, setBusyTaskId] = React.useState<string | null>(null)
const load = React.useCallback(async (): Promise<void> => {
@@ -917,11 +918,11 @@ export function ScheduledReportsWorkspace({
</span>
{(capability?.status === 'needs_binding' ||
capability?.status === 'unconfigured' ||
capability?.status === 'needs_verification' ||
capability?.status === 'initializing' ||
capability?.status === 'error' ||
capabilityError) && (
<Button variant="link" size="sm" className="ml-auto" onClick={onOpenWechatSettings}>
{capability?.status === 'needs_verification' ? '去检测' : '去配置'}
去配置
</Button>
)}
</div>
@@ -39,7 +39,7 @@ const capabilityLabel: Record<PersonalWechatSendCapability['status'], string> =
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<PersonalWechatSenderStatus | null>(
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({
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void detectCapability()}>
{detecting ? '检测中…' : '重新检测'}
{detecting ? '刷新中…' : isWindows ? '重新检测' : '刷新状态'}
</Button>
</div>
<div className="mt-5 grid grid-cols-3 gap-2" aria-label="微信发送能力明细">
@@ -574,7 +580,7 @@ export function PersonalWechatSendPage({
) : null}
</section>
<h2 className="settings-section-heading">配置与检测</h2>
<h2 className="settings-section-heading">配置状态</h2>
<PersonalWechatSetupGuide
runtimeStatus={runtimeStatus}
senderStatus={senderStatus}
@@ -582,11 +588,9 @@ export function PersonalWechatSendPage({
runtimeBusy={runtimeBusy}
binding={binding}
detecting={detecting}
detectionAttempted={detectionAttempted}
sessionBound={boundToCurrentWechat(senderStatus)}
onDownloadRuntime={() => void downloadRuntime()}
onBind={() => void bindWechat()}
onDetect={() => void detectCapability()}
onStartSending={() =>
onNotice('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
}
+1 -1
View File
@@ -49,7 +49,7 @@ export type PersonalWechatSendCapabilityState =
| 'unsupported'
| 'unconfigured'
| 'needs_binding'
| 'needs_verification'
| 'initializing'
| 'ready'
| 'error'
@@ -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()
})
})
@@ -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({
@@ -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 })
})
@@ -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) })
+1 -1
View File
@@ -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: '请先完成微信消息能力检测'
}