Skip to content

Add plugin ZTools 提供商 v1.0.0#281

Closed
Particaly wants to merge 1 commit into
ZToolsCenter:mainfrom
Particaly:plugin/ztools-f-provider
Closed

Add plugin ZTools 提供商 v1.0.0#281
Particaly wants to merge 1 commit into
ZToolsCenter:mainfrom
Particaly:plugin/ztools-f-provider

Conversation

@Particaly

@Particaly Particaly commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

插件信息

  • 名称: ZTools 提供商
  • 插件ID: ztools-f-provider
  • 版本: 1.0.0
  • 描述: OCR + 翻译提供商集合(百度/谷歌/有道/微软翻译、微信 OCR)
  • 作者: pantao
  • 类型: 新增

本次变更

  • init: ZTools OCR + 翻译提供商插件
  • perf: 为 native.zip 下载加入 gh-proxy 镜像竞速加速
  • feat: 翻译未指定目标语言时按文本内容自动推断(中→英,其余→中)
  • feat: 重构为 manage 单 feature,新增代码翻译功能与独立翻译子页
  • feat: 新增截图识别 feature,抽离 OCR 可视化为可复用组件并重写 README

截图 / 演示

自检清单

  • plugin.json 的 name / title / version / description / author 字段均已检查
  • 已移除调试日志、未使用文件、敏感信息(.env、token、密钥等)
  • 本次 PR 的 diff 仅涉及 plugins/ztools-f-provider/ 目录
  • 已在本地 ZTools 客户端实际加载并测试过此插件,主要功能正常
  • 同意以仓库声明的开源协议发布此插件

此 PR 由 ztools-plugin-cli 自动管理:每次 ztools publish 在分支上追加一个 commit,PR 链接保持不变。

- init: ZTools OCR + 翻译提供商插件
- perf: 为 native.zip 下载加入 gh-proxy 镜像竞速加速
- feat: 翻译未指定目标语言时按文本内容自动推断(中→英,其余→中)
- feat: 重构为 manage 单 feature,新增代码翻译功能与独立翻译子页
- feat: 新增截图识别 feature,抽离 OCR 可视化为可复用组件并重写 README
@Particaly Particaly marked this pull request as ready for review July 1, 2026 03:53

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +99 to +101
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

InitEngine 中,MultiByteToWideChar 在转换失败时会返回 0。如果返回 0wlen - 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);

Comment on lines +138 to +140
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);

Comment on lines +102 to +106
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

imagePath 为空字符串时,WideCharToMultiByte 会返回 1(因为 -1 会包含空字符 \0)。此时 len - 10picPath.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);
  }

Comment on lines +116 to +130
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)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在下载图片失败或发生错误时,代码立即调用了 fs.unlinkSync(tmp)。然而,此时 file.close()file.destroy() 尚未完全释放文件句柄(尤其是在 Windows 系统上),这会导致 fs.unlinkSync 抛出 EPERMEBUSY 异常,从而导致程序崩溃或临时文件残留。建议在流的 '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)
        })
      })

Comment on lines +70 to +73
file.on('error', (err) => {
try { fs.unlinkSync(dest); } catch (_) {}
reject(err);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

download 函数的错误处理中,直接调用 fs.unlinkSync(dest) 会因为文件流尚未关闭而在 Windows 系统上触发文件锁定错误(EPERM)。建议先调用 file.destroy(),并在流的 'close' 事件触发后再进行文件删除,以确保跨平台兼容性。

    file.on('error', (err) => {
      file.destroy();
      file.on('close', () => {
        try { fs.unlinkSync(dest); } catch (_) {}
        reject(err);
      });
    });

Comment on lines +131 to +168
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)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

异步翻译请求存在竞态条件(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)
    }
  }
}

Comment on lines +26 to +27
std::wstring dir = dllPath.substr(0, dllPath.find_last_of(L"\\/"));
::SetDllDirectoryW(dir.c_str());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

如果 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());
  }

Comment on lines +103 to +109
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

由于 _ocrMaterialize 是一个 async 异步函数,在此处使用同步的 fs.writeFileSync 会阻塞 Electron 渲染进程/主线程的事件循环,导致界面出现卡顿。建议改用异步的 fs.promises.writeFile 以提升应用响应速度。

Suggested change
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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在候选列表中使用 @mouseenter="selectedIndex = i" 会导致键盘导航与鼠标悬停发生冲突。当用户使用方向键(ArrowDown/ArrowUp)切换选项并触发列表滚动时,鼠标指针虽然静止,但由于列表项在鼠标下方移动,会触发 mouseenter 事件,从而瞬间将选中项重置为鼠标悬停的项,严重破坏键盘操作体验。建议在 mouseenter 处理器中增加对鼠标是否真正移动的校验(例如通过监听 mousemove 并对比坐标),或者在键盘滚动期间临时禁用鼠标悬停事件。

@Particaly Particaly closed this Jul 1, 2026
@Particaly Particaly deleted the plugin/ztools-f-provider branch July 1, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant