diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index e1fd76b79..de7b4fe4b 100644 --- a/docs/DOC-MAINTENANCE.md +++ b/docs/DOC-MAINTENANCE.md @@ -30,6 +30,7 @@ Aspirational / feature-branch content belongs in that branch's docs, or is clear | [`design.md`](./design.md) | The design system: theme mechanism, shadcn component selection, new-page recipe; tokens split to [`references/design-tokens.md`](./references/design-tokens.md), component palette to [`references/design-components.md`](./references/design-components.md), layout/motion/state/a11y patterns to [`references/design-patterns.md`](./references/design-patterns.md). | | [`verification.md`](./verification.md) | Lightweight end-to-end functional verification — throwaway scratch scripts driving the real built extension; report template split to [`references/verification-report-template.md`](./references/verification-report-template.md), debugging FAQ to [`references/verification-debugging.md`](./references/verification-debugging.md). | | [`architecture.md`](./architecture.md) | Deep internals: process model, message passing; subsystem deep-dives split to [`references/architecture-services.md`](./references/architecture-services.md), [`references/architecture-data.md`](./references/architecture-data.md), [`references/architecture-gm-api.md`](./references/architecture-gm-api.md), [`references/architecture-execution.md`](./references/architecture-execution.md), [`references/architecture-build.md`](./references/architecture-build.md). | +| [`cloud-sync.md`](./cloud-sync.md) | Cloud sync internals: sync files, digest/status semantics, provider differences, error classification, retry policy. | | [`translation.md`](./translation.md) | Translation / localization single source of truth. | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | This guide: doc-set organization rules + fact-check / anti-drift discipline. | | [`README.md`](./README.md) | The index that points to all of the above. | @@ -98,7 +99,7 @@ git ls-files eslint-rules/; git grep -l "require-last-error-check" -- eslint.con Link integrity — confirm every relative markdown link in the core docs resolves: ```bash -for doc in AGENTS.md docs/README.md docs/DOC-MAINTENANCE.md docs/develop.md docs/references/develop-testing.md docs/design.md docs/references/design-tokens.md docs/references/design-components.md docs/references/design-patterns.md docs/architecture.md docs/references/architecture-services.md docs/references/architecture-data.md docs/references/architecture-gm-api.md docs/references/architecture-execution.md docs/references/architecture-build.md docs/verification.md docs/references/verification-debugging.md docs/references/verification-report-template.md docs/translation.md; do +for doc in AGENTS.md docs/README.md docs/DOC-MAINTENANCE.md docs/develop.md docs/references/develop-testing.md docs/design.md docs/references/design-tokens.md docs/references/design-components.md docs/references/design-patterns.md docs/architecture.md docs/references/architecture-services.md docs/references/architecture-data.md docs/references/architecture-gm-api.md docs/references/architecture-execution.md docs/references/architecture-build.md docs/verification.md docs/references/verification-debugging.md docs/references/verification-report-template.md docs/cloud-sync.md docs/translation.md; do # the sed pipeline drops fenced code blocks and inline code spans first, so illustrative sample # links inside ```md snippets or `single-backtick` text (e.g. references/verification-report-template.md's # screenshot/resource examples, verification.md's "Evidence location" spans) aren't false-flagged as broken diff --git a/docs/README.md b/docs/README.md index f1a280e41..6b3d3f473 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ | [`design.md`](./design.md) | 设计系统参考:主题机制、shadcn 组件选型、新建页面配方总览;令牌完整值拆到 [`references/design-tokens.md`](./references/design-tokens.md),组件清单拆到 [`references/design-components.md`](./references/design-components.md),布局/响应式/动效/状态/无障碍范式拆到 [`references/design-patterns.md`](./references/design-patterns.md)。**做页面/对话框/区块前先读。** | | [`verification.md`](./verification.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例);报告模板拆到 [`references/verification-report-template.md`](./references/verification-report-template.md),调试 FAQ 拆到 [`references/verification-debugging.md`](./references/verification-debugging.md)。**验证改动是否真正跑通时读。** | | [`architecture.md`](./architecture.md) | 内部原理总览:多进程模型、消息传递;各子系统深入拆到 [`references/architecture-services.md`](./references/architecture-services.md)(服务层)、[`references/architecture-data.md`](./references/architecture-data.md)(数据层)、[`references/architecture-gm-api.md`](./references/architecture-gm-api.md)(GM API)、[`references/architecture-execution.md`](./references/architecture-execution.md)(脚本执行)、[`references/architecture-build.md`](./references/architecture-build.md)(构建管线)。 | +| [`cloud-sync.md`](./cloud-sync.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | ## 翻译 / Translation diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md new file mode 100644 index 000000000..c27821c02 --- /dev/null +++ b/docs/cloud-sync.md @@ -0,0 +1,455 @@ +# 云同步实现说明 + +本文是 ScriptCat 云同步的维护文档,描述当前分支上的实际实现。它面向需要修改或 review 同步逻辑的维护者,重点解释同步操作、状态文件、provider 差异、错误分类和生产数据兼容要求。 + +相关代码入口: + +- [`src/app/service/service_worker/synchronize.ts`](../src/app/service/service_worker/synchronize.ts):同步服务、队列、状态合并、digest 更新。 +- [`packages/filesystem/filesystem.ts`](../packages/filesystem/filesystem.ts):统一文件系统接口。 +- [`packages/filesystem/error.ts`](../packages/filesystem/error.ts):统一 typed provider error。 +- [`packages/filesystem/*`](../packages/filesystem/):各云盘 provider 实现。 + +## 维护目标 + +云同步的第一目标不是强事务,而是在浏览器扩展和多 provider 限制下做到“尽量正确且不破坏旧数据”。 + +必须保持的不变量: + +1. 单个脚本失败不能阻塞其他脚本同步。 +2. 成功脚本可以推进自己的 `file_digest`,失败脚本必须保留旧 digest。 +3. `scriptcat-sync.json` 写回前要合并远端较新状态,避免覆盖其他设备状态。 +4. provider 写入使用普通覆盖语义;同步层必须诚实记录无法检测的并发覆盖窗口。 +5. 旧 `.user.js`、旧 `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须继续可读。 +6. filesystem 包只负责执行文件操作、抛 typed error;同步冲突策略属于 `SynchronizeService`。 +7. 本地与云端都改过的脚本(真冲突)不自动覆盖任何一端:跳过、保留基线、聚合通知用户。 +8. `file_digest` 只是文件快照,表达不了「删除做到一半」「`.meta.json` 还欠一次写入」这类未完成意图;此类意图必须持久化到 `pending_sync_ops` 并在下一轮开头重放,不能指望方向判定自动再生成重试任务。 + +功能范围:云同步只同步脚本源码、来源 metadata(`.meta.json`)、启用状态与排序(`scriptcat-sync.json`)。GM storage 值和 `@require`/`@resource` 资源缓存**不**参与云同步(它们只在完整备份/导出路径处理),属于有意取舍,扩展需另行设计(隐私、容量、二进制资源、多端 merge)。 + +## 同步目录和文件 + +同步目录由云同步配置决定,业务上使用 `ScriptCat/sync` 作为脚本同步目录。同步目录中主要有四类文件。 + +### `.user.js` + +脚本源码文件。同步层用文件名中的 `uuid` 和 `FileInfo.digest` 识别脚本及远端内容状态。 + +### `.meta.json` + +脚本元信息文件。当前读取时只要求兼容以下字段: + +```ts +type SyncMeta = { + uuid: string; + origin?: string; + downloadUrl?: string; + checkUpdateUrl?: string; + isDeleted?: boolean; +}; +``` + +`origin`、`downloadUrl`、`checkUpdateUrl` 是安装或更新时的辅助信息。新增字段必须保持 optional,读取旧文件时不能要求存在。 + +### tombstone 删除标记 + +当用户启用同步删除时,删除云端脚本不会简单移除所有文件,而是删除 `.user.js` 并写入 `.meta.json`: + +```json +{ + "uuid": "", + "isDeleted": true +} +``` + +其他设备看到“只有 `.meta.json` 且 `isDeleted: true`”时,会删除本地脚本。当前没有单独的 `tombstone_digest`,也没有 tombstone GC 机制;不要在没有生命周期设计前新增这类状态。 + +### `scriptcat-sync.json` + +保存脚本启用状态、排序和更新时间。当前结构: + +```ts +type ScriptcatSync = { + version: string; + status: { + scripts: { + [uuid: string]: { + enable: boolean; + sort: number; + updatetime: number; + } | undefined; + }; + }; +}; +``` + +兼容要求: + +- 文件可能不存在。 +- 文件可能缺少 `status` 或 `status.scripts`。 +- 文件可能损坏或无法 JSON parse。 +- 写回时必须尽量保留远端较新状态,尤其是本轮失败脚本和 orphan 脚本的状态。 + +## 本地 `file_digest` + +`file_digest` 存在 `ChromeStorage("sync")` 中,用于记录上一次确认同步成功的云端文件 digest。 + +当前格式: + +```ts +type FileDigestMap = { + [filename: string]: string; +}; +``` + +注意事项: + +- digest 是 provider 返回的 opaque token,不一定是 md5。 +- WebDAV、S3、OneDrive 使用 ETag 风格 digest。 +- Dropbox 使用 `content_hash`。 +- Google Drive、Baidu 接近 md5。 +- Zip 可能为空。 +- 不能用本地 md5 覆盖 provider 已返回的原生 digest。 +- 文件操作失败时,对应文件名必须保留旧 digest,不能写入“看起来成功”的新值。 + +digest 更新有两条路径,区别在于对账范围: + +- `updateFileDigest()`(`syncOnce` 用)重新 `fs.list()` 全量构造新 map。`syncOnce` 已逐文件对账整份云端列表,可以安全全量盖章。 +- `updateFileDigestForUuids()`(`scriptInstall` / `scriptsDelete` 队列用)只更新本次涉及 uuid 的文件。队列路径没有对账整份云端列表,若也全量盖章,会把他端已更新、本机尚未 pull 的文件误标成已同步,导致下一轮 `syncOnce` 早退漏 pull。 + +两条路径都遵守同一套规则:云端仍在则记录 `fs.list()` 返回的原生 digest;刚 push 但 provider list 暂时不可见时才用 `pushScript()` 返回的本地 md5 兜底;云端已删除则移除记录;失败文件保留旧 digest,不写入“看起来成功”的新值。 + +## `sync_content_md5`(本地内容基线) + +另有一份独立的 `sync_content_md5`(同存 `ChromeStorage("sync")`,格式同 `FileDigestMap`)记录本机每次成功推送**或拉取**的内容 md5,即"上次同步成功时的本地内容基线"。它用于云端变化时判断本地内容是否也发生变化: + +- **方向判定(L4 修复)**:云端 digest 相对 `file_digest` 已变时,用本地当前内容 md5 与基线比较判断"本地是否也改过",取代跨时钟域的墙钟比较(本地毫秒 updatetime vs 服务端整秒 mtime 在同一秒内会误判方向,导致 push 覆盖较新的云端内容)。 + +它与 `file_digest` 用途不同:`file_digest` 存 provider 原生 digest 检测云端变化,`sync_content_md5` 存本地内容 md5 检测本地变化,二者不可混用。 + +`sync_content_md5` 随 `file_digest` 生命周期收敛,不会只增不删:`updateFileDigest()` 全量对账后清理 `file_digest` 之外的条目;`updateFileDigestForUuids()` 只清理本次确认已从云端删除的目标文件(队列路径未全量对账,不能全局清理)。 + +## `pending_sync_ops`(未完成操作登记) + +同存 `ChromeStorage("sync")`,按 uuid 登记尚未完成的多步操作: + +```ts +type PendingSyncOp = { op: "delete"; syncDelete: boolean } | { op: "push" }; +``` + +写入时机: + +- `scriptsDelete()` **写前登记** `op: "delete"`(含当时的 `syncDelete` 意图):两步删除(删 `.user.js` + 写 tombstone / 删 `.meta.json`)中途失败或 SW 中途重启后,登记仍在;该 uuid 全部步骤成功才清除。 +- push 部分成功(`.user.js` 成功、`.meta.json` 失败)时登记 `op: "push"`(队列路径与 `syncOnce` 内部 push 任务都登记)。原因:部分成功后 `.user.js` digest 已推进,且生产安装消息不带时间字段(云端 mtime 是 push 时刻的 `Date.now()`,本地时间必然不比它新),下一轮「digest 相等 + 本地时间不比云端新」会跳过整个 uuid,失败的 `.meta.json` 不会再获得重试。 + +重放时机:`syncOnceInternal()` 开头、主流程对账之前逐条重放: + +- `delete`:直接重放 `deleteCloudScript()`(已幂等,见下)。若不先重放,「本地无脚本 + 云端有 `.user.js`」会把删到一半的脚本拉回本地;「本地无脚本 + 云端只剩 `.meta.json`」则不命中任何决策分支,tombstone 永远欠写。 +- `push`:盲覆盖写,重放前先校验云端 `.user.js` digest 仍等于本机 `file_digest` 记录(即云端仍是本机上次写入的内容);已被他端改写或删除则丢弃登记,交回主流程方向判定,避免覆盖对端更新。本地脚本已删则丢弃登记。 +- 重放成功的 uuid 经 `updateFileDigestForUuids()` 推进 digest 与内容基线;重放仍失败的 uuid 保留登记、计入 `failedSyncUuids`,且本轮主流程跳过该 uuid(防止在半完成状态上误拉/误推)。 + +## 同步入口和队列 + +`SynchronizeService` 使用 `SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"` 串行化同步任务。以下入口都会进入同一队列: + +- 配置启用后触发的 `syncOnce()`。 +- 定时同步,Chrome alarm 名称为 `cloudSync`,周期为 60 分钟。 +- 非 sync 来源安装脚本后的 `scriptInstall()`。push 失败时按 `PushScriptPartialError` 只保留失败文件的旧 digest,已成功文件推进到云端最新值;部分成功还会登记 `pending_sync_ops` 的 push 意图(见上)。 +- 非 sync 来源删除脚本后的 `scriptsDelete()`。执行前写前登记删除意图,成功后清除。 + +串行队列很重要:安装、删除和定时同步都可能写同一批云端文件,如果并发执行,会扩大覆盖和 digest 污染风险。 + +## `syncOnceInternal()` 流程 + +`syncOnceInternal(syncConfig, fs)` 是主同步流程: + +0. 重放 `pending_sync_ops` 中未完成的删除/push(见上),重放失败的 uuid 本轮跳过。 +1. 调用 `fs.list()` 获取云端目录。 +2. 按文件名把 `.user.js` 和 `.meta.json` 组装成 `uuidMap`。 +3. 读取本地脚本列表,生成 `scriptMap`。 +4. 尝试读取 `scriptcat-sync.json`,失败时允许脚本同步继续,但本轮跳过 status 写回。 +5. 对每个云端 uuid 和本地脚本做决策。 +6. 用 `Promise.allSettled()` 等待所有文件任务,保持 per-file best-effort。 +7. 收集成功任务返回的 digest patch。 +8. 对失败任务记录 `failedSyncUuids` 和 `preserveDigestFiles`;push 部分失败时(`PushScriptPartialError`),已成功写入云端的文件不保留旧 digest,让下一轮只重试真正失败的文件。 +9. 如果启用 `syncStatus`,合并本地状态、初始云端状态、写回前重新读取的最新云端状态。 +10. 调用 `updateFileDigest()`,成功文件推进 digest,失败文件保留旧 digest。 + +### 决策规则 + +本地脚本存在、云端脚本也存在: + +- 云端缺 `.meta.json`(上一轮分片上传残留):push 本地脚本补齐 meta。 +- 云端 digest 与 `file_digest` 一致(云端自上次同步未变): + - 本地更新时间不比云端新(整秒对齐比较):再联合检查 `.meta.json` digest——`.user.js` 未变不代表 meta 未变。meta digest 与记录一致才跳过;不一致(他端只改了 meta 字段)则读取云端 meta 并采用其 `origin`/`downloadUrl`/`checkUpdateUrl`(tombstone + `.user.js` 仍在视为他端部分推送残留,等对端补完再判),采用成功后推进内容基线。若直接跳过,收尾的全量盖章会把未处理的 meta 标成已同步,之后永不再处理。meta 无 digest 记录(从未成功同步)时不判定,交由收尾盖章建立基线。 + - 本地更新时间更晚:补偿 push——云端 digest 检测不到本地编辑,队列 push 失败后也靠这里兜底。这里仍是跨时钟域比较(本地是客户端毫秒时钟,云端 mtime 在 WebDAV 等服务端仅整秒精度),比较前两侧都截断到整秒:同秒内的毫秒余数不触发补偿,避免每次编辑上云后(服务端写入时间与编辑同秒)多一轮冗余覆盖 push。 +- 云端 digest 与 `file_digest` 不一致(云端自上次同步已变,或本机无记录),由 `decideDirectionOnRemoteChange()` 决定方向。**不比较本地毫秒时钟与服务端整秒 mtime**(对端更新落在同一秒内时"本地时间戳更大"是误报,会 push 覆盖较新的云端内容,即 L4 同秒竞态): + - 本地内容 md5 == `sync_content_md5` 基线(本地未改):pull。 + - 本地内容已改,但与云端当前内容一致(两台设备做了同样编辑,或本机记账失败后云端实为本机所写):直接采用云端 digest 收敛基线,不产生写操作(adopt)。 + - 本地与云端都改了(真冲突):抛 `SyncBothChangedConflictError`,本轮跳过该脚本(沿用失败路径保留旧 digest 与云端 status),并聚合通知用户(见下)。 + - 无基线(升级前旧数据/从未同步成功):退回旧的时间比较规则(整秒对齐后时间更晚一方胜出,同秒判 pull)。 +- 冲突通知:一轮同步只发一条通知,列出所有冲突脚本名;同一批脚本持续冲突时后续轮次不重复通知(集合变化后重新通知)。冲突脚本会一直停走,直到某一端内容与另一端一致(自动收敛)或某一端被删除/重装。 + +注意:push 是普通覆盖写。`list → 决策 → write` 之间存在 TOCTOU 窗口:若对端恰好在这几秒内写入同一文件,后写者胜(last-writer-wins),同步层无法察觉。内容基线只能减少基于旧快照做出错误方向判断的概率,不能消除请求之间的并发窗口。 + +本地脚本存在、云端只有 `.meta.json`: + +- 如果 meta 是 tombstone,本地删除脚本。 +- 如果 meta 不是 tombstone,删除无效 meta 并重新 push 本地脚本。 + +本地脚本不存在、云端 `.user.js` 和 `.meta.json` 都存在: + +- pull 并安装云端脚本。 + +本地脚本不存在、云端只有 `.user.js`: + +- 视为 orphan cloud script,跳过。 +- 不删除、不覆盖、不清空对应远端 status。 + +本地脚本不存在、云端只有 `.meta.json`: + +- tombstone:保留(删除标记须长期可见)。 +- 非 tombstone:他端分片删除的残留,清理掉——否则其他仍持有该脚本的设备会把它当「无效 meta」删除后重新上传,等于撤销删除。 +- 以 digest 变化为门:digest 与记录一致时不重复读取(tombstone 首轮读取盖章后不再产生 I/O)。 + +遍历结束后,剩余只存在于本地的脚本会 push 到云端。 + +## push / pull / delete + +### `pushScript()` + +`pushScript()` 写两个文件: + +- `.user.js` +- `.meta.json` + +`modifiedDate` 使用 `script.updatetime || script.createtime || Date.now()`。 +写入使用 provider 的普通覆盖语义,不附加条件请求参数。 + +`pushScript()` 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。 + +### `pullScript()` + +`pullScript()` 会读取源码和 meta: + +1. `fs.open(file.script).read("string")`。 +2. `fs.open(file.meta).read("string")`。 +3. `JSON.parse(meta)`。 +4. `prepareScriptByCode()` 解析脚本。 +5. 根据 `scriptcat-sync.json` status 调整 enable/sort。 +6. `script.installScript({ upsertBy: "sync", updatetime: file.script.updatetime })` 写入本地——本地 updatetime 必须采用云端文件时间(与 push 对称),否则下一轮会把刚拉下来的内容误判为本地编辑触发补偿 push,在 etag 型 provider 上形成双设备永久 pull/push 振荡。 +7. 成功后把拉取内容的 md5 记入 `sync_content_md5` 基线,供下轮云端再变时判定本地是否也改过。 + +真实失败会向上抛出,由 `syncOnceInternal()` 作为单文件失败处理。不要在 `pullScript()` 内吞掉错误,否则会重新引入 digest 污染。 + +### `deleteCloudScript()` + +删除云端脚本时: + +- 先删除 `.user.js`。 +- 如果 `syncDelete` 为 true,写 tombstone meta。 +- 如果 `syncDelete` 为 false,删除 `.meta.json`。 + +两步删除对 typed `notFound` 幂等:目标文件已不存在即视为达成目的(部分 provider 如 Google Drive 对缺失文件抛 notFound,重放半途失败的删除不能被它中断)。其余失败会向上抛出。`scriptsDelete()` 必须逐条 catch,保证批量删除中一个 uuid 失败不影响后续 uuid;未完成的 uuid 依赖 `pending_sync_ops` 登记由下一轮重放。 + +## status 合并 + +`scriptcat-sync.json` 是 best-effort 状态同步,不是强事务。合并时遵守以下规则: + +1. 本轮文件同步失败的 uuid 保留云端原 status。 +2. 本轮刚 pull 的脚本保留云端 status,避免刚按云端更新后又写回本地旧状态。 +3. 本地状态更新时间更新时,候选写回本地 status。 +4. 云端状态更新时,应用云端 enable/sort 到本地。 +5. orphan uuid 的云端 status 保留。 +6. 写回前重新读取最新 `scriptcat-sync.json`,再用 `mergeScriptcatSyncStatus()` 合并,减少覆盖其他设备更新的概率。 + +如果初始读取 `scriptcat-sync.json` 失败,本轮不会写回 status 文件。 + +## 错误分类 + +provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映射: + +| 条件 | `SyncErrorKind` | 语义 | +| --- | --- | --- | +| `FileSystemError.conflict` | `conflict` | provider 报告文件冲突 | +| `FileSystemError.rateLimit` 或 `retryable` | `transient` | 429、瞬时 5xx(500/502/503/504)等可重试错误 | +| `FileSystemError.notFound` | `stale_snapshot` | list 到操作之间远端消失或缓存过期 | +| `FileSystemError.auth` 或 `WarpTokenError` | `fatal` | 授权失败 | +| 其他 | `fatal` | 未分类错误 | + +错误分类主要用于日志、保留 digest、后续 retry 策略和 review 判断。它不是用户可见错误协议。 + +## retry 策略 + +`LimiterFileSystem` 对不同操作使用不同重试策略: + +- 会重试:`verify`、`open`、`read`、`openDir`、`list`、`getDirUrl`。 +- 不重试:`create`、`createDir`、`writer.write()`、`delete()`。 + +原因:写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 + +typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501、505、507 等属于永久失败,不标记可重试,避免 limiter 空转退避。 + +## provider 差异 + +| Provider | digest 来源 | 写入方式 | 关键实现 | +| --- | --- | --- | --- | +| WebDAV | `etag` | 普通覆盖写入 | `putFileContents()` | +| S3 | `ETag` 去引号 | 普通覆盖写入 | PUT Object | +| OneDrive | `eTag` | 普通覆盖写入 | simple PUT / upload session | +| Google Drive | `md5Checksum` | 普通覆盖写入 | 先按路径查 fileId,再 PATCH 或 POST;path cache 可能 stale | +| Dropbox | `content_hash` | 普通覆盖写入 | 先 `exists()`,存在 overwrite,不存在 add | +| Baidu | `md5` | 普通覆盖写入 | precreate/upload/create,`rtype=3` 覆盖;HTTP 429/5xx typed | +| Zip | 空 | 普通覆盖写入 | 备份用途 | + +### WebDAV + +维护时注意: + +- `putFileContents()` 返回 false 时转为写入失败。 +- 删除 404 视为幂等成功。 + +### S3 + +维护时注意: + +- list 返回的 `ETag` 会去掉引号作为 digest。 +- `NoSuchKey` 删除视为成功。 + +### OneDrive + +维护时注意: + +- list 使用 `eTag` 作为 digest。 +- 空内容走 simple PUT,非空内容走 upload session。 +- upload session URL 不带 bearer token,request 层保留这个特殊路径。 +- read/delete 使用 raw `Response` 路径,需要手动转 typed error。 + +### Google Drive + +Google Drive 维护时注意: + +- digest 来自 `md5Checksum`。 +- 目录和文件通过 appDataFolder + path cache 查 fileId。 +- 写入是“先查同名文件,再 PATCH 或 POST”。 +- 删除是“先查 fileId,再 DELETE”。 +- reader path lookup miss 已转 typed notFound。 +- path cache stale 时 writer/list 会清缓存并重试一次。 + +### Dropbox + +Dropbox 维护时注意: + +- digest 来自 `content_hash`,必须当作 opaque provider digest。 +- 写入是 `exists()` 后 overwrite 或 add,存在 TOCTOU。 +- request 层已解析 `error_summary` 和 structured `path_lookup` / `path`。 +- 只有 `path/conflict` / `path_write/conflict` 判 conflict;其余 409(无写权限、空间不足等)保留原错误语义,不能被 createDir 当"目录已存在"吞掉。 +- raw download 429 会转 typed rateLimit。 +- 删除 not_found 视为幂等成功。 + +### Baidu + +Baidu 维护时注意: + +- digest 来自 `md5`。 +- 写入流程是 precreate、upload、create,`rtype=3` 覆盖。 +- 只把明确 file-exists errno 判为 conflict。 +- HTTP 429 转 typed rateLimit,瞬时 5xx(500/502/503/504)转 typed retryable。 +- 2xx 非 JSON 响应(如代理返回 HTML)会报错,不能当作成功——否则 list 会被判空触发全量覆盖。 +- `filemetas` 空列表强制转 typed notFound(errno -9);服务端返回的其他 errno 走通用 errno 分类,不一定是 notFound。 +- request 显式 `credentials: "omit"`,不要重新依赖全局 DNR 规则。 + +### Zip + +ZipFileSystem 主要服务备份/导出。 + +## 覆盖/冲突可见性 + +云同步在 best-effort、last-writer-wins 下可能静默覆盖或停走脚本。为让用户可感知、可回溯,增加了三处可见性(**不改变同步语义**,只增加提示与记录)。 + +### 同步状态 `cloud_sync_state` + +`syncOnce()` 每轮把设备本地同步状态写入 `ChromeStorage("sync")`(即 `chrome.storage.local`,物理键 `sync_cloud_sync_state`): + +```ts +type CloudSyncState = { + syncing: boolean; + lastSyncAt: number; // ms,从未同步为 0 + error?: string; // 最近一次失败原因(如账号验证失败) + counts: { total: number; overwrite: number; conflict: number; failed: number }; +}; +``` + +- 开始置 `syncing:true`,结束写 `counts`/`lastSyncAt`,异常写 `error`。注意:读旧值与写 `syncing` **不能** await 在 `syncOnceInternal` 之前,否则存储 I/O 会推迟内部起始,打乱测试的微任务门控。 +- 设置页「脚本同步」卡片顶部状态条(`SyncSection.tsx` + `syncStatus.ts`)读取并订阅 `chrome.storage.onChanged` 实时展示四态:正常 / 同步中 / 冲突(琥珀警示,已暂停需处理)/ 失败。覆盖(overwrite)不进入警示,属信息级:它是已发生、无需用户处理的事件,仅在「正常」态以中性信息行「N 个脚本被覆盖,可查看日志」+ 日志深链呈现(`sync_state_overwrite_info`)。单文件 best-effort 失败通过 `counts.failed` 显示为失败,不能回落成“同步正常”。 +- 状态条只在**已保存且启用**同步时展示(`SyncSection.tsx` 用 `savedEnable` 门控,非未保存草稿 `draft.enable`):仅勾选「启用脚本同步至」但尚未保存不会立刻出现「同步正常」。 +- `立即同步` 按钮经 `SynchronizeClient.cloudSyncOnce()` → SW `group.on("cloudSyncOnce")` → 用**已保存**配置跑一次 `syncOnce`(未启用则不触发)。构建文件系统失败发生在 `syncOnce()` 之前,因此 `cloudSyncOnce()` 会单独写入 `error` 状态并向 UI 抛出,由设置页显示 toast。 + +### 覆盖日志(`action` 标签) + +`decideDirectionOnRemoteChange()` 的**无内容基线兜底**分支(`baselineMd5 === undefined`)只能按跨时钟域墙钟比较(整秒对齐)pull/push,可能覆盖未知改动。该分支返回 `{ action, unverified: true }`,调用点在**写入成功后**据此打警告日志(失败轮次不登记也不通知,避免谎报覆盖、且不让去重键把下一轮真覆盖静默掉): + +```ts +this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name }); +``` + +日志经现有 `LoggerDAO` 落 IndexedDB(`service: "synchronize"`)。日志 `message` 保持稳定英文标识(与既有同步日志一致),人类可读文案由状态条与通知承载。 + +### 通知与深链 + +本轮有**冲突**时聚合一条 `InfoNotification`(冲突脚本已暂停走,需用户处理):已通知集合(`LAST_NOTIFIED_CONFLICT_KEY`)存入设备本地 sync storage,MV3 Service Worker 重启后同一批冲突不重复通知;集合变化或冲突消失时重置。 + +**覆盖(overwrite)不再弹桌面通知**:它是已发生、无需用户处理的信息级事件,只由 overwrite 日志与设置页状态条信息行 + 「查看日志」深链承载,避免首次同步/升级后无基线场景批量弹“N 个脚本被覆盖”的惊扰通知。 + +「查看日志」深链的 `?query` 载荷 `[{key,value}]` 由 Logger 页 `parseInitialQueries` 解析(状态条侧见 `syncStatus.ts` 的 `syncLogHref`):只有纯覆盖状态才预过滤到 `service=synchronize` 且 `action=overwrite`;存在冲突或失败时只过滤 `service=synchronize`,避免把对应失败日志隐藏掉。状态文案使用中性“发生覆盖”,具体是本地覆盖云端还是云端覆盖本地以日志的 `direction` 标签为准。 + +### 边界 + +- 日志目前没有自动清理机制(`LogCleanCycle` 设置与 `LoggerDAO.deleteBefore` 接口存在,但没有调用点执行清理),覆盖日志可长期回溯。 +- `overwrite` 只覆盖「无基线兜底」这一**可检测**的静默覆盖;纯 TOCTOU last-writer-wins(见上文 push 一节)客户端无法察觉,不在本轮可见性范围内。 + +## 生产兼容要求 + +改同步逻辑前必须检查: + +1. 旧云端目录只有 `.user.js` 和 `.meta.json` 时能否继续同步。 +2. 旧 `file_digest` 只有 string digest 时能否继续比较。 +3. 旧 `scriptcat-sync.json` 缺字段时是否会崩溃。 +4. 损坏 `scriptcat-sync.json` 是否会被本轮覆盖。 +5. orphan `.user.js` 是否仍被跳过而不是删除。 +6. 单文件失败是否只保留该文件旧 digest。 +7. provider 原生 digest 是否被本地 md5 覆盖。 + +## 不要做的事 + +- 不要把整个 sync round 改成 all-or-nothing。 +- 不要在 `pullScript()` 或 `deleteCloudScript()` 内 catch 后吞掉真实失败。 +- 不要让失败文件推进 digest。 +- 不要在无法读取远端 `scriptcat-sync.json` 时覆盖写回。 +- 不要把 Dropbox `content_hash` 当 rev。 +- 不要新增 `tombstone_digest`,除非同时定义 GC 和兼容策略。 +- 不要对普通无条件写入开启 transient retry。 + +## 测试重点 + +同步层测试在 [`src/app/service/service_worker/synchronize.test.ts`](../src/app/service/service_worker/synchronize.test.ts)。provider 测试在各自 `packages/filesystem/*/*.test.ts`。 + +修改同步逻辑时至少考虑以下场景: + +1. 多个文件中一个 push/pull/delete 失败,其他文件继续同步。 +2. 失败文件 digest 保留,成功文件 digest 推进。 +3. `scriptcat-sync.json` 写回失败不污染文件 digest。 +4. 损坏或旧格式 `scriptcat-sync.json` 不阻塞脚本同步。 +5. orphan `.user.js` 跳过并保留 status。 +6. provider conflict/transient/notFound 能映射到正确 `SyncErrorKind`。 +7. 云端已变、本地内容基线未变时必须 pull——即使本地 updatetime 大于云端 mtime(同秒竞态 L4)。 +8. 本地与云端都变(真冲突)时不 push 不 pull,保留旧 digest 与云端 status,一轮只发一条聚合通知,同一批冲突不重复通知。 +9. 云端已变、本地也变但内容与云端一致时,收敛基线且不产生写操作。 +10. 无内容基线(升级前旧数据)时退回整秒对齐的时间比较规则,同秒判 pull。 +11. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 +12. 同步失败计数必须在设置页显示为失败,不能显示为同步正常。 +13. 冲突或失败状态的日志深链不能被覆盖过滤条件隐藏。 +14. push 部分失败(`.user.js` 成功、`.meta.json` 失败)后,用生产形态的安装消息(不带 `updatetime`)验证下一轮仍会补传 `.meta.json`。 +15. 删除部分失败(tombstone 未写 / `.meta.json` 残留)后,下一轮(含 SW 重启)自动完成剩余步骤;删除全失败后不得把脚本拉回本地。 +16. 源码未变但云端 `.meta.json` digest 变化时,必须读取采用而不是盖章跳过。 + +真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index 033aec0fa..5e33606d4 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi, afterEach } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { isNotFoundError, isRateLimitError } from "../error"; import BaiduFileSystem from "./baidu"; +initTestEnv(); + describe("BaiduFileSystem", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -9,13 +13,19 @@ describe("BaiduFileSystem", () => { it("request should omit credentials without using global DNR rules", async () => { const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, json: async () => ({ errno: 0 }), }); vi.stubGlobal("fetch", fetchMock); // 监视 updateDynamicRules,确保不再依赖全局 DNR 规则 const updateDynamicRulesMock = vi.fn(); - (chrome as any).declarativeNetRequest.updateDynamicRules = updateDynamicRulesMock; + vi.stubGlobal("chrome", { + declarativeNetRequest: { + updateDynamicRules: updateDynamicRulesMock, + }, + }); const fs = new BaiduFileSystem("/apps", "token"); @@ -33,6 +43,73 @@ describe("BaiduFileSystem", () => { expect(updateDynamicRulesMock).not.toHaveBeenCalled(); }); + it("request 遇到 HTTP 429 时应抛出 typed rate-limit 错误", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + statusText: "Too Many Requests", + json: async () => ({ errno: 0, errmsg: "rate limited" }), + }); + vi.stubGlobal("fetch", fetchMock); + const fs = new BaiduFileSystem("/apps", "token"); + + await expect(fs.request("https://pan.baidu.com/rest/2.0/xpan/file?method=list")).rejects.toSatisfy( + isRateLimitError + ); + }); + + it("request 遇到 HTTP 5xx 时应抛出 typed retryable 错误", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + json: async () => ({ errno: 0, errmsg: "service unavailable" }), + }); + vi.stubGlobal("fetch", fetchMock); + const fs = new BaiduFileSystem("/apps", "token"); + + await expect(fs.request("https://pan.baidu.com/rest/2.0/xpan/file?method=list")).rejects.toMatchObject({ + provider: "baidu", + status: 503, + retryable: true, + }); + }); + + it("request 遇到 HTTP 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 501, + statusText: "Not Implemented", + json: async () => ({ errno: 0, errmsg: "not implemented" }), + }); + vi.stubGlobal("fetch", fetchMock); + const fs = new BaiduFileSystem("/apps", "token"); + + await expect(fs.request("https://pan.baidu.com/rest/2.0/xpan/file?method=list")).rejects.toMatchObject({ + provider: "baidu", + status: 501, + retryable: false, + }); + }); + + it("request 遇到 2xx 非 JSON 响应时应拒绝而不能当作成功", async () => { + // 代理/网关可能返回 200 + HTML 错误页;若被当成功,list() 会把云端判空并触发全量覆盖 + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => { + throw new SyntaxError("Unexpected token '<'"); + }, + }); + vi.stubGlobal("fetch", fetchMock); + const fs = new BaiduFileSystem("/apps", "token"); + + await expect(fs.request("https://pan.baidu.com/rest/2.0/xpan/file?method=list")).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("create should normalize double slashes in paths", async () => { const fs = new BaiduFileSystem("/apps//ScriptCat", "token"); @@ -52,4 +129,54 @@ describe("BaiduFileSystem", () => { `async=0&filelist=${encodeURIComponent(JSON.stringify(["/apps/ScriptCat/dir/file.user.js"]))}` ); }); + + it("创建目录遇到明确已存在 errno 时才标记为冲突", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: 31061, errmsg: "file already exists" }); + + await expect(fs.createDir("ScriptCat")).rejects.toMatchObject({ + provider: "baidu", + code: "31061", + conflict: true, + }); + }); + + it("创建目录遇到普通 errno 时不能误标记为冲突", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: 2, errmsg: "access denied" }); + + await expect(fs.createDir("ScriptCat")).rejects.toMatchObject({ + provider: "baidu", + code: "2", + conflict: false, + }); + }); + + it("写入预创建失败时保留普通 errno 的非冲突语义", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: 2, errmsg: "access denied" }); + const writer = await fs.create("dir/file.user.js"); + + await expect(writer.write("code")).rejects.toMatchObject({ + provider: "baidu", + code: "2", + conflict: false, + }); + }); + + it("读取文件元数据缺失时应抛出 typed not found 错误", async () => { + const fs = new BaiduFileSystem("/apps", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ errno: -9, errmsg: "file not found" }); + const reader = await fs.open({ + fsid: 123, + name: "missing.user.js", + path: "/apps", + size: 0, + digest: "", + createtime: 0, + updatetime: 0, + }); + + await expect(reader.read("string")).rejects.toSatisfy(isNotFoundError); + }); }); diff --git a/packages/filesystem/baidu/baidu.ts b/packages/filesystem/baidu/baidu.ts index 9184de2f0..dfdea8718 100644 --- a/packages/filesystem/baidu/baidu.ts +++ b/packages/filesystem/baidu/baidu.ts @@ -2,6 +2,7 @@ import { AuthVerify } from "../auth"; import type FileSystem from "../filesystem"; import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; +import { createBaiduFileSystemError } from "./error"; import { BaiduFileReader, BaiduFileWriter } from "./rw"; export default class BaiduFileSystem implements FileSystem { @@ -52,7 +53,7 @@ export default class BaiduFileSystem implements FileSystem { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } } @@ -62,18 +63,29 @@ export default class BaiduFileSystem implements FileSystem { config.headers = headers; // 对百度网盘请求显式禁用 cookie,避免依赖全局 DNR 规则造成并发竞态 config.credentials = "omit"; + const parseResponse = async (response: Response) => { + if (!response.ok) { + // 失败响应可能没有 JSON body,容忍解析失败以保留 HTTP 状态分类 + const data = await response.json().catch(() => ({ + errmsg: response.statusText || `HTTP ${response.status}`, + })); + throw createBaiduFileSystemError({ ...data, httpStatus: response.status }); + } + // 2xx 非 JSON(如代理返回的 HTML 页)必须报错,否则 list 会被判空导致全量覆盖云端 + return response.json(); + }; return fetch(url, config) - .then((data) => data.json()) + .then(parseResponse) .then(async (data) => { if (data.errno === 111 || data.errno === -6) { const token = await AuthVerify("baidu", true); this.accessToken = token; url = url.replace(/access_token=[^&]+/, `access_token=${token}`); return fetch(url, config) - .then((data2) => data2.json()) + .then(parseResponse) .then((data2) => { if (data2.errno === 111 || data2.errno === -6) { - throw new Error(JSON.stringify(data2)); + throw createBaiduFileSystemError(data2); } return data2; }); @@ -95,7 +107,7 @@ export default class BaiduFileSystem implements FileSystem { } ).then((data) => { if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } return data; }); @@ -126,7 +138,7 @@ export default class BaiduFileSystem implements FileSystem { if (data.errno === -9) { break; } - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } if (!data.list || data.list.length === 0) { diff --git a/packages/filesystem/baidu/error.ts b/packages/filesystem/baidu/error.ts new file mode 100644 index 000000000..ff8d3b3ec --- /dev/null +++ b/packages/filesystem/baidu/error.ts @@ -0,0 +1,37 @@ +import { FileSystemError } from "../error"; + +export type BaiduErrorResponse = { + errno?: number; + httpStatus?: number; + errmsg?: string; + error_msg?: string; + [key: string]: unknown; +}; + +const BAIDU_FILE_EXISTS_ERRNOS = new Set([31061]); + +export function createBaiduFileSystemError(data: BaiduErrorResponse): FileSystemError { + const code = typeof data.errno === "number" ? String(data.errno) : undefined; + const status = data.httpStatus; + const message = + data.errmsg || data.error_msg || (code ? `Baidu request failed with errno ${code}` : "Baidu request failed"); + const conflict = typeof data.errno === "number" && BAIDU_FILE_EXISTS_ERRNOS.has(data.errno); + const auth = data.errno === 111 || data.errno === -6 || status === 401; + const notFound = data.errno === -9 || status === 404; + const rateLimit = status === 429; + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + const transient = status !== undefined && [500, 502, 503, 504].includes(status); + + return new FileSystemError({ + provider: "baidu", + message, + status, + code, + conflict, + auth, + notFound, + rateLimit, + retryable: rateLimit || transient, + raw: data, + }); +} diff --git a/packages/filesystem/baidu/rw.ts b/packages/filesystem/baidu/rw.ts index df2e54519..6b3284982 100644 --- a/packages/filesystem/baidu/rw.ts +++ b/packages/filesystem/baidu/rw.ts @@ -1,6 +1,7 @@ import type { FileInfo, FileReader, FileWriter } from "../filesystem"; import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto"; import type BaiduFileSystem from "./baidu"; +import { createBaiduFileSystemError } from "./error"; export class BaiduFileReader implements FileReader { file: FileInfo; @@ -19,8 +20,11 @@ export class BaiduFileReader implements FileReader { this.fs.accessToken }&fsids=[${this.file.fsid!}]&dlink=1` ); - if (!data.list.length) { - throw new Error("file not found"); + if (data.errno) { + throw createBaiduFileSystemError(data); + } + if (!data.list?.length) { + throw createBaiduFileSystemError({ errno: -9, errmsg: "file not found" }); } const resp = await fetch(`${data.list[0].dlink}&access_token=${this.fs.accessToken}`); switch (type) { @@ -80,7 +84,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } const uploadid = data.uploadid; const body = new FormData(); @@ -102,7 +106,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } // 创建文件 urlencoded = new URLSearchParams(); @@ -121,7 +125,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } } } diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 6bf549d41..f88aa0ae7 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FileSystemError } from "../error"; import DropboxFileSystem from "./dropbox"; describe("DropboxFileSystem", () => { @@ -6,10 +7,222 @@ describe("DropboxFileSystem", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("request should throw typed not found error", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path_lookup/not_found/...", + error: { ".tag": "path_lookup", path_lookup: { ".tag": "not_found" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/get_metadata")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + code: "path_lookup/not_found/...", + notFound: true, + conflict: false, + }); + }); + + it("request should classify structured path_lookup not_found without error_summary", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error: { ".tag": "path_lookup", path_lookup: { ".tag": "not_found" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/get_metadata")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + notFound: true, + conflict: false, + }); + }); + + it("request should throw typed conflict error", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path/conflict/folder/...", + error: { ".tag": "path", path: { ".tag": "conflict" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/create_folder_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + code: "path/conflict/folder/...", + conflict: true, + notFound: false, + }); + }); + + it("request should classify structured path conflict without error_summary", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error: { ".tag": "path", path: { ".tag": "conflict" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/create_folder_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + conflict: true, + notFound: false, + }); + }); + + it("非冲突类 409(如无写权限)不应误判为 conflict", async () => { + // Dropbox 用 HTTP 409 承载所有结构化错误;若一律判 conflict, + // createDir 会把无写权限/空间不足当"目录已存在"静默吞掉 + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path/no_write_permission/..", + error: { ".tag": "path", path: { ".tag": "no_write_permission" } }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/create_folder_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + conflict: false, + notFound: false, + }); + }); + + it("delete 的 path_write/conflict 应判为 conflict", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => + JSON.stringify({ + error_summary: "path_write/conflict/file/..", + error: { ".tag": "path_write" }, + }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/delete_v2")).rejects.toMatchObject({ + provider: "dropbox", + status: 409, + conflict: true, + }); + }); + + it("request 遇到 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 501, + text: async () => "Not Implemented", + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/list_folder")).rejects.toMatchObject({ + provider: "dropbox", + status: 501, + retryable: false, + }); + }); + + it("request should throw typed rate-limit error", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => JSON.stringify({ error_summary: "too_many_requests/..." }), + }) + ); + + await expect(fs.request("https://api.dropboxapi.com/2/files/list_folder")).rejects.toMatchObject({ + provider: "dropbox", + status: 429, + code: "too_many_requests/...", + rateLimit: true, + retryable: true, + }); + }); + + it("读取文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new DropboxFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ + status: 429, + text: vi.fn().mockResolvedValue(JSON.stringify({ error_summary: "too_many_requests/..." })), + } as unknown as Response); + const reader = await fs.open({ + name: "limited.user.js", + path: "/", + size: 1, + digest: "digest", + createtime: 1, + updatetime: 1, + }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "dropbox", + status: 429, + code: "too_many_requests/...", + rateLimit: true, + retryable: true, + }); + }); + it("delete should be idempotent on path not found", async () => { const fs = new DropboxFileSystem("/", "token"); vi.spyOn(fs, "request").mockRejectedValue( - new Error('Dropbox API Error: 409 - {"error_summary":"path_lookup/not_found/..."}') + new FileSystemError({ + provider: "dropbox", + message: "not found", + status: 409, + code: "path_lookup/not_found/...", + notFound: true, + }) ); await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); @@ -18,7 +231,13 @@ describe("DropboxFileSystem", () => { it("exists should return false on path not found", async () => { const fs = new DropboxFileSystem("/", "token"); vi.spyOn(fs, "request").mockRejectedValue( - new Error('Dropbox API Error: 409 - {"error_summary":"path/not_found/..."}') + new FileSystemError({ + provider: "dropbox", + message: "not found", + status: 409, + code: "path/not_found/...", + notFound: true, + }) ); await expect(fs.exists("/missing.txt")).resolves.toBe(false); @@ -31,6 +250,31 @@ describe("DropboxFileSystem", () => { await expect(fs.exists("/test.txt")).rejects.toThrow("invalid_access_token"); }); + it("list should preserve Dropbox content_hash as opaque digest", async () => { + const fs = new DropboxFileSystem("/ScriptCat/sync", "token"); + vi.spyOn(fs, "request").mockResolvedValue({ + entries: [ + { + ".tag": "file", + name: "script.user.js", + size: 10, + content_hash: "dropbox-content-hash", + client_modified: "2026-01-01T00:00:00Z", + server_modified: "2026-01-01T00:00:01Z", + }, + ], + has_more: false, + }); + + await expect(fs.list()).resolves.toMatchObject([ + { + name: "script.user.js", + path: "/sync", + digest: "dropbox-content-hash", + }, + ]); + }); + it("create should normalize double slashes after the Dropbox app root", async () => { const fs = new DropboxFileSystem("/ScriptCat//sync", "token"); diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 46c264322..a914f60ff 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -1,12 +1,75 @@ import { AuthVerify } from "../auth"; +import { FileSystemError, isConflictError, isNotFoundError } from "../error"; import type FileSystem from "../filesystem"; import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import { DropboxFileReader, DropboxFileWriter } from "./rw"; -function isDropboxPathNotFound(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("path_lookup/not_found") || message.includes("path/not_found"); +type DropboxErrorBody = { + error_summary?: string; + error?: { + ".tag"?: string; + path_lookup?: { + ".tag"?: string; + }; + path?: { + ".tag"?: string; + }; + }; +}; + +function parseDropboxError(raw: unknown): { summary?: string; raw: unknown } { + let parsed = raw; + if (typeof raw === "string") { + try { + parsed = JSON.parse(raw); + } catch { + return { summary: raw, raw }; + } + } + + if (parsed && typeof parsed === "object") { + const body = parsed as DropboxErrorBody; + if (typeof body.error_summary === "string") { + return { summary: body.error_summary, raw: parsed }; + } + if (typeof body.error?.path_lookup?.[".tag"] === "string") { + return { summary: `path_lookup/${body.error.path_lookup[".tag"]}`, raw: parsed }; + } + if (typeof body.error?.path?.[".tag"] === "string") { + return { summary: `path/${body.error.path[".tag"]}`, raw: parsed }; + } + if (typeof body.error?.[".tag"] === "string") { + return { summary: body.error[".tag"], raw: parsed }; + } + } + + return { raw: parsed }; +} + +function toDropboxFileSystemError(status: number, raw: unknown): FileSystemError { + const { summary, raw: parsed } = parseDropboxError(raw); + const code = summary; + const notFound = summary?.includes("path_lookup/not_found") === true || summary?.includes("path/not_found") === true; + // Dropbox 用 HTTP 409 承载所有结构化错误(无写权限/空间不足等),只有明确 conflict 语义才算冲突 + const conflict = + !notFound && (summary?.includes("path/conflict") === true || summary?.includes("path_write/conflict") === true); + const rateLimit = status === 429; + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + const transient = [500, 502, 503, 504].includes(status); + const auth = status === 401 || summary?.includes("invalid_access_token") === true; + return new FileSystemError({ + provider: "dropbox", + message: `Dropbox API Error${status ? `: ${status}` : ""}${summary ? ` - ${summary}` : ""}`, + status, + code, + notFound, + conflict, + rateLimit, + auth, + retryable: rateLimit || transient, + raw: parsed, + }); } export default class DropboxFileSystem implements FileSystem { @@ -69,7 +132,7 @@ export default class DropboxFileSystem implements FileSystem { this.pathCache.add(fullPath); } catch (error: any) { // 如果目录已存在,Dropbox 会返回错误,但这是正常情况 - if (error.message && error.message.includes("path/conflict")) { + if (isConflictError(error)) { // 目录已存在,不需要报错 this.pathCache.add(fullPath); return; @@ -107,7 +170,7 @@ export default class DropboxFileSystem implements FileSystem { } if (!response.ok) { const errorText = await response.text(); - throw new Error(`Dropbox API Error: ${response.status} - ${errorText}`); + throw toDropboxFileSystemError(response.status, errorText); } return response.json(); }) @@ -122,23 +185,27 @@ export default class DropboxFileSystem implements FileSystem { .then(async (retryResponse) => { if (!retryResponse.ok) { const errorText = await retryResponse.text(); - throw new Error(`Dropbox API Error: ${retryResponse.status} - ${errorText}`); + throw toDropboxFileSystemError(retryResponse.status, errorText); } return retryResponse.json(); }) .then((retryData) => { if (retryData.error) { - throw new Error(JSON.stringify(retryData)); + throw toDropboxFileSystemError(200, retryData); } return retryData; }); } - throw new Error(JSON.stringify(data)); + throw toDropboxFileSystemError(200, data); } return data; }); } + async createResponseError(resp: Response): Promise { + return toDropboxFileSystemError(resp.status, await resp.text()); + } + async delete(path: string): Promise { const fullPath = joinPath(this.path, path); @@ -154,7 +221,7 @@ export default class DropboxFileSystem implements FileSystem { }), }); } catch (e: any) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return; } throw e; @@ -182,7 +249,7 @@ export default class DropboxFileSystem implements FileSystem { path: folderPath, }), }).catch((e) => { - if (e.message.includes("path/not_found")) { + if (isNotFoundError(e)) { return { entries: [], has_more: false }; // 返回空数组以避免后续错误 } throw e; @@ -247,7 +314,7 @@ export default class DropboxFileSystem implements FileSystem { }); return true; } catch (e) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return false; } throw e; diff --git a/packages/filesystem/dropbox/rw.ts b/packages/filesystem/dropbox/rw.ts index f63bfc2ea..076fc9e02 100644 --- a/packages/filesystem/dropbox/rw.ts +++ b/packages/filesystem/dropbox/rw.ts @@ -35,7 +35,7 @@ export class DropboxFileReader implements FileReader { ); if (data.status !== 200) { - return Promise.reject(await data.text()); + throw await this.fs.createResponseError(data); } switch (type) { diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 320f2cb55..eb5915e92 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -49,6 +49,119 @@ describe("GoogleDriveFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); + it("删除文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: 429, + message: "Quota exceeded", + status: "RESOURCE_EXHAUSTED", + }, + }), + }) + ); + + await expect(fs.delete("limited.txt")).rejects.toMatchObject({ + provider: "googledrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + + it("删除文件遇到 501 响应时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 501, + text: JSON.stringify({ + error: { + code: 501, + message: "Not implemented", + status: "UNIMPLEMENTED", + }, + }), + }) + ); + + await expect(fs.delete("stub.txt")).rejects.toMatchObject({ + provider: "googledrive", + status: 501, + retryable: false, + }); + }); + + it("403 userRateLimitExceeded 应视为 typed 限流错误", async () => { + // Drive API 至今仍常以 403 + reason=rateLimitExceeded/userRateLimitExceeded 表达限流, + // 官方建议指数退避,不能归为永久失败 + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 403, + text: JSON.stringify({ + error: { + code: 403, + message: "User Rate Limit Exceeded", + errors: [{ domain: "usageLimits", reason: "userRateLimitExceeded" }], + }, + }), + }) + ); + + await expect(fs.delete("limited.txt")).rejects.toMatchObject({ + provider: "googledrive", + status: 403, + rateLimit: true, + retryable: true, + }); + }); + + it("读取文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: 429, + message: "Quota exceeded", + status: "RESOURCE_EXHAUSTED", + }, + }), + }) + ); + const reader = await fs.open({ name: "limited.txt", path: "/", size: 0, digest: "", createtime: 0, updatetime: 0 }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "googledrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + + it("读取文件路径查找失败时应抛出 typed not found 错误", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.spyOn(fs, "getFileId").mockResolvedValue(null); + const requestSpy = vi.spyOn(fs, "request"); + const reader = await fs.open({ name: "missing.txt", path: "/", size: 0, digest: "", createtime: 0, updatetime: 0 }); + + await expect(reader.read("string")).rejects.toSatisfy(isNotFoundError); + expect(requestSpy).not.toHaveBeenCalled(); + }); + it("ensureDirExists should create missing nested directories and return final id", async () => { const fs = new GoogleDriveFileSystem("/", "token"); const findSpy = vi.spyOn(fs, "findFolderByName").mockResolvedValue(null); @@ -301,8 +414,9 @@ describe("GoogleDriveFileSystem", () => { } }); - it.each([409, 412])("request should throw typed conflict error for status %s", async (status) => { + it("请求遇到 409 时应抛出 typed conflict 错误", async () => { const fs = new GoogleDriveFileSystem("/", "token"); + const status = 409; vi.stubGlobal( "fetch", vi.fn().mockResolvedValueOnce( @@ -313,7 +427,7 @@ describe("GoogleDriveFileSystem", () => { error: { code: status, message: "Conflict", - status: status === 409 ? "ABORTED" : "FAILED_PRECONDITION", + status: "ABORTED", }, }), }) diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index b5934f20e..5f2c7401c 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -133,6 +133,15 @@ export default class GoogleDriveFileSystem implements FileSystem { ? raw : `Google Drive request failed${googleStatus ? ` with status ${googleStatus}` : ""}`; + // Drive API 至今仍常以 403 + reason=rateLimitExceeded/userRateLimitExceeded 表达限流(官方建议指数退避) + const reasons = Array.isArray(errorBody?.errors) + ? (errorBody.errors as { reason?: string }[]).map((item) => item?.reason) + : []; + const rateLimited403 = + googleStatus === 403 && + reasons.some((reason) => reason === "userRateLimitExceeded" || reason === "rateLimitExceeded"); + const rateLimit = googleStatus === 429 || rateLimited403; + return new FileSystemError({ provider: "googledrive", message, @@ -140,14 +149,15 @@ export default class GoogleDriveFileSystem implements FileSystem { code, auth: googleStatus === 401, notFound: googleStatus === 404, - conflict: googleStatus === 409 || googleStatus === 412, - rateLimit: googleStatus === 429, - retryable: googleStatus === 429 || (googleStatus !== undefined && googleStatus >= 500), + conflict: googleStatus === 409, + rateLimit, + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + retryable: rateLimit || (googleStatus !== undefined && [500, 502, 503, 504].includes(googleStatus)), raw, }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -232,7 +242,7 @@ export default class GoogleDriveFileSystem implements FileSystem { return; } if (resp.status !== 204 && resp.status !== 200) { - throw new Error(await resp.text()); + throw await this.createResponseError(resp); } }); diff --git a/packages/filesystem/googledrive/rw.ts b/packages/filesystem/googledrive/rw.ts index a19a6828d..ad69fef72 100644 --- a/packages/filesystem/googledrive/rw.ts +++ b/packages/filesystem/googledrive/rw.ts @@ -1,4 +1,4 @@ -import { isNotFoundError } from "../error"; +import { FileSystemError, isNotFoundError } from "../error"; import type { FileInfo, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import type GoogleDriveFileSystem from "./googledrive"; @@ -17,7 +17,12 @@ export class GoogleDriveFileReader implements FileReader { // 首先获取文件ID const fileId = await this.fs.getFileId(joinPath(this.file.path, this.file.name)); if (!fileId) { - return Promise.reject(new Error(`File not found: ${this.file.name}`)); + throw new FileSystemError({ + provider: "googledrive", + message: `File not found: ${this.file.name}`, + status: 404, + notFound: true, + }); } // 获取文件内容 @@ -28,7 +33,7 @@ export class GoogleDriveFileReader implements FileReader { ); if (data.status !== 200) { - return Promise.reject(await data.text()); + throw await this.fs.createResponseError(data); } switch (type) { diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts index f76ed56f4..a79f953a7 100644 --- a/packages/filesystem/limiter.test.ts +++ b/packages/filesystem/limiter.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { FileSystemError } from "./error"; import type FileSystem from "./filesystem"; import type { FileInfo, FileReader, FileWriter } from "./filesystem"; import LimiterFileSystem from "./limiter"; @@ -54,6 +55,66 @@ describe("LimiterFileSystem", () => { expect(fs.list).toHaveBeenCalledTimes(2); }); + it("应当根据 typed rateLimit 错误重试 list", async () => { + vi.useFakeTimers(); + const fs = createFs(); + vi.mocked(fs.list) + .mockRejectedValueOnce( + new FileSystemError({ + provider: "dropbox", + message: "Quota exhausted", + status: 429, + rateLimit: true, + retryable: true, + }) + ) + .mockResolvedValueOnce([]); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.list(); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toEqual([]); + expect(fs.list).toHaveBeenCalledTimes(2); + }); + + it("应当根据 typed retryable 5xx 错误重试 list", async () => { + vi.useFakeTimers(); + const fs = createFs(); + vi.mocked(fs.list) + .mockRejectedValueOnce( + new FileSystemError({ + provider: "googledrive", + message: "Service unavailable", + status: 503, + retryable: true, + }) + ) + .mockResolvedValueOnce([]); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.list(); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toEqual([]); + expect(fs.list).toHaveBeenCalledTimes(2); + }); + + it("遇到 typed conflict 错误时不能重试 list", async () => { + const fs = createFs(); + const conflictError = new FileSystemError({ + provider: "onedrive", + message: "Conflict", + status: 409, + conflict: true, + }); + vi.mocked(fs.list).mockRejectedValueOnce(conflictError); + const limiter = new LimiterFileSystem(fs); + + await expect(limiter.list()).rejects.toBe(conflictError); + expect(fs.list).toHaveBeenCalledTimes(1); + }); + it("should retry verify on 429", async () => { vi.useFakeTimers(); const fs = createFs(); @@ -163,6 +224,26 @@ describe("LimiterFileSystem", () => { expect(write).toHaveBeenCalledTimes(1); }); + it("writer.write 遇到 typed retryable 错误时仍不能重试", async () => { + const fs = createFs(); + const write = vi.fn(async () => {}); + const retryableError = new FileSystemError({ + provider: "googledrive", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.create).mockResolvedValueOnce({ + write, + }); + write.mockRejectedValueOnce(retryableError); + const limiter = new LimiterFileSystem(fs); + const writer = await limiter.create(file.path); + + await expect(writer.write("content")).rejects.toBe(retryableError); + expect(write).toHaveBeenCalledTimes(1); + }); + it("should retry reader.read on 429", async () => { vi.useFakeTimers(); const fs = createFs(); diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 5dfb54b81..1c1bed37a 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,7 +1,8 @@ import type FileSystem from "./filesystem"; import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; +import { FileSystemError } from "./error"; -const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); +const RETRYABLE_TRANSIENT_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); /** * 速率限制器 @@ -21,7 +22,7 @@ export class RateLimiter { /** * 执行限速操作 * @param fn 要执行的操作函数 - * @param op 操作类型,用于在遇到 429 时判断是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 + * @param op 操作类型,用于判断 transient 错误是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 * @returns 操作结果 */ async execute(fn: () => Promise, op = "unknown"): Promise { @@ -46,9 +47,9 @@ export class RateLimiter { } /** - * 执行操作并处理 429 错误重试 + * 执行操作并处理 transient 错误重试 * @param fn 要执行的操作函数 - * @param op 操作类型,用于判定该操作在遇到 429 时是否进入指数退避重试 + * @param op 操作类型,用于判定该操作在遇到 transient 错误时是否进入指数退避重试 * @returns 操作结果 */ private async executeWithRetry(fn: () => Promise, op: string): Promise { @@ -57,10 +58,8 @@ export class RateLimiter { try { return await fn(); } catch (error) { - // 检查错误字符串中是否包含 429 - const errorStr = String(error).toLowerCase(); - if (this.shouldRetry429(op, ` ${errorStr} `) && i < 10) { - // 遇到 429 错误且未达到重试上限,采用指数退避策略延迟后继续重试 + if (this.shouldRetryTransient(op, error) && i < 10) { + // 遇到 transient 错误且未达到重试上限,采用指数退避策略延迟后继续重试 const delay = Math.min(2000 * Math.pow(2, i), 60000); await new Promise((resolve) => setTimeout(resolve, delay)); // 继续下一次循环重试 @@ -74,11 +73,15 @@ export class RateLimiter { throw new Error(`Max retries exceeded (op=${op})`); } - private shouldRetry429(op: string, errorStr: string): boolean { - return ( - ((errorStr.includes("429") && /[^a-z\d]429[^a-z\d]/.test(errorStr)) || errorStr.includes("too many requests")) && - RETRYABLE_429_OPS.has(op) - ); + private shouldRetryTransient(op: string, error: unknown): boolean { + if (!RETRYABLE_TRANSIENT_OPS.has(op)) { + return false; + } + if (error instanceof FileSystemError) { + return error.rateLimit || error.retryable; + } + const errorStr = ` ${String(error).toLowerCase()} `; + return (errorStr.includes("429") && /[^a-z\d]429[^a-z\d]/.test(errorStr)) || errorStr.includes("too many requests"); } } diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index a7400cad1..20beaffe8 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -95,6 +95,76 @@ describe("OneDriveFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); + it("删除文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new OneDriveFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: "TooManyRequests", + message: "Too many requests", + }, + }), + }) + ); + + await expect(fs.delete("limited.txt")).rejects.toMatchObject({ + provider: "onedrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + + it("读取文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { + const fs = new OneDriveFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: "TooManyRequests", + message: "Too many requests", + }, + }), + }) + ); + const reader = await fs.open({ name: "limited.txt", path: "/", size: 0, digest: "", createtime: 0, updatetime: 0 }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "onedrive", + status: 429, + rateLimit: true, + retryable: true, + }); + }); + + it("删除文件遇到 501 响应时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const fs = new OneDriveFileSystem("/", "token"); + vi.spyOn(fs, "request").mockResolvedValue( + createMockResponse({ + ok: false, + status: 501, + text: JSON.stringify({ + error: { + code: "notImplemented", + message: "Not implemented", + }, + }), + }) + ); + + await expect(fs.delete("stub.txt")).rejects.toMatchObject({ + provider: "onedrive", + status: 501, + retryable: false, + }); + }); + it("create should normalize double slashes in paths", async () => { const fs = new OneDriveFileSystem("/ScriptCat//sync", "token"); @@ -233,11 +303,10 @@ describe("OneDriveFileSystem", () => { } }); - it.each([ - [409, "nameAlreadyExists"], - [412, "PreconditionFailed"], - ])("request should throw typed conflict error for status %s", async (status, code) => { + it("请求遇到 409 nameAlreadyExists 时应抛出 typed conflict 错误", async () => { const fs = new OneDriveFileSystem("/", "token"); + const status = 409; + const code = "nameAlreadyExists"; vi.stubGlobal( "fetch", vi.fn().mockResolvedValueOnce( diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 3a2bef2b0..6da951ab0 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -92,13 +92,10 @@ export default class OneDriveFileSystem implements FileSystem { : `OneDrive request failed${status ? ` with status ${status}` : ""}`; const auth = status === 401 || code === "InvalidAuthenticationToken"; const notFound = status === 404 || code === "itemNotFound"; - const conflict = - status === 409 || - status === 412 || - code === "nameAlreadyExists" || - code === "itemAlreadyExists" || - code === "PreconditionFailed"; + const conflict = status === 409 || code === "nameAlreadyExists" || code === "itemAlreadyExists"; const rateLimit = status === 429; + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + const transient = status !== undefined && [500, 502, 503, 504].includes(status); return new FileSystemError({ provider: "onedrive", @@ -109,12 +106,12 @@ export default class OneDriveFileSystem implements FileSystem { notFound, conflict, rateLimit, - retryable: rateLimit || (status !== undefined && status >= 500), + retryable: rateLimit || transient, raw, }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -127,7 +124,7 @@ export default class OneDriveFileSystem implements FileSystem { request(url: string, config?: RequestInit, nothen?: boolean): Promise { config = config || {}; - const headers = config.headers || new Headers(); + const headers = new Headers(config.headers); if (!url.includes("uploadSession")) { headers.set(`Authorization`, `Bearer ${this.accessToken}`); } @@ -183,18 +180,19 @@ export default class OneDriveFileSystem implements FileSystem { } async delete(path: string): Promise { + const config: RequestInit = { + method: "DELETE", + }; const resp = await this.request( `https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath(this.path, path)}`, - { - method: "DELETE", - }, + config, true ); if (resp.status === 404) { return; } if (resp.status !== 204) { - throw new Error(await resp.text()); + throw await this.createResponseError(resp); } } diff --git a/packages/filesystem/onedrive/rw.ts b/packages/filesystem/onedrive/rw.ts index b0d33d55a..86f7788f9 100644 --- a/packages/filesystem/onedrive/rw.ts +++ b/packages/filesystem/onedrive/rw.ts @@ -20,7 +20,7 @@ export class OneDriveFileReader implements FileReader { true ); if (data.status !== 200) { - throw new Error(await data.text()); + throw await this.fs.createResponseError(data); } switch (type) { case "string": @@ -60,10 +60,11 @@ export class OneDriveFileWriter implements FileWriter { // 预上传获取id const size = this.size(content); if (size === 0) { - return this.fs.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/content`, { + const config: RequestInit = { method: "PUT", body: content, - }); + }; + return this.fs.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/content`, config); } let myHeaders = new Headers(); diff --git a/packages/filesystem/s3/error.ts b/packages/filesystem/s3/error.ts new file mode 100644 index 000000000..ea9cf2f66 --- /dev/null +++ b/packages/filesystem/s3/error.ts @@ -0,0 +1,25 @@ +import { FileSystemError } from "../error"; +import { S3Error } from "./client"; + +export function createS3FileSystemError(error: unknown): unknown { + if (!(error instanceof S3Error)) { + return error; + } + + const rateLimit = error.statusCode === 429 || error.code === "SlowDown"; + // 只重试瞬时 5xx;501/505/507 等属于永久失败,重试只会空转退避 + const transient = [500, 502, 503, 504].includes(error.statusCode); + + return new FileSystemError({ + provider: "s3", + message: error.message, + status: error.statusCode, + code: error.code, + auth: error.statusCode === 401 || error.statusCode === 403, + notFound: error.statusCode === 404 || error.code === "NoSuchKey" || error.code === "NoSuchBucket", + conflict: error.statusCode === 409, + rateLimit, + retryable: rateLimit || transient, + raw: error, + }); +} diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index 378fb18ad..595f9d2e2 100644 --- a/packages/filesystem/s3/rw.ts +++ b/packages/filesystem/s3/rw.ts @@ -1,5 +1,6 @@ import type { S3Client } from "./client"; -import type { FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import { createS3FileSystemError } from "./error"; /** * S3 文件读取器 @@ -25,11 +26,15 @@ export class S3FileReader implements FileReader { * @throws {S3Error} 文件不存在或读取失败 */ async read(type: "string" | "blob" = "blob"): Promise { - const response = await this.client.request("GET", this.bucket, this.key); - if (type === "string") { - return response.text(); + try { + const response = await this.client.request("GET", this.bucket, this.key); + if (type === "string") { + return response.text(); + } + return response.blob(); + } catch (error) { + throw createS3FileSystemError(error); } - return response.blob(); } } @@ -46,11 +51,11 @@ export class S3FileWriter implements FileWriter { modifiedDate?: number; - constructor(client: S3Client, bucket: string, key: string, modifiedDate?: number) { + constructor(client: S3Client, bucket: string, key: string, opts?: FileCreateOptions) { this.client = client; this.bucket = bucket; this.key = key; - this.modifiedDate = modifiedDate; + this.modifiedDate = opts?.modifiedDate; } /** @@ -68,10 +73,13 @@ export class S3FileWriter implements FileWriter { // 历史兼容:S3 侧使用 createtime 元数据保存文件时间,实际来源是 FileCreateOptions.modifiedDate。 headers["x-amz-meta-createtime"] = new Date(this.modifiedDate).toISOString(); } - - await this.client.request("PUT", this.bucket, this.key, { - body: typeof body === "string" ? body : body, - headers, - }); + try { + await this.client.request("PUT", this.bucket, this.key, { + body: typeof body === "string" ? body : body, + headers, + }); + } catch (error) { + throw createS3FileSystemError(error); + } } } diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 8e52a51c7..99484cf8f 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -155,6 +155,52 @@ describe("S3FileSystem", () => { expect(mockClient.request).toHaveBeenCalledWith("GET", "test-bucket", "data/hello.txt"); expect(content).toBe("file content"); }); + + it("读取文件遇到 503 时应当抛出 typed 可重试错误", async () => { + const fileInfo: FileInfo = { + name: "busy.user.js", + path: "/", + size: 50, + digest: "xyz", + createtime: 1000, + updatetime: 2000, + }; + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("ServiceUnavailable", "Please retry later", 503) + ); + + const reader = await fs.open(fileInfo); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "s3", + status: 503, + code: "ServiceUnavailable", + retryable: true, + }); + }); + + it("读取文件遇到 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败(如后端不支持条件请求),重试只会空转退避 + const fileInfo: FileInfo = { + name: "busy.user.js", + path: "/", + size: 50, + digest: "xyz", + createtime: 1000, + updatetime: 2000, + }; + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("NotImplemented", "A header you provided implies functionality that is not implemented", 501) + ); + + const reader = await fs.open(fileInfo); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "s3", + status: 501, + retryable: false, + }); + }); }); // ---- create ---- @@ -400,6 +446,20 @@ describe("S3FileSystem", () => { await expect(fs.list()).rejects.toThrow("Permission denied"); }); + + it("列目录遇到 429 时应当抛出 typed 限流错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("SlowDown", "Please reduce your request rate", 429) + ); + + await expect(fs.list()).rejects.toMatchObject({ + provider: "s3", + status: 429, + code: "SlowDown", + rateLimit: true, + retryable: true, + }); + }); }); // ---- openDir ---- diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index 41ce89e6a..c0c9a671d 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -6,6 +6,7 @@ import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../fil import { joinPath } from "../utils"; import { S3FileReader, S3FileWriter } from "./rw"; import { WarpTokenError } from "../error"; +import { createS3FileSystemError } from "./error"; // ---- ListObjectsV2 XML 解析 ---- @@ -167,7 +168,7 @@ export default class S3FileSystem implements FileSystem { * @returns 文件写入器 */ async create(path: string, opts?: FileCreateOptions): Promise { - return new S3FileWriter(this.client, this.bucket, joinPath(this.basePath, path).substring(1), opts?.modifiedDate); + return new S3FileWriter(this.client, this.bucket, joinPath(this.basePath, path).substring(1), opts); } /** @@ -184,13 +185,14 @@ export default class S3FileSystem implements FileSystem { */ async delete(path: string): Promise { try { - await this.client.request("DELETE", this.bucket, joinPath(this.basePath, path).substring(1)); + const key = joinPath(this.basePath, path).substring(1); + await this.client.request("DELETE", this.bucket, key); } catch (error: any) { // S3 delete 是幂等的,key 不存在时也视为成功 if (error instanceof S3Error && error.code === "NoSuchKey") { return; } - throw error; + throw createS3FileSystemError(error); } } @@ -254,7 +256,7 @@ export default class S3FileSystem implements FileSystem { if (error instanceof S3Error && error.code === "AccessDenied") { throw new Error(`Permission denied. Check your IAM permissions for bucket: ${this.bucket}`); } - throw error; + throw createS3FileSystemError(error); } } diff --git a/packages/filesystem/webdav/error.ts b/packages/filesystem/webdav/error.ts new file mode 100644 index 000000000..e75f86751 --- /dev/null +++ b/packages/filesystem/webdav/error.ts @@ -0,0 +1,31 @@ +import { FileSystemError } from "../error"; + +type WebDAVLikeError = { + message?: string; + response?: { + status?: number; + }; +}; + +export function createWebDAVFileSystemError(error: unknown): unknown { + const webdavError = error as WebDAVLikeError; + const status = webdavError?.response?.status; + if (typeof status !== "number") { + return error; + } + + const rateLimit = status === 429; + // 只重试瞬时 5xx;501/505/507 等属于永久失败,重试只会空转退避 + const transient = [500, 502, 503, 504].includes(status); + + return new FileSystemError({ + provider: "webdav", + message: webdavError.message || `WebDAV request failed with status ${status}`, + status, + auth: status === 401 || status === 403, + notFound: status === 404, + rateLimit, + retryable: rateLimit || transient, + raw: error, + }); +} diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index c13afe2f4..9a03eefe9 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,5 +1,7 @@ import type { WebDAVClient } from "webdav"; import type { FileReader, FileWriter } from "../filesystem"; +import { FileSystemError } from "../error"; +import { createWebDAVFileSystemError } from "./error"; export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -12,17 +14,21 @@ export class WebDAVFileReader implements FileReader { } async read(type?: "string" | "blob"): Promise { - switch (type) { - case "string": - return await (this.client.getFileContents(this.path, { - format: "text", - }) as Promise); - default: { - const resp = (await this.client.getFileContents(this.path, { - format: "binary", - })) as ArrayBuffer; - return new Blob([resp]); + try { + switch (type) { + case "string": + return await (this.client.getFileContents(this.path, { + format: "text", + }) as Promise); + default: { + const resp = (await this.client.getFileContents(this.path, { + format: "binary", + })) as ArrayBuffer; + return new Blob([resp]); + } } + } catch (error) { + throw createWebDAVFileSystemError(error); } } } @@ -39,9 +45,19 @@ export class WebDAVFileWriter implements FileWriter { async write(content: string | Blob): Promise { const data = content instanceof Blob ? await content.arrayBuffer() : content; - const resp = await this.client.putFileContents(this.path, data); + let resp: boolean; + try { + resp = await this.client.putFileContents(this.path, data); + } catch (error) { + throw createWebDAVFileSystemError(error); + } if (!resp) { - throw new Error("write error"); + throw new FileSystemError({ + provider: "webdav", + message: "WebDAV write failed", + status: 500, + retryable: false, + }); } } } diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 4d91a5edb..ec4277604 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { WebDAVClient } from "webdav"; import { getPatcher } from "webdav"; import WebDAVFileSystem from "./webdav"; -import { WarpTokenError } from "../error"; +import { FileSystemError, WarpTokenError } from "../error"; +import { createWebDAVFileSystemError } from "./error"; /** 创建 mock WebDAVClient */ function createMockClient(overrides?: Partial): WebDAVClient { @@ -102,22 +103,43 @@ describe("WebDAVFileSystem", () => { await expect(fs.verify()).rejects.toBeInstanceOf(WarpTokenError); }); - it("应当在 getQuota 其他错误时抛出包含原始信息的 Error", async () => { + it("应当在 getQuota 瞬时 5xx 时抛出可重试的 FileSystemError(供 limiter 重试、下游判 transient)", async () => { (mockClient.getQuota as ReturnType).mockRejectedValue({ - message: "Network error", + message: "Service Unavailable", + response: { status: 503 }, }); const fs = createTestFS(mockClient); - await expect(fs.verify()).rejects.toThrow("WebDAV verify failed: Network error"); + const err = await fs.verify().then( + () => null, + (e) => e + ); + expect(err).toBeInstanceOf(FileSystemError); + expect((err as FileSystemError).retryable).toBe(true); }); - it("应当在 getDirectoryContents 其他错误时抛出包含原始信息的 Error", async () => { + it("应当在 getDirectoryContents 瞬时 5xx 时抛出可重试的 FileSystemError", async () => { (mockClient.getDirectoryContents as ReturnType).mockRejectedValue({ - message: "Network error", + message: "Internal Server Error", + response: { status: 500 }, }); const fs = createTestFS(mockClient); - await expect(fs.verify()).rejects.toThrow("WebDAV verify failed: Network error"); + const err = await fs.verify().then( + () => null, + (e) => e + ); + expect(err).toBeInstanceOf(FileSystemError); + expect((err as FileSystemError).retryable).toBe(true); + }); + }); + + describe("createWebDAVFileSystemError", () => { + it("409 不应判定为冲突(RFC 4918 中 PUT/MKCOL 的 409 是父集合不存在)", () => { + const err = createWebDAVFileSystemError({ message: "Conflict", response: { status: 409 } }); + + expect(err).toBeInstanceOf(FileSystemError); + expect((err as FileSystemError).conflict).toBe(false); }); }); @@ -221,6 +243,51 @@ describe("WebDAVFileSystem", () => { }); }); + describe("open", () => { + it("读取文件遇到 503 时应当抛出 typed 可重试错误", async () => { + const err = new Error("Service Unavailable"); + (err as any).response = { status: 503 }; + (mockClient.getFileContents as ReturnType).mockRejectedValue(err); + const fs = createTestFS(mockClient); + const reader = await fs.open({ + name: "busy.user.js", + path: "/", + size: 1, + digest: "digest", + createtime: 1, + updatetime: 1, + }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "webdav", + status: 503, + retryable: true, + }); + }); + + it("读取文件遇到 501 时不应标记为可重试", async () => { + // 501 Not Implemented 是永久失败,重试只会空转退避 + const err = new Error("Not Implemented"); + (err as any).response = { status: 501 }; + (mockClient.getFileContents as ReturnType).mockRejectedValue(err); + const fs = createTestFS(mockClient); + const reader = await fs.open({ + name: "busy.user.js", + path: "/", + size: 1, + digest: "digest", + createtime: 1, + updatetime: 1, + }); + + await expect(reader.read("string")).rejects.toMatchObject({ + provider: "webdav", + status: 501, + retryable: false, + }); + }); + }); + describe("list", () => { it("应当列出文件并过滤目录", async () => { (mockClient.getDirectoryContents as ReturnType).mockResolvedValue([ @@ -270,6 +337,20 @@ describe("WebDAVFileSystem", () => { await expect(fs.list()).rejects.toThrow("Server Error"); }); + + it("列目录遇到 429 时应当抛出 typed 限流错误", async () => { + const err = new Error("Too Many Requests"); + (err as any).response = { status: 429 }; + (mockClient.getDirectoryContents as ReturnType).mockRejectedValue(err); + const fs = createTestFS(mockClient); + + await expect(fs.list()).rejects.toMatchObject({ + provider: "webdav", + status: 429, + rateLimit: true, + retryable: true, + }); + }); }); describe("getDirUrl", () => { diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 871097537..b3bc789f0 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -5,6 +5,7 @@ import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../fil import { joinPath } from "../utils"; import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; import { WarpTokenError } from "../error"; +import { createWebDAVFileSystemError } from "./error"; // 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) // 全局单次注册 @@ -63,7 +64,9 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 401) { throw new WarpTokenError(e); } - throw new Error(`WebDAV verify failed: ${e.message}`); // 保留原始信息 + // 转 typed error(与 list/read 等一致):verify 在 RETRYABLE_TRANSIENT_OPS 内且每轮同步经 factory 必跑, + // 抛普通 Error 会让瞬时 5xx 既不被 limiter 重试、又被 classifySyncError 判 fatal 而中止整轮 + throw createWebDAVFileSystemError(e); } } @@ -87,7 +90,7 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 405 || e.message?.includes("405")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } @@ -98,7 +101,7 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 404 || e.message?.includes("404")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } @@ -108,7 +111,7 @@ export default class WebDAVFileSystem implements FileSystem { dir = (await this.client.getDirectoryContents(this.basePath)) as FileStat[]; } catch (e: any) { if (e.response?.status === 404) return [] as FileInfo[]; // 目录不存在视为空 - throw e; + throw createWebDAVFileSystemError(e); } const ret: FileInfo[] = []; for (const item of dir) { diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 720564940..ac046020d 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -315,6 +315,11 @@ export class SynchronizeClient extends Client { restoreConfigBundle(bundle: ConfigBundle): Promise { return this.do("restoreConfigBundle", bundle); } + + // 手动触发一次云同步(设置页「立即同步」) + cloudSyncOnce(): Promise { + return this.do("cloudSyncOnce"); + } } export class SubscribeClient extends Client { diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 453f97556..0f3f76602 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { SynchronizeService } from "./synchronize"; import { initTestEnv } from "@Tests/utils"; import type FileSystem from "@Packages/filesystem/filesystem"; +import { FileSystemError } from "@Packages/filesystem/error"; import type { CloudSyncConfig } from "@App/pkg/config/config"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { md5OfText } from "@App/pkg/utils/crypto"; @@ -9,6 +10,7 @@ import FileSystemFactory from "@Packages/filesystem/factory"; import { AgentModelRepo } from "@App/app/repo/agent_model"; import ChromeStorage from "@App/pkg/config/chrome_storage"; import { createMockOPFS } from "@App/app/repo/test-helpers"; +import { cacheInstance } from "@App/app/cache"; initTestEnv(); @@ -38,7 +40,8 @@ const createFs = (overrides: Partial = {}): FileSystem => }) as unknown as FileSystem; // 等待若干轮微任务,确保所有已就绪的 await 都被推进 -const flushMicrotasks = async (rounds = 10) => { +// (syncOnceInternal 开头有 pending_sync_ops 存储读取,轮数须覆盖它引入的额外 await) +const flushMicrotasks = async (rounds = 30) => { for (let i = 0; i < rounds; i++) { await Promise.resolve(); } @@ -323,176 +326,2468 @@ describe("SynchronizeService", () => { expect(written.status.scripts.orphan).toEqual(orphanStatus); }); - it("waits for installScript during pullScript", async () => { - let releaseInstall!: () => void; - const installGate = new Promise((resolve) => { - releaseInstall = resolve; - }); - const installScript = vi.fn().mockImplementation(() => installGate); + it("兼容缺少 status.scripts 的旧版 scriptcat-sync.json", async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); const fs = createFs({ - open: vi.fn().mockImplementation(async (file) => ({ - read: vi.fn().mockResolvedValue( - file.name.endsWith(".user.js") - ? `// ==UserScript== -// @name Pull Test -// @namespace sync-test -// @match https://example.com/* -// ==/UserScript== -console.log("ok");` - : JSON.stringify({ uuid: "pull-uuid" }) - ), - })), + list: vi.fn().mockResolvedValue([ + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ version: "1.0.0" })), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), }); const service = new SynchronizeService( {} as any, {} as any, - { - installScript, - } as any, + {} as any, {} as any, {} as any, {} as any, {} as any, { scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), } as any ); - let settled = false; - const pullPromise = service - .pullScript( - fs, - { - script: { - name: "pull-uuid.user.js", - path: "pull-uuid.user.js", + await service.syncOnce(syncConfig, fs); + + expect(writeMock).toHaveBeenCalledTimes(1); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts).toEqual({}); + }); + + it("scriptcat-sync.json 损坏时不阻塞脚本同步且不覆盖状态文件", async () => { + const createMock = vi.fn().mockResolvedValue({ + write: vi.fn().mockResolvedValue(undefined), + }); + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", size: 1, - digest: "d1", + digest: "sync-digest", createtime: 1, updatetime: 1, }, - meta: { - name: "pull-uuid.meta.json", - path: "pull-uuid.meta.json", + ]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", size: 1, - digest: "d2", + digest: "cloud-ok-user", createtime: 1, updatetime: 1, }, - }, - undefined - ) - .then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); + { + name: "ok.meta.json", + path: "ok.meta.json", + size: 1, + digest: "cloud-ok-meta", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue("{"), + }), + create: createMock, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({ + "ok.user.js": "pushed-ok-user", + "ok.meta.json": "pushed-ok-meta", + }); - releaseInstall(); - await pullPromise; + await service.syncOnce(syncConfig, fs); - expect(installScript).toHaveBeenCalledTimes(1); - expect(settled).toBe(true); + expect(service.pushScript).toHaveBeenCalledTimes(1); + expect(createMock).not.toHaveBeenCalled(); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "ok.user.js": "cloud-ok-user", + "ok.meta.json": "cloud-ok-meta", + }); }); - it("waits for deleteScript before updating file digest", async () => { - let releaseDelete!: () => void; - const deleteGate = new Promise((resolve) => { - releaseDelete = resolve; + it("写回 scriptcat-sync.json 前重新读取远端状态,避免覆盖其他设备更新", async () => { + const initialStatus = { enable: false, sort: 7, updatetime: 200 }; + const latestStatus = { enable: true, sort: 9, updatetime: 300 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const syncFile = { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }; + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "status-uuid.user.js", + path: "status-uuid.user.js", + size: 1, + digest: "script-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "status-uuid.meta.json", + path: "status-uuid.meta.json", + size: 1, + digest: "meta-digest", + createtime: 1, + updatetime: 1, + }, + syncFile, + ]), + open: vi + .fn() + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": initialStatus } }, + }) + ), + }) + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": latestStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), }); - const order: string[] = []; - const deleteScript = vi.fn().mockImplementation(async () => { - order.push("delete:start"); - await deleteGate; - order.push("delete:end"); + const scriptDAO = { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "status-uuid", + name: "status", + updatetime: 100, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + }; + const service = new SynchronizeService( + {} as any, + {} as any, + { + enableScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + scriptDAO as any + ); + await (service as any).storage.set("file_digest", { + "status-uuid.user.js": "script-digest", }); - // fs.list 第二次调用对应 updateFileDigest,这是 syncOnce 的最后一步 - const fsList = vi - .fn() - .mockImplementationOnce(async () => [ + + await service.syncOnce(syncConfig, fs); + + expect(fs.open).toHaveBeenCalledTimes(2); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts["status-uuid"]).toEqual(latestStatus); + }); + + it("写回 scriptcat-sync.json 时本地较新的状态仍覆盖远端旧状态", async () => { + const initialStatus = { enable: false, sort: 7, updatetime: 100 }; + const latestStatus = { enable: false, sort: 8, updatetime: 150 }; + const localStatus = { enable: true, sort: 1, updatetime: 200 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ { - name: "del-uuid.meta.json", - path: "del-uuid.meta.json", + name: "status-uuid.user.js", + path: "status-uuid.user.js", size: 1, - digest: "d1", + digest: "script-digest", + createtime: 1, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, + }, + { + name: "status-uuid.meta.json", + path: "status-uuid.meta.json", + size: 1, + digest: "meta-digest", createtime: 1, updatetime: 1, }, - ]) - .mockImplementationOnce(async () => { - order.push("digest:list"); - return []; - }); + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi + .fn() + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": initialStatus } }, + }) + ), + }) + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": latestStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + enableScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "status-uuid", + name: "status", + updatetime: localStatus.updatetime, + createtime: 1, + status: 1, + sort: localStatus.sort, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + } as any + ); + await (service as any).storage.set("file_digest", { + "status-uuid.user.js": "script-digest", + }); + + await service.syncOnce(syncConfig, fs); + + expect(fs.open).toHaveBeenCalledTimes(2); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts["status-uuid"]).toEqual(localStatus); + }); + + it("写回 scriptcat-sync.json 时远端已删除的 uuid 不应被复活", async () => { + const initialStatus = { enable: true, sort: 1, updatetime: 100 }; + const writeMock = vi.fn().mockResolvedValue(undefined); const fs = createFs({ - list: fsList, - open: vi.fn().mockResolvedValue({ - read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "del-uuid", isDeleted: true })), - }), + list: vi.fn().mockResolvedValue([ + { + name: "kept-uuid.user.js", + path: "kept-uuid.user.js", + size: 1, + digest: "d", + createtime: 1, + updatetime: 1, + }, + { + name: "kept-uuid.meta.json", + path: "kept-uuid.meta.json", + size: 1, + digest: "d", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-d", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi + .fn() + // initial read: both uuids present + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { + scripts: { + "kept-uuid": initialStatus, + "deleted-uuid": initialStatus, + }, + }, + }) + ), + }) + // latest re-read: deleted-uuid removed by another device + .mockResolvedValueOnce({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "kept-uuid": initialStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), }); const service = new SynchronizeService( {} as any, {} as any, - { deleteScript } as any, + { enableScript: vi.fn().mockResolvedValue(undefined) } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "kept-uuid", + name: "kept", + updatetime: 100, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + } as any + ); + // digest 与云端一致的稳态:本测试只考察 status 写回合并,不涉及方向判定 + await (service as any).storage.set("file_digest", { "kept-uuid.user.js": "d", "kept-uuid.meta.json": "d" }); + + await service.syncOnce(syncConfig, fs); + + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts).not.toHaveProperty("deleted-uuid"); + expect(written.status.scripts).toHaveProperty("kept-uuid"); + }); + + it("syncStatus 单个脚本排序更新失败时不阻塞整轮同步", async () => { + const cloudStatus = { enable: true, sort: 9, updatetime: 200 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "status-uuid.user.js", + path: "status-uuid.user.js", + size: 1, + digest: "script-digest", + createtime: 1, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, + }, + { + name: "status-uuid.meta.json", + path: "status-uuid.meta.json", + size: 1, + digest: "meta-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { "status-uuid": cloudStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const scriptDAO = { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "status-uuid", + name: "status", + updatetime: 100, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + update: vi.fn().mockRejectedValue(new Error("sort update failed")), + }; + const service = new SynchronizeService( + {} as any, + {} as any, + { + enableScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + scriptDAO as any + ); + await (service as any).storage.set("file_digest", { + "status-uuid.user.js": "script-digest", + "status-uuid.meta.json": "meta-digest", + "scriptcat-sync.json": "sync-digest", + }); + + await service.syncOnce(syncConfig, fs); + + expect(scriptDAO.update).toHaveBeenCalledWith("status-uuid", { sort: cloudStatus.sort }); + expect(writeMock).toHaveBeenCalledTimes(1); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts["status-uuid"]).toEqual(cloudStatus); + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "status-uuid.user.js": "script-digest", + "status-uuid.meta.json": "meta-digest", + "scriptcat-sync.json": "sync-digest", + }); + }); + + it("脚本文件同步失败时仍可回写其它脚本的 syncStatus 并保留失败脚本云端状态", async () => { + const failedCloudStatus = { enable: false, sort: 3, updatetime: 300 }; + const okCloudStatus = { enable: false, sort: 1, updatetime: 100 }; + const okLocalStatus = { enable: true, sort: 9, updatetime: 200 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "failed.user.js", + path: "failed.user.js", + size: 1, + digest: "failed-new-digest", + createtime: 1, + updatetime: 300, + }, + { + name: "failed.meta.json", + path: "failed.meta.json", + size: 1, + digest: "failed-meta-digest", + createtime: 1, + updatetime: 300, + }, + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "ok-digest", + createtime: 1, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, + }, + { + name: "ok.meta.json", + path: "ok.meta.json", + size: 1, + digest: "ok-meta-digest", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockImplementation((file) => { + if (file.name === "scriptcat-sync.json") { + return Promise.resolve({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { + scripts: { + failed: failedCloudStatus, + ok: okCloudStatus, + }, + }, + }) + ), + }); + } + return Promise.reject(new Error("read failed")); + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "failed", + name: "failed", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + { + uuid: "ok", + name: "ok", + updatetime: okLocalStatus.updatetime, + createtime: 1, + status: 1, + sort: okLocalStatus.sort, + metadata: {}, + }, + ]), + update: vi.fn().mockResolvedValue(undefined), + } as any + ); + await (service as any).storage.set("file_digest", { + "failed.user.js": "failed-old-digest", + "failed.meta.json": "failed-meta-digest", + "ok.user.js": "ok-digest", + "ok.meta.json": "ok-meta-digest", + }); + + await service.syncOnce(syncConfig, fs); + + expect(writeMock).toHaveBeenCalledTimes(1); + const written = JSON.parse(writeMock.mock.calls[0][0] as string); + expect(written.status.scripts.failed).toEqual(failedCloudStatus); + expect(written.status.scripts.ok).toEqual(okLocalStatus); + }); + + it("waits for installScript during pullScript", async () => { + let releaseInstall!: () => void; + const installGate = new Promise((resolve) => { + releaseInstall = resolve; + }); + const installScript = vi.fn().mockImplementation(() => installGate); + const fs = createFs({ + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue( + file.name.endsWith(".user.js") + ? `// ==UserScript== +// @name Pull Test +// @namespace sync-test +// @match https://example.com/* +// ==/UserScript== +console.log("ok");` + : JSON.stringify({ uuid: "pull-uuid" }) + ), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + installScript, + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + } as any + ); + + let settled = false; + const pullPromise = service + .pullScript( + fs, + { + script: { + name: "pull-uuid.user.js", + path: "pull-uuid.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + meta: { + name: "pull-uuid.meta.json", + path: "pull-uuid.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + }, + undefined + ) + .then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + releaseInstall(); + await pullPromise; + + expect(installScript).toHaveBeenCalledTimes(1); + expect(settled).toBe(true); + }); + + it("pull 安装时本地 updatetime 应采用云端文件时间,避免下一轮误判本地较新而补偿 push", async () => { + // prepareScriptByCode 会把 updatetime 置为 Date.now()(必然大于云端文件 mtime)。 + // 若不用云端时间覆盖,下一轮 syncOnce 会把刚拉下来的内容当"本地编辑过"再推回云端; + // 在 etag 型 provider(WebDAV/OneDrive)上相同内容重写也会换 etag, + // 两台设备会陷入 pull→push→etag 变化→对端 pull→push 的永久振荡。 + const installScript = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue( + file.name.endsWith(".user.js") + ? `// ==UserScript== +// @name Pull Time Test +// @namespace sync-test +// @match https://example.com/* +// ==/UserScript== +console.log("ok");` + : JSON.stringify({ uuid: "pull-time-uuid" }) + ), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + installScript, + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + } as any + ); + + await service.pullScript( + fs, + { + script: { + name: "pull-time-uuid.user.js", + path: "pull-time-uuid.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 12345, + }, + meta: { + name: "pull-time-uuid.meta.json", + path: "pull-time-uuid.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + }, + undefined + ); + + expect(installScript).toHaveBeenCalledTimes(1); + expect(installScript).toHaveBeenCalledWith(expect.objectContaining({ updatetime: 12345 })); + }); + + it("waits for deleteScript before updating file digest", async () => { + let releaseDelete!: () => void; + const deleteGate = new Promise((resolve) => { + releaseDelete = resolve; + }); + const order: string[] = []; + const deleteScript = vi.fn().mockImplementation(async () => { + order.push("delete:start"); + await deleteGate; + order.push("delete:end"); + }); + // fs.list 第二次调用对应 updateFileDigest,这是 syncOnce 的最后一步 + const fsList = vi + .fn() + .mockImplementationOnce(async () => [ + { + name: "del-uuid.meta.json", + path: "del-uuid.meta.json", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + ]) + .mockImplementationOnce(async () => { + order.push("digest:list"); + return []; + }); + const fs = createFs({ + list: fsList, + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "del-uuid", isDeleted: true })), + }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { deleteScript } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "del-uuid", + name: "del", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + + const promise = service.syncOnce(syncConfig, fs); + await flushMicrotasks(); + + // delete 已经开始但没结束,updateFileDigest 还没被调用 + expect(order).toEqual(["delete:start"]); + + releaseDelete(); + await promise; + + // delete 必须在 updateFileDigest 之前完成 + expect(order).toEqual(["delete:start", "delete:end", "digest:list"]); + }); + + it("同一轮同步多个远端删除标记只发送一条删除通知", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "first.meta.json", + path: "first.meta.json", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + { + name: "second.meta.json", + path: "second.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([]), + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: file.name.replace(".meta.json", ""), isDeleted: true })), + })), + }); + const deleteScript = vi.fn().mockResolvedValue(undefined); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const service = new SynchronizeService( + {} as any, + {} as any, + { deleteScript } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "first", + name: "first", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + { + uuid: "second", + name: "second", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(deleteScript).toHaveBeenCalledTimes(2); + expect(notificationSpy).toHaveBeenCalledTimes(1); + }); + + it("waits for pushScript before updating file digest", async () => { + let releasePush!: () => void; + const pushGate = new Promise((resolve) => { + releasePush = resolve; + }); + const order: string[] = []; + // pushScript 内部第一步是 fs.create(uuid.user.js),gate 在这里就能拦住整个 push + const fsCreate = vi.fn().mockImplementation(async (filename: string) => { + if (filename === "push-uuid.user.js") { + order.push("push:start"); + await pushGate; + order.push("push:end"); + } + return { write: vi.fn().mockResolvedValue(undefined) }; + }); + const fsList = vi + .fn() + .mockImplementationOnce(async () => []) + .mockImplementationOnce(async () => { + order.push("digest:list"); + return []; + }); + const fs = createFs({ + list: fsList, + create: fsCreate, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([ + { + uuid: "push-uuid", + name: "push", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + + const promise = service.syncOnce(syncConfig, fs); + await flushMicrotasks(); + + // push 已经开始但没结束,updateFileDigest 还没被调用 + expect(order).toEqual(["push:start"]); + + releasePush(); + await promise; + + // push 必须在 updateFileDigest 之前完成 + expect(order).toContain("push:end"); + expect(order).toContain("digest:list"); + expect(order.indexOf("push:end")).toBeLessThan(order.indexOf("digest:list")); + }); + + it("keeps pushed script digest when cloud list is stale after upload", async () => { + const scriptCode = "// code"; + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: scriptCode }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const metaJson = JSON.stringify({ + uuid: script.uuid, + origin: script.origin, + downloadUrl: script.downloadUrl, + checkUpdateUrl: script.checkUpdateUrl, + }); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "push-uuid.user.js": md5OfText(scriptCode), + "push-uuid.meta.json": md5OfText(metaJson), + }); + }); + + it("updateFileDigest 全量对账时应清理云端已删除文件的 sync_content_md5,避免只增不删", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValue([ + { name: "keep.user.js", path: "keep.user.js", size: 1, digest: "d-keep", createtime: 1, updatetime: 1 }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: { get: vi.fn() }, all: vi.fn().mockResolvedValue([]) } as any + ); + await (service as any).storage.set("sync_content_md5", { + "keep.user.js": "md5-keep", + // 云端已删除(不在 list 快照中),其 sync_content_md5 条目应被清理 + "gone.user.js": "md5-gone", + }); + + await service.updateFileDigest(fs as any); + + await expect((service as any).storage.get("sync_content_md5")).resolves.toEqual({ + "keep.user.js": "md5-keep", + }); + }); + + it("updateFileDigestForUuids 删除本次目标文件时仅清理目标 sync_content_md5,不误删无关条目", async () => { + // 队列路径未对账整份云端列表,只能按本次目标 uuid 清理,不能全量盖章误删他端窗口内的记录 + const fs = createFs({ + list: vi.fn().mockResolvedValue([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: { get: vi.fn() }, all: vi.fn() } as any + ); + await (service as any).storage.set("sync_content_md5", { + "del-uuid.user.js": "md5-user", + "del-uuid.meta.json": "md5-meta", + // 非本次目标且不在 list:不应被队列路径清理 + "other.user.js": "md5-other", + }); + + await service.updateFileDigestForUuids(fs as any, ["del-uuid"]); + + await expect((service as any).storage.get("sync_content_md5")).resolves.toEqual({ + "other.user.js": "md5-other", + }); + }); + + it("push 始终只传文件修改时间", async () => { + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "push-uuid.user.js", + path: "push-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + await (service as any).storage.set("file_digest", { + "push-uuid.user.js": "old-user-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(fs.create).toHaveBeenCalledWith("push-uuid.user.js", { modifiedDate: 10 }); + }); + + it("部分 push 失败时只推进成功文件 digest 并保留失败文件旧 digest", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "cloud-ok-new", + createtime: 1, + updatetime: 1, + }, + { + name: "bad.user.js", + path: "bad.user.js", + size: 1, + digest: "cloud-bad-new", + createtime: 1, + updatetime: 1, + }, + { + name: "bad.meta.json", + path: "bad.meta.json", + size: 1, + digest: "cloud-bad-meta-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + { + uuid: "bad", + name: "bad", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockImplementation(async (_fs, script: any) => { + if (script.uuid === "bad") { + throw new Error("push failed"); + } + return { + "ok.user.js": "ok-user-new", + "ok.meta.json": "ok-meta-new", + }; + }); + await (service as any).storage.set("file_digest", { + "bad.user.js": "bad-user-old", + "bad.meta.json": "bad-meta-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "ok.user.js": "cloud-ok-new", + "bad.user.js": "bad-user-old", + "bad.meta.json": "bad-meta-old", + "ok.meta.json": "ok-meta-new", + }); + }); + + it("单文件同步遇到 typed conflict 时应在日志中标记 conflict 分类", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "cloud-ok-new", + createtime: 1, + updatetime: 1, + }, + { + name: "bad.user.js", + path: "bad.user.js", + size: 1, + digest: "cloud-bad-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + { + uuid: "bad", + name: "bad", + updatetime: 1, + createtime: 1, + status: 1, + sort: 1, + metadata: {}, + }, + ]), + } as any + ); + const warnSpy = vi.spyOn(service.logger, "warn"); + vi.spyOn(service, "pushScript").mockImplementation(async (_fs, script: any) => { + if (script.uuid === "bad") { + throw new FileSystemError({ + provider: "dropbox", + message: "Path conflict", + status: 409, + conflict: true, + }); + } + return { "ok.user.js": "ok-user-new" }; + }); + await (service as any).storage.set("file_digest", { + "bad.user.js": "bad-user-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(warnSpy).toHaveBeenCalledWith( + "sync task failed", + expect.objectContaining({ error: "Path conflict" }), + expect.objectContaining({ + errorKind: "conflict", + files: ["bad.user.js", "bad.meta.json"], + }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "ok.user.js": "cloud-ok-new", + "bad.user.js": "bad-user-old", + }); + }); + + it.each([ + { + title: "typed transient", + error: new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }), + expectedKind: "transient", + expectedMessage: "Service unavailable", + }, + { + title: "typed stale snapshot", + error: new FileSystemError({ + provider: "onedrive", + message: "File moved", + status: 404, + notFound: true, + }), + expectedKind: "stale_snapshot", + expectedMessage: "File moved", + }, + ])( + "单文件同步遇到 $title 错误时应在日志中标记 $expectedKind 分类", + async ({ error, expectedKind, expectedMessage }) => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "bad.user.js", + path: "bad.user.js", + size: 1, + digest: "cloud-bad-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "bad", + name: "bad", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + const warnSpy = vi.spyOn(service.logger, "warn"); + vi.spyOn(service, "pushScript").mockRejectedValue(error); + await (service as any).storage.set("file_digest", { + "bad.user.js": "bad-user-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(warnSpy).toHaveBeenCalledWith( + "sync task failed", + expect.objectContaining({ error: expectedMessage }), + expect.objectContaining({ + errorKind: expectedKind, + files: ["bad.user.js", "bad.meta.json"], + }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "bad.user.js": "bad-user-old", + }); + } + ); + + it("scriptcat-sync.json 写回失败时仍推进已成功脚本 digest", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "ok.user.js", + path: "ok.user.js", + size: 1, + digest: "cloud-ok-user", + createtime: 1, + updatetime: 1, + }, + { + name: "ok.meta.json", + path: "ok.meta.json", + size: 1, + digest: "cloud-ok-meta", + createtime: 1, + updatetime: 1, + }, + ]), + create: vi.fn().mockResolvedValue({ + write: vi.fn().mockRejectedValue(new Error("status write failed")), + }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "ok", + name: "ok", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({ + "ok.user.js": "pushed-ok-user", + "ok.meta.json": "pushed-ok-meta", + }); + + await service.syncOnce(syncConfig, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "ok.user.js": "cloud-ok-user", + "ok.meta.json": "cloud-ok-meta", + }); + }); + + it("pullScript 失败时不推进对应云端 digest", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "pull-uuid.user.js", + path: "pull-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 10, + }, + { + name: "pull-uuid.meta.json", + path: "pull-uuid.meta.json", + size: 1, + digest: "cloud-meta-new", + createtime: 1, + updatetime: 10, + }, + ]) + .mockResolvedValueOnce([ + { + name: "pull-uuid.user.js", + path: "pull-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 10, + }, + { + name: "pull-uuid.meta.json", + path: "pull-uuid.meta.json", + size: 1, + digest: "cloud-meta-new", + createtime: 1, + updatetime: 10, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockRejectedValue(new Error("read failed")), + }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + installScript: vi.fn().mockResolvedValue(undefined), + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + await (service as any).storage.set("file_digest", { + "pull-uuid.user.js": "user-old", + "pull-uuid.meta.json": "meta-old", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "pull-uuid.user.js": "user-old", + "pull-uuid.meta.json": "meta-old", + }); + }); + + it("批量删除单条云端删除失败时继续处理后续脚本并保留失败 digest", async () => { + const deleteCalls: string[] = []; + const fs = createFs({ + delete: vi.fn().mockImplementation(async (path: string) => { + deleteCalls.push(path); + if (path === "fail.user.js") { + throw new Error("delete failed"); + } + }), + list: vi.fn().mockResolvedValue([ + { + name: "fail.user.js", + path: "fail.user.js", + size: 1, + digest: "fail-user-new", + createtime: 1, + updatetime: 1, + }, + { + name: "fail.meta.json", + path: "fail.meta.json", + size: 1, + digest: "fail-meta-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true, syncDelete: false }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + const warnSpy = vi.spyOn(service.logger, "warn"); + await (service as any).storage.set("file_digest", { + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + + await service.scriptsDelete([{ uuid: "fail", deleteBy: "user" } as any, { uuid: "ok", deleteBy: "user" } as any]); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(deleteCalls).toEqual(["fail.user.js", "ok.user.js", "ok.meta.json"]); + expect(warnSpy).toHaveBeenCalledWith( + "delete cloud script item failed", + expect.objectContaining({ error: "delete failed" }), + expect.objectContaining({ uuid: "fail", errorKind: "fatal" }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + }); + + it.each([ + { + title: "transient", + error: new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }), + expectedKind: "transient", + expectedMessage: "Service unavailable", + }, + { + title: "conflict", + error: new FileSystemError({ + provider: "dropbox", + message: "Path conflict", + status: 409, + conflict: true, + }), + expectedKind: "conflict", + expectedMessage: "Path conflict", + }, + ])( + "批量删除遇到 typed $title 失败时只阻塞对应脚本并标记 $expectedKind", + async ({ error, expectedKind, expectedMessage }) => { + const deleteCalls: string[] = []; + const fs = createFs({ + delete: vi.fn().mockImplementation(async (path: string) => { + deleteCalls.push(path); + if (path === "fail.user.js") { + throw error; + } + }), + list: vi.fn().mockResolvedValue([ + { + name: "fail.user.js", + path: "fail.user.js", + size: 1, + digest: "fail-user-new", + createtime: 1, + updatetime: 1, + }, + { + name: "fail.meta.json", + path: "fail.meta.json", + size: 1, + digest: "fail-meta-new", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true, syncDelete: false }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + const warnSpy = vi.spyOn(service.logger, "warn"); + await (service as any).storage.set("file_digest", { + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + + await service.scriptsDelete([{ uuid: "fail", deleteBy: "user" } as any, { uuid: "ok", deleteBy: "user" } as any]); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(deleteCalls).toEqual(["fail.user.js", "ok.user.js", "ok.meta.json"]); + expect(warnSpy).toHaveBeenCalledWith( + "delete cloud script item failed", + expect.objectContaining({ error: expectedMessage }), + expect.objectContaining({ uuid: "fail", errorKind: expectedKind }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + } + ); + + it("deleteCloudScript 始终执行普通删除", async () => { + const fs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + await (service as any).storage.set("file_digest", { + "delete-uuid.user.js": "old-user-digest", + "delete-uuid.meta.json": "old-meta-digest", + }); + + await service.deleteCloudScript(fs, "delete-uuid", false); + + expect((fs.delete as any).mock.calls[0]).toEqual(["delete-uuid.user.js"]); + expect((fs.delete as any).mock.calls[1]).toEqual(["delete-uuid.meta.json"]); + }); + + it("passes script modifiedDate when pushing script and meta files", async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + const createMock = vi.fn().mockResolvedValue({ write: writeMock }); + const fs = createFs({ + create: createMock, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([]), + } as any + ); + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 1234, + createtime: 1000, + status: 1, + sort: 0, + metadata: {}, + }; + + await service.pushScript(fs, script as any); + + expect(createMock.mock.calls[0]).toEqual(["push-uuid.user.js", { modifiedDate: 1234 }]); + expect(createMock.mock.calls[1]).toEqual(["push-uuid.meta.json", { modifiedDate: 1234 }]); + }); + + it("uses Date.now as modifiedDate when writing scriptcat-sync.json", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(9876); + const createMock = vi.fn().mockResolvedValue({ + write: vi.fn().mockResolvedValue(undefined), + }); + const fs = createFs({ + create: createMock, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + try { + await service.syncOnce(syncConfig, fs); + + expect(createMock).toHaveBeenCalledWith("scriptcat-sync.json", { + modifiedDate: 9876, + }); + } finally { + nowSpy.mockRestore(); + } + }); + + it("uses Date.now as modifiedDate when writing delete tombstone meta", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(6789); + const createMock = vi.fn().mockResolvedValue({ + write: vi.fn().mockResolvedValue(undefined), + }); + const fs = createFs({ + create: createMock, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + try { + await service.deleteCloudScript(fs, "delete-uuid", true); + + expect(createMock).toHaveBeenCalledWith("delete-uuid.meta.json", { + modifiedDate: 6789, + }); + } finally { + nowSpy.mockRestore(); + } + }); + + it("preserves cloud-native digest and does not overwrite with pushed md5", async () => { + // 各后端 digest 格式不一致(webdav/onedrive 是 etag、dropbox 是 content_hash 等), + // 上传后再次 list 已经能拿到原生 digest 时,必须保留它,不能被本地 md5 覆盖, + // 否则下次同步比对会因格式不一致而把未变动的脚本判定为已变动并触发不必要的拉取/推送 + const scriptCode = "// code"; + const script = { + uuid: "push-uuid", + name: "push", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const cloudListAfterPush = [ + { name: "push-uuid.user.js", digest: "etag-user-js", updatetime: 1 }, + { name: "push-uuid.meta.json", digest: "etag-meta-json", updatetime: 1 }, + ]; + const fs = createFs({ + list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(cloudListAfterPush), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: scriptCode }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "push-uuid.user.js": "etag-user-js", + "push-uuid.meta.json": "etag-meta-json", + }); + }); + + it("scriptInstall enters cloud_sync queue and updates digest after push", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const order: string[] = []; + + // 第一个占用队列的 syncOnce,gate 在 updateFileDigest 阶段 + const syncFs = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("sync:list"); + return []; + }) + .mockImplementationOnce(async () => { + order.push("sync:digest"); + await syncGate; + return []; + }), + }); + + const installFs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(installFs); + vi.spyOn(service, "pushScript").mockImplementation(async () => { + order.push("install:push"); + return {}; + }); + const realUpdateDigest = service.updateFileDigest.bind(service); + vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { + await realUpdateDigest(fs); + }); + vi.spyOn(service, "updateFileDigestForUuids").mockImplementation(async () => { + order.push("install:digest"); + }); + + const syncPromise = service.syncOnce(syncConfig, syncFs); + await flushMicrotasks(); + expect(order).toEqual(["sync:list", "sync:digest"]); + + await service.scriptInstall({ + script: { uuid: "u1", name: "t" } as any, + upsertBy: "user", + } as any); + await flushMicrotasks(); + + // syncOnce 还没释放,install 的 pushScript 不能跑 + expect(order).toEqual(["sync:list", "sync:digest"]); + + releaseSync(); + await syncPromise; + + // 在同一队列上排一个 barrier,barrier 完成意味着 install 任务也已完成 + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(order).toEqual(["sync:list", "sync:digest", "install:push", "install:digest"]); + }); + + it("scriptInstall 触发 push transient 失败时不污染 file_digest", async () => { + const fs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + const transientError = new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + vi.spyOn(service, "pushScript").mockRejectedValue(transientError); + const updateSpy = vi.spyOn(service, "updateFileDigest"); + const errorSpy = vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + await (service as any).storage.set("file_digest", { + "install-uuid.user.js": "old-user-digest", + "install-uuid.meta.json": "old-meta-digest", + }); + + await service.scriptInstall({ + script: { uuid: "install-uuid", name: "install" } as any, + upsertBy: "user", + } as any); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + "push script on install error", + expect.objectContaining({ error: "Service unavailable" }), + expect.objectContaining({ errorKind: "transient" }) + ); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "install-uuid.user.js": "old-user-digest", + "install-uuid.meta.json": "old-meta-digest", + }); + }); + + it("scriptInstall 不应把他端已更新但本机未 pull 的文件标成已同步(避免漏 pull)", async () => { + const installFs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "install-uuid.user.js", + path: "install-uuid.user.js", + size: 1, + digest: "install-user-etag", + createtime: 1, + updatetime: 1, + }, + { + name: "install-uuid.meta.json", + path: "install-uuid.meta.json", + size: 1, + digest: "install-meta-etag", + createtime: 1, + updatetime: 1, + }, + // 他端已把 other-uuid 更新到 d2/m2,本机尚未 pull + { + name: "other-uuid.user.js", + path: "other-uuid.user.js", + size: 1, + digest: "other-user-d2", + createtime: 1, + updatetime: 2, + }, + { + name: "other-uuid.meta.json", + path: "other-uuid.meta.json", + size: 1, + digest: "other-meta-m2", + createtime: 1, + updatetime: 2, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(installFs); + vi.spyOn(service, "pushScript").mockResolvedValue({ + "install-uuid.user.js": "install-user-md5", + "install-uuid.meta.json": "install-meta-md5", + }); + // 本机上次同步 other-uuid 记录的是旧 d1/m1 + await (service as any).storage.set("file_digest", { + "other-uuid.user.js": "other-user-d1", + "other-uuid.meta.json": "other-meta-m1", + }); + + await service.scriptInstall({ + script: { uuid: "install-uuid", name: "install" } as any, + upsertBy: "user", + } as any); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + const digest = (await (service as any).storage.get("file_digest")) as Record; + // 本次推送的文件应记录云端原生 digest + expect(digest["install-uuid.user.js"]).toBe("install-user-etag"); + expect(digest["install-uuid.meta.json"]).toBe("install-meta-etag"); + // 他端未 pull 的更新不能被标成已同步,否则下轮 syncOnce 会早退漏 pull + expect(digest["other-uuid.user.js"]).toBe("other-user-d1"); + expect(digest["other-uuid.meta.json"]).toBe("other-meta-m1"); + }); + + it("scriptsDelete enters cloud_sync queue and updates digest after deleting", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const order: string[] = []; + + const syncFs = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("sync:list"); + return []; + }) + .mockImplementationOnce(async () => { + order.push("sync:digest"); + await syncGate; + return []; + }), + }); + + const deleteFs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(deleteFs); + vi.spyOn(service, "deleteCloudScript").mockImplementation(async (_fs: any, uuid: string) => { + order.push(`delete:${uuid}`); + }); + const realUpdateDigest = service.updateFileDigest.bind(service); + vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { + await realUpdateDigest(fs); + }); + vi.spyOn(service, "updateFileDigestForUuids").mockImplementation(async () => { + order.push("delete:digest"); + }); + + const syncPromise = service.syncOnce(syncConfig, syncFs); + await flushMicrotasks(); + expect(order).toEqual(["sync:list", "sync:digest"]); + + await service.scriptsDelete([ + { uuid: "from-user", deleteBy: "user" } as any, + { uuid: "from-sync", deleteBy: "sync" } as any, + ]); + await flushMicrotasks(); + + // syncOnce 还没释放,delete 任务一步都不能跑 + expect(order).toEqual(["sync:list", "sync:digest"]); + + releaseSync(); + await syncPromise; + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + // deleteBy === "sync" 的不应触发云端删除;并且 digest 必须在删除全部完成后才更新 + expect(order).toEqual(["sync:list", "sync:digest", "delete:from-user", "delete:digest"]); + }); + + it("scriptsDelete skips enqueue when all entries are deleteBy=sync", async () => { + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + const buildSpy = vi.spyOn(service as any, "buildFileSystem"); + const getCloudSyncSpy = (service as any).systemConfig.getCloudSync as ReturnType; + + await service.scriptsDelete([{ uuid: "a", deleteBy: "sync" } as any, { uuid: "b", deleteBy: "sync" } as any]); + await flushMicrotasks(); + + // 全部来源是 sync(来自 syncOnce 内部的 deleteScript 回灌),应直接 return + // 不应去读取云同步配置,也不应建立任何文件系统连接 + expect(getCloudSyncSpy).not.toHaveBeenCalled(); + expect(buildSpy).not.toHaveBeenCalled(); + }); + + it("cloudSyncConfigChange swallows buildFileSystem error", async () => { + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + const buildErr = new Error("build fs failed"); + vi.spyOn(service as any, "buildFileSystem").mockRejectedValue(buildErr); + const errorSpy = vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + + try { + service.cloudSyncConfigChange({ ...syncConfig, enable: true }); + await flushMicrotasks(); + + expect(errorSpy).toHaveBeenCalledWith("cloud sync config change error", expect.anything()); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + } + }); + + it("scriptInstall 对已同步过的脚本执行普通覆盖写入", async () => { + const createCalls: Array<{ name: string; opts: any }> = []; + const fs = createFs({ + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockImplementation(async (name: string, opts: any) => { + createCalls.push({ name, opts }); + return { write: vi.fn().mockResolvedValue(undefined) }; + }), + } as unknown as Partial); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); + await service.scriptInstall({ + script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "" } as any, + upsertBy: "user", + } as any); + // 在同一队列排一个 barrier,barrier 完成意味着 install 任务已完成 + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + const userJs = createCalls.find((c) => c.name === "u1.user.js"); + const metaJson = createCalls.find((c) => c.name === "u1.meta.json"); + expect(userJs?.opts).toEqual({ modifiedDate: expect.any(Number) }); + expect(metaJson?.opts).toEqual({ modifiedDate: expect.any(Number) }); + }); + + it("本地编辑后即便云端 digest 未变也应推送而非因 digest 相等被跳过", async () => { + // 云端 digest 只反映云端侧变化,检测不到本地编辑。若脚本本地更新时间已比云端新, + // 即便 digest 相等也必须上传,否则本地改动永远上不了云(#1)。 + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu1", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm1", createtime: 1, updatetime: 5000 }, + ]) + .mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 20_000, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any + ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" })); + }); + + it("pushScript 在 .meta.json 写入失败时带出已成功写入的 .user.js", async () => { + // 分两次写 .user.js / .meta.json,前者成功后者失败时要让调用方知道 .user.js 已写成功, + // 才能只保留失败文件的旧 digest、推进成功文件的 digest。 + const fs = createFs({ + create: vi.fn().mockImplementation(async (name: string) => ({ + write: vi.fn().mockImplementation(async () => { + if (name === "u1.meta.json") throw new Error("meta write failed"); + }), + })), + } as unknown as Partial); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + } as any + ); + const script = { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 5, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + + await expect(service.pushScript(fs, script as any)).rejects.toMatchObject({ + writtenFiles: ["u1.user.js"], + }); + }); + + it("新脚本 .meta.json 失败:应推进已成功的 .user.js digest,并在下一轮补传 .meta.json", async () => { + let failMeta = true; + let seq = 0; + const cloud = new Map(); + const cloudFs = createFs({ + list: vi.fn(async () => + Array.from(cloud, ([name, f]) => ({ + name, + path: name, + size: 1, + digest: f.digest, + createtime: 1, + updatetime: f.updatetime, + })) + ), + create: vi.fn(async (name: string, opts: any) => ({ + write: vi.fn(async (content: string) => { + const existing = cloud.get(name); + if (failMeta && name === "u1.meta.json") { + throw new FileSystemError({ provider: "webdav", message: "meta fail", status: 500, retryable: true }); + } + cloud.set(name, { + digest: `d${++seq}`, + content, + updatetime: opts?.modifiedDate ?? existing?.updatetime ?? 0, + }); + }), + })), + } as unknown as Partial); + const daoScript = { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 5, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + all: vi.fn().mockResolvedValue([daoScript]), + get: vi.fn().mockResolvedValue(daoScript), + } as any + ); + + // 第一轮:.user.js 成功、.meta.json 失败 + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(cloud.has("u1.user.js")).toBe(true); + expect(cloud.has("u1.meta.json")).toBe(false); + const dig1 = (await (service as any).storage.get("file_digest")) as Record; + expect(dig1["u1.user.js"]).toBeDefined(); // 推进已成功文件的 digest + expect(dig1["u1.meta.json"]).toBeUndefined(); // 失败文件不记录 + + // 第二轮:meta 故障恢复,应补传 .meta.json(不因 .user.js digest 相等被当作无变动跳过) + failMeta = false; + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(cloud.has("u1.meta.json")).toBe(true); + }); + + // 模拟共享状态的云端存储:list/open/create/delete 操作同一份文件表; + // 删除不存在的文件抛 typed notFound(模拟 Google Drive 等不幂等的 provider); + // failures 中的文件名在写入/删除时抛瞬时错误 + const createCloudStore = (initial: [string, { digest: string; content: string; updatetime: number }][] = []) => { + const files = new Map(initial); + const failures = new Set(); + let seq = 0; + const transientError = (message: string) => + new FileSystemError({ provider: "webdav", message, status: 503, retryable: true }); + const notFoundError = (name: string) => + new FileSystemError({ provider: "webdav", message: `${name} not found`, status: 404, notFound: true }); + const fs = createFs({ + list: vi.fn(async () => + Array.from(files, ([name, f]) => ({ + name, + path: name, + size: 1, + digest: f.digest, + createtime: 1, + updatetime: f.updatetime, + })) + ), + open: vi.fn(async (info: { name: string }) => ({ + read: vi.fn(async () => { + const f = files.get(info.name); + if (!f) throw notFoundError(info.name); + return f.content; + }), + })), + create: vi.fn(async (name: string, opts: { modifiedDate?: number }) => ({ + write: vi.fn(async (content: string) => { + if (failures.has(name)) throw transientError(`write ${name} failed`); + files.set(name, { digest: `d${++seq}`, content, updatetime: opts?.modifiedDate ?? 0 }); + }), + })), + delete: vi.fn(async (name: string) => { + if (failures.has(name)) throw transientError(`delete ${name} failed`); + if (!files.has(name)) throw notFoundError(name); + files.delete(name); + }), + } as unknown as Partial); + return { fs, files, failures }; + }; + + it("生产形态安装消息(不带 updatetime)下 .meta.json 写失败:下一轮同步必须补传", async () => { + // publishInstallScript 不携带时间字段,pushScript 用 Date.now() 作云端 mtime。 + // .user.js 成功、.meta.json 失败后,下一轮「digest 相等 + 本地时间不比云端新」会跳过整个 uuid, + // 补传不能依赖时间比较,必须由持久化的 pending 重放兜底。 + const store = createCloudStore([ + ["u1.user.js", { digest: "cu1", content: "// v0", updatetime: 5000 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5000 }], + ]); + const daoScript = { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 10_000, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const service = new SynchronizeService( + {} as any, + {} as any, {} as any, {} as any, {} as any, {} as any, { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([ - { - uuid: "del-uuid", - name: "del", - updatetime: 1, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }, - ]), + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, syncStatus: false, enable: true }), + } as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v1" }) }, + all: vi.fn().mockResolvedValue([daoScript]), + get: vi.fn().mockResolvedValue(daoScript), } as any ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(store.fs); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); - const promise = service.syncOnce(syncConfig, fs); - await flushMicrotasks(); + // 第一轮:编辑发布生产形态消息(无 updatetime/createtime),.user.js 成功、.meta.json 失败 + store.failures.add("u1.meta.json"); + await service.scriptInstall({ + script: { uuid: "u1", type: 1, status: 1, name: "t", namespace: "ns" }, + update: true, + upsertBy: "user", + } as any); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); - // delete 已经开始但没结束,updateFileDigest 还没被调用 - expect(order).toEqual(["delete:start"]); + expect(store.files.get("u1.user.js")!.content).toBe("// v1"); + expect(store.files.get("u1.meta.json")!.digest).toBe("cm1"); - releaseDelete(); - await promise; + // 第二轮:provider 恢复。本地 updatetime(10s)早于云端写入时间(Date.now()), + // 「本地更新」的补偿推送不会触发,.meta.json 仍必须被补传 + store.failures.clear(); + await service.syncOnce({ ...syncConfig, syncStatus: false }, store.fs); - // delete 必须在 updateFileDigest 之前完成 - expect(order).toEqual(["delete:start", "delete:end", "digest:list"]); + expect(JSON.parse(store.files.get("u1.meta.json")!.content).uuid).toBe("u1"); }); - it("waits for pushScript before updating file digest", async () => { - let releasePush!: () => void; - const pushGate = new Promise((resolve) => { - releasePush = resolve; - }); - const order: string[] = []; - // pushScript 内部第一步是 fs.create(uuid.user.js),gate 在这里就能拦住整个 push - const fsCreate = vi.fn().mockImplementation(async (filename: string) => { - if (filename === "push-uuid.user.js") { - order.push("push:start"); - await pushGate; - order.push("push:end"); - } - return { write: vi.fn().mockResolvedValue(undefined) }; - }); - const fsList = vi - .fn() - .mockImplementationOnce(async () => []) - .mockImplementationOnce(async () => { - order.push("digest:list"); - return []; - }); - const fs = createFs({ - list: fsList, - create: fsCreate, + it("云端仅 .meta.json 变化(源码未变):必须采用云端 meta 而非盖章跳过", async () => { + const newMeta = JSON.stringify({ + uuid: "u1", + origin: "https://example.com/o.user.js", + downloadUrl: "https://example.com/new.user.js", + checkUpdateUrl: "https://example.com/new.meta.js", }); + const store = createCloudStore([ + ["u1.user.js", { digest: "cu1", content: "// v1", updatetime: 5000 }], + ["u1.meta.json", { digest: "cm2", content: newMeta, updatetime: 6000 }], + ]); + const update = vi.fn().mockResolvedValue(true); const service = new SynchronizeService( {} as any, {} as any, @@ -502,55 +2797,89 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: { - get: vi.fn().mockResolvedValue({ code: "// code" }), - }, + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v1" }) }, all: vi.fn().mockResolvedValue([ { - uuid: "push-uuid", - name: "push", - updatetime: 1, + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 4000, createtime: 1, status: 1, sort: 0, metadata: {}, }, ]), + update, } as any ); + // .user.js digest 未变、本地时间不比云端新,旧逻辑会直接跳过并在收尾盖章 meta digest + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); - const promise = service.syncOnce(syncConfig, fs); - await flushMicrotasks(); + await service.syncOnce({ ...syncConfig, syncStatus: false }, store.fs); - // push 已经开始但没结束,updateFileDigest 还没被调用 - expect(order).toEqual(["push:start"]); + expect(update).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ + downloadUrl: "https://example.com/new.user.js", + checkUpdateUrl: "https://example.com/new.meta.js", + }) + ); + // 采用成功后推进内容基线,云端 meta 不再被视为未处理 + await expect((service as any).storage.get("sync_content_md5")).resolves.toMatchObject({ + "u1.meta.json": md5OfText(newMeta), + }); + }); - releasePush(); - await promise; + it("syncDelete=true 删除部分失败(tombstone 未写入):SW 重启后下一轮自动补写", async () => { + const store = createCloudStore([ + ["s1.user.js", { digest: "cu1", content: "// v1", updatetime: 5000 }], + ["s1.meta.json", { digest: "cm1", content: JSON.stringify({ uuid: "s1" }), updatetime: 5000 }], + ]); + const makeService = () => { + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true, syncDelete: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(undefined), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(store.fs); + return service; + }; - // push 必须在 updateFileDigest 之前完成 - expect(order).toContain("push:end"); - expect(order).toContain("digest:list"); - expect(order.indexOf("push:end")).toBeLessThan(order.indexOf("digest:list")); + // 第一轮:删除 .user.js 成功,写 tombstone 失败 + store.failures.add("s1.meta.json"); + await makeService().scriptsDelete([{ uuid: "s1", deleteBy: "user" } as any]); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + expect(store.files.has("s1.user.js")).toBe(false); + expect(JSON.parse(store.files.get("s1.meta.json")!.content).isDeleted).toBeUndefined(); + + // SW 重启(新实例、共享 storage),provider 恢复:必须补写 tombstone 并清除 pending + store.failures.clear(); + const service2 = makeService(); + await service2.syncOnce({ ...syncConfig, syncStatus: false, syncDelete: true }, store.fs); + + expect(JSON.parse(store.files.get("s1.meta.json")!.content).isDeleted).toBe(true); + await expect((service2 as any).storage.get("pending_sync_ops")).resolves.toEqual({}); }); - it("keeps pushed script digest when cloud list is stale after upload", async () => { - const scriptCode = "// code"; - const script = { - uuid: "push-uuid", - name: "push", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 1, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const fs = createFs({ - list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([]), - }); + it("syncDelete=false 删除部分失败(.meta.json 残留):下一轮自动清理", async () => { + const store = createCloudStore([ + ["s1.user.js", { digest: "cu1", content: "// v1", updatetime: 5000 }], + ["s1.meta.json", { digest: "cm1", content: JSON.stringify({ uuid: "s1" }), updatetime: 5000 }], + ]); const service = new SynchronizeService( {} as any, {} as any, @@ -558,35 +2887,99 @@ console.log("ok");` {} as any, {} as any, {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true, syncDelete: false }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(undefined), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(store.fs); + + // 第一轮:删除 .user.js 成功,删除 .meta.json 失败 + store.failures.add("s1.meta.json"); + await service.scriptsDelete([{ uuid: "s1", deleteBy: "user" } as any]); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + expect(store.files.has("s1.user.js")).toBe(false); + expect(store.files.has("s1.meta.json")).toBe(true); + + // 第二轮:provider 恢复,残留 .meta.json 必须被清理 + store.failures.clear(); + await service.syncOnce({ ...syncConfig, syncStatus: false, syncDelete: false }, store.fs); + + expect(store.files.size).toBe(0); + }); + + it("删除云端全部失败后:下一轮必须重试删除,而不是把脚本拉回本地", async () => { + const code = `// ==UserScript== +// @name resurrect +// @namespace ns +// @version 1.0.0 +// ==/UserScript== +console.log(1);`; + const store = createCloudStore([ + ["s1.user.js", { digest: "cu1", content: code, updatetime: 5000 }], + ["s1.meta.json", { digest: "cm1", content: JSON.stringify({ uuid: "s1" }), updatetime: 5000 }], + ]); + const installScript = vi.fn().mockResolvedValue({}); + const service = new SynchronizeService( + {} as any, + {} as any, + { installScript } as any, + {} as any, + {} as any, {} as any, { - scriptCodeDAO: { - get: vi.fn().mockResolvedValue({ code: scriptCode }), - }, - all: vi.fn().mockResolvedValue([script]), + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true, syncDelete: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(undefined), } as any ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(store.fs); - await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + // 第一轮:第一步删除 .user.js 就失败,云端原样保留,本地脚本已删 + store.failures.add("s1.user.js"); + await service.scriptsDelete([{ uuid: "s1", deleteBy: "user" } as any]); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + expect(store.files.has("s1.user.js")).toBe(true); - const metaJson = JSON.stringify({ - uuid: script.uuid, - origin: script.origin, - downloadUrl: script.downloadUrl, - checkUpdateUrl: script.checkUpdateUrl, - }); - await expect((service as any).storage.get("file_digest")).resolves.toEqual({ - "push-uuid.user.js": md5OfText(scriptCode), - "push-uuid.meta.json": md5OfText(metaJson), - }); + // 第二轮:没有持久化删除意图时,「本地无脚本 + 云端有脚本」会被误判为新脚本拉回本地 + store.failures.clear(); + await service.syncOnce({ ...syncConfig, syncStatus: false, syncDelete: true }, store.fs); + + expect(installScript).not.toHaveBeenCalled(); + expect(store.files.has("s1.user.js")).toBe(false); + expect(JSON.parse(store.files.get("s1.meta.json")!.content).isDeleted).toBe(true); }); - it("passes script modifiedDate when pushing script and meta files", async () => { - const writeMock = vi.fn().mockResolvedValue(undefined); - const createMock = vi.fn().mockResolvedValue({ write: writeMock }); - const fs = createFs({ - create: createMock, - }); + it("deleteCloudScript 对云端已不存在的文件幂等成功", async () => { + const store = createCloudStore(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: {} } as any + ); + + await expect(service.deleteCloudScript(store.fs, "gone", false)).resolves.toBeUndefined(); + }); + + it("云端孤儿 .meta.json 对账:tombstone 保留,非 tombstone 残留清理", async () => { + // 他端分片删除残留的普通 .meta.json 若不清理,其他设备会按「无效 meta」重新上传脚本; + // tombstone 则是删除标记,必须长期保留 + const store = createCloudStore([ + ["t1.meta.json", { digest: "tm1", content: JSON.stringify({ uuid: "t1", isDeleted: true }), updatetime: 5000 }], + ["t2.meta.json", { digest: "nm1", content: JSON.stringify({ uuid: "t2" }), updatetime: 5000 }], + ]); const service = new SynchronizeService( {} as any, {} as any, @@ -596,39 +2989,50 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: { - get: vi.fn().mockResolvedValue({ code: "// code" }), - }, + scriptCodeDAO: {}, all: vi.fn().mockResolvedValue([]), } as any ); - const script = { - uuid: "push-uuid", - name: "push", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 1234, - createtime: 1000, - status: 1, - sort: 0, - metadata: {}, - }; - await service.pushScript(fs, script as any); + await service.syncOnce({ ...syncConfig, syncStatus: false }, store.fs); - expect(createMock.mock.calls[0]).toEqual(["push-uuid.user.js", { modifiedDate: 1234 }]); - expect(createMock.mock.calls[1]).toEqual(["push-uuid.meta.json", { modifiedDate: 1234 }]); + expect(store.files.has("t1.meta.json")).toBe(true); + expect(store.files.has("t2.meta.json")).toBe(false); }); - it("uses Date.now as modifiedDate when writing scriptcat-sync.json", async () => { - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(9876); - const createMock = vi.fn().mockResolvedValue({ - write: vi.fn().mockResolvedValue(undefined), - }); - const fs = createFs({ - create: createMock, - }); + it("本地编辑经 syncOnce 真实上云,且推进 digest 后下一轮不再重复推送", async () => { + // 端到端验证 #1:脚本已同步(云端 digest 与本地记录一致),随后本地编辑(updatetime 变新、 + // 但云端文件未变故 digest 仍相等)。syncOnce 必须真正把新内容覆盖上云; + // 且推进 digest/更新时间后,下一轮应回到稳态不再重复推送。 + let seq = 0; + const cloud = new Map([ + ["u1.user.js", { digest: "cu1", content: "// v1", updatetime: 5000 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5000 }], + ]); + const writes: string[] = []; + const cloudFs = createFs({ + list: vi.fn(async () => + Array.from(cloud, ([name, f]) => ({ + name, + path: name, + size: 1, + digest: f.digest, + createtime: 1, + updatetime: f.updatetime, + })) + ), + create: vi.fn(async (name: string, opts: any) => ({ + write: vi.fn(async (content: string) => { + const existing = cloud.get(name); + cloud.set(name, { + digest: `d${++seq}`, + content, + updatetime: opts?.modifiedDate ?? existing?.updatetime ?? 0, + }); + writes.push(name); + }), + })), + } as unknown as Partial); const service = new SynchronizeService( {} as any, {} as any, @@ -638,29 +3042,43 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v2" }) }, + all: vi.fn().mockResolvedValue([ + { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 20_000, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); - try { - await service.syncOnce(syncConfig, fs); + // 本地编辑(updatetime 20 > 云端 5,digest 仍相等)→ 应真正把 // v2 覆盖上云 + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(cloud.get("u1.user.js")!.content).toBe("// v2"); - expect(createMock).toHaveBeenCalledWith("scriptcat-sync.json", { - modifiedDate: 9876, - }); - } finally { - nowSpy.mockRestore(); - } + // 下一轮:digest 与更新时间已推进,稳态不再重复推送 + writes.length = 0; + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(writes).toEqual([]); }); - it("uses Date.now as modifiedDate when writing delete tombstone meta", async () => { - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(6789); - const createMock = vi.fn().mockResolvedValue({ - write: vi.fn().mockResolvedValue(undefined), - }); + it("云端未变且本地更新时间与云端同秒时不触发补偿 push(整秒对齐)", async () => { + // 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端时钟(WebDAV 等仅整秒精度): + // 同一秒内的毫秒余数不构成"本地更新"的证据,否则每次编辑上云后都会多一轮冗余覆盖 push const fs = createFs({ - create: createMock, + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu1", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm1", createtime: 1, updatetime: 5000 }, + ]), }); const service = new SynchronizeService( {} as any, @@ -671,45 +3089,33 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - try { - await service.deleteCloudScript(fs, "delete-uuid", true); - - expect(createMock).toHaveBeenCalledWith("delete-uuid.meta.json", { - modifiedDate: 6789, - }); - } finally { - nowSpy.mockRestore(); - } - }); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - it("preserves cloud-native digest and does not overwrite with pushed md5", async () => { - // 各后端 digest 格式不一致(webdav/onedrive 是 etag、dropbox 是 content_hash 等), - // 上传后再次 list 已经能拿到原生 digest 时,必须保留它,不能被本地 md5 覆盖, - // 否则下次同步比对会因格式不一致而把未变动的脚本判定为已变动并触发不必要的拉取/推送 - const scriptCode = "// code"; - const script = { - uuid: "push-uuid", - name: "push", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 1, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const cloudListAfterPush = [ - { name: "push-uuid.user.js", digest: "etag-user-js", updatetime: 1 }, - { name: "push-uuid.meta.json", digest: "etag-meta-json", updatetime: 1 }, - ]; + expect(pushSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + it("云端已变而本地内容未变时,即使本地时间戳更大也应 pull 而非 push(同秒竞态 L4)", async () => { + // 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端整秒时钟,两者不可比: + // 对端更新落在同一秒时,毫秒余数会让本地"看起来更新"而误走 push。 + // 方向判定应基于内容基线:云端 digest 已变、本地内容自上次同步未变 → pull。 const fs = createFs({ - list: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(cloudListAfterPush), + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), }); const service = new SynchronizeService( {} as any, @@ -720,44 +3126,37 @@ console.log("ok");` {} as any, {} as any, { - scriptCodeDAO: { - get: vi.fn().mockResolvedValue({ code: scriptCode }), - }, - all: vi.fn().mockResolvedValue([script]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// code") }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - await expect((service as any).storage.get("file_digest")).resolves.toEqual({ - "push-uuid.user.js": "etag-user-js", - "push-uuid.meta.json": "etag-meta-json", - }); + expect(pullSpy).toHaveBeenCalledWith(fs, expect.anything(), undefined, expect.objectContaining({ uuid: "u1" })); + expect(pushSpy).not.toHaveBeenCalled(); }); - it("scriptInstall enters cloud_sync queue and updates digest after push", async () => { - let releaseSync!: () => void; - const syncGate = new Promise((resolve) => { - releaseSync = resolve; - }); - const order: string[] = []; - - // 第一个占用队列的 syncOnce,gate 在 updateFileDigest 阶段 - const syncFs = createFs({ - list: vi - .fn() - .mockImplementationOnce(async () => { - order.push("sync:list"); - return []; - }) - .mockImplementationOnce(async () => { - order.push("sync:digest"); - await syncGate; - return []; - }), + it("本地与云端都已修改时不自动覆盖任何一端:保留旧 digest 并通知一次", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// cloud edit"), + })), }); - - const installFs = createFs(); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const cacheSpy = vi.spyOn(cacheInstance, "set"); const service = new SynchronizeService( {} as any, {} as any, @@ -765,73 +3164,81 @@ console.log("ok");` {} as any, {} as any, {} as any, + {} as any, { - getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), - } as any, - { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// local edit" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// base") }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(installFs); - vi.spyOn(service, "pushScript").mockImplementation(async () => { - order.push("install:push"); - return {}; + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(notificationSpy).toHaveBeenCalledTimes(1); + expect(cacheSpy).toHaveBeenCalledWith(expect.stringMatching(/^notification:.*:options$/), { + url: chrome.runtime.getURL("/src/options.html#/settings?section=sync"), }); - const realUpdateDigest = service.updateFileDigest.bind(service); - vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { - if (fs === installFs) { - order.push("install:digest"); - return; - } - await realUpdateDigest(fs); + // 冲突文件保留旧 digest,下一轮仍能识别云端变化 + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "u1.user.js": "cu1", + "u1.meta.json": "cm1", }); - - const syncPromise = service.syncOnce(syncConfig, syncFs); - await flushMicrotasks(); - expect(order).toEqual(["sync:list", "sync:digest"]); - - await service.scriptInstall({ - script: { uuid: "u1", name: "t" } as any, - upsertBy: "user", - } as any); - await flushMicrotasks(); - - // syncOnce 还没释放,install 的 pushScript 不能跑 - expect(order).toEqual(["sync:list", "sync:digest"]); - - releaseSync(); - await syncPromise; - - // 在同一队列上排一个 barrier,barrier 完成意味着 install 任务也已完成 - await stackAsyncTask("cloud_sync_queue", async () => "barrier"); - - expect(order).toEqual(["sync:list", "sync:digest", "install:push", "install:digest"]); }); - it("scriptsDelete enters cloud_sync queue and updates digest after deleting", async () => { - let releaseSync!: () => void; - const syncGate = new Promise((resolve) => { - releaseSync = resolve; + it("Service Worker 重启后同一批冲突仍只通知一次", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// cloud edit"), + })), }); - const order: string[] = []; - - const syncFs = createFs({ - list: vi + const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const scriptDAO = { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// local edit" }) }, + all: vi .fn() - .mockImplementationOnce(async () => { - order.push("sync:list"); - return []; - }) - .mockImplementationOnce(async () => { - order.push("sync:digest"); - await syncGate; - return []; - }), - }); + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any; + const makeService = () => + new SynchronizeService({} as any, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, scriptDAO); + const firstService = makeService(); + await (firstService as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (firstService as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// base") }); + + await firstService.syncOnce({ ...syncConfig, syncStatus: false }, fs); + const restartedService = makeService(); + await restartedService.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + }); - const deleteFs = createFs(); + it("云端已变但内容与本地一致时直接收敛基线,不 push 不 pull 不通知", async () => { + // 两台设备做了同样的编辑(或本机记账失败后云端内容实为本机所写): + // 内容一致只是基线过期,采用云端 digest 收敛即可,不算冲突 + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// same edit"), + })), + }); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); const service = new SynchronizeService( {} as any, {} as any, @@ -839,50 +3246,79 @@ console.log("ok");` {} as any, {} as any, {} as any, + {} as any, { - getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), - } as any, - { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// same edit" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// base") }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(deleteFs); - vi.spyOn(service, "deleteCloudScript").mockImplementation(async (_fs: any, uuid: string) => { - order.push(`delete:${uuid}`); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(notificationSpy).not.toHaveBeenCalled(); + // digest 与内容基线一并推进,下一轮回到稳态 + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "u1.user.js": "cu2", + "u1.meta.json": "cm2", }); - const realUpdateDigest = service.updateFileDigest.bind(service); - vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { - if (fs === deleteFs) { - order.push("delete:digest"); - return; - } - await realUpdateDigest(fs); + await expect((service as any).storage.get("sync_content_md5")).resolves.toMatchObject({ + "u1.user.js": md5OfText("// same edit"), }); + }); - const syncPromise = service.syncOnce(syncConfig, syncFs); - await flushMicrotasks(); - expect(order).toEqual(["sync:list", "sync:digest"]); - - await service.scriptsDelete([ - { uuid: "from-user", deleteBy: "user" } as any, - { uuid: "from-sync", deleteBy: "sync" } as any, - ]); - await flushMicrotasks(); - - // syncOnce 还没释放,delete 任务一步都不能跑 - expect(order).toEqual(["sync:list", "sync:digest"]); + it("无本地内容基线时退回时间比较决定方向(升级兼容)", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 20_000, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any + ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - releaseSync(); - await syncPromise; - await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - // deleteBy === "sync" 的不应触发云端删除;并且 digest 必须在删除全部完成后才更新 - expect(order).toEqual(["sync:list", "sync:digest", "delete:from-user", "delete:digest"]); + expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" })); + expect(pullSpy).not.toHaveBeenCalled(); }); - it("scriptsDelete skips enqueue when all entries are deleteBy=sync", async () => { + it("无基线回退的时间比较也按整秒对齐:同秒不判本地更新,应 pull", async () => { + // 回退规则仍是跨时钟域比较,同秒内毫秒余数会把"对端刚更新"误判成"本地更新"(L4), + // 截断到整秒后同秒判 pull,让较新的云端内容能同步下来 + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + }); const service = new SynchronizeService( {} as any, {} as any, @@ -890,56 +3326,435 @@ console.log("ok");` {} as any, {} as any, {} as any, + {} as any, { - getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), - } as any, - { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), } as any ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + const pushSpy = vi.spyOn(service, "pushScript").mockResolvedValue({}); + const pullSpy = vi.spyOn(service, "pullScript").mockResolvedValue(undefined); - const buildSpy = vi.spyOn(service as any, "buildFileSystem"); - const getCloudSyncSpy = (service as any).systemConfig.getCloudSync as ReturnType; - - await service.scriptsDelete([{ uuid: "a", deleteBy: "sync" } as any, { uuid: "b", deleteBy: "sync" } as any]); - await flushMicrotasks(); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - // 全部来源是 sync(来自 syncOnce 内部的 deleteScript 回灌),应直接 return - // 不应去读取云同步配置,也不应建立任何文件系统连接 - expect(getCloudSyncSpy).not.toHaveBeenCalled(); - expect(buildSpy).not.toHaveBeenCalled(); + expect(pullSpy).toHaveBeenCalledWith(fs, expect.anything(), undefined, expect.objectContaining({ uuid: "u1" })); + expect(pushSpy).not.toHaveBeenCalled(); }); - it("cloudSyncConfigChange swallows buildFileSystem error", async () => { + it("pull 成功后记录内容基线,供下轮云端再变时判定本地是否也改过", async () => { + const installScript = vi.fn().mockResolvedValue(undefined); + const pulledCode = `// ==UserScript== +// @name Baseline Test +// @namespace sync-test +// @match https://example.com/* +// ==/UserScript== +console.log("ok");`; + const pulledMeta = JSON.stringify({ uuid: "baseline-uuid" }); + const fs = createFs({ + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue(file.name.endsWith(".user.js") ? pulledCode : pulledMeta), + })), + }); const service = new SynchronizeService( {} as any, {} as any, + { installScript } as any, {} as any, {} as any, {} as any, {} as any, - {} as any, + { scriptCodeDAO: {} } as any + ); + + await service.pullScript( + fs, { - scriptCodeDAO: {}, - all: vi.fn().mockResolvedValue([]), - } as any + script: { + name: "baseline-uuid.user.js", + path: "baseline-uuid.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 12345, + }, + meta: { + name: "baseline-uuid.meta.json", + path: "baseline-uuid.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + }, + undefined ); - const buildErr = new Error("build fs failed"); - vi.spyOn(service as any, "buildFileSystem").mockRejectedValue(buildErr); - const errorSpy = vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); - const unhandled = vi.fn(); - process.on("unhandledRejection", unhandled); + await expect((service as any).storage.get("sync_content_md5")).resolves.toMatchObject({ + "baseline-uuid.user.js": md5OfText(pulledCode), + "baseline-uuid.meta.json": md5OfText(pulledMeta), + }); + }); - try { - service.cloudSyncConfigChange({ ...syncConfig, enable: true }); - await flushMicrotasks(); + describe("覆盖可见性", () => { + const makeService = (scriptCodeDAO: any) => + new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO } as any + ); + + it("云端已变但本地无内容基线时,方向判定标记为 unverified(可能覆盖未知改动)", async () => { + const service = makeService({ get: vi.fn().mockResolvedValue(undefined) }); + const cloudFile = { name: "u1.user.js", updatetime: 100_000 } as any; + const script = { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1 } as any; + + const direction = await (service as any).decideDirectionOnRemoteChange(createFs(), cloudFile, script, {}); + + expect(direction).toEqual({ action: "push", unverified: true }); + }); - expect(errorSpy).toHaveBeenCalledWith("cloud sync config change error", expect.anything()); - expect(unhandled).not.toHaveBeenCalled(); - } finally { - process.off("unhandledRejection", unhandled); - } + it("有内容基线且本地未改时,pull 不标记 unverified", async () => { + const code = "// code"; + const service = makeService({ get: vi.fn().mockResolvedValue({ code }) }); + const cloudFile = { name: "u1.user.js", updatetime: 100_000 } as any; + const script = { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1 } as any; + + const direction = await (service as any).decideDirectionOnRemoteChange(createFs(), cloudFile, script, { + "u1.user.js": md5OfText(code), + }); + + expect(direction).toEqual({ action: "pull" }); + }); + + it("无内容基线兜底覆盖时,记录 action:overwrite 的警告日志", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cloudD", createtime: 1, updatetime: 100_000 }, + { + name: "u1.meta.json", + path: "u1.meta.json", + size: 1, + digest: "cloudM", + createtime: 1, + updatetime: 100_000, + }, + ]) + .mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue(undefined) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({}); + const warnSpy = vi.spyOn(service.logger, "warn"); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const overwriteLabel = warnSpy.mock.calls + .flat() + .find((arg: any) => arg && typeof arg === "object" && arg.action === "overwrite"); + expect(overwriteLabel).toMatchObject({ action: "overwrite", direction: "push", uuid: "u1" }); + }); + + it("同一轮多个覆盖不再发送桌面通知(覆盖降级为信息级,只记录日志与计数)", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "c1", createtime: 1, updatetime: 100_000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "m1", createtime: 1, updatetime: 100_000 }, + { name: "u2.user.js", path: "u2.user.js", size: 1, digest: "c2", createtime: 1, updatetime: 100_000 }, + { name: "u2.meta.json", path: "u2.meta.json", size: 1, digest: "m2", createtime: 1, updatetime: 100_000 }, + ]) + .mockResolvedValueOnce([]), + }); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue(undefined) }, + all: vi.fn().mockResolvedValue([ + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, + { uuid: "u2", name: "u2", updatetime: 200_000, createtime: 1, status: 1, sort: 1, metadata: {} }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({}); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(notificationSpy).not.toHaveBeenCalled(); + const state = await (service as any).storage.get("cloud_sync_state"); + expect(state.counts).toMatchObject({ overwrite: 2 }); + }); + + it("兜底覆盖写入失败时不计数,下一轮真正覆盖成功才计数(覆盖不再触发通知)", async () => { + // 覆盖计数必须以"写入已发生"为前提:失败轮不能谎报覆盖; + // 覆盖已降级为信息级,任意轮次都不再弹桌面通知 + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "c1", createtime: 1, updatetime: 100_000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "m1", createtime: 1, updatetime: 100_000 }, + ]), + }); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue(undefined) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockRejectedValueOnce(new Error("cloud write failed")).mockResolvedValue({}); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(notificationSpy).not.toHaveBeenCalled(); + let state = await (service as any).storage.get("cloud_sync_state"); + expect(state.counts).toMatchObject({ overwrite: 0, failed: 1 }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(notificationSpy).not.toHaveBeenCalled(); + state = await (service as any).storage.get("cloud_sync_state"); + expect(state.counts).toMatchObject({ overwrite: 1, failed: 0 }); + }); + + it("同步完成后写入 cloud_sync_state(覆盖计数与 lastSyncAt)", async () => { + const fs = createFs({ + list: vi + .fn() + .mockResolvedValueOnce([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cloudD", createtime: 1, updatetime: 100_000 }, + { + name: "u1.meta.json", + path: "u1.meta.json", + size: 1, + digest: "cloudM", + createtime: 1, + updatetime: 100_000, + }, + ]) + .mockResolvedValueOnce([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue(undefined) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({}); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const state = await (service as any).storage.get("cloud_sync_state"); + expect(state).toMatchObject({ syncing: false, counts: expect.objectContaining({ overwrite: 1 }) }); + expect(state.lastSyncAt).toBeGreaterThan(0); + }); + + it("单文件同步失败计入 counts.failed", async () => { + const fs = createFs({ list: vi.fn().mockResolvedValue([]) }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockRejectedValue(new Error("push failed")); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const state = await (service as any).storage.get("cloud_sync_state"); + expect(state.counts).toMatchObject({ failed: 1, conflict: 0, overwrite: 0 }); + expect(state.error).toBeUndefined(); + }); + + it("真冲突计入 counts.conflict 且不重复计入 failed", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5000 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5000 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// cloud edit"), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// local edit" }) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "t", updatetime: 5497, createtime: 1, status: 1, sort: 0, metadata: {} }, + ]), + } as any + ); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + await (service as any).storage.set("sync_content_md5", { "u1.user.js": md5OfText("// base") }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const state = await (service as any).storage.get("cloud_sync_state"); + expect(state.counts).toMatchObject({ conflict: 1, failed: 0 }); + }); + + it("syncOnce 内部失败时写入 error 状态且结束 syncing", async () => { + const fs = createFs({ list: vi.fn().mockRejectedValue(new Error("list boom")) }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: {}, all: vi.fn().mockResolvedValue([]) } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const state = await (service as any).storage.get("cloud_sync_state"); + expect(state).toMatchObject({ syncing: false, error: expect.stringContaining("list boom") }); + }); + }); + + describe("手动同步 cloudSyncOnce", () => { + const cfg = (enable: boolean): CloudSyncConfig => ({ + enable, + syncDelete: false, + syncStatus: true, + filesystem: "webdav", + params: {}, + }); + + it("云同步未启用时不触发同步", async () => { + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { getCloudSync: vi.fn().mockResolvedValue(cfg(false)) } as any, + {} as any + ); + const syncSpy = vi.spyOn(service, "syncOnce").mockResolvedValue(undefined); + + await service.cloudSyncOnce(); + + expect(syncSpy).not.toHaveBeenCalled(); + }); + + it("云同步启用时构建文件系统并同步一次", async () => { + const config = cfg(true); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { getCloudSync: vi.fn().mockResolvedValue(config) } as any, + {} as any + ); + const fsMock = createFs(); + vi.spyOn(service, "buildFileSystem").mockResolvedValue(fsMock); + const syncSpy = vi.spyOn(service, "syncOnce").mockResolvedValue(undefined); + + await service.cloudSyncOnce(); + + expect(syncSpy).toHaveBeenCalledWith(config, fsMock); + }); + + it("构建文件系统失败时写入 error 状态并抛出,供状态条与用户可见", async () => { + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { getCloudSync: vi.fn().mockResolvedValue(cfg(true)) } as any, + {} as any + ); + vi.spyOn(service, "buildFileSystem").mockRejectedValue(new Error("WebDAV verify failed")); + const syncSpy = vi.spyOn(service, "syncOnce").mockResolvedValue(undefined); + + await expect(service.cloudSyncOnce()).rejects.toThrow("WebDAV verify failed"); + + expect(syncSpy).not.toHaveBeenCalled(); + const state = await (service as any).storage.get("cloud_sync_state"); + expect(state).toMatchObject({ syncing: false, error: expect.stringContaining("WebDAV verify failed") }); + }); }); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 8ccecb158..bd555d000 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -14,7 +14,7 @@ import type { FileInfo } from "@Packages/filesystem/filesystem"; import type FileSystem from "@Packages/filesystem/filesystem"; import ZipFileSystem from "@Packages/filesystem/zip/zip"; import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory"; -import { isWarpTokenError } from "@Packages/filesystem/error"; +import { FileSystemError, isNotFoundError, isWarpTokenError } from "@Packages/filesystem/error"; import type { Group } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; import { type IMessageQueue } from "@Packages/message/message_queue"; @@ -22,7 +22,13 @@ import { createJSZip } from "@App/pkg/utils/jszip-x"; import { type ValueService } from "./value"; import { type ResourceService } from "./resource"; import { createObjectURL } from "../offscreen/client"; -import { type CloudSyncConfig, type SystemConfig } from "@App/pkg/config/config"; +import { + type CloudSyncConfig, + type CloudSyncState, + type SystemConfig, + CLOUD_SYNC_STATE_KEY, + DEFAULT_CLOUD_SYNC_STATE, +} from "@App/pkg/config/config"; import type { TDeleteScript, TInstallScript, TInstallScriptParams } from "../queue"; import { errorMsg, makeBlobURL } from "@App/pkg/utils/utils"; import { t } from "i18next"; @@ -82,7 +88,53 @@ type FileDigestMap = { [key: string]: string; }; +type SyncTask = { + uuid: string; + promise: Promise; + preserveDigestFiles: string[]; +}; + +type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "fatal"; + +// 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端时钟(WebDAV 等仅整秒精度)。 +// 跨时钟域比较前两侧都截断到整秒:同一秒内的毫秒余数不构成"本地更新"的证据(L4 同秒竞态) +const isNewerBySecond = (localMs: number, cloudMs: number) => Math.floor(localMs / 1000) > Math.floor(cloudMs / 1000); + +// pushScript 分两次写 .user.js / .meta.json,前者成功后者失败时抛出本错误, +// 带出已成功写入的文件名,让调用方只保留真正失败文件的旧 digest、推进成功文件的 digest, +// 避免已成功文件继续保留旧 digest,让下一轮只重试真正失败的文件。 +class PushScriptPartialError extends Error { + constructor( + readonly originalError: unknown, + readonly writtenFiles: string[] + ) { + super(originalError instanceof Error ? originalError.message : String(originalError)); + this.name = "PushScriptPartialError"; + } +} + +// 本地与云端在上次同步后都发生了修改(真冲突):不自动覆盖任何一端, +// 本轮跳过该脚本(沿用失败路径保留旧 digest 与云端状态),并聚合通知用户手动处理 +class SyncBothChangedConflictError extends Error { + constructor( + readonly uuid: string, + readonly scriptName: string + ) { + super(`sync conflict: both local and cloud changed for ${uuid}`); + this.name = "SyncBothChangedConflictError"; + } +} + +// 未完成同步操作的持久化登记。file_digest 只是文件快照,表达不了「删除做到一半」 +// 「.meta.json 还欠一次写入」这类未完成意图:部分失败后下一轮的方向判定不一定会 +// 再生成重试任务(如「本地无脚本 + 云端只剩 .meta.json」不命中任何决策分支), +// 须由本记录在 syncOnce 开头驱动重放,全部步骤成功才清除。 +type PendingSyncOp = { op: "delete"; syncDelete: boolean } | { op: "push" }; +type PendingSyncOps = { [uuid: string]: PendingSyncOp }; + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; +const PENDING_SYNC_OPS_KEY = "pending_sync_ops"; +const LAST_NOTIFIED_CONFLICT_KEY = "last_notified_sync_conflicts"; function getScriptModifiedDate(script: PushScriptParam): number { return script.updatetime || script.createtime || Date.now(); @@ -401,22 +453,93 @@ export class SynchronizeService { // 同步一次 async syncOnce(syncConfig: CloudSyncConfig, fs: FileSystem) { return stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + // 设备本地同步状态:开始置 syncing,结束写入计数/时间或错误,供设置页状态条展示。 + // 读旧值与写 syncing 不能 await 在 syncOnceInternal 之前,否则存储 I/O 会推迟内部起始时序(见测试的微任务门控)。 + const prevStatePromise = this.storage.get(CLOUD_SYNC_STATE_KEY).then(async (prev) => { + const prevState = (prev as CloudSyncState) || DEFAULT_CLOUD_SYNC_STATE; + await this.storage.set(CLOUD_SYNC_STATE_KEY, { ...prevState, syncing: true, error: undefined }); + return prevState; + }); try { - await this.syncOnceInternal(syncConfig, fs); + const counts = await this.syncOnceInternal(syncConfig, fs); + await prevStatePromise; // 保证 syncing 起始写在结束写之前 + await this.storage.set(CLOUD_SYNC_STATE_KEY, { syncing: false, lastSyncAt: Date.now(), counts }); } catch (e) { this.logger.error("sync once error", Logger.E(e)); + const prevState = await prevStatePromise.catch(() => DEFAULT_CLOUD_SYNC_STATE); + await this.storage.set(CLOUD_SYNC_STATE_KEY, { + ...prevState, + syncing: false, + error: e instanceof Error ? e.message : String(e), + }); } }); } private async syncOnceInternal(syncConfig: CloudSyncConfig, fs: FileSystem) { this.logger.info("start sync once"); + // 重放上一轮未完成的操作(半途失败的删除、欠写的 .meta.json)。 + // 必须在主流程对账前先落地,否则「本地无脚本 + 云端有 .user.js」会把删到一半的脚本拉回本地 + const pendingOps = await this.getPendingSyncOps(); + const pendingFailedUuids = new Set(); + { + const pendingUuids = Object.keys(pendingOps); + if (pendingUuids.length) { + // push 重放是盲覆盖,须先确认云端 .user.js 仍是本机上次写入的内容;删除重放已幂等无须校验 + const needGuard = pendingUuids.some((uuid) => pendingOps[uuid].op === "push"); + const cloudDigests = needGuard + ? new Map((await fs.list()).map((file) => [file.name, file.digest])) + : new Map(); + const digestRecord = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; + const replayedUuids: string[] = []; + const replayedDigests: FileDigestMap = {}; + for (const uuid of pendingUuids) { + const op = pendingOps[uuid]; + try { + if (op.op === "delete") { + await this.deleteCloudScript(fs, uuid, op.syncDelete); + } else { + const script = await this.scriptDAO.get(uuid); + if (!script) { + // 本地已删,云端文件交给删除事件或主流程处理 + delete pendingOps[uuid]; + continue; + } + const name = `${uuid}.user.js`; + const cloudDigest = cloudDigests.get(name); + if (cloudDigest === undefined || cloudDigest !== digestRecord[name]) { + // 云端已被他端改写或删除,盲目补推会覆盖对端更新:交回主流程方向判定 + delete pendingOps[uuid]; + continue; + } + Object.assign(replayedDigests, await this.pushScript(fs, script)); + } + delete pendingOps[uuid]; + replayedUuids.push(uuid); + } catch (e) { + // 重放仍失败:保留登记待下一轮,本轮主流程跳过该 uuid,防止半完成状态被误判 + pendingFailedUuids.add(uuid); + this.logger.warn("replay pending sync op failed", Logger.E(e), { + uuid, + op: op.op, + errorKind: this.classifySyncError(e), + }); + } + } + await this.setPendingSyncOps(pendingOps); + if (replayedUuids.length) { + await this.updateFileDigestForUuids(fs, replayedUuids, replayedDigests); + } + } + } // 获取文件列表 const list = await fs.list(); // 根据文件名生成一个map const uuidMap = new Map>(); // 储存文件摘要,用于检测文件是否有变化 const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; + // 上次同步成功时的本地内容基线(md5),用于云端已变时判断本地是否也变过(方向判定不依赖跨时钟时间比较) + const syncedContentMd5Map = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; for (const file of list) { if (file.name.endsWith(".user.js")) { @@ -455,27 +578,51 @@ export class SynchronizeService { }, } as ScriptcatSync; let cloudStatus: ScriptcatSync["status"]["scripts"] = {}; + let canWriteScriptcatSync = true; if (file) { - // 如果有,则读取文件内容 - const cloudScriptCatSync = JSON.parse(await fs.open(file).then((f) => f.read("string"))) as ScriptcatSync; - cloudStatus = cloudScriptCatSync.status.scripts; + try { + // 如果有,则读取文件内容 + const cloudScriptCatSync = JSON.parse( + await fs.open(file).then((f) => f.read("string")) + ) as Partial; + cloudStatus = cloudScriptCatSync.status?.scripts || {}; + } catch (e) { + canWriteScriptcatSync = false; + this.logger.warn("read scriptcat-sync.json file failed", Logger.E(e)); + } } // 对比脚本列表和文件列表,进行同步 - const result: Promise[] = []; + const result: SyncTask[] = []; const updateScript: Map = new Map(); + // 无内容基线兜底覆盖:记录本轮"可能覆盖了未知改动"的脚本,供覆盖日志与聚合通知使用 + const overwriteScripts: { uuid: string; scriptName: string; direction: "pull" | "push" }[] = []; // 记录被跳过的孤儿云端脚本(仅 .user.js 无 .meta.json) // 避免本机回写 scriptcat-sync.json 时丢失对应 uuid 的云端 status const skippedOrphanUuids = new Set(); + let hasNotifiedSyncDelete = false; // 需要是同步操作,后续上传剩下的脚本 // 最后使用 Promise.allSettled 进行等待 + const addSyncTask = (uuid: string, promise: Promise, files?: string[]) => { + result.push({ + uuid, + promise, + preserveDigestFiles: files || [`${uuid}.user.js`, `${uuid}.meta.json`], + }); + }; uuidMap.forEach((file, uuid) => { + if (pendingFailedUuids.has(uuid)) { + // 该 uuid 仍有未完成操作且本轮重放失败:跳过主流程决策,避免在半完成状态上误拉/误推 + scriptMap.delete(uuid); + return; + } const script = scriptMap.get(uuid); if (script) { scriptMap.delete(uuid); // 脚本存在但是文件不存在,则读取.meta.json内容判断是否需要删除脚本 if (!file.script) { - result.push( + addSyncTask( + uuid, (async () => { // 读取meta文件 const meta = await fs.open(file.meta!); @@ -484,36 +631,97 @@ export class SynchronizeService { if (metaObj.isDeleted) { // 删除脚本 await this.script.deleteScript(script.uuid, "sync"); - InfoNotification( - i18n.t("settings:notification.script_sync_delete"), - i18n.t("settings:notification.script_sync_delete_desc", { - scriptName: i18nName(script), - }) - ); + if (!hasNotifiedSyncDelete) { + hasNotifiedSyncDelete = true; + InfoNotification( + i18n.t("settings:notification.script_sync_delete"), + i18n.t("settings:notification.script_sync_delete_desc", { + scriptName: i18nName(script), + }) + ); + } } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); return await this.pushScript(fs, script); } - })() + })(), + [file.meta!.name, `${uuid}.user.js`] ); return; } - // 过滤掉无变动的文件 - if (fileDigestMap[file.script!.name] === file.script!.digest) { + const updatetime = script.updatetime || script.createtime; + // 云端缺 .meta.json(上一轮分片上传残留):无论方向判定如何都需补传修复 + if (!file.meta) { + addSyncTask(uuid, this.pushScript(fs, script)); return; } - const updatetime = script.updatetime || script.createtime; - // 对比脚本更新时间和文件更新时间 - if (updatetime > file.script!.updatetime || !file.meta) { - // 如果脚本更新时间大于文件更新时间 - // 或者不存在.meta文件,则上传文件 - result.push(this.pushScript(fs, script)); - } else { - // 如果脚本更新时间小于文件更新时间,则更新脚本 - updateScript.set(uuid, true); - result.push(this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script)); + if (fileDigestMap[file.script!.name] === file.script!.digest) { + // 云端自上次同步未变:本地更新时间不比云端新(整秒对齐)则无事可做; + // 否则本地编辑过(digest 相等只反映云端未变),需补偿上传(#1,队列 push 失败后的兜底) + if (!isNewerBySecond(updatetime, file.script!.updatetime)) { + // .user.js 未变不代表 .meta.json 未变:他端可能只改了 meta 字段(downloadUrl 等)。 + // 不检查就跳过,收尾的全量盖章会把这份未处理的 meta 标成已同步,之后永不再处理。 + // 无记录(从未成功同步过该 meta)时无法判定变化,交由收尾盖章建立基线 + const metaRecord = fileDigestMap[file.meta!.name]; + if (metaRecord !== undefined && metaRecord !== file.meta!.digest) { + addSyncTask( + uuid, + (async () => { + const metaJson = (await fs.open(file.meta!).then((r) => r.read("string"))) as string; + const metaObj = JSON.parse(metaJson) as SyncMeta; + if (metaObj.isDeleted) { + // .user.js 仍在时的 tombstone 是他端部分推送的残留,等对端补完 meta 后再重新判定 + return; + } + await this.scriptDAO.update(uuid, { + origin: metaObj.origin ?? script.origin, + downloadUrl: metaObj.downloadUrl ?? script.downloadUrl, + checkUpdateUrl: metaObj.checkUpdateUrl ?? script.checkUpdateUrl, + }); + await this.recordSyncedContentMd5({ [file.meta!.name]: md5OfText(metaJson) }); + })(), + [file.meta!.name] + ); + } + return; + } + addSyncTask(uuid, this.pushScript(fs, script)); + return; } + // 云端自上次同步已变(或本机无记录)。本地毫秒时钟与服务端整秒 mtime 属于两个时钟域, + // 对端更新落在同一秒内时"本地时间戳更大"是误报(L4 同秒竞态), + // 方向判定优先用本地内容基线:本地内容自上次同步未变 → pull;双方都变 → 冲突,不自动覆盖任何一端 + addSyncTask( + uuid, + (async () => { + const direction = await this.decideDirectionOnRemoteChange(fs, file.script!, script, syncedContentMd5Map); + // 覆盖日志/通知只在写入成功后登记:失败轮通知会谎报覆盖, + // 且去重键提前落库后,下一轮真覆盖发生时反而被静默 + const recordOverwrite = (dir: "pull" | "push") => { + if (direction.action === "adopt" || !direction.unverified) return; + const scriptName = i18nName(script); + this.logger.warn("sync overwrite", { action: "overwrite", direction: dir, uuid, name: scriptName }); + overwriteScripts.push({ uuid, scriptName, direction: dir }); + }; + if (direction.action === "pull") { + updateScript.set(uuid, true); + await this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script); + recordOverwrite("pull"); + return; + } + if (direction.action === "push") { + const pushed = await this.pushScript(fs, script); + recordOverwrite("push"); + return pushed; + } + if (direction.action === "adopt") { + // 两端内容一致只是基线过期:返回内容 md5 让 updateFileDigest 推进基线,不产生写操作 + return direction.digestMap; + } + throw new SyncBothChangedConflictError(uuid, i18nName(script)); + })() + ); return; } // 如果脚本不存在,但文件存在,则安装脚本 @@ -528,137 +736,372 @@ export class SynchronizeService { return; } updateScript.set(uuid, true); - result.push(this.pullScript(fs, file as SyncFiles, cloudStatus[uuid])); + addSyncTask(uuid, this.pullScript(fs, file as SyncFiles, cloudStatus[uuid])); + return; + } + // 本地无脚本、云端只剩 .meta.json:tombstone 是删除标记须保留; + // 非 tombstone 是他端分片删除的残留,放着会被其他设备当「无效 meta」重新上传脚本。 + // 以 digest 变化为门(tombstone 首轮读取盖章后不再重复读取) + if (file.meta && fileDigestMap[file.meta.name] !== file.meta.digest) { + const meta = file.meta; + addSyncTask( + uuid, + (async () => { + const metaObj = JSON.parse((await fs.open(meta).then((r) => r.read("string"))) as string) as SyncMeta; + if (!metaObj.isDeleted) { + await fs.delete(meta.name); + } + })(), + [meta.name] + ); } }); // 上传剩下的脚本 scriptMap.forEach((script) => { - result.push(this.pushScript(fs, script)); + addSyncTask(script.uuid, this.pushScript(fs, script)); }); // 忽略错误 - const syncResults = await Promise.allSettled(result); + const syncResults = await Promise.allSettled(result.map((item) => item.promise)); const pushedFileDigestMap: FileDigestMap = {}; - syncResults.forEach((ret) => { + const preserveDigestFiles = new Set(); + // 重放失败的 uuid 也计入失败:状态条如实显示、status 写回保留云端原值 + const failedSyncUuids = new Set(pendingFailedUuids); + const conflictScripts: SyncBothChangedConflictError[] = []; + const partialPushUuids: string[] = []; + syncResults.forEach((ret, index) => { if (ret.status === "fulfilled" && ret.value) { Object.assign(pushedFileDigestMap, ret.value); + } else if (ret.status === "rejected") { + failedSyncUuids.add(result[index].uuid); + if (ret.reason instanceof SyncBothChangedConflictError) { + conflictScripts.push(ret.reason); + } + // 分片上传时已成功写入云端的文件(如 .user.js 成功、.meta.json 失败)不保留旧 digest, + // 让 updateFileDigest 记录其云端最新 digest,下一轮只重试真正失败的文件。 + const writtenFiles = ret.reason instanceof PushScriptPartialError ? ret.reason.writtenFiles : []; + if (writtenFiles.length > 0) { + partialPushUuids.push(result[index].uuid); + } + result[index].preserveDigestFiles.forEach((name) => { + if (!writtenFiles.includes(name)) { + preserveDigestFiles.add(name); + } + }); + this.logger.warn("sync task failed", Logger.E(ret.reason), { + errorKind: this.classifySyncError(ret.reason), + files: result[index].preserveDigestFiles, + }); } }); + // push 部分成功的 uuid:.user.js digest 会被推进,下一轮方向判定不会再生成 .meta.json + // 的重试任务,登记 pending push 由下一轮 syncOnce 开头重放 + if (partialPushUuids.length) { + for (const uuid of partialPushUuids) { + pendingOps[uuid] = { op: "push" }; + } + await this.setPendingSyncOps(pendingOps); + } + // 冲突通知:一轮只发一条;同一批脚本持续冲突时不重复轰炸,集合变化或冲突消失后重置 + const conflictKey = conflictScripts + .map((c) => c.uuid) + .sort() + .join(","); + if (conflictScripts.length) { + const lastNotifiedConflictKey = ((await this.storage.get(LAST_NOTIFIED_CONFLICT_KEY)) as string) || ""; + if (conflictKey !== lastNotifiedConflictKey) { + InfoNotification( + i18n.t("settings:notification.script_sync_conflict"), + i18n.t("settings:notification.script_sync_conflict_desc", { + scriptNames: conflictScripts.map((c) => c.scriptName).join(", "), + }), + { + url: chrome.runtime.getURL("/src/options.html#/settings?section=sync"), + } + ); + await this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, conflictKey); + } + } + // 覆盖不再弹桌面通知:覆盖是已发生、无需用户处理的信息级事件,仅由上面的 overwrite 日志 + // 与设置页状态条信息行 + 日志深链承载(见 docs/cloud-sync.md 覆盖可见性)。 // 同步状态 - if (syncConfig.syncStatus) { - const scriptlist = await this.scriptDAO.all(); - await Promise.allSettled( - scriptlist.map(async (script) => { - // 判断云端状态是否与本地状态一致 - const status = cloudStatus[script.uuid]; - const updatetime = script.updatetime || script.createtime; - if (!status) { - scriptcatSync.status.scripts[script.uuid] = { - enable: script.status === SCRIPT_STATUS_ENABLE, - sort: script.sort, - updatetime: updatetime, - }; - } else { - if (updateScript.has(script.uuid)) { - // 脚本已经更新过了,跳过状态同步 - scriptcatSync.status.scripts[script.uuid] = status; + if (syncConfig.syncStatus && canWriteScriptcatSync) { + try { + const scriptlist = await this.scriptDAO.all(); + await Promise.allSettled( + scriptlist.map(async (script) => { + if (failedSyncUuids.has(script.uuid)) { + scriptcatSync.status.scripts[script.uuid] = cloudStatus[script.uuid]; return; } - // 判断时间 - // 如果云端状态的更新时间小于本地状态的更新时间,则更新云端状态 - if (status.updatetime < updatetime) { + // 判断云端状态是否与本地状态一致 + const status = cloudStatus[script.uuid]; + const updatetime = script.updatetime || script.createtime; + if (!status) { scriptcatSync.status.scripts[script.uuid] = { enable: script.status === SCRIPT_STATUS_ENABLE, sort: script.sort, updatetime: updatetime, }; - return; - } - // 否则采用云端状态 - scriptcatSync.status.scripts[script.uuid] = status; - // 脚本顺序 - if (status.sort !== script.sort) { - await this.scriptDAO.update(script.uuid, { - sort: status.sort, - }); - } - // 脚本状态 - if (status.enable !== (script.status === SCRIPT_STATUS_ENABLE)) { - // 开启脚本 - await this.script.enableScript({ - uuid: script.uuid, - enable: status.enable, - }); + } else { + if (updateScript.has(script.uuid)) { + // 脚本已经更新过了,跳过状态同步 + scriptcatSync.status.scripts[script.uuid] = status; + return; + } + // 判断时间 + // 如果云端状态的更新时间小于本地状态的更新时间,则更新云端状态 + if (status.updatetime < updatetime) { + scriptcatSync.status.scripts[script.uuid] = { + enable: script.status === SCRIPT_STATUS_ENABLE, + sort: script.sort, + updatetime: updatetime, + }; + return; + } + // 否则采用云端状态 + scriptcatSync.status.scripts[script.uuid] = status; + // 脚本顺序 + if (status.sort !== script.sort) { + await this.scriptDAO.update(script.uuid, { + sort: status.sort, + }); + } + // 脚本状态 + if (status.enable !== (script.status === SCRIPT_STATUS_ENABLE)) { + // 开启脚本 + await this.script.enableScript({ + uuid: script.uuid, + enable: status.enable, + }); + } } + }) + ); + // 保留被跳过的 orphan uuid 的云端 status,避免覆盖另一台设备半上传的状态 + skippedOrphanUuids.forEach((uuid) => { + const status = cloudStatus[uuid]; + if (status) { + scriptcatSync.status.scripts[uuid] = status; } - }) - ); - // 保留被跳过的 orphan uuid 的云端 status,避免覆盖另一台设备半上传的状态 - skippedOrphanUuids.forEach((uuid) => { - const status = cloudStatus[uuid]; - if (status) { - scriptcatSync.status.scripts[uuid] = status; + }); + if (file) { + const latestCloudStatus = await this.readScriptcatSyncStatus(fs, file); + scriptcatSync.status.scripts = this.mergeScriptcatSyncStatus( + cloudStatus, + latestCloudStatus, + scriptcatSync.status.scripts + ); } - }); - // 保存脚本猫同步状态 - const modifiedDate = Date.now(); - const syncFile = await fs.create("scriptcat-sync.json", { modifiedDate }); - await syncFile.write(JSON.stringify(scriptcatSync, null, 2)); - this.logger.info("sync scriptcat-sync.json file success"); + // 保存脚本猫同步状态 + const modifiedDate = Date.now(); + const syncFile = await fs.create("scriptcat-sync.json", { modifiedDate }); + await syncFile.write(JSON.stringify(scriptcatSync, null, 2)); + this.logger.info("sync scriptcat-sync.json file success"); + } catch (e) { + this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); + } + } else if (syncConfig.syncStatus && !canWriteScriptcatSync) { + this.logger.warn("skip scriptcat-sync.json write because cloud status could not be read"); } // 重新获取文件列表,保存文件摘要 this.logger.info("update file digest"); - await this.updateFileDigest(fs, pushedFileDigestMap); + await this.updateFileDigest(fs, pushedFileDigestMap, preserveDigestFiles); + if (!conflictScripts.length) { + await this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, ""); + } this.logger.info("sync complete"); - return; + // failedSyncUuids 含冲突(冲突走失败路径),failed 计数排除冲突以免与 conflict 重复 + return { + total: scriptList.length, + overwrite: overwriteScripts.length, + conflict: conflictScripts.length, + failed: Math.max(0, failedSyncUuids.size - conflictScripts.length), + }; + } + + private classifySyncError(error: unknown): SyncErrorKind { + if (error instanceof PushScriptPartialError) { + return this.classifySyncError(error.originalError); + } + if (error instanceof SyncBothChangedConflictError) { + return "conflict"; + } + if (error instanceof FileSystemError) { + if (error.conflict) { + return "conflict"; + } + if (error.rateLimit || error.retryable) { + return "transient"; + } + if (error.notFound) { + return "stale_snapshot"; + } + // auth 等其余 typed 错误都属于 fatal + return "fatal"; + } + // WarpTokenError 及其他未分类错误一律 fatal + return "fatal"; + } + + private async readScriptcatSyncStatus(fs: FileSystem, file: FileInfo): Promise { + const cloudScriptCatSync = JSON.parse(await fs.open(file).then((f) => f.read("string"))) as Partial; + return cloudScriptCatSync.status?.scripts || {}; + } + + private mergeScriptcatSyncStatus( + initialStatus: ScriptcatSync["status"]["scripts"], + latestStatus: ScriptcatSync["status"]["scripts"], + candidateStatus: ScriptcatSync["status"]["scripts"] + ): ScriptcatSync["status"]["scripts"] { + const merged: ScriptcatSync["status"]["scripts"] = { ...latestStatus }; + for (const uuid of Object.keys(candidateStatus)) { + const candidate = candidateStatus[uuid]; + if (!candidate) { + continue; + } + const initial = initialStatus[uuid]; + const latest = latestStatus[uuid]; + const candidateOnlyPreservedInitial = + initial && + candidate.enable === initial.enable && + candidate.sort === initial.sort && + candidate.updatetime === initial.updatetime; + if (candidateOnlyPreservedInitial) { + // Defer to remote: if another device deleted this uuid, respect the deletion + if (latest !== undefined) { + merged[uuid] = latest; + } + continue; + } + if (!latest || candidate.updatetime >= latest.updatetime) { + merged[uuid] = candidate; + } + } + return merged; } - async updateFileDigest(fs: FileSystem, knownFileDigestMap: FileDigestMap = {}) { + async updateFileDigest( + fs: FileSystem, + knownFileDigestMap: FileDigestMap = {}, + preserveFileNames = new Set() + ) { + // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供后续同步方向判定 + await this.recordSyncedContentMd5(knownFileDigestMap); + const oldFileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const newList = await fs.list(); const newFileDigestMap: FileDigestMap = {}; for (const file of newList) { + if (preserveFileNames.has(file.name)) { + if (oldFileDigestMap[file.name] !== undefined) { + newFileDigestMap[file.name] = oldFileDigestMap[file.name]; + } + continue; + } newFileDigestMap[file.name] = file.digest; } // 各后端 digest 格式不一(WebDAV/OneDrive/S3 是 etag、Dropbox 是 content_hash、Zip 为空, // 仅 GoogleDrive/Baidu 是 md5),只在云端列表暂时漏掉刚上传的文件时用本地 md5 兜底, // 不能覆盖 fs.list 已返回的原生 digest,否则下次同步比对会因格式不一致而误判 for (const name in knownFileDigestMap) { - if (!(name in newFileDigestMap)) { + if (!(name in newFileDigestMap) && !preserveFileNames.has(name)) { newFileDigestMap[name] = knownFileDigestMap[name]; } } + preserveFileNames.forEach((name) => { + if (!(name in newFileDigestMap) && oldFileDigestMap[name] !== undefined) { + newFileDigestMap[name] = oldFileDigestMap[name]; + } + }); await this.storage.set("file_digest", newFileDigestMap); + // syncOnce 已全量对账整份云端列表,可安全清理 file_digest 之外的 sync_content_md5,避免只增不删 + await this.pruneSyncedContentMd5((name) => !(name in newFileDigestMap)); + return; + } + + // 队列路径(scriptInstall/scriptsDelete)只推送/删除了指定 uuid 的文件,只能更新这些文件的 digest 记录。 + // 不能像 syncOnce 那样全量盖章:syncOnce 已逐文件对账整份云端列表,而队列路径没有——全量盖章会把 + // 他端已更新但本机尚未 pull 的文件也标成已同步,导致下轮 syncOnce 早退漏 pull。 + async updateFileDigestForUuids( + fs: FileSystem, + uuids: string[], + knownFileDigestMap: FileDigestMap = {}, + preserveFileNames = new Set() + ) { + // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供后续同步方向判定 + await this.recordSyncedContentMd5(knownFileDigestMap); + const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; + const targetFiles = new Set(uuids.flatMap((uuid) => [`${uuid}.user.js`, `${uuid}.meta.json`])); + const newList = await fs.list(); + const listedDigest = new Map(newList.map((file) => [file.name, file.digest])); + // 本次确认已从云端删除的目标文件,其 sync_content_md5 一并清理(仅限本次目标,队列路径未全量对账不能全局清理) + const deletedTargetFiles = new Set(); + for (const name of targetFiles) { + if (preserveFileNames.has(name)) { + // 失败文件保留旧 digest,下轮重试 + continue; + } + if (listedDigest.has(name)) { + // 云端仍在:记录原生 digest(各后端 etag/content_hash/md5 格式不一,须用 list 的原生值而非本地 md5) + fileDigestMap[name] = listedDigest.get(name)!; + } else if (name in knownFileDigestMap) { + // 云端列表暂时漏掉刚上传的文件(最终一致性延迟):用本地 md5 兜底 + fileDigestMap[name] = knownFileDigestMap[name]; + } else { + // 云端已不存在(删除):移除记录 + delete fileDigestMap[name]; + deletedTargetFiles.add(name); + } + } + await this.storage.set("file_digest", fileDigestMap); + await this.pruneSyncedContentMd5((name) => deletedTargetFiles.has(name)); return; } + private async getPendingSyncOps(): Promise { + return ((await this.storage.get(PENDING_SYNC_OPS_KEY)) as PendingSyncOps) || {}; + } + + private async setPendingSyncOps(ops: PendingSyncOps) { + await this.storage.set(PENDING_SYNC_OPS_KEY, ops); + } + // 删除云端脚本数据 async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) { const filename = `${uuid}.user.js`; + const metaFilename = `${uuid}.meta.json`; const logger = this.logger.with({ uuid: uuid, file: filename, }); + // 删除幂等化:目标文件已不存在即视为达成目的。部分 provider(Google Drive 等) + // 对缺失文件抛 notFound,重放半途失败的删除时不能被它中断 + const deleteIgnoreMissing = async (name: string) => { + try { + await fs.delete(name); + } catch (e) { + if (!isNotFoundError(e)) throw e; + } + }; try { - await fs.delete(filename); + await deleteIgnoreMissing(filename); if (syncDelete) { // 留下一个.meta.json删除标记 const modifiedDate = Date.now(); - const meta = await fs.create(`${uuid}.meta.json`, { modifiedDate }); + const meta = await fs.create(metaFilename, { modifiedDate }); await meta.write( JSON.stringify({ uuid: uuid, - // origin: script.origin, - // downloadUrl: script.downloadUrl, - // checkUpdateUrl: script.checkUpdateUrl, isDeleted: true, }) ); } else { // 直接删除所有相关文件 - await fs.delete(`${uuid}.meta.json`); + await deleteIgnoreMissing(metaFilename); } logger.info("delete success"); } catch (e) { logger.error("delete file error", Logger.E(e)); + throw e; } return; } @@ -672,6 +1115,8 @@ export class SynchronizeService { name: script.name, file: filename, }); + // 记录本次已成功写入云端的文件,供部分失败时区分「成功文件」与「失败文件」 + const writtenFiles: string[] = []; try { const modifiedDate = getScriptModifiedDate(script); const w = await fs.create(filename, { modifiedDate }); @@ -679,6 +1124,7 @@ export class SynchronizeService { const code = await this.scriptCodeDAO.get(script.uuid); const scriptCode = code!.code; await w.write(scriptCode); + writtenFiles.push(filename); const meta = await fs.create(metaFilename, { modifiedDate }); const metaJson = JSON.stringify({ uuid: script.uuid, @@ -687,6 +1133,7 @@ export class SynchronizeService { checkUpdateUrl: script.checkUpdateUrl, }); await meta.write(metaJson); + writtenFiles.push(metaFilename); logger.info("push script success"); return { [filename]: md5OfText(scriptCode), @@ -694,8 +1141,75 @@ export class SynchronizeService { }; } catch (e) { logger.error("push script error", Logger.E(e)); - throw e; + throw new PushScriptPartialError(e, writtenFiles); + } + } + + // 落库本次成功推送/拉取内容的 md5(键为文件名):作为本地内容基线供方向判定(L4), + // 在 updateFileDigest* 内部、fs.list 之前调用,确保即便随后 list 抛错也已持久化。 + private async recordSyncedContentMd5(syncedMd5Map: FileDigestMap) { + const names = Object.keys(syncedMd5Map); + if (!names.length) { + return; + } + const stored = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; + for (const name of names) { + stored[name] = syncedMd5Map[name]; } + await this.storage.set("sync_content_md5", stored); + } + + // 随 file_digest 生命周期收敛 sync_content_md5,避免只增不删。shouldRemove 决定某文件名是否移除。 + private async pruneSyncedContentMd5(shouldRemove: (name: string) => boolean) { + const stored = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; + let changed = false; + for (const name of Object.keys(stored)) { + if (shouldRemove(name)) { + delete stored[name]; + changed = true; + } + } + if (changed) { + await this.storage.set("sync_content_md5", stored); + } + } + + // 云端 digest 相对上次同步已变时决定同步方向。 + // 不比较本地毫秒时钟与服务端整秒 mtime(同秒竞态会误判方向,L4): + // 用上次同步的本地内容基线判断本地是否也变过,双方都变才算冲突; + // 无基线(升级前旧数据/从未同步成功)时退回旧的时间比较规则。 + private async decideDirectionOnRemoteChange( + fs: FileSystem, + cloudFile: FileInfo, + script: Script, + syncedContentMd5Map: FileDigestMap + ): Promise< + { action: "push" | "pull" | "conflict"; unverified?: boolean } | { action: "adopt"; digestMap: FileDigestMap } + > { + const baselineMd5 = syncedContentMd5Map[cloudFile.name]; + if (baselineMd5 === undefined) { + // 无内容基线(升级前旧数据/从未同步成功):只能退回跨时钟域的墙钟比较(整秒对齐,同秒判 pull), + // 无法确认落败一端是否真未改动,因此标记 unverified——上层据此打 overwrite 日志并通知用户, + // 让其自行确认(见 docs/cloud-sync.md 覆盖可见性)。 + const updatetime = script.updatetime || script.createtime; + return { action: isNewerBySecond(updatetime, cloudFile.updatetime) ? "push" : "pull", unverified: true }; + } + const code = await this.scriptCodeDAO.get(script.uuid); + const localMd5 = code ? md5OfText(code.code) : undefined; + if (localMd5 === baselineMd5) { + return { action: "pull" }; + } + // 本地也变了:读取云端内容确认是否真冲突。两端内容一致(两台设备做了同样的编辑, + // 或本机记账失败后云端内容实为本机所写)只是基线过期,直接采用云端 digest 收敛 + try { + const cloudCode = (await fs.open(cloudFile).then((r) => r.read("string"))) as string; + if (localMd5 !== undefined && md5OfText(cloudCode) === localMd5) { + return { action: "adopt", digestMap: { [cloudFile.name]: localMd5 } }; + } + } catch (e) { + this.logger.warn("read cloud content for conflict check failed", Logger.E(e), { uuid: script.uuid }); + } + return { action: "conflict" }; } async pullScript(fs: FileSystem, file: SyncFiles, status: ScriptcatSyncStatus | undefined, existingScript?: Script) { @@ -735,10 +1249,20 @@ export class SynchronizeService { script, code, upsertBy: "sync", + // 本地 updatetime 采用云端文件时间(与 push 把 modifiedDate 设为本地 updatetime 对称)。 + // 若放任 prepareScriptByCode 的 Date.now(),下一轮会把刚拉下来的内容误判为 + // "本地编辑过"而补偿 push;etag 型 provider 重写会换 etag,两台设备将永久 pull/push 振荡 + updatetime: file.script.updatetime, + }); + // 记录拉取内容基线:下轮云端再变时用它判断本地是否也改过(方向判定不依赖跨时钟时间比较) + await this.recordSyncedContentMd5({ + [file.script.name]: md5OfText(code), + [file.meta.name]: md5OfText(metaJson), }); logger.info("pull script success"); } catch (e) { logger.error("pull script error", Logger.E(e)); + throw e; } } @@ -791,10 +1315,33 @@ export class SynchronizeService { if (config.enable) { stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { const fs = await this.buildFileSystem(config); - const pushedFileDigestMap = await this.pushScript(fs, params.script); - await this.updateFileDigest(fs, pushedFileDigestMap); + const script = params.script; + try { + const pushedFileDigestMap = await this.pushScript(fs, script); + await this.updateFileDigestForUuids(fs, [script.uuid], pushedFileDigestMap); + } catch (e) { + // 部分成功(如 .user.js 成功、.meta.json 失败)时,已写成功文件的云端 digest 已更新, + // 必须记录其最新 digest、只保留失败文件的旧值,避免下一轮误判成功文件仍未同步。 + // 全部失败(无文件写入成功)则不动 file_digest,保持"失败不污染"。 + const writtenFiles = e instanceof PushScriptPartialError ? e.writtenFiles : []; + if (writtenFiles.length > 0) { + // 部分成功后 .user.js digest 已推进,下一轮「digest 相等 + 本地时间不比云端新」 + // 会跳过整个 uuid(生产安装消息不带时间字段,云端 mtime 是 push 时刻的 Date.now(), + // 本地时间必然不比它新),失败的 .meta.json 不会再获得重试:登记 pending push 由 syncOnce 重放 + const pendingOps = await this.getPendingSyncOps(); + pendingOps[script.uuid] = { op: "push" }; + await this.setPendingSyncOps(pendingOps); + const preserve = new Set( + [`${script.uuid}.user.js`, `${script.uuid}.meta.json`].filter((name) => !writtenFiles.includes(name)) + ); + await this.updateFileDigestForUuids(fs, [script.uuid], {}, preserve); + } + throw e; + } }).catch((e) => { - this.logger.error("push script on install error", Logger.E(e)); + this.logger.error("push script on install error", Logger.E(e), { + errorKind: this.classifySyncError(e), + }); }); } } @@ -811,17 +1358,63 @@ export class SynchronizeService { if (config.enable) { stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { const fs = await this.buildFileSystem(config); + // 写前登记删除意图:两步删除(删 .user.js + 写 tombstone/删 .meta.json)中途失败 + // 或 SW 中途重启后,由 syncOnce 开头按登记重放,全部步骤成功才清除 + const pendingOps = await this.getPendingSyncOps(); for (const { uuid } of items) { - await this.deleteCloudScript(fs, uuid, config.syncDelete); + pendingOps[uuid] = { op: "delete", syncDelete: config.syncDelete }; + } + await this.setPendingSyncOps(pendingOps); + const preserveDigestFiles = new Set(); + for (const { uuid } of items) { + try { + await this.deleteCloudScript(fs, uuid, config.syncDelete); + delete pendingOps[uuid]; + } catch (e) { + preserveDigestFiles.add(`${uuid}.user.js`); + preserveDigestFiles.add(`${uuid}.meta.json`); + this.logger.warn("delete cloud script item failed", Logger.E(e), { + uuid, + errorKind: this.classifySyncError(e), + }); + } } - await this.updateFileDigest(fs); + await this.setPendingSyncOps(pendingOps); + await this.updateFileDigestForUuids( + fs, + items.map((item) => item.uuid), + {}, + preserveDigestFiles + ); }).catch((e) => { this.logger.error("delete cloud script error", Logger.E(e)); }); } } + // 手动触发一次云同步(设置页「立即同步」按钮)。用已保存配置,未启用则不触发。 + async cloudSyncOnce() { + const config = await this.systemConfig.getCloudSync(); + if (!config.enable) return; + let fs: FileSystem; + try { + fs = await this.buildFileSystem(config); + } catch (e) { + // 构建文件系统失败(连接/认证)发生在 syncOnce 之前,syncOnce 的状态处理覆盖不到; + // 这里补写 error 状态,让设置页状态条反映失败,并向上抛出供 UI 提示用户。 + const prev = ((await this.storage.get(CLOUD_SYNC_STATE_KEY)) as CloudSyncState) || DEFAULT_CLOUD_SYNC_STATE; + await this.storage.set(CLOUD_SYNC_STATE_KEY, { + ...prev, + syncing: false, + error: e instanceof Error ? e.message : String(e), + }); + throw e; + } + await this.syncOnce(config, fs); + } + init() { + this.group.on("cloudSyncOnce", this.cloudSyncOnce.bind(this)); this.group.on("export", this.requestExport.bind(this)); this.group.on("backupToCloud", this.backupToCloud.bind(this)); this.group.on("importResources", this.importResources.bind(this)); diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 5c4a4ea28..b21f77cfe 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "Skript-Löschsynchronisation", "script_sync_delete_desc": "Skript {{scriptName}} wurde gelöscht", + "script_sync_conflict": "Cloud-Synchronisierungskonflikt", + "script_sync_conflict_desc": "Skript {{scriptNames}} wurde sowohl lokal als auch in der Cloud geändert. Die Synchronisierung dieses Skripts ist pausiert, bitte lösen Sie den Konflikt manuell", "subscribe_update": "Abonnement {{subscribeName}} wurde aktualisiert", "subscribe_update_desc": "Neue Skripte: {{newScripts}}\nGelöschte Skripte: {{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "Papierkorb aktivieren", "trash_enabled_desc": "Gelöschte Skripte wandern zuerst in den Papierkorb und können jederzeit wiederhergestellt werden. Ist die Option deaktiviert, werden Skripte sofort endgültig gelöscht.", "trash_retention_custom": "Benutzerdefiniert", - "trash_retention_days_unit": "Tage" + "trash_retention_days_unit": "Tage", + "sync_view_logs": "Protokoll anzeigen", + "sync_now": "Jetzt synchronisieren", + "sync_state_idle": "Synchronisierung in Ordnung", + "sync_last_at": "Zuletzt synchronisiert {{time}}", + "sync_never": "Noch nicht synchronisiert", + "sync_state_syncing": "Synchronisiere…", + "sync_state_attention": "Letzte Synchronisierung erfordert Ihre Prüfung", + "sync_state_attention_desc": "{{conflict}} Skript(e) wurden bei der Synchronisierung durch Konflikte pausiert. Prüfen Sie das Protokoll.", + "sync_state_overwrite_info": "{{overwrite}} Skript(e) bei der letzten Synchronisierung überschrieben. Details finden Sie im Protokoll.", + "sync_state_error": "Synchronisierung fehlgeschlagen, pausiert", + "sync_state_failed_desc": "{{failed}} Skript(e) konnten nicht synchronisiert werden. Details finden Sie im Protokoll." } diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index 6dce1f92d..4796dc942 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "Script Sync Delete", "script_sync_delete_desc": "Script {{scriptName}} has been deleted", + "script_sync_conflict": "Cloud Sync Conflict", + "script_sync_conflict_desc": "Both the local and cloud versions of {{scriptNames}} have been modified. Sync for the script is paused; please resolve the conflict manually", "subscribe_update": "Subscribe {{subscribeName}} has been updated", "subscribe_update_desc": "New scripts: {{newScripts}}\nDeleted scripts: {{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "Enable Trash", "trash_enabled_desc": "Deleted scripts go to Trash first and can be restored at any time. When disabled, deleting a script removes it permanently right away.", "trash_retention_custom": "Custom", - "trash_retention_days_unit": "days" + "trash_retention_days_unit": "days", + "sync_view_logs": "View logs", + "sync_now": "Sync now", + "sync_state_idle": "Sync healthy", + "sync_last_at": "Last synced {{time}}", + "sync_never": "Not synced yet", + "sync_state_syncing": "Syncing…", + "sync_state_attention": "Last sync needs your review", + "sync_state_attention_desc": "{{conflict}} script(s) paused by conflicts during sync. Open the logs to review.", + "sync_state_overwrite_info": "{{overwrite}} script(s) overwritten during the last sync. View the logs for details.", + "sync_state_error": "Sync failed, paused", + "sync_state_failed_desc": "{{failed}} script(s) failed to sync. Open the logs for details." } diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index aecb7f613..c599e7d47 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "スクリプト同期削除", "script_sync_delete_desc": "スクリプト {{scriptName}} が削除されました", + "script_sync_conflict": "クラウド同期の競合", + "script_sync_conflict_desc": "スクリプト {{scriptNames}} はローカルとクラウドの両方で変更されています。同期を一時停止しました。手動で解決してください", "subscribe_update": "サブスクリプション {{subscribeName}} が更新されました", "subscribe_update_desc": "新しいスクリプト: {{newScripts}}\n削除されたスクリプト: {{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "ゴミ箱を有効にする", "trash_enabled_desc": "削除したスクリプトはまずゴミ箱に移動し、いつでも復元できます。無効にすると、削除したスクリプトはすぐに完全に削除されます。", "trash_retention_custom": "カスタム", - "trash_retention_days_unit": "日" + "trash_retention_days_unit": "日", + "sync_view_logs": "ログを表示", + "sync_now": "今すぐ同期", + "sync_state_idle": "同期は正常です", + "sync_last_at": "前回の同期 {{time}}", + "sync_never": "未同期", + "sync_state_syncing": "同期中…", + "sync_state_attention": "前回の同期に確認が必要な変更があります", + "sync_state_attention_desc": "同期中に {{conflict}} 個のスクリプトが競合で停止中です。ログを確認してください", + "sync_state_overwrite_info": "前回の同期で {{overwrite}} 個のスクリプトが上書きされました。ログで確認できます", + "sync_state_error": "同期に失敗し、停止しました", + "sync_state_failed_desc": "{{failed}} 件のスクリプトを同期できませんでした。ログで詳細を確認してください。" } diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 20f2598ae..fd96cf342 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "Синхронизация удаления скрипта", "script_sync_delete_desc": "Скрипт {{scriptName}} был удален", + "script_sync_conflict": "Конфликт облачной синхронизации", + "script_sync_conflict_desc": "Скрипт {{scriptNames}} изменен и локально, и в облаке. Синхронизация этого скрипта приостановлена, разрешите конфликт вручную", "subscribe_update": "Подписка {{subscribeName}} была обновлена", "subscribe_update_desc": "Новые скрипты: {{newScripts}}\nУдаленные скрипты: {{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "Включить корзину", "trash_enabled_desc": "Удалённые скрипты сначала попадают в корзину, и их можно восстановить в любой момент. Если отключить, скрипты будут удаляться окончательно сразу.", "trash_retention_custom": "Пользовательское", - "trash_retention_days_unit": "дн." + "trash_retention_days_unit": "дн.", + "sync_view_logs": "Показать журнал", + "sync_now": "Синхронизировать сейчас", + "sync_state_idle": "Синхронизация в норме", + "sync_last_at": "Последняя синхронизация {{time}}", + "sync_never": "Ещё не синхронизировано", + "sync_state_syncing": "Синхронизация…", + "sync_state_attention": "Последняя синхронизация требует проверки", + "sync_state_attention_desc": "Приостановлено скриптов из-за конфликтов: {{conflict}}. Проверьте журнал.", + "sync_state_overwrite_info": "При последней синхронизации перезаписано скриптов: {{overwrite}}. Подробности в журнале.", + "sync_state_error": "Сбой синхронизации, приостановлено", + "sync_state_failed_desc": "Не удалось синхронизировать скрипты: {{failed}}. Подробности смотрите в журнале." } diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index 64b283e4b..af5648beb 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "Betik Eşitleme Silme İşlemi", "script_sync_delete_desc": "{{scriptName}} betiği silindi", + "script_sync_conflict": "Bulut Eşitleme Çakışması", + "script_sync_conflict_desc": "{{scriptNames}} betiği hem yerelde hem bulutta değiştirildi. Bu betiğin eşitlemesi duraklatıldı, lütfen çakışmayı elle çözün", "subscribe_update": "{{subscribeName}} aboneliği güncellendi", "subscribe_update_desc": "Yeni betikler: {{newScripts}}\nSilinen betikler: {{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "Çöp Kutusunu Etkinleştir", "trash_enabled_desc": "Silinen betikler önce çöp kutusuna taşınır ve istediğiniz zaman geri yüklenebilir. Devre dışı bırakıldığında betikler doğrudan kalıcı olarak silinir.", "trash_retention_custom": "Özel", - "trash_retention_days_unit": "gün" + "trash_retention_days_unit": "gün", + "sync_view_logs": "Günlükleri görüntüle", + "sync_now": "Şimdi eşitle", + "sync_state_idle": "Eşitleme normal", + "sync_last_at": "Son eşitleme {{time}}", + "sync_never": "Henüz eşitlenmedi", + "sync_state_syncing": "Eşitleniyor…", + "sync_state_attention": "Son eşitleme incelemenizi gerektiriyor", + "sync_state_attention_desc": "Eşitleme sırasında {{conflict}} betik çakışma nedeniyle duraklatıldı. Günlükleri inceleyin.", + "sync_state_overwrite_info": "Son eşitlemede {{overwrite}} betiğin üzerine yazıldı. Ayrıntılar için günlükleri açın.", + "sync_state_error": "Eşitleme başarısız, duraklatıldı", + "sync_state_failed_desc": "{{failed}} betik eşitlenemedi. Ayrıntılar için günlükleri açın." } diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index d8f37fc00..753b47c6f 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "Đồng bộ xóa script", "script_sync_delete_desc": "Script {{scriptName}} đã bị xóa", + "script_sync_conflict": "Xung đột đồng bộ đám mây", + "script_sync_conflict_desc": "Script {{scriptNames}} đã thay đổi cả trên máy và trên đám mây. Đã tạm dừng đồng bộ script này, vui lòng xử lý thủ công", "subscribe_update": "Đăng ký {{subscribeName}} đã được cập nhật", "subscribe_update_desc": "Script mới: {{newScripts}}\nScript đã xóa: {{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "Bật thùng rác", "trash_enabled_desc": "Script đã xóa sẽ được chuyển vào thùng rác trước và có thể khôi phục bất cứ lúc nào. Khi tắt, script sẽ bị xóa vĩnh viễn ngay lập tức.", "trash_retention_custom": "Tùy chỉnh", - "trash_retention_days_unit": "ngày" + "trash_retention_days_unit": "ngày", + "sync_view_logs": "Xem nhật ký", + "sync_now": "Đồng bộ ngay", + "sync_state_idle": "Đồng bộ bình thường", + "sync_last_at": "Đồng bộ lần cuối {{time}}", + "sync_never": "Chưa đồng bộ", + "sync_state_syncing": "Đang đồng bộ…", + "sync_state_attention": "Lần đồng bộ gần nhất cần bạn xác nhận", + "sync_state_attention_desc": "Có {{conflict}} script bị tạm dừng do xung đột khi đồng bộ. Mở nhật ký để kiểm tra.", + "sync_state_overwrite_info": "Lần đồng bộ gần nhất có {{overwrite}} script bị ghi đè. Xem nhật ký để biết chi tiết.", + "sync_state_error": "Đồng bộ thất bại, đã tạm dừng", + "sync_state_failed_desc": "Không thể đồng bộ {{failed}} script. Mở nhật ký để xem chi tiết." } diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index d1e7fdc8c..01980b2ba 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "脚本删除同步", "script_sync_delete_desc": "脚本 {{scriptName}} 已被删除", + "script_sync_conflict": "云同步冲突", + "script_sync_conflict_desc": "脚本 {{scriptNames}} 的本地与云端版本均已修改,已暂停同步该脚本,请手动处理冲突", "subscribe_update": "订阅 {{subscribeName}} 已更新", "subscribe_update_desc": "新增脚本:{{newScripts}}\n删除脚本:{{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "启用回收站", "trash_enabled_desc": "删除脚本时先移入回收站,可随时还原;关闭后删除将直接彻底删除", "trash_retention_custom": "自定义", - "trash_retention_days_unit": "天" + "trash_retention_days_unit": "天", + "sync_view_logs": "查看日志", + "sync_now": "立即同步", + "sync_state_idle": "同步正常", + "sync_last_at": "上次同步 {{time}}", + "sync_never": "尚未同步", + "sync_state_syncing": "正在同步…", + "sync_state_attention": "上次同步有需要确认的改动", + "sync_state_attention_desc": "同步时有 {{conflict}} 个脚本冲突已暂停,请打开日志确认", + "sync_state_overwrite_info": "上次同步有 {{overwrite}} 个脚本被覆盖,可查看日志", + "sync_state_error": "同步失败,已暂停", + "sync_state_failed_desc": "{{failed}} 个脚本同步失败,请打开日志查看详情" } diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 4678cea24..057234a98 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -91,6 +91,8 @@ "notification": { "script_sync_delete": "腳本刪除同步", "script_sync_delete_desc": "腳本 {{scriptName}} 已被刪除", + "script_sync_conflict": "雲端同步衝突", + "script_sync_conflict_desc": "腳本 {{scriptNames}} 的本機與雲端版本皆已修改,已暫停同步該腳本,請手動處理衝突", "subscribe_update": "訂閱 {{subscribeName}} 已更新", "subscribe_update_desc": "新增腳本:{{newScripts}}\n刪除腳本:{{deletedScripts}}" }, @@ -123,5 +125,16 @@ "trash_enabled": "啟用回收筒", "trash_enabled_desc": "刪除腳本時先移入回收筒,可隨時還原;關閉後刪除將直接永久刪除", "trash_retention_custom": "自訂", - "trash_retention_days_unit": "天" + "trash_retention_days_unit": "天", + "sync_view_logs": "查看日誌", + "sync_now": "立即同步", + "sync_state_idle": "同步正常", + "sync_last_at": "上次同步 {{time}}", + "sync_never": "尚未同步", + "sync_state_syncing": "正在同步…", + "sync_state_attention": "上次同步有需要確認的變更", + "sync_state_attention_desc": "同步時有 {{conflict}} 個腳本衝突已暫停,請開啟日誌確認", + "sync_state_overwrite_info": "上次同步有 {{overwrite}} 個腳本被覆蓋,可檢視日誌", + "sync_state_error": "同步失敗,已暫停", + "sync_state_failed_desc": "{{failed}} 個腳本同步失敗,請開啟日誌查看詳細資訊" } diff --git a/src/pages/options/layout/SettingsLayout.test.tsx b/src/pages/options/layout/SettingsLayout.test.tsx index b0802ad8a..994ff4f23 100644 --- a/src/pages/options/layout/SettingsLayout.test.tsx +++ b/src/pages/options/layout/SettingsLayout.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { Settings } from "lucide-react"; +import { MemoryRouter } from "react-router-dom"; import { SettingsLayout } from "./SettingsLayout"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; @@ -28,15 +29,23 @@ describe("设置外壳 SettingsLayout", () => { const cats = [ { id: "general", icon: Settings, label: "通用" }, { id: "interface", icon: Settings, label: "界面" }, + { id: "sync", icon: Settings, label: "同步" }, ]; - const renderLayout = () => + const renderLayout = (initialEntry = "/settings") => render( - - {() =>
{"body"}
} -
+ + + {() =>
{"body"}
} +
+
); + it("通知入口指定云同步分区时自动滚动到同步设置", () => { + renderLayout("/settings?section=sync"); + expect(scrollTo).toHaveBeenCalledWith("sync"); + }); + describe("桌面端(竖向左栏)", () => { it("渲染标题与全部分类导航项", () => { renderLayout(); diff --git a/src/pages/options/layout/SettingsLayout.tsx b/src/pages/options/layout/SettingsLayout.tsx index 9fc3f0c2d..e452e2e68 100644 --- a/src/pages/options/layout/SettingsLayout.tsx +++ b/src/pages/options/layout/SettingsLayout.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, type ComponentType, type ReactNode } from " import { cn } from "@App/pkg/utils/cn"; import { useScrollSpy } from "../hooks/useScrollSpy"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; +import { useSearchParams } from "react-router-dom"; export interface SettingsCategory { id: string; @@ -24,8 +25,14 @@ export function SettingsLayout({ title, categories, children }: SettingsLayoutPr const ids = useMemo(() => categories.map((c) => c.id), [categories]); const { activeId, register, scrollContainerRef, scrollTo } = useScrollSpy(ids); const isMobile = useIsMobile(); + const [searchParams] = useSearchParams(); const activeChipRef = useRef(null); + useEffect(() => { + const section = searchParams.get("section"); + if (section && ids.includes(section)) scrollTo(section); + }, [ids, scrollTo, searchParams]); + // 移动横向栏:激活分类滚动到可视区域(仅横向,不影响页面纵向滚动) useEffect(() => { activeChipRef.current?.scrollIntoView({ block: "nearest", inline: "center" }); diff --git a/src/pages/options/routes/Setting/index.test.tsx b/src/pages/options/routes/Setting/index.test.tsx index 691c073b5..641f402a4 100644 --- a/src/pages/options/routes/Setting/index.test.tsx +++ b/src/pages/options/routes/Setting/index.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeAll, afterEach, beforeEach } from "vitest"; import { render, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockIntersectionObserver } from "@Tests/mockIntersectionObserver"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; @@ -45,7 +46,11 @@ afterEach(cleanup); describe("设置页", () => { it("渲染 7 个分类导航项", () => { - render(); + render( + + + + ); expect(document.querySelectorAll("nav button")).toHaveLength(7); }); }); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx index 0b646ed60..fc04cbe26 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx @@ -1,6 +1,13 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; +const { t } = vi.hoisted(() => ({ + t: vi.fn((key: string, opts?: Record) => + opts?.failed === undefined ? key : `${key}:${String(opts.failed)}` + ), +})); +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t }) })); + const { create } = vi.hoisted(() => ({ create: vi.fn(() => Promise.resolve({})) })); vi.mock("@Packages/filesystem/factory", () => ({ default: { @@ -33,8 +40,34 @@ vi.mock("@App/pages/store/global", () => ({ subscribeMessage: () => () => {}, })); +const { fetchCloudSyncState, subscribeCloudSyncState, requestCloudSyncOnce } = vi.hoisted(() => ({ + fetchCloudSyncState: vi.fn(), + subscribeCloudSyncState: vi.fn(() => () => {}), + requestCloudSyncOnce: vi.fn(() => Promise.resolve()), +})); +vi.mock("@App/pages/store/features/cloud_sync", () => ({ + fetchCloudSyncState, + subscribeCloudSyncState, + requestCloudSyncOnce, +})); + +const { notify } = vi.hoisted(() => ({ + notify: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }, +})); +vi.mock("@App/pages/components/ui/toast", () => ({ notify })); + import { SyncSection } from "./SyncSection"; +function mockState(over: Record = {}) { + fetchCloudSyncState.mockResolvedValue({ + syncing: false, + lastSyncAt: 0, + error: undefined, + counts: { total: 0, overwrite: 0, conflict: 0, failed: 0 }, + ...over, + }); +} + function mockCloudSync(over: Record = {}) { get.mockImplementation((key: string) => { if (key === "cloud_sync") @@ -50,12 +83,26 @@ function mockCloudSync(over: Record = {}) { }); } +beforeEach(() => { + // 默认:状态读取返回空闲态,订阅返回空清理函数,避免未显式 mock 的用例在 effect 中崩溃 + mockState(); + subscribeCloudSyncState.mockReturnValue(() => {}); + requestCloudSyncOnce.mockResolvedValue(undefined); + t.mockClear(); + notify.info.mockClear(); + notify.success.mockClear(); + notify.error.mockClear(); +}); + afterEach(() => { cleanup(); get.mockReset(); set.mockReset(); create.mockReset(); create.mockResolvedValue({}); + fetchCloudSyncState.mockReset(); + subscribeCloudSyncState.mockReset(); + requestCloudSyncOnce.mockReset(); }); describe("同步分区", () => { @@ -104,4 +151,93 @@ describe("同步分区", () => { await act(async () => fireEvent.click(screen.getByTestId("cloud_sync_save"))); expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ syncStatus: false })); }); + + it("启用同步且上次有覆盖/冲突时显示警示状态条与查看日志深链", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + mockState({ lastSyncAt: 1, counts: { total: 3, overwrite: 2, conflict: 1, failed: 0 } }); + render( () => {}} />); + const strip = await screen.findByTestId("cloud_sync_status"); + expect(strip.getAttribute("data-variant")).toBe("warning"); + const href = screen.getByTestId("cloud_sync_view_logs").getAttribute("href") || ""; + expect(decodeURIComponent(href)).toContain("synchronize"); + expect(decodeURIComponent(href)).not.toContain("overwrite"); + }); + + it("上次有文件同步失败时显示失败数量", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + mockState({ lastSyncAt: 1, counts: { total: 3, overwrite: 0, conflict: 0, failed: 2 } }); + render( () => {}} />); + + const strip = await screen.findByTestId("cloud_sync_status"); + expect(strip.getAttribute("data-variant")).toBe("error"); + expect(strip.textContent).toContain("settings:sync_state_failed_desc:2"); + }); + + it("点击立即同步触发一次云同步", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + mockState({ lastSyncAt: 1 }); + render( () => {}} />); + const btn = await screen.findByTestId("cloud_sync_now"); + await act(async () => fireEvent.click(btn)); + expect(requestCloudSyncOnce).toHaveBeenCalled(); + }); + + it("未启用同步时不显示状态条", async () => { + mockCloudSync({ enable: false }); + mockState({}); + render( () => {}} />); + await screen.findByTestId("cloud_sync_save"); + expect(screen.queryByTestId("cloud_sync_status")).toBeNull(); + }); + + it("勾选启用但未保存时立即同步按钮保持禁用(按已保存配置门控,避免点击静默无响应)", async () => { + mockCloudSync({ enable: false }); + mockState({}); + render( () => {}} />); + const enable = await screen.findByTestId("cloud_sync_enable"); + fireEvent.click(enable); + // 勾选草稿 enable 但未保存:SW cloudSyncOnce 用的是已保存配置(enable=false 时静默 return), + // 按钮须随已保存配置禁用,否则点击毫无反馈 + expect((screen.getByTestId("cloud_sync_now") as HTMLButtonElement).disabled).toBe(true); + }); + + it("勾选启用但未保存时不显示状态条(状态条只反映已保存配置)", async () => { + mockCloudSync({ enable: false }); + mockState({}); + render( () => {}} />); + const enable = await screen.findByTestId("cloud_sync_enable"); + fireEvent.click(enable); + // 仅勾选草稿、尚未保存,不应立刻出现「同步正常」状态条 + expect(screen.queryByTestId("cloud_sync_status")).toBeNull(); + }); + + it("勾选启用并保存成功后才显示状态条", async () => { + mockCloudSync({ enable: false, params: { webdav: { url: "https://dav" } } }); + mockState({}); + render( () => {}} />); + const enable = await screen.findByTestId("cloud_sync_enable"); + fireEvent.click(enable); + await act(async () => fireEvent.click(screen.getByTestId("cloud_sync_save"))); + expect(screen.queryByTestId("cloud_sync_status")).not.toBeNull(); + }); + + it("仅有覆盖无冲突时状态条为同步正常(覆盖降级为信息级)并可查看日志", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + mockState({ lastSyncAt: 1, counts: { total: 3, overwrite: 3, conflict: 0, failed: 0 } }); + render( () => {}} />); + const strip = await screen.findByTestId("cloud_sync_status"); + expect(strip.getAttribute("data-variant")).toBe("idle"); + expect(screen.getByTestId("cloud_sync_view_logs")).not.toBeNull(); + expect(strip.textContent).toContain("settings:sync_state_overwrite_info"); + }); + + it("立即同步失败时弹出错误提示(不产生未捕获异常)", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + mockState({ lastSyncAt: 1 }); + requestCloudSyncOnce.mockRejectedValue(new Error("verify failed")); + render( () => {}} />); + const btn = await screen.findByTestId("cloud_sync_now"); + await act(async () => fireEvent.click(btn)); + expect(notify.error).toHaveBeenCalled(); + }); }); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index 941b1cf6b..bcb08bee3 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -1,20 +1,50 @@ import { useEffect, useState } from "react"; +import { RefreshCw, TriangleAlert, CircleAlert, CircleCheckBig, ExternalLink } from "lucide-react"; import { SettingCard } from "../../../components/SettingCard"; import FileSystemParams from "../../../components/FileSystemParams"; import { Checkbox } from "@App/pages/components/ui/checkbox"; import { Button } from "@App/pages/components/ui/button"; import { systemConfig } from "@App/pages/store/global"; +import { + fetchCloudSyncState, + subscribeCloudSyncState, + requestCloudSyncOnce, +} from "@App/pages/store/features/cloud_sync"; import FileSystemFactory from "@Packages/filesystem/factory"; import { useTranslation } from "react-i18next"; import { notify } from "@App/pages/components/ui/toast"; -import type { CloudSyncConfig } from "@App/pkg/config/config"; +import { cn } from "@App/pkg/utils/cn"; +import { semTime } from "@App/locales/relative-date"; +import { DEFAULT_CLOUD_SYNC_STATE, type CloudSyncConfig, type CloudSyncState } from "@App/pkg/config/config"; +import { syncStatusVariant, syncLogHref, type SyncStatusVariant } from "./syncStatus"; + +// 各状态的容器/图标配色与图标(明暗自适应设计令牌) +const VARIANT_META: Record = { + idle: { box: "border-border bg-muted/40", icon: "text-success", Icon: CircleCheckBig }, + syncing: { box: "border-border bg-muted/40", icon: "text-muted-foreground", Icon: RefreshCw, spin: true }, + warning: { box: "border-warning/40 bg-warning-bg", icon: "text-warning", Icon: TriangleAlert }, + error: { box: "border-destructive/40 bg-destructive/10", icon: "text-destructive", Icon: CircleAlert }, +}; export function SyncSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { const { t } = useTranslation(); const [draft, setDraft] = useState(undefined); + // 状态条只反映已保存/实际生效的同步配置:勾选草稿(draft.enable)尚未保存时不应展示运行状态。 + const [savedEnable, setSavedEnable] = useState(false); + const [syncState, setSyncState] = useState(DEFAULT_CLOUD_SYNC_STATE); useEffect(() => { - void Promise.resolve(systemConfig.get("cloud_sync")).then((v) => setDraft(v as CloudSyncConfig)); + void Promise.resolve(systemConfig.get("cloud_sync")).then((v) => { + const cfg = v as CloudSyncConfig; + setDraft(cfg); + setSavedEnable(cfg.enable); + }); + }, []); + + // 读取并订阅设备本地同步状态(SW 每轮同步写入 chrome.storage) + useEffect(() => { + void fetchCloudSyncState().then(setSyncState); + return subscribeCloudSyncState(setSyncState); }, []); const patch = (next: Partial) => setDraft((d) => (d ? { ...d, ...next } : d)); @@ -32,14 +62,77 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE } } systemConfig.set("cloud_sync", draft); + setSavedEnable(draft.enable); notify.success(t("save_success")); }; + const syncNow = async () => { + // 触发失败(如连接/账号验证失败)时给出即时反馈,避免静默失败与未捕获异常; + // 状态条随后也会因 SW 写入 error 状态而反映失败。 + try { + await requestCloudSyncOnce(); + } catch (e) { + notify.error(`${t("settings:cloud_sync_verification_failed")}: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const variant = syncStatusVariant(syncState); + const meta = VARIANT_META[variant]; + let title: string; + let desc = ""; + switch (variant) { + case "warning": + title = t("settings:sync_state_attention"); + desc = t("settings:sync_state_attention_desc", { conflict: syncState.counts.conflict }); + break; + case "error": + title = t("settings:sync_state_error"); + desc = syncState.error || t("settings:sync_state_failed_desc", { failed: syncState.counts.failed }); + break; + case "syncing": + title = t("settings:sync_state_syncing"); + break; + default: + title = t("settings:sync_state_idle"); + // 覆盖降级为信息级:仅有覆盖(无冲突/失败)时状态仍是「同步正常」,附中性说明 + 日志深链 + if (syncState.counts.overwrite > 0) { + desc = t("settings:sync_state_overwrite_info", { overwrite: syncState.counts.overwrite }); + } else if (syncState.lastSyncAt > 0) { + desc = t("settings:sync_last_at", { time: semTime(new Date(syncState.lastSyncAt)) }); + } else { + desc = t("settings:sync_never"); + } + } + return (
{draft && (
+ {savedEnable && ( +
+ +
+ {title} + {desc && {desc}} +
+ {variant !== "syncing" && ( + + {t("settings:sync_view_logs")} + + + )} +
+ )} +
)} diff --git a/src/pages/options/routes/Setting/sections/syncStatus.test.ts b/src/pages/options/routes/Setting/sections/syncStatus.test.ts new file mode 100644 index 000000000..d228a9d73 --- /dev/null +++ b/src/pages/options/routes/Setting/sections/syncStatus.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { syncStatusVariant, syncLogHref } from "./syncStatus"; +import { DEFAULT_CLOUD_SYNC_STATE, type CloudSyncState } from "@App/pkg/config/config"; +import enSettings from "@App/locales/en-US/settings.json"; + +const base = ( + over: Omit, "counts"> & { counts?: Partial } +): CloudSyncState => ({ + ...DEFAULT_CLOUD_SYNC_STATE, + ...over, + counts: { ...DEFAULT_CLOUD_SYNC_STATE.counts, ...(over.counts || {}) }, +}); + +describe("同步状态样式判定", () => { + it("error 优先于同步中与覆盖/冲突", () => { + expect(syncStatusVariant(base({ error: "x", syncing: true, counts: { overwrite: 2, conflict: 1 } }))).toBe("error"); + }); + + it("同步中优先于覆盖/冲突", () => { + expect(syncStatusVariant(base({ syncing: true, counts: { overwrite: 1 } }))).toBe("syncing"); + }); + + it("有冲突为 warning", () => { + expect(syncStatusVariant(base({ counts: { conflict: 1 } }))).toBe("warning"); + }); + + it("仅有覆盖、无冲突无失败时为 idle(覆盖降级为信息级,不触发警示)", () => { + // 覆盖是已发生、无需用户处理的审计信息,不应与冲突同级弹琥珀警示 + expect(syncStatusVariant(base({ counts: { overwrite: 5 } }))).toBe("idle"); + }); + + it("有文件同步失败时为 error,不能显示同步正常", () => { + expect(syncStatusVariant(base({ counts: { failed: 1 } }))).toBe("error"); + }); + + it("上一轮有失败但重试进行中时应显示同步中,而非失败已暂停", () => { + // SW 起始写只清 error 不清 counts:重试期间旧 counts.failed 不能把状态条压成"失败" + expect(syncStatusVariant(base({ syncing: true, counts: { failed: 1 } }))).toBe("syncing"); + }); + + it("无异常为 idle", () => { + expect(syncStatusVariant(base({ counts: { total: 5 } }))).toBe("idle"); + }); + + it("只有覆盖时深链过滤 overwrite", () => { + expect(decodeURIComponent(syncLogHref(base({ counts: { overwrite: 1 } })))).toContain("overwrite"); + }); + + it("有冲突或失败时深链只过滤同步服务,不能隐藏对应日志", () => { + const conflictHref = decodeURIComponent(syncLogHref(base({ counts: { overwrite: 1, conflict: 1 } }))); + const failedHref = decodeURIComponent(syncLogHref(base({ counts: { failed: 1 } }))); + + expect(conflictHref).toContain("synchronize"); + expect(conflictHref).not.toContain("overwrite"); + expect(failedHref).toContain("synchronize"); + expect(failedHref).not.toContain("overwrite"); + }); + + it("覆盖信息文案不谎报固定方向(只说发生覆盖,不断言本地/云端谁覆盖谁)", () => { + // 覆盖方向随每个脚本而变,状态条信息行只能中性描述,不能硬编码单一方向 + expect(enSettings.sync_state_overwrite_info).toContain("overwritten during"); + expect(enSettings.sync_state_overwrite_info).not.toMatch(/\b(local|cloud|remote)\b/i); + }); + + it("警示文案只描述冲突已暂停,覆盖已降级不再出现在警示里", () => { + expect(enSettings.sync_state_attention_desc).toContain("conflict"); + expect(enSettings.sync_state_attention_desc).not.toContain("overwritten"); + }); +}); diff --git a/src/pages/options/routes/Setting/sections/syncStatus.ts b/src/pages/options/routes/Setting/sections/syncStatus.ts new file mode 100644 index 000000000..5cbfd0559 --- /dev/null +++ b/src/pages/options/routes/Setting/sections/syncStatus.ts @@ -0,0 +1,24 @@ +import { type CloudSyncState } from "@App/pkg/config/config"; + +export type SyncStatusVariant = "idle" | "syncing" | "warning" | "error"; + +// 状态优先级:失败(error) > 同步中 > 上轮文件失败 > 有冲突(暂停) > 正常。 +// SW 起始写只清 error 不清 counts:重试进行中不能被旧 counts.failed 压成"失败已暂停"。 +// 覆盖(overwrite)不进入警示:覆盖是已发生、无需用户处理的审计信息,仅在 idle 态以信息行 + 日志深链呈现。 +export function syncStatusVariant(state: CloudSyncState): SyncStatusVariant { + if (state.error) return "error"; + if (state.syncing) return "syncing"; + if (state.counts.failed > 0) return "error"; + if (state.counts.conflict > 0) return "warning"; + return "idle"; +} + +// 「查看日志」深链:预过滤 service=synchronize;有覆盖时进一步落到 overwrite 行。 +// 由 Logger 页 parseInitialQueries 解析(?query=)。 +export function syncLogHref(state: CloudSyncState): string { + const query: { key: string; value: string }[] = [{ key: "service", value: "synchronize" }]; + if (state.counts.overwrite > 0 && state.counts.conflict === 0 && state.counts.failed === 0 && !state.error) { + query.push({ key: "action", value: "overwrite" }); + } + return `#/logs?query=${encodeURIComponent(JSON.stringify(query))}`; +} diff --git a/src/pages/options/routes/Tools/index.test.tsx b/src/pages/options/routes/Tools/index.test.tsx index 008f7ee63..62cd258bf 100644 --- a/src/pages/options/routes/Tools/index.test.tsx +++ b/src/pages/options/routes/Tools/index.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeAll, afterEach, beforeEach } from "vitest"; import { render, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockIntersectionObserver } from "@Tests/mockIntersectionObserver"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; @@ -52,7 +53,11 @@ afterEach(cleanup); describe("工具页", () => { it("渲染 5 个分类导航项", () => { - render(); + render( + + + + ); const nav = document.querySelector("nav")!; expect(nav.querySelectorAll("button")).toHaveLength(5); }); diff --git a/src/pages/store/features/cloud_sync.ts b/src/pages/store/features/cloud_sync.ts new file mode 100644 index 000000000..8d22d052e --- /dev/null +++ b/src/pages/store/features/cloud_sync.ts @@ -0,0 +1,25 @@ +import ChromeStorage from "@App/pkg/config/chrome_storage"; +import { CLOUD_SYNC_STATE_KEY, DEFAULT_CLOUD_SYNC_STATE, type CloudSyncState } from "@App/pkg/config/config"; +import { synchronizeClient } from "./script"; + +// 设备本地同步状态由 SynchronizeService 写入 ChromeStorage("sync") 命名空间(chrome.storage.local)。 +// 页面直接读取并订阅 chrome.storage 变更即可,无需经 serviceWorker 消息。 +const storage = new ChromeStorage("sync", false); +const RAW_KEY = storage.buildKey(CLOUD_SYNC_STATE_KEY); + +export const fetchCloudSyncState = (): Promise => + storage.get(CLOUD_SYNC_STATE_KEY).then((v) => (v as CloudSyncState) || DEFAULT_CLOUD_SYNC_STATE); + +// 手动触发一次云同步(设置页「立即同步」) +export const requestCloudSyncOnce = (): Promise => synchronizeClient.cloudSyncOnce(); + +// 订阅同步状态变更,返回取消订阅函数 +export const subscribeCloudSyncState = (cb: (state: CloudSyncState) => void): (() => void) => { + const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => { + if (area === "local" && changes[RAW_KEY]) { + cb((changes[RAW_KEY].newValue as CloudSyncState) || DEFAULT_CLOUD_SYNC_STATE); + } + }; + chrome.storage.onChanged.addListener(listener); + return () => chrome.storage.onChanged.removeListener(listener); +}; diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index eba11867f..31efaea74 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -20,6 +20,23 @@ export type CloudSyncConfig = { params: { [key: string]: any }; }; +// 云同步运行状态(设备本地,非用户配置):供设置页「脚本同步」卡片顶部状态条展示。 +// 由 SynchronizeService 每轮同步写入本地存储(ChromeStorage "sync" 命名空间),页面读取并订阅 chrome.storage 变更。 +export type CloudSyncState = { + syncing: boolean; + lastSyncAt: number; // ms,从未同步为 0 + error?: string; // 最近一次同步失败原因(如账号验证失败) + counts: { total: number; overwrite: number; conflict: number; failed: number }; +}; + +export const CLOUD_SYNC_STATE_KEY = "cloud_sync_state"; + +export const DEFAULT_CLOUD_SYNC_STATE: CloudSyncState = { + syncing: false, + lastSyncAt: 0, + counts: { total: 0, overwrite: 0, conflict: 0, failed: 0 }, +}; + export type FaviconService = "scriptcat" | "google" | "duckduckgo" | "icon-horse" | "local"; export type CATFileStorage = {