mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
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:
@@ -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('微信消息发送能力已就绪,请在档案中选择会话开始发送。')
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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: '请先完成微信消息能力检测'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user