从作者 GitHub 原版 1.1.8 出发,到当前这个「能给家里人用」的版本。38 条改动 —— 每一次功能增加、bug 修复、安全加固都记在这里,方便回头看清「改了什么、为什么改、改完什么样」。
从「什么都存不进去」到「家里人日常能用」。
按版本节点看演进。1.2.4 / 1.2.5 / 1.2.6 三个号没单独发过,最后一起打成了 26.9.17。
| 阶段 | 版本 | 日期 | 主要内容 | 产物目录 |
|---|---|---|---|---|
| 起点 | 1.1.8 | 作者原版 | GitHub 用户 HandsomeMJZ 的 Electron 客户端。只读参考。 | — |
| 一 | 1.1.8 | 2026-09-14 | 修 403(能用了)、便携版数据目录、搭测试底座 | release-portable |
| 二 | 1.1.9 | 2026-09-16 | PDF/Word/Excel 预览 + 解除 32MB 上限、Office HTML 清洗、去作者痕迹 | release-portable-final |
| 三 | 1.2.0 | 2026-09-16 | 冒烟测试落地、corsEnabled 修复、外链防护 |
release-portable-r2 |
| 四 | 1.2.1 ~ 1.2.3 | 2026-09-16 | 图标字体本地化、正文改系统字体、真实应用启动验证、灰字提示统一模板 | -r3 / -r4 / -r5 |
| 五 | 1.2.4 | 2026-09-16 | 同步丢文件修复、两条卡死路径、Office 锁文件、时钟偏差、PPTX 预览 | 未单独发布 |
| 六 | 1.2.5 ~ 1.2.6 | 2026-09-16 | 配置原子写、单实例锁、覆盖串行化、回收站与版本历史(接口层) | 未单独发布 |
| 七 | 26.9.17 | 2026-09-17 | 首次正式发布(含前面全部 29 条改动),版本号改日期制 | release-portable-26.9.17 |
| 八 | 26.9.17-2 | 2026-09-17 | 回收站进侧边栏、整目录打包下载、版本历史界面、断点续传、CSP | -26.9.17-2b |
| 九 | 26.9.27 | 2026-09-27 | 补齐缺口 36(大文件也有版本历史)+ 重新打包 | release-portable 📌 固定 |
| 十 | 26.9.29 | 2026-09-29 | 回收站提示去掉空头承诺(改动 38)+ 重新打包 | release-portable 📌 固定 |
38 条全列在这里。「影响面」三列表示这个改动动了哪一层。
| # | 事项 | 类型 | 版本 | 渲染 | 主进程 | 测试 |
|---|---|---|---|---|---|---|
| 1 | 修写操作 403 不修等于不能用 | 修复 | 1.1.8 | — | ✅ | ✅ |
| 2 | 便携版数据目录 拷 exe 就带走配置 | 新增 | 1.1.8 | — | ✅ | — |
| 3 | 测试底座 假服务端 + API/同步测试 | 测试 | 1.1.8 | — | — | ✅ |
| 4 | 同步稳定阈值 2 秒 → 5 秒 | 调整 | 1.1.9 | — | ✅ | — |
| 5 | 修「自己传的文件被当成云端更新」 | 修复 | 1.1.9 | — | ✅ | — |
| 6 | 429 限速提示翻译成人话 | 体验 | 1.1.9 | — | ✅ | — |
| 7 | 预览扩展 + 解除 32MB 上限 PDF/Word/Excel,走自定义协议 | 新增 | 1.1.9 | ✅ | ✅ | ✅ |
| 8 | Office 预览的 HTML 清洗 防文档里藏脚本 | 加固 | 1.1.9 | ✅ | — | ✅ |
| 9 | 移除作者「关于」面板 | 清理 | 1.1.9 | ✅ | — | — |
| 10 | 移除「软件更新」面板 会咬人:装回原版覆盖定制 | 清理 | 1.1.9 | ✅ | ✅ | — |
| 11 | 冒烟测试落地 在真实 Electron 里跑 | 测试 | 1.2.0 | ✅ | ✅ | ✅ |
| 12 | 修 corsEnabled 漏写Word/Excel 预览整体失效 | 修复 | 1.2.0 | — | ✅ | ✅ |
| 13 | 外链防护(两个导航守卫) | 加固 | 1.2.0 | — | ✅ | ✅ |
| 14 | 图标字体本地化 不再等 Google Fonts | 加固 | 1.2.1 | ✅ | — | ✅ |
| 15 | 正文字体改走系统 | 加固 | 1.2.1 | ✅ | — | — |
| 16 | 清作者域名残留 | 清理 | 1.2.1 | ✅ | — | — |
| 17 | 真实应用启动验证 假 preload 跑真实界面 | 测试 | 1.2.2 | ✅ | ✅ | ✅ |
| 18 | 地址灰字提示统一为模板 | 清理 | 1.2.3 | ✅ | — | ✅ |
| 19 | 修复同步会丢文件 唯一能真丢本地文件的路径 | 修复 | 1.2.4 | — | ✅ | ✅ |
| 20 | 修两条「同步卡死但不报错」 | 修复 | 1.2.4 | — | ✅ | ✅ |
| 21 | Office 锁文件检测 | 新增 | 1.2.4 | — | ✅ | ✅ |
| 22 | 时钟偏差修正 | 新增 | 1.2.4 | — | ✅ | ✅ |
| 23 | 同步跳过原因细分 | 体验 | 1.2.4 | ✅ | ✅ | — |
| 24 | PPTX 预览 零依赖,不引 JSZip | 新增 | 1.2.4 | ✅ | — | ✅ |
| 25 | 配置原子写 + 备份恢复 配置坏了不再静默清空 | 修复 | 1.2.5 | — | ✅ | ✅ |
| 26 | 单实例锁 | 修复 | 1.2.5 | — | ✅ | — |
| 27 | 云端覆盖本地串行化 | 修复 | 1.2.5 | — | ✅ | ✅ |
| 28 | 接上回收站 | 新增 | 1.2.6 | ✅ | ✅ | ✅ |
| 29 | 接上版本历史(接口层) | 新增 | 1.2.6 | — | ✅ | ✅ |
| 30 | 版本号改日期制 + 打包发布 | 流程 | 26.9.17 | — | — | — |
| 31 | 回收站从弹窗搬进侧边栏 | 改造 | 26.9.17-2 | ✅ | — | ✅ |
| 32 | 整目录打包下载 | 新增 | 26.9.17-2 | ✅ | ✅ | ✅ |
| 33 | 版本历史接上界面 + 60MB + 只留 5 版 | 新增 | 26.9.17-2 | ✅ | ✅ | ✅ |
| 34 | 断点续传 | 新增 | 26.9.17-2 | — | ✅ | ✅ |
| 35 | 加 CSP | 加固 | 26.9.17-2 | ✅ | — | ✅ |
| 36 | 补齐缺口 36 大文件(含教学 PPT)也有版本历史 | 修复 | 26.9.27 | — | ✅ | ✅ |
| 37 | 修掉冒烟测试里两条失效断言 | 测试 | 26.9.17-2 | — | — | ✅ |
| 38 | 回收站文案去掉空头承诺 原文案写「服务端保留 30 天…超过 30 天后由服务端自动清理」,实际既无定时任务、也无「清理 N 天前」入口 —— 改成实话 | 修复 | 待打包 | ✅ | — | — |
renderer.js、index.html);
主进程 = 管文件、网络、窗口的那一层(main.js、apiClient.js、backupManager.js);
测试 = tools/ 下的验证脚本。测试那一列打勾多,是因为很多改动是先写测试、再改代码。
按时间顺序。每条都写了「问题是什么」「怎么改的」「改完什么样」。次要的条目合并在一条里讲。 「怎么改的」和「技术要点」默认收起 —— 想学细节,点标题就展开。
GitHub 用户 HandsomeMJZ 的 Electron 桌面客户端。主界面、同步框架已经能用,所以决定「最小改造」而不是重写。
%APPDATA% —— exe 拷到 U 盘后配置不跟着走,便携就没意义了。HTTP 429:{"ok":false,...} 原始文本,看不懂要等多久。当时手上还有自己写的「阿鹏云盘」。那套有账本 + 删除同步 + 云端拉取目录三件套, 复杂、难维护,两套部署指向同一对目录时还会互相拉扯。这个 GitHub 客户端的主界面和同步框架已经能用, 最小改造比重写划算。
客户端能登录、能列目录,但新建文件夹、上传文件、保存同步任务全部返回 403 CSRF check failed。
查下来不是客户端代码写错了,是服务端有防护、客户端没配合。
服务端对 /api/* 的非安全方法(POST / PUT / DELETE)做同源校验,
靠 Origin / Referer 判断请求是不是从自己的网页发出来的。
浏览器会自动带这两个头,但 Electron 主进程用 Node 发请求,不会带 —— 于是被当成跨站攻击拦掉。
而 /api/login 不做这个校验,所以症状特别有迷惑性:
能登录、能看目录,就是什么都存不进去。
cookieHeaders() 改名成 authHeaders(),统一在里面对同源请求附加 X-R2Drive-CSRF: same-originheadersForRedirect() 会把这个头去掉上传、新建文件夹、保存同步任务全部恢复正常。新增回归测试 tools/verify-api-client.js:
内置一个假服务端复刻 401/403 规则,既测「修复后能过」,也测「还原成旧请求头确实会被 403 拦」(对照组)。
Origin → Referer → Sec-Fetch-Site),
但 Electron 三个都不带,仍然走同一个回退分支。
两件事一起做:数据目录跟着 exe 走;同时建起一套能重复跑的自动化验证 —— 后面所有测试都挂在它上面。
%APPDATA%,exe 拷到 U 盘后是「干净」的 ——
要重新登录、重新配同步任务,便携就没意义了便携数据目录:electron-builder 打 portable 包时会注入 PORTABLE_EXECUTABLE_DIR(exe 所在目录)。
检测到它就 app.setPath('userData', path.join(dir, 'data'))。
留了开关 R2DRIVE_PORTABLE_DATA=0 可关闭;目录不可写时(比如只读 U 盘)自动回退到默认位置。
测试底座:新增 tools/mock-server.js(Node 原生 http 起的假服务端,
复刻真实的 CSRF 校验规则、401/403 响应、目录索引、上传/下载接口),
以及 verify-api-client.js、verify-backup-sync.js。
exe + data/ 一起拷走,配置跟着走,真正便携。测试这边有了可重复的自动化回归 ——
这个底座一直用到今天,后续所有验证脚本都挂在它上面。
mtime 回拨,
否则会被「仍在写入」的判断跳过 —— 这个坑后来每次写同步测试都要记得。
新增 PDF / Word / Excel 预览,同时取消预览的 32MB 硬上限,大视频能直接播、能拖进度条。 这两件事在技术上是同一个改法。
主进程:用 protocol.registerSchemesAsPrivileged() 把 r2drive 注册成特权协议
(standard / secure / supportFetchAPI / stream / bypassCSP),
必须在 app ready 之前调用。然后 protocol.handle('r2drive', ...) 把 URL 解出远端路径,
带上 Cookie 转发到服务端的 /api/download?inline=1,
原样透传状态码、Content-Type、Content-Range、Accept-Ranges。
渲染层:图片/视频/音频/PDF 一律直接引用协议 URL —— 不下载、不占内存、无上限、可拖进度条;
元素出错时自动退回老的 base64 预览(≤32MB)作为兜底。
Word 用 mammoth 解析 docx;Excel 用 SheetJS 解析,带多工作表页签;
文本/代码预览改成流式读取,最多读 2MB(原来无上限地读整个文件)。
顺手做的一件必须做的事(改动 8):新增 sanitizeHtml(),
对 mammoth / SheetJS 解析出来的 HTML 做白名单清洗。因为那段 HTML 来自用户上传的文档,
直接 innerHTML 等于把文档里藏的 <script>、onclick 搬进渲染进程执行 ——
而渲染进程能调 window.r2Drive 删你的文件。
script / style / iframe / object / embed /
form / input / link / meta / svg / math,
所有 on* 事件属性,以及 javascript: / vbscript: / data:text/html 开头的 URL。
保留的:正文、表格结构、data:image 内嵌图。
window.__PWNED__ 仍是 undefinedDecompressionStream(见改动 24)。csv 保持纯文本预览;只有 docx 走 mammoth,旧版二进制 .doc / .odt 降级为下载 ——
mammoth 读不了它们,硬走会报错。作者留了个「检查更新」功能,更新源指向他自己的仓库。保留的话,它会提示你「有新版本」, 而你点下载,装回来的是官方原版。
装回官方原版意味着:预览扩展、403 修复、便携数据目录全部丢失, 直接退回那个「什么都传不上去」的状态。而且用户很难意识到「更新」是罪魁祸首 —— 他会以为是「新版本有 bug」。
整块移除,涉及 7 个文件:index.html(面板 + 弹窗)、
renderer.js(state.update、8 个元素引用、6 个事件监听、9 个函数)、
main.js(3 个常量、2 个变量、15 个函数、3 个 IPC 通道)、
preload.js(4 个接口)、apiClient.js(3 个配置项)、styles.css(.update-* 全部规则)。
删除时特别留意别误删名字近似的东西:.sync-update-* 和 #syncUpdateModal
是多端文件同步的弹窗,跟软件更新无关;updateSelectionVisuals / updateActionBar /
updateFileViewModeTools 是「更新 UI 状态」的函数,也无关。这些全部保留。
定制版不会再被官方版覆盖。删除后做了全量扫描确认无残留。
顺带发现一个上游遗留问题:styles.css 全文括号差值是 -1(第 3924 行有个多余的 })——
用打包好的 asar 里的旧文件对比确认,这是改动前就存在的,浏览器会忽略多余右括号,行为无变化,所以没动它。
前面所有测试都是纯 Node 跑的,碰不到真实浏览器环境。
而客户端最关键的两条链路(自定义协议、Office 解析)恰恰只在真实 Chromium 里才成立。于是补了 npm run smoke。
「代码写对了」不等于「功能可用」。下面这个 bug 在源码层面完全看不出来, 是真实环境测试抓出来的。
corsEnabled 漏写protocol.registerSchemesAsPrivileged() 里的 corsEnabled
在 Electron 里默认是 false,而原来没写。后果是渲染层
fetch('r2drive://file/xxx') 直接 TypeError: Failed to fetch,
请求连 protocol.handle 都到不了(协议层日志里一条记录都没有)。
症状极其隐蔽 —— <img> / <video> / <audio> / <embed>
用的是 no-cors 请求,完全不受影响。所以图片、视频、音频、PDF 预览全都正常,
只有 Word / Excel 预览整体失效(那条链路是 fetch → ArrayBuffer → mammoth/SheetJS,卡在第一步)。
换句话说:改动 7 加的 Office 预览,在打包版里其实是坏的。
做了受控实验:同一份代码,只切换 corsEnabled 有无,在三种 origin(file:// /
http://127.0.0.1 / r2drive://app)下测。结果一致:缺了就是 Failed to fetch、handler 命中 0 次;
补上就是 200、命中 2 次。修复本身就是补一行,同时加了源码守卫断言防止回归。
冒烟测试本身也踩了两个环境坑(都不是脚本的问题):
--no-sandbox 时连 about:blank 都是 ERR_FAILED。
所以「脚本不执行」其实是「页面根本没加载」loadFile() 不做百分号编码,改用 pathToFileURL()previewUrl() 和 sanitizeHtml()
是从 renderer.js 里按标记串切片抽出来再注入渲染层的 —— 测的就是用户真正在跑的那份代码。corsEnabled 默认 false、console-message 的回调签名会变 ——
「没写」和「写错」一样致命,而且没写的那个连报错都没有。console.log 不进父进程输出,报告必须落盘再读。
云端覆盖本地那段,原来的顺序是先 rm 删掉本地文件、再 rename 把新文件换上去。
只要 rename 失败,本地文件就永久消失了。
rename 会失败的情形一点都不罕见:杀毒软件锁着、磁盘满、路径被别的程序占用。
更糟的是失败分支里还把临时文件也删了 —— 等于连最后一份数据都不留。
<同步根>/.r2sync-backup/<相对路径>.<时间戳>.r2sync-*.tmp 里把内容捞回来就算判断错了(时钟偏差、两台机器同时改),用户有后悔药。 这是「覆盖前留档 + 原子替换 + 失败回滚」这个模式的第一次应用,改动 22 又用了一次, 改动 25 把它用到了配置文件上。
用户原话:「现在客户端还没办法预览 pptx 吗?这个很重要平时用得最多。」 教学课件大量是 pptx。于是自己写了一个解析器(约 380 行),没有引入任何第三方库。
pptx 本质是个 ZIP 包,而 Chromium 已经内置了解压能力
(DecompressionStream('deflate-raw')),没必要为此引入 JSZip。
0x06054b50,注释区最长 65535 字节),再读中央目录(0x02014b50).rels 里的 Target 是相对路径(如 ../media/image1.png),
要解析成 zip 内的绝对路径;TargetMode="External" 的外链图片要排除presentation.xml 里的 sldIdLst,
读不到时按 slideN 的数字排序(保证 slide2 排在 slide10 前面)textContent,绝不拼 innerHTMLpptx 能预览了,且不增加任何打包体积(没引入库)。旧版二进制 .ppt 仍然只能提示「另存为 .pptx」。
测试用手搓的 stored ZIP 验证解析器,含「文档里写的 <script> 只会是文字」和「slide2 排在 slide10 前」这类边界。
DecompressionStream 已经能解 deflate,
省掉一个库 = 省掉一份体积、一份依赖风险。这套思路可以复用 —— 以后要做 docx 内嵌图、xlsx 图表,接着用。'slide10' < 'slide2' 是 true ——
这类「字符串排序」的坑在文件列表、版本号上都会再遇到。
这是和改动 19 同一类的毛病,只是这次丢的是配置。原来的
writeFileSync(configFile, ...) 动作是「打开 → 清空 → 写」,中间断电、崩溃、磁盘满,
config.json 就变成半截 JSON。
而 loadConfig() 解析失败时只 console.warn 一句,
然后静默退回全部默认值。后果比看起来严重:config.json 里装着
全部同步任务(本地目录 ↔ 云端目录的对应关系)、下载目录、会话、所有设置。
文件一坏,任务列表直接清空,同步悄悄停摆,界面上没有任何报错 —— 用户只会觉得「怎么不传了」。
config.json.tmp,再 renameSync 整体覆盖(同盘 rename 是原子的)config.json.bak.tmp 清掉,原配置保持不动loadConfig() 依次尝试 config.json → config.json.baknull / [1,2,3] 一律视为坏文件.bak 恢复后要回写主文件,
而「覆盖前先备份上一份」这一步会把当前那个损坏的主文件复制成 .bak,
把刚救回来的唯一好备份顶掉。
修法:备份前先确认原文件真的是可用配置(新增 isUsableConfigFile())。
null 和数组都能 parse,但都不是合法配置。格式校验要按语义,不按语法。
服务端早就有回收站(删除是「先把条目快照进回收站,R2 对象仍被引用着,保留 30 天」), 但客户端没有入口 —— 删掉的东西只能去网页端找回来。对老师来说,「误删了教学材料」是最要命的情况。
第一次接上时做的是「顶栏一个圆按钮 + 弹窗」。用户看了之后说: 「回收站很好用,需要直观一点,可以直接放左边侧边栏。」
这句话背后其实是一条放置原则,后来被固定下来:
接上服务端(1.2.6):契约从服务端源码读出来,不照猜 ——
GET /api/trash 拿列表、POST /api/trash/restore 传 {ids}、
POST /api/trash/purge 支持 {ids} / {olderThanDays} / {all:true} 三种模式。
读源码读出一条关键语义:三种模式在服务端是按顺序判的,客户端绝不能把两种一起发出去 —— 这点单独写了断言钉住。
搬进侧边栏(26.9.17-2):侧边栏本来就是「视图切换」结构(button.nav-item[data-view]),
加第 7 项顺着现有做法走。新增 <section id="trashView">,内部结构照抄其他视图;
列表项复用原来的样式。旧入口全部删掉(圆按钮、弹窗、关闭按钮),不留两处入口。
purge 三种模式不能混着发」这条,
光看接口文档根本不会知道 —— 但发错了会清掉不该清的东西。kind 统一成 file/folder、size 转数字,
过滤掉没有 id 的脏数据 —— 否则界面上会出现一行点不动也删不掉的幽灵项目。textContent 渲染(可能是别人上传的文件,名字里可能带脚本)。分三步走完,第三步最值得记:界面做完了、测试全绿、功能「看起来」好了, 但查服务端源码才发现 —— 大于 512KB 的文件压根不存版本。
家人共用 + 自动同步 = 最容易「内容被改坏」的组合。 文件被覆盖上传之后,文件本身还在、回收站里什么都没有 —— 这时候只有版本历史能救。 所以它比回收站更必要,不是更不必要。
listVersions / restoreVersion / deleteVersion。
契约同样从服务端源码读出。客户端再排一次序(按 ts 倒序),不依赖服务端返回顺序上限怎么从 20MB 变成 60MB:worker 里 VERSION_MAX_MB = 20 是硬编码的,
但接口支持客户端传参覆盖。教学 PPT 大多超 20MB,走默认等于没有保护 →
客户端传 60,只改客户端,不用动服务端。
「只留 5 版」怎么实现的:worker 的 VERSION_KEEP_MAX = 10 同样硬编码且不可配。
于是客户端在上传完成后自己收:pruneVersions() 列出旧版本,slice(5) 之后的逐个删。
里面有个去重集合 versionPruneSeen:同一路径在一个 app 生命周期里只查一次,否则批量同步会把请求数翻倍。
snapshotVersion()(覆盖前存旧版本)只在另外两条路径上挂了钩子,分布式上传那条没有。
意味着:大于 512KB 的文件根本没有版本历史,界面能开、能列出旧版本,但列表永远是空的。
/api/distributed/init 接收 versionMaxMB 并存进 session/api/distributed/complete 在覆盖写入前调 snapshotVersionversionMaxMB(普通上传 / multipart / distributed 三处统一 60MB)文件被改坏能自己救回来 —— 这是家人共用场景下最要紧的一条。 而且现在教学 PPT 那种大文件也真的有版本历史了。
另外一个刻意的设计:「恢复」直接执行,没有二次确认。 理由是「恢复」本身可逆(再恢复一次就行),弹确认框只会让人多点一次; 真要防误操作,防的是删除,不是恢复。
选中文件夹(或一批文件)→ 右键「打包下载」,服务端流式吐一个 zip,客户端原样落盘。
家人下载整套资料是最高频的操作。原来只能一个一个点,几十个文件要等几十次。
POST /api/download-zip,body { paths: [...], base: '父目录' }。
base 的作用是决定zip 内部的条目路径:base 是父目录时,
解压出来就是「文件夹名/里面的文件」;不传 base 会解出一堆散文件apiClient.downloadZip() 走 await pipelineAsync(...)(node:stream/promises)makeTransferId / makeSpeedTracker / makeProgressEmitter),不用新写const source = Readable.fromWeb(response.body) 再 source.on('data', ...) 计数 ——
这会让流提前进入 flowing 模式,在 pipeline 挂上去之前到达的数据可能直接丢掉。
改成往 pipeline 里串一个 Transform,在 transform() 里计数。pipeline(a, b, c) 三参数无回调会报
ERR_INVALID_ARG_TYPE(它把最后一个流当成了回调),必须用 promise 版。
家人下载整套资料从「点几十次」变成「点一次」。服务端有硬上限:80 个文件 / 150MB 总计, 超了报中文提示,客户端把中文说明原样透出来(不是「HTTP 400」这种看不懂的话)。 失败时会删掉半截文件,不在磁盘留个打不开的 zip。
教学 PPT 几十上百 MB,传一半断掉要从头再来,家里人用着会崩。
上传传到一半断了(断网、关窗口、服务端抽风),原来只能整份重传。
① 分布式上传(>512KB 走的就是这条):服务端没有「续传」接口,
但 /api/distributed/reallocate 能给「还没传完的分片」重新发一套上传地址(顺带清掉旧节点上的残留)。
客户端自己用 distributedSessions 记「resumeKey → sessionId + 已传分片号」,
重传时算出 missing,调 reallocate 拿新地址只传这些。
reallocate 失败(会话过期,服务端 TTL 24 小时)→ 静默退化成全新上传,不给用户弹错。
② R2 分片上传(兜底路径):init 的 body 里带 resumeKey,
服务端认得上一次的钥匙时会把已传分片还回来(resumed: true)。
提交前 parts.sort() 按分片号排好 —— 顺序错了 R2 会把文件拼错,而且不报错。
③ 钥匙怎么算:makeResumeKey(路径, 文件大小, 修改时间) → sha256 截 32 位。
文件一改钥匙就变,服务端不会把「旧文件的分片」错当成这次上传的一部分。
原来全站零安全响应头。客户端是本地 file:// 页面 + 一个能执行 HTML 的预览器,
真被塞进恶意 HTML 也没有兜底。于是在 index.html 头部加了一条 meta 形式的 CSP。
没有 CSP 时,只要有一段 HTML 被塞进渲染进程,里面的 <script> 就能执行 ——
而渲染进程能调 window.r2Drive 删文件。改动 8 的 sanitizeHtml() 兜住了 Office 那条路径,
但那是「在某个入口处过滤」,CSP 是最后一道兜底:就算有路径漏了,脚本也跑不起来。
meta 形式的 CSP 在 file:// 下对 'self' 匹配不可靠 → 每条都显式写上 file:connect-src 必须放 https: / http::文档来源是 file://,
写 'self' 对它毫无意义;而且手机端桥接要 fetch 用户自己填的 API 地址。
这一条没有收紧空间style-src 必须给 'unsafe-inline':动态行内样式 + 自定义品牌色是靠 <style> 写的script-src 不给 'unsafe-inline' / 'unsafe-eval':
这是这条 CSP 真正值钱的地方,渲染层也确实没有 eval / new Functionframe-src / base-uri / form-action 全关(全项目没有 <iframe>)object-src 写成 'none',看起来「关得越干净越好」—— 结果 PDF 预览直接空白。object-src 'none' 会把 PDF 预览静默拦掉。
<embed> 这个元素归 object-src 管,而 PDF 预览正是靠
document.createElement('embed') + src = r2drive://file/... 实现的。
所以 'none' 会让 PDF 预览直接空白,而且控制台不一定报得清楚 ——
正是 CSP 那个「静默失效」的特性。修正:改成 object-src r2drive:(只放行它真正要用的那一个协议)。
tools/smoke/index.js):真的启动 Electron,挂 securitypolicyviolation 监听,
实测内联样式能不能用、blob: / data: 图片能不能加载、内联脚本有没有被拦tools/verify-csp.js,30 项):把 index.html 和 styles.css 里出现的
每一条资源引用逐个拿去对白名单,判断会不会被拦<embed>/<object>,就断言 object-src 不是 'none',
并且从源码里读出协议名去核对它真的放行了那个协议(不在测试里另写一份)打包完之后,用户第一次在真机上跑了 npm run smoke。
结果:111 项、失败 2 项。两项都是测试自己的问题,客户端代码一行没错。
npm run smoke 是唯一会真的启动 Electron 的验证,
而它长期在开发机上跑不通(沙箱拦 .ssh)。等于这一层从来没被验证过 ——
里面藏了两条早就失效的断言,一直没人知道。
原断言用 indexOf('SYNC_BACKUP_DIR') 找位置,再取后面 400 个字符来判断。
但实测发现:这个字符串第一次出现是文件顶部的常量定义(第 44 行),
真正的判断在第 377 行 —— 压根不在那个 400 字符的窗口里。
起因是改动 19 把留档目录名提取成了常量(「只有一处定义」), 这条断言的锚点就悄悄指错了地方 —— 而冒烟测试跑不起来,所以没人发现。 改成从真正的判断处起算。
这条是自己弄坏的。改动 35 加 CSP 时,为了验证「内联脚本真的被拦下了」, 加了一个故意制造 CSP 违规的探针。于是「内联脚本被拦下」通过了, 但那条 error 同时被算进了「渲染进程零报错」,把它顶成失败 —— 探针自己把另一条断言弄挂了。
改法:把反向验证挪到主探针之外,中间先给报错列表拍个快照,断言改成看快照。 语义反而更准了 —— 快照拿的是「做这个故意违规之前」的报错,那正是「初始化期间」。
冒烟测试从 111 项失败 2 项 → 111 项全过(用户重跑确认)。
exe 不用重新打包:tools/ 不在 build.files 里,这些改动不进包。
后续还补了一条:用户重跑时终端冒出 Electron 42 的弃用警告
(console-message 的回调签名改了)。危险的地方不只是那条警告 ——
而是如果监听哪天失效,报错列表会永远是空的,「零报错」就变成一条永远通过的空断言。
于是改用新签名,并加了一条自检:故意打一条 error,确认真的被收到了。断言总数 111 → 112。
indexOf 找锚点之前,先确认这个字符串的第一次出现就是你要找的地方。
把字面量提取成常量之后,「第一次出现」的位置会变 —— 这类断言会静默失效。蔡老师看网页端回收站文案时觉得不对,一查发现客户端这处也一样不实 —— 而且这句更危险。
原文写「删除的文件会先放到这里,服务端保留 30 天…超过 30 天后由服务端自动清理」。 查下来两半都不成立:「清理 N 天前」按钮已删(没有手动入口)、worker 里也没有任何定时任务(不会自动清)。 最危险的地方是它让家人以为放着不管就会自己消失 —— 于是既不恢复也不清,空间一直占着。
「删除的文件会先放到这里,期间随时可以还原。回收站目前不会自动清理,放多久都在。」 后面保留原有的「『彻底删除』会立刻释放存储空间,不可恢复」—— 这句有用且真实,是操作警告。
TRASH_KEEP_DAYS),
发现那个常量无人引用;再全项目 grep「自动清理 / 30 天后」,又挖出 3 处同样的说法
(网页端文案、常量注释、客户端开发文档),一起改掉。改完立刻 grep 同类说法,能一次挖干净。
38 条改动里反复出现的做法。下次遇到同类问题可以直接照用。整节默认收起,点标题展开。
永远不要让「删掉旧的」和「写上新的」分成两步做。同盘的 rename 是原子操作,
用它把「替换」变成一步;并且在替换前把旧的挪走留档,失败时挪回来。
用在哪儿:云端覆盖本地(改动 19)、配置文件保存(改动 25)。 这是整个项目里最重要的一条工程模式 —— 两次数据丢失风险都靠它解决的。
不要照猜、不要只看接口文档。契约从源码里读出来,尤其是边界语义 —— 比如「三种 purge 模式不能混着发」「不传参数时走的是哪个默认值」。
用在哪儿:回收站、版本历史的全部契约都是从 worker 源码读出来的。 「purge 模式不能混发」这条光看文档根本不会知道,但发错了会清掉不该清的东西。
把被测函数从源文件里按标记串切片抽出来再注入测试环境, 而不是在测试里重写一份「差不多的」实现。
用在哪儿:previewUrl() / sanitizeHtml() / PPTX 解析器
都是从 renderer.js、pptx-viewer.js 里抽出来测的。
复制品会随着源文件改动而过期,还照样绿。
不只测「修复后能过」,还要测「还原成旧写法确实会失败」。 否则你无法区分「修复生效了」和「这个断言本来就不会失败」。
用在哪儿:verify-api-client.js 里内置假服务端,
既测带 CSRF 头能过,也测不带头确实会被 403 拦。
每个「修过一次、但可能再犯」的坑,都写成一条源码检查。 这样它不是靠人记得,而是靠测试拦住。
用在哪儿:corsEnabled: true 在不在、两个导航守卫在不在、
假 preload 覆盖度、删除文案不再谎称「不可撤销」、object-src 不许写 'none'……
先问「平台有没有」,再问「用哪个库」。 省掉一个库 = 省掉一份体积、一份依赖风险、一份升级负担。
用在哪儿:PDF 用 Chromium 内置查看器(省掉约 4MB 的 pdf.js);
ZIP 用 DecompressionStream('deflate-raw')(省掉 JSZip)。
把「下载文件才能看」变成「直接流式引用」。 base64 要把整个文件读进内存、体积还膨胀 1/3,所以必然有上限。
用在哪儿:r2drive:// 协议(改动 7)。
注意三点:registerSchemesAsPrivileged 必须在 app ready 之前调、
corsEnabled: true 不能漏、要回写 Access-Control-Allow-Origin。
凡是靠「什么都没发生」来判定通过的测试,都要问一句: 我怎么知道它真的在监听?加一条自检,故意制造一次能被捕获的事件。
用在哪儿:改动 37 —— 故意 console.error() 一条,
断言它真的被收到了。收报能力坏了必须响亮地失败,不能静默变成永远通过。
服务端的硬编码默认值,往往是「对开发者合理、对使用者不合理」。 客户端能传参覆盖的,就要按真实使用场景重设一遍。
用在哪儿:版本历史的大小门槛 20MB → 60MB(教学 PPT 大多超 20MB)、
保留版本数 10 → 5。不覆盖,功能就是空转的。
功能做对了、文案没跟上,等于白做 —— 甚至更糟,因为用户会因为文案而不敢用。
用在哪儿:删除确认框原来写「此操作无法在客户端撤销」, 让用户以为删了就真没了、不敢删也不敢找;接上回收站后改成「删除后会放进回收站保留 30 天」。 还有 429 的「请 0 分钟后再试」→ 钳到最少 1 分钟。
界面、弹窗、流程、测试全绿 —— 功能「看起来」好了。 但要回头问一句:这个功能在我真正要用的场景里,真的会生效吗?
用在哪儿:缺口 36 —— 版本历史全做完了,但大于 512KB 的文件(含教学 PPT) 压根不存版本,列表永远是空的。查服务端源码才发现。
用假服务端复刻真实语义(不依赖真实部署),用手工拼出的最小合法文件做夹具(不依赖 Office、不依赖网络)。
用在哪儿:tools/mock-server.js 复刻 CSRF / 401 / 403 规则;
手搓 stored ZIP 拼出最小合法 docx / pptx;手搓 20 秒 WAV 验证媒体 seek。
当前版本:26.9.29(2026-09-29 打包)
—— 相比 26.9.27 多了一条:回收站提示去掉空头承诺(改动 38)。
产物:release-portable/R2-Cloud-Drive-portable.exe(约 88 MB,
2026-09-29 11:58 重打)。verify-packaged.js 29 项全绿;
8 套 npm run verify 也重跑全过。回滚点 backup/26.9.29/。
📌 输出目录与文件名从这一版起固定:release-portable/ +
R2-Cloud-Drive-portable.exe,不再带版本号 —— 为的是做一个不用每次改的桌面快捷方式。
代价是从文件名读不出是哪一版,只能看 exe 的修改时间;版本号没丢,在 exe 属性里(鼠标悬停可见)。
🏷️ exe 属性里现在能看到:文件说明 R2 云盘客户端、
版本 26.9.29、公司 CZP、版权 Copyright © 2026 CZP
—— 「去作者」补掉了最后一处漏网(以前公司名还挂着 Electron 模板自带的 GitHub, Inc.)。
🔧 打包也收敛成一条命令:npm run pack:release
—— 自动「归档旧目录 → 把 data/ 挪出来 → 自检 → 打包 → data/ 放回 → 核对」。
「把 data/ 挪出来」这步不能省:里面有 config.json 和登录态,
把整个目录改名会把数据一起带走 → 新 exe 跑起来等于「从没配置过」。
归档那步若被外部进程占用(EPERM),脚本会自动降级:打到临时目录,再把新 exe 覆盖回来。
相对作者原版,现在多出来的东西。
改完一条命令就能验。第三层需要真机,跑不了就等于这一层没验证过。
| 层 | 命令 | 跑在哪儿 | 覆盖什么 |
|---|---|---|---|
| 语法 | npm run check | Node | 7 个源文件能不能过解析 |
| 逻辑 | npm run verify | Node(带假服务端) | 接口层、同步逻辑、配置安全、回收站/版本、预览解析 —— 8 个套件 |
| 真实环境 | npm run smoke | 真实 Electron | 自定义协议、Range、媒体 seek、Office 解析、真实界面启动 —— 112 项 |
| 打包后 | verify-packaged.js | Node | 包内源码与磁盘逐字节一致 + 新功能关键串真的进包 —— 29 项 |
为什么「打包后」要单独一层:
check / verify 测的都是磁盘上的源码,不是打进 exe 的那份。
而「源码对」不等于「包里的对」—— 曾经出过 package.json 被误删、重建后才对得上的事。
一个反直觉但正常的事实:包内的 package.json 与磁盘本来就不同 ——
electron-builder 会把 scripts / devDependencies / build 三段剥掉。
这不是出错,verify-packaged 里专门有一条断言把这件事钉住。
| 优先级 | 事项 | 说明 |
|---|---|---|
| 高 | 实机验收 | 所有验证都是自动化的,还没在真机上完整用过一遍。重点试:docx / xlsx / PDF / pptx / 大视频 / 回收站还原 / 打包下载 / 版本历史 / 同步 |
| 高 | 重跑 npm run smoke |
本机跑不通(沙箱拦 .ssh),需要自己开终端跑。
上一轮 112 项全过,但 CSP 动过 object-src,PDF 预览要重点看 |
| 中 | 清理旧 release 目录 | release-portable-26.9.17、-2、-2b 等,
被文件锁占用的需先退出应用再手动删。
⚠️ release-portable 是要留的(固定输出目录),别删 |
| 低 | 分享系列、站点设置 | 服务端已有接口,但要先判断是不是「网页端做更合适」 —— 能用网页端解决的不往客户端搬 |
| 低 | 全盘文件列举 /api/list-all |
大账号下可能很慢,要评估是否值得 |
| 低 | 把 LICENSE 打进包 |
MIT 要求保留版权声明,而 build.files 只含 src/** / assets/** / package.json。
私用场景影响小,改起来是一行 |
npm run smoke 在开发机上跑不通(沙箱拦 .ssh),需要用户自己开终端跑。
结果落在 tools/smoke/report.txt(Windows 上 Electron 是 GUI 程序,
主进程 console.log 不进父进程 stdout,所以只能落盘)。src/mobile/mobile.js 里没有)。
目前只做桌面端,未提需求。26.9.29)在同一天改两次就没法区分。
真遇到了就加后缀(26.9.29-2,注意不能写成四段式 26.9.29.1,那不是合法 semver)。这些不是「暂时不做」,是方向上就不做。列在这儿,是为了将来提议时能想起为什么当初不这么干。
| 不做 | 原因 |
|---|---|
❌ 引入同步账本(.sync-state.json 那套) | 这是另一套客户端难维护的主因;现在「远端时间 vs 本地时间」够用 |
| ❌ 做删除同步(本地删 → 云端删) | 一次误删就是灾难。要删去网页端,那儿有回收站 |
| ❌ 做「云端更新」拉取目录 | 会堆积副本;现有「多端同步提示」够用 |
| ❌ 改主界面结构 / 套网页外壳 | 自绘界面是它的优点 |
| ❌ 做「云端目录改名」流程 | 风险高(会整目录重传),要用就用网页端搬移 |
| ❌ 用 CDN 加载任何资源 | 离线可用 + 不依赖国外网络;目前真实页面里外部 http 资源数为 0 |
| ❌ 保留任何「指向作者仓库」的自动更新 | 会把定制版覆盖回原版(见改动 10) |