Cindy Desktop 中 vendored drawio 查看器的集成、更新与源码级实现解析
【免费下载链接】cindy
Consider it done. The open-source AI agent that works out of the box · 想到,就能做到。开源、开箱即用的 AI Agent。
Cindy 桌面端的文件浏览器支持直接预览.drawio图形文件,而这一能力依赖仓库内apps/desktop/src/renderer/vendor/drawio/目录中随包分发的 drawio viewer。本文以该目录的 README 为主线,完整讲解 vendor 的边界与许可条款、版本号的自动解析方式、更新操作流程,并顺着 DrawioPreview.tsx 与 FileBodyView.tsx 的调用链,说明离线加载、data-mxgraph参数、零宽挂载陷阱以及与 Electron CSP 的相互制约。读完可以独立完成一次 viewer 升级,并能在预览失败时按源码路径定位问题。
为什么要以 vendor 方式内置 viewer
README 给出的定位非常明确:这是"用于在工作区文件浏览里预览.drawio文件的官方 viewer 脚本,直接 vendor 进仓库以避免运行时从外网拉取"。
这一决策同时满足三个约束:
- 离线可用:桌面端不能假设用户此刻能访问公网;
DrawioPreview.tsx的头部注释也重申了"viewer 离线工作(vendor 目录里有,Vite 通过?url出 URL),不联网"。 - 可审计的版本与许可:文件随安装包分发,版本与许可证必须写进第三方声明,因此版本号需要可被脚本解析,而不是靠人工记忆。
- 不污染主包体积:viewer 本体约 3.6MB(磁盘上
viewer-static.min.js实测约 3.7MB),若被静态 import 会直接进入 renderer 主 bundle;因此实现上改为首次使用时动态注入<script>。
目录构成与许可边界
vendor 目录只有三个文件,职责边界清晰:
| 文件 | 作用 | 是否随包分发 |
|---|---|---|
viewer-static.min.js | 上游发布产物,drawio 的只读 viewer 脚本 | 是(renderer 资源) |
LICENSE | Apache-2.0 全文,Copyright (c) JGraph Ltd | 是(合规材料) |
README.md | 来源、版本、许可、更新方式与使用入口说明 | 仓库内文档 |
README 记录的元信息可以直接在仓库中复核:
- 来源:上游 drawio 项目的发布产物
viewer-static.min.js。 - 版本:
30.0.4,"以 min.js 内嵌的VERSION字符串为准"。在 bundle 中确实存在mxClient={VERSION:"30.0.4",...}这样的内联常量,这也是自动化脚本唯一可靠的版本来源。 - License:Apache-2.0,见同目录
LICENSE。 - 附加限制说明:上游对其 icon set / stencil / 模板素材另有"未经书面许可不得用于 Atlassian 产品"的附加条款;本目录只 vendor viewer代码本体,不包含也不分发那些素材,因此该限制对本产品不适用。
这条"素材 vs 代码本体"的区分是合规上最容易被忽略的一点。仓库把它同时写进了两处:README 的说明段,以及生成脚本中bundledComponent追加的免责文字——"Only the viewer JavaScript (viewer-static.min.js) is redistributed. Upstream icon sets / stencils / templates carry an additional no-Atlassian-use restriction; none of those assets are redistributed by this product."(见 generate-third-party-notices.mjs)。
需要特别提醒:因为不随包分发 stencil 与 icon 素材,某些依赖上游素材库的自定义形状在预览时可能只显示基础图形。这是"只分发代码本体"的直接后果,而不是 viewer 加载失败。
版本号如何被自动解析进第三方声明
README 明确写了"版本号自动从 min.js 解析,无需手改"。这一承诺由 scripts/generate-third-party-notices.mjs 实现,逻辑很短但很关键:
// drawio viewer — vendored 进仓库的 .drawio 文件预览脚本(renderer 资源随包分发) try { const drawioDir = path.join(DESKTOP_DIR, "src", "renderer", "vendor", "drawio"); const drawioLicense = normalizeNoticeText( fs.readFileSync(path.join(drawioDir, "LICENSE"), "utf8"), ); const drawioVersion = /VERSION:"([\d.]+)"/.exec( fs.readFileSync(path.join(drawioDir, "viewer-static.min.js"), "utf8"), )?.[1] || "vendored"; entries.push( bundledComponent({ name: "drawio viewer (vendored)", version: drawioVersion, license: "Apache-2.0", licenseText: drawioLicense + "\n\nOnly the viewer JavaScript (viewer-static.min.js) is redistributed. " + "Upstream icon sets / stencils / templates carry an additional no-Atlassian-use " + "restriction; none of those assets are redistributed by this product.", }), ); } catch { /* vendored 目录被移除时自动跳过 */ }从源码可以读出几个工程约束:
- 版本提取是正则而非 JSON 解析:
/VERSION:"([\d.]+)"/匹配 bundle 里的VERSION:"30.0.4"。若上游改变压缩产物中的这段形态(例如改成单引号或变量间接赋值),正则失配会静默回退为字符串"vendored",声明仍能生成但版本信息退化。升级后务必检查生成的声明文本。 - 失败即跳过:整个块包在
try/catch中,LICENSE或 min.js 缺失时不抛错,只跳过该条目。这意味着如果有人误删 vendor 目录,构建不会失败,但产物会缺少一条许可声明——需要靠 review 或声明文件的 diff 发现。 - 许可全文被规范化后内嵌:
normalizeNoticeText负责统一换行与空白,最终写入docs/legal/notices/下的声明与 SBOM 文件,生成物头部标注"本文件由 scripts/generate-third-party-notices.mjs 自动生成,请勿手改"。
当前仓库中已提交的声明可以直接印证结果,例如 docs/legal/notices/THIRD-PARTY-NOTICES.txt 与各平台文件desktop-macos.txt、desktop-win.txt、desktop-linux.txt均包含:
drawio viewer (vendored) 30.0.4 License: Apache-2.0SPDX 清单 docs/legal/notices/sbom/desktop-linux.spdx.json 中同样有"name": "drawio viewer (vendored)"条目。至于 vendored 资源的许可归口说明,见 docs/legal/README.md 中把vendor/drawio/LICENSE归类为"vendored 前端资源的上游许可证,随资源分发"。
更新流程:替换文件 + 重新生成声明
README 的更新方式只有两句,但落地时建议按下面四步执行,避免只完成一半:
第一步:取新版产物。从上游仓库或 diagrams.net 发布产物中获取新版viewer-static.min.js。
第二步:覆盖同名文件。用新文件覆盖apps/desktop/src/renderer/vendor/drawio/viewer-static.min.js。注意保持文件名不变——DrawioPreview.tsx通过固定路径@/vendor/drawio/viewer-static.min.js?url引用它,改名会直接导致构建期解析失败。若上游 LICENSE 有变更,同步更新同目录LICENSE。
第三步:重新生成第三方声明。
pnpm licenses:generate该命令在根 package.json 中定义为node scripts/generate-third-party-notices.mjs,会重建docs/legal/notices/下的声明文本与 SBOM。版本号无需手改。
第四步:核对与回归。建议至少确认三件事:
# 1) 新产物里确实内嵌了版本串(正则的来源) grep -o 'VERSION:"[0-9.]*"' apps/desktop/src/renderer/vendor/drawio/viewer-static.min.js | head -1 # 2) 声明中版本已被刷新 grep -n "drawio viewer (vendored)" docs/legal/notices/THIRD-PARTY-NOTICES.txt # 3) 声明相关测试仍通过 node --test scripts/__tests__/third-party-notices.test.mjs第 1 步的输出应与声明中的版本一致(当前为30.0.4);若第 1 步为空,说明新版压缩形态不再匹配正则,声明会退化成vendored,此时应更新生成脚本中的正则而不是手改声明文件。
使用入口:从文件树到预览组件的调用链
README 只授权一个入口:"apps/desktop/src/renderer/features/cc-agent/workdir-browse/DrawioPreview.tsx—— 首次打开.drawio文件时动态注入<script>加载(约 3.6MB,不进 main bundle),后续复用。业务方不要直接 import 本目录。"
"业务方不要直接 import"是硬性约束:min.js 是 UMD 风格的全局脚本,没有 ES 模块导出,直接import既拿不到GraphViewer,还会把它打进主包。正确姿势只有经过DrawioPreview这一层封装。
文件类型识别
是否走 drawio 分支由 lib/drawioExt.ts 判定:
export function isDrawioPath(filePath: string): boolean { const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); const name = filePath.slice(lastSlash + 1); const dot = name.lastIndexOf('.'); const ext = dot < 0 ? '' : name.slice(dot).toLowerCase(); return ext === '.drawio'; }三个细节值得注意:
- 同时处理
/与\,因此 Windows 路径与 POSIX 路径都能识别; - 先剥离目录再找扩展名,避免目录名中带点号造成误判(例如
a.b/notes不会被当成有扩展名); - 大小写不敏感(
toLowerCase()),.DRAWIO同样命中。
文本分支截流而非二进制分支
FileBodyView.tsx 中的关键实现是:.drawio文件本质是 XML 文本(无 NULL 字节),因此内容读取会落在text分支;而用户期望看到的是图形而不是原始 XML,所以在进入PlaintextEditor/ markdown 渲染之前先把它"截走":
if (isDrawioPath(relPath)) { return ( <DrawioPreview workdir={workdir} relPath={relPath} xmlContent={content.content} size={content.size} mtimeMs={content.mtimeMs} /> ); }源码注释同时说明了为什么在binary分支刻意不处理.drawio:"理论上加密 drawio 才会进 binary,极罕见,退回UnrenderablePlaceholder即可。"
外部拖拽文件同样受支持
dropExternalFile.ts 把 drawio 与文本、图片、PDF 一起列入可预览类型:
export function isDroppedFilePreviewSupported(filePath: string): boolean { return ( isTextPreviewSupported(filePath) || isImagePath(filePath) || isPdfPath(filePath) || isDrawioPath(filePath) ); }这解释了为什么从系统文件管理器拖入一个.drawio也能直接预览——它复用同一套isDrawioPath判定,避免了"文件树里能预览、拖进来不能预览"的行为割裂。
渲染实现剖析
DrawioPreview的核心是一个useEffect加一个模块级 Promise。拆开看有五个关键设计点。
1. 懒加载与模块级 Promise 复用
// 模块级 promise 复用 —— 第一次注入 script 就开始 race,后续 mount 共享同一个 // resolve。失败时清空,允许下一次重试(网络盘读不到 / SHA 损坏等极端场景)。 let viewerLoadPromise: Promise<DrawioGraphViewer> | null = null; function loadViewerOnce(): Promise<DrawioGraphViewer> { if (window.GraphViewer) return Promise.resolve(window.GraphViewer); if (viewerLoadPromise) return viewerLoadPromise; viewerLoadPromise = new Promise<DrawioGraphViewer>((resolve, reject) => { const script = document.createElement('script'); script.src = viewerUrl; script.async = true; script.onload = () => { if (window.GraphViewer) { resolve(window.GraphViewer); } else { viewerLoadPromise = null; reject(new Error('drawio viewer loaded but GraphViewer is undefined')); } }; script.onerror = () => { viewerLoadPromise = null; reject(new Error('failed to load drawio viewer-static.min.js')); }; document.head.appendChild(script); }); return viewerLoadPromise; }设计意图逐条对应:
script.src = viewerUrl中的viewerUrl来自import viewerUrl from '@/vendor/drawio/viewer-static.min.js?url'。Vite 的?url后缀把文件作为独立资源发射并返回其 URL,而不是把源码内联进模块图——这就是"不进 main bundle"的实现方式。顺带说明,vite.renderer.config.ts 中自定义的assetsInlineLimit只对*-worklet.js返回false,其余资产维持默认阈值行为;3.6MB 的脚本远超阈值,本来也不会被内联。if (window.GraphViewer)是幂等短路:多次挂载不会重复插脚本。viewerLoadPromise作为模块级缓存,让并发打开多个.drawio标签页时共享同一次加载。- 失败时把缓存置
null,使下一次打开可以重试——注释举的例子是网络盘读不到、SHA 损坏等极端场景。 onload之后仍要再检查window.GraphViewer:脚本加载成功不等于全局对象就位,这种情况会明确抛drawio viewer loaded but GraphViewer is undefined,而不是留一个空白画布。
2.data-mxgraph参数:逐项说明
viewer 的对外契约是"一个带data-mxgraphJSON 的容器 div",DrawioPreview按此构造:
const host = document.createElement('div'); host.className = 'mxgraph'; host.style.maxWidth = '100%'; host.setAttribute( 'data-mxgraph', JSON.stringify({ xml: xmlContent, highlight: '#0000ff', nav: true, resize: true, 'dark-mode': 'auto', 'check-visible-state': false, }), ); container.appendChild(host); viewer.createViewerForElement(host);| 参数 | 取值 | 作用与影响 |
|---|---|---|
xml | 文件原文(由父层useFileContent读出) | 图形数据源。直接内联,避免预览器二次读取文件 |
highlight | '#0000ff' | 高亮色(用于搜索/定位等交互反馈) |
nav | true | 打开平移、缩放等导航交互 |
resize | true | 容器尺寸变化时自适应重排 |
dark-mode | 'auto' | 跟随 OS/应用主题;注释说明"浅色仍是浅图,深色用对应反色" |
check-visible-state | false | 关键项,见下一节 |
3.check-visible-state: false:React 动态挂载下的零宽陷阱
源码给出了这个参数存在的真实原因,值得完整引用其含义:
drawio 默认会检查
host.offsetWidth,0 宽时挂MutationObserver等 parent attribute 变化才渲染。React 动态挂载 + flex 布局下,host 刚挂上去就是 0 宽,parent attribute 也不会再变,observer 永远等不到 → viewer 静默卡住不渲染。设为false让 drawio 立即渲染,布局到位后 resize handler 会自适应宽度。
也就是说,这个参数是"避免永久白屏"的必需项,而非性能优化。它同时解释了为什么外层容器用了flex min-h-full items-center justify-center p-6且内层ref容器是max-w-full——布局由 React 侧负责,绘制时机由 viewer 侧立即触发,两者解耦。
4. 用createViewerForElement而不是processElements
类型声明里对两个 API 的差异写得很直白:
interface DrawioGraphViewer { /** drawio 源码里这个参数是 class name 字符串(默认 'mxgraph'),不是 scope 元素。 * 传 DOM 元素会被 toString 成 "[object HTMLDivElement]" → 找不到任何元素 → 静默失败。 */ processElements: (className?: string) => void; /** 给单个已含>useEffect(() => { let cancelled = false; const container = containerRef.current; if (!container) return; container.replaceChildren(); setState({ kind: 'loading' }); (async () => { try { const viewer = await loadViewerOnce(); if (cancelled) return; // ...构造 host 并渲染 if (!cancelled) setState({ kind: 'rendered' }); } catch (err) { if (cancelled) return; log.warn('drawio render failed', { relPath, error: String(err) }); setState({ kind: 'error', message: String(err) }); } })(); return () => { cancelled = true; }; }, [workdir, relPath, xmlContent]);要点:
- 清理旧画布:
container.replaceChildren()在每次 effect 执行时先清空,避免切换文件后残留上一张图。 - 异步取消:
cancelled标志防止组件卸载或快速切换文件后对旧实例setState。 - 重渲染触发条件:依赖数组是
[workdir, relPath, xmlContent],因此文件内容变化(外部编辑保存)会触发重绘,而size/mtimeMs只用于降级占位卡展示,不参与绘制。 - 降级路径:出错时渲染 UnrenderablePlaceholder,透传
workdir、relPath、size、mtimeMs,让用户仍能通过"在系统中打开"等方式处理文件,而不是只看到一个报错。 - 已知限制:
replaceChildren()直接丢弃旧 DOM,没有调用 viewer 的销毁接口;源码中createViewerForElement的返回类型也只是unknown。从代码结构看,频繁切换多个大型.drawio文件时可能存在 viewer 内部对象留存,这属于可观察的推断而非已确认的问题。
加载态还有一处 i18n 文案:ccAgent.workdirBrowse.fileBody.drawioLoading,在 zh-CN/common.json 中为"正在加载 draw.io 预览…",同时提供了defaultValue兜底,因此即使 locale 缺键也不会露出 key 名。
为什么不 fetch 文件:xdt-file://与 CORS
DrawioPreview.tsx的头部注释解释了它为什么坚持"只接收xmlContentprop",而不是像图片预览那样自己去读文件:
文件内容(XML 文本)由父层
FileBodyView经useFileContent已读好,直接传xmlContentprop。不走fetch(xdt-file://...)—— 该协议corsEnabled: false,fetch API 会被跨源拦掉(TypeError: Failed to fetch),同时也省一次冗余读取。
仓库里可以找到对应的协议注册证据:schemePrivileges.test.ts 断言各自定义 scheme(含xdt-file)的权限集合为standard: true、secure: true、supportFetchAPI: true、bypassCSP: false、corsEnabled: false。其中corsEnabled: false正是 renderer 侧跨源fetch被拒的原因;测试注释也提到该组合对xdt-model(<model-viewer>fetch)与xdt-file(FBXLoader / drawio viewer fetch)是"load-bearing"的。
这里有一个值得注意的实现取向差异:协议层保留了supportFetchAPI: true,但 drawio 预览的实际代码路径选择完全绕开fetch,改由已经读过内容的上层直接传字符串。好处是一石二鸟——既避开 CORS 限制,又省掉一次磁盘或远端读取。这也解释了为什么DrawioPreviewProps里xmlContent: string是必填而非可选。
与 Electron 安全边界的相互作用
内置一个 3.6MB 的第三方脚本,不只是打包问题,还牵动 CSP。
script-src中的'unsafe-eval'
apps/desktop/src/main/security/csp.ts 明确记录了这条权衡:
const scriptSrc = isDev ? ["'self'", "'unsafe-inline'", "'unsafe-eval'", "'wasm-unsafe-eval'"] : ["'self'", "'unsafe-eval'", "'wasm-unsafe-eval'"];注释说明:vendored drawio viewer "calls real JS eval() from withincreateViewerForElementand runs in THIS renderer; without 'unsafe-eval' any.drawiofile preview deterministically throws EvalError and degrades to the unrenderable placeholder"。同时强调这是有意为之的取舍——'unsafe-eval'只让代码能到达已存在的eval()sink,而注入内联脚本与远程脚本这两条主路径在 prod 中依然被堵死:script-src中没有'unsafe-inline',也没有远程源。
对应的回归断言在 csp.test.ts:
it('script-src blocks inline + remote script (the primary XSS hardening) but allows eval for vendored drawio', () => { const d = parsePolicy(buildContentSecurityPolicy(PROD_CTX)); expect(d['script-src']).toEqual(["'self'", "'unsafe-eval'", "'wasm-unsafe-eval'"]); expect(d['script-src']).not.toContain("'unsafe-inline'"); expect(d['script-src']).toContain("'unsafe-eval'"); });这条测试是"升级 viewer 时不要顺手删'unsafe-eval'"的护栏:一旦删除,.drawio预览会稳定失败并降级为占位卡,而其他功能看起来一切正常,很容易被误判为 viewer 版本问题。源码注释还写明了后续收紧方向:把 drawio 移入独立<iframe sandbox>并配置自有 CSP,从而让 prod 可以移除'unsafe-eval'。
'self'与"内置"的双向约束
prod 的script-src只有'self',没有远程源。这与 vendor 决策互为因果:脚本必须与 renderer 同源,因此不能改回"运行时从 CDN 拉取",否则会被 CSP 直接拦截。
style-src中的字体域
同一份 CSP 的style-src包含https://fonts.googleapis.com,注释写的原因是"drawio viewer loads Google Font stylesheets for sketch-font diagrams"。这意味着在完全离线的环境下,手绘风格(sketch)图元的字体会回退,但样式表加载失败不会阻断图形绘制。定位这类"图能出来、字体不对"的反馈时,可以先区分是离线字体回退还是 viewer 未加载——后者会走UnrenderablePlaceholder,前者不会。
常见问题排查表
| 现象 | 首要怀疑点 | 源码定位 |
|---|---|---|
打开.drawio显示原始 XML | 走了 text 分支,isDrawioPath未命中 | drawioExt.ts、FileBodyView.tsx |
| 预览区域空白但无报错 | check-visible-state为真导致零宽不渲染;或误用processElements传了 DOM 元素 | DrawioPreview.tsx |
控制台出现EvalError | CSPscript-src缺少'unsafe-eval' | csp.ts、csp.test.ts |
| 提示 "drawio viewer loaded but GraphViewer is undefined" | 脚本加载成功但全局对象未就位(产物异常/被拦截) | loadViewerOnce的onload分支 |
| 提示 "failed to load drawio viewer-static.min.js" | 资源 URL 失效或文件缺失 | ?url导入与 vendor 目录 |
TypeError: Failed to fetch | 试图对xdt-file://做fetch | schemePrivileges.test.ts、DrawioPreview.tsx头部注释 |
声明中版本变成vendored | 新版 bundle 不匹配VERSION:"x.y.z"正则 | generate-third-party-notices.mjs |
| 部分自定义形状不显示 | 未分发上游 stencil / icon 素材 | README 的附加限制说明 |
| 手绘字体样式异常 | 离线无法访问fonts.googleapis.com | csp.ts 的style-src |
排查时的通用起点是DrawioPreview的日志:失败分支会调用log.warn('drawio render failed', { relPath, error }),logger 名称为drawio,可据此在日志中过滤。
维护清单:改动这类代码时该守住什么
- 保持唯一入口:外部只经
DrawioPreview,不要让业务组件直接importvendor 目录(README 明确要求)。若需要在别处预览 drawio,复用isDrawioPath+ 组件,而不是再写一套 loader。 - 坚持
?url懒加载:任何改成静态import的写法都会把 3.6MB 塞进主包,违背 README 记录的设计意图。 - 坚持传入
xmlContent:不要再引入fetch(xdt-file://...),corsEnabled: false决定了它必然失败。 - 升级后三件事:核对
VERSION串、跑pnpm licenses:generate并检查声明 diff、手动打开一个含手绘风格与自定义形状的.drawio验证渲染。 - 不要删
'unsafe-eval':除非完成 iframe 隔离方案,否则会直接破坏预览功能。
延伸阅读
- vendor 目录说明与许可边界:
apps/desktop/src/renderer/vendor/drawio/README.md、apps/desktop/src/renderer/vendor/drawio/LICENSE - 预览组件与类型声明:
apps/desktop/src/renderer/features/cc-agent/workdir-browse/DrawioPreview.tsx - 文件类型判定与路由:
apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/drawioExt.ts、apps/desktop/src/renderer/features/cc-agent/workdir-browse/FileBodyView.tsx - 拖拽入口:
apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts - 合规生成与产物:
scripts/generate-third-party-notices.mjs、docs/legal/notices/THIRD-PARTY-NOTICES.txt、docs/legal/README.md - 安全边界:
apps/desktop/src/main/security/csp.ts、apps/desktop/src/main/__tests__/schemePrivileges.test.ts
【免费下载链接】cindy
Consider it done. The open-source AI agent that works out of the box · 想到,就能做到。开源、开箱即用的 AI Agent。
相关推荐
告别手动升级!drawio-desktop自动更新系统全解析:Electron-updater实战指南
告别手动升级!drawio desktop自动更新系统全解析:Electron updater实战指南 drawio desktop是一款基于Electron构
桌面应用图形学Cindy 桌面端 Windows 独立更新器 cindy-updater 实战解析:CLI 契约、更新阶段与失败重试机制
Cindy 桌面端 Windows 独立更新器 cindy updater 实战解析:CLI 契约、更新阶段与失败重试机制 cindy updater 是 Ci
SumatraPDF 仓库中的 MuPDF Android 集成指南:从嵌入查看器到源码级定制
SumatraPDF 仓库中的 MuPDF Android 集成指南:从嵌入查看器到源码级定制 本篇技术指南以 MuPDF 官方 Android 使用文档( e
桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考