Add plugin ZTools 提供商 v1.0.0#286
Conversation
- feat: ZTools OCR + 翻译提供商插件(截图识别 / 代码翻译 / manage) - chore: 调整插件命名空间 - chore: 调整资源url
There was a problem hiding this comment.
Code Review
This pull request introduces a new ZTools provider plugin, ztools-f-provider, which wraps a local WeChat OCR engine and multiple translation services (Baidu, Google, Youdao, Microsoft) into reusable providers. It includes a native C++ Node-API addon for OCR bridging, a custom minimal protobuf codec, preload scripts for provider registration and engine downloading, and a Vue 3 frontend for interactive features like screenshot OCR and code translation. The review feedback highlights several critical stability and robustness issues across the codebase. Specifically, it identifies potential process crashes in the C++ addon due to unchecked MultiByteToWideChar results, potential null pointer dereferences and hanging Promises in the OCR manager, undefined behavior in the protobuf parser, and concurrency conflicts or unhandled stream errors in the JavaScript preload scripts.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const file = fs.createWriteStream(dest); | ||
| let loaded = 0; | ||
| let lastLog = 0; | ||
| for await (const chunk of res.body) { | ||
| loaded += chunk.length; | ||
| const now = Date.now(); | ||
| // 每 1.5s 打印一次进度,避免刷屏。 | ||
| if (now - lastLog > 1500) { | ||
| lastLog = now; | ||
| const pct = total > 0 ? Math.round((loaded / total) * 100) : '?'; | ||
| const mb = (loaded / 1048576).toFixed(1); | ||
| log(`downloading ${mb} MB (${pct}%)`); | ||
| } | ||
| if (!file.write(chunk)) { | ||
| await new Promise((r) => file.once('drain', r)); | ||
| } | ||
| } | ||
| await new Promise((resolve, reject) => { | ||
| file.on('error', (err) => { | ||
| try { fs.unlinkSync(dest); } catch (_) {} | ||
| reject(err); | ||
| }); | ||
| file.end(() => resolve()); | ||
| }); |
There was a problem hiding this comment.
在 download 函数中,如果 res.body 循环读取过程中发生错误,或者在写入文件时发生磁盘/权限错误,由于没有在循环开始前为 file 注册错误监听器,可能会导致未捕获的 error 事件进而使进程崩溃。此外,如果异步循环中途抛出异常,文件流 file 将无法被正确关闭或销毁,导致文件描述符泄漏。建议使用 Promise 包装器在最开始注册错误监听,并在出错时安全销毁文件流并清理临时文件。
const file = fs.createWriteStream(dest);
return new Promise((resolve, reject) => {
file.on('error', (err) => {
try { fs.unlinkSync(dest); } catch (_) {}
reject(err);
});
(async () => {
let loaded = 0;
let lastLog = 0;
for await (const chunk of res.body) {
loaded += chunk.length;
const now = Date.now();
if (now - lastLog > 1500) {
lastLog = now;
const pct = total > 0 ? Math.round((loaded / total) * 100) : '?';
const mb = (loaded / 1048576).toFixed(1);
log(`downloading ${mb} MB (${pct}%)`);
}
if (!file.write(chunk)) {
await new Promise((r) => file.once('drain', r));
}
}
file.end();
})().then(resolve, (err) => {
file.destroy();
try { fs.unlinkSync(dest); } catch (_) {}
reject(err);
});
});
| int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Dir.c_str(), -1, nullptr, 0); | ||
| std::wstring dataDir(wlen - 1, L'\0'); // exclude the NUL terminator | ||
| MultiByteToWideChar(CP_UTF8, 0, utf8Dir.c_str(), -1, &dataDir[0], wlen); |
There was a problem hiding this comment.
在 InitEngine 函数中,如果 MultiByteToWideChar 执行失败或返回 0(例如传入了空路径或非法字符),wlen - 1 将会变成 -1(即 std::wstring::npos 或一个极大的无符号数),这会导致 std::wstring 构造函数抛出 std::length_error 异常,从而导致整个 Node.js 进程崩溃。建议在构造 dataDir 之前对 wlen 进行有效性检查。
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Dir.c_str(), -1, nullptr, 0);
if (wlen <= 0) {
Napi::Error::New(env, "Invalid dataDir path").ThrowAsJavaScriptException();
return env.Undefined();
}
std::wstring dataDir(wlen - 1, L'\0'); // exclude the NUL terminator
MultiByteToWideChar(CP_UTF8, 0, utf8Dir.c_str(), -1, &dataDir[0], wlen);| int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, nullptr, 0); | ||
| std::wstring imagePath(wlen - 1, L'\0'); | ||
| MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, &imagePath[0], wlen); |
There was a problem hiding this comment.
同样地,在 Ocr 函数中,如果 MultiByteToWideChar 执行失败或返回 0,wlen - 1 将会变成 -1,导致 std::wstring 构造函数抛出 std::length_error 异常并使进程崩溃。建议在构造 imagePath 之前对 wlen 进行有效性检查,并在失败时拒绝 Promise。
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, nullptr, 0);
if (wlen <= 0) {
deferred.Reject(Napi::Error::New(env, "Invalid imagePath").Value());
return deferred.Promise();
}
std::wstring imagePath(wlen - 1, L'\0');
MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, &imagePath[0], wlen);| void* writeInfo = api.CreateMMMojoWriteInfo(static_cast<int>(MMMojoInfoMethod::kMMPush), 0, 1); | ||
| void* req = api.GetMMMojoWriteInfoRequest(writeInfo, static_cast<uint32_t>(payload.size())); | ||
| std::memcpy(req, payload.data(), payload.size()); | ||
| bool sent = api.SendMMMojoWriteInfo(env_, writeInfo); | ||
| // NOTE: do NOT call RemoveMMMojoWriteInfo here — removing the write-info | ||
| // before mmojo has dispatched the request causes the OCR task to be dropped | ||
| // (the C# reference SendPbSerializedData omits this call too). | ||
| (void)sent; | ||
| return taskId; |
There was a problem hiding this comment.
在 Submit 函数中,如果 CreateMMMojoWriteInfo 或 GetMMMojoWriteInfoRequest 返回 nullptr(例如由于环境未就绪或内存不足),直接调用 std::memcpy 将会导致空指针解引用并使整个应用崩溃。此外,如果 SendMMMojoWriteInfo 返回 false(发送失败),由于对应的任务永远不会收到回调,该任务将一直残留在 pending_ 队列中,导致 JS 侧的 Promise 永久挂起(Hanging Promise)并造成内存泄漏。建议对这些返回值进行严格的空值和成功状态校验,并在失败时将任务状态重置并返回 -1。
void* writeInfo = api.CreateMMMojoWriteInfo(static_cast<int>(MMMojoInfoMethod::kMMPush), 0, 1);
if (!writeInfo) {
SetTaskIdle(taskId);
return -1;
}
void* req = api.GetMMMojoWriteInfoRequest(writeInfo, static_cast<uint32_t>(payload.size()));
if (!req) {
api.RemoveMMMojoWriteInfo(writeInfo);
SetTaskIdle(taskId);
return -1;
}
std::memcpy(req, payload.data(), payload.size());
if (!api.SendMMMojoWriteInfo(env_, writeInfo)) {
api.RemoveMMMojoWriteInfo(writeInfo);
SetTaskIdle(taskId);
return -1;
}
return taskId;| std::vector<uint8_t> bytes; | ||
| if (!r.ReadBytes(bytes, static_cast<std::size_t>(len))) return false; | ||
| // The server returns base64-encoded UTF-8 here (see C# ParseOcrResult). | ||
| // We pass the raw (still base64) bytes to JS and decode there. | ||
| line.text.assign(reinterpret_cast<const char*>(bytes.data()), bytes.size()); |
There was a problem hiding this comment.
在 ParseSingleResult 中,如果 bytes 为空,bytes.data() 可能会返回 nullptr。在 C++ 中,向 std::string::assign 传递 nullptr 指针(即使大小为 0)在某些标准库实现中是未定义行为(Undefined Behavior),因为该函数要求传入的指针必须指向一个有效数组。建议在调用 assign 之前先检查 bytes 是否为空。
| std::vector<uint8_t> bytes; | |
| if (!r.ReadBytes(bytes, static_cast<std::size_t>(len))) return false; | |
| // The server returns base64-encoded UTF-8 here (see C# ParseOcrResult). | |
| // We pass the raw (still base64) bytes to JS and decode there. | |
| line.text.assign(reinterpret_cast<const char*>(bytes.data()), bytes.size()); | |
| std::vector<uint8_t> bytes; | |
| if (!r.ReadBytes(bytes, static_cast<std::size_t>(len))) return false; | |
| // The server returns base64-encoded UTF-8 here (see C# ParseOcrResult). | |
| // We pass the raw (still base64) bytes to JS and decode there. | |
| if (!bytes.empty()) { | |
| line.text.assign(reinterpret_cast<const char*>(bytes.data()), bytes.size()); | |
| } else { | |
| line.text.clear(); | |
| } |
| std::wstring dir = dllPath.substr(0, dllPath.find_last_of(L"\\/")); | ||
| ::SetDllDirectoryW(dir.c_str()); |
There was a problem hiding this comment.
如果传入的 dllPath 不包含任何路径分隔符(例如仅为文件名 "mmmojo_64.dll"),find_last_of 将会返回 std::wstring::npos。此时 dllPath.substr(0, npos) 会返回完整的文件名,导致 SetDllDirectoryW 被传入一个文件名而非目录,这可能会导致 DLL 加载失败或未定义行为。建议在调用 substr 之前先检查 find_last_of 的返回值是否有效。
size_t pos = dllPath.find_last_of(L"\\/");
if (pos != std::wstring::npos) {
std::wstring dir = dllPath.substr(0, pos);
::SetDllDirectoryW(dir.c_str());
}| const tmp = path.join(os.tmpdir(), `ztools-wechat-ocr-${Date.now()}.${ext}`) | ||
| fs.writeFileSync(tmp, image.substring(dataMatch[0].length), { encoding: 'base64' }) | ||
| return tmp | ||
| } | ||
|
|
||
| // http(s) URL:下载到临时文件 | ||
| return new Promise((resolve, reject) => { | ||
| const tmp = path.join(os.tmpdir(), `ztools-wechat-ocr-${Date.now()}.png`) |
There was a problem hiding this comment.
在 _ocrMaterialize 中,使用 Date.now() 来生成临时文件名。如果用户在同一毫秒内并发发起多个 OCR 请求,它们将会生成完全相同的临时文件名,从而导致文件写入冲突、数据覆盖或 OCR 识别失败。由于顶部已经引入了 crypto 模块,建议使用 crypto.randomUUID() 来生成唯一的文件名,以确保并发安全。
// data URI:解码写临时文件
const dataMatch = /^data:image\/([a-z]{1,20});base64,/i.exec(image)
if (dataMatch) {
const ext = dataMatch[1] === 'jpeg' ? 'jpg' : dataMatch[1]
const tmp = path.join(os.tmpdir(), `ztools-wechat-ocr-${crypto.randomUUID()}.${ext}`)
fs.writeFileSync(tmp, image.substring(dataMatch[0].length), { encoding: 'base64' })
return tmp
}
// http(s) URL:下载到临时文件
return new Promise((resolve, reject) => {
const tmp = path.join(os.tmpdir(), `ztools-wechat-ocr-${crypto.randomUUID()}.png`)| }) | ||
| res.pipe(file) | ||
| file.on('finish', () => file.close(() => resolve())) | ||
| file.on('error', (err) => { | ||
| try { fs.unlinkSync(dest) } catch (_) {} | ||
| reject(err) |
There was a problem hiding this comment.
在 _downloadFile 中,使用 res.pipe(file) 进行文件流式写入。如果在下载过程中网络连接中断或响应流 res 发生错误,由于没有为 res 注册 error 监听器,该错误将不会被捕获,可能导致 Promise 永久挂起,且临时文件流无法被正确销毁和清理。建议为 res 注册 error 监听器以确保网络异常时能正确拒绝 Promise 并清理资源。
res.on('error', (err) => {
file.destroy()
try { fs.unlinkSync(dest) } catch (_) {}
reject(err)
})
res.pipe(file)
file.on('finish', () => file.close(() => resolve()))
file.on('error', (err) => {
try { fs.unlinkSync(dest) } catch (_) {}
reject(err)
})| const chunks = [] | ||
| res.on('data', (c) => chunks.push(c)) | ||
| res.on('end', () => { | ||
| const buf = Buffer.concat(chunks) | ||
| resolve({ status: res.statusCode, headers: res.headers, body: buf.toString('utf8') }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
在 _httpRequest 中,没有为响应流 res 注册 error 监听器。如果在读取响应体数据(data 事件)的过程中网络连接中断或发生其他流错误,由于没有捕获该错误,Promise 将会永久挂起。建议为 res 注册 error 监听器以确保异常时能正确拒绝 Promise。
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('error', reject)
res.on('end', () => {
const buf = Buffer.concat(chunks)
resolve({ status: res.statusCode, headers: res.headers, body: buf.toString('utf8') })
})
插件信息
本次变更
截图 / 演示
自检清单
plugins/f-provider/目录此 PR 由 ztools-plugin-cli 自动管理:每次
ztools publish在分支上追加一个 commit,PR 链接保持不变。