[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fnWpzEZXRLavijAFzrZRfnMuyWF3tHCOoduF3xp_DKjU":3,"$fJU-4tot_gC5fDkujNeoE-cGsdMy5V_KcdUXLuAnTFgw":17,"$fEFYHfT0IPvx8bgUm757vJ9ysclVJK3dgL5AYMKN5c4w":423},{"slug":4,"title":5,"description":6,"content":7,"content_html":8,"pub_date":9,"tags":10,"draft":16},"hid-hotplug","HID 设备热插拔检测：从 udev 到 node-hid","在 Linux 上用 node-hid + usb 库实现可靠的 USB HID 设备热插拔检测，踩坑记录","import Chart from '..\u002F..\u002Fcomponents\u002FChart.vue'\n\nexport const solutionCompareData = {\n  labels: ['实现复杂度', 'CPU 占用', '可靠性', '跨平台', '无需 root'],\n  datasets: [\n    {\n      label: 'udev + inotify',\n      data: [4, 1, 5, 1, 1],\n      backgroundColor: 'rgba(255,0,170,0.25)',\n      borderColor: 'rgba(255,0,170,0.8)',\n      borderWidth: 2,\n      pointBackgroundColor: 'rgba(255,0,170,1)',\n      pointRadius: 4,\n    },\n    {\n      label: 'node-hid polling',\n      data: [1, 5, 3, 5, 5],\n      backgroundColor: 'rgba(255,165,0,0.2)',\n      borderColor: 'rgba(255,165,0,0.8)',\n      borderWidth: 2,\n      pointBackgroundColor: 'rgba(255,165,0,1)',\n      pointRadius: 4,\n    },\n    {\n      label: 'usb 库事件',\n      data: [3, 1, 4, 3, 5],\n      backgroundColor: 'rgba(57,255,20,0.2)',\n      borderColor: 'rgba(57,255,20,0.8)',\n      borderWidth: 2,\n      pointBackgroundColor: 'rgba(57,255,20,1)',\n      pointRadius: 4,\n    },\n    {\n      label: 'usb + node-hid 组合 ✅',\n      data: [4, 1, 5, 4, 5],\n      backgroundColor: 'rgba(0,212,255,0.2)',\n      borderColor: 'rgba(0,212,255,0.9)',\n      borderWidth: 2,\n      pointBackgroundColor: 'rgba(0,212,255,1)',\n      pointRadius: 4,\n    },\n  ]\n}\n\nexport const solutionCompareOptions = {\n  scales: {\n    r: {\n      min: 0,\n      max: 5,\n      ticks: {\n        display: false,\n        stepSize: 1,\n      },\n      grid: { color: 'rgba(136,136,170,0.25)' },\n      angleLines: { color: 'rgba(136,136,170,0.25)' },\n      pointLabels: {\n        color: '#c8c8d8',\n        font: { family: 'JetBrains Mono', size: 11 },\n      },\n    }\n  }\n}\n\n## 背景\n\n做键盘配置工具时，需要实时检测 HID 设备插拔。\n看起来很简单，实际上坑不少。\n\n## 方案对比\n\n| 方案 | 优点 | 缺点 |\n|------|------|------|\n| `udev` rules + inotify | 内核级，可靠 | 需要 root 权限规则 |\n| `node-hid` polling | 简单 | CPU 占用高 |\n| `usb` 库事件 | 无需 root，跨平台 | 依赖 libusb |\n| `usb` + `node-hid` 组合 | 最佳实践 ✅ | 需要两个依赖 |\n\n**四种方案综合评分对比：**\n\n\u003CChart client:only=\"vue\" type=\"radar\" data={solutionCompareData} options={solutionCompareOptions} height={280} \u002F>\n\n> 分数 1-5，越高越好。CPU 占用邠分评为高分（占用越低 → 得分越高）。\n\n## 基础实现\n\n```typescript\nimport usb from 'usb'\nimport HID from 'node-hid'\n\nconst TARGET_VID = 0x1234\nconst TARGET_PID = 0x5678\n\nfunction findDevice() {\n  return HID.devices().find(\n    d => d.vendorId === TARGET_VID && d.productId === TARGET_PID\n  )\n}\n\n\u002F\u002F usb 库监听插拔事件，比 polling 高效得多\nusb.on('attach', (device) => {\n  const desc = device.deviceDescriptor\n  if (desc.idVendor === TARGET_VID && desc.idProduct === TARGET_PID) {\n    \u002F\u002F 插入后等 100ms 让系统枚举完毕，再尝试打开\n    setTimeout(() => {\n      const info = findDevice()\n      if (info) openDevice(info)\n    }, 100)\n  }\n})\n\nusb.on('detach', (device) => {\n  const desc = device.deviceDescriptor\n  if (desc.idVendor === TARGET_VID && desc.idProduct === TARGET_PID) {\n    closeDevice()\n  }\n})\n```\n\n## 完整的 DeviceManager 类\n\n裸监听够用，但生产环境需要处理：设备偶尔断连、重连失败、多次重试……把这些逻辑封装进 `DeviceManager`，对外只暴露状态事件。\n\n```typescript\n\u002F\u002F src\u002Fmain\u002Fdevice-manager.ts\nimport { EventEmitter } from 'events'\nimport usb from 'usb'\nimport HID from 'node-hid'\n\ninterface DeviceManagerEvents {\n  connected: (devicePath: string) => void\n  disconnected: () => void\n  data: (report: Buffer) => void\n  error: (err: Error) => void\n}\n\nexport class DeviceManager extends EventEmitter {\n  private vid: number\n  private pid: number\n  private device: HID.HID | null = null\n  private reconnectTimer: NodeJS.Timeout | null = null\n  private reconnectAttempt = 0\n  private readonly MAX_RETRY = 8\n  private destroyed = false\n\n  constructor(vid: number, pid: number) {\n    super()\n    this.vid = vid\n    this.pid = pid\n\n    usb.on('attach', this.onAttach)\n    usb.on('detach', this.onDetach)\n  }\n\n  \u002F\u002F 指数退避：100ms → 200ms → 400ms → ... 最大 ~12.8s\n  private get retryDelay(): number {\n    return Math.min(100 * 2 ** this.reconnectAttempt, 12800)\n  }\n\n  private onAttach = (usbDevice: usb.Device) => {\n    const { idVendor, idProduct } = usbDevice.deviceDescriptor\n    if (idVendor !== this.vid || idProduct !== this.pid) return\n\n    \u002F\u002F 清掉之前的重连 timer（插拔快时可能有残留）\n    this.clearReconnectTimer()\n    this.reconnectAttempt = 0\n\n    \u002F\u002F 等内核枚举完毕\n    this.scheduleOpen(100)\n  }\n\n  private onDetach = (usbDevice: usb.Device) => {\n    const { idVendor, idProduct } = usbDevice.deviceDescriptor\n    if (idVendor !== this.vid || idProduct !== this.pid) return\n\n    this.close()\n    this.emit('disconnected')\n  }\n\n  private scheduleOpen(delayMs: number) {\n    this.reconnectTimer = setTimeout(() => this.tryOpen(), delayMs)\n  }\n\n  private clearReconnectTimer() {\n    if (this.reconnectTimer) {\n      clearTimeout(this.reconnectTimer)\n      this.reconnectTimer = null\n    }\n  }\n\n  private tryOpen() {\n    if (this.destroyed) return\n\n    const info = HID.devices().find(\n      d => d.vendorId === this.vid && d.productId === this.pid\n    )\n\n    if (!info || !info.path) {\n      \u002F\u002F 还没枚举好，按指数退避继续重试\n      this.reconnectAttempt++\n      if (this.reconnectAttempt \u003C= this.MAX_RETRY) {\n        console.warn(\n          `[DeviceManager] Device not found, retry ${this.reconnectAttempt}\u002F${this.MAX_RETRY} in ${this.retryDelay}ms`\n        )\n        this.scheduleOpen(this.retryDelay)\n      } else {\n        this.emit('error', new Error('Device not found after max retries'))\n      }\n      return\n    }\n\n    try {\n      this.device = new HID.HID(info.path)\n      this.reconnectAttempt = 0\n      this.startReadLoop()\n      this.emit('connected', info.path)\n    } catch (err) {\n      \u002F\u002F 打开失败也退避重试（比如权限问题短暂未就绪）\n      this.reconnectAttempt++\n      if (this.reconnectAttempt \u003C= this.MAX_RETRY) {\n        this.scheduleOpen(this.retryDelay)\n      } else {\n        this.emit('error', err instanceof Error ? err : new Error(String(err)))\n      }\n    }\n  }\n\n  private startReadLoop() {\n    if (!this.device) return\n\n    \u002F\u002F node-hid 异步读取：注册 data\u002Ferror 回调，不阻塞主线程\n    this.device.on('data', (data: Buffer) => {\n      this.emit('data', data)\n    })\n\n    this.device.on('error', (err: Error) => {\n      \u002F\u002F 设备被拔出时会触发 error，这是正常流程\n      this.close()\n      this.emit('disconnected')\n    })\n  }\n\n  open() {\n    \u002F\u002F 主动打开（应用启动时调用一次）\n    this.tryOpen()\n  }\n\n  close() {\n    this.clearReconnectTimer()\n    if (this.device) {\n      try { this.device.close() } catch {}\n      this.device = null\n    }\n  }\n\n  destroy() {\n    this.destroyed = true\n    this.close()\n    usb.off('attach', this.onAttach)\n    usb.off('detach', this.onDetach)\n    this.removeAllListeners()\n  }\n}\n```\n\n使用：\n\n```typescript\n\u002F\u002F src\u002Fmain\u002Findex.ts\nconst manager = new DeviceManager(0x1234, 0x5678)\n\nmanager.on('connected', (path) => console.log('已连接:', path))\nmanager.on('disconnected', () => console.log('已断开'))\nmanager.on('data', (buf) => handleReport(buf))\nmanager.on('error', (err) => console.error('HID 错误:', err))\n\nmanager.open() \u002F\u002F 启动时尝试首次打开\n\n\u002F\u002F 应用退出时\napp.on('before-quit', () => manager.destroy())\n```\n\n## 数据接收循环：64 字节 HID 报告\n\nHID 设备通讯的基本单位是**报告（Report）**，通常是 64 字节（含 1 字节 report ID）。\n\n上面的 `startReadLoop` 用了 node-hid 的**异步事件模式**（推荐）。但也有同步阻塞读的用法——要小心：\n\n```typescript\n\u002F\u002F ❌ 同步阻塞读：会卡住 Node.js 主线程\nfunction blockingReadLoop(device: HID.HID) {\n  while (true) {\n    const data = device.readSync() \u002F\u002F 阻塞直到有数据\n    processReport(data)\n  }\n}\n\n\u002F\u002F ✅ 异步非阻塞：用 on('data') 回调\nfunction asyncReadLoop(device: HID.HID) {\n  device.on('data', (data: Buffer) => {\n    \u002F\u002F data.length 通常是 64（含 report ID 填充）\n    const reportId = data[0]\n    const payload = data.slice(1) \u002F\u002F 实际数据从第 2 字节开始\n    processReport(reportId, payload)\n  })\n}\n\nfunction processReport(reportId: number, payload: Buffer) {\n  switch (reportId) {\n    case 0x01: \u002F\u002F 按键事件\n      handleKeyEvent(payload)\n      break\n    case 0x02: \u002F\u002F 状态响应\n      handleStatusResponse(payload)\n      break\n    default:\n      console.warn(`Unknown report ID: 0x${reportId.toString(16)}`)\n  }\n}\n```\n\n如果需要**发送数据**（Feature Report 或 Output Report）：\n\n```typescript\n\u002F\u002F 发送 64 字节报告（第一字节是 report ID，通常是 0x00）\nfunction sendReport(device: HID.HID, reportId: number, data: number[]) {\n  const report = [reportId, ...data]\n  \u002F\u002F 不足 64 字节的补零\n  while (report.length \u003C 65) report.push(0) \u002F\u002F node-hid 要求总长度包括 report ID\n  device.write(report)\n}\n```\n\n## Windows 上的差异\n\nWindows 的 USB 事件机制与 Linux\u002FmacOS 有本质不同：\n\n| | Linux | macOS | Windows |\n|--|-------|-------|---------|\n| 底层机制 | udev netlink | IOKit | `WM_DEVICECHANGE` 消息 |\n| `usb` 库支持 | ✅ libusb | ✅ libusb | ⚠️ 部分支持 |\n| HID 独占 | 否（hidraw） | 否 | 是（内核驱动独占） |\n\n**Windows 主要坑：**\n\n1. **`usb` 库事件不可靠**：libusb 在 Windows 上依赖 WinUSB\u002FlibusbK 驱动，但 HID 设备默认用 `hid.sys`，两者冲突，`usb.on('attach')` 可能根本不触发。\n\n2. **HID 独占问题**：Windows 的 HID 驱动独占设备，有时候 `new HID.HID(path)` 会因为系统或其他应用已持有句柄而失败。\n\n3. **推荐 Windows 方案**：不依赖 `usb` 库事件，改用轮询 + 短间隔：\n\n```typescript\n\u002F\u002F Windows 专用：轮询方案（每 500ms 扫一次设备列表）\nclass WindowsDeviceWatcher {\n  private timer: NodeJS.Timeout | null = null\n  private knownPaths = new Set\u003Cstring>()\n\n  start(vid: number, pid: number, onAttach: (path: string) => void, onDetach: (path: string) => void) {\n    this.timer = setInterval(() => {\n      const current = new Set(\n        HID.devices()\n          .filter(d => d.vendorId === vid && d.productId === pid && d.path)\n          .map(d => d.path!)\n      )\n\n      \u002F\u002F 新增\n      for (const p of current) {\n        if (!this.knownPaths.has(p)) onAttach(p)\n      }\n      \u002F\u002F 移除\n      for (const p of this.knownPaths) {\n        if (!current.has(p)) onDetach(p)\n      }\n\n      this.knownPaths = current\n    }, 500)\n  }\n\n  stop() {\n    if (this.timer) clearInterval(this.timer)\n  }\n}\n```\n\n跨平台判断：\n\n```typescript\nconst watcher = process.platform === 'win32'\n  ? new WindowsDeviceWatcher()\n  : new UsbEventWatcher() \u002F\u002F 用 usb 库\n```\n\n## 多设备管理\n\n同 VID\u002FPID 的多个设备怎么区分？靠 `path` 或 `serialNumber`。\n\n```typescript\n\u002F\u002F 用 Map\u003Cpath, HID.HID> 管理多个设备\nclass MultiDeviceManager {\n  private devices = new Map\u003Cstring, HID.HID>()\n\n  openAll(vid: number, pid: number) {\n    const infos = HID.devices().filter(\n      d => d.vendorId === vid && d.productId === pid && d.path\n    )\n\n    for (const info of infos) {\n      if (!info.path || this.devices.has(info.path)) continue\n\n      try {\n        const device = new HID.HID(info.path)\n        this.devices.set(info.path, device)\n\n        console.log(\n          `已打开设备: path=${info.path} serial=${info.serialNumber ?? '(无序列号)'}`\n        )\n\n        device.on('data', (data) => this.onData(info.path!, data))\n        device.on('error', () => this.onError(info.path!))\n      } catch (err) {\n        console.error(`打开设备失败: ${info.path}`, err)\n      }\n    }\n  }\n\n  private onData(path: string, data: Buffer) {\n    \u002F\u002F 用 path 区分是哪个设备来的数据\n    console.log(`[${path}] 收到报告:`, data.toString('hex'))\n  }\n\n  private onError(path: string) {\n    const device = this.devices.get(path)\n    try { device?.close() } catch {}\n    this.devices.delete(path)\n    console.warn(`设备断开: ${path}`)\n  }\n\n  closeAll() {\n    for (const [path, device] of this.devices) {\n      try { device.close() } catch {}\n    }\n    this.devices.clear()\n  }\n}\n```\n\n> **注意**：`serialNumber` 不是所有设备都有，廉价设备可能是空字符串。优先用 `path`，它在单次系统会话内是唯一的；重启后 `path` 可能变，这时候才需要靠 `serialNumber`。\n\n## 踩坑\n\n### 1. 枚举竞争\n\n`attach` 事件触发时，`HID.devices()` 可能还找不到设备（内核还在枚举）。\n解决：加 100~200ms 延迟，必要时用指数退避重试（见 `DeviceManager`）。\n\n### 2. Linux udev 权限\n\n默认普通用户无法访问 `\u002Fdev\u002Fhidraw*`。\n需要添加 udev rule：\n\n```bash\n# \u002Fetc\u002Fudev\u002Frules.d\u002F99-hid.rules\nSUBSYSTEM==\"hidraw\", ATTRS{idVendor}==\"1234\", ATTRS{idProduct}==\"5678\", MODE=\"0666\"\n```\n\n重载规则：`sudo udevadm control --reload-rules && sudo udevadm trigger`\n\n### 3. Electron 沙箱冲突\n\n`node-hid` 需要 Node.js native module 权限，Electron 的 `sandbox: true` 会拦截。\n方案：在 **main process** 里跑 HID 逻辑，通过 IPC 传给 renderer。永远不要在 renderer 里直接调 native module。\n\n## 结论\n\n`usb` 事件 + `node-hid` 操作 + `DeviceManager` 封装（含指数退避重连），加上 IPC 隔离，这套组合在生产环境用了半年，没出问题。Windows 上要额外做轮询回退。","\u003Cp>import Chart from ‘…\u002F…\u002Fcomponents\u002FChart.vue’\u003C\u002Fp>\n\u003Cp>export const solutionCompareData = {\nlabels: [‘实现复杂度’, ‘CPU 占用’, ‘可靠性’, ‘跨平台’, ‘无需 root’],\ndatasets: [\n{\nlabel: ‘udev + inotify’,\ndata: [4, 1, 5, 1, 1],\nbackgroundColor: ‘rgba(255,0,170,0.25)’,\nborderColor: ‘rgba(255,0,170,0.8)’,\nborderWidth: 2,\npointBackgroundColor: ‘rgba(255,0,170,1)’,\npointRadius: 4,\n},\n{\nlabel: ‘node-hid polling’,\ndata: [1, 5, 3, 5, 5],\nbackgroundColor: ‘rgba(255,165,0,0.2)’,\nborderColor: ‘rgba(255,165,0,0.8)’,\nborderWidth: 2,\npointBackgroundColor: ‘rgba(255,165,0,1)’,\npointRadius: 4,\n},\n{\nlabel: ‘usb 库事件’,\ndata: [3, 1, 4, 3, 5],\nbackgroundColor: ‘rgba(57,255,20,0.2)’,\nborderColor: ‘rgba(57,255,20,0.8)’,\nborderWidth: 2,\npointBackgroundColor: ‘rgba(57,255,20,1)’,\npointRadius: 4,\n},\n{\nlabel: ‘usb + node-hid 组合 ✅’,\ndata: [4, 1, 5, 4, 5],\nbackgroundColor: ‘rgba(0,212,255,0.2)’,\nborderColor: ‘rgba(0,212,255,0.9)’,\nborderWidth: 2,\npointBackgroundColor: ‘rgba(0,212,255,1)’,\npointRadius: 4,\n},\n]\n}\u003C\u002Fp>\n\u003Cp>export const solutionCompareOptions = {\nscales: {\nr: {\nmin: 0,\nmax: 5,\nticks: {\ndisplay: false,\nstepSize: 1,\n},\ngrid: { color: ‘rgba(136,136,170,0.25)’ },\nangleLines: { color: ‘rgba(136,136,170,0.25)’ },\npointLabels: {\ncolor: ‘#c8c8d8’,\nfont: { family: ‘JetBrains Mono’, size: 11 },\n},\n}\n}\n}\u003C\u002Fp>\n\u003Ch2 id=\"背景\">背景\u003C\u002Fh2>\n\u003Cp>做键盘配置工具时，需要实时检测 HID 设备插拔。\n看起来很简单，实际上坑不少。\u003C\u002Fp>\n\u003Ch2 id=\"方案对比\">方案对比\u003C\u002Fh2>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>方案\u003C\u002Fth>\n\u003Cth>优点\u003C\u002Fth>\n\u003Cth>缺点\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>udev\u003C\u002Fcode> rules + inotify\u003C\u002Ftd>\n\u003Ctd>内核级，可靠\u003C\u002Ftd>\n\u003Ctd>需要 root 权限规则\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>node-hid\u003C\u002Fcode> polling\u003C\u002Ftd>\n\u003Ctd>简单\u003C\u002Ftd>\n\u003Ctd>CPU 占用高\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>usb\u003C\u002Fcode> 库事件\u003C\u002Ftd>\n\u003Ctd>无需 root，跨平台\u003C\u002Ftd>\n\u003Ctd>依赖 libusb\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>usb\u003C\u002Fcode> + \u003Ccode>node-hid\u003C\u002Fcode> 组合\u003C\u002Ftd>\n\u003Ctd>最佳实践 ✅\u003C\u002Ftd>\n\u003Ctd>需要两个依赖\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>\u003Cstrong>四种方案综合评分对比：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003CChart client:only=\"vue\" type=\"radar\" data={solutionCompareData} options={solutionCompareOptions} height={280} \u002F>\n\u003Cblockquote>\n\u003Cp>分数 1-5，越高越好。CPU 占用邠分评为高分（占用越低 → 得分越高）。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2 id=\"基础实现\">基础实现\u003C\u002Fh2>\n\u003Cpre>\u003Ccode class=\"language-typescript\">import usb from 'usb'\nimport HID from 'node-hid'\n\nconst TARGET_VID = 0x1234\nconst TARGET_PID = 0x5678\n\nfunction findDevice() {\n  return HID.devices().find(\n    d =&gt; d.vendorId === TARGET_VID &amp;&amp; d.productId === TARGET_PID\n  )\n}\n\n\u002F\u002F usb 库监听插拔事件，比 polling 高效得多\nusb.on('attach', (device) =&gt; {\n  const desc = device.deviceDescriptor\n  if (desc.idVendor === TARGET_VID &amp;&amp; desc.idProduct === TARGET_PID) {\n    \u002F\u002F 插入后等 100ms 让系统枚举完毕，再尝试打开\n    setTimeout(() =&gt; {\n      const info = findDevice()\n      if (info) openDevice(info)\n    }, 100)\n  }\n})\n\nusb.on('detach', (device) =&gt; {\n  const desc = device.deviceDescriptor\n  if (desc.idVendor === TARGET_VID &amp;&amp; desc.idProduct === TARGET_PID) {\n    closeDevice()\n  }\n})\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"完整的-devicemanager-类\">完整的 DeviceManager 类\u003C\u002Fh2>\n\u003Cp>裸监听够用，但生产环境需要处理：设备偶尔断连、重连失败、多次重试……把这些逻辑封装进 \u003Ccode>DeviceManager\u003C\u002Fcode>，对外只暴露状态事件。\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F src\u002Fmain\u002Fdevice-manager.ts\nimport { EventEmitter } from 'events'\nimport usb from 'usb'\nimport HID from 'node-hid'\n\ninterface DeviceManagerEvents {\n  connected: (devicePath: string) =&gt; void\n  disconnected: () =&gt; void\n  data: (report: Buffer) =&gt; void\n  error: (err: Error) =&gt; void\n}\n\nexport class DeviceManager extends EventEmitter {\n  private vid: number\n  private pid: number\n  private device: HID.HID | null = null\n  private reconnectTimer: NodeJS.Timeout | null = null\n  private reconnectAttempt = 0\n  private readonly MAX_RETRY = 8\n  private destroyed = false\n\n  constructor(vid: number, pid: number) {\n    super()\n    this.vid = vid\n    this.pid = pid\n\n    usb.on('attach', this.onAttach)\n    usb.on('detach', this.onDetach)\n  }\n\n  \u002F\u002F 指数退避：100ms → 200ms → 400ms → ... 最大 ~12.8s\n  private get retryDelay(): number {\n    return Math.min(100 * 2 ** this.reconnectAttempt, 12800)\n  }\n\n  private onAttach = (usbDevice: usb.Device) =&gt; {\n    const { idVendor, idProduct } = usbDevice.deviceDescriptor\n    if (idVendor !== this.vid || idProduct !== this.pid) return\n\n    \u002F\u002F 清掉之前的重连 timer（插拔快时可能有残留）\n    this.clearReconnectTimer()\n    this.reconnectAttempt = 0\n\n    \u002F\u002F 等内核枚举完毕\n    this.scheduleOpen(100)\n  }\n\n  private onDetach = (usbDevice: usb.Device) =&gt; {\n    const { idVendor, idProduct } = usbDevice.deviceDescriptor\n    if (idVendor !== this.vid || idProduct !== this.pid) return\n\n    this.close()\n    this.emit('disconnected')\n  }\n\n  private scheduleOpen(delayMs: number) {\n    this.reconnectTimer = setTimeout(() =&gt; this.tryOpen(), delayMs)\n  }\n\n  private clearReconnectTimer() {\n    if (this.reconnectTimer) {\n      clearTimeout(this.reconnectTimer)\n      this.reconnectTimer = null\n    }\n  }\n\n  private tryOpen() {\n    if (this.destroyed) return\n\n    const info = HID.devices().find(\n      d =&gt; d.vendorId === this.vid &amp;&amp; d.productId === this.pid\n    )\n\n    if (!info || !info.path) {\n      \u002F\u002F 还没枚举好，按指数退避继续重试\n      this.reconnectAttempt++\n      if (this.reconnectAttempt &lt;= this.MAX_RETRY) {\n        console.warn(\n          `[DeviceManager] Device not found, retry ${this.reconnectAttempt}\u002F${this.MAX_RETRY} in ${this.retryDelay}ms`\n        )\n        this.scheduleOpen(this.retryDelay)\n      } else {\n        this.emit('error', new Error('Device not found after max retries'))\n      }\n      return\n    }\n\n    try {\n      this.device = new HID.HID(info.path)\n      this.reconnectAttempt = 0\n      this.startReadLoop()\n      this.emit('connected', info.path)\n    } catch (err) {\n      \u002F\u002F 打开失败也退避重试（比如权限问题短暂未就绪）\n      this.reconnectAttempt++\n      if (this.reconnectAttempt &lt;= this.MAX_RETRY) {\n        this.scheduleOpen(this.retryDelay)\n      } else {\n        this.emit('error', err instanceof Error ? err : new Error(String(err)))\n      }\n    }\n  }\n\n  private startReadLoop() {\n    if (!this.device) return\n\n    \u002F\u002F node-hid 异步读取：注册 data\u002Ferror 回调，不阻塞主线程\n    this.device.on('data', (data: Buffer) =&gt; {\n      this.emit('data', data)\n    })\n\n    this.device.on('error', (err: Error) =&gt; {\n      \u002F\u002F 设备被拔出时会触发 error，这是正常流程\n      this.close()\n      this.emit('disconnected')\n    })\n  }\n\n  open() {\n    \u002F\u002F 主动打开（应用启动时调用一次）\n    this.tryOpen()\n  }\n\n  close() {\n    this.clearReconnectTimer()\n    if (this.device) {\n      try { this.device.close() } catch {}\n      this.device = null\n    }\n  }\n\n  destroy() {\n    this.destroyed = true\n    this.close()\n    usb.off('attach', this.onAttach)\n    usb.off('detach', this.onDetach)\n    this.removeAllListeners()\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>使用：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F src\u002Fmain\u002Findex.ts\nconst manager = new DeviceManager(0x1234, 0x5678)\n\nmanager.on('connected', (path) =&gt; console.log('已连接:', path))\nmanager.on('disconnected', () =&gt; console.log('已断开'))\nmanager.on('data', (buf) =&gt; handleReport(buf))\nmanager.on('error', (err) =&gt; console.error('HID 错误:', err))\n\nmanager.open() \u002F\u002F 启动时尝试首次打开\n\n\u002F\u002F 应用退出时\napp.on('before-quit', () =&gt; manager.destroy())\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"数据接收循环-64-字节-hid-报告\">数据接收循环：64 字节 HID 报告\u003C\u002Fh2>\n\u003Cp>HID 设备通讯的基本单位是\u003Cstrong>报告（Report）\u003C\u002Fstrong>，通常是 64 字节（含 1 字节 report ID）。\u003C\u002Fp>\n\u003Cp>上面的 \u003Ccode>startReadLoop\u003C\u002Fcode> 用了 node-hid 的\u003Cstrong>异步事件模式\u003C\u002Fstrong>（推荐）。但也有同步阻塞读的用法——要小心：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F ❌ 同步阻塞读：会卡住 Node.js 主线程\nfunction blockingReadLoop(device: HID.HID) {\n  while (true) {\n    const data = device.readSync() \u002F\u002F 阻塞直到有数据\n    processReport(data)\n  }\n}\n\n\u002F\u002F ✅ 异步非阻塞：用 on('data') 回调\nfunction asyncReadLoop(device: HID.HID) {\n  device.on('data', (data: Buffer) =&gt; {\n    \u002F\u002F data.length 通常是 64（含 report ID 填充）\n    const reportId = data[0]\n    const payload = data.slice(1) \u002F\u002F 实际数据从第 2 字节开始\n    processReport(reportId, payload)\n  })\n}\n\nfunction processReport(reportId: number, payload: Buffer) {\n  switch (reportId) {\n    case 0x01: \u002F\u002F 按键事件\n      handleKeyEvent(payload)\n      break\n    case 0x02: \u002F\u002F 状态响应\n      handleStatusResponse(payload)\n      break\n    default:\n      console.warn(`Unknown report ID: 0x${reportId.toString(16)}`)\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>如果需要\u003Cstrong>发送数据\u003C\u002Fstrong>（Feature Report 或 Output Report）：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F 发送 64 字节报告（第一字节是 report ID，通常是 0x00）\nfunction sendReport(device: HID.HID, reportId: number, data: number[]) {\n  const report = [reportId, ...data]\n  \u002F\u002F 不足 64 字节的补零\n  while (report.length &lt; 65) report.push(0) \u002F\u002F node-hid 要求总长度包括 report ID\n  device.write(report)\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"windows-上的差异\">Windows 上的差异\u003C\u002Fh2>\n\u003Cp>Windows 的 USB 事件机制与 Linux\u002FmacOS 有本质不同：\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>\u003C\u002Fth>\n\u003Cth>Linux\u003C\u002Fth>\n\u003Cth>macOS\u003C\u002Fth>\n\u003Cth>Windows\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>底层机制\u003C\u002Ftd>\n\u003Ctd>udev netlink\u003C\u002Ftd>\n\u003Ctd>IOKit\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>WM_DEVICECHANGE\u003C\u002Fcode> 消息\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>usb\u003C\u002Fcode> 库支持\u003C\u002Ftd>\n\u003Ctd>✅ libusb\u003C\u002Ftd>\n\u003Ctd>✅ libusb\u003C\u002Ftd>\n\u003Ctd>⚠️ 部分支持\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>HID 独占\u003C\u002Ftd>\n\u003Ctd>否（hidraw）\u003C\u002Ftd>\n\u003Ctd>否\u003C\u002Ftd>\n\u003Ctd>是（内核驱动独占）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>\u003Cstrong>Windows 主要坑：\u003C\u002Fstrong>\u003C\u002Fp>\n\u003Col>\n\u003Cli>\n\u003Cp>\u003Cstrong>\u003Ccode>usb\u003C\u002Fcode> 库事件不可靠\u003C\u002Fstrong>：libusb 在 Windows 上依赖 WinUSB\u002FlibusbK 驱动，但 HID 设备默认用 \u003Ccode>hid.sys\u003C\u002Fcode>，两者冲突，\u003Ccode>usb.on('attach')\u003C\u002Fcode> 可能根本不触发。\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>HID 独占问题\u003C\u002Fstrong>：Windows 的 HID 驱动独占设备，有时候 \u003Ccode>new HID.HID(path)\u003C\u002Fcode> 会因为系统或其他应用已持有句柄而失败。\u003C\u002Fp>\n\u003C\u002Fli>\n\u003Cli>\n\u003Cp>\u003Cstrong>推荐 Windows 方案\u003C\u002Fstrong>：不依赖 \u003Ccode>usb\u003C\u002Fcode> 库事件，改用轮询 + 短间隔：\u003C\u002Fp>\n\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F Windows 专用：轮询方案（每 500ms 扫一次设备列表）\nclass WindowsDeviceWatcher {\n  private timer: NodeJS.Timeout | null = null\n  private knownPaths = new Set&lt;string&gt;()\n\n  start(vid: number, pid: number, onAttach: (path: string) =&gt; void, onDetach: (path: string) =&gt; void) {\n    this.timer = setInterval(() =&gt; {\n      const current = new Set(\n        HID.devices()\n          .filter(d =&gt; d.vendorId === vid &amp;&amp; d.productId === pid &amp;&amp; d.path)\n          .map(d =&gt; d.path!)\n      )\n\n      \u002F\u002F 新增\n      for (const p of current) {\n        if (!this.knownPaths.has(p)) onAttach(p)\n      }\n      \u002F\u002F 移除\n      for (const p of this.knownPaths) {\n        if (!current.has(p)) onDetach(p)\n      }\n\n      this.knownPaths = current\n    }, 500)\n  }\n\n  stop() {\n    if (this.timer) clearInterval(this.timer)\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>跨平台判断：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">const watcher = process.platform === 'win32'\n  ? new WindowsDeviceWatcher()\n  : new UsbEventWatcher() \u002F\u002F 用 usb 库\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"多设备管理\">多设备管理\u003C\u002Fh2>\n\u003Cp>同 VID\u002FPID 的多个设备怎么区分？靠 \u003Ccode>path\u003C\u002Fcode> 或 \u003Ccode>serialNumber\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-typescript\">\u002F\u002F 用 Map&lt;path, HID.HID&gt; 管理多个设备\nclass MultiDeviceManager {\n  private devices = new Map&lt;string, HID.HID&gt;()\n\n  openAll(vid: number, pid: number) {\n    const infos = HID.devices().filter(\n      d =&gt; d.vendorId === vid &amp;&amp; d.productId === pid &amp;&amp; d.path\n    )\n\n    for (const info of infos) {\n      if (!info.path || this.devices.has(info.path)) continue\n\n      try {\n        const device = new HID.HID(info.path)\n        this.devices.set(info.path, device)\n\n        console.log(\n          `已打开设备: path=${info.path} serial=${info.serialNumber ?? '(无序列号)'}`\n        )\n\n        device.on('data', (data) =&gt; this.onData(info.path!, data))\n        device.on('error', () =&gt; this.onError(info.path!))\n      } catch (err) {\n        console.error(`打开设备失败: ${info.path}`, err)\n      }\n    }\n  }\n\n  private onData(path: string, data: Buffer) {\n    \u002F\u002F 用 path 区分是哪个设备来的数据\n    console.log(`[${path}] 收到报告:`, data.toString('hex'))\n  }\n\n  private onError(path: string) {\n    const device = this.devices.get(path)\n    try { device?.close() } catch {}\n    this.devices.delete(path)\n    console.warn(`设备断开: ${path}`)\n  }\n\n  closeAll() {\n    for (const [path, device] of this.devices) {\n      try { device.close() } catch {}\n    }\n    this.devices.clear()\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>注意\u003C\u002Fstrong>：\u003Ccode>serialNumber\u003C\u002Fcode> 不是所有设备都有，廉价设备可能是空字符串。优先用 \u003Ccode>path\u003C\u002Fcode>，它在单次系统会话内是唯一的；重启后 \u003Ccode>path\u003C\u002Fcode> 可能变，这时候才需要靠 \u003Ccode>serialNumber\u003C\u002Fcode>。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2 id=\"踩坑\">踩坑\u003C\u002Fh2>\n\u003Ch3 id=\"1-枚举竞争\">1. 枚举竞争\u003C\u002Fh3>\n\u003Cp>\u003Ccode>attach\u003C\u002Fcode> 事件触发时，\u003Ccode>HID.devices()\u003C\u002Fcode> 可能还找不到设备（内核还在枚举）。\n解决：加 100~200ms 延迟，必要时用指数退避重试（见 \u003Ccode>DeviceManager\u003C\u002Fcode>）。\u003C\u002Fp>\n\u003Ch3 id=\"2-linux-udev-权限\">2. Linux udev 权限\u003C\u002Fh3>\n\u003Cp>默认普通用户无法访问 \u003Ccode>\u002Fdev\u002Fhidraw*\u003C\u002Fcode>。\n需要添加 udev rule：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\"># \u002Fetc\u002Fudev\u002Frules.d\u002F99-hid.rules\nSUBSYSTEM==&quot;hidraw&quot;, ATTRS{idVendor}==&quot;1234&quot;, ATTRS{idProduct}==&quot;5678&quot;, MODE=&quot;0666&quot;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>重载规则：\u003Ccode>sudo udevadm control --reload-rules &amp;&amp; sudo udevadm trigger\u003C\u002Fcode>\u003C\u002Fp>\n\u003Ch3 id=\"3-electron-沙箱冲突\">3. Electron 沙箱冲突\u003C\u002Fh3>\n\u003Cp>\u003Ccode>node-hid\u003C\u002Fcode> 需要 Node.js native module 权限，Electron 的 \u003Ccode>sandbox: true\u003C\u002Fcode> 会拦截。\n方案：在 \u003Cstrong>main process\u003C\u002Fstrong> 里跑 HID 逻辑，通过 IPC 传给 renderer。永远不要在 renderer 里直接调 native module。\u003C\u002Fp>\n\u003Ch2 id=\"结论\">结论\u003C\u002Fh2>\n\u003Cp>\u003Ccode>usb\u003C\u002Fcode> 事件 + \u003Ccode>node-hid\u003C\u002Fcode> 操作 + \u003Ccode>DeviceManager\u003C\u002Fcode> 封装（含指数退避重连），加上 IPC 隔离，这套组合在生产环境用了半年，没出问题。Windows 上要额外做轮询回退。\u003C\u002Fp>\n","2026-04-28",[11,12,13,14,15],"cpp","hid","linux","nodejs","electron",false,[18,31,41,53,63,70,77,84,91,98,108,117,127,136,144,152,161,170,179,189,196,205,211,218,224,233,240,247,255,258,267,276,286,296,306,314,324,335,345,354,362,368,376,384,392,400,408,415],{"slug":19,"title":20,"description":21,"pub_date":22,"tags":23,"draft":16,"word_count":30},"ide-skills-guide","Agent Skills 完全指南：21 款第三方 Skill 深度评测与使用心得","全面评测 21 款第三方 Agent Skills，涵盖 Vue 生态、前端设计、构建工具、实用工具四大分类。从安装配置到实际使用场景，带你了解每个 Skill 的功能特点、最佳实践与使用心得。","2026-06-15",[24,25,26,27,28,29],"agent","skills","AI","效率工具","前端","Vue",4169,{"slug":32,"title":33,"description":34,"pub_date":35,"tags":36,"draft":16,"word_count":40},"linux-kernel-skeleton-struct-funcptr-container_of","Linux 内核骨架：struct、函数指针与 container_of","读懂 Linux 内核源码的三件套：巨大的 struct 组合代替继承、函数指针表实现虚派发、container_of 宏从嵌入成员找回完整对象。","2026-05-09",[13,37,38,39],"kernel","C","container_of",1369,{"slug":42,"title":43,"description":44,"pub_date":45,"tags":46,"draft":16,"word_count":52},"astro-complete-guide-2025","Astro 5 深度剖析：Islands 架构原理、构建优化与 Cloudflare Workers 边缘部署","从编译器视角解析 Astro 5 的 Islands 架构实现原理，Content Layer API 的 Vite 插件机制，Server Islands 的流式渲染，以及如何在 Cloudflare Workers + D1 边缘环境下榨干性能。","2026-05-08",[47,48,49,50,51],"astro","frontend","cloudflare","performance","architecture",3663,{"slug":54,"title":55,"description":56,"pub_date":57,"tags":58,"draft":16,"word_count":62},"llm-prompt-engineering","Prompt Engineering 实战：让 LLM 真正听话的技巧","System prompt 怎么写、Few-shot 怎么设计、Chain-of-Thought 原理，以及常见失败模式和调试方法。","2026-05-03",[59,60,61],"ai","llm","工程实践",1723,{"slug":64,"title":65,"description":66,"pub_date":57,"tags":67,"draft":16,"word_count":69},"rag-system-design","RAG 系统设计：从 naive 到 production-ready","Retrieval-Augmented Generation 不只是「向量数据库 + LLM」，分块策略、召回质量、重排序、缓存才是工程核心。",[59,68,60,61],"rag",1613,{"slug":71,"title":72,"description":73,"pub_date":57,"tags":74,"draft":16,"word_count":76},"git-advanced-workflow","Git 进阶工作流：rebase、cherry-pick、bisect 的正确使用","merge 会了，但 rebase 总搞错？bisect 找 bug 提交？interactive rebase 整理历史？这篇一次说清楚。",[75,61],"git",1396,{"slug":78,"title":79,"description":80,"pub_date":57,"tags":81,"draft":16,"word_count":83},"docker-practical-guide","Docker 实战：从会用到用好","会 docker run 不够，Dockerfile 最佳实践、多阶段构建、Compose 编排、镜像瘦身才是日常真正需要的。",[82,13,61],"docker",1268,{"slug":85,"title":86,"description":87,"pub_date":57,"tags":88,"draft":16,"word_count":90},"anthropics-skills-guide","anthropics\u002Fskills：Anthropic 官方 Agent Skills 仓库解析","Anthropic 官方开源的 Agent Skills 标准仓库，127k stars，解析 SKILL.md 规范、17 个示例 skill 的设计模式，以及如何在 Claude Code \u002F Claude.ai \u002F API 中使用",[59,89,24,25],"Claude",2090,{"slug":92,"title":93,"description":94,"pub_date":57,"tags":95,"draft":16,"word_count":97},"karpathy-claude-code-guidelines","Karpathy 的 LLM 编码批评与 CLAUDE.md 最佳实践","基于 Andrej Karpathy 对 LLM 编程助手的观察，forrestchang 提炼出一个 CLAUDE.md 文件，4 条原则解决 AI 编码的典型失控问题：乱猜假设、过度设计、乱改代码、目标不清",[59,89,96,61],"Claude Code",2699,{"slug":99,"title":100,"description":101,"pub_date":57,"tags":102,"draft":16,"word_count":107},"typescript-advanced-patterns","TypeScript 高级模式：让类型系统为你工作","基础 TS 会了但类型总是 any？条件类型、映射类型、模板字面量类型、infer 关键字才是 TS 的真正威力。",[103,104,105,106],"typescript","类型系统","前端工程","高级模式",1419,{"slug":109,"title":110,"description":111,"pub_date":57,"tags":112,"draft":16,"word_count":116},"linux-performance-tuning","Linux 性能调优实战：从 top 到 perf 的完整工具链","遇到性能问题不知道从哪下手？这篇建立系统化的排查思路，从 CPU\u002F内存\u002FIO\u002F网络逐层分析。",[13,113,114,115],"性能","运维","系统编程",1524,{"slug":118,"title":119,"description":120,"pub_date":57,"tags":121,"draft":16,"word_count":126},"python-functional-programming","Python 函数式编程：map\u002Ffilter\u002Freduce 之外","Python 不是纯函数式语言，但 functools、itertools、偏函数、闭包这些工具用好了能让代码简洁一个量级。",[122,123,124,125],"python","函数式","闭包","装饰器",1867,{"slug":128,"title":129,"description":130,"pub_date":57,"tags":131,"draft":16,"word_count":135},"python-oop-guide","Python 面向对象：__init__ 之外你需要知道的","Python OOP 不只是 class + __init__，魔术方法、描述符、元类才是真正的武器。",[122,132,133,134],"OOP","面向对象","魔术方法",1792,{"slug":137,"title":138,"description":139,"pub_date":57,"tags":140,"draft":16,"word_count":143},"python-data-structures","Python 内置数据结构深度解析","list、dict、set、tuple 不只是数据容器，搞懂它们的底层实现和时间复杂度，才能写出高性能 Python。",[122,141,113,142],"数据结构","算法",1517,{"slug":145,"title":146,"description":147,"pub_date":57,"tags":148,"draft":16,"word_count":151},"python-basics-quick-start","Python 快速上手：写给有编程基础的人","已经会其他语言，想快速掌握 Python 的语法特性和思维方式，这篇是捷径。",[122,149,150],"入门","基础",1607,{"slug":153,"title":154,"description":155,"pub_date":57,"tags":156,"draft":16,"word_count":160},"python-dataclass-pydantic","Python dataclass vs Pydantic：数据类选型指南","dataclass 是标准库的轻量选择，Pydantic v2 是带验证的重武器，什么时候用哪个，这篇说清楚。",[122,157,158,159],"dataclass","pydantic","数据验证",1323,{"slug":162,"title":163,"description":164,"pub_date":57,"tags":165,"draft":16,"word_count":169},"python-asyncio-practical","Python asyncio 实战：从回调地狱到协程优雅","asyncio 是 Python 异步编程的核心，搞懂 event loop、Task、gather 这些概念才能写出真正高效的异步代码。",[122,166,167,168],"asyncio","并发","网络编程",1258,{"slug":171,"title":172,"description":173,"pub_date":57,"tags":174,"draft":16,"word_count":178},"python-type-hints-guide","Python 类型注解完全指南：从入门到实践","Python 3.5+ 引入类型注解，配合 mypy\u002Fpyright 让 Python 也能享受静态类型检查的好处。",[122,175,176,177],"typescript-style","type-hints","工具链",1102,{"slug":180,"title":181,"description":182,"pub_date":183,"tags":184,"draft":16,"word_count":188},"pwa-install-update-button","PWA 踩坑：为什么安装按钮从来不出现","从 beforeinstallprompt 到 Service Worker waiting，把 PWA 的安装与更新提示真正做对","2026-05-02",[185,186,187],"pwa","javascript","web",1683,{"slug":190,"title":191,"description":192,"pub_date":193,"tags":194,"draft":16,"word_count":195},"openclaw-vs-hermes-agent","OpenClaw vs Hermes Agent：两个本地优先 Agent 的设计差异","OpenClaw（Novita AI）和 Hermes Agent（Nous Research）都是本地运行的个人 AI Agent，但在记忆系统、技能学习、运行环境和模型生态上走了不同的路。深入对比两种架构的核心差异。","2026-05-01",[59,24,60],1679,{"slug":197,"title":198,"description":199,"pub_date":193,"tags":200,"draft":16,"word_count":204},"cpp-random-design-patterns","C++ 设计模式实战：RAII、观察者、工厂","用现代 C++（C++17\u002F20）实现三种高频设计模式：RAII 资源管理、观察者模式事件系统、工厂模式插件架构。每种模式给出问题场景、实现代码和真实工程案例。",[11,201,202,203],"设计模式","c++17","工程",2613,{"slug":206,"title":207,"description":208,"pub_date":193,"tags":209,"draft":16,"word_count":210},"data-structures-fundamentals","数据结构基础：从数组到红黑树","系统梳理常用数据结构的核心原理、时间复杂度和适用场景。数组、链表、栈、队列、哈希表、二叉树、堆、图，每种结构附实现要点和 C++ 代码片段。",[141,142,11,150],3004,{"slug":212,"title":213,"description":214,"pub_date":215,"tags":216,"draft":16,"word_count":217},"ai-agent-what-is","什么是 AI Agent？从 LLM 到自主执行","LLM 本身是无状态问答机，Agent 是什么让它’动’起来的？本文深入解析 Agent 的四个核心能力、ReAct 框架、工具调用原理，以及主流框架横向对比。","2026-04-30",[59,24,60],2116,{"slug":219,"title":220,"description":221,"pub_date":215,"tags":222,"draft":16,"word_count":223},"ai-agent-memory","AI Agent 的记忆系统：从上下文窗口到长期记忆","深入拆解 AI Agent 的四种记忆类型、上下文窗口压缩策略、RAG 向量检索原理，以及三种典型失败模式和工程选型建议。",[59,24,68],2052,{"slug":225,"title":226,"description":227,"pub_date":215,"tags":228,"draft":16,"word_count":232},"network-proxy-vpn-guide","代理与翻墙技术原理：从 HTTP 代理到现代协议","深入解析代理与 VPN 的本质区别，梳理从 SOCKS5 到 Shadowsocks、V2Ray\u002FXray、Hysteria2 的协议演进，以及机场订阅的技术本质。",[229,230,231],"网络","代理","协议",2148,{"slug":234,"title":235,"description":236,"pub_date":215,"tags":237,"draft":16,"word_count":151},"algorithm-binary-search","二分查找：永远写不对？记住这个模板","彻底搞清楚二分查找的边界问题：闭区间和左闭右开两套模板、三道经典 LeetCode 题目完整 C++ 实现，以及二分答案的进阶思路。",[142,238,239,11],"二分查找","leetcode",{"slug":241,"title":242,"description":243,"pub_date":215,"tags":244,"draft":16,"word_count":246},"algorithm-sliding-window","滑动窗口算法：从暴力到 O(n) 的思维跃迁","系统讲解滑动窗口算法的核心模板、适用题型，配合三道经典 LeetCode 题目的完整 C++ 实现，彻底理解双指针收缩思路。",[142,245,239,11],"滑动窗口",1943,{"slug":248,"title":249,"description":250,"pub_date":215,"tags":251,"draft":16,"word_count":254},"network-clash-config","Clash \u002F Mihomo 配置详解：规则、策略组与分流","深入解析 Clash\u002FMihomo 的核心配置结构，包括代理节点、策略组类型、规则优先级、DNS fake-ip 模式，以及一份实用的完整配置模板。",[229,252,230,253],"clash","配置",1292,{"slug":4,"title":5,"description":6,"pub_date":9,"tags":256,"draft":16,"word_count":257},[11,12,13,14,15],2039,{"slug":259,"title":260,"description":261,"pub_date":262,"tags":263,"draft":16,"word_count":266},"electron-ipc-types","Electron IPC 类型安全：从 any 到完全类型化","用 TypeScript 泛型封装 Electron IPC，彻底消灭 any，preload 契约集中管理","2026-04-25",[15,103,264,265],"ipc","vue",1446,{"slug":268,"title":269,"description":270,"pub_date":271,"tags":272,"draft":16,"word_count":275},"element-plus-popover-hide","手动关闭多个 el-popover（不用 v-model:visible）","通过 ref + Reflect.get 调用 hide() 方法手动关闭 Element Plus Popover，解释 Vue3 Proxy 导致无法直接调用实例方法的原因。","2024-10-25",[265,273,274],"element-plus","vue3",1321,{"slug":277,"title":278,"description":279,"pub_date":280,"tags":281,"draft":16,"word_count":285},"vite-vue3-ts-elementplus-pinia","用 Vite+（vp）从零搭建 Vue3 + TypeScript + Element Plus + Pinia + Vue Router","使用 Vite+ 统一工具链（vp）一条命令搭建 Vue3 全家桶，涵盖按需导入、Pinia store、路由配置，以及常见坑的解决方案。","2024-08-27",[265,282,103,273,283,284],"vite","pinia","vite-plus",1960,{"slug":287,"title":288,"description":289,"pub_date":290,"tags":291,"draft":16,"word_count":295},"cef-lnk2038-iterator-debug-level","CEF LNK2038：解决 _ITERATOR_DEBUG_LEVEL 不匹配错误","分析 CEF（Chromium Embedded Framework）集成时出现的 LNK2038 _ITERATOR_DEBUG_LEVEL 链接错误，从根本原因到解决方案的完整指南。","2024-05-07",[11,292,293,294],"CEF","Visual Studio","链接错误",1509,{"slug":297,"title":298,"description":299,"pub_date":300,"tags":301,"draft":16,"word_count":305},"npm-electron-install-fix","彻底解决 npm 安装 Electron 失败的问题","分析 npm install electron 失败的根本原因（下载二进制超时\u002F被墙），通过国内镜像（npmmirror）彻底解决，并介绍多种备选方案和常见错误排查。","2024-03-01",[15,302,303,304],"npm","前端工具链","国内镜像",1494,{"slug":307,"title":308,"description":309,"pub_date":310,"tags":311,"draft":16,"word_count":313},"git-out-of-memory","解决 git 报错：Fatal: Out of memory, malloc failed","分析 git 大仓库操作时出现 Out of memory malloc failed 的根本原因，通过调整 pack.windowMemory、http.postBuffer 和 git repack 彻底解决。","2024-01-31",[75,13,312],"工具",2244,{"slug":315,"title":316,"description":317,"pub_date":318,"tags":319,"draft":16,"word_count":323},"vmware-tools-install","在 VMware 虚拟机中安装 open-vm-tools 完整指南","详解 VMware Tools 的作用、open-vm-tools 与官方 VMware Tools 的区别，以及在 Ubuntu 虚拟机中安装并生效的完整步骤和常见问题排查。","2023-11-21",[320,13,321,322],"VMware","Ubuntu","虚拟机",2523,{"slug":325,"title":326,"description":327,"pub_date":328,"tags":329,"draft":16,"word_count":334},"load-balancing-algorithms","负载均衡算法完全指南：从轮询到一致性哈希","系统梳理静态与动态负载均衡算法，涵盖轮询、随机、权重、IP Hash、一致性 Hash、最少连接、最快响应等，并对比 Nginx、Dubbo、Spring Cloud LoadBalancer 的实现差异。","2023-11-15",[330,331,332,333],"分布式","负载均衡","Nginx","微服务",1764,{"slug":336,"title":337,"description":338,"pub_date":339,"tags":340,"draft":16,"word_count":344},"win-cw2a-ca2w","ATL 字符串转换：CW2A 与 CA2W 完全指南","详解 ATL 宏 CW2A\u002FCA2W 在 Unicode 与 ANSI 之间的字符串转换用法、头文件依赖、USES_CONVERSION 宏的作用与常见陷阱。","2023-06-09",[11,341,342,343],"windows","ATL","字符串",1665,{"slug":346,"title":347,"description":348,"pub_date":339,"tags":349,"draft":16,"word_count":353},"csharp-sendmessage-cpp","C# 通过 SendMessage 向 C++ 窗口发送消息与字符串","使用 P\u002FInvoke 调用 user32.dll 的 SendMessage，从 C# 发送自定义 WM_USER 消息及字符串指针给 C++ 原生窗口，并在 C++ 侧正确接收和转换。",[350,11,341,351,352],"C#","互操作","PInvoke",1554,{"slug":355,"title":356,"description":357,"pub_date":358,"tags":359,"draft":16,"word_count":361},"win-postmessage-vector","Windows PostMessage 跨线程传递 std::vector 指针","通过 PostMessage 在 Windows 消息队列中传递 std::vector 指针，使用 reinterpret_cast 将指针装入 LPARAM，并在接收方正确释放内存。","2023-05-26",[11,341,360],"WinAPI",1823,{"slug":363,"title":364,"description":365,"pub_date":358,"tags":366,"draft":16,"word_count":367},"exe-dll-single-package","将 EXE 和 DLL 打包成单一可执行文件","介绍两种将 exe 和依赖 dll 打包成单文件的方案：Enigma Virtual Box 和 WinRAR 自解压，适合发布 Windows 桌面程序时简化分发流程。",[341,11,312],1619,{"slug":369,"title":370,"description":371,"pub_date":358,"tags":372,"draft":16,"word_count":375},"cpp-random-mt19937","C++ 现代随机数生成：用 mt19937 彻底告别 rand()","深入讲解为什么 rand() 不够用，以及如何用 C++11 的 \u003Crandom> 库正确生成高质量随机数，涵盖 mt19937、各种分布和线程安全。",[11,373,374],"c++11","random",1549,{"slug":377,"title":378,"description":379,"pub_date":380,"tags":381,"draft":16,"word_count":383},"win-startup-registry","C++ 实现程序开机自启动：注册表方式详解","通过操作 Windows 注册表 Run 键实现程序开机自启动，包括 HKCU 与 HKLM 区别、完整封装代码、工作目录问题和 UAC 权限处理。","2022-12-26",[341,11,382],"registry",1201,{"slug":385,"title":386,"description":387,"pub_date":388,"tags":389,"draft":16,"word_count":391},"mfc-cstring-wparam","MFC 中 CString 与 WPARAM 之间的转换","详解 MFC 消息传递中 CString 无法直接强转为 WPARAM 的原因，以及两种正确的转换方案，并介绍结构体指针传递的正确姿势。","2022-11-25",[390,11,341],"mfc",1546,{"slug":393,"title":394,"description":395,"pub_date":396,"tags":397,"draft":16,"word_count":399},"duilib-static-build","正确编译 Duilib 静态库：避免 ATL 依赖和链接错误","详解如何用 DuiLib_Static.vcxproj 编译 Duilib 静态库，解决 VARIANT 未定义、Unicode 配置不匹配和 ATL 依赖等常见问题。","2022-08-24",[11,398,341,390],"duilib",2639,{"slug":401,"title":402,"description":403,"pub_date":404,"tags":405,"draft":16,"word_count":407},"mfc-dpi-adaptive","MFC 界面自适应不同分辨率","MFC 对话框程序实现控件和字体随分辨率自动缩放的完整方案，附 DPI Awareness 配置说明","2022-08-17",[390,11,341,406],"dpi",1414,{"slug":409,"title":410,"description":411,"pub_date":412,"tags":413,"draft":16,"word_count":414},"mfc-drag-window","MFC 无标题栏窗口客户区拖动：三种方法对比","MFC 对话框去掉标题栏后如何实现拖动移动窗口，三种方案完整实现与适用场景分析","2022-08-16",[390,11,341],1633,{"slug":416,"title":417,"description":418,"pub_date":419,"tags":420,"draft":16,"word_count":422},"algorithm-number-complement","整数的补数：位运算掩码解法","LeetCode 476 题，用掩码 XOR 实现整数补数，附 C++\u002FPython\u002FJava 三种实现及补数与补码的区别","2021-03-08",[142,421,239],"位运算",1374,[]]