Skip to content

Add plugin ZTools 提供商 v1.0.0#286

Open
kaineooo wants to merge 1 commit into
ZToolsCenter:mainfrom
kaineooo:plugin/f-provider
Open

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

Conversation

@kaineooo

@kaineooo kaineooo commented Jul 2, 2026

Copy link
Copy Markdown

插件信息

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

本次变更

  • feat: ZTools OCR + 翻译提供商插件(截图识别 / 代码翻译 / manage)
  • chore: 调整插件命名空间
  • chore: 调整资源url

截图 / 演示

自检清单

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

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

- feat: ZTools OCR + 翻译提供商插件(截图识别 / 代码翻译 / manage)
- chore: 调整插件命名空间
- chore: 调整资源url

@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, 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.

Comment on lines +52 to +75
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());
});

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 函数中,如果 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);
    });
  });

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(例如传入了空路径或非法字符),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);

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 执行失败或返回 0wlen - 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);

Comment on lines +118 to +126
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;

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

Submit 函数中,如果 CreateMMMojoWriteInfoGetMMMojoWriteInfoRequest 返回 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;

Comment on lines +151 to +155
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());

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

ParseSingleResult 中,如果 bytes 为空,bytes.data() 可能会返回 nullptr。在 C++ 中,向 std::string::assign 传递 nullptr 指针(即使大小为 0)在某些标准库实现中是未定义行为(Undefined Behavior),因为该函数要求传入的指针必须指向一个有效数组。建议在调用 assign 之前先检查 bytes 是否为空。

Suggested change
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();
}

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 不包含任何路径分隔符(例如仅为文件名 "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());
  }

Comment on lines +106 to +113
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`)

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 中,使用 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`)

Comment on lines +328 to +333
})
res.pipe(file)
file.on('finish', () => file.close(() => resolve()))
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.

medium

_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)
        })

Comment on lines +510 to +516
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') })
})
})

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

_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') })
        })

@kaineooo kaineooo marked this pull request as ready for review July 2, 2026 01:15
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