Add plugin ZTools 提供商 v1.0.0#281
Conversation
- init: ZTools OCR + 翻译提供商插件 - perf: 为 native.zip 下载加入 gh-proxy 镜像竞速加速 - feat: 翻译未指定目标语言时按文本内容自动推断(中→英,其余→中) - feat: 重构为 manage 单 feature,新增代码翻译功能与独立翻译子页 - feat: 新增截图识别 feature,抽离 OCR 可视化为可复用组件并重写 README
There was a problem hiding this comment.
Code Review
This pull request introduces a new ZTools provider plugin (ztools-f-provider) that integrates local WeChat OCR (via a C++ native addon) and multiple translation services (Baidu, Google, Youdao, Microsoft) alongside a Vue 3 frontend. The code review identified several critical stability and robustness issues that need to be addressed. In the C++ native addon, unchecked return values from string conversion functions (MultiByteToWideChar and WideCharToMultiByte) could lead to buffer underflows, memory corruption, or crashes. On the JavaScript/Node.js side, file locking issues on Windows were found when attempting to delete files before their streams are fully closed, and a blocking synchronous file write was used inside an asynchronous function. Finally, the frontend contains potential race conditions in asynchronous translation requests and a conflict between keyboard navigation and mouse hover events in the candidate list.
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.
| 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。如果返回 0,wlen - 1 将会是 -1(在转换为 size_t 时会变成一个极大的无符号数),这会导致 std::wstring 构造时抛出 std::bad_alloc 异常或直接导致程序崩溃。建议在构造 std::wstring 之前,先检查 wlen 是否大于 0。
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Dir.c_str(), -1, nullptr, 0);
if (wlen <= 0) {
Napi::Error::New(env, "Failed to convert path to wide string").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 的隐患。建议在此处也增加对 wlen 的有效性检查,并在失败时拒绝 Promise。
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, nullptr, 0);
if (wlen <= 0) {
deferred.Reject(Napi::Error::New(env, "Failed to convert path to wide string").Value());
return deferred.Promise();
}
std::wstring imagePath(wlen - 1, L'\0');
MultiByteToWideChar(CP_UTF8, 0, utf8Path.c_str(), -1, &imagePath[0], wlen);| int len = WideCharToMultiByte(CP_UTF8, 0, imagePath.c_str(), -1, nullptr, 0, nullptr, nullptr); | ||
| if (len > 0) { | ||
| picPath.resize(len - 1); | ||
| WideCharToMultiByte(CP_UTF8, 0, imagePath.c_str(), -1, &picPath[0], len, nullptr, nullptr); | ||
| } |
There was a problem hiding this comment.
当 imagePath 为空字符串时,WideCharToMultiByte 会返回 1(因为 -1 会包含空字符 \0)。此时 len - 1 为 0,picPath.resize(0) 会使 picPath 变为空。接着调用 WideCharToMultiByte 时传入的缓冲区大小为 1,但由于 picPath 为空,&picPath[0] 会越界访问,导致未定义行为或内存损坏。建议将检查条件改为 len > 1,从而安全地跳过空路径的转换。
int len = WideCharToMultiByte(CP_UTF8, 0, imagePath.c_str(), -1, nullptr, 0, nullptr, nullptr);
if (len > 1) {
picPath.resize(len - 1);
WideCharToMultiByte(CP_UTF8, 0, imagePath.c_str(), -1, &picPath[0], len, nullptr, nullptr);
}| const req = client.get(image, (res) => { | ||
| if (res.statusCode !== 200) { | ||
| file.close() | ||
| try { fs.unlinkSync(tmp) } catch (_) {} | ||
| reject(new Error('下载图片失败: HTTP ' + res.statusCode)) | ||
| return | ||
| } | ||
| res.pipe(file) | ||
| file.on('finish', () => file.close(() => resolve(tmp))) | ||
| }) | ||
| req.on('error', (err) => { | ||
| file.close() | ||
| try { fs.unlinkSync(tmp) } catch (_) {} | ||
| reject(err) | ||
| }) |
There was a problem hiding this comment.
在下载图片失败或发生错误时,代码立即调用了 fs.unlinkSync(tmp)。然而,此时 file.close() 或 file.destroy() 尚未完全释放文件句柄(尤其是在 Windows 系统上),这会导致 fs.unlinkSync 抛出 EPERM 或 EBUSY 异常,从而导致程序崩溃或临时文件残留。建议在流的 'close' 事件回调中执行删除操作,以确保文件句柄已完全关闭。
const req = client.get(image, (res) => {
if (res.statusCode !== 200) {
file.destroy()
file.on('close', () => {
try { fs.unlinkSync(tmp) } catch (_) {}
reject(new Error('下载图片失败: HTTP ' + res.statusCode))
})
return
}
res.pipe(file)
file.on('finish', () => file.close(() => resolve(tmp)))
})
req.on('error', (err) => {
file.destroy()
file.on('close', () => {
try { fs.unlinkSync(tmp) } catch (_) {}
reject(err)
})
})| file.on('error', (err) => { | ||
| try { fs.unlinkSync(dest); } catch (_) {} | ||
| reject(err); | ||
| }); |
There was a problem hiding this comment.
| async function run() { | ||
| // 取消挂起的自动翻译定时器,避免显式 run 后又被 debounce 触发一次 | ||
| if (autoTimer) { | ||
| clearTimeout(autoTimer) | ||
| autoTimer = null | ||
| } | ||
| if (!hasInput.value) { | ||
| result.value = { loading: false, text: '', error: '', ms: 0 } | ||
| return | ||
| } | ||
| const fnName = providerFnNames[provider.value] | ||
| result.value = { loading: true, text: '', error: '', ms: 0 } | ||
| const t0 = performance.now() | ||
| try { | ||
| const out = await ( | ||
| (window.services[fnName] as unknown) as | ||
| (this: typeof window.services, t: string, f?: string, to?: string) => Promise<{ text: string }> | ||
| ).call( | ||
| window.services, | ||
| sourceText.value, | ||
| sourceLang.value, | ||
| targetLang.value === 'auto' ? undefined : targetLang.value | ||
| ) | ||
| result.value = { | ||
| loading: false, | ||
| text: out.text, | ||
| error: '', | ||
| ms: Math.round(performance.now() - t0) | ||
| } | ||
| } catch (e: any) { | ||
| result.value = { | ||
| loading: false, | ||
| text: '', | ||
| error: e?.message ? String(e.message) : String(e), | ||
| ms: Math.round(performance.now() - t0) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
异步翻译请求存在竞态条件(Race Condition)。如果用户快速切换翻译提供商或频繁修改输入文本,多个请求会同时处于 pending 状态。由于网络延迟的不确定性,较早发出的请求可能会在较晚发出的请求之后返回,从而覆盖最新的翻译结果。建议引入一个递增的 requestId,在异步回调返回时校验是否为最新请求,若已过期则直接丢弃响应。
let currentRequestId = 0
async function run() {
// 取消挂起的自动翻译定时器,避免显式 run 后又被 debounce 触发一次
if (autoTimer) {
clearTimeout(autoTimer)
autoTimer = null
}
if (!hasInput.value) {
result.value = { loading: false, text: '', error: '', ms: 0 }
return
}
const requestId = ++currentRequestId
const fnName = providerFnNames[provider.value]
result.value = { loading: true, text: '', error: '', ms: 0 }
const t0 = performance.now()
try {
const out = await (
(window.services[fnName] as unknown) as
(this: typeof window.services, t: string, f?: string, to?: string) => Promise<{ text: string }>
).call(
window.services,
sourceText.value,
sourceLang.value,
targetLang.value === 'auto' ? undefined : targetLang.value
)
if (requestId !== currentRequestId) return
result.value = {
loading: false,
text: out.text,
error: '',
ms: Math.round(performance.now() - t0)
}
} catch (e: any) {
if (requestId !== currentRequestId) return
result.value = {
loading: false,
text: '',
error: e?.message ? String(e.message) : String(e),
ms: Math.round(performance.now() - t0)
}
}
}
| std::wstring dir = dllPath.substr(0, dllPath.find_last_of(L"\\/")); | ||
| ::SetDllDirectoryW(dir.c_str()); |
There was a problem hiding this comment.
如果 dllPath 中不包含任何斜杠或反斜杠,find_last_of 将返回 std::wstring::npos。此时 dllPath.substr(0, npos) 会返回整个字符串(包含文件名),导致 SetDllDirectoryW 被传入一个无效的目录路径,从而无法正确加载同级依赖。建议在调用 substr 之前先检查 lastSlash 是否有效。
size_t lastSlash = dllPath.find_last_of(L"\\/");
if (lastSlash != std::wstring::npos) {
std::wstring dir = dllPath.substr(0, lastSlash);
::SetDllDirectoryW(dir.c_str());
}| 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-${Date.now()}.${ext}`) | ||
| fs.writeFileSync(tmp, image.substring(dataMatch[0].length), { encoding: 'base64' }) | ||
| return tmp | ||
| } |
There was a problem hiding this comment.
由于 _ocrMaterialize 是一个 async 异步函数,在此处使用同步的 fs.writeFileSync 会阻塞 Electron 渲染进程/主线程的事件循环,导致界面出现卡顿。建议改用异步的 fs.promises.writeFile 以提升应用响应速度。
| 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-${Date.now()}.${ext}`) | |
| fs.writeFileSync(tmp, image.substring(dataMatch[0].length), { encoding: 'base64' }) | |
| return tmp | |
| } | |
| 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-${Date.now()}.${ext}`) | |
| await fs.promises.writeFile(tmp, image.substring(dataMatch[0].length), { encoding: 'base64' }) | |
| return tmp | |
| } |
| <span class="ct-value" :title="c.value">{{ c.value }}</span> | ||
| <span class="ct-style">{{ c.label }}</span> | ||
| </div> | ||
| </template> |
插件信息
本次变更
截图 / 演示
自检清单
plugins/ztools-f-provider/目录此 PR 由 ztools-plugin-cli 自动管理:每次
ztools publish在分支上追加一个 commit,PR 链接保持不变。