From 5b54a8814d732b4f06c33166c0651a21a40615c6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:49:17 +0900 Subject: [PATCH 001/103] Create sync-research.md --- docs/sync-research.md | 434 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/sync-research.md diff --git a/docs/sync-research.md b/docs/sync-research.md new file mode 100644 index 000000000..cc4dc356f --- /dev/null +++ b/docs/sync-research.md @@ -0,0 +1,434 @@ +# 云同步一致性与生产兼容性研究 + +## 目标 + +本分支用于研究和修复 ScriptCat 云同步在多设备、多 provider、网络异常和并发修改场景下的正确性问题。 + +核心目标: + +1. 避免云端文件被静默覆盖。 +2. 避免失败同步污染本地 `file_digest` 或远端 `scriptcat-sync.json`。 +3. 保持现有生产用户云端数据兼容。 +4. 不把 per-file best-effort 同步退化成整轮 all-or-nothing。 +5. 将 provider 能力、同步策略、用户通知分层处理。 + +## 现有同步数据结构 + +### `.user.js` + +每个脚本的源码文件。 + +命名规则: + +```text +.user.js +```` + +### `.meta.json` + +每个脚本的同步元信息。 + +旧格式大致包含: + +```ts +type SyncMeta = { + uuid: string; + origin?: string; + downloadUrl?: string; + checkUpdateUrl?: string; + isDeleted?: boolean; +}; +``` + +其中 `isDeleted: true` 表示 tombstone 删除标记。 + +### `scriptcat-sync.json` + +保存脚本启用状态、排序和状态更新时间。 + +```ts +type ScriptcatSync = { + version: string; + status: { + scripts: { + [uuid: string]: { + enable: boolean; + sort: number; + updatetime: number; + } | undefined; + }; + }; +}; +``` + +### `file_digest` + +保存在本地 chrome storage 中,用于判断云端文件是否相对上轮同步有变化。 + +现有格式: + +```ts +type FileDigestMap = { + [filename: string]: string; +}; +``` + +生产兼容要求: + +* 旧用户只有 digest,没有 version。 +* 旧用户的云端文件没有 provider token。 +* 新逻辑必须能读旧格式。 +* 新逻辑不能要求用户手动迁移。 + +## 已确认问题 + +### 1. 静默覆盖 + +旧接口中 `fs.create(path)` 默认覆盖远端文件。多设备并发时,如果设备 A 和设备 B 基于同一旧快照修改同一脚本,后写入者会覆盖先写入者,且用户无提示。 + +### 2. 失败后状态污染 + +若上传、下载、删除中某个步骤失败,但后续仍更新 `file_digest` 或 `scriptcat-sync.json`,下轮同步会误以为失败操作已经成功,导致本地与云端状态永久偏离。 + +### 3. `pullScript` / `deleteCloudScript` 错误粒度不足 + +旧实现中某些错误只记录日志,不向调用方抛出。这样 `syncOnceInternal` 无法区分成功和失败,容易把失败任务当作成功推进状态。 + +### 4. orphan `.user.js` 风险 + +远端可能出现只有 `.user.js`、没有 `.meta.json` 的半上传状态。此时不能安装、删除或覆盖该脚本,应跳过本轮并保留远端 status。 + +### 5. provider 能力不一致 + +不同后端能力差异很大: + +* WebDAV / S3 / OneDrive:通常可用 ETag / If-Match。 +* Dropbox:有 content_hash,但冲突错误需要 typed error,不应靠字符串匹配。 +* Google Drive:可读取 file id、version、md5Checksum,但 create / update 的原子 CAS 能力有限。 +* Baidu:部分操作只能 preflight,不能假装 atomic。 +* Zip:本地备份用途,通常不参与云端 CAS。 + +## 设计原则 + +### 原则 1:保持 per-file best-effort + +不能因为一个脚本失败,让其他 99 个成功脚本全部不推进。 + +正确行为: + +* 成功文件更新自己的 digest。 +* 失败文件保留旧 digest。 +* 下轮只重试失败或有变化的文件。 +* 冲突只阻塞冲突文件。 + +### 原则 2:状态推进必须和真实成功绑定 + +只有真实成功完成的文件,才能推进对应 digest。 + +不能出现: + +* 写文件失败但更新 digest。 +* 删除失败但写 tombstone digest。 +* pull 失败但认为脚本已更新。 +* syncStatus 失败但覆盖远端 status。 + +### 原则 3:filesystem 不承担业务冲突策略 + +filesystem 层应该暴露: + +* 读取文件 +* 写入文件 +* 删除文件 +* 条件写入能力 +* typed error +* provider capabilities + +同步层负责: + +* 何时 push +* 何时 pull +* 何时认为 conflict +* 如何通知用户 +* 如何推进 digest +* 如何处理 tombstone + +### 原则 4:provider token 必须 opaque + +不要把 Google Drive 的 `fileId:version` 这类 provider 内部结构塞进通用 `version` 字段。 + +建议: + +```ts +interface FileInfo { + name: string; + path: string; + size: number; + digest: string; + createtime: number; + updatetime: number; + + version?: string; + providerToken?: unknown; +} +``` + +`providerToken` 只由 provider 自己解释。 + +### 原则 5:明确 atomic 与 best-effort + +接口必须区分: + +```ts +type FileSystemCapabilities = { + atomicCompareAndSwap: boolean; + createOnly: boolean; + conditionalDelete: boolean; + nativeDigest: boolean; +}; +``` + +不能把 preflight 实现伪装成 atomic CAS。 + +## 建议实现阶段 + +## Phase 1:研究文档和测试基线 + +提交: + +```text +docs/sync-research.md +``` + +内容: + +* 当前同步机制说明 +* PR #1439 分析 +* CodFrm review 整理 +* provider 能力矩阵 +* 生产兼容原则 +* 测试计划 + +不改 runtime 代码。 + +## Phase 2:修复错误传播和 per-file digest 推进 + +目标: + +* `pullScript` 失败时抛出错误。 +* `deleteCloudScript` 失败时抛出错误。 +* `syncOnceInternal` 收集每个任务结果。 +* 成功任务返回 digest patch。 +* 失败任务不推进 digest。 +* 不因单个任务失败阻塞其他成功任务。 + +伪代码: + +```ts +const results = await Promise.allSettled(tasks); + +const digestPatch: FileDigestMap = {}; +const failures: SyncFailure[] = []; + +for (const result of results) { + if (result.status === "fulfilled") { + Object.assign(digestPatch, result.value?.digestPatch); + } else { + failures.push(classifySyncFailure(result.reason)); + } +} + +await updateFileDigest(fs, digestPatch, { + preserveFailedFiles: true, +}); +``` + +## Phase 3:保护 `scriptcat-sync.json` + +目标: + +* 写入前重新读取远端 `scriptcat-sync.json`。 +* 保留 orphan uuid 的远端 status。 +* 本轮失败文件不得覆盖远端 status。 +* syncStatus 单项失败不导致全局状态污染。 + +建议: + +```ts +const latestSyncFile = await readLatestScriptcatSync(fs); +const merged = mergeStatus({ + base: latestSyncFile, + localChanges, + skippedUuids, + failedUuids, +}); +``` + +## Phase 4:引入 capabilities 和 typed error + +新增或扩展: + +```ts +type FileSystemErrorKind = + | "conflict" + | "stale_snapshot" + | "transient" + | "unsupported" + | "fatal"; + +class FileSystemError extends Error { + kind: FileSystemErrorKind; + status?: number; + retryAfter?: number; +} +``` + +provider 需要把 HTTP 409 / 412 / 429 / 5xx 等转换成 typed error。 + +## Phase 5:条件写入 + +新增: + +```ts +type FileCreateOptions = { + modifiedDate?: number; + expectedDigest?: string; + expectedVersion?: string; + createOnly?: boolean; + providerToken?: unknown; +}; + +type FileDeleteOptions = { + expectedDigest?: string; + expectedVersion?: string; + providerToken?: unknown; +}; +``` + +调用层根据远端 snapshot 生成条件写入参数。 + +## Phase 6:通知和重试 + +目标: + +* transient 错误自动 retry。 +* retry 后仍失败才通知。 +* 通知按 provider / sync round 节流。 +* 用户提示区分: + + * 正在重试 + * 同步冲突 + * 授权失效 + * 网络失败 + * provider 不支持条件写入 + +## Provider 注意事项 + +### WebDAV + +优先使用 ETag / `If-Match`。 + +### S3 + +优先使用 ETag / conditional request。 + +### OneDrive + +检查所有 `nothen=true` 调用点,避免行为改变导致调用者收到非预期响应。 + +### Google Drive + +不要把 `fileId:version` 放进通用 `version`。 + +可用方案: + +* `version` 保持 provider 原生 version。 +* `providerToken` 保存 `{ fileId, version }`。 +* createOnly 如果无法 atomic,应标记为 best-effort。 + +### Dropbox + +不要通过错误 message string 匹配冲突。 + +应在 request 层解析 Dropbox API error,转换成 typed `FileSystemError`. + +### Baidu + +只把明确的 file-exists errno 转换成 conflict。 + +不要把所有非零 errno 都当成 createOnly conflict。 + +### Zip + +保持简单实现,不需要云端 CAS 能力。 + +## 测试矩阵 + +### 同步任务级别 + +1. 99 个 push 成功,1 个 push 失败。 +2. 99 个 pull 成功,1 个 pull 失败。 +3. 删除 10 个脚本,其中 1 个删除失败。 +4. syncStatus 单个 enableScript 失败。 +5. `scriptcat-sync.json` 写入失败。 +6. `file_digest` 更新失败。 + +### 数据兼容 + +1. 旧 `.meta.json` 无 version。 +2. 旧 `file_digest` 只有 digest。 +3. 旧 `scriptcat-sync.json` 无新增字段。 +4. 云端只有 `.user.js`。 +5. 云端只有 `.meta.json` tombstone。 +6. 云端 `.user.js` 和 `.meta.json` 更新时间不一致。 + +### 并发冲突 + +1. 两台设备同时修改同一脚本。 +2. 一台删除,另一台修改。 +3. 一台新增,另一台同时新增同 uuid。 +4. 一台更新 status,另一台更新 code。 +5. 远端文件在 list 和 write 之间变化。 + +### provider + +1. WebDAV ETag mismatch。 +2. S3 ETag mismatch。 +3. OneDrive if-match mismatch。 +4. Dropbox conflict typed error。 +5. Google Drive best-effort preflight race。 +6. Baidu errno 分类。 + +## 暂不建议立即做的事情 + +1. 不建议一次性把所有 provider 都改成复杂 CAS。 +2. 不建议把 filesystem 变成业务冲突处理层。 +3. 不建议新增独立 `tombstone_digest`,除非有明确 GC。 +4. 不建议让任何单文件失败阻塞整轮同步。 +5. 不建议没有测试就修改 Google Drive / Baidu 的删除逻辑。 + +## 最小可接受修复 + +第一轮代码修复可以只做到: + +1. `pullScript` 和 `deleteCloudScript` 失败向上抛出。 +2. `syncOnceInternal` 按文件收集结果。 +3. 成功文件推进 digest,失败文件不推进。 +4. orphan `.user.js` 跳过且保留 status。 +5. 新增测试证明不会再出现 “99 成功 + 1 失败 => 100 个都重复/污染” 的问题。 + +## 验证要求 + +每次提交前记录: + +```text +repo: +branch: +commit: +changed files: +tests run: +tests failed: +tests skipped: +known risks: +``` + +如果测试无法运行,必须写明原因。 From b4fda8601e69a3f52d2a68ff15d824f98cf2dd82 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:22:52 +0900 Subject: [PATCH 002/103] =?UTF-8?q?=F0=9F=93=84=20docs(sync):=20document?= =?UTF-8?q?=20cloud=20sync=20correctness=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 1 + docs/sync-research.md | 544 +++++++++++++++++++++--------------------- 2 files changed, 270 insertions(+), 275 deletions(-) diff --git a/docs/README.md b/docs/README.md index 653ea9e25..363cee575 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ | [`DEVELOP.md`](./DEVELOP.md) | 开发规范:命令、目录结构、编码风格、UI/主题、测试机制、i18n、提交/PR 流程。**写代码前先读。** | | [`VERIFICATION.md`](./VERIFICATION.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例)。**验证改动是否真正跑通时读。** | | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 内部原理深入:多进程模型、消息传递、服务/数据层、GM API、脚本执行、构建管线。 | +| [`sync-research.md`](./sync-research.md) | 云同步一致性研究:现有同步架构、PR #1439 讨论整理、生产兼容原则、分阶段修复计划和测试矩阵。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | ## 翻译 / Translation diff --git a/docs/sync-research.md b/docs/sync-research.md index cc4dc356f..f48a4c67b 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -2,364 +2,340 @@ ## 目标 -本分支用于研究和修复 ScriptCat 云同步在多设备、多 provider、网络异常和并发修改场景下的正确性问题。 +本分支用于研究 ScriptCat 云同步在多设备、多 provider、网络异常、并发修改和旧数据兼容场景下的正确性问题。目标不是把 PR #1439 原样搬进来,而是在 `upstream/main` / `main` 现有同步语义上做可验证、可分阶段合并的修复。 核心目标: 1. 避免云端文件被静默覆盖。 2. 避免失败同步污染本地 `file_digest` 或远端 `scriptcat-sync.json`。 -3. 保持现有生产用户云端数据兼容。 +3. 保持现有生产用户云端数据兼容,不要求手动迁移。 4. 不把 per-file best-effort 同步退化成整轮 all-or-nothing。 -5. 将 provider 能力、同步策略、用户通知分层处理。 +5. 将 provider 能力、同步策略、错误分类和用户通知分层处理。 -## 现有同步数据结构 +## 当前同步架构 -### `.user.js` +入口在 `src/app/service/service_worker/synchronize.ts` 的 `SynchronizeService`。云同步由 `buildFileSystem()` 建立 provider 文件系统,然后通过 `syncOnce()` 进入 `cloud_sync_queue` 串行队列,最终执行 `syncOnceInternal()`。安装脚本和删除脚本的消息也复用同一个队列,避免和定时同步并发写云端。 -每个脚本的源码文件。 +### `syncOnceInternal` -命名规则: +当前流程: -```text -.user.js -```` +1. `fs.list()` 读取 `ScriptCat/sync` 目录。 +2. 按文件名把 `.user.js` 和 `.meta.json` 组装成 `uuidMap`。 +3. 从本地 DAO 读取脚本列表,生成 `scriptMap`。 +4. 如果存在 `scriptcat-sync.json`,读取其中的 `status.scripts` 作为云端状态。 +5. 遍历 `uuidMap` 决定每个 uuid 的动作: + - 本地脚本存在,云端只有 `.meta.json`:如果 meta 是 tombstone,则本地删除;否则删除无效 meta 并重新 push。 + - 本地脚本存在,云端 `.user.js` digest 与 `file_digest` 一致:跳过。 + - 本地脚本更新时间更晚,或云端缺 `.meta.json`:`pushScript()`。 + - 云端更新时间更晚:`pullScript()`。 + - 本地脚本不存在但云端 `.user.js` 和 `.meta.json` 都存在:`pullScript()` 安装。 + - 云端只有 `.user.js`、没有 `.meta.json`:跳过并记录 orphan uuid,避免误删或覆盖半上传文件。 +6. 剩余本地脚本全部 `pushScript()`。 +7. `Promise.allSettled()` 等待所有任务;fulfilled 且返回 digest patch 的任务合并到 `pushedFileDigestMap`。 +8. 如果开启 `syncStatus`,用本地状态和 `scriptcat-sync.json` 的 status 时间戳合并 enable/sort,然后直接 `fs.create("scriptcat-sync.json")` 覆盖写回。 +9. `updateFileDigest(fs, pushedFileDigestMap)` 重新 list 云端,把当前云端 digest 写入本地 `file_digest`;如果刚 push 的文件未出现在 list 里,用本地 md5 兜底。 -### `.meta.json` +重要现状:当前 `syncOnceInternal()` 仍保持 task 级 `Promise.allSettled()`,不会因为单个 push/pull Promise rejected 而停止所有任务;但 `pullScript()` 和 `deleteCloudScript()` 内部会 catch 后只记录日志,导致上层无法知道真实失败。 -每个脚本的同步元信息。 +### `pushScript` -旧格式大致包含: +`pushScript(fs, script)` 当前无条件覆盖写: -```ts -type SyncMeta = { - uuid: string; - origin?: string; - downloadUrl?: string; - checkUpdateUrl?: string; - isDeleted?: boolean; -}; -``` +1. 写 `${uuid}.user.js`,`modifiedDate` 使用脚本 `updatetime || createtime || Date.now()`。 +2. 从 `scriptCodeDAO` 读取源码并写入 `.user.js`。 +3. 写 `${uuid}.meta.json`,内容包含 `uuid`、`origin`、`downloadUrl`、`checkUpdateUrl`。 +4. 返回本地计算的 md5 digest patch,供 `updateFileDigest()` 在云端 list 暂时不可见时兜底。 -其中 `isDeleted: true` 表示 tombstone 删除标记。 +风险:`fs.create()` 的 provider 实现通常是覆盖写或先查再写,当前没有 create-only、expected digest、expected version,也没有原子 CAS。 -### `scriptcat-sync.json` +### `pullScript` -保存脚本启用状态、排序和状态更新时间。 +`pullScript(fs, file, status, existingScript?)` 当前流程: -```ts -type ScriptcatSync = { - version: string; - status: { - scripts: { - [uuid: string]: { - enable: boolean; - sort: number; - updatetime: number; - } | undefined; - }; - }; -}; -``` - -### `file_digest` - -保存在本地 chrome storage 中,用于判断云端文件是否相对上轮同步有变化。 - -现有格式: - -```ts -type FileDigestMap = { - [filename: string]: string; -}; -``` +1. 读取 `.user.js` 源码。 +2. 读取 `.meta.json` 并 `JSON.parse`。 +3. `prepareScriptByCode()` 解析脚本元信息。 +4. 如果有 `scriptcat-sync.json` status,则根据 status 更新时间决定 enable/sort。 +5. `script.installScript({ upsertBy: "sync" })` 写入本地。 +6. catch 所有异常,只记录日志,不向调用方抛出。 -生产兼容要求: - -* 旧用户只有 digest,没有 version。 -* 旧用户的云端文件没有 provider token。 -* 新逻辑必须能读旧格式。 -* 新逻辑不能要求用户手动迁移。 - -## 已确认问题 - -### 1. 静默覆盖 - -旧接口中 `fs.create(path)` 默认覆盖远端文件。多设备并发时,如果设备 A 和设备 B 基于同一旧快照修改同一脚本,后写入者会覆盖先写入者,且用户无提示。 +风险:下载、解析、安装失败都会被视为任务 fulfilled,后续可能继续写 `scriptcat-sync.json` 和 `file_digest`,造成状态污染。另一方面,不能简单把所有错误都抛成整轮失败,因为坏单文件不应卡死其他脚本。 -### 2. 失败后状态污染 +### `deleteCloudScript` -若上传、下载、删除中某个步骤失败,但后续仍更新 `file_digest` 或 `scriptcat-sync.json`,下轮同步会误以为失败操作已经成功,导致本地与云端状态永久偏离。 +`deleteCloudScript(fs, uuid, syncDelete)` 当前流程: -### 3. `pullScript` / `deleteCloudScript` 错误粒度不足 +1. 删除 `${uuid}.user.js`。 +2. 如果 `syncDelete` 为 true,写 `${uuid}.meta.json` tombstone:`{ uuid, isDeleted: true }`。 +3. 如果 `syncDelete` 为 false,删除 `${uuid}.meta.json`。 +4. catch 所有异常,只记录日志,不向调用方抛出。 -旧实现中某些错误只记录日志,不向调用方抛出。这样 `syncOnceInternal` 无法区分成功和失败,容易把失败任务当作成功推进状态。 +风险:删除失败、tombstone 写失败或 meta 删除失败都会被调用方当作成功;`scriptsDelete()` 随后仍会 `updateFileDigest()`。 -### 4. orphan `.user.js` 风险 +### `updateFileDigest` -远端可能出现只有 `.user.js`、没有 `.meta.json` 的半上传状态。此时不能安装、删除或覆盖该脚本,应跳过本轮并保留远端 status。 +`file_digest` 是本地 `ChromeStorage("sync")` 中的 digest cache。当前 `updateFileDigest()` 会: -### 5. provider 能力不一致 +1. `fs.list()` 读取云端当前文件。 +2. 用云端返回的 `file.digest` 生成完整新 map。 +3. 对刚 push 但 list 缺失的文件,用 `knownFileDigestMap` 的本地 md5 兜底。 +4. `storage.set("file_digest", newFileDigestMap)` 全量替换。 -不同后端能力差异很大: +当前实现已经避免在云端 list 返回 provider 原生 digest 时用本地 md5 覆盖它,这对 WebDAV/OneDrive/S3 的 ETag 和 Dropbox content_hash 很关键。 -* WebDAV / S3 / OneDrive:通常可用 ETag / If-Match。 -* Dropbox:有 content_hash,但冲突错误需要 typed error,不应靠字符串匹配。 -* Google Drive:可读取 file id、version、md5Checksum,但 create / update 的原子 CAS 能力有限。 -* Baidu:部分操作只能 preflight,不能假装 atomic。 -* Zip:本地备份用途,通常不参与云端 CAS。 +仍需注意:全量替换适合“完整 list 成功”场景;如果某个文件操作失败但 `updateFileDigest()` 仍基于新 list 全量写入,失败文件也可能被推进。 -## 设计原则 +### `scriptInstall` -### 原则 1:保持 per-file best-effort +非 sync 来源安装脚本时: -不能因为一个脚本失败,让其他 99 个成功脚本全部不推进。 +1. 读取云同步配置。 +2. 如果启用,进入 `cloud_sync_queue`。 +3. `buildFileSystem()`。 +4. `pushScript()`。 +5. `updateFileDigest(fs, pushedFileDigestMap)`。 -正确行为: +失败只记录日志。这个路径的本地安装已经成功,云端失败可由后续定时 sync 补偿;通知需要谨慎,避免 transient 失败造成噪声。 -* 成功文件更新自己的 digest。 -* 失败文件保留旧 digest。 -* 下轮只重试失败或有变化的文件。 -* 冲突只阻塞冲突文件。 +### `scriptsDelete` -### 原则 2:状态推进必须和真实成功绑定 +非 sync 来源删除脚本时: -只有真实成功完成的文件,才能推进对应 digest。 +1. 过滤 `deleteBy === "sync"` 的事件,避免同步拉取/删除造成回灌。 +2. 如果启用云同步,进入 `cloud_sync_queue`。 +3. `buildFileSystem()`。 +4. 顺序调用 `deleteCloudScript()`。 +5. `updateFileDigest(fs)`。 -不能出现: +当前 `deleteCloudScript()` 吞错,所以循环会继续;如果未来改为抛错,必须逐条 catch,否则批量删除中第 3 条失败会阻止后续 7 条处理。 -* 写文件失败但更新 digest。 -* 删除失败但写 tombstone digest。 -* pull 失败但认为脚本已更新。 -* syncStatus 失败但覆盖远端 status。 +## 同步文件语义 -### 原则 3:filesystem 不承担业务冲突策略 - -filesystem 层应该暴露: +### `.user.js` -* 读取文件 -* 写入文件 -* 删除文件 -* 条件写入能力 -* typed error -* provider capabilities +每个脚本的源码文件,命名为 `.user.js`。当前同步判断主要看它的 `digest` 和 `updatetime`。 -同步层负责: +### `.meta.json` -* 何时 push -* 何时 pull -* 何时认为 conflict -* 如何通知用户 -* 如何推进 digest -* 如何处理 tombstone +每个脚本的同步元信息,命名为 `.meta.json`。生产旧格式大致为: -### 原则 4:provider token 必须 opaque +```ts +type SyncMeta = { + uuid: string; + origin?: string; + downloadUrl?: string; + checkUpdateUrl?: string; + isDeleted?: boolean; +}; +``` -不要把 Google Drive 的 `fileId:version` 这类 provider 内部结构塞进通用 `version` 字段。 +旧用户云端可能只有这些字段。新增字段必须 optional,读取时必须容忍缺失。 -建议: +### tombstone 删除标记 -```ts -interface FileInfo { - name: string; - path: string; - size: number; - digest: string; - createtime: number; - updatetime: number; - - version?: string; - providerToken?: unknown; -} -``` +当 `syncDelete` 开启时,删除云端脚本不是简单移除所有文件,而是保留 `.meta.json` 且写入 `isDeleted: true`。其他设备看到“只有 meta 且 isDeleted=true”时会删除本地脚本并通知用户。 -`providerToken` 只由 provider 自己解释。 +不建议立即新增独立 `tombstone_digest`。它会引入生命周期、清理时机和最终一致性下误保留的问题;除非先定义 GC 规则,否则会增加状态面。 -### 原则 5:明确 atomic 与 best-effort +### `scriptcat-sync.json` -接口必须区分: +保存脚本启用状态、排序和更新时间: ```ts -type FileSystemCapabilities = { - atomicCompareAndSwap: boolean; - createOnly: boolean; - conditionalDelete: boolean; - nativeDigest: boolean; +type ScriptcatSync = { + version: string; + status: { + scripts: { + [uuid: string]: { + enable: boolean; + sort: number; + updatetime: number; + } | undefined; + }; + }; }; ``` -不能把 preflight 实现伪装成 atomic CAS。 +当前写回是覆盖式 `fs.create("scriptcat-sync.json")`。生产兼容要求: -## 建议实现阶段 +- 旧文件可能没有未来新增字段。 +- orphan `.user.js` 对应 uuid 的远端 status 必须保留,避免本机把另一台设备半上传状态覆盖掉。 +- 一轮同步中失败的 uuid 不应导致 status 被错误清空或推进。 +- 写回前应尽量基于最新远端状态合并,避免覆盖其他设备刚写入的 status。 -## Phase 1:研究文档和测试基线 +### `file_digest` -提交: +本地 digest cache,当前格式是: -```text -docs/sync-research.md +```ts +type FileDigestMap = { + [filename: string]: string; +}; ``` -内容: - -* 当前同步机制说明 -* PR #1439 分析 -* CodFrm review 整理 -* provider 能力矩阵 -* 生产兼容原则 -* 测试计划 - -不改 runtime 代码。 +生产兼容要求: -## Phase 2:修复错误传播和 per-file digest 推进 +- 旧记录只有 digest,没有 version/provider token。 +- 新逻辑必须能读取旧格式。 +- 新增结构时应支持 string 旧值和 object 新值并存。 +- digest 只能代表“上轮已确认成功同步的远端文件状态”,不能写入失败任务的结果。 -目标: +## Provider 实现差异 -* `pullScript` 失败时抛出错误。 -* `deleteCloudScript` 失败时抛出错误。 -* `syncOnceInternal` 收集每个任务结果。 -* 成功任务返回 digest patch。 -* 失败任务不推进 digest。 -* 不因单个任务失败阻塞其他成功任务。 +当前 `FileSystem` 接口只有 `create(path, { modifiedDate })` 和 `delete(path)`,没有 capabilities、conditional write/delete 或 opaque provider token。 -伪代码: +| Provider | `list()` digest | `create()` 当前语义 | `delete()` 当前语义 | 关键差异 | +| --- | --- | --- | --- | --- | +| WebDAV | `etag` | `putFileContents` 覆盖写 | 404 幂等成功 | 天然可扩展 `If-Match` / create-only,但当前未暴露 | +| S3 | 去引号 ETag | PUT 覆盖写,可写 `x-amz-meta-createtime` | DELETE 幂等,`NoSuchKey` 成功 | 可用 conditional request;当前未传条件头 | +| OneDrive | `eTag` | simple/upload session,conflictBehavior replace | raw Response 路径,404 成功,其他错误字符串化 | 有 typed request error,但 `nothen=true` 调用点仍需审计 | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 无通用原子 CAS;path cache 和同名文件会影响正确性;`nothen=true` 错误类型化不完整 | +| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,not_found 字符串判断为成功 | rev 未暴露;冲突/不存在多靠 message string | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 抛普通 Error | 可用 md5 preflight,但不是 atomic;errno 分类必须精确 | +| Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | -```ts -const results = await Promise.allSettled(tasks); - -const digestPatch: FileDigestMap = {}; -const failures: SyncFailure[] = []; - -for (const result of results) { - if (result.status === "fulfilled") { - Object.assign(digestPatch, result.value?.digestPatch); - } else { - failures.push(classifySyncFailure(result.reason)); - } -} - -await updateFileDigest(fs, digestPatch, { - preserveFailedFiles: true, -}); -``` +`LimiterFileSystem` 当前只对白名单读类操作的 429 自动重试:`verify/open/read/openDir/list/getDirUrl`。`create/createDir/write/delete` 遇到 429 不重试。这是保守的,避免重复非幂等写;后续若要支持写重试,应结合 create-only/CAS 或 provider 幂等能力。 -## Phase 3:保护 `scriptcat-sync.json` +## 已确认问题 -目标: +1. 静默覆盖:`fs.create()` 默认覆盖,两个设备基于旧快照修改同一文件时后写覆盖先写。 +2. 失败后状态污染:push/pull/delete 失败后仍可能写 `file_digest` 或 `scriptcat-sync.json`。 +3. `pullScript()` 吞错:下载、JSON parse、userscript parse、安装失败都无法被上层区分。 +4. `deleteCloudScript()` 吞错:删除或 tombstone 写失败后调用方仍会推进 digest。 +5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 +6. `scriptcat-sync.json` 覆盖写:可能覆盖其他设备刚更新的 enable/sort。 +7. provider 能力不一致:不能用同一个 `expectedVersion` 语义假装所有 provider 都支持 atomic CAS。 +8. 错误类型不完整:部分 provider 有 `FileSystemError`,部分仍抛普通 Error 或字符串。 +9. transient 写失败无有限 retry:429/5xx 在 write/delete 上当前直接失败。 +10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 -* 写入前重新读取远端 `scriptcat-sync.json`。 -* 保留 orphan uuid 的远端 status。 -* 本轮失败文件不得覆盖远端 status。 -* syncStatus 单项失败不导致全局状态污染。 +## PR #1439 分析 -建议: +PR #1439 试图修复多设备并发写入、删除、拉取/推送失败导致的静默覆盖和状态污染。方向上,它抓到了真实问题:写入应带前置条件,失败不能推进本地 digest,provider 错误应类型化,tombstone 和 orphan 需要明确语义。 -```ts -const latestSyncFile = await readLatestScriptcatSync(fs); -const merged = mergeStatus({ - base: latestSyncFile, - localChanges, - skippedUuids, - failedUuids, -}); -``` +### cyfung1031 的改动意图 -## Phase 4:引入 capabilities 和 typed error +- 给 `FileInfo` 增加 version 类 token。 +- 给 create/delete 增加 expected digest/version 和 createOnly。 +- provider 使用 ETag/rev/version/content_hash/md5 做条件写入或 preflight。 +- `syncOnceInternal()` 在失败时避免继续推进 `scriptcat-sync.json` 和 `file_digest`。 +- 处理 orphan `.user.js`、tombstone 收敛、Google Drive 重名、Baidu errno、OneDrive/Google raw response 等具体问题。 +- 增加同步失败通知和大量 provider/sync 测试。 -新增或扩展: +### CodFrm review 核心意见 -```ts -type FileSystemErrorKind = - | "conflict" - | "stale_snapshot" - | "transient" - | "unsupported" - | "fatal"; - -class FileSystemError extends Error { - kind: FileSystemErrorKind; - status?: number; - retryAfter?: number; -} -``` +CodFrm 的核心担忧是:PR 修了“报错”,但没有保留“消化错误”的粒度。旧代码的问题是 silent data loss,新代码的风险是 all-or-nothing 事务。 -provider 需要把 HTTP 409 / 412 / 429 / 5xx 等转换成 typed error。 +需要吸收的意见: -## Phase 5:条件写入 +- filesystem 包不应承担业务冲突策略;它应暴露原子能力、条件操作和 typed error,同步层决定业务冲突处理。 +- 99 个成功 + 1 个失败时,成功文件 digest 应推进,失败文件保留旧 digest,下轮只重试失败文件。 +- transient、conflict、fatal、unsupported 应分类,不能都当作整轮失败。 +- status sync 是 best-effort,单个 enable/sort 更新失败不应让整轮 sync 卡死。 +- `pullScript()` 不应吞掉真实失败,但坏 `.meta.json` 或坏 userscript 也不应卡死整个账号。 +- `deleteCloudScript()` 抛错后,`scriptsDelete()` 必须逐条 catch,不能中断后续 uuid。 +- 安装/删除事件触发的同步通知要节流,transient 失败可让定时同步兜底。 +- `updateFileDigest()` 的 list retry / known digest 兜底不能掩盖 provider 最终一致性和 digest 格式差异。 +- Google Drive 的 `fileId:version` 不应被包装成通用 version 语义。 +- `tombstone_digest` 会带来额外生命周期和 GC 问题。 -新增: +### Copilot / reviewer 指出的问题 -```ts -type FileCreateOptions = { - modifiedDate?: number; - expectedDigest?: string; - expectedVersion?: string; - createOnly?: boolean; - providerToken?: unknown; -}; +- `FileSystem.delete()` 签名改变后必须更新所有实现,例如 zip,否则 TypeScript 接口不匹配。 +- Baidu `createOnly` 不能把所有非 0 `errno` 都判为 conflict;只能匹配明确 file-exists / duplicate-name 错误。 +- Dropbox 不能靠 message string 长期判断冲突,应在 request 层转换 typed error。 +- OneDrive / Google Drive 的 `nothen=true` raw response 调用点必须全部审计,否则 typed error 不完整。 -type FileDeleteOptions = { - expectedDigest?: string; - expectedVersion?: string; - providerToken?: unknown; -}; -``` +### 正确方向 -调用层根据远端 snapshot 生成条件写入参数。 +- 保持旧数据可读,新增字段 optional。 +- 成功文件和失败文件分开推进状态。 +- provider 层暴露能力和原子操作,不写业务策略。 +- 能 atomic 的 provider 使用原生条件写;只能 preflight 的 provider 明确标记 best-effort。 +- orphan `.user.js` without `.meta.json` 跳过并保留远端 status。 +- `scriptcat-sync.json` 写回前合并远端最新状态,避免覆盖其他设备。 -## Phase 6:通知和重试 +### 可能过度设计或破坏语义的方向 -目标: +- 一次性改所有 provider、通知、tombstone digest、导出、alarm,改动面过大。 +- 任一文件失败就跳过全部 digest 更新,导致成功文件下轮重做甚至自冲突。 +- filesystem 层承载“同步冲突业务策略”,增加包职责。 +- 将 Google Drive `fileId:version` 暴露为通用 `version`。 +- 独立 `tombstone_digest` 未定义 GC 前就落地。 +- 每个 install/delete transient 失败都通知用户。 -* transient 错误自动 retry。 -* retry 后仍失败才通知。 -* 通知按 provider / sync round 节流。 -* 用户提示区分: +## 兼容生产数据的设计原则 - * 正在重试 - * 同步冲突 - * 授权失效 - * 网络失败 - * provider 不支持条件写入 +1. 读旧格式:`.meta.json`、`scriptcat-sync.json`、`file_digest` 必须容忍缺字段。 +2. 写新格式要 optional:新增字段不能成为读取旧数据的前提。 +3. per-file best-effort:成功文件推进自己的 digest,失败文件保留旧 digest。 +4. 状态推进绑定真实成功:失败任务不得写成功 digest,不得覆盖对应 status。 +5. `scriptcat-sync.json` 合并写:保留 orphan、失败 uuid 和远端较新的 status。 +6. provider token opaque:同步层只传回 provider 给出的 token,不解析 provider 内部结构。 +7. 明确 atomic vs best-effort:preflight 只能降低风险,不能宣称 CAS。 +8. transient 有限 retry:只在幂等或有条件保护的写路径开启。 +9. 通知节流:按 sync round 聚合,不逐文件弹。 -## Provider 注意事项 +## 分阶段实现计划 -### WebDAV +### Phase 1:研究文档和测试基线 -优先使用 ETag / `If-Match`。 +只更新 `docs/sync-research.md` 和文档索引。提交前不改 runtime 代码。 -### S3 +### Phase 2:最小测试 -优先使用 ETag / conditional request。 +先写失败测试,覆盖: -### OneDrive +- 99 个文件成功、1 个文件失败时,成功文件 digest 可推进,失败文件不推进。 +- `pullScript()` 失败不能写入成功 digest。 +- `deleteCloudScript()` 失败不能推进 digest。 +- orphan `.user.js` without `.meta.json` 跳过且保留 `scriptcat-sync.json` status。 +- syncStatus 单个 enable/sort 更新失败不能卡死整轮。 -检查所有 `nothen=true` 调用点,避免行为改变导致调用者收到非预期响应。 +### Phase 3:最小实现 -### Google Drive +不引入 provider CAS,先修同步层状态污染: -不要把 `fileId:version` 放进通用 `version`。 +- 让 `pullScript()` 对真实失败向上返回失败,但坏远端单文件只阻塞该 uuid。 +- 让 `deleteCloudScript()` 返回成功/失败结果;`scriptsDelete()` 逐条 catch 并继续。 +- `syncOnceInternal()` 记录成功 uuid 和失败 uuid。 +- `updateFileDigest()` 支持基于旧 digest map + 成功 patch 的局部推进,失败文件保留旧值。 +- 有失败时 `scriptcat-sync.json` 只写安全合并结果,或跳过会污染的 uuid。 -可用方案: +### Phase 4:capabilities 和 typed error -* `version` 保持 provider 原生 version。 -* `providerToken` 保存 `{ fileId, version }`。 -* createOnly 如果无法 atomic,应标记为 best-effort。 +新增最小能力描述,而不是把冲突策略塞进 filesystem: -### Dropbox +```ts +type FileSystemCapabilities = { + supportsAtomicCompareAndSwap: boolean; + supportsCreateOnly: boolean; + supportsConditionalDelete: boolean; +}; +``` -不要通过错误 message string 匹配冲突。 +错误分类建议: -应在 request 层解析 Dropbox API error,转换成 typed `FileSystemError`. +```ts +type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; +``` -### Baidu +### Phase 5:provider 条件操作 follow-up -只把明确的 file-exists errno 转换成 conflict。 +分 provider 小步提交: -不要把所有非零 errno 都当成 createOnly conflict。 +- WebDAV / S3 / OneDrive:优先用 If-Match / ETag。 +- Dropbox:request 层 typed error,rev 作为 opaque token。 +- Google Drive:providerToken 保存 fileId/version;preflight 明确 best-effort。 +- Baidu:只把明确 file-exists errno 判 conflict;md5 preflight 明确 best-effort。 +- Zip:保持简单,不参与云端 CAS。 -### Zip +### Phase 6:重试和通知 -保持简单实现,不需要云端 CAS 能力。 +- transient 429/5xx 有限 retry/backoff。 +- install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 +- 通知包含失败数量和首个错误类型,不逐文件弹。 ## 测试矩阵 @@ -367,15 +343,16 @@ type FileDeleteOptions = { 1. 99 个 push 成功,1 个 push 失败。 2. 99 个 pull 成功,1 个 pull 失败。 -3. 删除 10 个脚本,其中 1 个删除失败。 -4. syncStatus 单个 enableScript 失败。 +3. 删除 10 个脚本,其中 1 个删除失败,后续 9 个仍处理。 +4. syncStatus 单个 `enableScript` 或 sort 更新失败。 5. `scriptcat-sync.json` 写入失败。 6. `file_digest` 更新失败。 +7. install 触发 push transient 失败,不污染 digest。 ### 数据兼容 -1. 旧 `.meta.json` 无 version。 -2. 旧 `file_digest` 只有 digest。 +1. 旧 `.meta.json` 无新增字段。 +2. 旧 `file_digest` 只有 digest string。 3. 旧 `scriptcat-sync.json` 无新增字段。 4. 云端只有 `.user.js`。 5. 云端只有 `.meta.json` tombstone。 @@ -393,28 +370,45 @@ type FileDeleteOptions = { 1. WebDAV ETag mismatch。 2. S3 ETag mismatch。 -3. OneDrive if-match mismatch。 -4. Dropbox conflict typed error。 +3. OneDrive If-Match mismatch。 +4. Dropbox typed conflict。 5. Google Drive best-effort preflight race。 6. Baidu errno 分类。 - -## 暂不建议立即做的事情 - -1. 不建议一次性把所有 provider 都改成复杂 CAS。 -2. 不建议把 filesystem 变成业务冲突处理层。 -3. 不建议新增独立 `tombstone_digest`,除非有明确 GC。 -4. 不建议让任何单文件失败阻塞整轮同步。 -5. 不建议没有测试就修改 Google Drive / Baidu 的删除逻辑。 +7. Limiter 对 read 429 重试、write/delete 429 不重复非幂等操作。 + +## 风险清单 + +1. 把单文件失败升级为整轮失败,会让大批量同步重复执行并制造自冲突。 +2. 把失败文件 digest 写成成功,会永久隐藏失败。 +3. 覆盖式写 `scriptcat-sync.json` 会丢掉其他设备状态。 +4. provider 原生 digest 与本地 md5 混用会导致误判变更。 +5. Google Drive 同名文件和 path cache 会导致读写非预期文件。 +6. Baidu/Google Drive preflight 不是 atomic,不能承诺完全消除 TOCTOU。 +7. Dropbox 字符串匹配错误脆弱,API 文案变化会破坏分类。 +8. tombstone 状态若无 GC,会让删除收敛和 status 合并越来越复杂。 +9. 写路径 retry 若没有幂等保护,可能重复创建或覆盖。 +10. 过早改所有 provider 会让 PR 过大,难以 review 和回滚。 + +## 暂不建议实现的内容 + +1. 不建议一次性把 PR #1439 全量合并。 +2. 不建议把整个同步 round 改成 all-or-nothing。 +3. 不建议让 filesystem 包承担业务冲突策略。 +4. 不建议新增独立 `tombstone_digest`,除非同时设计 GC。 +5. 不建议把 Google Drive `fileId:version` 暴露成通用 version。 +6. 不建议在没有 typed error 前按字符串大规模分类冲突。 +7. 不建议没有测试就修改 Google Drive / Baidu 删除逻辑。 ## 最小可接受修复 -第一轮代码修复可以只做到: +第一轮 runtime 修复应只做到: -1. `pullScript` 和 `deleteCloudScript` 失败向上抛出。 -2. `syncOnceInternal` 按文件收集结果。 +1. `pullScript()` 和 `deleteCloudScript()` 的失败可被上层识别。 +2. `syncOnceInternal()` 按文件收集结果。 3. 成功文件推进 digest,失败文件不推进。 4. orphan `.user.js` 跳过且保留 status。 -5. 新增测试证明不会再出现 “99 成功 + 1 失败 => 100 个都重复/污染” 的问题。 +5. syncStatus 单项失败不阻塞整轮。 +6. 新增测试证明不会再出现“99 成功 + 1 失败导致 100 个都重复/污染”的问题。 ## 验证要求 @@ -431,4 +425,4 @@ tests skipped: known risks: ``` -如果测试无法运行,必须写明原因。 +如果测试无法运行,必须写明原因,不能写“已验证”。 From db2d563fc5742550971e1373864db59bfabef794 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:27:17 +0900 Subject: [PATCH 003/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20faile?= =?UTF-8?q?d=20task=20digest=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 4fc2ba742..bc5e58ef1 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -445,6 +445,225 @@ console.log("ok");` }); }); + 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("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); + 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"]); + await expect((service as any).storage.get("file_digest")).resolves.toEqual({ + "fail.user.js": "fail-user-old", + "fail.meta.json": "fail-meta-old", + }); + }); + it("passes script modifiedDate when pushing script and meta files", async () => { const writeMock = vi.fn().mockResolvedValue(undefined); const createMock = vi.fn().mockResolvedValue({ write: writeMock }); From 85c32dde968b68694f6cc1c845a98647ea1aaf03 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:27:59 +0900 Subject: [PATCH 004/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20preserve=20?= =?UTF-8?q?failed=20task=20digests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 80 +++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 371f19178..7f15866c8 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -73,6 +73,11 @@ type FileDigestMap = { [key: string]: string; }; +type SyncTask = { + promise: Promise; + preserveDigestFiles: string[]; +}; + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; function getScriptModifiedDate(script: PushScriptParam): number { @@ -404,20 +409,27 @@ export class SynchronizeService { } // 对比脚本列表和文件列表,进行同步 - const result: Promise[] = []; + const result: SyncTask[] = []; const updateScript: Map = new Map(); // 记录被跳过的孤儿云端脚本(仅 .user.js 无 .meta.json) // 避免本机回写 scriptcat-sync.json 时丢失对应 uuid 的云端 status const skippedOrphanUuids = new Set(); // 需要是同步操作,后续上传剩下的脚本 // 最后使用 Promise.allSettled 进行等待 + const addSyncTask = (uuid: string, promise: Promise, files?: string[]) => { + result.push({ + promise, + preserveDigestFiles: files || [`${uuid}.user.js`, `${uuid}.meta.json`], + }); + }; uuidMap.forEach((file, uuid) => { 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!); @@ -437,7 +449,8 @@ export class SynchronizeService { await fs.delete(file.meta!.name); return await this.pushScript(fs, script); } - })() + })(), + [file.meta!.name, `${uuid}.user.js`] ); return; } @@ -450,11 +463,11 @@ export class SynchronizeService { if (updatetime > file.script!.updatetime || !file.meta) { // 如果脚本更新时间大于文件更新时间 // 或者不存在.meta文件,则上传文件 - result.push(this.pushScript(fs, script)); + addSyncTask(uuid, this.pushScript(fs, script)); } else { // 如果脚本更新时间小于文件更新时间,则更新脚本 updateScript.set(uuid, true); - result.push(this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script)); + addSyncTask(uuid, this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script)); } return; } @@ -470,23 +483,29 @@ 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])); } }); // 上传剩下的脚本 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(); + syncResults.forEach((ret, index) => { if (ret.status === "fulfilled" && ret.value) { Object.assign(pushedFileDigestMap, ret.value); + } else if (ret.status === "rejected") { + result[index].preserveDigestFiles.forEach((name) => preserveDigestFiles.add(name)); + this.logger.warn("sync task failed", Logger.E(ret.reason), { + files: result[index].preserveDigestFiles, + }); } }); // 同步状态 - if (syncConfig.syncStatus) { + if (syncConfig.syncStatus && preserveDigestFiles.size === 0) { const scriptlist = await this.scriptDAO.all(); await Promise.allSettled( scriptlist.map(async (script) => { @@ -546,28 +565,48 @@ export class SynchronizeService { 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"); + } else if (syncConfig.syncStatus) { + this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { + failedFiles: [...preserveDigestFiles], + }); } // 重新获取文件列表,保存文件摘要 this.logger.info("update file digest"); - await this.updateFileDigest(fs, pushedFileDigestMap); + await this.updateFileDigest(fs, pushedFileDigestMap, preserveDigestFiles); this.logger.info("sync complete"); return; } - async updateFileDigest(fs: FileSystem, knownFileDigestMap: FileDigestMap = {}) { + async updateFileDigest( + fs: FileSystem, + knownFileDigestMap: FileDigestMap = {}, + preserveFileNames = new Set() + ) { + 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); return; } @@ -601,6 +640,7 @@ export class SynchronizeService { logger.info("delete success"); } catch (e) { logger.error("delete file error", Logger.E(e)); + throw e; } return; } @@ -681,6 +721,7 @@ export class SynchronizeService { logger.info("pull script success"); } catch (e) { logger.error("pull script error", Logger.E(e)); + throw e; } } @@ -753,10 +794,19 @@ export class SynchronizeService { if (config.enable) { stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { const fs = await this.buildFileSystem(config); + const preserveDigestFiles = new Set(); for (const { uuid } of items) { - await this.deleteCloudScript(fs, uuid, config.syncDelete); + try { + await this.deleteCloudScript(fs, uuid, config.syncDelete); + } catch (e) { + preserveDigestFiles.add(`${uuid}.user.js`); + preserveDigestFiles.add(`${uuid}.meta.json`); + this.logger.warn("delete cloud script item failed", Logger.E(e), { + uuid, + }); + } } - await this.updateFileDigest(fs); + await this.updateFileDigest(fs, {}, preserveDigestFiles); }).catch((e) => { this.logger.error("delete cloud script error", Logger.E(e)); }); From a6042cd8482a8f0aded6feec86e01841a7ec9630 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:33:19 +0900 Subject: [PATCH 005/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20statu?= =?UTF-8?q?s=20merge=20before=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index bc5e58ef1..3b35b023c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -187,6 +187,185 @@ describe("SynchronizeService", () => { expect(written.status.scripts.orphan).toEqual(orphanStatus); }); + 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 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", + }); + + 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: "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, + }, + { + 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("waits for installScript during pullScript", async () => { let releaseInstall!: () => void; const installGate = new Promise((resolve) => { From 7af330ac390e9a1b3857e781854c753cee62446c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:33:56 +0900 Subject: [PATCH 006/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20merge=20lat?= =?UTF-8?q?est=20status=20before=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 7f15866c8..d62cde61d 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -560,6 +560,14 @@ export class SynchronizeService { 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 }); @@ -577,6 +585,40 @@ export class SynchronizeService { return; } + 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) { + merged[uuid] = latest || candidate; + continue; + } + if (!latest || candidate.updatetime >= latest.updatetime) { + merged[uuid] = candidate; + } + } + return merged; + } + async updateFileDigest( fs: FileSystem, knownFileDigestMap: FileDigestMap = {}, From 2cc67e4aa3cac82934f72b31632eebe71e320739 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:38:40 +0900 Subject: [PATCH 007/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20statu?= =?UTF-8?q?s=20sync=20best=20effort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 3b35b023c..96fc2ecb4 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -366,6 +366,92 @@ describe("SynchronizeService", () => { expect(written.status.scripts["status-uuid"]).toEqual(localStatus); }); + 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, + updatetime: 1, + }, + { + 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("waits for installScript during pullScript", async () => { let releaseInstall!: () => void; const installGate = new Promise((resolve) => { From 83867028df997aad76a3baf6ed98cc352519964b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:39:45 +0900 Subject: [PATCH 008/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20legac?= =?UTF-8?q?y=20sync=20status=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 96fc2ecb4..ad822c228 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -187,6 +187,45 @@ describe("SynchronizeService", () => { expect(written.status.scripts.orphan).toEqual(orphanStatus); }); + it("兼容缺少 status.scripts 的旧版 scriptcat-sync.json", async () => { + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + 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, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + 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 initialStatus = { enable: false, sort: 7, updatetime: 200 }; const latestStatus = { enable: true, sort: 9, updatetime: 300 }; From 0d14a0d1272161806df70d93d416a41ee9dbc7c2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:41:38 +0900 Subject: [PATCH 009/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20tolerate=20?= =?UTF-8?q?legacy=20sync=20status=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index d62cde61d..cd95c20db 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -404,8 +404,10 @@ export class SynchronizeService { let cloudStatus: ScriptcatSync["status"]["scripts"] = {}; if (file) { // 如果有,则读取文件内容 - const cloudScriptCatSync = JSON.parse(await fs.open(file).then((f) => f.read("string"))) as ScriptcatSync; - cloudStatus = cloudScriptCatSync.status.scripts; + const cloudScriptCatSync = JSON.parse( + await fs.open(file).then((f) => f.read("string")) + ) as Partial; + cloudStatus = cloudScriptCatSync.status?.scripts || {}; } // 对比脚本列表和文件列表,进行同步 From 0da9e0ef32bd4d14dc4e6b0cfedb47b674f2c135 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:43:01 +0900 Subject: [PATCH 010/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20statu?= =?UTF-8?q?s=20write=20failure=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index ad822c228..edaed259a 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -837,6 +837,69 @@ console.log("ok");` }); }); + 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 From 5e5f39bd5357fe4a33d40aa2374a907ed0624c6e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:44:52 +0900 Subject: [PATCH 011/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20isolate=20s?= =?UTF-8?q?tatus=20file=20write=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 120 +++++++++--------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index cd95c20db..00e034673 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -508,73 +508,77 @@ export class SynchronizeService { }); // 同步状态 if (syncConfig.syncStatus && preserveDigestFiles.size === 0) { - 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; - return; - } - // 判断时间 - // 如果云端状态的更新时间小于本地状态的更新时间,则更新云端状态 - if (status.updatetime < updatetime) { + try { + 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, }; - 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 + ); } - }); - 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"); + } catch (e) { + this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); } - // 保存脚本猫同步状态 - 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"); } else if (syncConfig.syncStatus) { this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { failedFiles: [...preserveDigestFiles], From 5d3fd072ea2404a10431df40e3610254b65fce7c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:47:23 +0900 Subject: [PATCH 012/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20corru?= =?UTF-8?q?pt=20status=20file=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index edaed259a..b213cb4c4 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -226,6 +226,84 @@ describe("SynchronizeService", () => { 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: "sync-digest", + createtime: 1, + updatetime: 1, + }, + ]) + .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, + }, + ]), + 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", + }); + + await service.syncOnce(syncConfig, fs); + + 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("写回 scriptcat-sync.json 前重新读取远端状态,避免覆盖其他设备更新", async () => { const initialStatus = { enable: false, sort: 7, updatetime: 200 }; const latestStatus = { enable: true, sort: 9, updatetime: 300 }; From b6788e3fb10c27d4b14b5d8170f7e375e8cbbb04 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:48:54 +0900 Subject: [PATCH 013/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20isolate=20u?= =?UTF-8?q?nreadable=20status=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 00e034673..7695a2de2 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -402,12 +402,18 @@ 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 Partial; - 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)); + } } // 对比脚本列表和文件列表,进行同步 @@ -507,7 +513,7 @@ export class SynchronizeService { } }); // 同步状态 - if (syncConfig.syncStatus && preserveDigestFiles.size === 0) { + if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && canWriteScriptcatSync) { try { const scriptlist = await this.scriptDAO.all(); await Promise.allSettled( @@ -579,6 +585,8 @@ export class SynchronizeService { } catch (e) { this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); } + } else if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && !canWriteScriptcatSync) { + this.logger.warn("skip scriptcat-sync.json write because cloud status could not be read"); } else if (syncConfig.syncStatus) { this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { failedFiles: [...preserveDigestFiles], From 841ab6302c2735edf5619948b40396b56692c22f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:50:50 +0900 Subject: [PATCH 014/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20typed=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 91 ++++++++++++++++++++- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 6bf549d41..72e21ee97 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,88 @@ 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 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 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("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 +97,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); From 0322251611726004ca1ffeb97616f643c9d8c6e4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:53:08 +0900 Subject: [PATCH 015/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20use=20typed?= =?UTF-8?q?=20dropbox=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.ts | 69 ++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 46c264322..1dd218143 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -1,12 +1,59 @@ 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; + }; +}; + +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?.[".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; + const conflict = !notFound && (status === 409 || summary?.includes("path/conflict") === true); + const rateLimit = status === 429; + 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 || status >= 500, + raw: parsed, + }); } export default class DropboxFileSystem implements FileSystem { @@ -69,7 +116,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 +154,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,18 +169,18 @@ 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; }); @@ -154,7 +201,7 @@ export default class DropboxFileSystem implements FileSystem { }), }); } catch (e: any) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return; } throw e; @@ -182,7 +229,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 +294,7 @@ export default class DropboxFileSystem implements FileSystem { }); return true; } catch (e) { - if (isDropboxPathNotFound(e)) { + if (isNotFoundError(e)) { return false; } throw e; From caf4c3b6872a6d8612adbecb229c4ee53c266c40 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:57:26 +0900 Subject: [PATCH 016/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20baidu?= =?UTF-8?q?=20errno=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/baidu/baidu.test.ts | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index 033aec0fa..bfc352988 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -52,4 +52,38 @@ 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, + }); + }); }); From d0a89d54ab9cfb6f6c11efd65ebd20ee09091d34 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:00:30 +0900 Subject: [PATCH 017/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20classify=20ba?= =?UTF-8?q?idu=20errno=20precisely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/baidu/baidu.ts | 9 +++++---- packages/filesystem/baidu/error.ts | 29 +++++++++++++++++++++++++++++ packages/filesystem/baidu/rw.ts | 7 ++++--- 3 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 packages/filesystem/baidu/error.ts diff --git a/packages/filesystem/baidu/baidu.ts b/packages/filesystem/baidu/baidu.ts index 9184de2f0..144d83481 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); } } @@ -73,7 +74,7 @@ export default class BaiduFileSystem implements FileSystem { .then((data2) => data2.json()) .then((data2) => { if (data2.errno === 111 || data2.errno === -6) { - throw new Error(JSON.stringify(data2)); + throw createBaiduFileSystemError(data2); } return data2; }); @@ -95,7 +96,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 +127,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..769c2ba14 --- /dev/null +++ b/packages/filesystem/baidu/error.ts @@ -0,0 +1,29 @@ +import { FileSystemError } from "../error"; + +export type BaiduErrorResponse = { + errno?: 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 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; + const notFound = data.errno === -9; + + return new FileSystemError({ + provider: "baidu", + message, + code, + conflict, + auth, + notFound, + raw: data, + }); +} diff --git a/packages/filesystem/baidu/rw.ts b/packages/filesystem/baidu/rw.ts index df2e54519..acec44e19 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; @@ -80,7 +81,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 +103,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } // 创建文件 urlencoded = new URLSearchParams(); @@ -121,7 +122,7 @@ export class BaiduFileWriter implements FileWriter { } ); if (data.errno) { - throw new Error(JSON.stringify(data)); + throw createBaiduFileSystemError(data); } } } From fa29bb3ff672af7247d9a5315ee2eba13b411b32 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:22:38 +0900 Subject: [PATCH 018/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20raw=20r?= =?UTF-8?q?esponse=20typed=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../googledrive/googledrive.test.ts | 51 +++++++++++++++++++ packages/filesystem/onedrive/onedrive.test.ts | 47 +++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 320f2cb55..1bf0635e2 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -49,6 +49,57 @@ 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("读取文件遇到 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("ensureDirExists should create missing nested directories and return final id", async () => { const fs = new GoogleDriveFileSystem("/", "token"); const findSpy = vi.spyOn(fs, "findFolderByName").mockResolvedValue(null); diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index a7400cad1..7af87d17e 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -95,6 +95,53 @@ 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("create should normalize double slashes in paths", async () => { const fs = new OneDriveFileSystem("/ScriptCat//sync", "token"); From 6a65acb8e07568d0c97952c26e53de5f104f7a55 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:24:23 +0900 Subject: [PATCH 019/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20raw=20?= =?UTF-8?q?provider=20response=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/googledrive/googledrive.ts | 4 ++-- packages/filesystem/googledrive/rw.ts | 2 +- packages/filesystem/onedrive/onedrive.ts | 4 ++-- packages/filesystem/onedrive/rw.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index b5934f20e..b0fb91921 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -147,7 +147,7 @@ export default class GoogleDriveFileSystem implements FileSystem { }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -232,7 +232,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..fef4d367d 100644 --- a/packages/filesystem/googledrive/rw.ts +++ b/packages/filesystem/googledrive/rw.ts @@ -28,7 +28,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/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 3a2bef2b0..60b3725bb 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -114,7 +114,7 @@ export default class OneDriveFileSystem implements FileSystem { }); } - private async createResponseError(resp: Response): Promise { + async createResponseError(resp: Response): Promise { const text = await resp.text(); let raw; try { @@ -194,7 +194,7 @@ export default class OneDriveFileSystem implements FileSystem { 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..8a0dc0fa8 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": From 071364442b9137c1aa397bfb119fc6d4ce386f10 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:30:37 +0900 Subject: [PATCH 020/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20typed?= =?UTF-8?q?=20transient=20retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.test.ts | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) 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(); From cd6f2d42e17b3cef5262e0a22b9ce9ca67090471 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:32:51 +0900 Subject: [PATCH 021/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20retry=20typed?= =?UTF-8?q?=20transient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 5dfb54b81..b2fbd5e10 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,5 +1,6 @@ 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"]); @@ -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_429_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"); } } From 355e0a6b808781081bd54c05f2bd74afbc94a2a5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:36:32 +0900 Subject: [PATCH 022/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20delet?= =?UTF-8?q?e=20notification=20throttling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index b213cb4c4..6af6e3ecb 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -713,6 +713,74 @@ console.log("ok");` 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) => { From b1ad94fe3ea9374b588269f9185e51bafc02d7d5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:37:56 +0900 Subject: [PATCH 023/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20throttle=20?= =?UTF-8?q?delete=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 7695a2de2..d3c6afc30 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -422,6 +422,7 @@ export class SynchronizeService { // 记录被跳过的孤儿云端脚本(仅 .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[]) => { @@ -446,12 +447,15 @@ export class SynchronizeService { if (metaObj.isDeleted) { // 删除脚本 await this.script.deleteScript(script.uuid, "sync"); - InfoNotification( - i18n.t("notification.script_sync_delete"), - i18n.t("notification.script_sync_delete_desc", { - scriptName: i18nName(script), - }) - ); + if (!hasNotifiedSyncDelete) { + hasNotifiedSyncDelete = true; + InfoNotification( + i18n.t("notification.script_sync_delete"), + i18n.t("notification.script_sync_delete_desc", { + scriptName: i18nName(script), + }) + ); + } } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); From b10703501ea5c95ef41ab75348efc762e2a6ae68 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:42:39 +0900 Subject: [PATCH 024/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20filesys?= =?UTF-8?q?tem=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/filesystem.test.ts | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/filesystem/filesystem.test.ts diff --git a/packages/filesystem/filesystem.test.ts b/packages/filesystem/filesystem.test.ts new file mode 100644 index 000000000..e367586fc --- /dev/null +++ b/packages/filesystem/filesystem.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import type FileSystem from "./filesystem"; +import * as filesystemModule from "./filesystem"; +import LimiterFileSystem from "./limiter"; + +const getFileSystemCapabilities = (filesystemModule as Record).getFileSystemCapabilities as ( + fs: FileSystem +) => unknown; + +function createFs(overrides: Partial = {}): FileSystem { + return { + verify: vi.fn(async () => {}), + open: vi.fn(async () => ({ read: vi.fn(async () => "") })), + openDir: vi.fn(async () => createFs()), + create: vi.fn(async () => ({ write: vi.fn(async () => {}) })), + createDir: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + list: vi.fn(async () => []), + getDirUrl: vi.fn(async () => ""), + ...overrides, + }; +} + +describe("FileSystem capabilities", () => { + it("未声明能力时应当默认不支持原子同步能力", () => { + const fs = createFs(); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + + it("应当合并 provider 显式声明的能力", () => { + const fs = createFs({ + capabilities: { + supportsCreateOnly: true, + }, + } as Partial); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: true, + supportsConditionalDelete: false, + }); + }); + + it("LimiterFileSystem 应当透传底层 provider 能力", () => { + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsConditionalDelete: true, + }, + } as Partial); + const limiter = new LimiterFileSystem(fs); + + expect(getFileSystemCapabilities(limiter)).toEqual({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: false, + supportsConditionalDelete: true, + }); + }); +}); From abbd80de3cdcd3d5939977c8da6a4c6f69a0d910 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:45:34 +0900 Subject: [PATCH 025/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20expose=20file?= =?UTF-8?q?system=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/filesystem.test.ts | 10 +++------- packages/filesystem/filesystem.ts | 20 ++++++++++++++++++++ packages/filesystem/limiter.ts | 5 +++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/filesystem/filesystem.test.ts b/packages/filesystem/filesystem.test.ts index e367586fc..7bef88c5e 100644 --- a/packages/filesystem/filesystem.test.ts +++ b/packages/filesystem/filesystem.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type FileSystem from "./filesystem"; -import * as filesystemModule from "./filesystem"; +import { getFileSystemCapabilities } from "./filesystem"; import LimiterFileSystem from "./limiter"; -const getFileSystemCapabilities = (filesystemModule as Record).getFileSystemCapabilities as ( - fs: FileSystem -) => unknown; - function createFs(overrides: Partial = {}): FileSystem { return { verify: vi.fn(async () => {}), @@ -37,7 +33,7 @@ describe("FileSystem capabilities", () => { capabilities: { supportsCreateOnly: true, }, - } as Partial); + }); expect(getFileSystemCapabilities(fs)).toEqual({ supportsAtomicCompareAndSwap: false, @@ -52,7 +48,7 @@ describe("FileSystem capabilities", () => { supportsAtomicCompareAndSwap: true, supportsConditionalDelete: true, }, - } as Partial); + }); const limiter = new LimiterFileSystem(fs); expect(getFileSystemCapabilities(limiter)).toEqual({ diff --git a/packages/filesystem/filesystem.ts b/packages/filesystem/filesystem.ts index be40308f0..a96947f87 100644 --- a/packages/filesystem/filesystem.ts +++ b/packages/filesystem/filesystem.ts @@ -31,8 +31,28 @@ export type FileCreateOptions = { modifiedDate?: number; }; +export type FileSystemCapabilities = { + supportsAtomicCompareAndSwap: boolean; + supportsCreateOnly: boolean; + supportsConditionalDelete: boolean; +}; + +export const DEFAULT_FILE_SYSTEM_CAPABILITIES: FileSystemCapabilities = { + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, +}; + +export function getFileSystemCapabilities(fs: FileSystem): FileSystemCapabilities { + return { + ...DEFAULT_FILE_SYSTEM_CAPABILITIES, + ...fs.capabilities, + }; +} + // 文件读取 export default interface FileSystem { + readonly capabilities?: Partial; // 授权验证 verify(): Promise; // 打开文件 diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index b2fbd5e10..1108b896d 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,5 +1,6 @@ import type FileSystem from "./filesystem"; import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; +import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); @@ -97,6 +98,10 @@ export default class LimiterFileSystem implements FileSystem { this.limiter = limiter || new RateLimiter(); } + get capabilities(): FileSystemCapabilities { + return getFileSystemCapabilities(this.fs); + } + verify(): Promise { return this.limiter.execute(() => this.fs.verify(), "verify"); } From b26a2c55418c860242bdbc4e25f775eb02010772 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:48:09 +0900 Subject: [PATCH 026/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20raw=20read=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 72e21ee97..513b12611 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -79,6 +79,30 @@ describe("DropboxFileSystem", () => { }); }); + 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( From 90ba0623ded3ffeec56646a6d684bf01ebee89d2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:49:54 +0900 Subject: [PATCH 027/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20dropbo?= =?UTF-8?q?x=20raw=20read=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.ts | 4 ++++ packages/filesystem/dropbox/rw.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 1dd218143..f36ae2e44 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -186,6 +186,10 @@ export default class DropboxFileSystem implements FileSystem { }); } + async createResponseError(resp: Response): Promise { + return toDropboxFileSystemError(resp.status, await resp.text()); + } + async delete(path: string): Promise { const fullPath = joinPath(this.path, path); 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) { From aa007acca9da5cc58fc006c0d009d508d9b3f969 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:04:20 +0900 Subject: [PATCH 028/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20s3=20tr?= =?UTF-8?q?ansient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/s3.test.ts | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 8e52a51c7..7d624e5e7 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -155,6 +155,29 @@ 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, + }); + }); }); // ---- create ---- @@ -400,6 +423,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 ---- From feaf735403ca98fc5af6cc5b6a2e61a4ce21930d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:06:43 +0900 Subject: [PATCH 029/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20s3=20t?= =?UTF-8?q?ransient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/error.ts | 23 +++++++++++++++++++++++ packages/filesystem/s3/rw.ts | 25 +++++++++++++++++-------- packages/filesystem/s3/s3.ts | 5 +++-- 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 packages/filesystem/s3/error.ts diff --git a/packages/filesystem/s3/error.ts b/packages/filesystem/s3/error.ts new file mode 100644 index 000000000..78cbbb67b --- /dev/null +++ b/packages/filesystem/s3/error.ts @@ -0,0 +1,23 @@ +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"; + + 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 || error.statusCode === 412 || error.code === "PreconditionFailed", + rateLimit, + retryable: rateLimit || error.statusCode >= 500, + raw: error, + }); +} diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index 378fb18ad..dcfdc9c6b 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 { 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(); } } @@ -69,9 +74,13 @@ export class S3FileWriter implements FileWriter { 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.ts b/packages/filesystem/s3/s3.ts index 41ce89e6a..42559555d 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 解析 ---- @@ -190,7 +191,7 @@ export default class S3FileSystem implements FileSystem { if (error instanceof S3Error && error.code === "NoSuchKey") { return; } - throw error; + throw createS3FileSystemError(error); } } @@ -254,7 +255,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); } } From 089d8edb2b395d3c08a3a4ca0c39378060450cf1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:15:21 +0900 Subject: [PATCH 030/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20webdav?= =?UTF-8?q?=20transient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/webdav.test.ts | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 4d91a5edb..d5227e1d5 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -221,6 +221,29 @@ 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, + }); + }); + }); + describe("list", () => { it("应当列出文件并过滤目录", async () => { (mockClient.getDirectoryContents as ReturnType).mockResolvedValue([ @@ -270,6 +293,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", () => { From 3f239555426f5be560d0d35e0712f48a95d071b9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:17:41 +0900 Subject: [PATCH 031/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20webdav?= =?UTF-8?q?=20transient=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/error.ts | 30 ++++++++++++++++++++++++++ packages/filesystem/webdav/rw.ts | 32 ++++++++++++++++++---------- packages/filesystem/webdav/webdav.ts | 7 +++--- 3 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 packages/filesystem/webdav/error.ts diff --git a/packages/filesystem/webdav/error.ts b/packages/filesystem/webdav/error.ts new file mode 100644 index 000000000..4c7a9a86c --- /dev/null +++ b/packages/filesystem/webdav/error.ts @@ -0,0 +1,30 @@ +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; + + return new FileSystemError({ + provider: "webdav", + message: webdavError.message || `WebDAV request failed with status ${status}`, + status, + auth: status === 401 || status === 403, + notFound: status === 404, + conflict: status === 409 || status === 412, + rateLimit, + retryable: rateLimit || status >= 500, + raw: error, + }); +} diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index c13afe2f4..15f77f755 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,5 +1,6 @@ import type { WebDAVClient } from "webdav"; import type { FileReader, FileWriter } from "../filesystem"; +import { createWebDAVFileSystemError } from "./error"; export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -12,17 +13,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,7 +44,12 @@ 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"); } diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 871097537..33cedada9 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) // 全局单次注册 @@ -87,7 +88,7 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 405 || e.message?.includes("405")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } @@ -98,7 +99,7 @@ export default class WebDAVFileSystem implements FileSystem { if (e.response?.status === 404 || e.message?.includes("404")) { return; } - throw e; + throw createWebDAVFileSystemError(e); } } @@ -108,7 +109,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) { From dfd23f74f2278cc23f5e623e7816c3170b324d31 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:22:54 +0900 Subject: [PATCH 032/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20s3=20co?= =?UTF-8?q?nditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/s3.test.ts | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 7d624e5e7..4fb3e6d94 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -47,6 +47,14 @@ describe("S3FileSystem", () => { fs = new S3FileSystem("test-bucket", mockClient); }); + it("应当声明支持原子条件写入和条件删除能力", () => { + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }); + }); + // ---- verify ---- describe("verify", () => { it("应当成功验证 bucket", async () => { @@ -226,6 +234,42 @@ describe("S3FileSystem", () => { ); }); + it("条件写入应当将 expectedDigest 转成 If-Match", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); + + const writer = await (fs as any).create("output.txt", { expectedDigest: "abc123" }); + await writer.write("hello world"); + + expect(mockClient.request).toHaveBeenCalledWith( + "PUT", + "test-bucket", + "output.txt", + expect.objectContaining({ + headers: expect.objectContaining({ + "if-match": '"abc123"', + }), + }) + ); + }); + + it("createOnly 写入应当使用 If-None-Match", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); + + const writer = await (fs as any).create("new.txt", { createOnly: true }); + await writer.write("hello world"); + + expect(mockClient.request).toHaveBeenCalledWith( + "PUT", + "test-bucket", + "new.txt", + expect.objectContaining({ + headers: expect.objectContaining({ + "if-none-match": "*", + }), + }) + ); + }); + it("normalizes double slashes in object keys", async () => { const subFs = new S3FileSystem("test-bucket", mockClient, "/ScriptCat//sync"); @@ -275,6 +319,23 @@ describe("S3FileSystem", () => { expect(mockClient.request).toHaveBeenCalledWith("DELETE", "test-bucket", "ScriptCat/sync/dir/file.user.js"); }); + + it("条件删除应当将 expectedDigest 转成 If-Match", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true, status: 204 })); + + await (fs as any).delete("test.txt", { expectedDigest: "abc123" }); + + expect(mockClient.request).toHaveBeenCalledWith( + "DELETE", + "test-bucket", + "test.txt", + expect.objectContaining({ + headers: { + "if-match": '"abc123"', + }, + }) + ); + }); }); // ---- list ---- From 889d08dec4f92a6f5cf41932a05f7802a4facb41 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:26:40 +0900 Subject: [PATCH 033/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20support=20s3?= =?UTF-8?q?=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/filesystem.ts | 8 +++++++- packages/filesystem/limiter.ts | 6 +++--- packages/filesystem/s3/rw.ts | 21 ++++++++++++++++++--- packages/filesystem/s3/s3.ts | 25 +++++++++++++++++++++---- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/filesystem/filesystem.ts b/packages/filesystem/filesystem.ts index a96947f87..5fa2c6259 100644 --- a/packages/filesystem/filesystem.ts +++ b/packages/filesystem/filesystem.ts @@ -29,6 +29,12 @@ export type FileReadWriter = FileReader & FileWriter; export type FileCreateOptions = { modifiedDate?: number; + expectedDigest?: string; + createOnly?: boolean; +}; + +export type FileDeleteOptions = { + expectedDigest?: string; }; export type FileSystemCapabilities = { @@ -64,7 +70,7 @@ export default interface FileSystem { // 创建目录 createDir(dir: string, opts?: FileCreateOptions): Promise; // 删除文件 - delete(path: string): Promise; + delete(path: string, opts?: FileDeleteOptions): Promise; // 文件列表 list(): Promise; // getDirUrl 获取目录的url diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 1108b896d..b9cff1ad0 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,5 +1,5 @@ import type FileSystem from "./filesystem"; -import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; +import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; @@ -135,8 +135,8 @@ export default class LimiterFileSystem implements FileSystem { return this.limiter.execute(() => this.fs.createDir(dir, opts), "createDir"); } - delete(path: string): Promise { - return this.limiter.execute(() => this.fs.delete(path), "delete"); + delete(path: string, opts?: FileDeleteOptions): Promise { + return this.limiter.execute(() => this.fs.delete(path, opts), "delete"); } list(): Promise { diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index dcfdc9c6b..87b11749d 100644 --- a/packages/filesystem/s3/rw.ts +++ b/packages/filesystem/s3/rw.ts @@ -1,7 +1,11 @@ import type { S3Client } from "./client"; -import type { FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { createS3FileSystemError } from "./error"; +function quoteETag(digest: string): string { + return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; +} + /** * S3 文件读取器 * 通过 GET 请求下载 S3 对象 @@ -51,11 +55,17 @@ export class S3FileWriter implements FileWriter { modifiedDate?: number; - constructor(client: S3Client, bucket: string, key: string, modifiedDate?: number) { + expectedDigest?: string; + + createOnly?: boolean; + + 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; + this.expectedDigest = opts?.expectedDigest; + this.createOnly = opts?.createOnly; } /** @@ -73,6 +83,11 @@ export class S3FileWriter implements FileWriter { // 历史兼容:S3 侧使用 createtime 元数据保存文件时间,实际来源是 FileCreateOptions.modifiedDate。 headers["x-amz-meta-createtime"] = new Date(this.modifiedDate).toISOString(); } + if (this.expectedDigest) { + headers["if-match"] = quoteETag(this.expectedDigest); + } else if (this.createOnly) { + headers["if-none-match"] = "*"; + } try { await this.client.request("PUT", this.bucket, this.key, { diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index 42559555d..fe9f76459 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -2,7 +2,7 @@ import { XMLParser } from "fast-xml-parser"; import { S3Client, S3Error } from "./client"; import type { S3ClientConfig } from "./client"; import type FileSystem from "../filesystem"; -import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import { S3FileReader, S3FileWriter } from "./rw"; import { WarpTokenError } from "../error"; @@ -26,6 +26,10 @@ const xmlParser = new XMLParser({ isArray: (name) => name === "Contents", }); +function quoteETag(digest: string): string { + return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; +} + /** 从 ListObjectsV2 XML 响应中解析对象列表 */ function parseListObjectsV2(xml: string): ListObjectsV2Result { const parsed = xmlParser.parse(xml); @@ -53,6 +57,12 @@ function parseListObjectsV2(xml: string): ListObjectsV2Result { * 使用原生 fetch + AWS Signature V4 签名,不依赖 @aws-sdk/client-s3 */ export default class S3FileSystem implements FileSystem { + readonly capabilities = { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }; + client: S3Client; bucket: string; @@ -168,7 +178,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); } /** @@ -183,9 +193,16 @@ export default class S3FileSystem implements FileSystem { * 此操作幂等——删除不存在的文件也会成功 * @param path 相对于当前 basePath 的文件路径 */ - async delete(path: string): Promise { + async delete(path: string, opts?: FileDeleteOptions): Promise { try { - await this.client.request("DELETE", this.bucket, joinPath(this.basePath, path).substring(1)); + const key = joinPath(this.basePath, path).substring(1); + if (opts?.expectedDigest) { + await this.client.request("DELETE", this.bucket, key, { + headers: { "if-match": quoteETag(opts.expectedDigest) }, + }); + return; + } + await this.client.request("DELETE", this.bucket, key); } catch (error: any) { // S3 delete 是幂等的,key 不存在时也视为成功 if (error instanceof S3Error && error.code === "NoSuchKey") { From 5ba7db86ada33af156a1f2ade5f4ee9976937c18 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:20:04 +0900 Subject: [PATCH 034/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20webdav?= =?UTF-8?q?=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/webdav.test.ts | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index d5227e1d5..b0447ef7e 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -32,6 +32,16 @@ describe("WebDAVFileSystem", () => { mockClient = createMockClient(); }); + it("应当声明支持原子条件写入和条件删除能力", () => { + const fs = createTestFS(mockClient); + + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }); + }); + describe("initWebDAVPatch", () => { it("应当通过 getPatcher 注册 fetch patch,设置 credentials 为 omit", () => { // fromCredentials 内部调用 initWebDAVPatch,验证 patcher 已注册 fetch @@ -206,6 +216,18 @@ describe("WebDAVFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); + + it("条件删除应当将 expectedDigest 转成 If-Match", async () => { + const fs = createTestFS(mockClient); + + await (fs as any).delete("test.txt", { expectedDigest: '"abc123"' }); + + expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt", { + headers: { + "If-Match": '"abc123"', + }, + }); + }); }); describe("create", () => { @@ -219,6 +241,38 @@ describe("WebDAVFileSystem", () => { expect((writer as any).path).toBe("/ScriptCat/sync/dir/file.user.js"); }); + + it("条件写入应当将 expectedDigest 转成 If-Match", async () => { + const fs = createTestFS(mockClient); + const writer = await (fs as any).create("test.txt", { expectedDigest: '"abc123"' }); + + await writer.write("content"); + + expect(mockClient.putFileContents).toHaveBeenCalledWith( + "/test.txt", + "content", + expect.objectContaining({ + headers: { + "If-Match": '"abc123"', + }, + }) + ); + }); + + it("createOnly 写入应当使用 WebDAV overwrite=false", async () => { + const fs = createTestFS(mockClient); + const writer = await (fs as any).create("new.txt", { createOnly: true }); + + await writer.write("content"); + + expect(mockClient.putFileContents).toHaveBeenCalledWith( + "/new.txt", + "content", + expect.objectContaining({ + overwrite: false, + }) + ); + }); }); describe("open", () => { From 8778b1923036f7aa8eb0e5524289c7e621af7d00 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:21:57 +0900 Subject: [PATCH 035/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20support=20web?= =?UTF-8?q?dav=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/rw.ts | 32 +++++++++++++++++++++++++--- packages/filesystem/webdav/webdav.ts | 23 +++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index 15f77f755..da717761d 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,7 +1,9 @@ import type { WebDAVClient } from "webdav"; -import type { FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { createWebDAVFileSystemError } from "./error"; +const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); + export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -37,16 +39,24 @@ export class WebDAVFileWriter implements FileWriter { path: string; - constructor(client: WebDAVClient, path: string) { + opts?: FileCreateOptions; + + constructor(client: WebDAVClient, path: string, opts?: FileCreateOptions) { this.client = client; this.path = path; + this.opts = opts; } async write(content: string | Blob): Promise { const data = content instanceof Blob ? await content.arrayBuffer() : content; let resp: boolean; try { - resp = await this.client.putFileContents(this.path, data); + const opts = this.buildWriteOptions(); + if (opts) { + resp = await this.client.putFileContents(this.path, data, opts); + } else { + resp = await this.client.putFileContents(this.path, data); + } } catch (error) { throw createWebDAVFileSystemError(error); } @@ -54,4 +64,20 @@ export class WebDAVFileWriter implements FileWriter { throw new Error("write error"); } } + + private buildWriteOptions() { + if (this.opts?.expectedDigest) { + return { + headers: { + "If-Match": quoteETag(this.opts.expectedDigest), + }, + }; + } + if (this.opts?.createOnly) { + return { + overwrite: false, + }; + } + return undefined; + } } diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 33cedada9..db8440954 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -6,6 +6,7 @@ import { joinPath } from "../utils"; import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; import { WarpTokenError } from "../error"; import { createWebDAVFileSystemError } from "./error"; +import type { FileDeleteOptions } from "../filesystem"; // 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) // 全局单次注册 @@ -24,7 +25,15 @@ const initWebDAVPatch = () => { }); }; +const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); + export default class WebDAVFileSystem implements FileSystem { + readonly capabilities = { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }; + client: WebDAVClient; url: string; @@ -76,8 +85,8 @@ export default class WebDAVFileSystem implements FileSystem { return WebDAVFileSystem.fromSameClient(this, joinPath(this.basePath, path)); } - async create(path: string, _opts?: FileCreateOptions): Promise { - return new WebDAVFileWriter(this.client, joinPath(this.basePath, path)); + async create(path: string, opts?: FileCreateOptions): Promise { + return new WebDAVFileWriter(this.client, joinPath(this.basePath, path), opts); } async createDir(path: string, _opts?: FileCreateOptions): Promise { @@ -92,8 +101,16 @@ export default class WebDAVFileSystem implements FileSystem { } } - async delete(path: string): Promise { + async delete(path: string, opts?: FileDeleteOptions): Promise { try { + if (opts?.expectedDigest) { + await this.client.deleteFile(joinPath(this.basePath, path), { + headers: { + "If-Match": quoteETag(opts.expectedDigest), + }, + }); + return; + } await this.client.deleteFile(joinPath(this.basePath, path)); } catch (e: any) { if (e.response?.status === 404 || e.message?.includes("404")) { From a52998c68fba3b4456581f79d0521bbc4dff4dc4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:25:33 +0900 Subject: [PATCH 036/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20onedriv?= =?UTF-8?q?e=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/onedrive/onedrive.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index 7af87d17e..c5f4a06fa 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -28,6 +28,16 @@ describe("OneDriveFileSystem", () => { vi.stubGlobal("fetch", originalFetch); }); + it("应当声明支持原子条件写入和条件删除能力", () => { + const fs = new OneDriveFileSystem("/", "token"); + + expect((fs as any).capabilities).toMatchObject({ + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }); + }); + it("request should return retry result after token refresh", async () => { await localStorageDAO.saveValue("netdisk:token:onedrive", { accessToken: "expired-token", @@ -163,6 +173,24 @@ describe("OneDriveFileSystem", () => { ); }); + it("条件删除应当将 expectedDigest 转成 If-Match", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const request = vi.spyOn(fs, "request").mockResolvedValue({ status: 204 }); + + await (fs as any).delete("test.txt", { expectedDigest: "abc123" }); + + expect(request).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/drive/special/approot:/test.txt", + { + method: "DELETE", + headers: expect.objectContaining({ + "If-Match": "abc123", + }), + }, + true + ); + }); + it("createDir should create nested directories from root", async () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); @@ -399,4 +427,34 @@ describe("OneDriveFileSystem", () => { const headers = (requestSpy.mock.calls[1][1] as RequestInit).headers as Headers; expect(headers.get("Content-Range")).toBe("bytes 0-2/3"); }); + + it("条件写入应当将 expectedDigest 传给 upload session 的 If-Match", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi + .spyOn(fs, "request") + .mockResolvedValueOnce({ uploadUrl: "https://upload.example/session" }) + .mockResolvedValueOnce({}); + + const writer = await (fs as any).create("not-empty.txt", { expectedDigest: "abc123" }); + await writer.write("abc"); + + const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers; + expect(headers.get("If-Match")).toBe("abc123"); + }); + + it("createOnly 写入应当将 If-None-Match 传给 upload session 并使用 fail 语义", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi + .spyOn(fs, "request") + .mockResolvedValueOnce({ uploadUrl: "https://upload.example/session" }) + .mockResolvedValueOnce({}); + + const writer = await (fs as any).create("not-empty.txt", { createOnly: true }); + await writer.write("abc"); + + const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers; + const body = JSON.parse((requestSpy.mock.calls[0][1] as RequestInit).body as string); + expect(headers.get("If-None-Match")).toBe("*"); + expect(body.item["@microsoft.graph.conflictBehavior"]).toBe("fail"); + }); }); From 41c07ed3c50d6a0c918c669cec2ed942560dd075 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:28:06 +0900 Subject: [PATCH 037/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20support=20one?= =?UTF-8?q?drive=20conditional=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/onedrive/onedrive.ts | 28 ++++++++++++------ packages/filesystem/onedrive/rw.ts | 36 ++++++++++++++++++++---- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 60b3725bb..e7529e1fc 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -1,11 +1,17 @@ import { AuthVerify } from "../auth"; import { FileSystemError } from "../error"; -import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem"; import type FileSystem from "../filesystem"; import { joinPath } from "../utils"; import { OneDriveFileReader, OneDriveFileWriter } from "./rw"; export default class OneDriveFileSystem implements FileSystem { + readonly capabilities = { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }; + accessToken?: string; path: string; @@ -32,8 +38,8 @@ export default class OneDriveFileSystem implements FileSystem { return new OneDriveFileSystem(joinPath(this.path, path), this.accessToken); } - async create(path: string, _opts?: FileCreateOptions): Promise { - return new OneDriveFileWriter(this, joinPath(this.path, path)); + async create(path: string, opts?: FileCreateOptions): Promise { + return new OneDriveFileWriter(this, joinPath(this.path, path), opts); } async createDir(dir: string, _opts?: FileCreateOptions): Promise { @@ -127,7 +133,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}`); } @@ -182,12 +188,18 @@ export default class OneDriveFileSystem implements FileSystem { }); } - async delete(path: string): Promise { + async delete(path: string, opts?: FileDeleteOptions): Promise { + const config: RequestInit = { + method: "DELETE", + }; + if (opts?.expectedDigest) { + config.headers = { + "If-Match": opts.expectedDigest, + }; + } 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) { diff --git a/packages/filesystem/onedrive/rw.ts b/packages/filesystem/onedrive/rw.ts index 8a0dc0fa8..7c6158b38 100644 --- a/packages/filesystem/onedrive/rw.ts +++ b/packages/filesystem/onedrive/rw.ts @@ -1,5 +1,5 @@ import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto"; -import type { FileInfo, FileReader, FileWriter } from "../filesystem"; +import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import type OneDriveFileSystem from "./onedrive"; @@ -37,9 +37,12 @@ export class OneDriveFileWriter implements FileWriter { fs: OneDriveFileSystem; - constructor(fs: OneDriveFileSystem, path: string) { + opts?: FileCreateOptions; + + constructor(fs: OneDriveFileSystem, path: string, opts?: FileCreateOptions) { this.fs = fs; this.path = path; + this.opts = opts; } size(content: string | Blob) { @@ -60,21 +63,30 @@ 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, - }); + }; + const writeHeaders = this.buildConditionalHeaders(); + if (writeHeaders) { + config.headers = writeHeaders; + } + return this.fs.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/content`, config); } let myHeaders = new Headers(); myHeaders.append("Content-Type", "application/json"); + const conditionHeaders = this.buildConditionalHeaders(); + if (conditionHeaders) { + conditionHeaders.forEach((value, key) => myHeaders.set(key, value)); + } const uploadUrl = await this.fs .request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/createUploadSession`, { method: "POST", headers: myHeaders, body: JSON.stringify({ item: { - "@microsoft.graph.conflictBehavior": "replace", + "@microsoft.graph.conflictBehavior": this.opts?.createOnly ? "fail" : "replace", // description: "description", // fileSystemInfo: { // "@odata.type": "microsoft.graph.fileSystemInfo", @@ -97,4 +109,18 @@ export class OneDriveFileWriter implements FileWriter { headers: myHeaders, }); } + + private buildConditionalHeaders(): Headers | undefined { + if (this.opts?.expectedDigest) { + const headers = new Headers(); + headers.set("If-Match", this.opts.expectedDigest); + return headers; + } + if (this.opts?.createOnly) { + const headers = new Headers(); + headers.set("If-None-Match", "*"); + return headers; + } + return undefined; + } } From a3ec5e1866790ac389fb84a9103c2c541f2fbf3f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:49:11 +0900 Subject: [PATCH 038/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20condi?= =?UTF-8?q?tional=20push=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 6af6e3ecb..348815602 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -895,6 +895,176 @@ console.log("ok");` }); }); + it("push 已有云端文件时应当用旧 digest 作为 expectedDigest", 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({ + capabilities: { + supportsAtomicCompareAndSwap: true, + }, + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "push-uuid.user.js", + path: "push-uuid.user.js", + size: 1, + digest: "cloud-user-new", + createtime: 1, + updatetime: 1, + }, + { + name: "push-uuid.meta.json", + path: "push-uuid.meta.json", + size: 1, + digest: "cloud-meta-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", + "push-uuid.meta.json": "old-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(fs.create).toHaveBeenCalledWith( + "push-uuid.user.js", + expect.objectContaining({ expectedDigest: "old-user-digest" }) + ); + expect(fs.create).toHaveBeenCalledWith( + "push-uuid.meta.json", + expect.objectContaining({ expectedDigest: "old-meta-digest" }) + ); + }); + + it("push 云端缺失文件时应当使用 createOnly,避免覆盖并发新增", async () => { + const script = { + uuid: "new-uuid", + name: "new", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsCreateOnly: true, + }, + 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: "// code" }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(fs.create).toHaveBeenCalledWith("new-uuid.user.js", expect.objectContaining({ createOnly: true })); + expect(fs.create).toHaveBeenCalledWith("new-uuid.meta.json", expect.objectContaining({ createOnly: true })); + }); + + 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", + expect.not.objectContaining({ + expectedDigest: expect.anything(), + createOnly: expect.anything(), + }) + ); + }); + it("部分 push 失败时只推进成功文件 digest 并保留失败文件旧 digest", async () => { const fs = createFs({ list: vi From 4d4355fbe31bf47d4e19a33533826a4ea9b3931c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 05:51:25 +0900 Subject: [PATCH 039/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20use=20provi?= =?UTF-8?q?der=20conditional=20writes=20when=20safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index d3c6afc30..ab981ccf3 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -10,8 +10,9 @@ import { } from "@App/app/repo/scripts"; import BackupExport from "@App/pkg/backup/export"; import type { BackupData, ResourceBackup, ScriptBackupData, ScriptOptions, ValueStorage } from "@App/pkg/backup/struct"; -import type { FileInfo } from "@Packages/filesystem/filesystem"; +import type { FileCreateOptions, FileInfo } from "@Packages/filesystem/filesystem"; import type FileSystem from "@Packages/filesystem/filesystem"; +import { getFileSystemCapabilities } 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"; @@ -73,6 +74,12 @@ type FileDigestMap = { [key: string]: string; }; +type PushScriptOptions = { + fileDigestMap?: FileDigestMap; + scriptFile?: FileInfo; + metaFile?: FileInfo; +}; + type SyncTask = { promise: Promise; preserveDigestFiles: string[]; @@ -459,7 +466,7 @@ export class SynchronizeService { } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); - return await this.pushScript(fs, script); + return await this.pushScript(fs, script, { fileDigestMap }); } })(), [file.meta!.name, `${uuid}.user.js`] @@ -475,7 +482,10 @@ export class SynchronizeService { if (updatetime > file.script!.updatetime || !file.meta) { // 如果脚本更新时间大于文件更新时间 // 或者不存在.meta文件,则上传文件 - addSyncTask(uuid, this.pushScript(fs, script)); + addSyncTask( + uuid, + this.pushScript(fs, script, { fileDigestMap, scriptFile: file.script, metaFile: file.meta }) + ); } else { // 如果脚本更新时间小于文件更新时间,则更新脚本 updateScript.set(uuid, true); @@ -500,7 +510,7 @@ export class SynchronizeService { }); // 上传剩下的脚本 scriptMap.forEach((script) => { - addSyncTask(script.uuid, this.pushScript(fs, script)); + addSyncTask(script.uuid, this.pushScript(fs, script, { fileDigestMap })); }); // 忽略错误 const syncResults = await Promise.allSettled(result.map((item) => item.promise)); @@ -706,7 +716,7 @@ export class SynchronizeService { } // 上传脚本 - async pushScript(fs: FileSystem, script: PushScriptParam): Promise { + async pushScript(fs: FileSystem, script: PushScriptParam, opts: PushScriptOptions = {}): Promise { const filename = `${script.uuid}.user.js`; const metaFilename = `${script.uuid}.meta.json`; const logger = this.logger.with({ @@ -716,12 +726,18 @@ export class SynchronizeService { }); try { const modifiedDate = getScriptModifiedDate(script); - const w = await fs.create(filename, { modifiedDate }); + const w = await fs.create( + filename, + this.buildPushCreateOptions(fs, filename, modifiedDate, opts.scriptFile, opts.fileDigestMap) + ); // 获取脚本代码 const code = await this.scriptCodeDAO.get(script.uuid); const scriptCode = code!.code; await w.write(scriptCode); - const meta = await fs.create(metaFilename, { modifiedDate }); + const meta = await fs.create( + metaFilename, + this.buildPushCreateOptions(fs, metaFilename, modifiedDate, opts.metaFile, opts.fileDigestMap) + ); const metaJson = JSON.stringify({ uuid: script.uuid, origin: script.origin, @@ -740,6 +756,28 @@ export class SynchronizeService { } } + private buildPushCreateOptions( + fs: FileSystem, + filename: string, + modifiedDate: number, + existingFile?: FileInfo, + fileDigestMap?: FileDigestMap + ): FileCreateOptions { + const capabilities = getFileSystemCapabilities(fs); + const createOptions: FileCreateOptions = { modifiedDate }; + if (!existingFile) { + if (capabilities.supportsCreateOnly) { + createOptions.createOnly = true; + } + return createOptions; + } + const expectedDigest = fileDigestMap?.[filename]; + if (expectedDigest && capabilities.supportsAtomicCompareAndSwap) { + createOptions.expectedDigest = expectedDigest; + } + return createOptions; + } + async pullScript(fs: FileSystem, file: SyncFiles, status: ScriptcatSyncStatus | undefined, existingScript?: Script) { const logger = this.logger.with({ uuid: existingScript?.uuid || "", From 03250cdf0e0707ee03266ce3198d2ed595b0e1f4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:15:52 +0900 Subject: [PATCH 040/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20condi?= =?UTF-8?q?tional=20cloud=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 348815602..e683f6a4c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1347,6 +1347,68 @@ console.log("ok");` }); }); + it("deleteCloudScript 支持条件删除时应当传 expectedDigest", async () => { + const fs = createFs({ + capabilities: { + supportsConditionalDelete: true, + }, + }); + 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).toHaveBeenCalledWith( + "delete-uuid.user.js", + expect.objectContaining({ expectedDigest: "old-user-digest" }) + ); + expect(fs.delete).toHaveBeenCalledWith( + "delete-uuid.meta.json", + expect.objectContaining({ expectedDigest: "old-meta-digest" }) + ); + }); + + it("deleteCloudScript 无条件删除能力时不应传 expectedDigest", 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 }); From 1aa8b4baeb805507b17306124397ffd7d2ae2946 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:17:35 +0900 Subject: [PATCH 041/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20use=20provi?= =?UTF-8?q?der=20conditional=20delete=20when=20safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index ab981ccf3..2f363d8a3 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -684,16 +684,18 @@ export class SynchronizeService { // 删除云端脚本数据 async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) { const filename = `${uuid}.user.js`; + const metaFilename = `${uuid}.meta.json`; + const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const logger = this.logger.with({ uuid: uuid, file: filename, }); try { - await fs.delete(filename); + await this.deleteCloudFile(fs, filename, fileDigestMap); 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, @@ -705,7 +707,7 @@ export class SynchronizeService { ); } else { // 直接删除所有相关文件 - await fs.delete(`${uuid}.meta.json`); + await this.deleteCloudFile(fs, metaFilename, fileDigestMap); } logger.info("delete success"); } catch (e) { @@ -715,6 +717,26 @@ export class SynchronizeService { return; } + private buildDeleteOptions(fs: FileSystem, filename: string, fileDigestMap: FileDigestMap) { + if (!getFileSystemCapabilities(fs).supportsConditionalDelete) { + return undefined; + } + const expectedDigest = fileDigestMap[filename]; + if (!expectedDigest) { + return undefined; + } + return { expectedDigest }; + } + + private async deleteCloudFile(fs: FileSystem, filename: string, fileDigestMap: FileDigestMap) { + const opts = this.buildDeleteOptions(fs, filename, fileDigestMap); + if (opts) { + await fs.delete(filename, opts); + return; + } + await fs.delete(filename); + } + // 上传脚本 async pushScript(fs: FileSystem, script: PushScriptParam, opts: PushScriptOptions = {}): Promise { const filename = `${script.uuid}.user.js`; From 5aad551dd7b71ab23d1a698d266a1dc56f8cc911 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:20:04 +0900 Subject: [PATCH 042/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20webdav?= =?UTF-8?q?=20create-only=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/webdav.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index b0447ef7e..5ac6fadd5 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -2,7 +2,7 @@ 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 { isConflictError, WarpTokenError } from "../error"; /** 创建 mock WebDAVClient */ function createMockClient(overrides?: Partial): WebDAVClient { @@ -273,6 +273,14 @@ describe("WebDAVFileSystem", () => { }) ); }); + + it("createOnly 冲突返回 false 时应当抛出 typed conflict 错误", async () => { + (mockClient.putFileContents as ReturnType).mockResolvedValue(false); + const fs = createTestFS(mockClient); + const writer = await (fs as any).create("exists.txt", { createOnly: true }); + + await expect(writer.write("content")).rejects.toSatisfy(isConflictError); + }); }); describe("open", () => { From e59ea1f91d0d1bda46ce739bf9a871284dc9b7fd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:22:22 +0900 Subject: [PATCH 043/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20type=20webdav?= =?UTF-8?q?=20create-only=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/webdav/rw.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index da717761d..c3c2d43f9 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,5 +1,6 @@ import type { WebDAVClient } from "webdav"; import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import { FileSystemError } from "../error"; import { createWebDAVFileSystemError } from "./error"; const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); @@ -61,6 +62,14 @@ export class WebDAVFileWriter implements FileWriter { throw createWebDAVFileSystemError(error); } if (!resp) { + if (this.opts?.createOnly) { + throw new FileSystemError({ + provider: "webdav", + message: "WebDAV create-only write conflict", + status: 412, + conflict: true, + }); + } throw new Error("write error"); } } From c63bfb471f38317df0610114262c1ddafd94f14b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:26:12 +0900 Subject: [PATCH 044/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20statu?= =?UTF-8?q?s=20write=20after=20file=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index e683f6a4c..59e5f426a 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -569,6 +569,122 @@ describe("SynchronizeService", () => { }); }); + 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, + updatetime: 1, + }, + { + 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) => { From 1a2ab69c280d7974bb0fff6112e84cbac8fec661 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:26:45 +0900 Subject: [PATCH 045/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20preserve=20?= =?UTF-8?q?failed=20script=20status=20while=20syncing=20others?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 2f363d8a3..58f4b9d8b 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -81,6 +81,7 @@ type PushScriptOptions = { }; type SyncTask = { + uuid: string; promise: Promise; preserveDigestFiles: string[]; }; @@ -434,6 +435,7 @@ export class SynchronizeService { // 最后使用 Promise.allSettled 进行等待 const addSyncTask = (uuid: string, promise: Promise, files?: string[]) => { result.push({ + uuid, promise, preserveDigestFiles: files || [`${uuid}.user.js`, `${uuid}.meta.json`], }); @@ -516,10 +518,12 @@ export class SynchronizeService { const syncResults = await Promise.allSettled(result.map((item) => item.promise)); const pushedFileDigestMap: FileDigestMap = {}; const preserveDigestFiles = new Set(); + const failedSyncUuids = new Set(); 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); result[index].preserveDigestFiles.forEach((name) => preserveDigestFiles.add(name)); this.logger.warn("sync task failed", Logger.E(ret.reason), { files: result[index].preserveDigestFiles, @@ -527,11 +531,15 @@ export class SynchronizeService { } }); // 同步状态 - if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && canWriteScriptcatSync) { + 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; + } // 判断云端状态是否与本地状态一致 const status = cloudStatus[script.uuid]; const updatetime = script.updatetime || script.createtime; @@ -599,12 +607,8 @@ export class SynchronizeService { } catch (e) { this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e)); } - } else if (syncConfig.syncStatus && preserveDigestFiles.size === 0 && !canWriteScriptcatSync) { + } else if (syncConfig.syncStatus && !canWriteScriptcatSync) { this.logger.warn("skip scriptcat-sync.json write because cloud status could not be read"); - } else if (syncConfig.syncStatus) { - this.logger.warn("skip scriptcat-sync.json write because some sync tasks failed", { - failedFiles: [...preserveDigestFiles], - }); } // 重新获取文件列表,保存文件摘要 this.logger.info("update file digest"); From 4a8380cf8d19d8cac52dbc248ccb79fec36e1196 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:30:42 +0900 Subject: [PATCH 046/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20s3=20co?= =?UTF-8?q?nditional=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/s3/s3.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 4fb3e6d94..8244bdbc1 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isConflictError } from "../error"; import S3FileSystem from "./s3"; import { S3Client, S3Error } from "./client"; import type { FileInfo } from "../filesystem"; @@ -270,6 +271,16 @@ describe("S3FileSystem", () => { ); }); + it("createOnly 写入遇到 412 时应当抛出 typed conflict 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("PreconditionFailed", "At least one precondition failed", 412) + ); + + const writer = await (fs as any).create("new.txt", { createOnly: true }); + + await expect(writer.write("hello world")).rejects.toSatisfy(isConflictError); + }); + it("normalizes double slashes in object keys", async () => { const subFs = new S3FileSystem("test-bucket", mockClient, "/ScriptCat//sync"); @@ -336,6 +347,14 @@ describe("S3FileSystem", () => { }) ); }); + + it("条件删除遇到 412 时应当抛出 typed conflict 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("PreconditionFailed", "At least one precondition failed", 412) + ); + + await expect((fs as any).delete("test.txt", { expectedDigest: "abc123" })).rejects.toSatisfy(isConflictError); + }); }); // ---- list ---- From 74c2ce209dea5ae335c605c8cc61537111bc527a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:32:56 +0900 Subject: [PATCH 047/103] docs(sync): update implemented provider status --- docs/sync-research.md | 45 +++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index f48a4c67b..34e25581e 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -183,16 +183,25 @@ type FileDigestMap = { ## Provider 实现差异 -当前 `FileSystem` 接口只有 `create(path, { modifiedDate })` 和 `delete(path)`,没有 capabilities、conditional write/delete 或 opaque provider token。 +当前 `FileSystem` 接口已经有最小 capabilities,并允许 `create()` / `delete()` 接收条件参数: + +- `supportsAtomicCompareAndSwap` +- `supportsCreateOnly` +- `supportsConditionalDelete` +- `FileCreateOptions.expectedDigest` +- `FileCreateOptions.createOnly` +- `FileDeleteOptions.expectedDigest` + +同步层只在 provider 显式声明能力时传条件参数;未声明能力的 provider 继续保持旧覆盖语义,避免把 best-effort preflight 伪装成 atomic CAS。 | Provider | `list()` digest | `create()` 当前语义 | `delete()` 当前语义 | 关键差异 | | --- | --- | --- | --- | --- | -| WebDAV | `etag` | `putFileContents` 覆盖写 | 404 幂等成功 | 天然可扩展 `If-Match` / create-only,但当前未暴露 | -| S3 | 去引号 ETag | PUT 覆盖写,可写 `x-amz-meta-createtime` | DELETE 幂等,`NoSuchKey` 成功 | 可用 conditional request;当前未传条件头 | -| OneDrive | `eTag` | simple/upload session,conflictBehavior replace | raw Response 路径,404 成功,其他错误字符串化 | 有 typed request error,但 `nothen=true` 调用点仍需审计 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 无通用原子 CAS;path cache 和同名文件会影响正确性;`nothen=true` 错误类型化不完整 | -| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,not_found 字符串判断为成功 | rev 未暴露;冲突/不存在多靠 message string | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 抛普通 Error | 可用 md5 preflight,但不是 atomic;errno 分类必须精确 | +| WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | +| S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | +| OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 未声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | +| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 未声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单读类操作的 429 自动重试:`verify/open/read/openDir/list/getDirUrl`。`create/createDir/write/delete` 遇到 429 不重试。这是保守的,避免重复非幂等写;后续若要支持写重试,应结合 create-only/CAS 或 provider 幂等能力。 @@ -200,13 +209,13 @@ type FileDigestMap = { ## 已确认问题 1. 静默覆盖:`fs.create()` 默认覆盖,两个设备基于旧快照修改同一文件时后写覆盖先写。 -2. 失败后状态污染:push/pull/delete 失败后仍可能写 `file_digest` 或 `scriptcat-sync.json`。 -3. `pullScript()` 吞错:下载、JSON parse、userscript parse、安装失败都无法被上层区分。 -4. `deleteCloudScript()` 吞错:删除或 tombstone 写失败后调用方仍会推进 digest。 +2. 失败后状态污染:本分支已修复主要路径,push/pull/delete 失败文件保留旧 `file_digest`;失败 uuid 的 status 回写会保留云端状态。 +3. `pullScript()` 吞错:本分支已改为让真实失败向上表现为单文件任务失败。 +4. `deleteCloudScript()` 吞错:本分支已改为删除或 tombstone 写失败时向上抛错,批量删除调用方逐条 catch。 5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 -6. `scriptcat-sync.json` 覆盖写:可能覆盖其他设备刚更新的 enable/sort。 -7. provider 能力不一致:不能用同一个 `expectedVersion` 语义假装所有 provider 都支持 atomic CAS。 -8. 错误类型不完整:部分 provider 有 `FileSystemError`,部分仍抛普通 Error 或字符串。 +6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 +7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 +8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;普通网络错误仍可能保持原始 Error。 9. transient 写失败无有限 retry:429/5xx 在 write/delete 上当前直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 @@ -321,14 +330,16 @@ type FileSystemCapabilities = { type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; ``` +本分支当前已实现 capabilities、provider typed error 的关键路径,以及 read 类 429/5xx 有限 retry;还没有把 `SyncErrorKind` 作为同步层显式类型落地。 + ### Phase 5:provider 条件操作 follow-up 分 provider 小步提交: -- WebDAV / S3 / OneDrive:优先用 If-Match / ETag。 -- Dropbox:request 层 typed error,rev 作为 opaque token。 -- Google Drive:providerToken 保存 fileId/version;preflight 明确 best-effort。 -- Baidu:只把明确 file-exists errno 判 conflict;md5 preflight 明确 best-effort。 +- WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 +- Dropbox:request 层 typed error 已落地;rev 作为 opaque token 尚未实现。 +- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。 +- Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 From ce0400c5470dd34d3b4b68b46033ad70cdd10d16 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:38:06 +0900 Subject: [PATCH 048/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20confl?= =?UTF-8?q?ict=20error=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 59e5f426a..2b08fccd2 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"; @@ -1269,6 +1270,94 @@ console.log("ok");` }); }); + 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: "s3", + message: "Precondition failed", + status: 412, + 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: "Precondition failed" }), + 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("scriptcat-sync.json 写回失败时仍推进已成功脚本 digest", async () => { const fs = createFs({ list: vi From 2b4ab24cdd09aedda406ee47e4ff39f988a201b8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:38:40 +0900 Subject: [PATCH 049/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20classify=20?= =?UTF-8?q?per-file=20sync=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 58f4b9d8b..576d219a5 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -15,7 +15,7 @@ import type FileSystem from "@Packages/filesystem/filesystem"; import { getFileSystemCapabilities } 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, 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"; @@ -86,6 +86,8 @@ type SyncTask = { preserveDigestFiles: string[]; }; +type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; function getScriptModifiedDate(script: PushScriptParam): number { @@ -526,6 +528,7 @@ export class SynchronizeService { failedSyncUuids.add(result[index].uuid); result[index].preserveDigestFiles.forEach((name) => preserveDigestFiles.add(name)); this.logger.warn("sync task failed", Logger.E(ret.reason), { + errorKind: this.classifySyncError(ret.reason), files: result[index].preserveDigestFiles, }); } @@ -617,6 +620,31 @@ export class SynchronizeService { return; } + private classifySyncError(error: unknown): SyncErrorKind { + if (error instanceof FileSystemError) { + if (error.conflict) { + return "conflict"; + } + if (error.rateLimit || error.retryable) { + return "transient"; + } + if (error.notFound) { + return "stale_snapshot"; + } + if (error.auth) { + return "fatal"; + } + return "fatal"; + } + if (isWarpTokenError(error)) { + return "fatal"; + } + if (error instanceof Error && /\bunsupported\b/i.test(error.message)) { + return "unsupported"; + } + 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 || {}; From 683f4b85a65b975bc376c4a75310acebaf8f1725 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:43:27 +0900 Subject: [PATCH 050/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20conditi?= =?UTF-8?q?onal=20write=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.test.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts index a79f953a7..04c8e0fbe 100644 --- a/packages/filesystem/limiter.test.ts +++ b/packages/filesystem/limiter.test.ts @@ -244,6 +244,73 @@ describe("LimiterFileSystem", () => { expect(write).toHaveBeenCalledTimes(1); }); + it("受 expectedDigest 保护的 writer.write 遇到 typed retryable 错误时应当重试", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const write = vi.fn(async () => {}); + const retryableError = new FileSystemError({ + provider: "s3", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.create).mockResolvedValueOnce({ + write, + }); + write.mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); + const limiter = new LimiterFileSystem(fs); + const writer = await limiter.create(file.path, { expectedDigest: "digest" }); + + const promise = writer.write("content"); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeUndefined(); + expect(write).toHaveBeenCalledTimes(2); + }); + + it("受 createOnly 保护的 writer.write 遇到 typed retryable 错误时应当重试", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const write = vi.fn(async () => {}); + const retryableError = new FileSystemError({ + provider: "webdav", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.create).mockResolvedValueOnce({ + write, + }); + write.mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); + const limiter = new LimiterFileSystem(fs); + const writer = await limiter.create(file.path, { createOnly: true }); + + const promise = writer.write("content"); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeUndefined(); + expect(write).toHaveBeenCalledTimes(2); + }); + + it("受 expectedDigest 保护的 delete 遇到 typed retryable 错误时应当重试", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const retryableError = new FileSystemError({ + provider: "onedrive", + message: "Service unavailable", + status: 503, + retryable: true, + }); + vi.mocked(fs.delete).mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.delete(file.path, { expectedDigest: "digest" }); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeUndefined(); + expect(fs.delete).toHaveBeenCalledTimes(2); + }); + it("should retry reader.read on 429", async () => { vi.useFakeTimers(); const fs = createFs(); From 3eae169e5935d12ff79bf64a273e480b22386c30 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:44:01 +0900 Subject: [PATCH 051/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20retry=20prote?= =?UTF-8?q?cted=20conditional=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index b9cff1ad0..1016e1e75 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -3,7 +3,16 @@ import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWr import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; -const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); +const RETRYABLE_429_OPS = new Set([ + "verify", + "open", + "read", + "openDir", + "list", + "getDirUrl", + "conditionalWrite", + "conditionalDelete", +]); /** * 速率限制器 @@ -125,8 +134,9 @@ export default class LimiterFileSystem implements FileSystem { async create(path: string, opts?: FileCreateOptions): Promise { return this.limiter.execute(async () => { const writer = await this.fs.create(path, opts); + const writeOp = opts?.expectedDigest || opts?.createOnly ? "conditionalWrite" : "write"; return { - write: (content) => this.limiter.execute(() => writer.write(content), "write"), + write: (content) => this.limiter.execute(() => writer.write(content), writeOp), }; }, "create"); } @@ -136,7 +146,8 @@ export default class LimiterFileSystem implements FileSystem { } delete(path: string, opts?: FileDeleteOptions): Promise { - return this.limiter.execute(() => this.fs.delete(path, opts), "delete"); + const op = opts?.expectedDigest ? "conditionalDelete" : "delete"; + return this.limiter.execute(() => this.fs.delete(path, opts), op); } list(): Promise { From 0f61332b17acc79463e286a94314e6a59e299b9b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:46:22 +0900 Subject: [PATCH 052/103] docs(sync): reflect protected write retry --- docs/sync-research.md | 10 +++++----- packages/filesystem/limiter.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 34e25581e..ba346590e 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -204,7 +204,7 @@ type FileDigestMap = { | Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 未声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | -`LimiterFileSystem` 当前只对白名单读类操作的 429 自动重试:`verify/open/read/openDir/list/getDirUrl`。`create/createDir/write/delete` 遇到 429 不重试。这是保守的,避免重复非幂等写;后续若要支持写重试,应结合 create-only/CAS 或 provider 幂等能力。 +`LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 ## 已确认问题 @@ -216,7 +216,7 @@ type FileDigestMap = { 6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;普通网络错误仍可能保持原始 Error。 -9. transient 写失败无有限 retry:429/5xx 在 write/delete 上当前直接失败。 +9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 ## PR #1439 分析 @@ -330,7 +330,7 @@ type FileSystemCapabilities = { type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; ``` -本分支当前已实现 capabilities、provider typed error 的关键路径,以及 read 类 429/5xx 有限 retry;还没有把 `SyncErrorKind` 作为同步层显式类型落地。 +本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类,以及读类和受条件保护写/删的 transient 有限 retry。 ### Phase 5:provider 条件操作 follow-up @@ -344,7 +344,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" ### Phase 6:重试和通知 -- transient 429/5xx 有限 retry/backoff。 +- transient 429/5xx 有限 retry/backoff 已在 `LimiterFileSystem` 落地,范围限于读类操作和受条件保护的写/删。 - install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 - 通知包含失败数量和首个错误类型,不逐文件弹。 @@ -385,7 +385,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 4. Dropbox typed conflict。 5. Google Drive best-effort preflight race。 6. Baidu errno 分类。 -7. Limiter 对 read 429 重试、write/delete 429 不重复非幂等操作。 +7. Limiter 对 read 类 transient 错误重试;只对受条件保护的 write/delete transient 错误重试,普通 write/delete 不重复非幂等操作。 ## 风险清单 diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 1016e1e75..84847915f 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -3,7 +3,7 @@ import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWr import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; import { FileSystemError } from "./error"; -const RETRYABLE_429_OPS = new Set([ +const RETRYABLE_TRANSIENT_OPS = new Set([ "verify", "open", "read", @@ -32,7 +32,7 @@ export class RateLimiter { /** * 执行限速操作 * @param fn 要执行的操作函数 - * @param op 操作类型,用于在遇到 429 时判断是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 + * @param op 操作类型,用于判断 transient 错误是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 * @returns 操作结果 */ async execute(fn: () => Promise, op = "unknown"): Promise { @@ -57,9 +57,9 @@ export class RateLimiter { } /** - * 执行操作并处理 429 错误重试 + * 执行操作并处理 transient 错误重试 * @param fn 要执行的操作函数 - * @param op 操作类型,用于判定该操作在遇到 429 时是否进入指数退避重试 + * @param op 操作类型,用于判定该操作在遇到 transient 错误时是否进入指数退避重试 * @returns 操作结果 */ private async executeWithRetry(fn: () => Promise, op: string): Promise { @@ -84,7 +84,7 @@ export class RateLimiter { } private shouldRetryTransient(op: string, error: unknown): boolean { - if (!RETRYABLE_429_OPS.has(op)) { + if (!RETRYABLE_TRANSIENT_OPS.has(op)) { return false; } if (error instanceof FileSystemError) { From b87a55c6b635bc4d170bbdebe5ac3551ef481a12 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:49:59 +0900 Subject: [PATCH 053/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20sync?= =?UTF-8?q?=20error=20kind=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 2b08fccd2..fef26030c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1358,6 +1358,98 @@ console.log("ok");` }); }); + 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: "unsupported", + error: new Error("unsupported conditional write"), + expectedKind: "unsupported", + expectedMessage: "unsupported conditional write", + }, + ])( + "单文件同步遇到 $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 From 41b91104b021fd052ae10579dc973c577c3bb6d4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:51:49 +0900 Subject: [PATCH 054/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20insta?= =?UTF-8?q?ll=20push=20transient=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index fef26030c..c8ecdbae2 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1935,6 +1935,55 @@ console.log("ok");` 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" }) + ); + 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("scriptsDelete enters cloud_sync queue and updates digest after deleting", async () => { let releaseSync!: () => void; const syncGate = new Promise((resolve) => { From 28c50a85fe95b078f8a70e4ce1a6197fad0cb00f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:54:01 +0900 Subject: [PATCH 055/103] =?UTF-8?q?=F0=9F=90=9B=20fix(sync):=20classify=20?= =?UTF-8?q?queued=20sync=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/synchronize.test.ts | 9 ++++++++- src/app/service/service_worker/synchronize.ts | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index c8ecdbae2..aca51a548 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1629,6 +1629,7 @@ console.log("ok");` } 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", @@ -1638,6 +1639,11 @@ console.log("ok");` 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", @@ -1976,7 +1982,8 @@ console.log("ok");` expect(updateSpy).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith( "push script on install error", - expect.objectContaining({ error: "Service unavailable" }) + 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", diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 576d219a5..c44d9aaee 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -929,7 +929,9 @@ export class SynchronizeService { const pushedFileDigestMap = await this.pushScript(fs, params.script); await this.updateFileDigest(fs, pushedFileDigestMap); }).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), + }); }); } } @@ -955,6 +957,7 @@ export class SynchronizeService { preserveDigestFiles.add(`${uuid}.meta.json`); this.logger.warn("delete cloud script item failed", Logger.E(e), { uuid, + errorKind: this.classifySyncError(e), }); } } From 70c6a5994352005bc2316d728547422b64fef913 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:55:19 +0900 Subject: [PATCH 056/103] docs(sync): record queued failure classification audit --- docs/sync-research.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index ba346590e..dd6e30699 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -330,7 +330,7 @@ type FileSystemCapabilities = { type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; ``` -本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类,以及读类和受条件保护写/删的 transient 有限 retry。 +本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类(包括 `syncOnce` per-file 失败、`scriptInstall` 排队 push 失败、`scriptsDelete` 单项删除失败),以及读类和受条件保护写/删的 transient 有限 retry。 ### Phase 5:provider 条件操作 follow-up @@ -338,7 +338,8 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 - Dropbox:request 层 typed error 已落地;rev 作为 opaque token 尚未实现。 -- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。 +- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 +- OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 - Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 From 0828472a6e9f149e1c08e2e8b2af347f80e9d743 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:07:27 +0900 Subject: [PATCH 057/103] docs(sync): refine remaining rollout plan --- docs/sync-research.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/sync-research.md b/docs/sync-research.md index dd6e30699..a5f6bec54 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -349,6 +349,43 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 - 通知包含失败数量和首个错误类型,不逐文件弹。 +## 剩余 rollout checklist + +后续提交应继续保持小步、可回滚,并按“测试先行、实现随后、文档同步”的顺序推进。 + +### 必须继续验证 + +1. queued delete 的 typed `transient` / `conflict` 分类:已覆盖 fatal,仍需确认 provider typed error 在 `scriptsDelete()` 单项失败路径中保持 per-item best-effort。 +2. provider token opaque:继续用测试确认 WebDAV/S3/OneDrive/Dropbox 的 digest/token 只作为 provider 回传值使用,不在同步层解析成通用 version。 +3. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 +4. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 + +### 可以做的小步 commit + +1. `✅ test(sync): cover queued delete typed failures` + - 只补 `scriptsDelete()` 单项 `FileSystemError` transient/conflict 测试。 + - 验证失败 uuid 保留旧 digest,后续 uuid 继续处理,日志含 `errorKind`。 +2. `🐛 fix(sync): preserve queued delete error kinds` + - 仅在测试暴露缺口时提交。 + - 不改变队列、digest、status 合并语义。 +3. `✅ test(fs): cover provider opaque tokens` + - 补 provider digest/token 不被同步层改写的回归测试。 + - 不引入 Google Drive `fileId:version`。 +4. `🐛 fix(fs): harden provider typed conflicts` + - 只修测试证明的 typed error 漏洞。 + - 优先 Dropbox raw error shape、OneDrive 409/412、WebDAV/S3 412。 +5. `docs(sync): document manual verification path` + - 写清真实扩展手工验证步骤和旧云目录样例。 + - 不增加新 runtime 行为。 + +### 暂不进入本轮 + +1. Dropbox `rev` CAS:需要独立设计,不能把 `content_hash` 当 rev。 +2. Google Drive / Baidu atomic CAS:没有原生条件写时只能 best-effort preflight。 +3. `tombstone_digest`:没有 GC 设计前不新增。 +4. 通知聚合 UI:先保留日志和定时同步兜底,避免扩大 UI / i18n 范围。 +5. 既有 React hooks lint warning:与同步修复无关。 + ## 测试矩阵 ### 同步任务级别 From 93bad78adad51972c37e058e49efb659f213cb92 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:09:20 +0900 Subject: [PATCH 058/103] =?UTF-8?q?=E2=9C=85=20test(sync):=20cover=20queue?= =?UTF-8?q?d=20delete=20typed=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index aca51a548..3fb1a7e1c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1650,6 +1650,97 @@ console.log("ok");` }); }); + 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: "s3", + message: "Precondition failed", + status: 412, + conflict: true, + }), + expectedKind: "conflict", + expectedMessage: "Precondition failed", + }, + ])( + "批量删除遇到 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 支持条件删除时应当传 expectedDigest", async () => { const fs = createFs({ capabilities: { From a8a14872941dddb5dcdc86635531ffb084672c36 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:11:36 +0900 Subject: [PATCH 059/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20opaque=20digest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 513b12611..21d5243b6 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -140,6 +140,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"); From 0b3d1340395a59d7ba3d71bbe7a2f822042f0433 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:12:40 +0900 Subject: [PATCH 060/103] docs(sync): document manual verification path --- docs/sync-research.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/sync-research.md b/docs/sync-research.md index a5f6bec54..fb470c190 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -386,6 +386,33 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 4. 通知聚合 UI:先保留日志和定时同步兜底,避免扩大 UI / i18n 范围。 5. 既有 React hooks lint warning:与同步修复无关。 +## 手工验证路径 + +手工验证应遵循 [`VERIFICATION.md`](./VERIFICATION.md):先跑 cheap signals,再用 `e2e/scratch/` 一次性脚本加载真实 `dist/ext`,验证完删除 scratch 脚本。不要为了本同步修复新增永久 E2E。 + +建议按以下云目录夹具逐项验证: + +1. 旧格式完整脚本:云端只有 `.user.js` 和旧 `.meta.json`,无新增字段;预期可正常 pull/install,`file_digest` 写入 provider 原生 digest。 +2. 旧 `file_digest`:本地 storage 只有 `{ "uuid.user.js": "digest" }` string map;预期 push/delete 条件参数只在 provider 声明能力且旧 digest 存在时传递。 +3. 旧 `scriptcat-sync.json`:缺少未来字段或缺少 `status.scripts`;预期不崩溃,写回仍保留可读 status。 +4. orphan `.user.js`:云端只有 `.user.js`;预期跳过,不删除云端文件,不清空对应远端 status。 +5. 单文件 pull 失败:让一个 `.meta.json` 损坏或源码无法解析;预期只阻塞该 uuid,其他脚本继续同步,失败文件 digest 不推进。 +6. 单文件 delete 失败:让 provider 对某个 uuid 删除返回 412/429/5xx;预期后续 uuid 继续处理,失败 uuid digest 保留,日志含 `errorKind`。 +7. `scriptcat-sync.json` 并发状态:两端分别更新不同脚本 enable/sort;预期写回前合并远端最新状态,不覆盖另一端较新 status。 + +每次手工验证记录: + +```text +provider: +initial cloud files: +local scripts: +action: +observed logs: +file_digest after: +scriptcat-sync.json after: +result: +``` + ## 测试矩阵 ### 同步任务级别 From aedc3a8d075748a15de21f55ce489f3baa277259 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:16:59 +0900 Subject: [PATCH 061/103] =?UTF-8?q?=F0=9F=90=9B=20fix(fs):=20classify=20dr?= =?UTF-8?q?opbox=20structured=20not=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 22 +++++++++++++++++++++ packages/filesystem/dropbox/dropbox.ts | 12 +++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 21d5243b6..35b287471 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -35,6 +35,28 @@ describe("DropboxFileSystem", () => { }); }); + 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( diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index f36ae2e44..538d07899 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -9,6 +9,12 @@ type DropboxErrorBody = { error_summary?: string; error?: { ".tag"?: string; + path_lookup?: { + ".tag"?: string; + }; + path?: { + ".tag"?: string; + }; }; }; @@ -27,6 +33,12 @@ function parseDropboxError(raw: unknown): { summary?: string; raw: unknown } { 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 }; } From f96958dc2e5a1222686dc55b783753b9d2895e74 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:20:05 +0900 Subject: [PATCH 062/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20dropbox?= =?UTF-8?q?=20structured=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/dropbox/dropbox.test.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 35b287471..34011cb86 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -81,6 +81,28 @@ describe("DropboxFileSystem", () => { }); }); + 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("request should throw typed rate-limit error", async () => { const fs = new DropboxFileSystem("/", "token"); vi.stubGlobal( From 7c1bd3748a7d31da2b6570846aa8359b86d7ef9f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:25:45 +0900 Subject: [PATCH 063/103] docs(sync): keep rollout checklist current --- docs/sync-research.md | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index fb470c190..690d8031a 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -337,7 +337,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 分 provider 小步提交: - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 -- Dropbox:request 层 typed error 已落地;rev 作为 opaque token 尚未实现。 +- Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 - Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 - Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 @@ -355,28 +355,21 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" ### 必须继续验证 -1. queued delete 的 typed `transient` / `conflict` 分类:已覆盖 fatal,仍需确认 provider typed error 在 `scriptsDelete()` 单项失败路径中保持 per-item best-effort。 -2. provider token opaque:继续用测试确认 WebDAV/S3/OneDrive/Dropbox 的 digest/token 只作为 provider 回传值使用,不在同步层解析成通用 version。 -3. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 -4. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 +1. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 +2. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 +3. Google Drive / Baidu best-effort 风险:仍需手工确认不会被误标成 atomic CAS。 ### 可以做的小步 commit -1. `✅ test(sync): cover queued delete typed failures` - - 只补 `scriptsDelete()` 单项 `FileSystemError` transient/conflict 测试。 - - 验证失败 uuid 保留旧 digest,后续 uuid 继续处理,日志含 `errorKind`。 -2. `🐛 fix(sync): preserve queued delete error kinds` - - 仅在测试暴露缺口时提交。 - - 不改变队列、digest、status 合并语义。 -3. `✅ test(fs): cover provider opaque tokens` - - 补 provider digest/token 不被同步层改写的回归测试。 - - 不引入 Google Drive `fileId:version`。 -4. `🐛 fix(fs): harden provider typed conflicts` - - 只修测试证明的 typed error 漏洞。 - - 优先 Dropbox raw error shape、OneDrive 409/412、WebDAV/S3 412。 -5. `docs(sync): document manual verification path` - - 写清真实扩展手工验证步骤和旧云目录样例。 - - 不增加新 runtime 行为。 +1. `docs(sync): keep rollout checklist current` + - 每完成一组测试/实现后同步文档状态。 + - 不增加 runtime 行为。 +2. `✅ test(fs): cover remaining provider typed gaps` + - 只补明确缺口;当前重点是手工审计中发现的 provider raw error shape。 + - 不为 Google Drive / Baidu 添加伪 atomic 测试。 +3. `docs(sync): record manual verification result` + - 真实扩展和真实 provider 验证后记录观察结果。 + - 若无法验证,明确写未验证原因。 ### 暂不进入本轮 From d1be55776df400ea0ef9c7745d0f4a592159aabb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:36:49 +0900 Subject: [PATCH 064/103] =?UTF-8?q?=E2=9C=85=20test(fs):=20cover=20non-ato?= =?UTF-8?q?mic=20provider=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/baidu/baidu.test.ts | 20 ++++++++++++++++++- packages/filesystem/dropbox/dropbox.test.ts | 11 ++++++++++ .../googledrive/googledrive.test.ts | 11 ++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index bfc352988..2f52b9b2a 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,12 +1,26 @@ import { describe, expect, it, vi, afterEach } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { getFileSystemCapabilities } from "../filesystem"; import BaiduFileSystem from "./baidu"; +initTestEnv(); + describe("BaiduFileSystem", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + it("不应声明原子条件写入能力", () => { + const fs = new BaiduFileSystem("/apps", "token"); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + it("request should omit credentials without using global DNR rules", async () => { const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ errno: 0 }), @@ -15,7 +29,11 @@ describe("BaiduFileSystem", () => { // 监视 updateDynamicRules,确保不再依赖全局 DNR 规则 const updateDynamicRulesMock = vi.fn(); - (chrome as any).declarativeNetRequest.updateDynamicRules = updateDynamicRulesMock; + vi.stubGlobal("chrome", { + declarativeNetRequest: { + updateDynamicRules: updateDynamicRulesMock, + }, + }); const fs = new BaiduFileSystem("/apps", "token"); diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 34011cb86..a0fd65325 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FileSystemError } from "../error"; +import { getFileSystemCapabilities } from "../filesystem"; import DropboxFileSystem from "./dropbox"; describe("DropboxFileSystem", () => { @@ -11,6 +12,16 @@ describe("DropboxFileSystem", () => { vi.unstubAllGlobals(); }); + it("不应声明原子条件写入能力", () => { + const fs = new DropboxFileSystem("/", "token"); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + it("request should throw typed not found error", async () => { const fs = new DropboxFileSystem("/", "token"); vi.stubGlobal( diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 1bf0635e2..add9c9c0d 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error"; +import { getFileSystemCapabilities } from "../filesystem"; import { joinPath } from "../utils"; import GoogleDriveFileSystem from "./googledrive"; @@ -29,6 +30,16 @@ describe("GoogleDriveFileSystem", () => { vi.stubGlobal("fetch", originalFetch); }); + it("不应声明原子条件写入能力", () => { + const fs = new GoogleDriveFileSystem("/", "token"); + + expect(getFileSystemCapabilities(fs)).toEqual({ + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, + }); + }); + it("delete should be idempotent when file id is missing", async () => { const fs = new GoogleDriveFileSystem("/", "token"); vi.spyOn(fs, "getFileId").mockResolvedValue(null); From 178eea1c20ebf24c4b58b7e2756c38fb27f2c787 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:39:00 +0900 Subject: [PATCH 065/103] docs(sync): record non-atomic provider coverage --- docs/sync-research.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 690d8031a..1b2dcd3d5 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -199,9 +199,9 @@ type FileDigestMap = { | WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | | S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | | OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 未声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | -| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 未声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | +| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 @@ -338,9 +338,9 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 - Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 -- Google Drive:仍不声明 atomic 能力;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 +- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 +- Baidu:不声明 atomic 能力已有 capability 测试锁定;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 From 2264d9e76581d0f63018d4cbb8196a4c1b728811 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:44:50 +0900 Subject: [PATCH 066/103] fix(sync/fs): address remaining provider verification gaps --- packages/filesystem/baidu/baidu.test.ts | 17 +++++++++++++++++ packages/filesystem/baidu/rw.ts | 7 +++++-- .../filesystem/googledrive/googledrive.test.ts | 10 ++++++++++ packages/filesystem/googledrive/rw.ts | 9 +++++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index 2f52b9b2a..ed2386439 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { initTestEnv } from "@Tests/utils"; +import { isNotFoundError } from "../error"; import { getFileSystemCapabilities } from "../filesystem"; import BaiduFileSystem from "./baidu"; @@ -104,4 +105,20 @@ describe("BaiduFileSystem", () => { 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/rw.ts b/packages/filesystem/baidu/rw.ts index acec44e19..6b3284982 100644 --- a/packages/filesystem/baidu/rw.ts +++ b/packages/filesystem/baidu/rw.ts @@ -20,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) { diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index add9c9c0d..428c5f473 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -111,6 +111,16 @@ describe("GoogleDriveFileSystem", () => { }); }); + 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); diff --git a/packages/filesystem/googledrive/rw.ts b/packages/filesystem/googledrive/rw.ts index fef4d367d..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, + }); } // 获取文件内容 From 917a30c41d6d860c8c699e6eccdfd8aef1db9637 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:04:13 +0900 Subject: [PATCH 067/103] docs(sync): record provider gap closure --- docs/sync-research.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 1b2dcd3d5..8e06cacd8 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -199,9 +199,9 @@ type FileDigestMap = { | WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | | S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | | OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;path cache 和同名文件仍是主要风险 | +| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;reader path lookup miss 已转 typed notFound;path cache 和同名文件仍是主要风险 | | Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;只有明确 file-exists errno 才标记 conflict | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 @@ -215,7 +215,7 @@ type FileDigestMap = { 5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 -8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;普通网络错误仍可能保持原始 Error。 +8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss 和 Baidu filemetas miss 已补 typed notFound;普通网络错误仍可能保持原始 Error。 9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 @@ -338,9 +338,9 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 - Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 -- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 +- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;reader path lookup miss 已补 typed notFound。若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:不声明 atomic 能力已有 capability 测试锁定;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 +- Baidu:不声明 atomic 能力已有 capability 测试锁定;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 @@ -359,15 +359,18 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 2. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 3. Google Drive / Baidu best-effort 风险:仍需手工确认不会被误标成 atomic CAS。 -### 可以做的小步 commit +### 已完成的本地验证 commit -1. `docs(sync): keep rollout checklist current` - - 每完成一组测试/实现后同步文档状态。 - - 不增加 runtime 行为。 -2. `✅ test(fs): cover remaining provider typed gaps` - - 只补明确缺口;当前重点是手工审计中发现的 provider raw error shape。 - - 不为 Google Drive / Baidu 添加伪 atomic 测试。 -3. `docs(sync): record manual verification result` +1. `✅ test(fs): cover non-atomic provider capabilities` + - 锁定 Google Drive / Dropbox / Baidu 不声明 atomic/create-only/conditional delete。 +2. `fix(sync/fs): address remaining provider verification gaps` + - Google Drive reader path lookup miss 转 typed notFound。 + - Baidu reader filemetas errno/空列表转 typed notFound。 + - 不为 Google Drive / Baidu 添加伪 atomic 语义。 + +### 剩余可做 commit + +1. `docs(sync): record manual verification result` - 真实扩展和真实 provider 验证后记录观察结果。 - 若无法验证,明确写未验证原因。 From 39cf65b1f93d9c87adfbadd853036321292b1ae2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:05:38 +0900 Subject: [PATCH 068/103] docs(sync): record manual verification result --- docs/sync-research.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 8e06cacd8..a72ec9cf4 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -368,11 +368,11 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - Baidu reader filemetas errno/空列表转 typed notFound。 - 不为 Google Drive / Baidu 添加伪 atomic 语义。 -### 剩余可做 commit +### 已完成的验证记录 commit 1. `docs(sync): record manual verification result` - - 真实扩展和真实 provider 验证后记录观察结果。 - - 若无法验证,明确写未验证原因。 + - 已记录本地 unit/type/lint/build 验证结果。 + - 真实 provider 验证需要 OAuth/账号和云端夹具;未执行的路径明确列为未验证。 ### 暂不进入本轮 @@ -409,6 +409,30 @@ scriptcat-sync.json after: result: ``` +### 当前验证记录 + +本地可运行验证: + +- `pnpm test --run src/app/service/service_worker/synchronize.test.ts packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts packages/filesystem/dropbox/dropbox.test.ts packages/filesystem/onedrive/onedrive.test.ts packages/filesystem/s3/s3.test.ts packages/filesystem/webdav/webdav.test.ts packages/filesystem/limiter.test.ts packages/filesystem/filesystem.test.ts` + - 9 个 test files,189 个 tests 通过。 +- `pnpm test --run packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts` + - 修正 Baidu test fixture 类型后重跑,2 个 test files,27 个 tests 通过。 +- `pnpm run typecheck` + - 通过。 +- `pnpm run lint` + - 通过,仍有 12 个既有 React hooks warning,均不在同步修复范围。 +- `pnpm run build` + - production build 通过;Rspack 输出既有 bundle size、CSS order、Monaco dynamic require warnings。 + +尚未执行的真实 provider 验证: + +- WebDAV / S3 / OneDrive 真实 If-Match / ETag mismatch。 +- Dropbox 真实 structured conflict / rate-limit。 +- Google Drive 真实 path cache stale、同名文件和 best-effort race。 +- Baidu 真实 `filemetas` 缺失、errno 分类和覆盖写行为。 + +未执行原因:当前本地环境没有可用于 OAuth/云端账号的 provider 凭据,也没有预置云端夹具目录;不能把 mock/unit/build 结果宣称为真实云端验证。 + ## 测试矩阵 ### 同步任务级别 From 2cdf9289ab470a3abf232ca7e8d0d2c805ce902a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:12:30 +0900 Subject: [PATCH 069/103] docs(sync): record real provider verification --- docs/sync-research.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/sync-research.md b/docs/sync-research.md index a72ec9cf4..2b6bc162d 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -433,6 +433,19 @@ result: 未执行原因:当前本地环境没有可用于 OAuth/云端账号的 provider 凭据,也没有预置云端夹具目录;不能把 mock/unit/build 结果宣称为真实云端验证。 +### 真实 provider 验证记录 + +本轮未能执行真实云端验证。阻塞条件是本地环境没有可用的 provider OAuth 凭据、S3/WebDAV 测试账号、Dropbox/Google Drive/Baidu/OneDrive 授权会话,也没有预置的云端夹具目录。由于同步修复涉及生产数据兼容,不能用 mock 响应替代真实 provider 结论。 + +真实验证恢复条件: + +1. 每个 provider 准备独立测试目录,目录内只放一次性夹具数据。 +2. 预置旧格式 `.user.js`、`.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json`。 +3. 至少两端配置同一 provider,用于验证 `scriptcat-sync.json` 合并写和 per-file best-effort。 +4. 对 WebDAV/S3/OneDrive 准备 ETag/If-Match mismatch 场景。 +5. 对 Google Drive/Baidu 明确记录 best-effort 行为,不把 preflight 当 atomic CAS。 +6. 每次验证后保存 provider、初始云端文件、操作、日志、`file_digest`、`scriptcat-sync.json` 和结果。 + ## 测试矩阵 ### 同步任务级别 From 34d6c5f2ba4ada5396ef409bbb8f25f0a238ac91 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:14:58 +0900 Subject: [PATCH 070/103] fix(sync/fs): address real provider verification findings --- packages/filesystem/baidu/baidu.test.ts | 36 ++++++++++++++++++++++++- packages/filesystem/baidu/baidu.ts | 13 +++++++-- packages/filesystem/baidu/error.ts | 10 +++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index ed2386439..af36b4cf9 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { initTestEnv } from "@Tests/utils"; -import { isNotFoundError } from "../error"; +import { isNotFoundError, isRateLimitError } from "../error"; import { getFileSystemCapabilities } from "../filesystem"; import BaiduFileSystem from "./baidu"; @@ -24,6 +24,8 @@ 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); @@ -52,6 +54,38 @@ 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("create should normalize double slashes in paths", async () => { const fs = new BaiduFileSystem("/apps//ScriptCat", "token"); diff --git a/packages/filesystem/baidu/baidu.ts b/packages/filesystem/baidu/baidu.ts index 144d83481..4c3ddd8be 100644 --- a/packages/filesystem/baidu/baidu.ts +++ b/packages/filesystem/baidu/baidu.ts @@ -63,15 +63,24 @@ export default class BaiduFileSystem implements FileSystem { config.headers = headers; // 对百度网盘请求显式禁用 cookie,避免依赖全局 DNR 规则造成并发竞态 config.credentials = "omit"; + const parseResponse = async (response: Response) => { + const data = await response.json().catch(() => ({ + errmsg: response.statusText || `HTTP ${response.status}`, + })); + if (!response.ok) { + throw createBaiduFileSystemError({ ...data, httpStatus: response.status }); + } + return data; + }; 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 createBaiduFileSystemError(data2); diff --git a/packages/filesystem/baidu/error.ts b/packages/filesystem/baidu/error.ts index 769c2ba14..d5cb49bc4 100644 --- a/packages/filesystem/baidu/error.ts +++ b/packages/filesystem/baidu/error.ts @@ -2,6 +2,7 @@ import { FileSystemError } from "../error"; export type BaiduErrorResponse = { errno?: number; + httpStatus?: number; errmsg?: string; error_msg?: string; [key: string]: unknown; @@ -11,19 +12,24 @@ 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; - const notFound = data.errno === -9; + const auth = data.errno === 111 || data.errno === -6 || status === 401; + const notFound = data.errno === -9 || status === 404; + const rateLimit = status === 429; return new FileSystemError({ provider: "baidu", message, + status, code, conflict, auth, notFound, + rateLimit, + retryable: rateLimit || (status !== undefined && status >= 500), raw: data, }); } From dec82aaf8d28f9529cdc235d5f0677d353b4cdf2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:16:00 +0900 Subject: [PATCH 071/103] docs(sync): finalize rollout status --- docs/sync-research.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/sync-research.md b/docs/sync-research.md index 2b6bc162d..5288b6ba5 100644 --- a/docs/sync-research.md +++ b/docs/sync-research.md @@ -201,7 +201,7 @@ type FileDigestMap = { | OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | | Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;reader path lookup miss 已转 typed notFound;path cache 和同名文件仍是主要风险 | | Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | +| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;HTTP 429/5xx 已转 typed rateLimit/retryable;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | | Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | `LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 @@ -215,7 +215,7 @@ type FileDigestMap = { 5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 -8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss 和 Baidu filemetas miss 已补 typed notFound;普通网络错误仍可能保持原始 Error。 +8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss、Baidu filemetas miss 和 Baidu HTTP 429/5xx 已补 typed error;普通网络错误仍可能保持原始 Error。 9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 @@ -340,7 +340,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" - Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 - Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;reader path lookup miss 已补 typed notFound。若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 - OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:不声明 atomic 能力已有 capability 测试锁定;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 +- Baidu:不声明 atomic 能力已有 capability 测试锁定;HTTP 429/5xx 已补 typed rateLimit/retryable;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 - Zip:保持简单,不参与云端 CAS。 ### Phase 6:重试和通知 @@ -373,6 +373,21 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" 1. `docs(sync): record manual verification result` - 已记录本地 unit/type/lint/build 验证结果。 - 真实 provider 验证需要 OAuth/账号和云端夹具;未执行的路径明确列为未验证。 +2. `docs(sync): record real provider verification` + - 已记录真实 provider 验证未执行的阻塞条件和恢复条件。 + +### 已完成的 provider finding commit + +1. `fix(sync/fs): address real provider verification findings` + - Baidu HTTP 429 转 typed rateLimit。 + - Baidu HTTP 5xx 转 typed retryable。 + - 保持 Baidu 非 atomic 能力声明不变。 + +### Rollout 状态 + +本地可验证范围已完成:同步层 per-file best-effort、旧数据兼容、provider capabilities、关键 typed error、受保护写/删 retry、Baidu/Google Drive 非 atomic 锁定均已有测试或文档记录。 + +真实 provider rollout 仍需凭据环境:WebDAV/S3/OneDrive 的真实 ETag/If-Match mismatch、Dropbox structured conflict、Google Drive/Baidu best-effort race 和 OAuth 刷新路径仍必须在真实账号中验证后再宣称生产完成。 ### 暂不进入本轮 From be5e7940f8520a78cb37bae06f6514a913fa2bfa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:42:26 +0900 Subject: [PATCH 072/103] Delete sync-research.md --- docs/sync-research.md | 552 ------------------------------------------ 1 file changed, 552 deletions(-) delete mode 100644 docs/sync-research.md diff --git a/docs/sync-research.md b/docs/sync-research.md deleted file mode 100644 index 5288b6ba5..000000000 --- a/docs/sync-research.md +++ /dev/null @@ -1,552 +0,0 @@ -# 云同步一致性与生产兼容性研究 - -## 目标 - -本分支用于研究 ScriptCat 云同步在多设备、多 provider、网络异常、并发修改和旧数据兼容场景下的正确性问题。目标不是把 PR #1439 原样搬进来,而是在 `upstream/main` / `main` 现有同步语义上做可验证、可分阶段合并的修复。 - -核心目标: - -1. 避免云端文件被静默覆盖。 -2. 避免失败同步污染本地 `file_digest` 或远端 `scriptcat-sync.json`。 -3. 保持现有生产用户云端数据兼容,不要求手动迁移。 -4. 不把 per-file best-effort 同步退化成整轮 all-or-nothing。 -5. 将 provider 能力、同步策略、错误分类和用户通知分层处理。 - -## 当前同步架构 - -入口在 `src/app/service/service_worker/synchronize.ts` 的 `SynchronizeService`。云同步由 `buildFileSystem()` 建立 provider 文件系统,然后通过 `syncOnce()` 进入 `cloud_sync_queue` 串行队列,最终执行 `syncOnceInternal()`。安装脚本和删除脚本的消息也复用同一个队列,避免和定时同步并发写云端。 - -### `syncOnceInternal` - -当前流程: - -1. `fs.list()` 读取 `ScriptCat/sync` 目录。 -2. 按文件名把 `.user.js` 和 `.meta.json` 组装成 `uuidMap`。 -3. 从本地 DAO 读取脚本列表,生成 `scriptMap`。 -4. 如果存在 `scriptcat-sync.json`,读取其中的 `status.scripts` 作为云端状态。 -5. 遍历 `uuidMap` 决定每个 uuid 的动作: - - 本地脚本存在,云端只有 `.meta.json`:如果 meta 是 tombstone,则本地删除;否则删除无效 meta 并重新 push。 - - 本地脚本存在,云端 `.user.js` digest 与 `file_digest` 一致:跳过。 - - 本地脚本更新时间更晚,或云端缺 `.meta.json`:`pushScript()`。 - - 云端更新时间更晚:`pullScript()`。 - - 本地脚本不存在但云端 `.user.js` 和 `.meta.json` 都存在:`pullScript()` 安装。 - - 云端只有 `.user.js`、没有 `.meta.json`:跳过并记录 orphan uuid,避免误删或覆盖半上传文件。 -6. 剩余本地脚本全部 `pushScript()`。 -7. `Promise.allSettled()` 等待所有任务;fulfilled 且返回 digest patch 的任务合并到 `pushedFileDigestMap`。 -8. 如果开启 `syncStatus`,用本地状态和 `scriptcat-sync.json` 的 status 时间戳合并 enable/sort,然后直接 `fs.create("scriptcat-sync.json")` 覆盖写回。 -9. `updateFileDigest(fs, pushedFileDigestMap)` 重新 list 云端,把当前云端 digest 写入本地 `file_digest`;如果刚 push 的文件未出现在 list 里,用本地 md5 兜底。 - -重要现状:当前 `syncOnceInternal()` 仍保持 task 级 `Promise.allSettled()`,不会因为单个 push/pull Promise rejected 而停止所有任务;但 `pullScript()` 和 `deleteCloudScript()` 内部会 catch 后只记录日志,导致上层无法知道真实失败。 - -### `pushScript` - -`pushScript(fs, script)` 当前无条件覆盖写: - -1. 写 `${uuid}.user.js`,`modifiedDate` 使用脚本 `updatetime || createtime || Date.now()`。 -2. 从 `scriptCodeDAO` 读取源码并写入 `.user.js`。 -3. 写 `${uuid}.meta.json`,内容包含 `uuid`、`origin`、`downloadUrl`、`checkUpdateUrl`。 -4. 返回本地计算的 md5 digest patch,供 `updateFileDigest()` 在云端 list 暂时不可见时兜底。 - -风险:`fs.create()` 的 provider 实现通常是覆盖写或先查再写,当前没有 create-only、expected digest、expected version,也没有原子 CAS。 - -### `pullScript` - -`pullScript(fs, file, status, existingScript?)` 当前流程: - -1. 读取 `.user.js` 源码。 -2. 读取 `.meta.json` 并 `JSON.parse`。 -3. `prepareScriptByCode()` 解析脚本元信息。 -4. 如果有 `scriptcat-sync.json` status,则根据 status 更新时间决定 enable/sort。 -5. `script.installScript({ upsertBy: "sync" })` 写入本地。 -6. catch 所有异常,只记录日志,不向调用方抛出。 - -风险:下载、解析、安装失败都会被视为任务 fulfilled,后续可能继续写 `scriptcat-sync.json` 和 `file_digest`,造成状态污染。另一方面,不能简单把所有错误都抛成整轮失败,因为坏单文件不应卡死其他脚本。 - -### `deleteCloudScript` - -`deleteCloudScript(fs, uuid, syncDelete)` 当前流程: - -1. 删除 `${uuid}.user.js`。 -2. 如果 `syncDelete` 为 true,写 `${uuid}.meta.json` tombstone:`{ uuid, isDeleted: true }`。 -3. 如果 `syncDelete` 为 false,删除 `${uuid}.meta.json`。 -4. catch 所有异常,只记录日志,不向调用方抛出。 - -风险:删除失败、tombstone 写失败或 meta 删除失败都会被调用方当作成功;`scriptsDelete()` 随后仍会 `updateFileDigest()`。 - -### `updateFileDigest` - -`file_digest` 是本地 `ChromeStorage("sync")` 中的 digest cache。当前 `updateFileDigest()` 会: - -1. `fs.list()` 读取云端当前文件。 -2. 用云端返回的 `file.digest` 生成完整新 map。 -3. 对刚 push 但 list 缺失的文件,用 `knownFileDigestMap` 的本地 md5 兜底。 -4. `storage.set("file_digest", newFileDigestMap)` 全量替换。 - -当前实现已经避免在云端 list 返回 provider 原生 digest 时用本地 md5 覆盖它,这对 WebDAV/OneDrive/S3 的 ETag 和 Dropbox content_hash 很关键。 - -仍需注意:全量替换适合“完整 list 成功”场景;如果某个文件操作失败但 `updateFileDigest()` 仍基于新 list 全量写入,失败文件也可能被推进。 - -### `scriptInstall` - -非 sync 来源安装脚本时: - -1. 读取云同步配置。 -2. 如果启用,进入 `cloud_sync_queue`。 -3. `buildFileSystem()`。 -4. `pushScript()`。 -5. `updateFileDigest(fs, pushedFileDigestMap)`。 - -失败只记录日志。这个路径的本地安装已经成功,云端失败可由后续定时 sync 补偿;通知需要谨慎,避免 transient 失败造成噪声。 - -### `scriptsDelete` - -非 sync 来源删除脚本时: - -1. 过滤 `deleteBy === "sync"` 的事件,避免同步拉取/删除造成回灌。 -2. 如果启用云同步,进入 `cloud_sync_queue`。 -3. `buildFileSystem()`。 -4. 顺序调用 `deleteCloudScript()`。 -5. `updateFileDigest(fs)`。 - -当前 `deleteCloudScript()` 吞错,所以循环会继续;如果未来改为抛错,必须逐条 catch,否则批量删除中第 3 条失败会阻止后续 7 条处理。 - -## 同步文件语义 - -### `.user.js` - -每个脚本的源码文件,命名为 `.user.js`。当前同步判断主要看它的 `digest` 和 `updatetime`。 - -### `.meta.json` - -每个脚本的同步元信息,命名为 `.meta.json`。生产旧格式大致为: - -```ts -type SyncMeta = { - uuid: string; - origin?: string; - downloadUrl?: string; - checkUpdateUrl?: string; - isDeleted?: boolean; -}; -``` - -旧用户云端可能只有这些字段。新增字段必须 optional,读取时必须容忍缺失。 - -### tombstone 删除标记 - -当 `syncDelete` 开启时,删除云端脚本不是简单移除所有文件,而是保留 `.meta.json` 且写入 `isDeleted: true`。其他设备看到“只有 meta 且 isDeleted=true”时会删除本地脚本并通知用户。 - -不建议立即新增独立 `tombstone_digest`。它会引入生命周期、清理时机和最终一致性下误保留的问题;除非先定义 GC 规则,否则会增加状态面。 - -### `scriptcat-sync.json` - -保存脚本启用状态、排序和更新时间: - -```ts -type ScriptcatSync = { - version: string; - status: { - scripts: { - [uuid: string]: { - enable: boolean; - sort: number; - updatetime: number; - } | undefined; - }; - }; -}; -``` - -当前写回是覆盖式 `fs.create("scriptcat-sync.json")`。生产兼容要求: - -- 旧文件可能没有未来新增字段。 -- orphan `.user.js` 对应 uuid 的远端 status 必须保留,避免本机把另一台设备半上传状态覆盖掉。 -- 一轮同步中失败的 uuid 不应导致 status 被错误清空或推进。 -- 写回前应尽量基于最新远端状态合并,避免覆盖其他设备刚写入的 status。 - -### `file_digest` - -本地 digest cache,当前格式是: - -```ts -type FileDigestMap = { - [filename: string]: string; -}; -``` - -生产兼容要求: - -- 旧记录只有 digest,没有 version/provider token。 -- 新逻辑必须能读取旧格式。 -- 新增结构时应支持 string 旧值和 object 新值并存。 -- digest 只能代表“上轮已确认成功同步的远端文件状态”,不能写入失败任务的结果。 - -## Provider 实现差异 - -当前 `FileSystem` 接口已经有最小 capabilities,并允许 `create()` / `delete()` 接收条件参数: - -- `supportsAtomicCompareAndSwap` -- `supportsCreateOnly` -- `supportsConditionalDelete` -- `FileCreateOptions.expectedDigest` -- `FileCreateOptions.createOnly` -- `FileDeleteOptions.expectedDigest` - -同步层只在 provider 显式声明能力时传条件参数;未声明能力的 provider 继续保持旧覆盖语义,避免把 best-effort preflight 伪装成 atomic CAS。 - -| Provider | `list()` digest | `create()` 当前语义 | `delete()` 当前语义 | 关键差异 | -| --- | --- | --- | --- | --- | -| WebDAV | `etag` | `putFileContents` 覆盖写;有能力时支持 `If-Match` / `overwrite=false` | 404 幂等成功;有能力时支持 `If-Match` | 原生条件写/删;create-only false 响应已转 typed conflict | -| S3 | 去引号 ETag | PUT 覆盖写;有能力时支持 `If-Match` / `If-None-Match` | DELETE 幂等,`NoSuchKey` 成功;有能力时支持 `If-Match` | 原生条件请求;412 / `PreconditionFailed` 归类为 typed conflict | -| OneDrive | `eTag` | simple/upload session;有能力时传 `If-Match` / `If-None-Match` | raw Response 路径,404 成功;有能力时传 `If-Match` | 原生条件请求;`nothen=true` raw response 路径已覆盖 429/404/409/412 typed error 测试 | -| Google Drive | `md5Checksum` | 查同名后 PATCH 或 POST | 先查 fileId 再 DELETE,404 成功 | 已测试不声明 atomic/create-only/conditional delete;reader path lookup miss 已转 typed notFound;path cache 和同名文件仍是主要风险 | -| Dropbox | `content_hash` | 先 `exists()`,存在 overwrite,不存在 add | `delete_v2`,typed not_found 幂等成功 | 已测试不声明 atomic 能力;rev 未暴露;request 层已把 not_found/conflict/rate-limit 转 typed error | -| Baidu | `md5` | precreate/upload/create,`rtype=3` 覆盖 | filemanager delete,非 0 errno 转 typed error | 已测试不声明 atomic 能力;HTTP 429/5xx 已转 typed rateLimit/retryable;reader filemetas errno/空列表已转 typed notFound;只有明确 file-exists errno 才标记 conflict | -| Zip | 空或 JSZip 元数据 | 本地 zip 写入 | 删除 zip entry | 备份用途,不应强行接入云端 CAS 语义 | - -`LimiterFileSystem` 当前只对白名单操作的 transient 错误自动重试:`verify/open/read/openDir/list/getDirUrl`,以及受 `expectedDigest` / `createOnly` 保护的 `write` 和受 `expectedDigest` 保护的 `delete`。普通 `create/createDir/write/delete` 仍不重试,避免重复非幂等写。 - -## 已确认问题 - -1. 静默覆盖:`fs.create()` 默认覆盖,两个设备基于旧快照修改同一文件时后写覆盖先写。 -2. 失败后状态污染:本分支已修复主要路径,push/pull/delete 失败文件保留旧 `file_digest`;失败 uuid 的 status 回写会保留云端状态。 -3. `pullScript()` 吞错:本分支已改为让真实失败向上表现为单文件任务失败。 -4. `deleteCloudScript()` 吞错:本分支已改为删除或 tombstone 写失败时向上抛错,批量删除调用方逐条 catch。 -5. orphan `.user.js`:当前已跳过且保留 status,这是正确方向;后续不能回退成删除或覆盖。 -6. `scriptcat-sync.json` 覆盖写:本分支已在写回前重新读取并合并远端最新状态;仍需真实 provider 环境验证竞态窗口。 -7. provider 能力不一致:本分支用 capabilities 控制条件操作,未把 Google Drive / Baidu preflight 声明为 atomic。 -8. 错误类型不完整:WebDAV/S3/OneDrive/GoogleDrive/Dropbox/Baidu 的关键 404/409/412/429/5xx 路径已有 typed error 覆盖;Google Drive path lookup miss、Baidu filemetas miss 和 Baidu HTTP 429/5xx 已补 typed error;普通网络错误仍可能保持原始 Error。 -9. transient 写失败有限 retry:本分支只对有条件保护的 `write/delete` 开启 retry;无条件写/删仍直接失败。 -10. 通知策略未分层:安装/删除触发的 transient 同步失败不一定应该马上打扰用户。 - -## PR #1439 分析 - -PR #1439 试图修复多设备并发写入、删除、拉取/推送失败导致的静默覆盖和状态污染。方向上,它抓到了真实问题:写入应带前置条件,失败不能推进本地 digest,provider 错误应类型化,tombstone 和 orphan 需要明确语义。 - -### cyfung1031 的改动意图 - -- 给 `FileInfo` 增加 version 类 token。 -- 给 create/delete 增加 expected digest/version 和 createOnly。 -- provider 使用 ETag/rev/version/content_hash/md5 做条件写入或 preflight。 -- `syncOnceInternal()` 在失败时避免继续推进 `scriptcat-sync.json` 和 `file_digest`。 -- 处理 orphan `.user.js`、tombstone 收敛、Google Drive 重名、Baidu errno、OneDrive/Google raw response 等具体问题。 -- 增加同步失败通知和大量 provider/sync 测试。 - -### CodFrm review 核心意见 - -CodFrm 的核心担忧是:PR 修了“报错”,但没有保留“消化错误”的粒度。旧代码的问题是 silent data loss,新代码的风险是 all-or-nothing 事务。 - -需要吸收的意见: - -- filesystem 包不应承担业务冲突策略;它应暴露原子能力、条件操作和 typed error,同步层决定业务冲突处理。 -- 99 个成功 + 1 个失败时,成功文件 digest 应推进,失败文件保留旧 digest,下轮只重试失败文件。 -- transient、conflict、fatal、unsupported 应分类,不能都当作整轮失败。 -- status sync 是 best-effort,单个 enable/sort 更新失败不应让整轮 sync 卡死。 -- `pullScript()` 不应吞掉真实失败,但坏 `.meta.json` 或坏 userscript 也不应卡死整个账号。 -- `deleteCloudScript()` 抛错后,`scriptsDelete()` 必须逐条 catch,不能中断后续 uuid。 -- 安装/删除事件触发的同步通知要节流,transient 失败可让定时同步兜底。 -- `updateFileDigest()` 的 list retry / known digest 兜底不能掩盖 provider 最终一致性和 digest 格式差异。 -- Google Drive 的 `fileId:version` 不应被包装成通用 version 语义。 -- `tombstone_digest` 会带来额外生命周期和 GC 问题。 - -### Copilot / reviewer 指出的问题 - -- `FileSystem.delete()` 签名改变后必须更新所有实现,例如 zip,否则 TypeScript 接口不匹配。 -- Baidu `createOnly` 不能把所有非 0 `errno` 都判为 conflict;只能匹配明确 file-exists / duplicate-name 错误。 -- Dropbox 不能靠 message string 长期判断冲突,应在 request 层转换 typed error。 -- OneDrive / Google Drive 的 `nothen=true` raw response 调用点必须全部审计,否则 typed error 不完整。 - -### 正确方向 - -- 保持旧数据可读,新增字段 optional。 -- 成功文件和失败文件分开推进状态。 -- provider 层暴露能力和原子操作,不写业务策略。 -- 能 atomic 的 provider 使用原生条件写;只能 preflight 的 provider 明确标记 best-effort。 -- orphan `.user.js` without `.meta.json` 跳过并保留远端 status。 -- `scriptcat-sync.json` 写回前合并远端最新状态,避免覆盖其他设备。 - -### 可能过度设计或破坏语义的方向 - -- 一次性改所有 provider、通知、tombstone digest、导出、alarm,改动面过大。 -- 任一文件失败就跳过全部 digest 更新,导致成功文件下轮重做甚至自冲突。 -- filesystem 层承载“同步冲突业务策略”,增加包职责。 -- 将 Google Drive `fileId:version` 暴露为通用 `version`。 -- 独立 `tombstone_digest` 未定义 GC 前就落地。 -- 每个 install/delete transient 失败都通知用户。 - -## 兼容生产数据的设计原则 - -1. 读旧格式:`.meta.json`、`scriptcat-sync.json`、`file_digest` 必须容忍缺字段。 -2. 写新格式要 optional:新增字段不能成为读取旧数据的前提。 -3. per-file best-effort:成功文件推进自己的 digest,失败文件保留旧 digest。 -4. 状态推进绑定真实成功:失败任务不得写成功 digest,不得覆盖对应 status。 -5. `scriptcat-sync.json` 合并写:保留 orphan、失败 uuid 和远端较新的 status。 -6. provider token opaque:同步层只传回 provider 给出的 token,不解析 provider 内部结构。 -7. 明确 atomic vs best-effort:preflight 只能降低风险,不能宣称 CAS。 -8. transient 有限 retry:只在幂等或有条件保护的写路径开启。 -9. 通知节流:按 sync round 聚合,不逐文件弹。 - -## 分阶段实现计划 - -### Phase 1:研究文档和测试基线 - -只更新 `docs/sync-research.md` 和文档索引。提交前不改 runtime 代码。 - -### Phase 2:最小测试 - -先写失败测试,覆盖: - -- 99 个文件成功、1 个文件失败时,成功文件 digest 可推进,失败文件不推进。 -- `pullScript()` 失败不能写入成功 digest。 -- `deleteCloudScript()` 失败不能推进 digest。 -- orphan `.user.js` without `.meta.json` 跳过且保留 `scriptcat-sync.json` status。 -- syncStatus 单个 enable/sort 更新失败不能卡死整轮。 - -### Phase 3:最小实现 - -不引入 provider CAS,先修同步层状态污染: - -- 让 `pullScript()` 对真实失败向上返回失败,但坏远端单文件只阻塞该 uuid。 -- 让 `deleteCloudScript()` 返回成功/失败结果;`scriptsDelete()` 逐条 catch 并继续。 -- `syncOnceInternal()` 记录成功 uuid 和失败 uuid。 -- `updateFileDigest()` 支持基于旧 digest map + 成功 patch 的局部推进,失败文件保留旧值。 -- 有失败时 `scriptcat-sync.json` 只写安全合并结果,或跳过会污染的 uuid。 - -### Phase 4:capabilities 和 typed error - -新增最小能力描述,而不是把冲突策略塞进 filesystem: - -```ts -type FileSystemCapabilities = { - supportsAtomicCompareAndSwap: boolean; - supportsCreateOnly: boolean; - supportsConditionalDelete: boolean; -}; -``` - -错误分类建议: - -```ts -type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; -``` - -本分支当前已实现 capabilities、provider typed error 的关键路径、同步层 `SyncErrorKind` 日志分类(包括 `syncOnce` per-file 失败、`scriptInstall` 排队 push 失败、`scriptsDelete` 单项删除失败),以及读类和受条件保护写/删的 transient 有限 retry。 - -### Phase 5:provider 条件操作 follow-up - -分 provider 小步提交: - -- WebDAV / S3 / OneDrive:已优先用 If-Match / ETag,并有条件写/删测试。 -- Dropbox:request 层 typed error 已落地,覆盖 `error_summary` 和 structured `path_lookup` / `path` 形态;`content_hash` 作为 opaque digest 保留。rev 作为 opaque CAS token 尚未实现。 -- Google Drive:仍不声明 atomic 能力,并已有 capability 测试锁定;reader path lookup miss 已补 typed notFound。若未来做 preflight,必须明确 best-effort。当前 `nothen=true` raw `Response` 路径只用于 read/delete,request 层会在 401 后刷新 token 并返回重试后的 `Response`。 -- OneDrive:read/delete 使用 `nothen=true` raw `Response` 路径;request 层覆盖 401 token refresh,upload session 不带 bearer token 的路径保持原有语义。 -- Baidu:不声明 atomic 能力已有 capability 测试锁定;HTTP 429/5xx 已补 typed rateLimit/retryable;reader filemetas errno/空列表已补 typed notFound;只把明确 file-exists errno 判 conflict 已落地;md5 preflight 尚未实现,且只能标记 best-effort。 -- Zip:保持简单,不参与云端 CAS。 - -### Phase 6:重试和通知 - -- transient 429/5xx 有限 retry/backoff 已在 `LimiterFileSystem` 落地,范围限于读类操作和受条件保护的写/删。 -- install/delete 触发路径优先日志和下轮 sync 兜底,最终失败再聚合通知。 -- 通知包含失败数量和首个错误类型,不逐文件弹。 - -## 剩余 rollout checklist - -后续提交应继续保持小步、可回滚,并按“测试先行、实现随后、文档同步”的顺序推进。 - -### 必须继续验证 - -1. `scriptcat-sync.json` 真实 provider 竞态:当前已有重新读取合并逻辑,仍需要真实 WebDAV/S3/OneDrive 环境手工验证。 -2. 旧数据兼容回归:旧 `.user.js` + `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须持续可读。 -3. Google Drive / Baidu best-effort 风险:仍需手工确认不会被误标成 atomic CAS。 - -### 已完成的本地验证 commit - -1. `✅ test(fs): cover non-atomic provider capabilities` - - 锁定 Google Drive / Dropbox / Baidu 不声明 atomic/create-only/conditional delete。 -2. `fix(sync/fs): address remaining provider verification gaps` - - Google Drive reader path lookup miss 转 typed notFound。 - - Baidu reader filemetas errno/空列表转 typed notFound。 - - 不为 Google Drive / Baidu 添加伪 atomic 语义。 - -### 已完成的验证记录 commit - -1. `docs(sync): record manual verification result` - - 已记录本地 unit/type/lint/build 验证结果。 - - 真实 provider 验证需要 OAuth/账号和云端夹具;未执行的路径明确列为未验证。 -2. `docs(sync): record real provider verification` - - 已记录真实 provider 验证未执行的阻塞条件和恢复条件。 - -### 已完成的 provider finding commit - -1. `fix(sync/fs): address real provider verification findings` - - Baidu HTTP 429 转 typed rateLimit。 - - Baidu HTTP 5xx 转 typed retryable。 - - 保持 Baidu 非 atomic 能力声明不变。 - -### Rollout 状态 - -本地可验证范围已完成:同步层 per-file best-effort、旧数据兼容、provider capabilities、关键 typed error、受保护写/删 retry、Baidu/Google Drive 非 atomic 锁定均已有测试或文档记录。 - -真实 provider rollout 仍需凭据环境:WebDAV/S3/OneDrive 的真实 ETag/If-Match mismatch、Dropbox structured conflict、Google Drive/Baidu best-effort race 和 OAuth 刷新路径仍必须在真实账号中验证后再宣称生产完成。 - -### 暂不进入本轮 - -1. Dropbox `rev` CAS:需要独立设计,不能把 `content_hash` 当 rev。 -2. Google Drive / Baidu atomic CAS:没有原生条件写时只能 best-effort preflight。 -3. `tombstone_digest`:没有 GC 设计前不新增。 -4. 通知聚合 UI:先保留日志和定时同步兜底,避免扩大 UI / i18n 范围。 -5. 既有 React hooks lint warning:与同步修复无关。 - -## 手工验证路径 - -手工验证应遵循 [`VERIFICATION.md`](./VERIFICATION.md):先跑 cheap signals,再用 `e2e/scratch/` 一次性脚本加载真实 `dist/ext`,验证完删除 scratch 脚本。不要为了本同步修复新增永久 E2E。 - -建议按以下云目录夹具逐项验证: - -1. 旧格式完整脚本:云端只有 `.user.js` 和旧 `.meta.json`,无新增字段;预期可正常 pull/install,`file_digest` 写入 provider 原生 digest。 -2. 旧 `file_digest`:本地 storage 只有 `{ "uuid.user.js": "digest" }` string map;预期 push/delete 条件参数只在 provider 声明能力且旧 digest 存在时传递。 -3. 旧 `scriptcat-sync.json`:缺少未来字段或缺少 `status.scripts`;预期不崩溃,写回仍保留可读 status。 -4. orphan `.user.js`:云端只有 `.user.js`;预期跳过,不删除云端文件,不清空对应远端 status。 -5. 单文件 pull 失败:让一个 `.meta.json` 损坏或源码无法解析;预期只阻塞该 uuid,其他脚本继续同步,失败文件 digest 不推进。 -6. 单文件 delete 失败:让 provider 对某个 uuid 删除返回 412/429/5xx;预期后续 uuid 继续处理,失败 uuid digest 保留,日志含 `errorKind`。 -7. `scriptcat-sync.json` 并发状态:两端分别更新不同脚本 enable/sort;预期写回前合并远端最新状态,不覆盖另一端较新 status。 - -每次手工验证记录: - -```text -provider: -initial cloud files: -local scripts: -action: -observed logs: -file_digest after: -scriptcat-sync.json after: -result: -``` - -### 当前验证记录 - -本地可运行验证: - -- `pnpm test --run src/app/service/service_worker/synchronize.test.ts packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts packages/filesystem/dropbox/dropbox.test.ts packages/filesystem/onedrive/onedrive.test.ts packages/filesystem/s3/s3.test.ts packages/filesystem/webdav/webdav.test.ts packages/filesystem/limiter.test.ts packages/filesystem/filesystem.test.ts` - - 9 个 test files,189 个 tests 通过。 -- `pnpm test --run packages/filesystem/baidu/baidu.test.ts packages/filesystem/googledrive/googledrive.test.ts` - - 修正 Baidu test fixture 类型后重跑,2 个 test files,27 个 tests 通过。 -- `pnpm run typecheck` - - 通过。 -- `pnpm run lint` - - 通过,仍有 12 个既有 React hooks warning,均不在同步修复范围。 -- `pnpm run build` - - production build 通过;Rspack 输出既有 bundle size、CSS order、Monaco dynamic require warnings。 - -尚未执行的真实 provider 验证: - -- WebDAV / S3 / OneDrive 真实 If-Match / ETag mismatch。 -- Dropbox 真实 structured conflict / rate-limit。 -- Google Drive 真实 path cache stale、同名文件和 best-effort race。 -- Baidu 真实 `filemetas` 缺失、errno 分类和覆盖写行为。 - -未执行原因:当前本地环境没有可用于 OAuth/云端账号的 provider 凭据,也没有预置云端夹具目录;不能把 mock/unit/build 结果宣称为真实云端验证。 - -### 真实 provider 验证记录 - -本轮未能执行真实云端验证。阻塞条件是本地环境没有可用的 provider OAuth 凭据、S3/WebDAV 测试账号、Dropbox/Google Drive/Baidu/OneDrive 授权会话,也没有预置的云端夹具目录。由于同步修复涉及生产数据兼容,不能用 mock 响应替代真实 provider 结论。 - -真实验证恢复条件: - -1. 每个 provider 准备独立测试目录,目录内只放一次性夹具数据。 -2. 预置旧格式 `.user.js`、`.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json`。 -3. 至少两端配置同一 provider,用于验证 `scriptcat-sync.json` 合并写和 per-file best-effort。 -4. 对 WebDAV/S3/OneDrive 准备 ETag/If-Match mismatch 场景。 -5. 对 Google Drive/Baidu 明确记录 best-effort 行为,不把 preflight 当 atomic CAS。 -6. 每次验证后保存 provider、初始云端文件、操作、日志、`file_digest`、`scriptcat-sync.json` 和结果。 - -## 测试矩阵 - -### 同步任务级别 - -1. 99 个 push 成功,1 个 push 失败。 -2. 99 个 pull 成功,1 个 pull 失败。 -3. 删除 10 个脚本,其中 1 个删除失败,后续 9 个仍处理。 -4. syncStatus 单个 `enableScript` 或 sort 更新失败。 -5. `scriptcat-sync.json` 写入失败。 -6. `file_digest` 更新失败。 -7. install 触发 push transient 失败,不污染 digest。 - -### 数据兼容 - -1. 旧 `.meta.json` 无新增字段。 -2. 旧 `file_digest` 只有 digest string。 -3. 旧 `scriptcat-sync.json` 无新增字段。 -4. 云端只有 `.user.js`。 -5. 云端只有 `.meta.json` tombstone。 -6. 云端 `.user.js` 和 `.meta.json` 更新时间不一致。 - -### 并发冲突 - -1. 两台设备同时修改同一脚本。 -2. 一台删除,另一台修改。 -3. 一台新增,另一台同时新增同 uuid。 -4. 一台更新 status,另一台更新 code。 -5. 远端文件在 list 和 write 之间变化。 - -### provider - -1. WebDAV ETag mismatch。 -2. S3 ETag mismatch。 -3. OneDrive If-Match mismatch。 -4. Dropbox typed conflict。 -5. Google Drive best-effort preflight race。 -6. Baidu errno 分类。 -7. Limiter 对 read 类 transient 错误重试;只对受条件保护的 write/delete transient 错误重试,普通 write/delete 不重复非幂等操作。 - -## 风险清单 - -1. 把单文件失败升级为整轮失败,会让大批量同步重复执行并制造自冲突。 -2. 把失败文件 digest 写成成功,会永久隐藏失败。 -3. 覆盖式写 `scriptcat-sync.json` 会丢掉其他设备状态。 -4. provider 原生 digest 与本地 md5 混用会导致误判变更。 -5. Google Drive 同名文件和 path cache 会导致读写非预期文件。 -6. Baidu/Google Drive preflight 不是 atomic,不能承诺完全消除 TOCTOU。 -7. Dropbox 字符串匹配错误脆弱,API 文案变化会破坏分类。 -8. tombstone 状态若无 GC,会让删除收敛和 status 合并越来越复杂。 -9. 写路径 retry 若没有幂等保护,可能重复创建或覆盖。 -10. 过早改所有 provider 会让 PR 过大,难以 review 和回滚。 - -## 暂不建议实现的内容 - -1. 不建议一次性把 PR #1439 全量合并。 -2. 不建议把整个同步 round 改成 all-or-nothing。 -3. 不建议让 filesystem 包承担业务冲突策略。 -4. 不建议新增独立 `tombstone_digest`,除非同时设计 GC。 -5. 不建议把 Google Drive `fileId:version` 暴露成通用 version。 -6. 不建议在没有 typed error 前按字符串大规模分类冲突。 -7. 不建议没有测试就修改 Google Drive / Baidu 删除逻辑。 - -## 最小可接受修复 - -第一轮 runtime 修复应只做到: - -1. `pullScript()` 和 `deleteCloudScript()` 的失败可被上层识别。 -2. `syncOnceInternal()` 按文件收集结果。 -3. 成功文件推进 digest,失败文件不推进。 -4. orphan `.user.js` 跳过且保留 status。 -5. syncStatus 单项失败不阻塞整轮。 -6. 新增测试证明不会再出现“99 成功 + 1 失败导致 100 个都重复/污染”的问题。 - -## 验证要求 - -每次提交前记录: - -```text -repo: -branch: -commit: -changed files: -tests run: -tests failed: -tests skipped: -known risks: -``` - -如果测试无法运行,必须写明原因,不能写“已验证”。 From eac7b8d9fe3c7106f895a12410fbd10dd687d78b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:55:42 +0900 Subject: [PATCH 073/103] =?UTF-8?q?=F0=9F=93=84=20docs(sync):=20document?= =?UTF-8?q?=20cloud=20sync=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CLOUD-SYNC.md | 374 ++++++++++++++++++++++++++++++++++++++++ docs/DOC-MAINTENANCE.md | 3 +- docs/README.md | 2 +- 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 docs/CLOUD-SYNC.md diff --git a/docs/CLOUD-SYNC.md b/docs/CLOUD-SYNC.md new file mode 100644 index 000000000..a1588ae8b --- /dev/null +++ b/docs/CLOUD-SYNC.md @@ -0,0 +1,374 @@ +# 云同步实现说明 + +本文是 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):统一文件系统接口、条件操作参数、provider capability。 +- [`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 没有声明原子能力时,同步层不能传条件写删参数,也不能把 best-effort 行为描述成 CAS。 +5. 旧 `.user.js`、旧 `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须继续可读。 +6. filesystem 包只负责暴露能力、执行文件操作、抛 typed error;同步冲突策略属于 `SynchronizeService`。 + +## 同步目录和文件 + +同步目录由云同步配置决定,业务上使用 `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,不能写入“看起来成功”的新值。 + +`updateFileDigest()` 会重新 `fs.list()` 构造新 map;对于刚 push 但 provider list 暂时不可见的文件,才使用 `pushScript()` 返回的本地 md5 作为兜底。 + +## 同步入口和队列 + +`SynchronizeService` 使用 `SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"` 串行化同步任务。以下入口都会进入同一队列: + +- 配置启用后触发的 `syncOnce()`。 +- 定时同步,Chrome alarm 名称为 `cloudSync`,周期为 60 分钟。 +- 非 sync 来源安装脚本后的 `scriptInstall()`。 +- 非 sync 来源删除脚本后的 `scriptsDelete()`。 + +串行队列很重要:安装、删除和定时同步都可能写同一批云端文件,如果并发执行,会扩大覆盖和 digest 污染风险。 + +## `syncOnceInternal()` 流程 + +`syncOnceInternal(syncConfig, fs)` 是主同步流程: + +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`。 +9. 如果启用 `syncStatus`,合并本地状态、初始云端状态、写回前重新读取的最新云端状态。 +10. 调用 `updateFileDigest()`,成功文件推进 digest,失败文件保留旧 digest。 + +### 决策规则 + +本地脚本存在、云端脚本也存在: + +- 如果 `.user.js` digest 与 `file_digest` 一致,跳过。 +- 如果本地脚本更新时间更晚,push 本地脚本。 +- 如果云端更新时间更晚,pull 云端脚本。 +- 如果云端缺 `.meta.json`,push 本地脚本补齐 meta。 + +本地脚本存在、云端只有 `.meta.json`: + +- 如果 meta 是 tombstone,本地删除脚本。 +- 如果 meta 不是 tombstone,删除无效 meta 并重新 push 本地脚本。 + +本地脚本不存在、云端 `.user.js` 和 `.meta.json` 都存在: + +- pull 并安装云端脚本。 + +本地脚本不存在、云端只有 `.user.js`: + +- 视为 orphan cloud script,跳过。 +- 不删除、不覆盖、不清空对应远端 status。 + +遍历结束后,剩余只存在于本地的脚本会 push 到云端。 + +## push / pull / delete + +### `pushScript()` + +`pushScript()` 写两个文件: + +- `.user.js` +- `.meta.json` + +`modifiedDate` 使用 `script.updatetime || script.createtime || Date.now()`。 + +写入参数由 `buildPushCreateOptions()` 决定: + +- 云端文件不存在且 provider 支持 `supportsCreateOnly`:传 `createOnly: true`。 +- 云端文件存在且 provider 支持 `supportsAtomicCompareAndSwap`,并且 `file_digest` 中有旧 digest:传 `expectedDigest`。 +- provider 未声明能力时,只传 `modifiedDate`,保持旧覆盖语义。 + +`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" })` 写入本地。 + +真实失败会向上抛出,由 `syncOnceInternal()` 作为单文件失败处理。不要在 `pullScript()` 内吞掉错误,否则会重新引入 digest 污染。 + +### `deleteCloudScript()` + +删除云端脚本时: + +- 先删除 `.user.js`。 +- 如果 `syncDelete` 为 true,写 tombstone meta。 +- 如果 `syncDelete` 为 false,删除 `.meta.json`。 + +删除参数由 `buildDeleteOptions()` 决定: + +- provider 支持 `supportsConditionalDelete` 且 `file_digest` 有旧 digest 时,传 `expectedDigest`。 +- 否则走普通 delete。 + +失败会向上抛出。`scriptsDelete()` 必须逐条 catch,保证批量删除中一个 uuid 失败不影响后续 uuid。 + +## 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、可重试错误 | +| `FileSystemError.notFound` | `stale_snapshot` | list 到操作之间远端消失或缓存过期 | +| `FileSystemError.auth` 或 `WarpTokenError` | `fatal` | 授权失败 | +| error message 包含 `unsupported` | `unsupported` | provider 不支持 | +| 其他 | `fatal` | 未分类错误 | + +错误分类主要用于日志、保留 digest、后续 retry 策略和 review 判断。它不是用户可见错误协议。 + +## retry 策略 + +`LimiterFileSystem` 对不同操作使用不同重试策略: + +- 会重试:`verify`、`open`、`read`、`openDir`、`list`、`getDirUrl`。 +- 会重试:带 `expectedDigest` 或 `createOnly` 的 `writer.write()`。 +- 会重试:带 `expectedDigest` 的 `delete()`。 +- 不重试:普通 `create`、`createDir`、普通 `writer.write()`、普通 `delete()`。 + +原因:没有条件保护的写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 + +## provider 差异 + +| Provider | digest 来源 | atomic CAS | create-only | conditional delete | 关键实现 | +| --- | --- | --- | --- | --- | --- | +| WebDAV | `etag` | 支持 | 支持 | 支持 | 写入用 `If-Match`;create-only 用 `overwrite: false`;删除用 `If-Match` | +| S3 | `ETag` 去引号 | 支持 | 支持 | 支持 | 写入用 `if-match`;create-only 用 `if-none-match: *`;删除用 `if-match` | +| OneDrive | `eTag` | 支持 | 支持 | 支持 | simple upload / upload session 带 `If-Match` 或 `If-None-Match`;删除带 `If-Match` | +| Google Drive | `md5Checksum` | 不支持 | 不支持 | 不支持 | 先按路径查 fileId,再 PATCH 或 POST;path cache 可能 stale | +| Dropbox | `content_hash` | 不支持 | 不支持 | 不支持 | 先 `exists()`,存在 overwrite,不存在 add;未暴露 rev CAS | +| Baidu | `md5` | 不支持 | 不支持 | 不支持 | precreate/upload/create,`rtype=3` 覆盖;HTTP 429/5xx typed | +| Zip | 空或 zip metadata | 不支持 | 不支持 | 不支持 | 备份用途,不参与云端 CAS | + +### WebDAV + +WebDAV 是原生条件能力 provider。维护时注意: + +- `capabilities` 三项均为 true。 +- ETag 写入和删除时需要保持引号,`quoteETag()` 会补齐。 +- create-only 依赖 `putFileContents(..., { overwrite: false })`。 +- `putFileContents()` 返回 false 时,在 create-only 场景转 typed conflict。 +- 删除 404 视为幂等成功。 + +### S3 + +S3 是原生条件能力 provider。维护时注意: + +- `capabilities` 三项均为 true。 +- list 返回的 `ETag` 会去掉引号作为 digest。 +- 写入 `expectedDigest` 时发送 `if-match`。 +- create-only 发送 `if-none-match: *`。 +- 删除 `expectedDigest` 时发送 `if-match`。 +- `PreconditionFailed` / 412 转 typed conflict。 +- `NoSuchKey` 删除视为成功。 + +### OneDrive + +OneDrive 是原生条件能力 provider。维护时注意: + +- `capabilities` 三项均为 true。 +- list 使用 `eTag` 作为 digest。 +- 空内容走 simple PUT,非空内容走 upload session。 +- 条件写会把 `If-Match` / `If-None-Match` 放到 simple PUT 或 upload session 创建请求。 +- upload session URL 不带 bearer token,request 层保留这个特殊路径。 +- read/delete 使用 raw `Response` 路径,需要手动转 typed error。 + +### Google Drive + +Google Drive 当前不声明 atomic 能力。维护时注意: + +- digest 来自 `md5Checksum`。 +- 目录和文件通过 appDataFolder + path cache 查 fileId。 +- 写入是“先查同名文件,再 PATCH 或 POST”,不是 atomic CAS。 +- 删除是“先查 fileId,再 DELETE”,不是 conditional delete。 +- reader path lookup miss 已转 typed notFound。 +- path cache stale 时 writer/list 会清缓存并重试一次。 +- 不要把 `fileId` 或 `fileId:version` 包装成通用 version 语义。 + +### Dropbox + +Dropbox 当前不声明 atomic 能力。维护时注意: + +- digest 来自 `content_hash`,必须当作 opaque provider digest。 +- 写入是 `exists()` 后 overwrite 或 add,存在 TOCTOU。 +- 当前没有使用 Dropbox rev CAS。 +- request 层已解析 `error_summary` 和 structured `path_lookup` / `path`。 +- raw download 429 会转 typed rateLimit。 +- 删除 not_found 视为幂等成功。 + +### Baidu + +Baidu 当前不声明 atomic 能力。维护时注意: + +- digest 来自 `md5`。 +- 写入流程是 precreate、upload、create,`rtype=3` 覆盖。 +- 只把明确 file-exists errno 判为 conflict。 +- HTTP 429 转 typed rateLimit。 +- HTTP 5xx 转 typed retryable。 +- `filemetas` errno 或空列表转 typed notFound。 +- request 显式 `credentials: "omit"`,不要重新依赖全局 DNR 规则。 +- Baidu 没有被声明为 create-only 或 CAS provider。 + +### Zip + +ZipFileSystem 主要服务备份/导出,不应接入云同步 CAS 语义。它不声明 capabilities。 + +## 生产兼容要求 + +改同步逻辑前必须检查: + +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 覆盖。 +8. 非 atomic provider 是否被错误声明 capability。 + +## 不要做的事 + +- 不要把整个 sync round 改成 all-or-nothing。 +- 不要在 `pullScript()` 或 `deleteCloudScript()` 内 catch 后吞掉真实失败。 +- 不要让失败文件推进 digest。 +- 不要在无法读取远端 `scriptcat-sync.json` 时覆盖写回。 +- 不要把 Google Drive / Baidu 的 preflight 当 atomic CAS。 +- 不要把 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. 条件写删只在 provider 声明能力时使用。 +8. Google Drive / Dropbox / Baidu 明确保持非 atomic capability。 + +真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 + diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index c1f434e15..0ba399b9d 100644 --- a/docs/DOC-MAINTENANCE.md +++ b/docs/DOC-MAINTENANCE.md @@ -29,6 +29,7 @@ Aspirational / feature-branch content belongs in that branch's docs, or is clear | [`DEVELOP.md`](./DEVELOP.md) | The concrete "how": commands, structure, style, testing, i18n, commit/PR. | | [`VERIFICATION.md`](./VERIFICATION.md) | Lightweight end-to-end functional verification — throwaway scratch scripts driving the real built extension. | | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | Deep internals: process model, message passing, service/data layers, GM API, execution, build. | +| [`CLOUD-SYNC.md`](./CLOUD-SYNC.md) | Cloud sync internals: sync files, digest/status semantics, provider differences, error classification, retry policy. | | [`translation/README.md`](./translation/README.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. | @@ -97,7 +98,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/DEVELOP.md docs/VERIFICATION.md docs/ARCHITECTURE.md docs/DOC-MAINTENANCE.md docs/translation/README.md; do +for doc in AGENTS.md docs/README.md docs/DEVELOP.md docs/VERIFICATION.md docs/ARCHITECTURE.md docs/CLOUD-SYNC.md docs/DOC-MAINTENANCE.md docs/translation/README.md; do grep -oE '\]\(([^)]+)\)' "$doc" | sed -E 's/^\]\(|\)$//g' | grep -vE '^https?:|^#' | while read -r link; do target="$(dirname "$doc")/${link%%#*}" [ -e "$target" ] && echo "ok $doc → $link" || echo "BROKEN $doc → $link" diff --git a/docs/README.md b/docs/README.md index 363cee575..02d25d449 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ | [`DEVELOP.md`](./DEVELOP.md) | 开发规范:命令、目录结构、编码风格、UI/主题、测试机制、i18n、提交/PR 流程。**写代码前先读。** | | [`VERIFICATION.md`](./VERIFICATION.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例)。**验证改动是否真正跑通时读。** | | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 内部原理深入:多进程模型、消息传递、服务/数据层、GM API、脚本执行、构建管线。 | -| [`sync-research.md`](./sync-research.md) | 云同步一致性研究:现有同步架构、PR #1439 讨论整理、生产兼容原则、分阶段修复计划和测试矩阵。 | +| [`CLOUD-SYNC.md`](./CLOUD-SYNC.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | ## 翻译 / Translation From 293682211864fb901c51607eb7f2566ef12034d6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:59:46 +0900 Subject: [PATCH 074/103] AI Audit 0617 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R: final review 0002 — all R-0001 findings verified, Approved K: record response 0001 and update status (all R-0001 findings addressed) K: fix mergeScriptcatSyncStatus resurrection bug; add missing deletion test (R-0001-01) R: review commits after 5478168 — 4 findings (High: merge resurrection, Med: quoteETag dup, Med: OneDrive If-Match unquoted, Low: WebDAV untyped write error) Co-Authored-By: Claude Sonnet 4.6 --- packages/filesystem/onedrive/onedrive.test.ts | 4 +- packages/filesystem/onedrive/onedrive.ts | 3 +- packages/filesystem/onedrive/rw.ts | 3 +- packages/filesystem/onedrive/utils.ts | 1 + packages/filesystem/s3/rw.ts | 5 +- packages/filesystem/s3/s3.ts | 5 +- packages/filesystem/s3/utils.ts | 3 + packages/filesystem/webdav/rw.ts | 10 ++- packages/filesystem/webdav/utils.ts | 1 + packages/filesystem/webdav/webdav.ts | 3 +- .../service_worker/synchronize.test.ts | 89 +++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 5 +- 12 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 packages/filesystem/onedrive/utils.ts create mode 100644 packages/filesystem/s3/utils.ts create mode 100644 packages/filesystem/webdav/utils.ts diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index c5f4a06fa..664062325 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -184,7 +184,7 @@ describe("OneDriveFileSystem", () => { { method: "DELETE", headers: expect.objectContaining({ - "If-Match": "abc123", + "If-Match": '"abc123"', }), }, true @@ -439,7 +439,7 @@ describe("OneDriveFileSystem", () => { await writer.write("abc"); const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers; - expect(headers.get("If-Match")).toBe("abc123"); + expect(headers.get("If-Match")).toBe('"abc123"'); }); it("createOnly 写入应当将 If-None-Match 传给 upload session 并使用 fail 语义", async () => { diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index e7529e1fc..0ed1867ad 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -4,6 +4,7 @@ import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWr import type FileSystem from "../filesystem"; import { joinPath } from "../utils"; import { OneDriveFileReader, OneDriveFileWriter } from "./rw"; +import { quoteETag } from "./utils"; export default class OneDriveFileSystem implements FileSystem { readonly capabilities = { @@ -194,7 +195,7 @@ export default class OneDriveFileSystem implements FileSystem { }; if (opts?.expectedDigest) { config.headers = { - "If-Match": opts.expectedDigest, + "If-Match": quoteETag(opts.expectedDigest), }; } const resp = await this.request( diff --git a/packages/filesystem/onedrive/rw.ts b/packages/filesystem/onedrive/rw.ts index 7c6158b38..16f2e6353 100644 --- a/packages/filesystem/onedrive/rw.ts +++ b/packages/filesystem/onedrive/rw.ts @@ -2,6 +2,7 @@ import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto"; import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import type OneDriveFileSystem from "./onedrive"; +import { quoteETag } from "./utils"; export class OneDriveFileReader implements FileReader { file: FileInfo; @@ -113,7 +114,7 @@ export class OneDriveFileWriter implements FileWriter { private buildConditionalHeaders(): Headers | undefined { if (this.opts?.expectedDigest) { const headers = new Headers(); - headers.set("If-Match", this.opts.expectedDigest); + headers.set("If-Match", quoteETag(this.opts.expectedDigest)); return headers; } if (this.opts?.createOnly) { diff --git a/packages/filesystem/onedrive/utils.ts b/packages/filesystem/onedrive/utils.ts new file mode 100644 index 000000000..2028bdc15 --- /dev/null +++ b/packages/filesystem/onedrive/utils.ts @@ -0,0 +1 @@ +export const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index 87b11749d..2cc6c9d6e 100644 --- a/packages/filesystem/s3/rw.ts +++ b/packages/filesystem/s3/rw.ts @@ -1,10 +1,7 @@ import type { S3Client } from "./client"; import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { createS3FileSystemError } from "./error"; - -function quoteETag(digest: string): string { - return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; -} +import { quoteETag } from "./utils"; /** * S3 文件读取器 diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index fe9f76459..d45f46698 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -7,6 +7,7 @@ import { joinPath } from "../utils"; import { S3FileReader, S3FileWriter } from "./rw"; import { WarpTokenError } from "../error"; import { createS3FileSystemError } from "./error"; +import { quoteETag } from "./utils"; // ---- ListObjectsV2 XML 解析 ---- @@ -26,10 +27,6 @@ const xmlParser = new XMLParser({ isArray: (name) => name === "Contents", }); -function quoteETag(digest: string): string { - return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; -} - /** 从 ListObjectsV2 XML 响应中解析对象列表 */ function parseListObjectsV2(xml: string): ListObjectsV2Result { const parsed = xmlParser.parse(xml); diff --git a/packages/filesystem/s3/utils.ts b/packages/filesystem/s3/utils.ts new file mode 100644 index 000000000..641ec18f6 --- /dev/null +++ b/packages/filesystem/s3/utils.ts @@ -0,0 +1,3 @@ +export function quoteETag(digest: string): string { + return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; +} diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index c3c2d43f9..337b70a6c 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -2,8 +2,7 @@ import type { WebDAVClient } from "webdav"; import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { FileSystemError } from "../error"; import { createWebDAVFileSystemError } from "./error"; - -const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); +import { quoteETag } from "./utils"; export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -70,7 +69,12 @@ export class WebDAVFileWriter implements FileWriter { conflict: true, }); } - throw new Error("write error"); + throw new FileSystemError({ + provider: "webdav", + message: "WebDAV write failed", + status: 500, + retryable: false, + }); } } diff --git a/packages/filesystem/webdav/utils.ts b/packages/filesystem/webdav/utils.ts new file mode 100644 index 000000000..2028bdc15 --- /dev/null +++ b/packages/filesystem/webdav/utils.ts @@ -0,0 +1 @@ +export const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index db8440954..1da40aa22 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -7,6 +7,7 @@ import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; import { WarpTokenError } from "../error"; import { createWebDAVFileSystemError } from "./error"; import type { FileDeleteOptions } from "../filesystem"; +import { quoteETag } from "./utils"; // 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) // 全局单次注册 @@ -25,8 +26,6 @@ const initWebDAVPatch = () => { }); }; -const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); - export default class WebDAVFileSystem implements FileSystem { readonly capabilities = { supportsAtomicCompareAndSwap: true, diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 3fb1a7e1c..33e9fc6fa 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -484,6 +484,95 @@ describe("SynchronizeService", () => { 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: 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, + { 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 + ); + + 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); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index c44d9aaee..226bd4128 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -669,7 +669,10 @@ export class SynchronizeService { candidate.sort === initial.sort && candidate.updatetime === initial.updatetime; if (candidateOnlyPreservedInitial) { - merged[uuid] = latest || candidate; + // 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) { From ad8a493977f42d5f9f8e6272cbd447e683654a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 11:55:56 +0800 Subject: [PATCH 075/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BA=91=E5=90=8C=E6=AD=A5=E5=88=86=E7=89=87=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?/=E6=9C=AC=E5=9C=B0=E7=BC=96=E8=BE=91=E6=BC=8F=E4=BC=A0?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E6=B0=B8=E4=B9=85=20CAS=20=E5=86=B2?= =?UTF-8?q?=E7=AA=81=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #2 scriptInstall 编辑已同步脚本改用 CAS 覆盖而非 create-only: buildPushCreateOptions 仅在"本轮未列到且本地无 digest 记录"(真正首次上传) 才用 create-only;已同步过的脚本走 CAS,避免 create-only 撞已存在云端文件必然 412。 - #3 分片上传(.user.js 成功/.meta.json 失败)不再永久 CAS 冲突: pushScript 抛 PushScriptPartialError 带出已成功文件;syncOnce 与 scriptInstall 只保留失败文件旧 digest、推进成功文件的云端最新 digest,成功文件 digest 不再永久变旧。 - #1 digest 相等时仍按本地更新时间/‎.meta.json 齐全判定: 早退仅当 .meta.json 齐全、digest 一致且本地不比云端新时才跳过;否则本地编辑需上传、 云端缺 meta 需补传。修复本地编辑永久不上云,并避免推进成功 digest 后漏补 meta 成孤儿。 补 5 个测试:#2 CAS 覆盖、#1 本地编辑推送、pushScript 带出成功文件、新脚本 meta 失败下一轮补传、 scriptInstall 分片失败后 syncOnce 自愈不永久 412。 --- .../service_worker/synchronize.test.ts | 304 +++++++++++++++++- src/app/service/service_worker/synchronize.ts | 75 ++++- 2 files changed, 367 insertions(+), 12 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 6e28a4c19..c37572d95 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -440,7 +440,8 @@ describe("SynchronizeService", () => { size: 1, digest: "script-digest", createtime: 1, - updatetime: 1, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, }, { name: "status-uuid.meta.json", @@ -616,7 +617,8 @@ describe("SynchronizeService", () => { size: 1, digest: "script-digest", createtime: 1, - updatetime: 1, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, }, { name: "status-uuid.meta.json", @@ -720,7 +722,8 @@ describe("SynchronizeService", () => { size: 1, digest: "ok-digest", createtime: 1, - updatetime: 1, + // 云端文件与本地同版本(digest 一致、更新时间不早于本地),仅同步状态差异,不触发文件推送 + updatetime: 200, }, { name: "ok.meta.json", @@ -2335,4 +2338,299 @@ console.log("ok");` process.off("unhandledRejection", unhandled); } }); + + it("scriptInstall 对已同步过的脚本改用 CAS 而非 create-only 上传", async () => { + // 编辑一个已经同步到云端的脚本会以 upsertBy=user 再次触发 installScript。 + // 云端文件已存在,此时若仍用 create-only 上传必然 412 冲突,本地编辑永远上不了云。 + // 正确行为:凭本地已存的云端 digest 做 CAS 覆盖(expectedDigest),不带 createOnly。 + const createCalls: Array<{ name: string; opts: any }> = []; + const fs = createFs({ + capabilities: { supportsCreateOnly: true, supportsAtomicCompareAndSwap: true }, + 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); + // 该脚本此前已同步过,本地记录了它的云端 digest + await (service as any).storage.set("file_digest", { + "u1.user.js": "etag-user", + "u1.meta.json": "etag-meta", + }); + + 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?.createOnly).toBeUndefined(); + expect(userJs?.opts?.expectedDigest).toBe("etag-user"); + expect(metaJson?.opts?.createOnly).toBeUndefined(); + expect(metaJson?.opts?.expectedDigest).toBe("etag-meta"); + }); + + 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: 5 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm1", createtime: 1, updatetime: 5 }, + ]) + .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, 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" }), expect.anything()); + }); + + it("pushScript 在 .meta.json 写入失败时带出已成功写入的 .user.js", async () => { + // 分两次写 .user.js / .meta.json,前者成功后者失败时要让调用方知道 .user.js 已写成功, + // 才能只保留失败文件的旧 digest、推进成功文件的 digest,避免永久 CAS 冲突(#3)。 + 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({ + capabilities: { supportsCreateOnly: true }, + 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 (opts?.createOnly && existing) { + throw new FileSystemError({ provider: "webdav", message: "createOnly", status: 412, conflict: true }); + } + 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 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", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 5, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } 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); + }); + + it("编辑已同步脚本时 .meta.json 失败,下一轮同步自愈而不陷入永久 CAS 冲突", async () => { + // 端到端复现 #2+#3+#1:编辑已同步脚本 → scriptInstall 推送,.user.js 成功、.meta.json 失败。 + // 已成功的 .user.js digest 必须被推进,否则下一轮 syncOnce 拿过期 digest 做 CAS 永久 412。 + let failMeta = true; + const writes: string[] = []; + let seq = 0; + const cloud = new Map([ + ["u1.user.js", { digest: "cu1", content: "// v0", updatetime: 5 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5 }], + ]); + const conflict = () => + new FileSystemError({ provider: "webdav", message: "CAS conflict", status: 412, conflict: true }); + const cloudFs = createFs({ + capabilities: { supportsCreateOnly: true, supportsAtomicCompareAndSwap: true }, + 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 (opts?.createOnly && existing) throw conflict(); + if (opts?.expectedDigest !== undefined && (!existing || existing.digest !== opts.expectedDigest)) { + throw conflict(); + } + if (failMeta && name === "u1.meta.json") throw conflict(); + 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, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, syncStatus: false, enable: true }), + } as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v1" }) }, + all: vi.fn().mockResolvedValue([ + { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 20, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(cloudFs); + await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); + + // 第一轮:编辑触发 scriptInstall,.user.js 成功、.meta.json 失败 + await service.scriptInstall({ + script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "", updatetime: 10 } as any, + upsertBy: "user", + } as any); + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(writes).toContain("u1.user.js"); + expect(cloud.get("u1.meta.json")!.digest).toBe("cm1"); + + // 第二轮:meta 故障恢复,syncOnce 应自愈(成功文件 digest 已推进,CAS 不再永久冲突) + failMeta = false; + writes.length = 0; + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + + expect(writes).toContain("u1.user.js"); + expect(writes).toContain("u1.meta.json"); + }); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index ca3217f13..197b83e3f 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -93,6 +93,19 @@ type SyncTask = { type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; +// pushScript 分两次写 .user.js / .meta.json,前者成功后者失败时抛出本错误, +// 带出已成功写入的文件名,让调用方只保留真正失败文件的旧 digest、推进成功文件的 digest, +// 避免成功文件的 digest 永久变旧后与云端 CAS 永远不匹配(永久 412)。 +class PushScriptPartialError extends Error { + constructor( + readonly originalError: unknown, + readonly writtenFiles: string[] + ) { + super(originalError instanceof Error ? originalError.message : String(originalError)); + this.name = "PushScriptPartialError"; + } +} + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; function getScriptModifiedDate(script: PushScriptParam): number { @@ -485,11 +498,18 @@ export class SynchronizeService { ); return; } - // 过滤掉无变动的文件 - if (fileDigestMap[file.script!.name] === file.script!.digest) { + const updatetime = script.updatetime || script.createtime; + // 过滤掉无变动的文件:仅当 .meta.json 齐全、云端 digest 与上次同步记录一致、 + // 且本地更新时间不比云端新时,才认为无需处理直接跳过。 + // 否则要么本地编辑过(updatetime 更新,digest 相等只反映云端未变)需上传(#1), + // 要么云端缺 .meta.json(上一轮分片上传残留)需补传,均不能在此提前跳过。 + if ( + file.meta && + fileDigestMap[file.script!.name] === file.script!.digest && + updatetime <= file.script!.updatetime + ) { return; } - const updatetime = script.updatetime || script.createtime; // 对比脚本更新时间和文件更新时间 if (updatetime > file.script!.updatetime || !file.meta) { // 如果脚本更新时间大于文件更新时间 @@ -534,7 +554,14 @@ export class SynchronizeService { Object.assign(pushedFileDigestMap, ret.value); } else if (ret.status === "rejected") { failedSyncUuids.add(result[index].uuid); - result[index].preserveDigestFiles.forEach((name) => preserveDigestFiles.add(name)); + // 分片上传时已成功写入云端的文件(如 .user.js 成功、.meta.json 失败)不保留旧 digest, + // 让 updateFileDigest 记录其云端最新 digest,避免成功文件 digest 永久变旧、后续 CAS 永久冲突。 + const writtenFiles = ret.reason instanceof PushScriptPartialError ? ret.reason.writtenFiles : []; + 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, @@ -629,6 +656,9 @@ export class SynchronizeService { } private classifySyncError(error: unknown): SyncErrorKind { + if (error instanceof PushScriptPartialError) { + return this.classifySyncError(error.originalError); + } if (error instanceof FileSystemError) { if (error.conflict) { return "conflict"; @@ -789,6 +819,8 @@ export class SynchronizeService { name: script.name, file: filename, }); + // 记录本次已成功写入云端的文件,供部分失败时区分「成功文件」与「失败文件」 + const writtenFiles: string[] = []; try { const modifiedDate = getScriptModifiedDate(script); const w = await fs.create( @@ -799,6 +831,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, this.buildPushCreateOptions(fs, metaFilename, modifiedDate, opts.metaFile, opts.fileDigestMap) @@ -810,6 +843,7 @@ export class SynchronizeService { checkUpdateUrl: script.checkUpdateUrl, }); await meta.write(metaJson); + writtenFiles.push(metaFilename); logger.info("push script success"); return { [filename]: md5OfText(scriptCode), @@ -817,7 +851,7 @@ export class SynchronizeService { }; } catch (e) { logger.error("push script error", Logger.E(e)); - throw e; + throw new PushScriptPartialError(e, writtenFiles); } } @@ -830,13 +864,18 @@ export class SynchronizeService { ): FileCreateOptions { const capabilities = getFileSystemCapabilities(fs); const createOptions: FileCreateOptions = { modifiedDate }; - if (!existingFile) { + // CAS 的 If-Match 基准取本地记录的云端 digest(脚本上次同步/安装成功时的版本), + // 这样其他设备改动过云端文件时能正确检测冲突而不误覆盖。 + const expectedDigest = fileDigestMap?.[filename]; + // 只有确实首次上传(本轮没列到该文件、本地也无它的 digest 记录)才用 create-only 防止误覆盖他处文件; + // 已同步过的脚本(本地有 digest 记录,如编辑后经 scriptInstall 再次上传)必须走 CAS 覆盖, + // 否则 create-only 撞上已存在的云端文件必然 412,本地改动永远上不了云。 + if (existingFile === undefined && expectedDigest === undefined) { if (capabilities.supportsCreateOnly) { createOptions.createOnly = true; } return createOptions; } - const expectedDigest = fileDigestMap?.[filename]; if (expectedDigest && capabilities.supportsAtomicCompareAndSwap) { createOptions.expectedDigest = expectedDigest; } @@ -937,8 +976,26 @@ 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); + // 已同步过的脚本本地记录了其云端 digest,据此做 CAS 覆盖;没有记录才是首次上传走 create-only。 + // 缺了它,编辑已存在的云端脚本会因 create-only 而 412 冲突,本地改动永远上不了云。 + const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; + const script = params.script; + try { + const pushedFileDigestMap = await this.pushScript(fs, script, { fileDigestMap }); + await this.updateFileDigest(fs, pushedFileDigestMap); + } catch (e) { + // 部分成功(如 .user.js 成功、.meta.json 失败)时,已写成功文件的云端 digest 已更新, + // 必须记录其最新 digest、只保留失败文件的旧值,否则下一轮同步会拿过期 digest 做 CAS 永久 412。 + // 全部失败(无文件写入成功)则不动 file_digest,保持"失败不污染"。 + const writtenFiles = e instanceof PushScriptPartialError ? e.writtenFiles : []; + if (writtenFiles.length > 0) { + const preserve = new Set( + [`${script.uuid}.user.js`, `${script.uuid}.meta.json`].filter((name) => !writtenFiles.includes(name)) + ); + await this.updateFileDigest(fs, {}, preserve); + } + throw e; + } }).catch((e) => { this.logger.error("push script on install error", Logger.E(e), { errorKind: this.classifySyncError(e), From 43e3aea8c5249fcde97f3fb1fc45515f43008e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 12:10:01 +0800 Subject: [PATCH 076/103] =?UTF-8?q?=E2=9C=85=20=E8=A1=A5=20#1=20=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95=EF=BC=9A=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=BB=8F=20syncOnce=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E4=B8=8A=E4=BA=91=E4=B8=94=E7=A8=B3=E6=80=81=E4=B8=8D=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=8E=A8=E9=80=81=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index c37572d95..4e69c67a1 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -2633,4 +2633,79 @@ console.log("ok");` expect(writes).toContain("u1.user.js"); expect(writes).toContain("u1.meta.json"); }); + + it("本地编辑经 syncOnce 真实上云,且推进 digest 后下一轮不再重复推送", async () => { + // 端到端验证 #1:脚本已同步(云端 digest 与本地记录一致),随后本地编辑(updatetime 变新、 + // 但云端文件未变故 digest 仍相等)。syncOnce 必须真正把新内容 CAS 覆盖上云; + // 且推进 digest/更新时间后,下一轮应回到稳态不再重复推送。 + let seq = 0; + const cloud = new Map([ + ["u1.user.js", { digest: "cu1", content: "// v1", updatetime: 5 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5 }], + ]); + const writes: string[] = []; + const cloudFs = createFs({ + capabilities: { supportsAtomicCompareAndSwap: true }, + 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 (opts?.expectedDigest !== undefined && (!existing || existing.digest !== opts.expectedDigest)) { + throw new FileSystemError({ provider: "webdav", message: "CAS conflict", status: 412, conflict: true }); + } + 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, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v2" }) }, + all: vi.fn().mockResolvedValue([ + { + uuid: "u1", + name: "t", + origin: "", + downloadUrl: "", + checkUpdateUrl: "", + updatetime: 20, + 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" }); + + // 本地编辑(updatetime 20 > 云端 5,digest 仍相等)→ 应真正把 // v2 覆盖上云 + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(cloud.get("u1.user.js")!.content).toBe("// v2"); + + // 下一轮:digest 与更新时间已推进,稳态不再重复推送 + writes.length = 0; + await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + expect(writes).toEqual([]); + }); }); From afde8bf0b2dce98469b2e7c977b8ceae35b83926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 11:45:25 +0800 Subject: [PATCH 077/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BA=91=E7=9B=98=20provider=20=E9=94=99=E8=AF=AF=E5=88=86?= =?UTF-8?q?=E7=B1=BB=EF=BC=9ABaidu=202xx=20=E9=9D=9E=20JSON=20=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E3=80=81Dropbox=20409=20=E6=94=B6=E7=AA=84=E3=80=8150?= =?UTF-8?q?1=20=E4=B8=8D=E5=86=8D=E9=87=8D=E8=AF=95=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/baidu/baidu.test.ts | 35 ++++++++++ packages/filesystem/baidu/baidu.ts | 10 +-- packages/filesystem/baidu/error.ts | 4 +- packages/filesystem/dropbox/dropbox.test.ts | 66 +++++++++++++++++++ packages/filesystem/dropbox/dropbox.ts | 8 ++- packages/filesystem/limiter.test.ts | 2 + packages/filesystem/onedrive/onedrive.test.ts | 23 +++++++ packages/filesystem/onedrive/onedrive.ts | 4 +- packages/filesystem/s3/error.ts | 4 +- packages/filesystem/s3/s3.test.ts | 23 +++++++ packages/filesystem/webdav/error.ts | 4 +- packages/filesystem/webdav/webdav.test.ts | 22 +++++++ 12 files changed, 195 insertions(+), 10 deletions(-) diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index af36b4cf9..fac43f71f 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -86,6 +86,41 @@ describe("BaiduFileSystem", () => { }); }); + 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"); diff --git a/packages/filesystem/baidu/baidu.ts b/packages/filesystem/baidu/baidu.ts index 4c3ddd8be..dfdea8718 100644 --- a/packages/filesystem/baidu/baidu.ts +++ b/packages/filesystem/baidu/baidu.ts @@ -64,13 +64,15 @@ export default class BaiduFileSystem implements FileSystem { // 对百度网盘请求显式禁用 cookie,避免依赖全局 DNR 规则造成并发竞态 config.credentials = "omit"; const parseResponse = async (response: Response) => { - const data = await response.json().catch(() => ({ - errmsg: response.statusText || `HTTP ${response.status}`, - })); if (!response.ok) { + // 失败响应可能没有 JSON body,容忍解析失败以保留 HTTP 状态分类 + const data = await response.json().catch(() => ({ + errmsg: response.statusText || `HTTP ${response.status}`, + })); throw createBaiduFileSystemError({ ...data, httpStatus: response.status }); } - return data; + // 2xx 非 JSON(如代理返回的 HTML 页)必须报错,否则 list 会被判空导致全量覆盖云端 + return response.json(); }; return fetch(url, config) .then(parseResponse) diff --git a/packages/filesystem/baidu/error.ts b/packages/filesystem/baidu/error.ts index d5cb49bc4..ff8d3b3ec 100644 --- a/packages/filesystem/baidu/error.ts +++ b/packages/filesystem/baidu/error.ts @@ -19,6 +19,8 @@ export function createBaiduFileSystemError(data: BaiduErrorResponse): FileSystem 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", @@ -29,7 +31,7 @@ export function createBaiduFileSystemError(data: BaiduErrorResponse): FileSystem auth, notFound, rateLimit, - retryable: rateLimit || (status !== undefined && status >= 500), + retryable: rateLimit || transient, raw: data, }); } diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index a0fd65325..54411c980 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -114,6 +114,72 @@ describe("DropboxFileSystem", () => { }); }); + 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( diff --git a/packages/filesystem/dropbox/dropbox.ts b/packages/filesystem/dropbox/dropbox.ts index 538d07899..a914f60ff 100644 --- a/packages/filesystem/dropbox/dropbox.ts +++ b/packages/filesystem/dropbox/dropbox.ts @@ -51,8 +51,12 @@ 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; - const conflict = !notFound && (status === 409 || summary?.includes("path/conflict") === 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", @@ -63,7 +67,7 @@ function toDropboxFileSystemError(status: number, raw: unknown): FileSystemError conflict, rateLimit, auth, - retryable: rateLimit || status >= 500, + retryable: rateLimit || transient, raw: parsed, }); } diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts index 04c8e0fbe..65937f4c6 100644 --- a/packages/filesystem/limiter.test.ts +++ b/packages/filesystem/limiter.test.ts @@ -309,6 +309,8 @@ describe("LimiterFileSystem", () => { await expect(promise).resolves.toBeUndefined(); expect(fs.delete).toHaveBeenCalledTimes(2); + // 守卫 opts 透传:若 limiter 丢掉 expectedDigest,条件删除会退化成无条件删除 + expect(fs.delete).toHaveBeenCalledWith(file.path, { expectedDigest: "digest" }); }); it("should retry reader.read on 429", async () => { diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index 664062325..28c95cef8 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -152,6 +152,29 @@ describe("OneDriveFileSystem", () => { }); }); + 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"); diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 0ed1867ad..552c82444 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -106,6 +106,8 @@ export default class OneDriveFileSystem implements FileSystem { code === "itemAlreadyExists" || code === "PreconditionFailed"; const rateLimit = status === 429; + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + const transient = status !== undefined && [500, 502, 503, 504].includes(status); return new FileSystemError({ provider: "onedrive", @@ -116,7 +118,7 @@ export default class OneDriveFileSystem implements FileSystem { notFound, conflict, rateLimit, - retryable: rateLimit || (status !== undefined && status >= 500), + retryable: rateLimit || transient, raw, }); } diff --git a/packages/filesystem/s3/error.ts b/packages/filesystem/s3/error.ts index 78cbbb67b..e8be345f9 100644 --- a/packages/filesystem/s3/error.ts +++ b/packages/filesystem/s3/error.ts @@ -7,6 +7,8 @@ export function createS3FileSystemError(error: unknown): unknown { } 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", @@ -17,7 +19,7 @@ export function createS3FileSystemError(error: unknown): unknown { notFound: error.statusCode === 404 || error.code === "NoSuchKey" || error.code === "NoSuchBucket", conflict: error.statusCode === 409 || error.statusCode === 412 || error.code === "PreconditionFailed", rateLimit, - retryable: rateLimit || error.statusCode >= 500, + retryable: rateLimit || transient, raw: error, }); } diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 8244bdbc1..6166404c3 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -187,6 +187,29 @@ describe("S3FileSystem", () => { 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 ---- diff --git a/packages/filesystem/webdav/error.ts b/packages/filesystem/webdav/error.ts index 4c7a9a86c..8db517429 100644 --- a/packages/filesystem/webdav/error.ts +++ b/packages/filesystem/webdav/error.ts @@ -15,6 +15,8 @@ export function createWebDAVFileSystemError(error: unknown): unknown { } const rateLimit = status === 429; + // 只重试瞬时 5xx;501/505/507 等属于永久失败,重试只会空转退避 + const transient = [500, 502, 503, 504].includes(status); return new FileSystemError({ provider: "webdav", @@ -24,7 +26,7 @@ export function createWebDAVFileSystemError(error: unknown): unknown { notFound: status === 404, conflict: status === 409 || status === 412, rateLimit, - retryable: rateLimit || status >= 500, + retryable: rateLimit || transient, raw: error, }); } diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 5ac6fadd5..95b47103d 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -304,6 +304,28 @@ describe("WebDAVFileSystem", () => { 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", () => { From ac51457a5b0628eabc5b8a3172cb08f6fd216f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 11:46:56 +0800 Subject: [PATCH 078/103] =?UTF-8?q?=F0=9F=93=9D=20cloud-sync=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E6=AD=A5=E9=94=99=E8=AF=AF=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=9A=E7=9E=AC=E6=97=B6=205xx=20=E9=9B=86?= =?UTF-8?q?=E5=90=88=E3=80=81Dropbox=20409=20=E6=94=B6=E7=AA=84=E3=80=81Ba?= =?UTF-8?q?idu=202xx=20=E8=A7=A3=E6=9E=90=E3=80=81capabilities=20=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=81=87=E8=AE=BE=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index a1588ae8b..d920f1e2b 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -227,7 +227,7 @@ provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映 | 条件 | `SyncErrorKind` | 语义 | | --- | --- | --- | | `FileSystemError.conflict` | `conflict` | 条件写删失败或 provider 冲突 | -| `FileSystemError.rateLimit` 或 `retryable` | `transient` | 429、5xx、可重试错误 | +| `FileSystemError.rateLimit` 或 `retryable` | `transient` | 429、瞬时 5xx(500/502/503/504)等可重试错误 | | `FileSystemError.notFound` | `stale_snapshot` | list 到操作之间远端消失或缓存过期 | | `FileSystemError.auth` 或 `WarpTokenError` | `fatal` | 授权失败 | | error message 包含 `unsupported` | `unsupported` | provider 不支持 | @@ -246,6 +246,8 @@ provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映 原因:没有条件保护的写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 +typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501(如后端不支持条件请求)、505、507 等属于永久失败,不标记可重试,避免 limiter 空转退避。 + ## provider 差异 | Provider | digest 来源 | atomic CAS | create-only | conditional delete | 关键实现 | @@ -258,6 +260,8 @@ provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映 | Baidu | `md5` | 不支持 | 不支持 | 不支持 | precreate/upload/create,`rtype=3` 覆盖;HTTP 429/5xx typed | | Zip | 空或 zip metadata | 不支持 | 不支持 | 不支持 | 备份用途,不参与云端 CAS | +注意:WebDAV / S3 / OneDrive 的 `capabilities` 是按协议硬编码的声明,没有对具体服务器做探测。若某个 S3 兼容后端静默忽略条件头,条件写/删会退化为无条件覆盖且客户端无法察觉;对条件请求返回 501 的后端会得到不可重试的 typed 错误并在日志中标记 `fatal`。 + ### WebDAV WebDAV 是原生条件能力 provider。维护时注意: @@ -311,6 +315,7 @@ Dropbox 当前不声明 atomic 能力。维护时注意: - 写入是 `exists()` 后 overwrite 或 add,存在 TOCTOU。 - 当前没有使用 Dropbox rev CAS。 - request 层已解析 `error_summary` 和 structured `path_lookup` / `path`。 +- 只有 `path/conflict` / `path_write/conflict` 判 conflict;其余 409(无写权限、空间不足等)保留原错误语义,不能被 createDir 当"目录已存在"吞掉。 - raw download 429 会转 typed rateLimit。 - 删除 not_found 视为幂等成功。 @@ -321,8 +326,8 @@ Baidu 当前不声明 atomic 能力。维护时注意: - digest 来自 `md5`。 - 写入流程是 precreate、upload、create,`rtype=3` 覆盖。 - 只把明确 file-exists errno 判为 conflict。 -- HTTP 429 转 typed rateLimit。 -- HTTP 5xx 转 typed retryable。 +- HTTP 429 转 typed rateLimit,瞬时 5xx(500/502/503/504)转 typed retryable。 +- 2xx 非 JSON 响应(如代理返回 HTML)会报错,不能当作成功——否则 list 会被判空触发全量覆盖。 - `filemetas` errno 或空列表转 typed notFound。 - request 显式 `credentials: "omit"`,不要重新依赖全局 DNR 规则。 - Baidu 没有被声明为 create-only 或 CAS provider。 From 42c5dfda24811d7aa67b5d558cc4311647376586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 11:49:53 +0800 Subject: [PATCH 079/103] =?UTF-8?q?=E2=9C=85=20OneDrive=20=E7=A9=BA?= =?UTF-8?q?=E5=86=85=E5=AE=B9=20simple=20PUT=20=E6=9D=A1=E4=BB=B6=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=A4=B4=E6=96=AD=E8=A8=80=EF=BC=88=E8=A1=A5=20review?= =?UTF-8?q?=20=E7=9F=A9=E9=98=B5=E8=A6=86=E7=9B=96=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=89#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/onedrive/onedrive.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index 28c95cef8..474d3f657 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -432,6 +432,30 @@ describe("OneDriveFileSystem", () => { expect((requestSpy.mock.calls[0][1] as RequestInit).body).toBe(emptyBlob); }); + it("空内容 simple PUT 条件写入应携带 HTTP 引号形式的 If-Match", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); + + const writer = await fs.create("empty.txt", { expectedDigest: "abc123" }); + await writer.write(""); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const config = requestSpy.mock.calls[0][1] as RequestInit; + expect((config.headers as Headers).get("If-Match")).toBe('"abc123"'); + }); + + it("空内容 simple PUT create-only 应携带 If-None-Match: *", async () => { + const fs = new OneDriveFileSystem("/", "token"); + const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); + + const writer = await fs.create("empty.txt", { createOnly: true }); + await writer.write(""); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const config = requestSpy.mock.calls[0][1] as RequestInit; + expect((config.headers as Headers).get("If-None-Match")).toBe("*"); + }); + it("writer should keep upload session for non-empty content", async () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi From 4c56c7ed717bb8687c774c85a5a2ebdbb7f53fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 11:59:08 +0800 Subject: [PATCH 080/103] =?UTF-8?q?=F0=9F=90=9B=20=E6=9C=AC=E8=BD=AE=20lis?= =?UTF-8?q?t=20=E5=B7=B2=E7=A1=AE=E8=AE=A4=E4=B8=8D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6=E8=B5=B0=20create-only=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=94=A8=E8=BF=87=E6=9C=9F=20digest=20?= =?UTF-8?q?=E5=81=9A=20CAS=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 183 ++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 32 +-- 2 files changed, 204 insertions(+), 11 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 4e69c67a1..980dac796 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1306,6 +1306,189 @@ console.log("ok");` ); }); + it("删除无效 meta 后重传:本轮已确认不存在的文件应走 createOnly 而非过期 digest CAS", async () => { + // 云端只有普通 .meta.json(无 .user.js):删除无效 meta 后重传两个文件。 + // 本轮 list 已确认 .user.js 不存在、meta 刚被我们删除,若再拿本地记录的过期 digest + // 对不存在的文件做 If-Match 必然 412,且失败保留旧 digest 后永不自愈。 + const script = { + uuid: "remeta-uuid", + name: "remeta", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + supportsConditionalDelete: true, + }, + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "remeta-uuid.meta.json", + path: "remeta-uuid.meta.json", + size: 1, + digest: "cloud-meta", + createtime: 1, + updatetime: 1, + }, + ]) + .mockResolvedValueOnce([]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "remeta-uuid", origin: "origin" })), + }), + } 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" }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + await (service as any).storage.set("file_digest", { + "remeta-uuid.user.js": "stale-user-digest", + "remeta-uuid.meta.json": "stale-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const createCalls = vi.mocked(fs.create).mock.calls; + const userCall = createCalls.find((c) => c[0] === "remeta-uuid.user.js"); + const metaCall = createCalls.find((c) => c[0] === "remeta-uuid.meta.json"); + expect(userCall?.[1]).toMatchObject({ createOnly: true }); + expect(userCall?.[1]?.expectedDigest).toBeUndefined(); + expect(metaCall?.[1]).toMatchObject({ createOnly: true }); + expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); + }); + + it("云端缺 .meta.json 的补传:meta 走 createOnly,.user.js 仍用记录 digest 做 CAS", async () => { + const script = { + uuid: "remeta2-uuid", + name: "remeta2", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + }, + list: vi + .fn() + .mockResolvedValueOnce([ + { + name: "remeta2-uuid.user.js", + path: "remeta2-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", { + "remeta2-uuid.user.js": "old-user-digest", + "remeta2-uuid.meta.json": "stale-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const createCalls = vi.mocked(fs.create).mock.calls; + const userCall = createCalls.find((c) => c[0] === "remeta2-uuid.user.js"); + const metaCall = createCalls.find((c) => c[0] === "remeta2-uuid.meta.json"); + expect(userCall?.[1]).toMatchObject({ expectedDigest: "old-user-digest" }); + expect(metaCall?.[1]).toMatchObject({ createOnly: true }); + expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); + }); + + it("云端已确认不存在的新脚本:即便本地残留过期 digest 记录也应走 createOnly", async () => { + const script = { + uuid: "fresh-uuid", + name: "fresh", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 10, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const fs = createFs({ + capabilities: { + supportsAtomicCompareAndSwap: true, + supportsCreateOnly: true, + }, + 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: "// code" }), + }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + await (service as any).storage.set("file_digest", { + "fresh-uuid.user.js": "stale-user-digest", + "fresh-uuid.meta.json": "stale-meta-digest", + }); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + const createCalls = vi.mocked(fs.create).mock.calls; + const userCall = createCalls.find((c) => c[0] === "fresh-uuid.user.js"); + const metaCall = createCalls.find((c) => c[0] === "fresh-uuid.meta.json"); + expect(userCall?.[1]).toMatchObject({ createOnly: true }); + expect(userCall?.[1]?.expectedDigest).toBeUndefined(); + expect(metaCall?.[1]).toMatchObject({ createOnly: true }); + expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); + }); + it("部分 push 失败时只推进成功文件 digest 并保留失败文件旧 digest", async () => { const fs = createFs({ list: vi diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 197b83e3f..6125269d6 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -83,6 +83,8 @@ type PushScriptOptions = { fileDigestMap?: FileDigestMap; scriptFile?: FileInfo; metaFile?: FileInfo; + // syncOnce 场景为 true:scriptFile/metaFile 反映本轮 fs.list() 快照,undefined 表示已确认云端不存在 + hasListSnapshot?: boolean; }; type SyncTask = { @@ -491,7 +493,7 @@ export class SynchronizeService { } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); - return await this.pushScript(fs, script, { fileDigestMap }); + return await this.pushScript(fs, script, { fileDigestMap, hasListSnapshot: true }); } })(), [file.meta!.name, `${uuid}.user.js`] @@ -516,7 +518,12 @@ export class SynchronizeService { // 或者不存在.meta文件,则上传文件 addSyncTask( uuid, - this.pushScript(fs, script, { fileDigestMap, scriptFile: file.script, metaFile: file.meta }) + this.pushScript(fs, script, { + fileDigestMap, + scriptFile: file.script, + metaFile: file.meta, + hasListSnapshot: true, + }) ); } else { // 如果脚本更新时间小于文件更新时间,则更新脚本 @@ -542,7 +549,7 @@ export class SynchronizeService { }); // 上传剩下的脚本 scriptMap.forEach((script) => { - addSyncTask(script.uuid, this.pushScript(fs, script, { fileDigestMap })); + addSyncTask(script.uuid, this.pushScript(fs, script, { fileDigestMap, hasListSnapshot: true })); }); // 忽略错误 const syncResults = await Promise.allSettled(result.map((item) => item.promise)); @@ -825,7 +832,7 @@ export class SynchronizeService { const modifiedDate = getScriptModifiedDate(script); const w = await fs.create( filename, - this.buildPushCreateOptions(fs, filename, modifiedDate, opts.scriptFile, opts.fileDigestMap) + this.buildPushCreateOptions(fs, filename, modifiedDate, opts.scriptFile, opts) ); // 获取脚本代码 const code = await this.scriptCodeDAO.get(script.uuid); @@ -834,7 +841,7 @@ export class SynchronizeService { writtenFiles.push(filename); const meta = await fs.create( metaFilename, - this.buildPushCreateOptions(fs, metaFilename, modifiedDate, opts.metaFile, opts.fileDigestMap) + this.buildPushCreateOptions(fs, metaFilename, modifiedDate, opts.metaFile, opts) ); const metaJson = JSON.stringify({ uuid: script.uuid, @@ -859,17 +866,20 @@ export class SynchronizeService { fs: FileSystem, filename: string, modifiedDate: number, - existingFile?: FileInfo, - fileDigestMap?: FileDigestMap + existingFile: FileInfo | undefined, + opts: PushScriptOptions ): FileCreateOptions { const capabilities = getFileSystemCapabilities(fs); const createOptions: FileCreateOptions = { modifiedDate }; // CAS 的 If-Match 基准取本地记录的云端 digest(脚本上次同步/安装成功时的版本), // 这样其他设备改动过云端文件时能正确检测冲突而不误覆盖。 - const expectedDigest = fileDigestMap?.[filename]; - // 只有确实首次上传(本轮没列到该文件、本地也无它的 digest 记录)才用 create-only 防止误覆盖他处文件; - // 已同步过的脚本(本地有 digest 记录,如编辑后经 scriptInstall 再次上传)必须走 CAS 覆盖, - // 否则 create-only 撞上已存在的云端文件必然 412,本地改动永远上不了云。 + // 但本轮 list 快照已确认云端不存在的文件(existingFile 为 undefined 且有快照)例外: + // 本地残留的 digest 必然过期,对不存在的文件做 If-Match 必然 412, + // 且失败保留旧 digest 后永不自愈,只能走 create-only。 + // 无快照的场景(scriptInstall 队列)才允许用本地记录 digest 做 CAS—— + // 已同步过的脚本编辑后再次上传必须 CAS 覆盖,否则 create-only 撞上已存在文件必然 412。 + const expectedDigest = + existingFile === undefined && opts.hasListSnapshot ? undefined : opts.fileDigestMap?.[filename]; if (existingFile === undefined && expectedDigest === undefined) { if (capabilities.supportsCreateOnly) { createOptions.createOnly = true; From 44f51fb3a70461034e490d4aba5feeced76af0e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 12:00:16 +0800 Subject: [PATCH 081/103] =?UTF-8?q?=F0=9F=93=9D=20cloud-sync=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E6=AD=A5=E8=A1=A5=E5=81=BF=20push/CAS=20?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E8=AF=AD=E4=B9=89/=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E5=81=9C=E8=B5=B0=E8=AF=B4=E6=98=8E=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index d920f1e2b..a85800aae 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -115,7 +115,7 @@ type FileDigestMap = { - 配置启用后触发的 `syncOnce()`。 - 定时同步,Chrome alarm 名称为 `cloudSync`,周期为 60 分钟。 -- 非 sync 来源安装脚本后的 `scriptInstall()`。 +- 非 sync 来源安装脚本后的 `scriptInstall()`。push 失败时按 `PushScriptPartialError` 只保留失败文件的旧 digest,已成功文件推进到云端最新值。 - 非 sync 来源删除脚本后的 `scriptsDelete()`。 串行队列很重要:安装、删除和定时同步都可能写同一批云端文件,如果并发执行,会扩大覆盖和 digest 污染风险。 @@ -131,7 +131,7 @@ type FileDigestMap = { 5. 对每个云端 uuid 和本地脚本做决策。 6. 用 `Promise.allSettled()` 等待所有文件任务,保持 per-file best-effort。 7. 收集成功任务返回的 digest patch。 -8. 对失败任务记录 `failedSyncUuids` 和 `preserveDigestFiles`。 +8. 对失败任务记录 `failedSyncUuids` 和 `preserveDigestFiles`;push 部分失败时(`PushScriptPartialError`),已成功写入云端的文件不保留旧 digest,让其推进到云端最新值,避免过期 digest 造成后续 CAS 永久冲突。 9. 如果启用 `syncStatus`,合并本地状态、初始云端状态、写回前重新读取的最新云端状态。 10. 调用 `updateFileDigest()`,成功文件推进 digest,失败文件保留旧 digest。 @@ -139,11 +139,13 @@ type FileDigestMap = { 本地脚本存在、云端脚本也存在: -- 如果 `.user.js` digest 与 `file_digest` 一致,跳过。 -- 如果本地脚本更新时间更晚,push 本地脚本。 +- 仅当 `.meta.json` 齐全、`.user.js` digest 与 `file_digest` 一致、且本地更新时间不比云端新时,跳过。 +- 如果本地脚本更新时间更晚,push 本地脚本——即便云端 digest 未变也要补偿 push,云端 digest 检测不到本地编辑。 - 如果云端更新时间更晚,pull 云端脚本。 - 如果云端缺 `.meta.json`,push 本地脚本补齐 meta。 +注意:云端 digest 已变且本地更新时间更晚(双端并发编辑)时,CAS provider 上 push 会以 conflict 失败并在后续轮次持续失败,直到云端出现更新(转为 pull)或人工介入。这是有意的保守语义——拒绝用本地版本静默覆盖他端改动——当前仅日志可见(`errorKind: "conflict"`)。 + 本地脚本存在、云端只有 `.meta.json`: - 如果 meta 是 tombstone,本地删除脚本。 @@ -173,8 +175,10 @@ type FileDigestMap = { 写入参数由 `buildPushCreateOptions()` 决定: -- 云端文件不存在且 provider 支持 `supportsCreateOnly`:传 `createOnly: true`。 -- 云端文件存在且 provider 支持 `supportsAtomicCompareAndSwap`,并且 `file_digest` 中有旧 digest:传 `expectedDigest`。 +- syncOnce 场景(`hasListSnapshot: true`,文件存在性来自本轮 `fs.list()`): + - 云端文件存在且 provider 支持 `supportsAtomicCompareAndSwap`,并且 `file_digest` 中有旧 digest:传 `expectedDigest` 做 CAS 冲突检测。 + - 云端文件确认不存在:传 `createOnly: true`(支持时)。此时不得回退 `file_digest`——本地残留 digest 必然过期,对不存在的文件做 If-Match 必然 412 且永不自愈。 +- scriptInstall 队列场景(无 list 快照):`file_digest` 有记录(脚本已同步过)→ 传 `expectedDigest` 做 CAS 覆盖;无记录(首次上传)→ 传 `createOnly: true`。 - provider 未声明能力时,只传 `modifiedDate`,保持旧覆盖语义。 `pushScript()` 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。 From a610ec466b5f0526cc38cda02fbcef89340d6c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 12:13:15 +0800 Subject: [PATCH 082/103] =?UTF-8?q?=F0=9F=90=9B=20Google=20Drive=20501=20?= =?UTF-8?q?=E7=AD=89=E6=B0=B8=E4=B9=85=E6=80=A7=205xx=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=8F=AF=E9=87=8D=E8=AF=95=EF=BC=88=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=AC=AC=E5=85=AD=E5=AE=B6=20provider=EF=BC=89#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../googledrive/googledrive.test.ts | 25 +++++++++++++++++++ .../filesystem/googledrive/googledrive.ts | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 428c5f473..f036512e7 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -85,6 +85,31 @@ describe("GoogleDriveFileSystem", () => { }); }); + 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("读取文件遇到 raw 429 响应时抛出 typed 限流错误", async () => { const fs = new GoogleDriveFileSystem("/", "token"); vi.spyOn(fs, "getFileId").mockResolvedValue("file-1"); diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index b0fb91921..f1a1cb5f2 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -142,7 +142,8 @@ export default class GoogleDriveFileSystem implements FileSystem { notFound: googleStatus === 404, conflict: googleStatus === 409 || googleStatus === 412, rateLimit: googleStatus === 429, - retryable: googleStatus === 429 || (googleStatus !== undefined && googleStatus >= 500), + // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 + retryable: googleStatus === 429 || (googleStatus !== undefined && [500, 502, 503, 504].includes(googleStatus)), raw, }); } From 4819c76d301fa29ab4bfa4d998e046e23aa906bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 12:24:14 +0800 Subject: [PATCH 083/103] =?UTF-8?q?=F0=9F=90=9B=20pull=20=E6=97=B6?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=20updatetime=20=E9=87=87=E7=94=A8=E4=BA=91?= =?UTF-8?q?=E7=AB=AF=E6=96=87=E4=BB=B6=E6=97=B6=E9=97=B4=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E8=A1=A5=E5=81=BF=20push=20=E6=8C=AF=E8=8D=A1=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 2 +- .../service_worker/synchronize.test.ts | 62 +++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 4 ++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index a85800aae..b72b70766 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -192,7 +192,7 @@ type FileDigestMap = { 3. `JSON.parse(meta)`。 4. `prepareScriptByCode()` 解析脚本。 5. 根据 `scriptcat-sync.json` status 调整 enable/sort。 -6. `script.installScript({ upsertBy: "sync" })` 写入本地。 +6. `script.installScript({ upsertBy: "sync", updatetime: file.script.updatetime })` 写入本地——本地 updatetime 必须采用云端文件时间(与 push 对称),否则下一轮会把刚拉下来的内容误判为本地编辑触发补偿 push,在 etag 型 provider 上形成双设备永久 pull/push 振荡。 真实失败会向上抛出,由 `syncOnceInternal()` 作为单文件失败处理。不要在 `pullScript()` 内吞掉错误,否则会重新引入 digest 污染。 diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 980dac796..0dd94c861 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -883,6 +883,68 @@ console.log("ok");` 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) => { diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 6125269d6..955b45345 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -929,6 +929,10 @@ export class SynchronizeService { script, code, upsertBy: "sync", + // 本地 updatetime 采用云端文件时间(与 push 把 modifiedDate 设为本地 updatetime 对称)。 + // 若放任 prepareScriptByCode 的 Date.now(),下一轮会把刚拉下来的内容误判为 + // "本地编辑过"而补偿 push;etag 型 provider 重写会换 etag,两台设备将永久 pull/push 振荡 + updatetime: file.script.updatetime, }); logger.info("pull script success"); } catch (e) { From b8515422b7d7f684f681897bc64b1865e098a288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 14:52:15 +0800 Subject: [PATCH 084/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=B8=A4=E5=A4=84=E9=81=97=E7=95=99=E6=94=B6=E6=95=9B?= =?UTF-8?q?=EF=BC=9A=E9=98=9F=E5=88=97=20digest=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E9=98=B2=E6=BC=8F=20pull=20+=20CAS=20=E8=87=AA=E6=88=91=20412?= =?UTF-8?q?=20=E5=86=85=E5=AE=B9=E8=BA=AB=E4=BB=BD=E6=94=B6=E6=95=9B=20#15?= =?UTF-8?q?04?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2(main 既有)scriptInstall/scriptsDelete 改用 updateFileDigestForUuids 只更新本 uuid 文件;updateFileDigest 全量盖章仅保留给已逐文件对账的 syncOnce。避免本机改动 任一脚本时,把他端已更新但尚未 pull 的文件也标成已同步而下轮 syncOnce 早退漏 pull。 #1 push 成功内容 md5 先于易失的 fs.list 落库(push_content_md5);pushScript 遇 CAS 冲突时读云端内容比对,识别为「本机上次成功写入」(digest 记账失败导致 If-Match 基准 过期)则以云端当前原生 digest 重挂基准重推一次收敛,打破自我 412 死循环;内容不符即 真·他端并发编辑,维持停在冲突不覆盖他端。 均 TDD(先失败测试后修复),新增 3 个用例,全量 3182 单测通过。 --- .../service_worker/synchronize.test.ts | 247 +++++++++++++++++- src/app/service/service_worker/synchronize.ts | 119 ++++++++- 2 files changed, 355 insertions(+), 11 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 0dd94c861..8daab9457 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1269,6 +1269,160 @@ console.log("ok");` ); }); + it("CAS 冲突时云端内容是本机上次所写则重挂基准重推,收敛自我 412", async () => { + const script = { + uuid: "sh-uuid", + name: "sh", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 20, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const written: { name: string; content: string }[] = []; + const conflictError = new FileSystemError({ + provider: "webdav", + message: "precondition failed", + status: 412, + conflict: true, + }); + const fs = createFs({ + capabilities: { supportsAtomicCompareAndSwap: true }, + // 首次用过期基准 d0 做 If-Match → 412(自己上次写入把云端推到了 e1,但 digest 记账失败) + create: vi.fn().mockImplementation(async (name: string, opts: { expectedDigest?: string }) => { + if (name === "sh-uuid.user.js" && opts?.expectedDigest === "d0-stale") { + throw conflictError; + } + return { + write: vi.fn().mockImplementation(async (content: string) => { + written.push({ name, content }); + }), + }; + }), + list: vi.fn().mockResolvedValue([ + { name: "sh-uuid.user.js", path: "sh-uuid.user.js", size: 1, digest: "e1-cloud", createtime: 1, updatetime: 5 }, + { + name: "sh-uuid.meta.json", + path: "sh-uuid.meta.json", + size: 1, + digest: "m1-cloud", + createtime: 1, + updatetime: 5, + }, + ]), + open: vi.fn().mockImplementation(async (fileInfo: { name: string }) => ({ + read: vi.fn().mockResolvedValue(fileInfo.name === "sh-uuid.user.js" ? "// cloud-edit-1" : "{}"), + })), + }); + 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-2" }) }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + // 本机上次成功写入云端的内容是 edit-1(digest 记账失败 → file_digest 仍停在 d0) + await (service as any).storage.set("push_content_md5", { + "sh-uuid.user.js": md5OfText("// cloud-edit-1"), + }); + + const result = await service.pushScript(fs as any, script as any, { + fileDigestMap: { "sh-uuid.user.js": "d0-stale", "sh-uuid.meta.json": "m0-stale" }, + }); + + // 首次以过期基准 CAS + expect(fs.create).toHaveBeenCalledWith("sh-uuid.user.js", expect.objectContaining({ expectedDigest: "d0-stale" })); + // 识别为自我所写后以云端当前 digest 重挂基准重推 + expect(fs.create).toHaveBeenCalledWith("sh-uuid.user.js", expect.objectContaining({ expectedDigest: "e1-cloud" })); + // 重推的是本机当前内容 edit-2 + expect(written).toContainEqual({ name: "sh-uuid.user.js", content: "// local-edit-2" }); + expect(result["sh-uuid.user.js"]).toBe(md5OfText("// local-edit-2")); + }); + + it("CAS 冲突时云端内容非本机所写则维持停在冲突,不重推覆盖他端", async () => { + const script = { + uuid: "cf-uuid", + name: "cf", + origin: "origin", + downloadUrl: "download-url", + checkUpdateUrl: "check-update-url", + updatetime: 20, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }; + const conflictError = new FileSystemError({ + provider: "webdav", + message: "precondition failed", + status: 412, + conflict: true, + }); + const create = vi.fn().mockImplementation(async (name: string, opts: { expectedDigest?: string }) => { + if (name === "cf-uuid.user.js" && opts?.expectedDigest === "d0-stale") { + throw conflictError; + } + return { write: vi.fn().mockResolvedValue(undefined) }; + }); + const fs = createFs({ + capabilities: { supportsAtomicCompareAndSwap: true }, + create, + list: vi.fn().mockResolvedValue([ + { name: "cf-uuid.user.js", path: "cf-uuid.user.js", size: 1, digest: "eB-cloud", createtime: 1, updatetime: 9 }, + { + name: "cf-uuid.meta.json", + path: "cf-uuid.meta.json", + size: 1, + digest: "mB-cloud", + createtime: 1, + updatetime: 9, + }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// other-device"), + })), + }); + 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" }) }, + all: vi.fn().mockResolvedValue([script]), + } as any + ); + vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + await (service as any).storage.set("push_content_md5", { + "cf-uuid.user.js": md5OfText("// my-previous-push"), + }); + + await expect( + service.pushScript(fs as any, script as any, { + fileDigestMap: { "cf-uuid.user.js": "d0-stale", "cf-uuid.meta.json": "m0-stale" }, + }) + ).rejects.toThrow(); + + // 云端内容不是本机所写 → 不应以其它 digest 重推覆盖他端 + const userDigests = create.mock.calls + .filter((c: any[]) => c[0] === "cf-uuid.user.js") + .map((c: any[]) => c[1]?.expectedDigest); + expect(userDigests).toEqual(["d0-stale"]); + }); + it("push 云端缺失文件时应当使用 createOnly,避免覆盖并发新增", async () => { const script = { uuid: "new-uuid", @@ -2372,12 +2526,11 @@ console.log("ok");` }); const realUpdateDigest = service.updateFileDigest.bind(service); vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { - if (fs === installFs) { - order.push("install:digest"); - return; - } await realUpdateDigest(fs); }); + vi.spyOn(service, "updateFileDigestForUuids").mockImplementation(async () => { + order.push("install:digest"); + }); const syncPromise = service.syncOnce(syncConfig, syncFs); await flushMicrotasks(); @@ -2451,6 +2604,85 @@ console.log("ok");` }); }); + 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) => { @@ -2495,12 +2727,11 @@ console.log("ok");` }); const realUpdateDigest = service.updateFileDigest.bind(service); vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { - if (fs === deleteFs) { - order.push("delete:digest"); - return; - } await realUpdateDigest(fs); }); + vi.spyOn(service, "updateFileDigestForUuids").mockImplementation(async () => { + order.push("delete:digest"); + }); const syncPromise = service.syncOnce(syncConfig, syncFs); await flushMicrotasks(); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 955b45345..371b7664a 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -85,6 +85,8 @@ type PushScriptOptions = { metaFile?: FileInfo; // syncOnce 场景为 true:scriptFile/metaFile 反映本轮 fs.list() 快照,undefined 表示已确认云端不存在 hasListSnapshot?: boolean; + // 自我 412 收敛的重推标记,避免二次冲突时无限重试 + selfHealRetried?: boolean; }; type SyncTask = { @@ -732,6 +734,8 @@ export class SynchronizeService { knownFileDigestMap: FileDigestMap = {}, preserveFileNames = new Set() ) { + // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 + await this.recordPushedContentMd5(knownFileDigestMap); const oldFileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const newList = await fs.list(); const newFileDigestMap: FileDigestMap = {}; @@ -761,6 +765,41 @@ export class SynchronizeService { return; } + // 队列路径(scriptInstall/scriptsDelete)只推送/删除了指定 uuid 的文件,只能更新这些文件的 digest 记录。 + // 不能像 syncOnce 那样全量盖章:syncOnce 已逐文件对账整份云端列表,而队列路径没有——全量盖章会把 + // 他端已更新但本机尚未 pull 的文件也标成已同步,导致下轮 syncOnce 早退漏 pull。 + async updateFileDigestForUuids( + fs: FileSystem, + uuids: string[], + knownFileDigestMap: FileDigestMap = {}, + preserveFileNames = new Set() + ) { + // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 + await this.recordPushedContentMd5(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])); + 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]; + } + } + await this.storage.set("file_digest", fileDigestMap); + return; + } + // 删除云端脚本数据 async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) { const filename = `${uuid}.user.js`; @@ -857,11 +896,80 @@ export class SynchronizeService { [metaFilename]: md5OfText(metaJson), }; } catch (e) { + // 自我 412 收敛:本地记录的云端 digest 记账失败(上次 fs.list 抛错 / SW 被杀)后再次编辑, + // 会拿过期 digest 对「自己上次写入的云端内容」做 If-Match,陷入永久 412。识别到冲突文件的 + // 云端内容正是本机上次成功写入时,以云端当前 digest 为新基准重推一次收敛;真·他端并发编辑 + // (内容不符)则维持停在冲突,绝不覆盖他端。 + if (!opts.selfHealRetried && !writtenFiles.includes(filename) && this.classifySyncError(e) === "conflict") { + const rebased = await this.rebaseSelfWrittenConflict(fs, script, opts.fileDigestMap); + if (rebased) { + logger.warn("self-heal 412 conflict by rebasing on own last push"); + return await this.pushScript(fs, script, { + fileDigestMap: rebased, + hasListSnapshot: false, + selfHealRetried: true, + }); + } + } logger.error("push script error", Logger.E(e)); throw new PushScriptPartialError(e, writtenFiles); } } + // CAS 冲突时判断冲突文件的云端内容是否为本机上次成功写入: + // 是 → 返回以云端当前原生 digest 为新基准的 fileDigestMap(供重推);否 → 返回 null(真·他端并发编辑)。 + private async rebaseSelfWrittenConflict( + fs: FileSystem, + script: PushScriptParam, + fileDigestMap: FileDigestMap = {} + ): Promise { + const filename = `${script.uuid}.user.js`; + const metaFilename = `${script.uuid}.meta.json`; + try { + const pushedContentMd5 = ((await this.storage.get("push_content_md5")) as FileDigestMap) || {}; + const recordedMd5 = pushedContentMd5[filename]; + if (!recordedMd5) { + return null; + } + const list = await fs.list(); + const cloudFiles = new Map(list.map((file) => [file.name, file])); + const cloudScript = cloudFiles.get(filename); + if (!cloudScript) { + return null; + } + const cloudCode = (await fs.open(cloudScript).then((r) => r.read("string"))) as string; + if (md5OfText(cloudCode) !== recordedMd5) { + // 云端内容不是本机上次写入 → 真·他端并发编辑,维持停在冲突 + return null; + } + const rebased: FileDigestMap = { ...fileDigestMap }; + for (const name of [filename, metaFilename]) { + const info = cloudFiles.get(name); + if (info) { + rebased[name] = info.digest; + } + } + return rebased; + } catch (e) { + this.logger.warn("rebase self-written conflict failed", Logger.E(e), { uuid: script.uuid }); + return null; + } + } + + // 落库本次成功推送内容的 md5(键为文件名),供 CAS 冲突时识别云端是否为本机所写。 + // 在 updateFileDigest* 内部、fs.list 之前调用,确保即便随后 list 抛错也已持久化。 + private async recordPushedContentMd5(pushedMd5Map: FileDigestMap) { + const names = Object.keys(pushedMd5Map); + if (!names.length) { + return; + } + const stored = ((await this.storage.get("push_content_md5")) as FileDigestMap) || {}; + for (const name of names) { + stored[name] = pushedMd5Map[name]; + } + await this.storage.set("push_content_md5", stored); + } + private buildPushCreateOptions( fs: FileSystem, filename: string, @@ -996,7 +1104,7 @@ export class SynchronizeService { const script = params.script; try { const pushedFileDigestMap = await this.pushScript(fs, script, { fileDigestMap }); - await this.updateFileDigest(fs, pushedFileDigestMap); + await this.updateFileDigestForUuids(fs, [script.uuid], pushedFileDigestMap); } catch (e) { // 部分成功(如 .user.js 成功、.meta.json 失败)时,已写成功文件的云端 digest 已更新, // 必须记录其最新 digest、只保留失败文件的旧值,否则下一轮同步会拿过期 digest 做 CAS 永久 412。 @@ -1006,7 +1114,7 @@ export class SynchronizeService { const preserve = new Set( [`${script.uuid}.user.js`, `${script.uuid}.meta.json`].filter((name) => !writtenFiles.includes(name)) ); - await this.updateFileDigest(fs, {}, preserve); + await this.updateFileDigestForUuids(fs, [script.uuid], {}, preserve); } throw e; } @@ -1043,7 +1151,12 @@ export class SynchronizeService { }); } } - await this.updateFileDigest(fs, {}, preserveDigestFiles); + await this.updateFileDigestForUuids( + fs, + items.map((item) => item.uuid), + {}, + preserveDigestFiles + ); }).catch((e) => { this.logger.error("delete cloud script error", Logger.E(e)); }); From 980c06945bfadcb05104c6aeac2fdfe1069e5c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Jul 2026 15:28:58 +0800 Subject: [PATCH 085/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20push=5Fcontent=5Fmd5=20=E9=9A=8F=20file=5Fdigest=20?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=B8=85=E7=90=86=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=8F=AA=E5=A2=9E=E4=B8=8D=E5=88=A0=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: - updateFileDigest 全量对账后清理 file_digest 之外的 push_content_md5 - updateFileDigestForUuids 仅清理本次确认已删除的目标文件(队列未全量对账,不能全局清理误删他端窗口内记录) TDD 补 2 个测试:全量路径清理云端已删文件、队列路径 scoped 清理不误删无关条目。 docs(cloud-sync): - 补齐 b8515422 引入的「自我 412 收敛」「两条 digest 更新路径」「push_content_md5」语义 - 并发冲突表述从「仅日志可见」细化为自我 412 自愈 vs 真并发编辑停走 - 修正 Baidu filemetas notFound 措辞(仅空列表强制 notFound) --- docs/cloud-sync.md | 31 +++++++++- .../service_worker/synchronize.test.ts | 60 +++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 21 +++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index b72b70766..5e11c4b38 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -107,7 +107,18 @@ type FileDigestMap = { - 不能用本地 md5 覆盖 provider 已返回的原生 digest。 - 文件操作失败时,对应文件名必须保留旧 digest,不能写入“看起来成功”的新值。 -`updateFileDigest()` 会重新 `fs.list()` 构造新 map;对于刚 push 但 provider list 暂时不可见的文件,才使用 `pushScript()` 返回的本地 md5 作为兜底。 +digest 更新有两条路径,区别在于对账范围: + +- `updateFileDigest()`(`syncOnce` 用)重新 `fs.list()` 全量构造新 map。`syncOnce` 已逐文件对账整份云端列表,可以安全全量盖章。 +- `updateFileDigestForUuids()`(`scriptInstall` / `scriptsDelete` 队列用)只更新本次涉及 uuid 的文件。队列路径没有对账整份云端列表,若也全量盖章,会把他端已更新、本机尚未 pull 的文件误标成已同步,导致下一轮 `syncOnce` 早退漏 pull。 + +两条路径都遵守同一套规则:云端仍在则记录 `fs.list()` 返回的原生 digest;刚 push 但 provider list 暂时不可见时才用 `pushScript()` 返回的本地 md5 兜底;云端已删除则移除记录;失败文件保留旧 digest,不写入“看起来成功”的新值。 + +## `push_content_md5` + +另有一份独立的 `push_content_md5`(同存 `ChromeStorage("sync")`,格式同 `FileDigestMap`)记录本机每次成功推送的内容 md5,仅供自我 412 收敛(见下文 `pushScript()` 一节)判定云端冲突文件是否为本机上次所写。它与 `file_digest` 用途不同:`file_digest` 存 provider 原生 digest 做 CAS 基准,`push_content_md5` 存本地内容 md5 做内容身份判定,二者不可混用。 + +`push_content_md5` 随 `file_digest` 生命周期收敛,不会只增不删:`updateFileDigest()` 全量对账后清理 `file_digest` 之外的条目;`updateFileDigestForUuids()` 只清理本次确认已从云端删除的目标文件(队列路径未全量对账,不能全局清理)。 ## 同步入口和队列 @@ -144,7 +155,10 @@ type FileDigestMap = { - 如果云端更新时间更晚,pull 云端脚本。 - 如果云端缺 `.meta.json`,push 本地脚本补齐 meta。 -注意:云端 digest 已变且本地更新时间更晚(双端并发编辑)时,CAS provider 上 push 会以 conflict 失败并在后续轮次持续失败,直到云端出现更新(转为 pull)或人工介入。这是有意的保守语义——拒绝用本地版本静默覆盖他端改动——当前仅日志可见(`errorKind: "conflict"`)。 +注意:云端 digest 已变且本地更新时间更晚时,CAS provider 上 push 会以 conflict 失败。此时按云端内容身份分两种情况: + +- 冲突文件的云端内容正是本机上次成功推送(`push_content_md5` 命中):说明只是本地 digest 记账丢失(上一轮 `fs.list()` 抛错或 SW 被杀),并非真·并发编辑。`pushScript()` 会以云端当前 digest 为新基准自我收敛重推一次(见下文「自我 412 收敛」),避免永久卡在自我冲突。 +- 云端内容与本机记录不符(真·双端并发编辑):维持停在 conflict,后续轮次持续失败,直到云端出现更新(转为 pull)或人工介入。这是有意的保守语义——拒绝用本地版本静默覆盖他端改动——当前仅日志可见(`errorKind: "conflict"`)。 本地脚本存在、云端只有 `.meta.json`: @@ -183,6 +197,15 @@ type FileDigestMap = { `pushScript()` 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。 +#### 自我 412 收敛 + +`pushScript()` 捕获到 `.user.js` 写入 conflict、且本轮尚未重试(`selfHealRetried` 未置位)时,调用 `rebaseSelfWrittenConflict()`:读 `push_content_md5` 取本机上次推送的内容 md5,再 `fs.list()` + 读云端当前内容比对。 + +- 命中(云端内容 == 本机上次所写):以云端当前原生 digest 为新基准,用 `selfHealRetried: true` 重推一次;该标记防止二次冲突时无限重试。 +- 未命中(云端内容 != 本机记录,即真·他端并发编辑):返回 null,维持停在 conflict,绝不覆盖他端。 + +这解决「本机写入成功但 digest 记账失败后再次编辑,拿过期 digest 对自己写的云端内容做 If-Match 而永久 412」的自我死锁;只在识别到云端就是自己所写时才重推,不破坏对真·并发编辑的保守停走语义。 + ### `pullScript()` `pullScript()` 会读取源码和 meta: @@ -332,7 +355,7 @@ Baidu 当前不声明 atomic 能力。维护时注意: - 只把明确 file-exists errno 判为 conflict。 - HTTP 429 转 typed rateLimit,瞬时 5xx(500/502/503/504)转 typed retryable。 - 2xx 非 JSON 响应(如代理返回 HTML)会报错,不能当作成功——否则 list 会被判空触发全量覆盖。 -- `filemetas` errno 或空列表转 typed notFound。 +- `filemetas` 空列表强制转 typed notFound(errno -9);服务端返回的其他 errno 走通用 errno 分类,不一定是 notFound。 - request 显式 `credentials: "omit"`,不要重新依赖全局 DNR 规则。 - Baidu 没有被声明为 create-only 或 CAS provider。 @@ -378,6 +401,8 @@ ZipFileSystem 主要服务备份/导出,不应接入云同步 CAS 语义。它 6. provider conflict/transient/notFound 能映射到正确 `SyncErrorKind`。 7. 条件写删只在 provider 声明能力时使用。 8. Google Drive / Dropbox / Baidu 明确保持非 atomic capability。 +9. 自我 412 收敛:本机 digest 记账丢失后再次编辑,识别云端为自己所写则重推;真·并发编辑维持停在 conflict。 +10. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 8daab9457..e99287414 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1423,6 +1423,66 @@ console.log("ok");` expect(userDigests).toEqual(["d0-stale"]); }); + it("updateFileDigest 全量对账时应清理云端已删除文件的 push_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("push_content_md5", { + "keep.user.js": "md5-keep", + // 云端已删除(不在 list 快照中),其 push_content_md5 条目应被清理 + "gone.user.js": "md5-gone", + }); + + await service.updateFileDigest(fs as any); + + await expect((service as any).storage.get("push_content_md5")).resolves.toEqual({ + "keep.user.js": "md5-keep", + }); + }); + + it("updateFileDigestForUuids 删除本次目标文件时仅清理目标 push_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("push_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("push_content_md5")).resolves.toEqual({ + "other.user.js": "md5-other", + }); + }); + it("push 云端缺失文件时应当使用 createOnly,避免覆盖并发新增", async () => { const script = { uuid: "new-uuid", diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 371b7664a..12a0219d1 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -762,6 +762,8 @@ export class SynchronizeService { } }); await this.storage.set("file_digest", newFileDigestMap); + // syncOnce 已全量对账整份云端列表,可安全清理 file_digest 之外的 push_content_md5,避免只增不删 + await this.prunePushedContentMd5((name) => !(name in newFileDigestMap)); return; } @@ -780,6 +782,8 @@ export class SynchronizeService { 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])); + // 本次确认已从云端删除的目标文件,其 push_content_md5 一并清理(仅限本次目标,队列路径未全量对账不能全局清理) + const deletedTargetFiles = new Set(); for (const name of targetFiles) { if (preserveFileNames.has(name)) { // 失败文件保留旧 digest,下轮重试 @@ -794,9 +798,11 @@ export class SynchronizeService { } else { // 云端已不存在(删除):移除记录 delete fileDigestMap[name]; + deletedTargetFiles.add(name); } } await this.storage.set("file_digest", fileDigestMap); + await this.prunePushedContentMd5((name) => deletedTargetFiles.has(name)); return; } @@ -970,6 +976,21 @@ export class SynchronizeService { await this.storage.set("push_content_md5", stored); } + // 随 file_digest 生命周期收敛 push_content_md5,避免只增不删。shouldRemove 决定某文件名是否移除。 + private async prunePushedContentMd5(shouldRemove: (name: string) => boolean) { + const stored = ((await this.storage.get("push_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("push_content_md5", stored); + } + } + private buildPushCreateOptions( fs: FileSystem, filename: string, From 3c32615fb1871d0aa6117339019a633e0d2b01d2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:03:01 +0900 Subject: [PATCH 086/103] =?UTF-8?q?=F0=9F=90=9B=20WebDAV/S3/OneDrive=20?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=86=99=E5=88=A0=E8=83=BD=E5=8A=9B=E5=85=A8?= =?UTF-8?q?=E7=BA=BF=E4=BF=9D=E5=AE=88=E5=85=B3=E9=97=AD=EF=BC=8C=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=E6=97=A0=E6=9D=A1=E4=BB=B6=E8=AF=BB=E5=86=99=E6=9C=80?= =?UTF-8?q?=E7=BB=88=E4=B8=80=E8=87=B4=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实服务端实测:坚果云对正确 etag 的 If-Match 也恒 412(跨设备更新卡死), Alist 类完全忽略条件头(静默 no-op 无防覆盖),MinIO 忽略 If-None-Match:*, S3 DeleteObject 本无条件删除语义。capability 不能按协议类型硬编码, 探测机制落地前全部声明 false;provider 侧条件头映射代码与单测保留供后续回开。 Co-Authored-By: Claude Fable 5 --- packages/filesystem/onedrive/onedrive.test.ts | 8 ++++---- packages/filesystem/onedrive/onedrive.ts | 8 +++++--- packages/filesystem/s3/s3.test.ts | 8 ++++---- packages/filesystem/s3/s3.ts | 8 +++++--- packages/filesystem/webdav/webdav.test.ts | 8 ++++---- packages/filesystem/webdav/webdav.ts | 9 ++++++--- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index 474d3f657..340993af6 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -28,13 +28,13 @@ describe("OneDriveFileSystem", () => { vi.stubGlobal("fetch", originalFetch); }); - it("应当声明支持原子条件写入和条件删除能力", () => { + it("不应声明条件写删能力:与其余 provider 一致回落无条件读写,避免同步层行为分裂", () => { const fs = new OneDriveFileSystem("/", "token"); expect((fs as any).capabilities).toMatchObject({ - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - supportsConditionalDelete: true, + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, }); }); diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 552c82444..dcff8bf65 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -7,10 +7,12 @@ import { OneDriveFileReader, OneDriveFileWriter } from "./rw"; import { quoteETag } from "./utils"; export default class OneDriveFileSystem implements FileSystem { + // Graph 实测三项条件原语都生效,但同步层统一走无条件读写 + 最终一致(#1504 结论), + // 避免同一套同步逻辑在不同 provider 上行为分裂;待按 endpoint 探测能力的后续 PR 再回开 readonly capabilities = { - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - supportsConditionalDelete: true, + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, }; accessToken?: string; diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 6166404c3..e8f477986 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -48,11 +48,11 @@ describe("S3FileSystem", () => { fs = new S3FileSystem("test-bucket", mockClient); }); - it("应当声明支持原子条件写入和条件删除能力", () => { + it("不应声明条件写删能力:MinIO 实测忽略 If-None-Match:*,DeleteObject 无条件删除语义", () => { expect((fs as any).capabilities).toMatchObject({ - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - supportsConditionalDelete: true, + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, }); }); diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index d45f46698..78046189b 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -54,10 +54,12 @@ function parseListObjectsV2(xml: string): ListObjectsV2Result { * 使用原生 fetch + AWS Signature V4 签名,不依赖 @aws-sdk/client-s3 */ export default class S3FileSystem implements FileSystem { + // S3 兼容服务端对条件写删的支持因实现/版本而异(MinIO 实测忽略 If-None-Match:*, + // DeleteObject 本无 If-Match 语义),统一保守声明为不支持,回落无条件读写 + 最终一致(#1504 实测结论) readonly capabilities = { - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - supportsConditionalDelete: true, + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, }; client: S3Client; diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 95b47103d..140d9b6e0 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -32,13 +32,13 @@ describe("WebDAVFileSystem", () => { mockClient = createMockClient(); }); - it("应当声明支持原子条件写入和条件删除能力", () => { + it("不应声明条件写删能力:真实服务端行为不一致(坚果云恒 412、Alist 类忽略 If-Match)", () => { const fs = createTestFS(mockClient); expect((fs as any).capabilities).toMatchObject({ - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - supportsConditionalDelete: true, + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, }); }); diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 1da40aa22..ae5793d68 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -27,10 +27,13 @@ const initWebDAVPatch = () => { }; export default class WebDAVFileSystem implements FileSystem { + // 真实 WebDAV 服务端对条件头的兑现极不一致且方向相反(坚果云对正确 etag 的 If-Match 也恒 412, + // Alist 类完全忽略 If-Match/If-None-Match),任何一项都不能依赖,保守声明为不支持, + // 回落无条件读写 + 最终一致(#1504 实测结论) readonly capabilities = { - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - supportsConditionalDelete: true, + supportsAtomicCompareAndSwap: false, + supportsCreateOnly: false, + supportsConditionalDelete: false, }; client: WebDAVClient; From 1227c43b5c1528b64a25c13acb185dfd42347ab2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:03:17 +0900 Subject: [PATCH 087/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=96=B9=E5=90=91=E5=88=A4=E5=AE=9A=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=86=85=E5=AE=B9=E5=9F=BA=E7=BA=BF=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8C=E7=A7=92=E7=AB=9E=E6=80=81=E8=AF=AF?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=B9=B6=E5=A2=9E=E5=8A=A0=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E5=81=9C=E8=B5=B0=E9=80=9A=E7=9F=A5=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地毫秒 updatetime 与服务端整秒 mtime 属于两个时钟域,对端更新落在同一秒内时 会误走 push 覆盖较新的云端内容(L4)。云端 digest 已变时改由 sync_content_md5 (上次同步成功的本地内容基线,push/pull 均记录)判定方向:本地未变 → pull; 双方都变且内容一致 → 收敛基线;真冲突 → 停走保留旧 digest,聚合通知用户 (一轮一条、同一批冲突不重复),绝不自动覆盖任何一端。无基线时退回时间比较(升级兼容)。 Co-Authored-By: Claude Fable 5 --- .../service_worker/synchronize.test.ts | 278 +++++++++++++++++- src/app/service/service_worker/synchronize.ts | 174 ++++++++--- src/locales/de-DE/settings.json | 2 + src/locales/en-US/settings.json | 2 + src/locales/ja-JP/settings.json | 2 + src/locales/ru-RU/settings.json | 2 + src/locales/tr-TR/settings.json | 2 + src/locales/vi-VN/settings.json | 2 + src/locales/zh-CN/settings.json | 2 + src/locales/zh-TW/settings.json | 2 + 10 files changed, 424 insertions(+), 44 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index e99287414..8b1c3ac6e 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1332,7 +1332,7 @@ console.log("ok");` ); vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); // 本机上次成功写入云端的内容是 edit-1(digest 记账失败 → file_digest 仍停在 d0) - await (service as any).storage.set("push_content_md5", { + await (service as any).storage.set("sync_content_md5", { "sh-uuid.user.js": md5OfText("// cloud-edit-1"), }); @@ -1406,7 +1406,7 @@ console.log("ok");` } as any ); vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); - await (service as any).storage.set("push_content_md5", { + await (service as any).storage.set("sync_content_md5", { "cf-uuid.user.js": md5OfText("// my-previous-push"), }); @@ -1423,7 +1423,7 @@ console.log("ok");` expect(userDigests).toEqual(["d0-stale"]); }); - it("updateFileDigest 全量对账时应清理云端已删除文件的 push_content_md5,避免只增不删", async () => { + it("updateFileDigest 全量对账时应清理云端已删除文件的 sync_content_md5,避免只增不删", async () => { const fs = createFs({ list: vi .fn() @@ -1441,20 +1441,20 @@ console.log("ok");` {} as any, { scriptCodeDAO: { get: vi.fn() }, all: vi.fn().mockResolvedValue([]) } as any ); - await (service as any).storage.set("push_content_md5", { + await (service as any).storage.set("sync_content_md5", { "keep.user.js": "md5-keep", - // 云端已删除(不在 list 快照中),其 push_content_md5 条目应被清理 + // 云端已删除(不在 list 快照中),其 sync_content_md5 条目应被清理 "gone.user.js": "md5-gone", }); await service.updateFileDigest(fs as any); - await expect((service as any).storage.get("push_content_md5")).resolves.toEqual({ + await expect((service as any).storage.get("sync_content_md5")).resolves.toEqual({ "keep.user.js": "md5-keep", }); }); - it("updateFileDigestForUuids 删除本次目标文件时仅清理目标 push_content_md5,不误删无关条目", async () => { + it("updateFileDigestForUuids 删除本次目标文件时仅清理目标 sync_content_md5,不误删无关条目", async () => { // 队列路径未对账整份云端列表,只能按本次目标 uuid 清理,不能全量盖章误删他端窗口内的记录 const fs = createFs({ list: vi.fn().mockResolvedValue([]), @@ -1469,7 +1469,7 @@ console.log("ok");` {} as any, { scriptCodeDAO: { get: vi.fn() }, all: vi.fn() } as any ); - await (service as any).storage.set("push_content_md5", { + await (service as any).storage.set("sync_content_md5", { "del-uuid.user.js": "md5-user", "del-uuid.meta.json": "md5-meta", // 非本次目标且不在 list:不应被队列路径清理 @@ -1478,7 +1478,7 @@ console.log("ok");` await service.updateFileDigestForUuids(fs as any, ["del-uuid"]); - await expect((service as any).storage.get("push_content_md5")).resolves.toEqual({ + await expect((service as any).storage.get("sync_content_md5")).resolves.toEqual({ "other.user.js": "md5-other", }); }); @@ -3244,4 +3244,264 @@ console.log("ok");` await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); expect(writes).toEqual([]); }); + + it("云端已变而本地内容未变时,即使本地时间戳更大也应 pull 而非 push(同秒竞态 L4)", async () => { + // 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端整秒时钟,两者不可比: + // 对端更新落在同一秒时,毫秒余数会让本地"看起来更新"而误走 push。 + // 方向判定应基于内容基线:云端 digest 已变、本地内容自上次同步未变 → 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, + {} 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: 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); + + expect(pullSpy).toHaveBeenCalledWith(fs, expect.anything(), undefined, expect.objectContaining({ uuid: "u1" })); + expect(pushSpy).not.toHaveBeenCalled(); + }); + + 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 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({ 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); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(notificationSpy).toHaveBeenCalledTimes(1); + // 冲突文件保留旧 digest,下一轮仍能识别云端变化 + await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ + "u1.user.js": "cu1", + "u1.meta.json": "cm1", + }); + }); + + 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 }, + ]), + open: vi.fn().mockImplementation(async () => ({ + read: vi.fn().mockResolvedValue("// cloud edit"), + })), + }); + 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({ 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") }); + vi.spyOn(service, "pushScript").mockResolvedValue({}); + vi.spyOn(service, "pullScript").mockResolvedValue(undefined); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + }); + + 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, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + 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); + + 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", + }); + await expect((service as any).storage.get("sync_content_md5")).resolves.toMatchObject({ + "u1.user.js": md5OfText("// same edit"), + }); + }); + + it("无本地内容基线时退回时间比较决定方向(升级兼容)", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5 }, + ]), + }); + 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, 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); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" }), expect.anything()); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + 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, + { scriptCodeDAO: {} } as any + ); + + await service.pullScript( + fs, + { + 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 + ); + + await expect((service as any).storage.get("sync_content_md5")).resolves.toMatchObject({ + "baseline-uuid.user.js": md5OfText(pulledCode), + "baseline-uuid.meta.json": md5OfText(pulledMeta), + }); + }); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 12a0219d1..c3c968acb 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -110,6 +110,18 @@ class PushScriptPartialError extends Error { } } +// 本地与云端在上次同步后都发生了修改(真冲突):不自动覆盖任何一端, +// 本轮跳过该脚本(沿用失败路径保留旧 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"; + } +} + const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; function getScriptModifiedDate(script: PushScriptParam): number { @@ -123,6 +135,9 @@ export class SynchronizeService { storage: ChromeStorage = new ChromeStorage("sync", false); + // 上一轮已通知过的冲突脚本集合(uuid 排序拼接),冲突集合未变化时不重复通知 + private lastNotifiedConflictKey = ""; + constructor( private msgSender: MessageSend, private group: Group, @@ -399,6 +414,8 @@ export class SynchronizeService { 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")) { @@ -503,21 +520,25 @@ export class SynchronizeService { return; } const updatetime = script.updatetime || script.createtime; - // 过滤掉无变动的文件:仅当 .meta.json 齐全、云端 digest 与上次同步记录一致、 - // 且本地更新时间不比云端新时,才认为无需处理直接跳过。 - // 否则要么本地编辑过(updatetime 更新,digest 相等只反映云端未变)需上传(#1), - // 要么云端缺 .meta.json(上一轮分片上传残留)需补传,均不能在此提前跳过。 - if ( - file.meta && - fileDigestMap[file.script!.name] === file.script!.digest && - updatetime <= file.script!.updatetime - ) { + // 云端缺 .meta.json(上一轮分片上传残留):无论方向判定如何都需补传修复 + if (!file.meta) { + addSyncTask( + uuid, + this.pushScript(fs, script, { + fileDigestMap, + scriptFile: file.script, + metaFile: file.meta, + hasListSnapshot: true, + }) + ); return; } - // 对比脚本更新时间和文件更新时间 - if (updatetime > file.script!.updatetime || !file.meta) { - // 如果脚本更新时间大于文件更新时间 - // 或者不存在.meta文件,则上传文件 + if (fileDigestMap[file.script!.name] === file.script!.digest) { + // 云端自上次同步未变:本地更新时间不比云端新则无事可做; + // 否则本地编辑过(digest 相等只反映云端未变),需补偿上传(#1,队列 push 失败后的兜底) + if (updatetime <= file.script!.updatetime) { + return; + } addSyncTask( uuid, this.pushScript(fs, script, { @@ -527,11 +548,34 @@ export class SynchronizeService { hasListSnapshot: true, }) ); - } else { - // 如果脚本更新时间小于文件更新时间,则更新脚本 - updateScript.set(uuid, true); - addSyncTask(uuid, this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script)); + return; } + // 云端自上次同步已变(或本机无记录)。本地毫秒时钟与服务端整秒 mtime 属于两个时钟域, + // 对端更新落在同一秒内时"本地时间戳更大"是误报(L4 同秒竞态), + // 方向判定优先用本地内容基线:本地内容自上次同步未变 → pull;双方都变 → 冲突,不自动覆盖任何一端 + addSyncTask( + uuid, + (async () => { + const direction = await this.decideDirectionOnRemoteChange(fs, file.script!, script, syncedContentMd5Map); + if (direction.action === "pull") { + updateScript.set(uuid, true); + return await this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script); + } + if (direction.action === "push") { + return await this.pushScript(fs, script, { + fileDigestMap, + scriptFile: file.script, + metaFile: file.meta, + hasListSnapshot: true, + }); + } + if (direction.action === "adopt") { + // 两端内容一致只是基线过期:返回内容 md5 让 updateFileDigest 推进基线,不产生写操作 + return direction.digestMap; + } + throw new SyncBothChangedConflictError(uuid, i18nName(script)); + })() + ); return; } // 如果脚本不存在,但文件存在,则安装脚本 @@ -558,11 +602,15 @@ export class SynchronizeService { const pushedFileDigestMap: FileDigestMap = {}; const preserveDigestFiles = new Set(); const failedSyncUuids = new Set(); + const conflictScripts: SyncBothChangedConflictError[] = []; 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,避免成功文件 digest 永久变旧、后续 CAS 永久冲突。 const writtenFiles = ret.reason instanceof PushScriptPartialError ? ret.reason.writtenFiles : []; @@ -577,6 +625,20 @@ export class SynchronizeService { }); } }); + // 冲突通知:一轮只发一条;同一批脚本持续冲突时不重复轰炸,集合变化或冲突消失后重置 + const conflictKey = conflictScripts + .map((c) => c.uuid) + .sort() + .join(","); + if (conflictScripts.length && conflictKey !== this.lastNotifiedConflictKey) { + InfoNotification( + i18n.t("settings:notification.script_sync_conflict"), + i18n.t("settings:notification.script_sync_conflict_desc", { + scriptNames: conflictScripts.map((c) => c.scriptName).join(", "), + }) + ); + } + this.lastNotifiedConflictKey = conflictKey; // 同步状态 if (syncConfig.syncStatus && canWriteScriptcatSync) { try { @@ -668,6 +730,9 @@ export class SynchronizeService { if (error instanceof PushScriptPartialError) { return this.classifySyncError(error.originalError); } + if (error instanceof SyncBothChangedConflictError) { + return "conflict"; + } if (error instanceof FileSystemError) { if (error.conflict) { return "conflict"; @@ -735,7 +800,7 @@ export class SynchronizeService { preserveFileNames = new Set() ) { // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 - await this.recordPushedContentMd5(knownFileDigestMap); + await this.recordSyncedContentMd5(knownFileDigestMap); const oldFileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const newList = await fs.list(); const newFileDigestMap: FileDigestMap = {}; @@ -762,8 +827,8 @@ export class SynchronizeService { } }); await this.storage.set("file_digest", newFileDigestMap); - // syncOnce 已全量对账整份云端列表,可安全清理 file_digest 之外的 push_content_md5,避免只增不删 - await this.prunePushedContentMd5((name) => !(name in newFileDigestMap)); + // syncOnce 已全量对账整份云端列表,可安全清理 file_digest 之外的 sync_content_md5,避免只增不删 + await this.pruneSyncedContentMd5((name) => !(name in newFileDigestMap)); return; } @@ -777,12 +842,12 @@ export class SynchronizeService { preserveFileNames = new Set() ) { // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 - await this.recordPushedContentMd5(knownFileDigestMap); + 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])); - // 本次确认已从云端删除的目标文件,其 push_content_md5 一并清理(仅限本次目标,队列路径未全量对账不能全局清理) + // 本次确认已从云端删除的目标文件,其 sync_content_md5 一并清理(仅限本次目标,队列路径未全量对账不能全局清理) const deletedTargetFiles = new Set(); for (const name of targetFiles) { if (preserveFileNames.has(name)) { @@ -802,7 +867,7 @@ export class SynchronizeService { } } await this.storage.set("file_digest", fileDigestMap); - await this.prunePushedContentMd5((name) => deletedTargetFiles.has(name)); + await this.pruneSyncedContentMd5((name) => deletedTargetFiles.has(name)); return; } @@ -932,8 +997,8 @@ export class SynchronizeService { const filename = `${script.uuid}.user.js`; const metaFilename = `${script.uuid}.meta.json`; try { - const pushedContentMd5 = ((await this.storage.get("push_content_md5")) as FileDigestMap) || {}; - const recordedMd5 = pushedContentMd5[filename]; + const syncedContentMd5 = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; + const recordedMd5 = syncedContentMd5[filename]; if (!recordedMd5) { return null; } @@ -962,23 +1027,24 @@ export class SynchronizeService { } } - // 落库本次成功推送内容的 md5(键为文件名),供 CAS 冲突时识别云端是否为本机所写。 + // 落库本次成功推送/拉取内容的 md5(键为文件名):作为本地内容基线供方向判定(L4), + // 也供 CAS 冲突时识别云端是否为本机所写。 // 在 updateFileDigest* 内部、fs.list 之前调用,确保即便随后 list 抛错也已持久化。 - private async recordPushedContentMd5(pushedMd5Map: FileDigestMap) { - const names = Object.keys(pushedMd5Map); + private async recordSyncedContentMd5(syncedMd5Map: FileDigestMap) { + const names = Object.keys(syncedMd5Map); if (!names.length) { return; } - const stored = ((await this.storage.get("push_content_md5")) as FileDigestMap) || {}; + const stored = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; for (const name of names) { - stored[name] = pushedMd5Map[name]; + stored[name] = syncedMd5Map[name]; } - await this.storage.set("push_content_md5", stored); + await this.storage.set("sync_content_md5", stored); } - // 随 file_digest 生命周期收敛 push_content_md5,避免只增不删。shouldRemove 决定某文件名是否移除。 - private async prunePushedContentMd5(shouldRemove: (name: string) => boolean) { - const stored = ((await this.storage.get("push_content_md5")) as FileDigestMap) || {}; + // 随 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)) { @@ -987,7 +1053,7 @@ export class SynchronizeService { } } if (changed) { - await this.storage.set("push_content_md5", stored); + await this.storage.set("sync_content_md5", stored); } } @@ -1021,6 +1087,39 @@ export class SynchronizeService { return createOptions; } + // 云端 digest 相对上次同步已变时决定同步方向。 + // 不比较本地毫秒时钟与服务端整秒 mtime(同秒竞态会误判方向,L4): + // 用上次同步的本地内容基线判断本地是否也变过,双方都变才算冲突; + // 无基线(升级前旧数据/从未同步成功)时退回旧的时间比较规则。 + private async decideDirectionOnRemoteChange( + fs: FileSystem, + cloudFile: FileInfo, + script: Script, + syncedContentMd5Map: FileDigestMap + ): Promise<{ action: "push" | "pull" | "conflict" } | { action: "adopt"; digestMap: FileDigestMap }> { + const baselineMd5 = syncedContentMd5Map[cloudFile.name]; + if (baselineMd5 === undefined) { + const updatetime = script.updatetime || script.createtime; + return { action: updatetime > cloudFile.updatetime ? "push" : "pull" }; + } + 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) { const logger = this.logger.with({ uuid: existingScript?.uuid || "", @@ -1063,6 +1162,11 @@ export class SynchronizeService { // "本地编辑过"而补偿 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)); diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index 466ef7e2e..8afa805ef 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -88,6 +88,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}}" }, diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index cedcce7f0..f6985c066 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -88,6 +88,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}}" }, diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index af4d89eca..62191731e 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -88,6 +88,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}}" }, diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 5229765ff..2f3988314 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -88,6 +88,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}}" }, diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index dcdda8689..809ef45a0 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -88,6 +88,8 @@ "notification": { "script_sync_delete": "Betik Silme Senkronizasyonu", "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}}" }, diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index 053c1b176..253093283 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -88,6 +88,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}}" }, diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index b4740e22d..e3fbd1bd2 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -88,6 +88,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}}" }, diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index e3196d38f..145dea1e4 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -88,6 +88,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}}" }, From c75ea8bc00a902db133a2b2602664e006607e91b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:03:29 +0900 Subject: [PATCH 088/103] =?UTF-8?q?=F0=9F=93=9D=20cloud-sync=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=9A=E8=83=BD=E5=8A=9B=E5=85=B3=E9=97=AD=E4=BE=9D?= =?UTF-8?q?=E6=8D=AE=E3=80=81=E5=86=85=E5=AE=B9=E5=9F=BA=E7=BA=BF=E6=96=B9?= =?UTF-8?q?=E5=90=91=E5=88=A4=E5=AE=9A=E3=80=81=E5=86=B2=E7=AA=81=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=B8=8E=E5=8A=9F=E8=83=BD=E8=8C=83=E5=9B=B4=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/cloud-sync.md | 81 +++++++++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 5e11c4b38..773428988 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -21,6 +21,9 @@ 4. provider 没有声明原子能力时,同步层不能传条件写删参数,也不能把 best-effort 行为描述成 CAS。 5. 旧 `.user.js`、旧 `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须继续可读。 6. filesystem 包只负责暴露能力、执行文件操作、抛 typed error;同步冲突策略属于 `SynchronizeService`。 +7. 本地与云端都改过的脚本(真冲突)不自动覆盖任何一端:跳过、保留基线、聚合通知用户。 + +功能范围:云同步只同步脚本源码、来源 metadata(`.meta.json`)、启用状态与排序(`scriptcat-sync.json`)。GM storage 值和 `@require`/`@resource` 资源缓存**不**参与云同步(它们只在完整备份/导出路径处理),属于有意取舍,扩展需另行设计(隐私、容量、二进制资源、多端 merge)。 ## 同步目录和文件 @@ -114,11 +117,16 @@ digest 更新有两条路径,区别在于对账范围: 两条路径都遵守同一套规则:云端仍在则记录 `fs.list()` 返回的原生 digest;刚 push 但 provider list 暂时不可见时才用 `pushScript()` 返回的本地 md5 兜底;云端已删除则移除记录;失败文件保留旧 digest,不写入“看起来成功”的新值。 -## `push_content_md5` +## `sync_content_md5`(本地内容基线) + +另有一份独立的 `sync_content_md5`(同存 `ChromeStorage("sync")`,格式同 `FileDigestMap`)记录本机每次成功推送**或拉取**的内容 md5,即"上次同步成功时的本地内容基线"。它有两个用途: -另有一份独立的 `push_content_md5`(同存 `ChromeStorage("sync")`,格式同 `FileDigestMap`)记录本机每次成功推送的内容 md5,仅供自我 412 收敛(见下文 `pushScript()` 一节)判定云端冲突文件是否为本机上次所写。它与 `file_digest` 用途不同:`file_digest` 存 provider 原生 digest 做 CAS 基准,`push_content_md5` 存本地内容 md5 做内容身份判定,二者不可混用。 +1. **方向判定(L4 修复)**:云端 digest 相对 `file_digest` 已变时,用本地当前内容 md5 与基线比较判断"本地是否也改过",取代跨时钟域的墙钟比较(本地毫秒 updatetime vs 服务端整秒 mtime 在同一秒内会误判方向,导致 push 覆盖较新的云端内容)。 +2. **自我 412 收敛**(见下文 `pushScript()` 一节):判定云端冲突文件是否为本机上次所写。 -`push_content_md5` 随 `file_digest` 生命周期收敛,不会只增不删:`updateFileDigest()` 全量对账后清理 `file_digest` 之外的条目;`updateFileDigestForUuids()` 只清理本次确认已从云端删除的目标文件(队列路径未全量对账,不能全局清理)。 +它与 `file_digest` 用途不同:`file_digest` 存 provider 原生 digest 检测云端变化,`sync_content_md5` 存本地内容 md5 检测本地变化,二者不可混用。 + +`sync_content_md5` 随 `file_digest` 生命周期收敛,不会只增不删:`updateFileDigest()` 全量对账后清理 `file_digest` 之外的条目;`updateFileDigestForUuids()` 只清理本次确认已从云端删除的目标文件(队列路径未全量对账,不能全局清理)。 ## 同步入口和队列 @@ -150,15 +158,18 @@ digest 更新有两条路径,区别在于对账范围: 本地脚本存在、云端脚本也存在: -- 仅当 `.meta.json` 齐全、`.user.js` digest 与 `file_digest` 一致、且本地更新时间不比云端新时,跳过。 -- 如果本地脚本更新时间更晚,push 本地脚本——即便云端 digest 未变也要补偿 push,云端 digest 检测不到本地编辑。 -- 如果云端更新时间更晚,pull 云端脚本。 -- 如果云端缺 `.meta.json`,push 本地脚本补齐 meta。 - -注意:云端 digest 已变且本地更新时间更晚时,CAS provider 上 push 会以 conflict 失败。此时按云端内容身份分两种情况: +- 云端缺 `.meta.json`(上一轮分片上传残留):push 本地脚本补齐 meta。 +- 云端 digest 与 `file_digest` 一致(云端自上次同步未变): + - 本地更新时间不比云端新:跳过。 + - 本地更新时间更晚:补偿 push——云端 digest 检测不到本地编辑,队列 push 失败后也靠这里兜底。 +- 云端 digest 与 `file_digest` 不一致(云端自上次同步已变,或本机无记录),由 `decideDirectionOnRemoteChange()` 决定方向。**不比较本地毫秒时钟与服务端整秒 mtime**(对端更新落在同一秒内时"本地时间戳更大"是误报,会 push 覆盖较新的云端内容,即 L4 同秒竞态): + - 本地内容 md5 == `sync_content_md5` 基线(本地未改):pull。 + - 本地内容已改,但与云端当前内容一致(两台设备做了同样编辑,或本机记账失败后云端实为本机所写):直接采用云端 digest 收敛基线,不产生写操作(adopt)。 + - 本地与云端都改了(真冲突):抛 `SyncBothChangedConflictError`,本轮跳过该脚本(沿用失败路径保留旧 digest 与云端 status),并聚合通知用户(见下)。 + - 无基线(升级前旧数据/从未同步成功):退回旧的时间比较规则(时间更晚一方胜出)。 +- 冲突通知:一轮同步只发一条通知,列出所有冲突脚本名;同一批脚本持续冲突时后续轮次不重复通知(集合变化后重新通知)。冲突脚本会一直停走,直到某一端内容与另一端一致(自动收敛)或某一端被删除/重装。 -- 冲突文件的云端内容正是本机上次成功推送(`push_content_md5` 命中):说明只是本地 digest 记账丢失(上一轮 `fs.list()` 抛错或 SW 被杀),并非真·并发编辑。`pushScript()` 会以云端当前 digest 为新基准自我收敛重推一次(见下文「自我 412 收敛」),避免永久卡在自我冲突。 -- 云端内容与本机记录不符(真·双端并发编辑):维持停在 conflict,后续轮次持续失败,直到云端出现更新(转为 pull)或人工介入。这是有意的保守语义——拒绝用本地版本静默覆盖他端改动——当前仅日志可见(`errorKind: "conflict"`)。 +注意:所有 provider 目前都不声明条件写删能力(见「provider 差异」),push 是无条件覆盖写。`list → 决策 → write` 之间存在 TOCTOU 窗口:若对端恰好在这几秒内写入同一文件,后写者胜(last-writer-wins),同步层无法察觉。内容基线把误覆盖收窄到"真·同时编辑同一脚本"这一罕见场景;需要原子保护时必须由 provider 侧 CAS 支持(后续按 endpoint 探测能力的方案)。 本地脚本存在、云端只有 `.meta.json`: @@ -195,11 +206,13 @@ digest 更新有两条路径,区别在于对账范围: - scriptInstall 队列场景(无 list 快照):`file_digest` 有记录(脚本已同步过)→ 传 `expectedDigest` 做 CAS 覆盖;无记录(首次上传)→ 传 `createOnly: true`。 - provider 未声明能力时,只传 `modifiedDate`,保持旧覆盖语义。 +当前所有 provider 的 capabilities 均为 false(见「provider 差异」),因此生产路径实际只传 `modifiedDate`;条件参数逻辑保留并有单测覆盖,供后续"按 endpoint 探测能力"方案回开时使用。 + `pushScript()` 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。 #### 自我 412 收敛 -`pushScript()` 捕获到 `.user.js` 写入 conflict、且本轮尚未重试(`selfHealRetried` 未置位)时,调用 `rebaseSelfWrittenConflict()`:读 `push_content_md5` 取本机上次推送的内容 md5,再 `fs.list()` + 读云端当前内容比对。 +`pushScript()` 捕获到 `.user.js` 写入 conflict、且本轮尚未重试(`selfHealRetried` 未置位)时,调用 `rebaseSelfWrittenConflict()`:读 `sync_content_md5` 取本机上次同步的内容 md5,再 `fs.list()` + 读云端当前内容比对。 - 命中(云端内容 == 本机上次所写):以云端当前原生 digest 为新基准,用 `selfHealRetried: true` 重推一次;该标记防止二次冲突时无限重试。 - 未命中(云端内容 != 本机记录,即真·他端并发编辑):返回 null,维持停在 conflict,绝不覆盖他端。 @@ -216,6 +229,7 @@ digest 更新有两条路径,区别在于对账范围: 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 污染。 @@ -279,43 +293,47 @@ typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501(如后端不 | Provider | digest 来源 | atomic CAS | create-only | conditional delete | 关键实现 | | --- | --- | --- | --- | --- | --- | -| WebDAV | `etag` | 支持 | 支持 | 支持 | 写入用 `If-Match`;create-only 用 `overwrite: false`;删除用 `If-Match` | -| S3 | `ETag` 去引号 | 支持 | 支持 | 支持 | 写入用 `if-match`;create-only 用 `if-none-match: *`;删除用 `if-match` | -| OneDrive | `eTag` | 支持 | 支持 | 支持 | simple upload / upload session 带 `If-Match` 或 `If-None-Match`;删除带 `If-Match` | +| WebDAV | `etag` | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(`If-Match` / `overwrite: false`)但同步层不会触发 | +| S3 | `ETag` 去引号 | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(`if-match` / `if-none-match: *`)但同步层不会触发 | +| OneDrive | `eTag` | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(simple PUT / upload session `If-Match`)但同步层不会触发 | | Google Drive | `md5Checksum` | 不支持 | 不支持 | 不支持 | 先按路径查 fileId,再 PATCH 或 POST;path cache 可能 stale | | Dropbox | `content_hash` | 不支持 | 不支持 | 不支持 | 先 `exists()`,存在 overwrite,不存在 add;未暴露 rev CAS | | Baidu | `md5` | 不支持 | 不支持 | 不支持 | precreate/upload/create,`rtype=3` 覆盖;HTTP 429/5xx typed | | Zip | 空或 zip metadata | 不支持 | 不支持 | 不支持 | 备份用途,不参与云端 CAS | -注意:WebDAV / S3 / OneDrive 的 `capabilities` 是按协议硬编码的声明,没有对具体服务器做探测。若某个 S3 兼容后端静默忽略条件头,条件写/删会退化为无条件覆盖且客户端无法察觉;对条件请求返回 501 的后端会得到不可重试的 typed 错误并在日志中标记 `fatal`。 +**为什么 WebDAV / S3 / OneDrive 不再声明条件能力**(#1504 真实服务端实测结论): + +- WebDAV:两台真实服务端行为相反且都不可用——坚果云对**正确 etag** 的 `If-Match` 也恒 412(CAS 路径完全写不进),Alist 类完全忽略 `If-Match`/`If-None-Match`(条件头是静默 no-op,无任何防覆盖)。没有客户端改动能同时适配两者。 +- S3:MinIO 实测仅 `If-Match` CAS 真原子;`If-None-Match: *` 被忽略(视版本),DeleteObject 本无条件删除语义。 +- OneDrive(Graph):三项实测都生效,但恰恰因为它合规,历史上 `scriptInstall` 的 create-only 误用会 409/412 卡死每次编辑——能力按 provider 类型硬编码的假设整体不成立。 + +结论:capability 不能按协议类型硬编码,只能按具体账号/endpoint 实测探测。在探测机制落地前,所有 provider 保守声明为不支持,同步层回落无条件读写 + 内容基线方向判定 + 冲突停走通知(最终一致,不会卡死)。provider 侧条件头映射代码与单测保留,供后续回开。 ### WebDAV -WebDAV 是原生条件能力 provider。维护时注意: +维护时注意: -- `capabilities` 三项均为 true。 -- ETag 写入和删除时需要保持引号,`quoteETag()` 会补齐。 +- `capabilities` 三项均为 false(原因见上表说明)。 +- 条件头映射代码保留:ETag 写入和删除时 `quoteETag()` 补齐引号(坚果云 PROPFIND 的 etag 不带引号且只认不带引号的 `If-Match`,是能力关闭的原因之一)。 - create-only 依赖 `putFileContents(..., { overwrite: false })`。 - `putFileContents()` 返回 false 时,在 create-only 场景转 typed conflict。 - 删除 404 视为幂等成功。 ### S3 -S3 是原生条件能力 provider。维护时注意: +维护时注意: -- `capabilities` 三项均为 true。 +- `capabilities` 三项均为 false(原因见上表说明)。 - list 返回的 `ETag` 会去掉引号作为 digest。 -- 写入 `expectedDigest` 时发送 `if-match`。 -- create-only 发送 `if-none-match: *`。 -- 删除 `expectedDigest` 时发送 `if-match`。 +- 条件头映射代码保留:写入 `expectedDigest` 时发送 `if-match`;create-only 发送 `if-none-match: *`;删除 `expectedDigest` 时发送 `if-match`。 - `PreconditionFailed` / 412 转 typed conflict。 - `NoSuchKey` 删除视为成功。 ### OneDrive -OneDrive 是原生条件能力 provider。维护时注意: +维护时注意: -- `capabilities` 三项均为 true。 +- `capabilities` 三项均为 false(原因见上表说明)。 - list 使用 `eTag` 作为 digest。 - 空内容走 simple PUT,非空内容走 upload session。 - 条件写会把 `If-Match` / `If-None-Match` 放到 simple PUT 或 upload session 创建请求。 @@ -399,10 +417,13 @@ ZipFileSystem 主要服务备份/导出,不应接入云同步 CAS 语义。它 4. 损坏或旧格式 `scriptcat-sync.json` 不阻塞脚本同步。 5. orphan `.user.js` 跳过并保留 status。 6. provider conflict/transient/notFound 能映射到正确 `SyncErrorKind`。 -7. 条件写删只在 provider 声明能力时使用。 -8. Google Drive / Dropbox / Baidu 明确保持非 atomic capability。 -9. 自我 412 收敛:本机 digest 记账丢失后再次编辑,识别云端为自己所写则重推;真·并发编辑维持停在 conflict。 -10. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 +7. 条件写删只在 provider 声明能力时使用;当前所有 provider capabilities 均为 false,不得发出任何条件头。 +8. 云端已变、本地内容基线未变时必须 pull——即使本地 updatetime 大于云端 mtime(同秒竞态 L4)。 +9. 本地与云端都变(真冲突)时不 push 不 pull,保留旧 digest 与云端 status,一轮只发一条聚合通知,同一批冲突不重复通知。 +10. 云端已变、本地也变但内容与云端一致时,收敛基线且不产生写操作。 +11. 无内容基线(升级前旧数据)时退回时间比较规则。 +12. 自我 412 收敛:本机 digest 记账丢失后再次编辑,识别云端为自己所写则重推;真·并发编辑维持停在 conflict。 +13. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 From 272959d8b5eb7ce0aa84d1b3d7ea30138a1aba04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 00:30:24 +0800 Subject: [PATCH 089/103] =?UTF-8?q?=E2=9C=A8=20=E5=A2=9E=E5=8A=A0=E4=BA=91?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=A6=86=E7=9B=96=E5=86=B2=E7=AA=81=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 40 ++++ src/app/service/service_worker/client.ts | 5 + .../service_worker/synchronize.test.ts | 196 ++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 88 +++++++- src/locales/de-DE/settings.json | 15 +- src/locales/en-US/settings.json | 15 +- src/locales/ja-JP/settings.json | 15 +- src/locales/ru-RU/settings.json | 15 +- src/locales/tr-TR/settings.json | 15 +- src/locales/vi-VN/settings.json | 15 +- src/locales/zh-CN/settings.json | 15 +- src/locales/zh-TW/settings.json | 15 +- .../Setting/sections/SyncSection.test.tsx | 61 +++++- .../routes/Setting/sections/SyncSection.tsx | 99 ++++++++- .../Setting/sections/syncStatus.test.ts | 35 ++++ .../routes/Setting/sections/syncStatus.ts | 19 ++ src/pages/store/features/cloud_sync.ts | 25 +++ src/pkg/config/config.ts | 17 ++ 18 files changed, 679 insertions(+), 26 deletions(-) create mode 100644 src/pages/options/routes/Setting/sections/syncStatus.test.ts create mode 100644 src/pages/options/routes/Setting/sections/syncStatus.ts create mode 100644 src/pages/store/features/cloud_sync.ts diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 773428988..76288b242 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -381,6 +381,46 @@ Baidu 当前不声明 atomic 能力。维护时注意: ZipFileSystem 主要服务备份/导出,不应接入云同步 CAS 语义。它不声明 capabilities。 +## 覆盖/冲突可见性 + +云同步在 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` 实时展示四态:正常 / 同步中 / 有覆盖或冲突(琥珀警示)/ 失败。 +- `立即同步` 按钮经 `SynchronizeClient.cloudSyncOnce()` → SW `group.on("cloudSyncOnce")` → 用**已保存**配置跑一次 `syncOnce`(未启用则不触发)。 + +### 覆盖日志(`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`(仿冲突的 `lastNotifiedOverwriteKey`,一轮一条、同批不重复、集合变化重发),点击打开 `/src/options.html#/logs?query=...`。`?query` 载荷 `[{key,value}]` 由 Logger 页 `parseInitialQueries` 解析,预过滤到 `service=synchronize` 且 `action=overwrite` 的行。状态条「查看日志」用同一深链格式。 + +### 边界 + +- 日志按 `LogCleanCycle`(默认 7 天,`LoggerDAO.deleteBefore`)自动清理,回溯窗口约最近 7 天。 +- `overwrite` 只覆盖「无基线兜底」这一**可检测**的静默覆盖;纯 TOCTOU last-writer-wins(见上文 push 一节)客户端无法察觉,不在本轮可见性范围内。 + ## 生产兼容要求 改同步逻辑前必须检查: 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 af14fe99f..883a8f3b6 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -3608,4 +3608,200 @@ console.log("ok");`; "baseline-uuid.meta.json": md5OfText(pulledMeta), }); }); + + 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 } as any; + const script = { uuid: "u1", name: "u1", updatetime: 200, createtime: 1 } as any; + + const direction = await (service as any).decideDirectionOnRemoteChange(createFs(), cloudFile, script, {}); + + expect(direction).toEqual({ action: "push", unverified: true }); + }); + + it("有内容基线且本地未改时,pull 不标记 unverified", async () => { + const code = "// code"; + const service = makeService({ get: vi.fn().mockResolvedValue({ code }) }); + const cloudFile = { name: "u1.user.js", updatetime: 100 } as any; + const script = { uuid: "u1", name: "u1", updatetime: 200, 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 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cloudM", createtime: 1, updatetime: 100 }, + ]) + .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, 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 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "m1", createtime: 1, updatetime: 100 }, + { name: "u2.user.js", path: "u2.user.js", size: 1, digest: "c2", createtime: 1, updatetime: 100 }, + { name: "u2.meta.json", path: "u2.meta.json", size: 1, digest: "m2", createtime: 1, updatetime: 100 }, + ]) + .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, createtime: 1, status: 1, sort: 0, metadata: {} }, + { uuid: "u2", name: "u2", updatetime: 200, createtime: 1, status: 1, sort: 1, metadata: {} }, + ]), + } as any + ); + vi.spyOn(service, "pushScript").mockResolvedValue({}); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + }); + + 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 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cloudM", createtime: 1, updatetime: 100 }, + ]) + .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, 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); + }); + }); + + 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); + }); + }); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index e278f692b..18e398d04 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -23,7 +23,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"; @@ -128,6 +134,12 @@ class SyncBothChangedConflictError extends Error { const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; +// 「查看日志」深链的标签过滤载荷(供 Logger 页 parseInitialQueries 解析)。覆盖通知直接落到 overwrite 行。 +const SYNC_LOG_QUERY_OVERWRITE = JSON.stringify([ + { key: "service", value: "synchronize" }, + { key: "action", value: "overwrite" }, +]); + function getScriptModifiedDate(script: PushScriptParam): number { return script.updatetime || script.createtime || Date.now(); } @@ -142,6 +154,9 @@ export class SynchronizeService { // 上一轮已通知过的冲突脚本集合(uuid 排序拼接),冲突集合未变化时不重复通知 private lastNotifiedConflictKey = ""; + // 上一轮已通知过的覆盖脚本集合(uuid 排序拼接),覆盖集合未变化时不重复通知 + private lastNotifiedOverwriteKey = ""; + constructor( private msgSender: MessageSend, private group: Group, @@ -448,10 +463,25 @@ 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), + }); } }); } @@ -521,6 +551,8 @@ export class SynchronizeService { // 对比脚本列表和文件列表,进行同步 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(); @@ -608,10 +640,20 @@ export class SynchronizeService { (async () => { const direction = await this.decideDirectionOnRemoteChange(fs, file.script!, script, syncedContentMd5Map); if (direction.action === "pull") { + if (direction.unverified) { + const scriptName = i18nName(script); + this.logger.warn("sync overwrite", { action: "overwrite", direction: "pull", uuid, name: scriptName }); + overwriteScripts.push({ uuid, scriptName, direction: "pull" }); + } updateScript.set(uuid, true); return await this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script); } if (direction.action === "push") { + if (direction.unverified) { + const scriptName = i18nName(script); + this.logger.warn("sync overwrite", { action: "overwrite", direction: "push", uuid, name: scriptName }); + overwriteScripts.push({ uuid, scriptName, direction: "push" }); + } return await this.pushScript(fs, script, { fileDigestMap, scriptFile: file.script, @@ -689,6 +731,23 @@ export class SynchronizeService { ); } this.lastNotifiedConflictKey = conflictKey; + // 覆盖通知:无内容基线兜底覆盖可能覆盖了未知改动,聚合一轮一条;同批不重复(集合变化或消失后重置)。 + // 点击深链到已过滤 service=synchronize & action=overwrite 的日志,让用户自行确认改动。 + const overwriteKey = overwriteScripts + .map((c) => c.uuid) + .sort() + .join(","); + if (overwriteScripts.length && overwriteKey !== this.lastNotifiedOverwriteKey) { + InfoNotification( + i18n.t("settings:notification.script_sync_overwrite"), + i18n.t("settings:notification.script_sync_overwrite_desc", { + scriptNames: overwriteScripts.map((c) => c.scriptName).join(", "), + count: overwriteScripts.length, + }), + { url: `/src/options.html#/logs?query=${encodeURIComponent(SYNC_LOG_QUERY_OVERWRITE)}` } + ); + } + this.lastNotifiedOverwriteKey = overwriteKey; // 同步状态 if (syncConfig.syncStatus && canWriteScriptcatSync) { try { @@ -773,7 +832,13 @@ export class SynchronizeService { this.logger.info("update file digest"); await this.updateFileDigest(fs, pushedFileDigestMap, preserveDigestFiles); 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 { @@ -1146,11 +1211,15 @@ export class SynchronizeService { cloudFile: FileInfo, script: Script, syncedContentMd5Map: FileDigestMap - ): Promise<{ action: "push" | "pull" | "conflict" } | { action: "adopt"; digestMap: FileDigestMap }> { + ): Promise< + { action: "push" | "pull" | "conflict"; unverified?: boolean } | { action: "adopt"; digestMap: FileDigestMap } + > { const baselineMd5 = syncedContentMd5Map[cloudFile.name]; if (baselineMd5 === undefined) { + // 无内容基线(升级前旧数据/从未同步成功):只能退回跨时钟域的墙钟比较,无法确认落败一端是否真未改动, + // 因此标记 unverified——上层据此打 overwrite 日志并通知用户,让其自行确认(见 docs/cloud-sync.md 覆盖可见性)。 const updatetime = script.updatetime || script.createtime; - return { action: updatetime > cloudFile.updatetime ? "push" : "pull" }; + return { action: updatetime > cloudFile.updatetime ? "push" : "pull", unverified: true }; } const code = await this.scriptCodeDAO.get(script.uuid); const localMd5 = code ? md5OfText(code.code) : undefined; @@ -1338,7 +1407,16 @@ export class SynchronizeService { } } + // 手动触发一次云同步(设置页「立即同步」按钮)。用已保存配置,未启用则不触发。 + async cloudSyncOnce() { + const config = await this.systemConfig.getCloudSync(); + if (!config.enable) return; + const fs = await this.buildFileSystem(config); + 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 1d9c04982..fd545289e 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -94,7 +94,9 @@ "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}}" + "subscribe_update_desc": "Neue Skripte: {{newScripts}}\nGelöschte Skripte: {{deletedScripts}}", + "script_sync_overwrite": "Cloud-Sync-Überschreibung", + "script_sync_overwrite_desc": "{{scriptNames}} und {{count}} Skript(e) wurden durch die Cloud-Version überschrieben. Klicken Sie, um das Sync-Protokoll zu prüfen." }, "enable_background": { "title": "Hintergrundausführung aktivieren", @@ -125,5 +127,14 @@ "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": "{{overwrite}} Skript(e) von der Cloud überschrieben, {{conflict}} Konflikt(e) pausiert. Öffnen Sie das Protokoll zur Bestätigung.", + "sync_state_error": "Synchronisierung fehlgeschlagen, pausiert" } diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index c9e3eb8bf..98ef96502 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -94,7 +94,9 @@ "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}}" + "subscribe_update_desc": "New scripts: {{newScripts}}\nDeleted scripts: {{deletedScripts}}", + "script_sync_overwrite": "Cloud sync overwrite", + "script_sync_overwrite_desc": "{{scriptNames}} and {{count}} script(s) were overwritten by the cloud version. Click to review the sync log." }, "enable_background": { "title": "Enable Background Running", @@ -125,5 +127,14 @@ "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": "{{overwrite}} script(s) overwritten by cloud, {{conflict}} conflict(s) paused. Open the log to confirm.", + "sync_state_error": "Sync failed, paused" } diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index 939349928..ba3c1704f 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -94,7 +94,9 @@ "script_sync_conflict": "クラウド同期の競合", "script_sync_conflict_desc": "スクリプト {{scriptNames}} はローカルとクラウドの両方で変更されています。同期を一時停止しました。手動で解決してください", "subscribe_update": "サブスクリプション {{subscribeName}} が更新されました", - "subscribe_update_desc": "新しいスクリプト: {{newScripts}}\n削除されたスクリプト: {{deletedScripts}}" + "subscribe_update_desc": "新しいスクリプト: {{newScripts}}\n削除されたスクリプト: {{deletedScripts}}", + "script_sync_overwrite": "クラウド同期の上書き通知", + "script_sync_overwrite_desc": "{{scriptNames}} など {{count}} 個のスクリプトがクラウド版で上書きされました。クリックして同期ログを確認してください" }, "enable_background": { "title": "バックグラウンド実行を有効にする", @@ -125,5 +127,14 @@ "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": "{{overwrite}} 個のスクリプトがクラウドで上書き、{{conflict}} 個の競合が停止中です。ログを開いて確認してください", + "sync_state_error": "同期に失敗し、停止しました" } diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 5390c4199..0a0a6bf3f 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -94,7 +94,9 @@ "script_sync_conflict": "Конфликт облачной синхронизации", "script_sync_conflict_desc": "Скрипт {{scriptNames}} изменен и локально, и в облаке. Синхронизация этого скрипта приостановлена, разрешите конфликт вручную", "subscribe_update": "Подписка {{subscribeName}} была обновлена", - "subscribe_update_desc": "Новые скрипты: {{newScripts}}\nУдаленные скрипты: {{deletedScripts}}" + "subscribe_update_desc": "Новые скрипты: {{newScripts}}\nУдаленные скрипты: {{deletedScripts}}", + "script_sync_overwrite": "Перезапись при облачной синхронизации", + "script_sync_overwrite_desc": "{{scriptNames}} и ещё {{count}} скрипт(ов) перезаписаны облачной версией. Нажмите, чтобы просмотреть журнал синхронизации." }, "enable_background": { "title": "Включить фоновый режим", @@ -125,5 +127,14 @@ "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": "{{overwrite}} скрипт(ов) перезаписаны облаком, {{conflict}} конфликт(ов) приостановлено. Откройте журнал для подтверждения.", + "sync_state_error": "Сбой синхронизации, приостановлено" } diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index 0248a1456..002bed915 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -94,7 +94,9 @@ "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}}" + "subscribe_update_desc": "Yeni betikler: {{newScripts}}\nSilinen betikler: {{deletedScripts}}", + "script_sync_overwrite": "Bulut eşitleme üzerine yazma", + "script_sync_overwrite_desc": "{{scriptNames}} ve {{count}} betik bulut sürümüyle üzerine yazıldı. Eşitleme günlüğünü görmek için tıklayın." }, "enable_background": { "title": "Arka Planda Çalıştırmayı Etkinleştir", @@ -125,5 +127,14 @@ "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": "{{overwrite}} betik bulut tarafından üzerine yazıldı, {{conflict}} çakışma duraklatıldı. Onaylamak için günlüğü açın.", + "sync_state_error": "Eşitleme başarısız, duraklatıldı" } diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index ae710be19..ed45bfbd8 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -94,7 +94,9 @@ "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}}" + "subscribe_update_desc": "Script mới: {{newScripts}}\nScript đã xóa: {{deletedScripts}}", + "script_sync_overwrite": "Cảnh báo ghi đè đồng bộ đám mây", + "script_sync_overwrite_desc": "{{scriptNames}} và {{count}} tập lệnh đã bị ghi đè bởi phiên bản đám mây. Nhấp để xem nhật ký đồng bộ." }, "enable_background": { "title": "Bật chạy nền", @@ -125,5 +127,14 @@ "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": "{{overwrite}} tập lệnh bị đám mây ghi đè, {{conflict}} xung đột đã tạm dừng. Mở nhật ký để xác nhận.", + "sync_state_error": "Đồng bộ thất bại, đã tạm dừng" } diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index a904b86bd..d3374673f 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -94,7 +94,9 @@ "script_sync_conflict": "云同步冲突", "script_sync_conflict_desc": "脚本 {{scriptNames}} 的本地与云端版本均已修改,已暂停同步该脚本,请手动处理冲突", "subscribe_update": "订阅 {{subscribeName}} 已更新", - "subscribe_update_desc": "新增脚本:{{newScripts}}\n删除脚本:{{deletedScripts}}" + "subscribe_update_desc": "新增脚本:{{newScripts}}\n删除脚本:{{deletedScripts}}", + "script_sync_overwrite": "云同步覆盖提醒", + "script_sync_overwrite_desc": "{{scriptNames}} 等 {{count}} 个脚本已被云端版本覆盖,点击查看同步记录确认" }, "enable_background": { "title": "启用后台运行", @@ -125,5 +127,14 @@ "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": "{{overwrite}} 个脚本被云端覆盖、{{conflict}} 个冲突已暂停,点开日志确认", + "sync_state_error": "同步失败,已暂停" } diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 0ca76295b..13a823c84 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -94,7 +94,9 @@ "script_sync_conflict": "雲端同步衝突", "script_sync_conflict_desc": "腳本 {{scriptNames}} 的本機與雲端版本皆已修改,已暫停同步該腳本,請手動處理衝突", "subscribe_update": "訂閱 {{subscribeName}} 已更新", - "subscribe_update_desc": "新增腳本:{{newScripts}}\n刪除腳本:{{deletedScripts}}" + "subscribe_update_desc": "新增腳本:{{newScripts}}\n刪除腳本:{{deletedScripts}}", + "script_sync_overwrite": "雲同步覆蓋提醒", + "script_sync_overwrite_desc": "{{scriptNames}} 等 {{count}} 個腳本已被雲端版本覆蓋,點擊查看同步記錄確認" }, "enable_background": { "title": "啟用背景運行", @@ -125,5 +127,14 @@ "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": "{{overwrite}} 個腳本被雲端覆蓋、{{conflict}} 個衝突已暫停,點開日誌確認", + "sync_state_error": "同步失敗,已暫停" } diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx index 0b646ed60..af9ac55ea 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx @@ -1,4 +1,4 @@ -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 { create } = vi.hoisted(() => ({ create: vi.fn(() => Promise.resolve({})) })); @@ -33,8 +33,29 @@ 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, +})); + 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 +71,22 @@ function mockCloudSync(over: Record = {}) { }); } +beforeEach(() => { + // 默认:状态读取返回空闲态,订阅返回空清理函数,避免未显式 mock 的用例在 effect 中崩溃 + mockState(); + subscribeCloudSyncState.mockReturnValue(() => {}); + requestCloudSyncOnce.mockResolvedValue(undefined); +}); + afterEach(() => { cleanup(); get.mockReset(); set.mockReset(); create.mockReset(); create.mockResolvedValue({}); + fetchCloudSyncState.mockReset(); + subscribeCloudSyncState.mockReset(); + requestCloudSyncOnce.mockReset(); }); describe("同步分区", () => { @@ -104,4 +135,32 @@ 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)).toContain("overwrite"); + }); + + 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(); + }); }); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index 941b1cf6b..1032fd6b5 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -1,22 +1,46 @@ 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); + const [syncState, setSyncState] = useState(DEFAULT_CLOUD_SYNC_STATE); useEffect(() => { void Promise.resolve(systemConfig.get("cloud_sync")).then((v) => setDraft(v as CloudSyncConfig)); }, []); + // 读取并订阅设备本地同步状态(SW 每轮同步写入 chrome.storage) + useEffect(() => { + void fetchCloudSyncState().then(setSyncState); + return subscribeCloudSyncState(setSyncState); + }, []); + const patch = (next: Partial) => setDraft((d) => (d ? { ...d, ...next } : d)); const save = async () => { @@ -35,11 +59,66 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE notify.success(t("save_success")); }; + const syncNow = async () => { + await requestCloudSyncOnce(); + }; + + 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", { + overwrite: syncState.counts.overwrite, + conflict: syncState.counts.conflict, + }); + break; + case "error": + title = t("settings:sync_state_error"); + desc = syncState.error || ""; + break; + case "syncing": + title = t("settings:sync_state_syncing"); + break; + default: + title = t("settings:sync_state_idle"); + desc = + syncState.lastSyncAt > 0 + ? t("settings:sync_last_at", { time: semTime(new Date(syncState.lastSyncAt)) }) + : t("settings:sync_never"); + } + return (
{draft && (
+ {draft.enable && ( +
+ +
+ {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..5b9931c58 --- /dev/null +++ b/src/pages/options/routes/Setting/sections/syncStatus.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { syncStatusVariant, syncLogHref } from "./syncStatus"; +import { DEFAULT_CLOUD_SYNC_STATE, type CloudSyncState } from "@App/pkg/config/config"; + +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: { total: 5 } }))).toBe("idle"); + }); + + it("warning 深链带 overwrite 过滤,其它只带 service", () => { + expect(decodeURIComponent(syncLogHref("warning"))).toContain("overwrite"); + expect(decodeURIComponent(syncLogHref("idle"))).toContain("synchronize"); + expect(decodeURIComponent(syncLogHref("idle"))).not.toContain("overwrite"); + }); +}); 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..d6ab916fa --- /dev/null +++ b/src/pages/options/routes/Setting/sections/syncStatus.ts @@ -0,0 +1,19 @@ +import { type CloudSyncState } from "@App/pkg/config/config"; + +export type SyncStatusVariant = "idle" | "syncing" | "warning" | "error"; + +// 状态优先级:失败 > 同步中 > 有覆盖/冲突 > 正常 +export function syncStatusVariant(state: CloudSyncState): SyncStatusVariant { + if (state.error) return "error"; + if (state.syncing) return "syncing"; + if (state.counts.overwrite > 0 || state.counts.conflict > 0) return "warning"; + return "idle"; +} + +// 「查看日志」深链:预过滤 service=synchronize;有覆盖时进一步落到 overwrite 行。 +// 由 Logger 页 parseInitialQueries 解析(?query=)。 +export function syncLogHref(variant: SyncStatusVariant): string { + const query: { key: string; value: string }[] = [{ key: "service", value: "synchronize" }]; + if (variant === "warning") query.push({ key: "action", value: "overwrite" }); + return `#/logs?query=${encodeURIComponent(JSON.stringify(query))}`; +} 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 = { From b62e65c9a02990fb9b1ae062920e75d6b1ae9af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 00:50:23 +0800 Subject: [PATCH 090/103] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BA=91=E5=90=8C=E6=AD=A5=E6=9D=A1=E4=BB=B6=E5=86=99=E5=88=A0?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 83 +-- packages/filesystem/baidu/baidu.test.ts | 11 - packages/filesystem/dropbox/dropbox.test.ts | 11 - packages/filesystem/filesystem.test.ts | 43 +- packages/filesystem/filesystem.ts | 28 +- .../googledrive/googledrive.test.ts | 11 - packages/filesystem/limiter.test.ts | 69 --- packages/filesystem/limiter.ts | 26 +- packages/filesystem/onedrive/onedrive.test.ts | 82 --- packages/filesystem/onedrive/onedrive.ts | 22 +- packages/filesystem/onedrive/rw.ts | 32 +- packages/filesystem/onedrive/utils.ts | 1 - packages/filesystem/s3/rw.ts | 13 - packages/filesystem/s3/s3.test.ts | 80 --- packages/filesystem/s3/s3.ts | 19 +- packages/filesystem/s3/utils.ts | 3 - packages/filesystem/webdav/rw.ts | 39 +- packages/filesystem/webdav/utils.ts | 1 - packages/filesystem/webdav/webdav.test.ts | 64 +- packages/filesystem/webdav/webdav.ts | 25 +- .../service_worker/synchronize.test.ts | 546 +----------------- src/app/service/service_worker/synchronize.ts | 178 +----- 22 files changed, 73 insertions(+), 1314 deletions(-) delete mode 100644 packages/filesystem/onedrive/utils.ts delete mode 100644 packages/filesystem/s3/utils.ts delete mode 100644 packages/filesystem/webdav/utils.ts diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 76288b242..d26e3ad0d 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -197,28 +197,10 @@ digest 更新有两条路径,区别在于对账范围: - `.meta.json` `modifiedDate` 使用 `script.updatetime || script.createtime || Date.now()`。 - -写入参数由 `buildPushCreateOptions()` 决定: - -- syncOnce 场景(`hasListSnapshot: true`,文件存在性来自本轮 `fs.list()`): - - 云端文件存在且 provider 支持 `supportsAtomicCompareAndSwap`,并且 `file_digest` 中有旧 digest:传 `expectedDigest` 做 CAS 冲突检测。 - - 云端文件确认不存在:传 `createOnly: true`(支持时)。此时不得回退 `file_digest`——本地残留 digest 必然过期,对不存在的文件做 If-Match 必然 412 且永不自愈。 -- scriptInstall 队列场景(无 list 快照):`file_digest` 有记录(脚本已同步过)→ 传 `expectedDigest` 做 CAS 覆盖;无记录(首次上传)→ 传 `createOnly: true`。 -- provider 未声明能力时,只传 `modifiedDate`,保持旧覆盖语义。 - -当前所有 provider 的 capabilities 均为 false(见「provider 差异」),因此生产路径实际只传 `modifiedDate`;条件参数逻辑保留并有单测覆盖,供后续"按 endpoint 探测能力"方案回开时使用。 +写入使用 provider 的普通覆盖语义,不附加条件请求参数。 `pushScript()` 成功后返回本地计算的 md5 patch,仅用于 provider list 暂时看不到刚上传文件时兜底。它不能覆盖 provider 已返回的原生 digest。 -#### 自我 412 收敛 - -`pushScript()` 捕获到 `.user.js` 写入 conflict、且本轮尚未重试(`selfHealRetried` 未置位)时,调用 `rebaseSelfWrittenConflict()`:读 `sync_content_md5` 取本机上次同步的内容 md5,再 `fs.list()` + 读云端当前内容比对。 - -- 命中(云端内容 == 本机上次所写):以云端当前原生 digest 为新基准,用 `selfHealRetried: true` 重推一次;该标记防止二次冲突时无限重试。 -- 未命中(云端内容 != 本机记录,即真·他端并发编辑):返回 null,维持停在 conflict,绝不覆盖他端。 - -这解决「本机写入成功但 digest 记账失败后再次编辑,拿过期 digest 对自己写的云端内容做 If-Match 而永久 412」的自我死锁;只在识别到云端就是自己所写时才重推,不破坏对真·并发编辑的保守停走语义。 - ### `pullScript()` `pullScript()` 会读取源码和 meta: @@ -241,11 +223,6 @@ digest 更新有两条路径,区别在于对账范围: - 如果 `syncDelete` 为 true,写 tombstone meta。 - 如果 `syncDelete` 为 false,删除 `.meta.json`。 -删除参数由 `buildDeleteOptions()` 决定: - -- provider 支持 `supportsConditionalDelete` 且 `file_digest` 有旧 digest 时,传 `expectedDigest`。 -- 否则走普通 delete。 - 失败会向上抛出。`scriptsDelete()` 必须逐条 catch,保证批量删除中一个 uuid 失败不影响后续 uuid。 ## status 合并 @@ -267,7 +244,7 @@ provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映 | 条件 | `SyncErrorKind` | 语义 | | --- | --- | --- | -| `FileSystemError.conflict` | `conflict` | 条件写删失败或 provider 冲突 | +| `FileSystemError.conflict` | `conflict` | provider 报告文件冲突 | | `FileSystemError.rateLimit` 或 `retryable` | `transient` | 429、瞬时 5xx(500/502/503/504)等可重试错误 | | `FileSystemError.notFound` | `stale_snapshot` | list 到操作之间远端消失或缓存过期 | | `FileSystemError.auth` 或 `WarpTokenError` | `fatal` | 授权失败 | @@ -281,51 +258,36 @@ provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映 `LimiterFileSystem` 对不同操作使用不同重试策略: - 会重试:`verify`、`open`、`read`、`openDir`、`list`、`getDirUrl`。 -- 会重试:带 `expectedDigest` 或 `createOnly` 的 `writer.write()`。 -- 会重试:带 `expectedDigest` 的 `delete()`。 -- 不重试:普通 `create`、`createDir`、普通 `writer.write()`、普通 `delete()`。 +- 不重试:`create`、`createDir`、`writer.write()`、`delete()`。 -原因:没有条件保护的写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 +原因:写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501(如后端不支持条件请求)、505、507 等属于永久失败,不标记可重试,避免 limiter 空转退避。 ## provider 差异 -| Provider | digest 来源 | atomic CAS | create-only | conditional delete | 关键实现 | -| --- | --- | --- | --- | --- | --- | -| WebDAV | `etag` | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(`If-Match` / `overwrite: false`)但同步层不会触发 | -| S3 | `ETag` 去引号 | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(`if-match` / `if-none-match: *`)但同步层不会触发 | -| OneDrive | `eTag` | 不声明 | 不声明 | 不声明 | 条件头映射代码保留(simple PUT / upload session `If-Match`)但同步层不会触发 | -| Google Drive | `md5Checksum` | 不支持 | 不支持 | 不支持 | 先按路径查 fileId,再 PATCH 或 POST;path cache 可能 stale | -| Dropbox | `content_hash` | 不支持 | 不支持 | 不支持 | 先 `exists()`,存在 overwrite,不存在 add;未暴露 rev CAS | -| Baidu | `md5` | 不支持 | 不支持 | 不支持 | precreate/upload/create,`rtype=3` 覆盖;HTTP 429/5xx typed | -| Zip | 空或 zip metadata | 不支持 | 不支持 | 不支持 | 备份用途,不参与云端 CAS | - -**为什么 WebDAV / S3 / OneDrive 不再声明条件能力**(#1504 真实服务端实测结论): - -- WebDAV:两台真实服务端行为相反且都不可用——坚果云对**正确 etag** 的 `If-Match` 也恒 412(CAS 路径完全写不进),Alist 类完全忽略 `If-Match`/`If-None-Match`(条件头是静默 no-op,无任何防覆盖)。没有客户端改动能同时适配两者。 -- S3:MinIO 实测仅 `If-Match` CAS 真原子;`If-None-Match: *` 被忽略(视版本),DeleteObject 本无条件删除语义。 -- OneDrive(Graph):三项实测都生效,但恰恰因为它合规,历史上 `scriptInstall` 的 create-only 误用会 409/412 卡死每次编辑——能力按 provider 类型硬编码的假设整体不成立。 - -结论:capability 不能按协议类型硬编码,只能按具体账号/endpoint 实测探测。在探测机制落地前,所有 provider 保守声明为不支持,同步层回落无条件读写 + 内容基线方向判定 + 冲突停走通知(最终一致,不会卡死)。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 | 空或 zip metadata | 普通覆盖写入 | 备份用途 | ### WebDAV 维护时注意: -- `capabilities` 三项均为 false(原因见上表说明)。 -- 条件头映射代码保留:ETag 写入和删除时 `quoteETag()` 补齐引号(坚果云 PROPFIND 的 etag 不带引号且只认不带引号的 `If-Match`,是能力关闭的原因之一)。 -- create-only 依赖 `putFileContents(..., { overwrite: false })`。 -- `putFileContents()` 返回 false 时,在 create-only 场景转 typed conflict。 +- `putFileContents()` 返回 false 时转为写入失败。 - 删除 404 视为幂等成功。 ### S3 维护时注意: -- `capabilities` 三项均为 false(原因见上表说明)。 - list 返回的 `ETag` 会去掉引号作为 digest。 -- 条件头映射代码保留:写入 `expectedDigest` 时发送 `if-match`;create-only 发送 `if-none-match: *`;删除 `expectedDigest` 时发送 `if-match`。 - `PreconditionFailed` / 412 转 typed conflict。 - `NoSuchKey` 删除视为成功。 @@ -333,24 +295,21 @@ typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501(如后端不 维护时注意: -- `capabilities` 三项均为 false(原因见上表说明)。 - list 使用 `eTag` 作为 digest。 - 空内容走 simple PUT,非空内容走 upload session。 -- 条件写会把 `If-Match` / `If-None-Match` 放到 simple PUT 或 upload session 创建请求。 - upload session URL 不带 bearer token,request 层保留这个特殊路径。 - read/delete 使用 raw `Response` 路径,需要手动转 typed error。 ### Google Drive -Google Drive 当前不声明 atomic 能力。维护时注意: +Google Drive 维护时注意: - digest 来自 `md5Checksum`。 - 目录和文件通过 appDataFolder + path cache 查 fileId。 -- 写入是“先查同名文件,再 PATCH 或 POST”,不是 atomic CAS。 -- 删除是“先查 fileId,再 DELETE”,不是 conditional delete。 +- 写入是“先查同名文件,再 PATCH 或 POST”。 +- 删除是“先查 fileId,再 DELETE”。 - reader path lookup miss 已转 typed notFound。 - path cache stale 时 writer/list 会清缓存并重试一次。 -- 不要把 `fileId` 或 `fileId:version` 包装成通用 version 语义。 ### Dropbox @@ -366,7 +325,7 @@ Dropbox 当前不声明 atomic 能力。维护时注意: ### Baidu -Baidu 当前不声明 atomic 能力。维护时注意: +Baidu 维护时注意: - digest 来自 `md5`。 - 写入流程是 precreate、upload、create,`rtype=3` 覆盖。 @@ -375,11 +334,10 @@ Baidu 当前不声明 atomic 能力。维护时注意: - 2xx 非 JSON 响应(如代理返回 HTML)会报错,不能当作成功——否则 list 会被判空触发全量覆盖。 - `filemetas` 空列表强制转 typed notFound(errno -9);服务端返回的其他 errno 走通用 errno 分类,不一定是 notFound。 - request 显式 `credentials: "omit"`,不要重新依赖全局 DNR 规则。 -- Baidu 没有被声明为 create-only 或 CAS provider。 ### Zip -ZipFileSystem 主要服务备份/导出,不应接入云同步 CAS 语义。它不声明 capabilities。 +ZipFileSystem 主要服务备份/导出。 ## 覆盖/冲突可见性 @@ -432,7 +390,6 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name 5. orphan `.user.js` 是否仍被跳过而不是删除。 6. 单文件失败是否只保留该文件旧 digest。 7. provider 原生 digest 是否被本地 md5 覆盖。 -8. 非 atomic provider 是否被错误声明 capability。 ## 不要做的事 @@ -440,7 +397,6 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name - 不要在 `pullScript()` 或 `deleteCloudScript()` 内 catch 后吞掉真实失败。 - 不要让失败文件推进 digest。 - 不要在无法读取远端 `scriptcat-sync.json` 时覆盖写回。 -- 不要把 Google Drive / Baidu 的 preflight 当 atomic CAS。 - 不要把 Dropbox `content_hash` 当 rev。 - 不要新增 `tombstone_digest`,除非同时定义 GC 和兼容策略。 - 不要对普通无条件写入开启 transient retry。 @@ -466,4 +422,3 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name 13. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 - diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index fac43f71f..5e33606d4 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { initTestEnv } from "@Tests/utils"; import { isNotFoundError, isRateLimitError } from "../error"; -import { getFileSystemCapabilities } from "../filesystem"; import BaiduFileSystem from "./baidu"; initTestEnv(); @@ -12,16 +11,6 @@ describe("BaiduFileSystem", () => { vi.restoreAllMocks(); }); - it("不应声明原子条件写入能力", () => { - const fs = new BaiduFileSystem("/apps", "token"); - - expect(getFileSystemCapabilities(fs)).toEqual({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }); - }); - it("request should omit credentials without using global DNR rules", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 54411c980..f88aa0ae7 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FileSystemError } from "../error"; -import { getFileSystemCapabilities } from "../filesystem"; import DropboxFileSystem from "./dropbox"; describe("DropboxFileSystem", () => { @@ -12,16 +11,6 @@ describe("DropboxFileSystem", () => { vi.unstubAllGlobals(); }); - it("不应声明原子条件写入能力", () => { - const fs = new DropboxFileSystem("/", "token"); - - expect(getFileSystemCapabilities(fs)).toEqual({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }); - }); - it("request should throw typed not found error", async () => { const fs = new DropboxFileSystem("/", "token"); vi.stubGlobal( diff --git a/packages/filesystem/filesystem.test.ts b/packages/filesystem/filesystem.test.ts index 7bef88c5e..2cccb86f9 100644 --- a/packages/filesystem/filesystem.test.ts +++ b/packages/filesystem/filesystem.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; import type FileSystem from "./filesystem"; -import { getFileSystemCapabilities } from "./filesystem"; import LimiterFileSystem from "./limiter"; function createFs(overrides: Partial = {}): FileSystem { @@ -17,44 +16,10 @@ function createFs(overrides: Partial = {}): FileSystem { }; } -describe("FileSystem capabilities", () => { - it("未声明能力时应当默认不支持原子同步能力", () => { - const fs = createFs(); +describe("FileSystem 公共接口", () => { + it("LimiterFileSystem 不应暴露已移除的条件操作能力", () => { + const limiter = new LimiterFileSystem(createFs()); - expect(getFileSystemCapabilities(fs)).toEqual({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }); - }); - - it("应当合并 provider 显式声明的能力", () => { - const fs = createFs({ - capabilities: { - supportsCreateOnly: true, - }, - }); - - expect(getFileSystemCapabilities(fs)).toEqual({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: true, - supportsConditionalDelete: false, - }); - }); - - it("LimiterFileSystem 应当透传底层 provider 能力", () => { - const fs = createFs({ - capabilities: { - supportsAtomicCompareAndSwap: true, - supportsConditionalDelete: true, - }, - }); - const limiter = new LimiterFileSystem(fs); - - expect(getFileSystemCapabilities(limiter)).toEqual({ - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: false, - supportsConditionalDelete: true, - }); + expect("capabilities" in limiter).toBe(false); }); }); diff --git a/packages/filesystem/filesystem.ts b/packages/filesystem/filesystem.ts index 5fa2c6259..be40308f0 100644 --- a/packages/filesystem/filesystem.ts +++ b/packages/filesystem/filesystem.ts @@ -29,36 +29,10 @@ export type FileReadWriter = FileReader & FileWriter; export type FileCreateOptions = { modifiedDate?: number; - expectedDigest?: string; - createOnly?: boolean; }; -export type FileDeleteOptions = { - expectedDigest?: string; -}; - -export type FileSystemCapabilities = { - supportsAtomicCompareAndSwap: boolean; - supportsCreateOnly: boolean; - supportsConditionalDelete: boolean; -}; - -export const DEFAULT_FILE_SYSTEM_CAPABILITIES: FileSystemCapabilities = { - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, -}; - -export function getFileSystemCapabilities(fs: FileSystem): FileSystemCapabilities { - return { - ...DEFAULT_FILE_SYSTEM_CAPABILITIES, - ...fs.capabilities, - }; -} - // 文件读取 export default interface FileSystem { - readonly capabilities?: Partial; // 授权验证 verify(): Promise; // 打开文件 @@ -70,7 +44,7 @@ export default interface FileSystem { // 创建目录 createDir(dir: string, opts?: FileCreateOptions): Promise; // 删除文件 - delete(path: string, opts?: FileDeleteOptions): Promise; + delete(path: string): Promise; // 文件列表 list(): Promise; // getDirUrl 获取目录的url diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index f036512e7..5a445cae5 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error"; -import { getFileSystemCapabilities } from "../filesystem"; import { joinPath } from "../utils"; import GoogleDriveFileSystem from "./googledrive"; @@ -30,16 +29,6 @@ describe("GoogleDriveFileSystem", () => { vi.stubGlobal("fetch", originalFetch); }); - it("不应声明原子条件写入能力", () => { - const fs = new GoogleDriveFileSystem("/", "token"); - - expect(getFileSystemCapabilities(fs)).toEqual({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }); - }); - it("delete should be idempotent when file id is missing", async () => { const fs = new GoogleDriveFileSystem("/", "token"); vi.spyOn(fs, "getFileId").mockResolvedValue(null); diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts index 65937f4c6..a79f953a7 100644 --- a/packages/filesystem/limiter.test.ts +++ b/packages/filesystem/limiter.test.ts @@ -244,75 +244,6 @@ describe("LimiterFileSystem", () => { expect(write).toHaveBeenCalledTimes(1); }); - it("受 expectedDigest 保护的 writer.write 遇到 typed retryable 错误时应当重试", async () => { - vi.useFakeTimers(); - const fs = createFs(); - const write = vi.fn(async () => {}); - const retryableError = new FileSystemError({ - provider: "s3", - message: "Service unavailable", - status: 503, - retryable: true, - }); - vi.mocked(fs.create).mockResolvedValueOnce({ - write, - }); - write.mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); - const limiter = new LimiterFileSystem(fs); - const writer = await limiter.create(file.path, { expectedDigest: "digest" }); - - const promise = writer.write("content"); - await vi.runOnlyPendingTimersAsync(); - - await expect(promise).resolves.toBeUndefined(); - expect(write).toHaveBeenCalledTimes(2); - }); - - it("受 createOnly 保护的 writer.write 遇到 typed retryable 错误时应当重试", async () => { - vi.useFakeTimers(); - const fs = createFs(); - const write = vi.fn(async () => {}); - const retryableError = new FileSystemError({ - provider: "webdav", - message: "Service unavailable", - status: 503, - retryable: true, - }); - vi.mocked(fs.create).mockResolvedValueOnce({ - write, - }); - write.mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); - const limiter = new LimiterFileSystem(fs); - const writer = await limiter.create(file.path, { createOnly: true }); - - const promise = writer.write("content"); - await vi.runOnlyPendingTimersAsync(); - - await expect(promise).resolves.toBeUndefined(); - expect(write).toHaveBeenCalledTimes(2); - }); - - it("受 expectedDigest 保护的 delete 遇到 typed retryable 错误时应当重试", async () => { - vi.useFakeTimers(); - const fs = createFs(); - const retryableError = new FileSystemError({ - provider: "onedrive", - message: "Service unavailable", - status: 503, - retryable: true, - }); - vi.mocked(fs.delete).mockRejectedValueOnce(retryableError).mockResolvedValueOnce(undefined); - const limiter = new LimiterFileSystem(fs); - - const promise = limiter.delete(file.path, { expectedDigest: "digest" }); - await vi.runOnlyPendingTimersAsync(); - - await expect(promise).resolves.toBeUndefined(); - expect(fs.delete).toHaveBeenCalledTimes(2); - // 守卫 opts 透传:若 limiter 丢掉 expectedDigest,条件删除会退化成无条件删除 - expect(fs.delete).toHaveBeenCalledWith(file.path, { expectedDigest: "digest" }); - }); - 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 84847915f..1c1bed37a 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,18 +1,8 @@ import type FileSystem from "./filesystem"; -import type { FileCreateOptions, FileDeleteOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; -import { getFileSystemCapabilities, type FileSystemCapabilities } from "./filesystem"; +import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; import { FileSystemError } from "./error"; -const RETRYABLE_TRANSIENT_OPS = new Set([ - "verify", - "open", - "read", - "openDir", - "list", - "getDirUrl", - "conditionalWrite", - "conditionalDelete", -]); +const RETRYABLE_TRANSIENT_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); /** * 速率限制器 @@ -107,10 +97,6 @@ export default class LimiterFileSystem implements FileSystem { this.limiter = limiter || new RateLimiter(); } - get capabilities(): FileSystemCapabilities { - return getFileSystemCapabilities(this.fs); - } - verify(): Promise { return this.limiter.execute(() => this.fs.verify(), "verify"); } @@ -134,9 +120,8 @@ export default class LimiterFileSystem implements FileSystem { async create(path: string, opts?: FileCreateOptions): Promise { return this.limiter.execute(async () => { const writer = await this.fs.create(path, opts); - const writeOp = opts?.expectedDigest || opts?.createOnly ? "conditionalWrite" : "write"; return { - write: (content) => this.limiter.execute(() => writer.write(content), writeOp), + write: (content) => this.limiter.execute(() => writer.write(content), "write"), }; }, "create"); } @@ -145,9 +130,8 @@ export default class LimiterFileSystem implements FileSystem { return this.limiter.execute(() => this.fs.createDir(dir, opts), "createDir"); } - delete(path: string, opts?: FileDeleteOptions): Promise { - const op = opts?.expectedDigest ? "conditionalDelete" : "delete"; - return this.limiter.execute(() => this.fs.delete(path, opts), op); + delete(path: string): Promise { + return this.limiter.execute(() => this.fs.delete(path), "delete"); } list(): Promise { diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index 340993af6..358c7db8a 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -28,16 +28,6 @@ describe("OneDriveFileSystem", () => { vi.stubGlobal("fetch", originalFetch); }); - it("不应声明条件写删能力:与其余 provider 一致回落无条件读写,避免同步层行为分裂", () => { - const fs = new OneDriveFileSystem("/", "token"); - - expect((fs as any).capabilities).toMatchObject({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }); - }); - it("request should return retry result after token refresh", async () => { await localStorageDAO.saveValue("netdisk:token:onedrive", { accessToken: "expired-token", @@ -196,24 +186,6 @@ describe("OneDriveFileSystem", () => { ); }); - it("条件删除应当将 expectedDigest 转成 If-Match", async () => { - const fs = new OneDriveFileSystem("/", "token"); - const request = vi.spyOn(fs, "request").mockResolvedValue({ status: 204 }); - - await (fs as any).delete("test.txt", { expectedDigest: "abc123" }); - - expect(request).toHaveBeenCalledWith( - "https://graph.microsoft.com/v1.0/me/drive/special/approot:/test.txt", - { - method: "DELETE", - headers: expect.objectContaining({ - "If-Match": '"abc123"', - }), - }, - true - ); - }); - it("createDir should create nested directories from root", async () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); @@ -432,30 +404,6 @@ describe("OneDriveFileSystem", () => { expect((requestSpy.mock.calls[0][1] as RequestInit).body).toBe(emptyBlob); }); - it("空内容 simple PUT 条件写入应携带 HTTP 引号形式的 If-Match", async () => { - const fs = new OneDriveFileSystem("/", "token"); - const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); - - const writer = await fs.create("empty.txt", { expectedDigest: "abc123" }); - await writer.write(""); - - expect(requestSpy).toHaveBeenCalledTimes(1); - const config = requestSpy.mock.calls[0][1] as RequestInit; - expect((config.headers as Headers).get("If-Match")).toBe('"abc123"'); - }); - - it("空内容 simple PUT create-only 应携带 If-None-Match: *", async () => { - const fs = new OneDriveFileSystem("/", "token"); - const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); - - const writer = await fs.create("empty.txt", { createOnly: true }); - await writer.write(""); - - expect(requestSpy).toHaveBeenCalledTimes(1); - const config = requestSpy.mock.calls[0][1] as RequestInit; - expect((config.headers as Headers).get("If-None-Match")).toBe("*"); - }); - it("writer should keep upload session for non-empty content", async () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi @@ -474,34 +422,4 @@ describe("OneDriveFileSystem", () => { const headers = (requestSpy.mock.calls[1][1] as RequestInit).headers as Headers; expect(headers.get("Content-Range")).toBe("bytes 0-2/3"); }); - - it("条件写入应当将 expectedDigest 传给 upload session 的 If-Match", async () => { - const fs = new OneDriveFileSystem("/", "token"); - const requestSpy = vi - .spyOn(fs, "request") - .mockResolvedValueOnce({ uploadUrl: "https://upload.example/session" }) - .mockResolvedValueOnce({}); - - const writer = await (fs as any).create("not-empty.txt", { expectedDigest: "abc123" }); - await writer.write("abc"); - - const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers; - expect(headers.get("If-Match")).toBe('"abc123"'); - }); - - it("createOnly 写入应当将 If-None-Match 传给 upload session 并使用 fail 语义", async () => { - const fs = new OneDriveFileSystem("/", "token"); - const requestSpy = vi - .spyOn(fs, "request") - .mockResolvedValueOnce({ uploadUrl: "https://upload.example/session" }) - .mockResolvedValueOnce({}); - - const writer = await (fs as any).create("not-empty.txt", { createOnly: true }); - await writer.write("abc"); - - const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers; - const body = JSON.parse((requestSpy.mock.calls[0][1] as RequestInit).body as string); - expect(headers.get("If-None-Match")).toBe("*"); - expect(body.item["@microsoft.graph.conflictBehavior"]).toBe("fail"); - }); }); diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index dcff8bf65..ac5a45565 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -1,20 +1,11 @@ import { AuthVerify } from "../auth"; import { FileSystemError } from "../error"; -import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem"; +import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import type FileSystem from "../filesystem"; import { joinPath } from "../utils"; import { OneDriveFileReader, OneDriveFileWriter } from "./rw"; -import { quoteETag } from "./utils"; export default class OneDriveFileSystem implements FileSystem { - // Graph 实测三项条件原语都生效,但同步层统一走无条件读写 + 最终一致(#1504 结论), - // 避免同一套同步逻辑在不同 provider 上行为分裂;待按 endpoint 探测能力的后续 PR 再回开 - readonly capabilities = { - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }; - accessToken?: string; path: string; @@ -41,8 +32,8 @@ export default class OneDriveFileSystem implements FileSystem { return new OneDriveFileSystem(joinPath(this.path, path), this.accessToken); } - async create(path: string, opts?: FileCreateOptions): Promise { - return new OneDriveFileWriter(this, joinPath(this.path, path), opts); + async create(path: string, _opts?: FileCreateOptions): Promise { + return new OneDriveFileWriter(this, joinPath(this.path, path)); } async createDir(dir: string, _opts?: FileCreateOptions): Promise { @@ -193,15 +184,10 @@ export default class OneDriveFileSystem implements FileSystem { }); } - async delete(path: string, opts?: FileDeleteOptions): Promise { + async delete(path: string): Promise { const config: RequestInit = { method: "DELETE", }; - if (opts?.expectedDigest) { - config.headers = { - "If-Match": quoteETag(opts.expectedDigest), - }; - } const resp = await this.request( `https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath(this.path, path)}`, config, diff --git a/packages/filesystem/onedrive/rw.ts b/packages/filesystem/onedrive/rw.ts index 16f2e6353..86f7788f9 100644 --- a/packages/filesystem/onedrive/rw.ts +++ b/packages/filesystem/onedrive/rw.ts @@ -1,8 +1,7 @@ import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto"; -import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "../filesystem"; +import type { FileInfo, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import type OneDriveFileSystem from "./onedrive"; -import { quoteETag } from "./utils"; export class OneDriveFileReader implements FileReader { file: FileInfo; @@ -38,12 +37,9 @@ export class OneDriveFileWriter implements FileWriter { fs: OneDriveFileSystem; - opts?: FileCreateOptions; - - constructor(fs: OneDriveFileSystem, path: string, opts?: FileCreateOptions) { + constructor(fs: OneDriveFileSystem, path: string) { this.fs = fs; this.path = path; - this.opts = opts; } size(content: string | Blob) { @@ -68,26 +64,18 @@ export class OneDriveFileWriter implements FileWriter { method: "PUT", body: content, }; - const writeHeaders = this.buildConditionalHeaders(); - if (writeHeaders) { - config.headers = writeHeaders; - } return this.fs.request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/content`, config); } let myHeaders = new Headers(); myHeaders.append("Content-Type", "application/json"); - const conditionHeaders = this.buildConditionalHeaders(); - if (conditionHeaders) { - conditionHeaders.forEach((value, key) => myHeaders.set(key, value)); - } const uploadUrl = await this.fs .request(`https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/createUploadSession`, { method: "POST", headers: myHeaders, body: JSON.stringify({ item: { - "@microsoft.graph.conflictBehavior": this.opts?.createOnly ? "fail" : "replace", + "@microsoft.graph.conflictBehavior": "replace", // description: "description", // fileSystemInfo: { // "@odata.type": "microsoft.graph.fileSystemInfo", @@ -110,18 +98,4 @@ export class OneDriveFileWriter implements FileWriter { headers: myHeaders, }); } - - private buildConditionalHeaders(): Headers | undefined { - if (this.opts?.expectedDigest) { - const headers = new Headers(); - headers.set("If-Match", quoteETag(this.opts.expectedDigest)); - return headers; - } - if (this.opts?.createOnly) { - const headers = new Headers(); - headers.set("If-None-Match", "*"); - return headers; - } - return undefined; - } } diff --git a/packages/filesystem/onedrive/utils.ts b/packages/filesystem/onedrive/utils.ts deleted file mode 100644 index 2028bdc15..000000000 --- a/packages/filesystem/onedrive/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts index 2cc6c9d6e..595f9d2e2 100644 --- a/packages/filesystem/s3/rw.ts +++ b/packages/filesystem/s3/rw.ts @@ -1,7 +1,6 @@ import type { S3Client } from "./client"; import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { createS3FileSystemError } from "./error"; -import { quoteETag } from "./utils"; /** * S3 文件读取器 @@ -52,17 +51,11 @@ export class S3FileWriter implements FileWriter { modifiedDate?: number; - expectedDigest?: string; - - createOnly?: boolean; - constructor(client: S3Client, bucket: string, key: string, opts?: FileCreateOptions) { this.client = client; this.bucket = bucket; this.key = key; this.modifiedDate = opts?.modifiedDate; - this.expectedDigest = opts?.expectedDigest; - this.createOnly = opts?.createOnly; } /** @@ -80,12 +73,6 @@ export class S3FileWriter implements FileWriter { // 历史兼容:S3 侧使用 createtime 元数据保存文件时间,实际来源是 FileCreateOptions.modifiedDate。 headers["x-amz-meta-createtime"] = new Date(this.modifiedDate).toISOString(); } - if (this.expectedDigest) { - headers["if-match"] = quoteETag(this.expectedDigest); - } else if (this.createOnly) { - headers["if-none-match"] = "*"; - } - try { await this.client.request("PUT", this.bucket, this.key, { body: typeof body === "string" ? body : body, diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index e8f477986..99484cf8f 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { isConflictError } from "../error"; import S3FileSystem from "./s3"; import { S3Client, S3Error } from "./client"; import type { FileInfo } from "../filesystem"; @@ -48,14 +47,6 @@ describe("S3FileSystem", () => { fs = new S3FileSystem("test-bucket", mockClient); }); - it("不应声明条件写删能力:MinIO 实测忽略 If-None-Match:*,DeleteObject 无条件删除语义", () => { - expect((fs as any).capabilities).toMatchObject({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }); - }); - // ---- verify ---- describe("verify", () => { it("应当成功验证 bucket", async () => { @@ -258,52 +249,6 @@ describe("S3FileSystem", () => { ); }); - it("条件写入应当将 expectedDigest 转成 If-Match", async () => { - (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); - - const writer = await (fs as any).create("output.txt", { expectedDigest: "abc123" }); - await writer.write("hello world"); - - expect(mockClient.request).toHaveBeenCalledWith( - "PUT", - "test-bucket", - "output.txt", - expect.objectContaining({ - headers: expect.objectContaining({ - "if-match": '"abc123"', - }), - }) - ); - }); - - it("createOnly 写入应当使用 If-None-Match", async () => { - (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); - - const writer = await (fs as any).create("new.txt", { createOnly: true }); - await writer.write("hello world"); - - expect(mockClient.request).toHaveBeenCalledWith( - "PUT", - "test-bucket", - "new.txt", - expect.objectContaining({ - headers: expect.objectContaining({ - "if-none-match": "*", - }), - }) - ); - }); - - it("createOnly 写入遇到 412 时应当抛出 typed conflict 错误", async () => { - (mockClient.request as ReturnType).mockRejectedValue( - new S3Error("PreconditionFailed", "At least one precondition failed", 412) - ); - - const writer = await (fs as any).create("new.txt", { createOnly: true }); - - await expect(writer.write("hello world")).rejects.toSatisfy(isConflictError); - }); - it("normalizes double slashes in object keys", async () => { const subFs = new S3FileSystem("test-bucket", mockClient, "/ScriptCat//sync"); @@ -353,31 +298,6 @@ describe("S3FileSystem", () => { expect(mockClient.request).toHaveBeenCalledWith("DELETE", "test-bucket", "ScriptCat/sync/dir/file.user.js"); }); - - it("条件删除应当将 expectedDigest 转成 If-Match", async () => { - (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true, status: 204 })); - - await (fs as any).delete("test.txt", { expectedDigest: "abc123" }); - - expect(mockClient.request).toHaveBeenCalledWith( - "DELETE", - "test-bucket", - "test.txt", - expect.objectContaining({ - headers: { - "if-match": '"abc123"', - }, - }) - ); - }); - - it("条件删除遇到 412 时应当抛出 typed conflict 错误", async () => { - (mockClient.request as ReturnType).mockRejectedValue( - new S3Error("PreconditionFailed", "At least one precondition failed", 412) - ); - - await expect((fs as any).delete("test.txt", { expectedDigest: "abc123" })).rejects.toSatisfy(isConflictError); - }); }); // ---- list ---- diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts index 78046189b..c0c9a671d 100644 --- a/packages/filesystem/s3/s3.ts +++ b/packages/filesystem/s3/s3.ts @@ -2,12 +2,11 @@ import { XMLParser } from "fast-xml-parser"; import { S3Client, S3Error } from "./client"; import type { S3ClientConfig } from "./client"; import type FileSystem from "../filesystem"; -import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem"; +import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import { S3FileReader, S3FileWriter } from "./rw"; import { WarpTokenError } from "../error"; import { createS3FileSystemError } from "./error"; -import { quoteETag } from "./utils"; // ---- ListObjectsV2 XML 解析 ---- @@ -54,14 +53,6 @@ function parseListObjectsV2(xml: string): ListObjectsV2Result { * 使用原生 fetch + AWS Signature V4 签名,不依赖 @aws-sdk/client-s3 */ export default class S3FileSystem implements FileSystem { - // S3 兼容服务端对条件写删的支持因实现/版本而异(MinIO 实测忽略 If-None-Match:*, - // DeleteObject 本无 If-Match 语义),统一保守声明为不支持,回落无条件读写 + 最终一致(#1504 实测结论) - readonly capabilities = { - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }; - client: S3Client; bucket: string; @@ -192,15 +183,9 @@ export default class S3FileSystem implements FileSystem { * 此操作幂等——删除不存在的文件也会成功 * @param path 相对于当前 basePath 的文件路径 */ - async delete(path: string, opts?: FileDeleteOptions): Promise { + async delete(path: string): Promise { try { const key = joinPath(this.basePath, path).substring(1); - if (opts?.expectedDigest) { - await this.client.request("DELETE", this.bucket, key, { - headers: { "if-match": quoteETag(opts.expectedDigest) }, - }); - return; - } await this.client.request("DELETE", this.bucket, key); } catch (error: any) { // S3 delete 是幂等的,key 不存在时也视为成功 diff --git a/packages/filesystem/s3/utils.ts b/packages/filesystem/s3/utils.ts deleted file mode 100644 index 641ec18f6..000000000 --- a/packages/filesystem/s3/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function quoteETag(digest: string): string { - return digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`; -} diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts index 337b70a6c..9a03eefe9 100644 --- a/packages/filesystem/webdav/rw.ts +++ b/packages/filesystem/webdav/rw.ts @@ -1,8 +1,7 @@ import type { WebDAVClient } from "webdav"; -import type { FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import type { FileReader, FileWriter } from "../filesystem"; import { FileSystemError } from "../error"; import { createWebDAVFileSystemError } from "./error"; -import { quoteETag } from "./utils"; export class WebDAVFileReader implements FileReader { client: WebDAVClient; @@ -39,36 +38,20 @@ export class WebDAVFileWriter implements FileWriter { path: string; - opts?: FileCreateOptions; - - constructor(client: WebDAVClient, path: string, opts?: FileCreateOptions) { + constructor(client: WebDAVClient, path: string) { this.client = client; this.path = path; - this.opts = opts; } async write(content: string | Blob): Promise { const data = content instanceof Blob ? await content.arrayBuffer() : content; let resp: boolean; try { - const opts = this.buildWriteOptions(); - if (opts) { - resp = await this.client.putFileContents(this.path, data, opts); - } else { - resp = await this.client.putFileContents(this.path, data); - } + resp = await this.client.putFileContents(this.path, data); } catch (error) { throw createWebDAVFileSystemError(error); } if (!resp) { - if (this.opts?.createOnly) { - throw new FileSystemError({ - provider: "webdav", - message: "WebDAV create-only write conflict", - status: 412, - conflict: true, - }); - } throw new FileSystemError({ provider: "webdav", message: "WebDAV write failed", @@ -77,20 +60,4 @@ export class WebDAVFileWriter implements FileWriter { }); } } - - private buildWriteOptions() { - if (this.opts?.expectedDigest) { - return { - headers: { - "If-Match": quoteETag(this.opts.expectedDigest), - }, - }; - } - if (this.opts?.createOnly) { - return { - overwrite: false, - }; - } - return undefined; - } } diff --git a/packages/filesystem/webdav/utils.ts b/packages/filesystem/webdav/utils.ts deleted file mode 100644 index 2028bdc15..000000000 --- a/packages/filesystem/webdav/utils.ts +++ /dev/null @@ -1 +0,0 @@ -export const quoteETag = (digest: string) => (digest.startsWith('"') && digest.endsWith('"') ? digest : `"${digest}"`); diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 140d9b6e0..512eb8ec6 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { WebDAVClient } from "webdav"; import { getPatcher } from "webdav"; import WebDAVFileSystem from "./webdav"; -import { isConflictError, WarpTokenError } from "../error"; +import { WarpTokenError } from "../error"; /** 创建 mock WebDAVClient */ function createMockClient(overrides?: Partial): WebDAVClient { @@ -32,16 +32,6 @@ describe("WebDAVFileSystem", () => { mockClient = createMockClient(); }); - it("不应声明条件写删能力:真实服务端行为不一致(坚果云恒 412、Alist 类忽略 If-Match)", () => { - const fs = createTestFS(mockClient); - - expect((fs as any).capabilities).toMatchObject({ - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }); - }); - describe("initWebDAVPatch", () => { it("应当通过 getPatcher 注册 fetch patch,设置 credentials 为 omit", () => { // fromCredentials 内部调用 initWebDAVPatch,验证 patcher 已注册 fetch @@ -216,18 +206,6 @@ describe("WebDAVFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); - - it("条件删除应当将 expectedDigest 转成 If-Match", async () => { - const fs = createTestFS(mockClient); - - await (fs as any).delete("test.txt", { expectedDigest: '"abc123"' }); - - expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt", { - headers: { - "If-Match": '"abc123"', - }, - }); - }); }); describe("create", () => { @@ -241,46 +219,6 @@ describe("WebDAVFileSystem", () => { expect((writer as any).path).toBe("/ScriptCat/sync/dir/file.user.js"); }); - - it("条件写入应当将 expectedDigest 转成 If-Match", async () => { - const fs = createTestFS(mockClient); - const writer = await (fs as any).create("test.txt", { expectedDigest: '"abc123"' }); - - await writer.write("content"); - - expect(mockClient.putFileContents).toHaveBeenCalledWith( - "/test.txt", - "content", - expect.objectContaining({ - headers: { - "If-Match": '"abc123"', - }, - }) - ); - }); - - it("createOnly 写入应当使用 WebDAV overwrite=false", async () => { - const fs = createTestFS(mockClient); - const writer = await (fs as any).create("new.txt", { createOnly: true }); - - await writer.write("content"); - - expect(mockClient.putFileContents).toHaveBeenCalledWith( - "/new.txt", - "content", - expect.objectContaining({ - overwrite: false, - }) - ); - }); - - it("createOnly 冲突返回 false 时应当抛出 typed conflict 错误", async () => { - (mockClient.putFileContents as ReturnType).mockResolvedValue(false); - const fs = createTestFS(mockClient); - const writer = await (fs as any).create("exists.txt", { createOnly: true }); - - await expect(writer.write("content")).rejects.toSatisfy(isConflictError); - }); }); describe("open", () => { diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index ae5793d68..33cedada9 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -6,8 +6,6 @@ import { joinPath } from "../utils"; import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; import { WarpTokenError } from "../error"; import { createWebDAVFileSystemError } from "./error"; -import type { FileDeleteOptions } from "../filesystem"; -import { quoteETag } from "./utils"; // 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) // 全局单次注册 @@ -27,15 +25,6 @@ const initWebDAVPatch = () => { }; export default class WebDAVFileSystem implements FileSystem { - // 真实 WebDAV 服务端对条件头的兑现极不一致且方向相反(坚果云对正确 etag 的 If-Match 也恒 412, - // Alist 类完全忽略 If-Match/If-None-Match),任何一项都不能依赖,保守声明为不支持, - // 回落无条件读写 + 最终一致(#1504 实测结论) - readonly capabilities = { - supportsAtomicCompareAndSwap: false, - supportsCreateOnly: false, - supportsConditionalDelete: false, - }; - client: WebDAVClient; url: string; @@ -87,8 +76,8 @@ export default class WebDAVFileSystem implements FileSystem { return WebDAVFileSystem.fromSameClient(this, joinPath(this.basePath, path)); } - async create(path: string, opts?: FileCreateOptions): Promise { - return new WebDAVFileWriter(this.client, joinPath(this.basePath, path), opts); + async create(path: string, _opts?: FileCreateOptions): Promise { + return new WebDAVFileWriter(this.client, joinPath(this.basePath, path)); } async createDir(path: string, _opts?: FileCreateOptions): Promise { @@ -103,16 +92,8 @@ export default class WebDAVFileSystem implements FileSystem { } } - async delete(path: string, opts?: FileDeleteOptions): Promise { + async delete(path: string): Promise { try { - if (opts?.expectedDigest) { - await this.client.deleteFile(joinPath(this.basePath, path), { - headers: { - "If-Match": quoteETag(opts.expectedDigest), - }, - }); - return; - } await this.client.deleteFile(joinPath(this.basePath, path)); } catch (e: any) { if (e.response?.status === 404 || e.message?.includes("404")) { diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 883a8f3b6..f73574b00 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1302,231 +1302,6 @@ console.log("ok");` }); }); - it("push 已有云端文件时应当用旧 digest 作为 expectedDigest", 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({ - capabilities: { - supportsAtomicCompareAndSwap: true, - }, - list: vi - .fn() - .mockResolvedValueOnce([ - { - name: "push-uuid.user.js", - path: "push-uuid.user.js", - size: 1, - digest: "cloud-user-new", - createtime: 1, - updatetime: 1, - }, - { - name: "push-uuid.meta.json", - path: "push-uuid.meta.json", - size: 1, - digest: "cloud-meta-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", - "push-uuid.meta.json": "old-meta-digest", - }); - - await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - - expect(fs.create).toHaveBeenCalledWith( - "push-uuid.user.js", - expect.objectContaining({ expectedDigest: "old-user-digest" }) - ); - expect(fs.create).toHaveBeenCalledWith( - "push-uuid.meta.json", - expect.objectContaining({ expectedDigest: "old-meta-digest" }) - ); - }); - - it("CAS 冲突时云端内容是本机上次所写则重挂基准重推,收敛自我 412", async () => { - const script = { - uuid: "sh-uuid", - name: "sh", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 20, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const written: { name: string; content: string }[] = []; - const conflictError = new FileSystemError({ - provider: "webdav", - message: "precondition failed", - status: 412, - conflict: true, - }); - const fs = createFs({ - capabilities: { supportsAtomicCompareAndSwap: true }, - // 首次用过期基准 d0 做 If-Match → 412(自己上次写入把云端推到了 e1,但 digest 记账失败) - create: vi.fn().mockImplementation(async (name: string, opts: { expectedDigest?: string }) => { - if (name === "sh-uuid.user.js" && opts?.expectedDigest === "d0-stale") { - throw conflictError; - } - return { - write: vi.fn().mockImplementation(async (content: string) => { - written.push({ name, content }); - }), - }; - }), - list: vi.fn().mockResolvedValue([ - { name: "sh-uuid.user.js", path: "sh-uuid.user.js", size: 1, digest: "e1-cloud", createtime: 1, updatetime: 5 }, - { - name: "sh-uuid.meta.json", - path: "sh-uuid.meta.json", - size: 1, - digest: "m1-cloud", - createtime: 1, - updatetime: 5, - }, - ]), - open: vi.fn().mockImplementation(async (fileInfo: { name: string }) => ({ - read: vi.fn().mockResolvedValue(fileInfo.name === "sh-uuid.user.js" ? "// cloud-edit-1" : "{}"), - })), - }); - 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-2" }) }, - all: vi.fn().mockResolvedValue([script]), - } as any - ); - vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); - // 本机上次成功写入云端的内容是 edit-1(digest 记账失败 → file_digest 仍停在 d0) - await (service as any).storage.set("sync_content_md5", { - "sh-uuid.user.js": md5OfText("// cloud-edit-1"), - }); - - const result = await service.pushScript(fs as any, script as any, { - fileDigestMap: { "sh-uuid.user.js": "d0-stale", "sh-uuid.meta.json": "m0-stale" }, - }); - - // 首次以过期基准 CAS - expect(fs.create).toHaveBeenCalledWith("sh-uuid.user.js", expect.objectContaining({ expectedDigest: "d0-stale" })); - // 识别为自我所写后以云端当前 digest 重挂基准重推 - expect(fs.create).toHaveBeenCalledWith("sh-uuid.user.js", expect.objectContaining({ expectedDigest: "e1-cloud" })); - // 重推的是本机当前内容 edit-2 - expect(written).toContainEqual({ name: "sh-uuid.user.js", content: "// local-edit-2" }); - expect(result["sh-uuid.user.js"]).toBe(md5OfText("// local-edit-2")); - }); - - it("CAS 冲突时云端内容非本机所写则维持停在冲突,不重推覆盖他端", async () => { - const script = { - uuid: "cf-uuid", - name: "cf", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 20, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const conflictError = new FileSystemError({ - provider: "webdav", - message: "precondition failed", - status: 412, - conflict: true, - }); - const create = vi.fn().mockImplementation(async (name: string, opts: { expectedDigest?: string }) => { - if (name === "cf-uuid.user.js" && opts?.expectedDigest === "d0-stale") { - throw conflictError; - } - return { write: vi.fn().mockResolvedValue(undefined) }; - }); - const fs = createFs({ - capabilities: { supportsAtomicCompareAndSwap: true }, - create, - list: vi.fn().mockResolvedValue([ - { name: "cf-uuid.user.js", path: "cf-uuid.user.js", size: 1, digest: "eB-cloud", createtime: 1, updatetime: 9 }, - { - name: "cf-uuid.meta.json", - path: "cf-uuid.meta.json", - size: 1, - digest: "mB-cloud", - createtime: 1, - updatetime: 9, - }, - ]), - open: vi.fn().mockImplementation(async () => ({ - read: vi.fn().mockResolvedValue("// other-device"), - })), - }); - 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" }) }, - all: vi.fn().mockResolvedValue([script]), - } as any - ); - vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); - await (service as any).storage.set("sync_content_md5", { - "cf-uuid.user.js": md5OfText("// my-previous-push"), - }); - - await expect( - service.pushScript(fs as any, script as any, { - fileDigestMap: { "cf-uuid.user.js": "d0-stale", "cf-uuid.meta.json": "m0-stale" }, - }) - ).rejects.toThrow(); - - // 云端内容不是本机所写 → 不应以其它 digest 重推覆盖他端 - const userDigests = create.mock.calls - .filter((c: any[]) => c[0] === "cf-uuid.user.js") - .map((c: any[]) => c[1]?.expectedDigest); - expect(userDigests).toEqual(["d0-stale"]); - }); - it("updateFileDigest 全量对账时应清理云端已删除文件的 sync_content_md5,避免只增不删", async () => { const fs = createFs({ list: vi @@ -1587,48 +1362,7 @@ console.log("ok");` }); }); - it("push 云端缺失文件时应当使用 createOnly,避免覆盖并发新增", async () => { - const script = { - uuid: "new-uuid", - name: "new", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 10, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const fs = createFs({ - capabilities: { - supportsCreateOnly: true, - }, - 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: "// code" }), - }, - all: vi.fn().mockResolvedValue([script]), - } as any - ); - - await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - - expect(fs.create).toHaveBeenCalledWith("new-uuid.user.js", expect.objectContaining({ createOnly: true })); - expect(fs.create).toHaveBeenCalledWith("new-uuid.meta.json", expect.objectContaining({ createOnly: true })); - }); - - it("没有能力声明时 push 不应传条件写入参数", async () => { + it("push 始终只传文件修改时间", async () => { const script = { uuid: "push-uuid", name: "push", @@ -1677,196 +1411,7 @@ console.log("ok");` await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - expect(fs.create).toHaveBeenCalledWith( - "push-uuid.user.js", - expect.not.objectContaining({ - expectedDigest: expect.anything(), - createOnly: expect.anything(), - }) - ); - }); - - it("删除无效 meta 后重传:本轮已确认不存在的文件应走 createOnly 而非过期 digest CAS", async () => { - // 云端只有普通 .meta.json(无 .user.js):删除无效 meta 后重传两个文件。 - // 本轮 list 已确认 .user.js 不存在、meta 刚被我们删除,若再拿本地记录的过期 digest - // 对不存在的文件做 If-Match 必然 412,且失败保留旧 digest 后永不自愈。 - const script = { - uuid: "remeta-uuid", - name: "remeta", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 10, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const fs = createFs({ - capabilities: { - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - supportsConditionalDelete: true, - }, - list: vi - .fn() - .mockResolvedValueOnce([ - { - name: "remeta-uuid.meta.json", - path: "remeta-uuid.meta.json", - size: 1, - digest: "cloud-meta", - createtime: 1, - updatetime: 1, - }, - ]) - .mockResolvedValueOnce([]), - open: vi.fn().mockResolvedValue({ - read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "remeta-uuid", origin: "origin" })), - }), - } 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" }), - }, - all: vi.fn().mockResolvedValue([script]), - } as any - ); - await (service as any).storage.set("file_digest", { - "remeta-uuid.user.js": "stale-user-digest", - "remeta-uuid.meta.json": "stale-meta-digest", - }); - - await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - - const createCalls = vi.mocked(fs.create).mock.calls; - const userCall = createCalls.find((c) => c[0] === "remeta-uuid.user.js"); - const metaCall = createCalls.find((c) => c[0] === "remeta-uuid.meta.json"); - expect(userCall?.[1]).toMatchObject({ createOnly: true }); - expect(userCall?.[1]?.expectedDigest).toBeUndefined(); - expect(metaCall?.[1]).toMatchObject({ createOnly: true }); - expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); - }); - - it("云端缺 .meta.json 的补传:meta 走 createOnly,.user.js 仍用记录 digest 做 CAS", async () => { - const script = { - uuid: "remeta2-uuid", - name: "remeta2", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 10, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const fs = createFs({ - capabilities: { - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - }, - list: vi - .fn() - .mockResolvedValueOnce([ - { - name: "remeta2-uuid.user.js", - path: "remeta2-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", { - "remeta2-uuid.user.js": "old-user-digest", - "remeta2-uuid.meta.json": "stale-meta-digest", - }); - - await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - - const createCalls = vi.mocked(fs.create).mock.calls; - const userCall = createCalls.find((c) => c[0] === "remeta2-uuid.user.js"); - const metaCall = createCalls.find((c) => c[0] === "remeta2-uuid.meta.json"); - expect(userCall?.[1]).toMatchObject({ expectedDigest: "old-user-digest" }); - expect(metaCall?.[1]).toMatchObject({ createOnly: true }); - expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); - }); - - it("云端已确认不存在的新脚本:即便本地残留过期 digest 记录也应走 createOnly", async () => { - const script = { - uuid: "fresh-uuid", - name: "fresh", - origin: "origin", - downloadUrl: "download-url", - checkUpdateUrl: "check-update-url", - updatetime: 10, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }; - const fs = createFs({ - capabilities: { - supportsAtomicCompareAndSwap: true, - supportsCreateOnly: true, - }, - 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: "// code" }), - }, - all: vi.fn().mockResolvedValue([script]), - } as any - ); - await (service as any).storage.set("file_digest", { - "fresh-uuid.user.js": "stale-user-digest", - "fresh-uuid.meta.json": "stale-meta-digest", - }); - - await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - - const createCalls = vi.mocked(fs.create).mock.calls; - const userCall = createCalls.find((c) => c[0] === "fresh-uuid.user.js"); - const metaCall = createCalls.find((c) => c[0] === "fresh-uuid.meta.json"); - expect(userCall?.[1]).toMatchObject({ createOnly: true }); - expect(userCall?.[1]?.expectedDigest).toBeUndefined(); - expect(metaCall?.[1]).toMatchObject({ createOnly: true }); - expect(metaCall?.[1]?.expectedDigest).toBeUndefined(); + expect(fs.create).toHaveBeenCalledWith("push-uuid.user.js", { modifiedDate: 10 }); }); it("部分 push 失败时只推进成功文件 digest 并保留失败文件旧 digest", async () => { @@ -2428,43 +1973,7 @@ console.log("ok");` } ); - it("deleteCloudScript 支持条件删除时应当传 expectedDigest", async () => { - const fs = createFs({ - capabilities: { - supportsConditionalDelete: true, - }, - }); - 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).toHaveBeenCalledWith( - "delete-uuid.user.js", - expect.objectContaining({ expectedDigest: "old-user-digest" }) - ); - expect(fs.delete).toHaveBeenCalledWith( - "delete-uuid.meta.json", - expect.objectContaining({ expectedDigest: "old-meta-digest" }) - ); - }); - - it("deleteCloudScript 无条件删除能力时不应传 expectedDigest", async () => { + it("deleteCloudScript 始终执行普通删除", async () => { const fs = createFs(); const service = new SynchronizeService( {} as any, @@ -2979,13 +2488,9 @@ console.log("ok");` } }); - it("scriptInstall 对已同步过的脚本改用 CAS 而非 create-only 上传", async () => { - // 编辑一个已经同步到云端的脚本会以 upsertBy=user 再次触发 installScript。 - // 云端文件已存在,此时若仍用 create-only 上传必然 412 冲突,本地编辑永远上不了云。 - // 正确行为:凭本地已存的云端 digest 做 CAS 覆盖(expectedDigest),不带 createOnly。 + it("scriptInstall 对已同步过的脚本执行普通覆盖写入", async () => { const createCalls: Array<{ name: string; opts: any }> = []; const fs = createFs({ - capabilities: { supportsCreateOnly: true, supportsAtomicCompareAndSwap: true }, list: vi.fn().mockResolvedValue([]), create: vi.fn().mockImplementation(async (name: string, opts: any) => { createCalls.push({ name, opts }); @@ -3010,12 +2515,6 @@ console.log("ok");` } as any ); vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(fs); - // 该脚本此前已同步过,本地记录了它的云端 digest - await (service as any).storage.set("file_digest", { - "u1.user.js": "etag-user", - "u1.meta.json": "etag-meta", - }); - await service.scriptInstall({ script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "" } as any, upsertBy: "user", @@ -3025,10 +2524,8 @@ console.log("ok");` const userJs = createCalls.find((c) => c.name === "u1.user.js"); const metaJson = createCalls.find((c) => c.name === "u1.meta.json"); - expect(userJs?.opts?.createOnly).toBeUndefined(); - expect(userJs?.opts?.expectedDigest).toBe("etag-user"); - expect(metaJson?.opts?.createOnly).toBeUndefined(); - expect(metaJson?.opts?.expectedDigest).toBe("etag-meta"); + expect(userJs?.opts).toEqual({ modifiedDate: expect.any(Number) }); + expect(metaJson?.opts).toEqual({ modifiedDate: expect.any(Number) }); }); it("本地编辑后即便云端 digest 未变也应推送而非因 digest 相等被跳过", async () => { @@ -3065,12 +2562,12 @@ console.log("ok");` await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" }), expect.anything()); + expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" })); }); it("pushScript 在 .meta.json 写入失败时带出已成功写入的 .user.js", async () => { // 分两次写 .user.js / .meta.json,前者成功后者失败时要让调用方知道 .user.js 已写成功, - // 才能只保留失败文件的旧 digest、推进成功文件的 digest,避免永久 CAS 冲突(#3)。 + // 才能只保留失败文件的旧 digest、推进成功文件的 digest。 const fs = createFs({ create: vi.fn().mockImplementation(async (name: string) => ({ write: vi.fn().mockImplementation(async () => { @@ -3113,7 +2610,6 @@ console.log("ok");` let seq = 0; const cloud = new Map(); const cloudFs = createFs({ - capabilities: { supportsCreateOnly: true }, list: vi.fn(async () => Array.from(cloud, ([name, f]) => ({ name, @@ -3127,9 +2623,6 @@ console.log("ok");` create: vi.fn(async (name: string, opts: any) => ({ write: vi.fn(async (content: string) => { const existing = cloud.get(name); - if (opts?.createOnly && existing) { - throw new FileSystemError({ provider: "webdav", message: "createOnly", status: 412, conflict: true }); - } if (failMeta && name === "u1.meta.json") { throw new FileSystemError({ provider: "webdav", message: "meta fail", status: 500, retryable: true }); } @@ -3182,9 +2675,7 @@ console.log("ok");` expect(cloud.has("u1.meta.json")).toBe(true); }); - it("编辑已同步脚本时 .meta.json 失败,下一轮同步自愈而不陷入永久 CAS 冲突", async () => { - // 端到端复现 #2+#3+#1:编辑已同步脚本 → scriptInstall 推送,.user.js 成功、.meta.json 失败。 - // 已成功的 .user.js digest 必须被推进,否则下一轮 syncOnce 拿过期 digest 做 CAS 永久 412。 + it("编辑已同步脚本时 .meta.json 失败,下一轮同步应补传", async () => { let failMeta = true; const writes: string[] = []; let seq = 0; @@ -3192,10 +2683,7 @@ console.log("ok");` ["u1.user.js", { digest: "cu1", content: "// v0", updatetime: 5 }], ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5 }], ]); - const conflict = () => - new FileSystemError({ provider: "webdav", message: "CAS conflict", status: 412, conflict: true }); const cloudFs = createFs({ - capabilities: { supportsCreateOnly: true, supportsAtomicCompareAndSwap: true }, list: vi.fn(async () => Array.from(cloud, ([name, f]) => ({ name, @@ -3209,11 +2697,7 @@ console.log("ok");` create: vi.fn(async (name: string, opts: any) => ({ write: vi.fn(async (content: string) => { const existing = cloud.get(name); - if (opts?.createOnly && existing) throw conflict(); - if (opts?.expectedDigest !== undefined && (!existing || existing.digest !== opts.expectedDigest)) { - throw conflict(); - } - if (failMeta && name === "u1.meta.json") throw conflict(); + if (failMeta && name === "u1.meta.json") throw new Error("meta write failed"); cloud.set(name, { digest: `d${++seq}`, content, @@ -3265,7 +2749,7 @@ console.log("ok");` expect(writes).toContain("u1.user.js"); expect(cloud.get("u1.meta.json")!.digest).toBe("cm1"); - // 第二轮:meta 故障恢复,syncOnce 应自愈(成功文件 digest 已推进,CAS 不再永久冲突) + // 第二轮:meta 故障恢复,syncOnce 应补传两个文件 failMeta = false; writes.length = 0; await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); @@ -3276,7 +2760,7 @@ console.log("ok");` it("本地编辑经 syncOnce 真实上云,且推进 digest 后下一轮不再重复推送", async () => { // 端到端验证 #1:脚本已同步(云端 digest 与本地记录一致),随后本地编辑(updatetime 变新、 - // 但云端文件未变故 digest 仍相等)。syncOnce 必须真正把新内容 CAS 覆盖上云; + // 但云端文件未变故 digest 仍相等)。syncOnce 必须真正把新内容覆盖上云; // 且推进 digest/更新时间后,下一轮应回到稳态不再重复推送。 let seq = 0; const cloud = new Map([ @@ -3285,7 +2769,6 @@ console.log("ok");` ]); const writes: string[] = []; const cloudFs = createFs({ - capabilities: { supportsAtomicCompareAndSwap: true }, list: vi.fn(async () => Array.from(cloud, ([name, f]) => ({ name, @@ -3299,9 +2782,6 @@ console.log("ok");` create: vi.fn(async (name: string, opts: any) => ({ write: vi.fn(async (content: string) => { const existing = cloud.get(name); - if (opts?.expectedDigest !== undefined && (!existing || existing.digest !== opts.expectedDigest)) { - throw new FileSystemError({ provider: "webdav", message: "CAS conflict", status: 412, conflict: true }); - } cloud.set(name, { digest: `d${++seq}`, content, @@ -3551,7 +3031,7 @@ console.log("ok");` await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" }), expect.anything()); + expect(pushSpy).toHaveBeenCalledWith(fs, expect.objectContaining({ uuid: "u1" })); expect(pullSpy).not.toHaveBeenCalled(); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 18e398d04..3a24bdc1b 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -10,9 +10,8 @@ import { } from "@App/app/repo/scripts"; import BackupExport from "@App/pkg/backup/export"; import type { BackupData, ResourceBackup, ScriptBackupData, ScriptOptions, ValueStorage } from "@App/pkg/backup/struct"; -import type { FileCreateOptions, FileInfo } from "@Packages/filesystem/filesystem"; +import type { FileInfo } from "@Packages/filesystem/filesystem"; import type FileSystem from "@Packages/filesystem/filesystem"; -import { getFileSystemCapabilities } from "@Packages/filesystem/filesystem"; import ZipFileSystem from "@Packages/filesystem/zip/zip"; import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory"; import { FileSystemError, isWarpTokenError } from "@Packages/filesystem/error"; @@ -89,16 +88,6 @@ type FileDigestMap = { [key: string]: string; }; -type PushScriptOptions = { - fileDigestMap?: FileDigestMap; - scriptFile?: FileInfo; - metaFile?: FileInfo; - // syncOnce 场景为 true:scriptFile/metaFile 反映本轮 fs.list() 快照,undefined 表示已确认云端不存在 - hasListSnapshot?: boolean; - // 自我 412 收敛的重推标记,避免二次冲突时无限重试 - selfHealRetried?: boolean; -}; - type SyncTask = { uuid: string; promise: Promise; @@ -594,7 +583,7 @@ export class SynchronizeService { } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); - return await this.pushScript(fs, script, { fileDigestMap, hasListSnapshot: true }); + return await this.pushScript(fs, script); } })(), [file.meta!.name, `${uuid}.user.js`] @@ -604,15 +593,7 @@ export class SynchronizeService { const updatetime = script.updatetime || script.createtime; // 云端缺 .meta.json(上一轮分片上传残留):无论方向判定如何都需补传修复 if (!file.meta) { - addSyncTask( - uuid, - this.pushScript(fs, script, { - fileDigestMap, - scriptFile: file.script, - metaFile: file.meta, - hasListSnapshot: true, - }) - ); + addSyncTask(uuid, this.pushScript(fs, script)); return; } if (fileDigestMap[file.script!.name] === file.script!.digest) { @@ -621,15 +602,7 @@ export class SynchronizeService { if (updatetime <= file.script!.updatetime) { return; } - addSyncTask( - uuid, - this.pushScript(fs, script, { - fileDigestMap, - scriptFile: file.script, - metaFile: file.meta, - hasListSnapshot: true, - }) - ); + addSyncTask(uuid, this.pushScript(fs, script)); return; } // 云端自上次同步已变(或本机无记录)。本地毫秒时钟与服务端整秒 mtime 属于两个时钟域, @@ -654,12 +627,7 @@ export class SynchronizeService { this.logger.warn("sync overwrite", { action: "overwrite", direction: "push", uuid, name: scriptName }); overwriteScripts.push({ uuid, scriptName, direction: "push" }); } - return await this.pushScript(fs, script, { - fileDigestMap, - scriptFile: file.script, - metaFile: file.meta, - hasListSnapshot: true, - }); + return await this.pushScript(fs, script); } if (direction.action === "adopt") { // 两端内容一致只是基线过期:返回内容 md5 让 updateFileDigest 推进基线,不产生写操作 @@ -687,7 +655,7 @@ export class SynchronizeService { }); // 上传剩下的脚本 scriptMap.forEach((script) => { - addSyncTask(script.uuid, this.pushScript(fs, script, { fileDigestMap, hasListSnapshot: true })); + addSyncTask(script.uuid, this.pushScript(fs, script)); }); // 忽略错误 const syncResults = await Promise.allSettled(result.map((item) => item.promise)); @@ -914,7 +882,7 @@ export class SynchronizeService { knownFileDigestMap: FileDigestMap = {}, preserveFileNames = new Set() ) { - // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 + // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供后续同步方向判定 await this.recordSyncedContentMd5(knownFileDigestMap); const oldFileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const newList = await fs.list(); @@ -956,7 +924,7 @@ export class SynchronizeService { knownFileDigestMap: FileDigestMap = {}, preserveFileNames = new Set() ) { - // 先落库本次成功推送内容的 md5(独立于易失的 fs.list),供 CAS 冲突时识别云端是否为本机所写 + // 先落库本次成功推送内容的 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`])); @@ -990,13 +958,12 @@ export class SynchronizeService { async deleteCloudScript(fs: FileSystem, uuid: string, syncDelete: boolean) { const filename = `${uuid}.user.js`; const metaFilename = `${uuid}.meta.json`; - const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const logger = this.logger.with({ uuid: uuid, file: filename, }); try { - await this.deleteCloudFile(fs, filename, fileDigestMap); + await fs.delete(filename); if (syncDelete) { // 留下一个.meta.json删除标记 const modifiedDate = Date.now(); @@ -1012,7 +979,7 @@ export class SynchronizeService { ); } else { // 直接删除所有相关文件 - await this.deleteCloudFile(fs, metaFilename, fileDigestMap); + await fs.delete(metaFilename); } logger.info("delete success"); } catch (e) { @@ -1022,28 +989,8 @@ export class SynchronizeService { return; } - private buildDeleteOptions(fs: FileSystem, filename: string, fileDigestMap: FileDigestMap) { - if (!getFileSystemCapabilities(fs).supportsConditionalDelete) { - return undefined; - } - const expectedDigest = fileDigestMap[filename]; - if (!expectedDigest) { - return undefined; - } - return { expectedDigest }; - } - - private async deleteCloudFile(fs: FileSystem, filename: string, fileDigestMap: FileDigestMap) { - const opts = this.buildDeleteOptions(fs, filename, fileDigestMap); - if (opts) { - await fs.delete(filename, opts); - return; - } - await fs.delete(filename); - } - // 上传脚本 - async pushScript(fs: FileSystem, script: PushScriptParam, opts: PushScriptOptions = {}): Promise { + async pushScript(fs: FileSystem, script: PushScriptParam): Promise { const filename = `${script.uuid}.user.js`; const metaFilename = `${script.uuid}.meta.json`; const logger = this.logger.with({ @@ -1055,19 +1002,13 @@ export class SynchronizeService { const writtenFiles: string[] = []; try { const modifiedDate = getScriptModifiedDate(script); - const w = await fs.create( - filename, - this.buildPushCreateOptions(fs, filename, modifiedDate, opts.scriptFile, opts) - ); + const w = await fs.create(filename, { modifiedDate }); // 获取脚本代码 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, - this.buildPushCreateOptions(fs, metaFilename, modifiedDate, opts.metaFile, opts) - ); + const meta = await fs.create(metaFilename, { modifiedDate }); const metaJson = JSON.stringify({ uuid: script.uuid, origin: script.origin, @@ -1082,68 +1023,12 @@ export class SynchronizeService { [metaFilename]: md5OfText(metaJson), }; } catch (e) { - // 自我 412 收敛:本地记录的云端 digest 记账失败(上次 fs.list 抛错 / SW 被杀)后再次编辑, - // 会拿过期 digest 对「自己上次写入的云端内容」做 If-Match,陷入永久 412。识别到冲突文件的 - // 云端内容正是本机上次成功写入时,以云端当前 digest 为新基准重推一次收敛;真·他端并发编辑 - // (内容不符)则维持停在冲突,绝不覆盖他端。 - if (!opts.selfHealRetried && !writtenFiles.includes(filename) && this.classifySyncError(e) === "conflict") { - const rebased = await this.rebaseSelfWrittenConflict(fs, script, opts.fileDigestMap); - if (rebased) { - logger.warn("self-heal 412 conflict by rebasing on own last push"); - return await this.pushScript(fs, script, { - fileDigestMap: rebased, - hasListSnapshot: false, - selfHealRetried: true, - }); - } - } logger.error("push script error", Logger.E(e)); throw new PushScriptPartialError(e, writtenFiles); } } - // CAS 冲突时判断冲突文件的云端内容是否为本机上次成功写入: - // 是 → 返回以云端当前原生 digest 为新基准的 fileDigestMap(供重推);否 → 返回 null(真·他端并发编辑)。 - private async rebaseSelfWrittenConflict( - fs: FileSystem, - script: PushScriptParam, - fileDigestMap: FileDigestMap = {} - ): Promise { - const filename = `${script.uuid}.user.js`; - const metaFilename = `${script.uuid}.meta.json`; - try { - const syncedContentMd5 = ((await this.storage.get("sync_content_md5")) as FileDigestMap) || {}; - const recordedMd5 = syncedContentMd5[filename]; - if (!recordedMd5) { - return null; - } - const list = await fs.list(); - const cloudFiles = new Map(list.map((file) => [file.name, file])); - const cloudScript = cloudFiles.get(filename); - if (!cloudScript) { - return null; - } - const cloudCode = (await fs.open(cloudScript).then((r) => r.read("string"))) as string; - if (md5OfText(cloudCode) !== recordedMd5) { - // 云端内容不是本机上次写入 → 真·他端并发编辑,维持停在冲突 - return null; - } - const rebased: FileDigestMap = { ...fileDigestMap }; - for (const name of [filename, metaFilename]) { - const info = cloudFiles.get(name); - if (info) { - rebased[name] = info.digest; - } - } - return rebased; - } catch (e) { - this.logger.warn("rebase self-written conflict failed", Logger.E(e), { uuid: script.uuid }); - return null; - } - } - // 落库本次成功推送/拉取内容的 md5(键为文件名):作为本地内容基线供方向判定(L4), - // 也供 CAS 冲突时识别云端是否为本机所写。 // 在 updateFileDigest* 内部、fs.list 之前调用,确保即便随后 list 抛错也已持久化。 private async recordSyncedContentMd5(syncedMd5Map: FileDigestMap) { const names = Object.keys(syncedMd5Map); @@ -1172,36 +1057,6 @@ export class SynchronizeService { } } - private buildPushCreateOptions( - fs: FileSystem, - filename: string, - modifiedDate: number, - existingFile: FileInfo | undefined, - opts: PushScriptOptions - ): FileCreateOptions { - const capabilities = getFileSystemCapabilities(fs); - const createOptions: FileCreateOptions = { modifiedDate }; - // CAS 的 If-Match 基准取本地记录的云端 digest(脚本上次同步/安装成功时的版本), - // 这样其他设备改动过云端文件时能正确检测冲突而不误覆盖。 - // 但本轮 list 快照已确认云端不存在的文件(existingFile 为 undefined 且有快照)例外: - // 本地残留的 digest 必然过期,对不存在的文件做 If-Match 必然 412, - // 且失败保留旧 digest 后永不自愈,只能走 create-only。 - // 无快照的场景(scriptInstall 队列)才允许用本地记录 digest 做 CAS—— - // 已同步过的脚本编辑后再次上传必须 CAS 覆盖,否则 create-only 撞上已存在文件必然 412。 - const expectedDigest = - existingFile === undefined && opts.hasListSnapshot ? undefined : opts.fileDigestMap?.[filename]; - if (existingFile === undefined && expectedDigest === undefined) { - if (capabilities.supportsCreateOnly) { - createOptions.createOnly = true; - } - return createOptions; - } - if (expectedDigest && capabilities.supportsAtomicCompareAndSwap) { - createOptions.expectedDigest = expectedDigest; - } - return createOptions; - } - // 云端 digest 相对上次同步已变时决定同步方向。 // 不比较本地毫秒时钟与服务端整秒 mtime(同秒竞态会误判方向,L4): // 用上次同步的本地内容基线判断本地是否也变过,双方都变才算冲突; @@ -1342,16 +1197,13 @@ export class SynchronizeService { if (config.enable) { stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { const fs = await this.buildFileSystem(config); - // 已同步过的脚本本地记录了其云端 digest,据此做 CAS 覆盖;没有记录才是首次上传走 create-only。 - // 缺了它,编辑已存在的云端脚本会因 create-only 而 412 冲突,本地改动永远上不了云。 - const fileDigestMap = ((await this.storage.get("file_digest")) as FileDigestMap) || {}; const script = params.script; try { - const pushedFileDigestMap = await this.pushScript(fs, script, { fileDigestMap }); + const pushedFileDigestMap = await this.pushScript(fs, script); await this.updateFileDigestForUuids(fs, [script.uuid], pushedFileDigestMap); } catch (e) { // 部分成功(如 .user.js 成功、.meta.json 失败)时,已写成功文件的云端 digest 已更新, - // 必须记录其最新 digest、只保留失败文件的旧值,否则下一轮同步会拿过期 digest 做 CAS 永久 412。 + // 必须记录其最新 digest、只保留失败文件的旧值,避免下一轮误判成功文件仍未同步。 // 全部失败(无文件写入成功)则不动 file_digest,保持"失败不污染"。 const writtenFiles = e instanceof PushScriptPartialError ? e.writtenFiles : []; if (writtenFiles.length > 0) { From 0654166375a1b8a990d22a9c060b838a8967c13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 00:55:51 +0800 Subject: [PATCH 091/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E3=80=8C=E7=AB=8B=E5=8D=B3=E5=90=8C=E6=AD=A5=E3=80=8D?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=EF=BC=9A?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=86=99=E5=85=A5=20error=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=B9=B6=E6=8F=90=E7=A4=BA=E7=94=A8=E6=88=B7=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 21 +++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 15 ++++++++++++- .../Setting/sections/SyncSection.test.tsx | 18 ++++++++++++++++ .../routes/Setting/sections/SyncSection.tsx | 8 ++++++- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index f73574b00..bef348921 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -3283,5 +3283,26 @@ console.log("ok");`; 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 3a24bdc1b..266d9d6e6 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -1263,7 +1263,20 @@ export class SynchronizeService { async cloudSyncOnce() { const config = await this.systemConfig.getCloudSync(); if (!config.enable) return; - const fs = await this.buildFileSystem(config); + 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); } diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx index af9ac55ea..c1e8e82f7 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx @@ -44,6 +44,11 @@ vi.mock("@App/pages/store/features/cloud_sync", () => ({ 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 = {}) { @@ -76,6 +81,9 @@ beforeEach(() => { mockState(); subscribeCloudSyncState.mockReturnValue(() => {}); requestCloudSyncOnce.mockResolvedValue(undefined); + notify.info.mockClear(); + notify.success.mockClear(); + notify.error.mockClear(); }); afterEach(() => { @@ -163,4 +171,14 @@ describe("同步分区", () => { await screen.findByTestId("cloud_sync_save"); expect(screen.queryByTestId("cloud_sync_status")).toBeNull(); }); + + 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 1032fd6b5..f6a6e4a13 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -60,7 +60,13 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE }; const syncNow = async () => { - await requestCloudSyncOnce(); + // 触发失败(如连接/账号验证失败)时给出即时反馈,避免静默失败与未捕获异常; + // 状态条随后也会因 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); From fbad739435e41ce05f8da83be6c610362860b9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 01:24:36 +0800 Subject: [PATCH 092/103] =?UTF-8?q?=F0=9F=90=9B=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=BA=91=E5=90=8C=E6=AD=A5=E5=A4=B1=E8=B4=A5=E4=B8=8E=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=8F=AF=E8=A7=81=E6=80=A7=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 42 ++++----- packages/filesystem/filesystem.test.ts | 25 ------ .../service_worker/synchronize.test.ts | 89 ++++++++++++------- src/app/service/service_worker/synchronize.ts | 64 +++++++------ src/locales/de-DE/settings.json | 7 +- src/locales/en-US/settings.json | 7 +- src/locales/ja-JP/settings.json | 7 +- src/locales/ru-RU/settings.json | 7 +- src/locales/tr-TR/settings.json | 7 +- src/locales/vi-VN/settings.json | 7 +- src/locales/zh-CN/settings.json | 7 +- src/locales/zh-TW/settings.json | 7 +- .../Setting/sections/SyncSection.test.tsx | 20 ++++- .../routes/Setting/sections/SyncSection.tsx | 4 +- .../Setting/sections/syncStatus.test.ts | 28 +++++- .../routes/Setting/sections/syncStatus.ts | 8 +- 16 files changed, 195 insertions(+), 141 deletions(-) delete mode 100644 packages/filesystem/filesystem.test.ts diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index d26e3ad0d..d3df06aee 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -5,7 +5,7 @@ 相关代码入口: - [`src/app/service/service_worker/synchronize.ts`](../src/app/service/service_worker/synchronize.ts):同步服务、队列、状态合并、digest 更新。 -- [`packages/filesystem/filesystem.ts`](../packages/filesystem/filesystem.ts):统一文件系统接口、条件操作参数、provider capability。 +- [`packages/filesystem/filesystem.ts`](../packages/filesystem/filesystem.ts):统一文件系统接口。 - [`packages/filesystem/error.ts`](../packages/filesystem/error.ts):统一 typed provider error。 - [`packages/filesystem/*`](../packages/filesystem/):各云盘 provider 实现。 @@ -18,9 +18,9 @@ 1. 单个脚本失败不能阻塞其他脚本同步。 2. 成功脚本可以推进自己的 `file_digest`,失败脚本必须保留旧 digest。 3. `scriptcat-sync.json` 写回前要合并远端较新状态,避免覆盖其他设备状态。 -4. provider 没有声明原子能力时,同步层不能传条件写删参数,也不能把 best-effort 行为描述成 CAS。 +4. provider 写入使用普通覆盖语义;同步层必须诚实记录无法检测的并发覆盖窗口。 5. 旧 `.user.js`、旧 `.meta.json`、旧 `file_digest` string map、缺字段 `scriptcat-sync.json` 必须继续可读。 -6. filesystem 包只负责暴露能力、执行文件操作、抛 typed error;同步冲突策略属于 `SynchronizeService`。 +6. filesystem 包只负责执行文件操作、抛 typed error;同步冲突策略属于 `SynchronizeService`。 7. 本地与云端都改过的脚本(真冲突)不自动覆盖任何一端:跳过、保留基线、聚合通知用户。 功能范围:云同步只同步脚本源码、来源 metadata(`.meta.json`)、启用状态与排序(`scriptcat-sync.json`)。GM storage 值和 `@require`/`@resource` 资源缓存**不**参与云同步(它们只在完整备份/导出路径处理),属于有意取舍,扩展需另行设计(隐私、容量、二进制资源、多端 merge)。 @@ -119,10 +119,9 @@ digest 更新有两条路径,区别在于对账范围: ## `sync_content_md5`(本地内容基线) -另有一份独立的 `sync_content_md5`(同存 `ChromeStorage("sync")`,格式同 `FileDigestMap`)记录本机每次成功推送**或拉取**的内容 md5,即"上次同步成功时的本地内容基线"。它有两个用途: +另有一份独立的 `sync_content_md5`(同存 `ChromeStorage("sync")`,格式同 `FileDigestMap`)记录本机每次成功推送**或拉取**的内容 md5,即"上次同步成功时的本地内容基线"。它用于云端变化时判断本地内容是否也发生变化: -1. **方向判定(L4 修复)**:云端 digest 相对 `file_digest` 已变时,用本地当前内容 md5 与基线比较判断"本地是否也改过",取代跨时钟域的墙钟比较(本地毫秒 updatetime vs 服务端整秒 mtime 在同一秒内会误判方向,导致 push 覆盖较新的云端内容)。 -2. **自我 412 收敛**(见下文 `pushScript()` 一节):判定云端冲突文件是否为本机上次所写。 +- **方向判定(L4 修复)**:云端 digest 相对 `file_digest` 已变时,用本地当前内容 md5 与基线比较判断"本地是否也改过",取代跨时钟域的墙钟比较(本地毫秒 updatetime vs 服务端整秒 mtime 在同一秒内会误判方向,导致 push 覆盖较新的云端内容)。 它与 `file_digest` 用途不同:`file_digest` 存 provider 原生 digest 检测云端变化,`sync_content_md5` 存本地内容 md5 检测本地变化,二者不可混用。 @@ -150,7 +149,7 @@ digest 更新有两条路径,区别在于对账范围: 5. 对每个云端 uuid 和本地脚本做决策。 6. 用 `Promise.allSettled()` 等待所有文件任务,保持 per-file best-effort。 7. 收集成功任务返回的 digest patch。 -8. 对失败任务记录 `failedSyncUuids` 和 `preserveDigestFiles`;push 部分失败时(`PushScriptPartialError`),已成功写入云端的文件不保留旧 digest,让其推进到云端最新值,避免过期 digest 造成后续 CAS 永久冲突。 +8. 对失败任务记录 `failedSyncUuids` 和 `preserveDigestFiles`;push 部分失败时(`PushScriptPartialError`),已成功写入云端的文件不保留旧 digest,让下一轮只重试真正失败的文件。 9. 如果启用 `syncStatus`,合并本地状态、初始云端状态、写回前重新读取的最新云端状态。 10. 调用 `updateFileDigest()`,成功文件推进 digest,失败文件保留旧 digest。 @@ -169,7 +168,7 @@ digest 更新有两条路径,区别在于对账范围: - 无基线(升级前旧数据/从未同步成功):退回旧的时间比较规则(时间更晚一方胜出)。 - 冲突通知:一轮同步只发一条通知,列出所有冲突脚本名;同一批脚本持续冲突时后续轮次不重复通知(集合变化后重新通知)。冲突脚本会一直停走,直到某一端内容与另一端一致(自动收敛)或某一端被删除/重装。 -注意:所有 provider 目前都不声明条件写删能力(见「provider 差异」),push 是无条件覆盖写。`list → 决策 → write` 之间存在 TOCTOU 窗口:若对端恰好在这几秒内写入同一文件,后写者胜(last-writer-wins),同步层无法察觉。内容基线把误覆盖收窄到"真·同时编辑同一脚本"这一罕见场景;需要原子保护时必须由 provider 侧 CAS 支持(后续按 endpoint 探测能力的方案)。 +注意:push 是普通覆盖写。`list → 决策 → write` 之间存在 TOCTOU 窗口:若对端恰好在这几秒内写入同一文件,后写者胜(last-writer-wins),同步层无法察觉。内容基线只能减少基于旧快照做出错误方向判断的概率,不能消除请求之间的并发窗口。 本地脚本存在、云端只有 `.meta.json`: @@ -262,7 +261,7 @@ provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映 原因:写入和删除不是安全幂等操作。重复执行可能创建重复文件、覆盖并发更新或误删。 -typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501(如后端不支持条件请求)、505、507 等属于永久失败,不标记可重试,避免 limiter 空转退避。 +typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501、505、507 等属于永久失败,不标记可重试,避免 limiter 空转退避。 ## provider 差异 @@ -313,11 +312,10 @@ Google Drive 维护时注意: ### Dropbox -Dropbox 当前不声明 atomic 能力。维护时注意: +Dropbox 维护时注意: - digest 来自 `content_hash`,必须当作 opaque provider digest。 - 写入是 `exists()` 后 overwrite 或 add,存在 TOCTOU。 -- 当前没有使用 Dropbox rev CAS。 - request 层已解析 `error_summary` 和 structured `path_lookup` / `path`。 - 只有 `path/conflict` / `path_write/conflict` 判 conflict;其余 409(无写权限、空间不足等)保留原错误语义,不能被 createDir 当"目录已存在"吞掉。 - raw download 429 会转 typed rateLimit。 @@ -357,8 +355,8 @@ type CloudSyncState = { ``` - 开始置 `syncing:true`,结束写 `counts`/`lastSyncAt`,异常写 `error`。注意:读旧值与写 `syncing` **不能** await 在 `syncOnceInternal` 之前,否则存储 I/O 会推迟内部起始,打乱测试的微任务门控。 -- 设置页「脚本同步」卡片顶部状态条(`SyncSection.tsx` + `syncStatus.ts`)读取并订阅 `chrome.storage.onChanged` 实时展示四态:正常 / 同步中 / 有覆盖或冲突(琥珀警示)/ 失败。 -- `立即同步` 按钮经 `SynchronizeClient.cloudSyncOnce()` → SW `group.on("cloudSyncOnce")` → 用**已保存**配置跑一次 `syncOnce`(未启用则不触发)。 +- 设置页「脚本同步」卡片顶部状态条(`SyncSection.tsx` + `syncStatus.ts`)读取并订阅 `chrome.storage.onChanged` 实时展示四态:正常 / 同步中 / 有覆盖或冲突(琥珀警示)/ 失败。单文件 best-effort 失败通过 `counts.failed` 显示为失败,不能回落成“同步正常”。 +- `立即同步` 按钮经 `SynchronizeClient.cloudSyncOnce()` → SW `group.on("cloudSyncOnce")` → 用**已保存**配置跑一次 `syncOnce`(未启用则不触发)。构建文件系统失败发生在 `syncOnce()` 之前,因此 `cloudSyncOnce()` 会单独写入 `error` 状态并向 UI 抛出,由设置页显示 toast。 ### 覆盖日志(`action` 标签) @@ -372,7 +370,9 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name ### 通知与深链 -本轮有覆盖时聚合一条 `InfoNotification`(仿冲突的 `lastNotifiedOverwriteKey`,一轮一条、同批不重复、集合变化重发),点击打开 `/src/options.html#/logs?query=...`。`?query` 载荷 `[{key,value}]` 由 Logger 页 `parseInitialQueries` 解析,预过滤到 `service=synchronize` 且 `action=overwrite` 的行。状态条「查看日志」用同一深链格式。 +本轮有覆盖时聚合一条 `InfoNotification`,点击打开 `/src/options.html#/logs?query=...`。覆盖和冲突的已通知集合存入设备本地 sync storage,因此 MV3 Service Worker 重启后同一批问题也不会重复通知;集合或覆盖方向变化时重新提醒,问题消失时清空。 + +`?query` 载荷 `[{key,value}]` 由 Logger 页 `parseInitialQueries` 解析。只有纯覆盖状态才预过滤到 `service=synchronize` 且 `action=overwrite`;存在冲突或失败时只过滤 `service=synchronize`,避免把对应失败日志隐藏掉。覆盖通知与状态文案使用中性“同步时发生覆盖”,具体是本地覆盖云端还是云端覆盖本地以日志的 `direction` 标签为准。 ### 边界 @@ -413,12 +413,12 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name 4. 损坏或旧格式 `scriptcat-sync.json` 不阻塞脚本同步。 5. orphan `.user.js` 跳过并保留 status。 6. provider conflict/transient/notFound 能映射到正确 `SyncErrorKind`。 -7. 条件写删只在 provider 声明能力时使用;当前所有 provider capabilities 均为 false,不得发出任何条件头。 -8. 云端已变、本地内容基线未变时必须 pull——即使本地 updatetime 大于云端 mtime(同秒竞态 L4)。 -9. 本地与云端都变(真冲突)时不 push 不 pull,保留旧 digest 与云端 status,一轮只发一条聚合通知,同一批冲突不重复通知。 -10. 云端已变、本地也变但内容与云端一致时,收敛基线且不产生写操作。 -11. 无内容基线(升级前旧数据)时退回时间比较规则。 -12. 自我 412 收敛:本机 digest 记账丢失后再次编辑,识别云端为自己所写则重推;真·并发编辑维持停在 conflict。 -13. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 +7. 云端已变、本地内容基线未变时必须 pull——即使本地 updatetime 大于云端 mtime(同秒竞态 L4)。 +8. 本地与云端都变(真冲突)时不 push 不 pull,保留旧 digest 与云端 status,一轮只发一条聚合通知,同一批冲突不重复通知。 +9. 云端已变、本地也变但内容与云端一致时,收敛基线且不产生写操作。 +10. 无内容基线(升级前旧数据)时退回时间比较规则。 +11. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 +12. 同步失败计数必须在设置页显示为失败,不能显示为同步正常。 +13. 冲突或失败状态的日志深链不能被覆盖过滤条件隐藏。 真实 provider 验证仍需要账号和夹具。不能把 unit test 或 mock response 结果宣称为真实云端验证。 diff --git a/packages/filesystem/filesystem.test.ts b/packages/filesystem/filesystem.test.ts deleted file mode 100644 index 2cccb86f9..000000000 --- a/packages/filesystem/filesystem.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type FileSystem from "./filesystem"; -import LimiterFileSystem from "./limiter"; - -function createFs(overrides: Partial = {}): FileSystem { - return { - verify: vi.fn(async () => {}), - open: vi.fn(async () => ({ read: vi.fn(async () => "") })), - openDir: vi.fn(async () => createFs()), - create: vi.fn(async () => ({ write: vi.fn(async () => {}) })), - createDir: vi.fn(async () => {}), - delete: vi.fn(async () => {}), - list: vi.fn(async () => []), - getDirUrl: vi.fn(async () => ""), - ...overrides, - }; -} - -describe("FileSystem 公共接口", () => { - it("LimiterFileSystem 不应暴露已移除的条件操作能力", () => { - const limiter = new LimiterFileSystem(createFs()); - - expect("capabilities" in limiter).toBe(false); - }); -}); diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index bef348921..d60f9b2b4 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -1562,9 +1562,9 @@ console.log("ok");` vi.spyOn(service, "pushScript").mockImplementation(async (_fs, script: any) => { if (script.uuid === "bad") { throw new FileSystemError({ - provider: "s3", - message: "Precondition failed", - status: 412, + provider: "dropbox", + message: "Path conflict", + status: 409, conflict: true, }); } @@ -1578,7 +1578,7 @@ console.log("ok");` expect(warnSpy).toHaveBeenCalledWith( "sync task failed", - expect.objectContaining({ error: "Precondition failed" }), + expect.objectContaining({ error: "Path conflict" }), expect.objectContaining({ errorKind: "conflict", files: ["bad.user.js", "bad.meta.json"], @@ -1615,9 +1615,9 @@ console.log("ok");` }, { title: "unsupported", - error: new Error("unsupported conditional write"), + error: new Error("unsupported provider operation"), expectedKind: "unsupported", - expectedMessage: "unsupported conditional write", + expectedMessage: "unsupported provider operation", }, ])( "单文件同步遇到 $title 错误时应在日志中标记 $expectedKind 分类", @@ -1897,13 +1897,13 @@ console.log("ok");` { title: "conflict", error: new FileSystemError({ - provider: "s3", - message: "Precondition failed", - status: 412, + provider: "dropbox", + message: "Path conflict", + status: 409, conflict: true, }), expectedKind: "conflict", - expectedMessage: "Precondition failed", + expectedMessage: "Path conflict", }, ])( "批量删除遇到 typed $title 失败时只阻塞对应脚本并标记 $expectedKind", @@ -2912,7 +2912,7 @@ console.log("ok");` }); }); - it("同一批冲突脚本多轮同步只通知一次,冲突消失后重置", async () => { + 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 }, @@ -2923,30 +2923,23 @@ console.log("ok");` })), }); 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({ 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") }); - vi.spyOn(service, "pushScript").mockResolvedValue({}); - vi.spyOn(service, "pullScript").mockResolvedValue(undefined); + const scriptDAO = { + 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; + 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 service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + await firstService.syncOnce({ ...syncConfig, syncStatus: false }, fs); + const restartedService = makeService(); + await restartedService.syncOnce({ ...syncConfig, syncStatus: false }, fs); expect(notificationSpy).toHaveBeenCalledTimes(1); }); @@ -3199,6 +3192,34 @@ console.log("ok");`; expect(notificationSpy).toHaveBeenCalledTimes(1); }); + it("Service Worker 重启后同一批覆盖仍只通知一次", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "c1", createtime: 1, updatetime: 100 }, + { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "m1", createtime: 1, updatetime: 100 }, + ]), + }); + const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const scriptDAO = { + scriptCodeDAO: { get: vi.fn().mockResolvedValue(undefined) }, + all: vi + .fn() + .mockResolvedValue([ + { uuid: "u1", name: "u1", updatetime: 200, 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(); + vi.spyOn(firstService, "pushScript").mockResolvedValue({}); + await firstService.syncOnce({ ...syncConfig, syncStatus: false }, fs); + const restartedService = makeService(); + vi.spyOn(restartedService, "pushScript").mockResolvedValue({}); + await restartedService.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(notificationSpy).toHaveBeenCalledTimes(1); + }); + it("同步完成后写入 cloud_sync_state(覆盖计数与 lastSyncAt)", async () => { const fs = createFs({ list: vi diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 266d9d6e6..6e21368a1 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -98,7 +98,7 @@ type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" // pushScript 分两次写 .user.js / .meta.json,前者成功后者失败时抛出本错误, // 带出已成功写入的文件名,让调用方只保留真正失败文件的旧 digest、推进成功文件的 digest, -// 避免成功文件的 digest 永久变旧后与云端 CAS 永远不匹配(永久 412)。 +// 避免已成功文件继续保留旧 digest,让下一轮只重试真正失败的文件。 class PushScriptPartialError extends Error { constructor( readonly originalError: unknown, @@ -122,6 +122,8 @@ class SyncBothChangedConflictError extends Error { } const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; +const LAST_NOTIFIED_CONFLICT_KEY = "last_notified_sync_conflicts"; +const LAST_NOTIFIED_OVERWRITE_KEY = "last_notified_sync_overwrites"; // 「查看日志」深链的标签过滤载荷(供 Logger 页 parseInitialQueries 解析)。覆盖通知直接落到 overwrite 行。 const SYNC_LOG_QUERY_OVERWRITE = JSON.stringify([ @@ -140,12 +142,6 @@ export class SynchronizeService { storage: ChromeStorage = new ChromeStorage("sync", false); - // 上一轮已通知过的冲突脚本集合(uuid 排序拼接),冲突集合未变化时不重复通知 - private lastNotifiedConflictKey = ""; - - // 上一轮已通知过的覆盖脚本集合(uuid 排序拼接),覆盖集合未变化时不重复通知 - private lastNotifiedOverwriteKey = ""; - constructor( private msgSender: MessageSend, private group: Group, @@ -672,7 +668,7 @@ export class SynchronizeService { conflictScripts.push(ret.reason); } // 分片上传时已成功写入云端的文件(如 .user.js 成功、.meta.json 失败)不保留旧 digest, - // 让 updateFileDigest 记录其云端最新 digest,避免成功文件 digest 永久变旧、后续 CAS 永久冲突。 + // 让 updateFileDigest 记录其云端最新 digest,下一轮只重试真正失败的文件。 const writtenFiles = ret.reason instanceof PushScriptPartialError ? ret.reason.writtenFiles : []; result[index].preserveDigestFiles.forEach((name) => { if (!writtenFiles.includes(name)) { @@ -690,32 +686,38 @@ export class SynchronizeService { .map((c) => c.uuid) .sort() .join(","); - if (conflictScripts.length && conflictKey !== this.lastNotifiedConflictKey) { - InfoNotification( - i18n.t("settings:notification.script_sync_conflict"), - i18n.t("settings:notification.script_sync_conflict_desc", { - scriptNames: conflictScripts.map((c) => c.scriptName).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(", "), + }) + ); + await this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, conflictKey); + } } - this.lastNotifiedConflictKey = conflictKey; // 覆盖通知:无内容基线兜底覆盖可能覆盖了未知改动,聚合一轮一条;同批不重复(集合变化或消失后重置)。 // 点击深链到已过滤 service=synchronize & action=overwrite 的日志,让用户自行确认改动。 const overwriteKey = overwriteScripts - .map((c) => c.uuid) + .map((c) => `${c.uuid}:${c.direction}`) .sort() .join(","); - if (overwriteScripts.length && overwriteKey !== this.lastNotifiedOverwriteKey) { - InfoNotification( - i18n.t("settings:notification.script_sync_overwrite"), - i18n.t("settings:notification.script_sync_overwrite_desc", { - scriptNames: overwriteScripts.map((c) => c.scriptName).join(", "), - count: overwriteScripts.length, - }), - { url: `/src/options.html#/logs?query=${encodeURIComponent(SYNC_LOG_QUERY_OVERWRITE)}` } - ); + if (overwriteScripts.length) { + const lastNotifiedOverwriteKey = ((await this.storage.get(LAST_NOTIFIED_OVERWRITE_KEY)) as string) || ""; + if (overwriteKey !== lastNotifiedOverwriteKey) { + InfoNotification( + i18n.t("settings:notification.script_sync_overwrite"), + i18n.t("settings:notification.script_sync_overwrite_desc", { + scriptNames: overwriteScripts.map((c) => c.scriptName).join(", "), + count: overwriteScripts.length, + }), + { url: `/src/options.html#/logs?query=${encodeURIComponent(SYNC_LOG_QUERY_OVERWRITE)}` } + ); + await this.storage.set(LAST_NOTIFIED_OVERWRITE_KEY, overwriteKey); + } } - this.lastNotifiedOverwriteKey = overwriteKey; // 同步状态 if (syncConfig.syncStatus && canWriteScriptcatSync) { try { @@ -799,6 +801,14 @@ export class SynchronizeService { // 重新获取文件列表,保存文件摘要 this.logger.info("update file digest"); await this.updateFileDigest(fs, pushedFileDigestMap, preserveDigestFiles); + const notificationKeyResets: Promise[] = []; + if (!conflictScripts.length) { + notificationKeyResets.push(this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, "")); + } + if (!overwriteScripts.length) { + notificationKeyResets.push(this.storage.set(LAST_NOTIFIED_OVERWRITE_KEY, "")); + } + await Promise.all(notificationKeyResets); this.logger.info("sync complete"); // failedSyncUuids 含冲突(冲突走失败路径),failed 计数排除冲突以免与 conflict 重复 return { diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index fd545289e..e68ea7d1b 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "Abonnement {{subscribeName}} wurde aktualisiert", "subscribe_update_desc": "Neue Skripte: {{newScripts}}\nGelöschte Skripte: {{deletedScripts}}", "script_sync_overwrite": "Cloud-Sync-Überschreibung", - "script_sync_overwrite_desc": "{{scriptNames}} und {{count}} Skript(e) wurden durch die Cloud-Version überschrieben. Klicken Sie, um das Sync-Protokoll zu prüfen." + "script_sync_overwrite_desc": "Bei der Synchronisierung wurden {{count}} Skript(e) überschrieben ({{scriptNames}}). Prüfen Sie Richtung und Details im Protokoll." }, "enable_background": { "title": "Hintergrundausführung aktivieren", @@ -135,6 +135,7 @@ "sync_never": "Noch nicht synchronisiert", "sync_state_syncing": "Synchronisiere…", "sync_state_attention": "Letzte Synchronisierung erfordert Ihre Prüfung", - "sync_state_attention_desc": "{{overwrite}} Skript(e) von der Cloud überschrieben, {{conflict}} Konflikt(e) pausiert. Öffnen Sie das Protokoll zur Bestätigung.", - "sync_state_error": "Synchronisierung fehlgeschlagen, pausiert" + "sync_state_attention_desc": "Bei der Synchronisierung wurden {{overwrite}} Skript(e) überschrieben und {{conflict}} Konflikt(e) pausiert. Prüfen Sie das 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 98ef96502..aa917d48a 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "Subscribe {{subscribeName}} has been updated", "subscribe_update_desc": "New scripts: {{newScripts}}\nDeleted scripts: {{deletedScripts}}", "script_sync_overwrite": "Cloud sync overwrite", - "script_sync_overwrite_desc": "{{scriptNames}} and {{count}} script(s) were overwritten by the cloud version. Click to review the sync log." + "script_sync_overwrite_desc": "{{scriptNames}}: {{count}} script(s) were overwritten during sync. Open the logs to review the direction and details." }, "enable_background": { "title": "Enable Background Running", @@ -135,6 +135,7 @@ "sync_never": "Not synced yet", "sync_state_syncing": "Syncing…", "sync_state_attention": "Last sync needs your review", - "sync_state_attention_desc": "{{overwrite}} script(s) overwritten by cloud, {{conflict}} conflict(s) paused. Open the log to confirm.", - "sync_state_error": "Sync failed, paused" + "sync_state_attention_desc": "{{overwrite}} script(s) overwritten during sync, {{conflict}} conflict(s) paused. Open the logs to review.", + "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 ba3c1704f..3864f992f 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "サブスクリプション {{subscribeName}} が更新されました", "subscribe_update_desc": "新しいスクリプト: {{newScripts}}\n削除されたスクリプト: {{deletedScripts}}", "script_sync_overwrite": "クラウド同期の上書き通知", - "script_sync_overwrite_desc": "{{scriptNames}} など {{count}} 個のスクリプトがクラウド版で上書きされました。クリックして同期ログを確認してください" + "script_sync_overwrite_desc": "同期中に {{count}} 個のスクリプト({{scriptNames}})で上書きが発生しました。ログで方向と詳細を確認してください" }, "enable_background": { "title": "バックグラウンド実行を有効にする", @@ -135,6 +135,7 @@ "sync_never": "未同期", "sync_state_syncing": "同期中…", "sync_state_attention": "前回の同期に確認が必要な変更があります", - "sync_state_attention_desc": "{{overwrite}} 個のスクリプトがクラウドで上書き、{{conflict}} 個の競合が停止中です。ログを開いて確認してください", - "sync_state_error": "同期に失敗し、停止しました" + "sync_state_attention_desc": "同期中に {{overwrite}} 個のスクリプトで上書きが発生し、{{conflict}} 個の競合が停止中です。ログを確認してください", + "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 0a0a6bf3f..3f01227e0 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "Подписка {{subscribeName}} была обновлена", "subscribe_update_desc": "Новые скрипты: {{newScripts}}\nУдаленные скрипты: {{deletedScripts}}", "script_sync_overwrite": "Перезапись при облачной синхронизации", - "script_sync_overwrite_desc": "{{scriptNames}} и ещё {{count}} скрипт(ов) перезаписаны облачной версией. Нажмите, чтобы просмотреть журнал синхронизации." + "script_sync_overwrite_desc": "Во время синхронизации перезаписано скриптов: {{count}} ({{scriptNames}}). Направление и подробности смотрите в журнале." }, "enable_background": { "title": "Включить фоновый режим", @@ -135,6 +135,7 @@ "sync_never": "Ещё не синхронизировано", "sync_state_syncing": "Синхронизация…", "sync_state_attention": "Последняя синхронизация требует проверки", - "sync_state_attention_desc": "{{overwrite}} скрипт(ов) перезаписаны облаком, {{conflict}} конфликт(ов) приостановлено. Откройте журнал для подтверждения.", - "sync_state_error": "Сбой синхронизации, приостановлено" + "sync_state_attention_desc": "Во время синхронизации перезаписано скриптов: {{overwrite}}, приостановлено конфликтов: {{conflict}}. Проверьте журнал.", + "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 002bed915..6e780a848 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "{{subscribeName}} aboneliği güncellendi", "subscribe_update_desc": "Yeni betikler: {{newScripts}}\nSilinen betikler: {{deletedScripts}}", "script_sync_overwrite": "Bulut eşitleme üzerine yazma", - "script_sync_overwrite_desc": "{{scriptNames}} ve {{count}} betik bulut sürümüyle üzerine yazıldı. Eşitleme günlüğünü görmek için tıklayın." + "script_sync_overwrite_desc": "Eşitleme sırasında {{count}} betiğin üzerine yazıldı ({{scriptNames}}). Yön ve ayrıntılar için günlükleri açın." }, "enable_background": { "title": "Arka Planda Çalıştırmayı Etkinleştir", @@ -135,6 +135,7 @@ "sync_never": "Henüz eşitlenmedi", "sync_state_syncing": "Eşitleniyor…", "sync_state_attention": "Son eşitleme incelemenizi gerektiriyor", - "sync_state_attention_desc": "{{overwrite}} betik bulut tarafından üzerine yazıldı, {{conflict}} çakışma duraklatıldı. Onaylamak için günlüğü açın.", - "sync_state_error": "Eşitleme başarısız, duraklatıldı" + "sync_state_attention_desc": "Eşitleme sırasında {{overwrite}} betiğin üzerine yazıldı ve {{conflict}} çakışma duraklatıldı. Günlükleri inceleyin.", + "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 ed45bfbd8..d6d477f7e 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "Đăng ký {{subscribeName}} đã được cập nhật", "subscribe_update_desc": "Script mới: {{newScripts}}\nScript đã xóa: {{deletedScripts}}", "script_sync_overwrite": "Cảnh báo ghi đè đồng bộ đám mây", - "script_sync_overwrite_desc": "{{scriptNames}} và {{count}} tập lệnh đã bị ghi đè bởi phiên bản đám mây. Nhấp để xem nhật ký đồng bộ." + "script_sync_overwrite_desc": "Có {{count}} script bị ghi đè trong khi đồng bộ ({{scriptNames}}). Mở nhật ký để xem hướng và chi tiết." }, "enable_background": { "title": "Bật chạy nền", @@ -135,6 +135,7 @@ "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": "{{overwrite}} tập lệnh bị đám mây ghi đè, {{conflict}} xung đột đã tạm dừng. Mở nhật ký để xác nhận.", - "sync_state_error": "Đồng bộ thất bại, đã tạm dừng" + "sync_state_attention_desc": "Có {{overwrite}} script bị ghi đè khi đồng bộ và {{conflict}} xung đột đã tạm dừng. Mở nhật ký để kiểm tra.", + "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 d3374673f..f092fb91d 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "订阅 {{subscribeName}} 已更新", "subscribe_update_desc": "新增脚本:{{newScripts}}\n删除脚本:{{deletedScripts}}", "script_sync_overwrite": "云同步覆盖提醒", - "script_sync_overwrite_desc": "{{scriptNames}} 等 {{count}} 个脚本已被云端版本覆盖,点击查看同步记录确认" + "script_sync_overwrite_desc": "同步时有 {{count}} 个脚本发生覆盖({{scriptNames}}),请打开日志确认覆盖方向和详情" }, "enable_background": { "title": "启用后台运行", @@ -135,6 +135,7 @@ "sync_never": "尚未同步", "sync_state_syncing": "正在同步…", "sync_state_attention": "上次同步有需要确认的改动", - "sync_state_attention_desc": "{{overwrite}} 个脚本被云端覆盖、{{conflict}} 个冲突已暂停,点开日志确认", - "sync_state_error": "同步失败,已暂停" + "sync_state_attention_desc": "同步时有 {{overwrite}} 个脚本发生覆盖、{{conflict}} 个冲突已暂停,请打开日志确认", + "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 13a823c84..566c8d6da 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -96,7 +96,7 @@ "subscribe_update": "訂閱 {{subscribeName}} 已更新", "subscribe_update_desc": "新增腳本:{{newScripts}}\n刪除腳本:{{deletedScripts}}", "script_sync_overwrite": "雲同步覆蓋提醒", - "script_sync_overwrite_desc": "{{scriptNames}} 等 {{count}} 個腳本已被雲端版本覆蓋,點擊查看同步記錄確認" + "script_sync_overwrite_desc": "同步時有 {{count}} 個腳本發生覆蓋({{scriptNames}}),請開啟日誌確認覆蓋方向與詳細資訊" }, "enable_background": { "title": "啟用背景運行", @@ -135,6 +135,7 @@ "sync_never": "尚未同步", "sync_state_syncing": "正在同步…", "sync_state_attention": "上次同步有需要確認的變更", - "sync_state_attention_desc": "{{overwrite}} 個腳本被雲端覆蓋、{{conflict}} 個衝突已暫停,點開日誌確認", - "sync_state_error": "同步失敗,已暫停" + "sync_state_attention_desc": "同步時有 {{overwrite}} 個腳本發生覆蓋、{{conflict}} 個衝突已暫停,請開啟日誌確認", + "sync_state_error": "同步失敗,已暫停", + "sync_state_failed_desc": "{{failed}} 個腳本同步失敗,請開啟日誌查看詳細資訊" } diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx index c1e8e82f7..d0bc25b2f 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, 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: { @@ -81,6 +88,7 @@ beforeEach(() => { mockState(); subscribeCloudSyncState.mockReturnValue(() => {}); requestCloudSyncOnce.mockResolvedValue(undefined); + t.mockClear(); notify.info.mockClear(); notify.success.mockClear(); notify.error.mockClear(); @@ -152,7 +160,17 @@ describe("同步分区", () => { 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)).toContain("overwrite"); + 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 () => { diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index f6a6e4a13..1b60ce11e 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -83,7 +83,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE break; case "error": title = t("settings:sync_state_error"); - desc = syncState.error || ""; + desc = syncState.error || t("settings:sync_state_failed_desc", { failed: syncState.counts.failed }); break; case "syncing": title = t("settings:sync_state_syncing"); @@ -115,7 +115,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE {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 index 5b9931c58..2b7fb1eb2 100644 --- a/src/pages/options/routes/Setting/sections/syncStatus.test.ts +++ b/src/pages/options/routes/Setting/sections/syncStatus.test.ts @@ -1,6 +1,7 @@ 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 } @@ -23,13 +24,32 @@ describe("同步状态样式判定", () => { expect(syncStatusVariant(base({ counts: { conflict: 1 } }))).toBe("warning"); }); + it("有文件同步失败时为 error,不能显示同步正常", () => { + expect(syncStatusVariant(base({ counts: { failed: 1 } }))).toBe("error"); + }); + it("无异常为 idle", () => { expect(syncStatusVariant(base({ counts: { total: 5 } }))).toBe("idle"); }); - it("warning 深链带 overwrite 过滤,其它只带 service", () => { - expect(decodeURIComponent(syncLogHref("warning"))).toContain("overwrite"); - expect(decodeURIComponent(syncLogHref("idle"))).toContain("synchronize"); - expect(decodeURIComponent(syncLogHref("idle"))).not.toContain("overwrite"); + 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.notification.script_sync_overwrite_desc).toBe( + "{{scriptNames}}: {{count}} script(s) were overwritten during sync. Open the logs to review the direction and details." + ); + expect(enSettings.sync_state_attention_desc).toContain("overwritten during sync"); }); }); diff --git a/src/pages/options/routes/Setting/sections/syncStatus.ts b/src/pages/options/routes/Setting/sections/syncStatus.ts index d6ab916fa..7670a1c13 100644 --- a/src/pages/options/routes/Setting/sections/syncStatus.ts +++ b/src/pages/options/routes/Setting/sections/syncStatus.ts @@ -4,7 +4,7 @@ export type SyncStatusVariant = "idle" | "syncing" | "warning" | "error"; // 状态优先级:失败 > 同步中 > 有覆盖/冲突 > 正常 export function syncStatusVariant(state: CloudSyncState): SyncStatusVariant { - if (state.error) return "error"; + if (state.error || state.counts.failed > 0) return "error"; if (state.syncing) return "syncing"; if (state.counts.overwrite > 0 || state.counts.conflict > 0) return "warning"; return "idle"; @@ -12,8 +12,10 @@ export function syncStatusVariant(state: CloudSyncState): SyncStatusVariant { // 「查看日志」深链:预过滤 service=synchronize;有覆盖时进一步落到 overwrite 行。 // 由 Logger 页 parseInitialQueries 解析(?query=)。 -export function syncLogHref(variant: SyncStatusVariant): string { +export function syncLogHref(state: CloudSyncState): string { const query: { key: string; value: string }[] = [{ key: "service", value: "synchronize" }]; - if (variant === "warning") query.push({ key: "action", value: "overwrite" }); + 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))}`; } From 8229e9dd0aa8dc98eef446f7b3d64da611eaff09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 10:33:33 +0800 Subject: [PATCH 093/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=B7=A8=E6=97=B6=E9=92=9F=E5=9F=9F=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E6=95=B4=E7=A7=92=E5=AF=B9=E9=BD=90=EF=BC=8C?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E9=80=9A=E7=9F=A5=E6=94=B9=E4=B8=BA=E5=86=99?= =?UTF-8?q?=E5=85=A5=E6=88=90=E5=8A=9F=E5=90=8E=E7=99=BB=E8=AE=B0=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补偿 push 与无基线回退比较前两侧截断到整秒:同秒内毫秒余数不再误判"本地更新", 消除真实 provider 上每次编辑后的冗余覆盖 push;无基线回退同秒改判 pull - 无基线兜底的 overwrite 日志/通知在写入成功后才登记:失败轮不谎报覆盖, 去重键也不会把下一轮真覆盖静默掉 - classifySyncError 移除 auth 冗余分支与已无真实来源的 unsupported 分类 - 补 counts.failed / counts.conflict / 顶层 error 状态回归测试 --- .../service_worker/synchronize.test.ts | 283 +++++++++++++++--- src/app/service/service_worker/synchronize.ts | 54 ++-- 2 files changed, 272 insertions(+), 65 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index d60f9b2b4..d4a3271c8 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -702,6 +702,8 @@ describe("SynchronizeService", () => { 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); @@ -1613,12 +1615,6 @@ console.log("ok");` expectedKind: "stale_snapshot", expectedMessage: "File moved", }, - { - title: "unsupported", - error: new Error("unsupported provider operation"), - expectedKind: "unsupported", - expectedMessage: "unsupported provider operation", - }, ])( "单文件同步遇到 $title 错误时应在日志中标记 $expectedKind 分类", async ({ error, expectedKind, expectedMessage }) => { @@ -2535,8 +2531,8 @@ console.log("ok");` list: vi .fn() .mockResolvedValueOnce([ - { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu1", createtime: 1, updatetime: 5 }, - { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm1", createtime: 1, updatetime: 5 }, + { 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([]), }); @@ -2553,7 +2549,7 @@ console.log("ok");` all: vi .fn() .mockResolvedValue([ - { uuid: "u1", name: "t", updatetime: 20, createtime: 1, status: 1, sort: 0, metadata: {} }, + { uuid: "u1", name: "t", updatetime: 20_000, createtime: 1, status: 1, sort: 0, metadata: {} }, ]), } as any ); @@ -2680,8 +2676,8 @@ console.log("ok");` const writes: string[] = []; let seq = 0; const cloud = new Map([ - ["u1.user.js", { digest: "cu1", content: "// v0", updatetime: 5 }], - ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5 }], + ["u1.user.js", { digest: "cu1", content: "// v0", updatetime: 5000 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5000 }], ]); const cloudFs = createFs({ list: vi.fn(async () => @@ -2727,7 +2723,7 @@ console.log("ok");` origin: "", downloadUrl: "", checkUpdateUrl: "", - updatetime: 20, + updatetime: 20_000, createtime: 1, status: 1, sort: 0, @@ -2741,7 +2737,7 @@ console.log("ok");` // 第一轮:编辑触发 scriptInstall,.user.js 成功、.meta.json 失败 await service.scriptInstall({ - script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "", updatetime: 10 } as any, + script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "", updatetime: 10_000 } as any, upsertBy: "user", } as any); await stackAsyncTask("cloud_sync_queue", async () => "barrier"); @@ -2764,8 +2760,8 @@ console.log("ok");` // 且推进 digest/更新时间后,下一轮应回到稳态不再重复推送。 let seq = 0; const cloud = new Map([ - ["u1.user.js", { digest: "cu1", content: "// v1", updatetime: 5 }], - ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5 }], + ["u1.user.js", { digest: "cu1", content: "// v1", updatetime: 5000 }], + ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5000 }], ]); const writes: string[] = []; const cloudFs = createFs({ @@ -2808,7 +2804,7 @@ console.log("ok");` origin: "", downloadUrl: "", checkUpdateUrl: "", - updatetime: 20, + updatetime: 20_000, createtime: 1, status: 1, sort: 0, @@ -2829,6 +2825,42 @@ console.log("ok");` expect(writes).toEqual([]); }); + it("云端未变且本地更新时间与云端同秒时不触发补偿 push(整秒对齐)", async () => { + // 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端时钟(WebDAV 等仅整秒精度): + // 同一秒内的毫秒余数不构成"本地更新"的证据,否则每次编辑上云后都会多一轮冗余覆盖 push + const fs = createFs({ + 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, + {} 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: 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); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + }); + it("云端已变而本地内容未变时,即使本地时间戳更大也应 pull 而非 push(同秒竞态 L4)", async () => { // 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端整秒时钟,两者不可比: // 对端更新落在同一秒时,毫秒余数会让本地"看起来更新"而误走 push。 @@ -2997,8 +3029,8 @@ console.log("ok");` it("无本地内容基线时退回时间比较决定方向(升级兼容)", async () => { const fs = createFs({ list: vi.fn().mockResolvedValue([ - { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cu2", createtime: 1, updatetime: 5 }, - { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cm2", createtime: 1, updatetime: 5 }, + { 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( @@ -3014,7 +3046,7 @@ console.log("ok");` all: vi .fn() .mockResolvedValue([ - { uuid: "u1", name: "t", updatetime: 20, createtime: 1, status: 1, sort: 0, metadata: {} }, + { uuid: "u1", name: "t", updatetime: 20_000, createtime: 1, status: 1, sort: 0, metadata: {} }, ]), } as any ); @@ -3028,6 +3060,42 @@ console.log("ok");` expect(pullSpy).not.toHaveBeenCalled(); }); + 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, + {} 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: 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); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); + + expect(pullSpy).toHaveBeenCalledWith(fs, expect.anything(), undefined, expect.objectContaining({ uuid: "u1" })); + expect(pushSpy).not.toHaveBeenCalled(); + }); + it("pull 成功后记录内容基线,供下轮云端再变时判定本地是否也改过", async () => { const installScript = vi.fn().mockResolvedValue(undefined); const pulledCode = `// ==UserScript== @@ -3097,8 +3165,8 @@ console.log("ok");`; it("云端已变但本地无内容基线时,方向判定标记为 unverified(可能覆盖未知改动)", async () => { const service = makeService({ get: vi.fn().mockResolvedValue(undefined) }); - const cloudFile = { name: "u1.user.js", updatetime: 100 } as any; - const script = { uuid: "u1", name: "u1", updatetime: 200, createtime: 1 } as any; + 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, {}); @@ -3108,8 +3176,8 @@ console.log("ok");`; it("有内容基线且本地未改时,pull 不标记 unverified", async () => { const code = "// code"; const service = makeService({ get: vi.fn().mockResolvedValue({ code }) }); - const cloudFile = { name: "u1.user.js", updatetime: 100 } as any; - const script = { uuid: "u1", name: "u1", updatetime: 200, createtime: 1 } as any; + 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), @@ -3123,8 +3191,15 @@ console.log("ok");`; list: vi .fn() .mockResolvedValueOnce([ - { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "cloudD", createtime: 1, updatetime: 100 }, - { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cloudM", createtime: 1, updatetime: 100 }, + { 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([]), }); @@ -3141,7 +3216,7 @@ console.log("ok");`; all: vi .fn() .mockResolvedValue([ - { uuid: "u1", name: "u1", updatetime: 200, createtime: 1, status: 1, sort: 0, metadata: {} }, + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, ]), } as any ); @@ -3161,10 +3236,10 @@ console.log("ok");`; list: vi .fn() .mockResolvedValueOnce([ - { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "c1", createtime: 1, updatetime: 100 }, - { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "m1", createtime: 1, updatetime: 100 }, - { name: "u2.user.js", path: "u2.user.js", size: 1, digest: "c2", createtime: 1, updatetime: 100 }, - { name: "u2.meta.json", path: "u2.meta.json", size: 1, digest: "m2", createtime: 1, updatetime: 100 }, + { 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([]), }); @@ -3180,8 +3255,8 @@ console.log("ok");`; { scriptCodeDAO: { get: vi.fn().mockResolvedValue(undefined) }, all: vi.fn().mockResolvedValue([ - { uuid: "u1", name: "u1", updatetime: 200, createtime: 1, status: 1, sort: 0, metadata: {} }, - { uuid: "u2", name: "u2", updatetime: 200, createtime: 1, status: 1, sort: 1, metadata: {} }, + { 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 ); @@ -3195,8 +3270,8 @@ console.log("ok");`; it("Service Worker 重启后同一批覆盖仍只通知一次", async () => { const fs = createFs({ list: vi.fn().mockResolvedValue([ - { name: "u1.user.js", path: "u1.user.js", size: 1, digest: "c1", createtime: 1, updatetime: 100 }, - { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "m1", createtime: 1, updatetime: 100 }, + { 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"); @@ -3205,7 +3280,7 @@ console.log("ok");`; all: vi .fn() .mockResolvedValue([ - { uuid: "u1", name: "u1", updatetime: 200, createtime: 1, status: 1, sort: 0, metadata: {} }, + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, ]), } as any; const makeService = () => @@ -3220,13 +3295,62 @@ console.log("ok");`; expect(notificationSpy).toHaveBeenCalledTimes(1); }); + 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).toHaveBeenCalledTimes(1); + 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 }, - { name: "u1.meta.json", path: "u1.meta.json", size: 1, digest: "cloudM", createtime: 1, updatetime: 100 }, + { 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([]), }); @@ -3243,7 +3367,7 @@ console.log("ok");`; all: vi .fn() .mockResolvedValue([ - { uuid: "u1", name: "u1", updatetime: 200, createtime: 1, status: 1, sort: 0, metadata: {} }, + { uuid: "u1", name: "u1", updatetime: 200_000, createtime: 1, status: 1, sort: 0, metadata: {} }, ]), } as any ); @@ -3255,6 +3379,89 @@ console.log("ok");`; 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", () => { diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 6e21368a1..b706d6d80 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -94,7 +94,11 @@ type SyncTask = { preserveDigestFiles: string[]; }; -type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "unsupported" | "fatal"; +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, @@ -593,9 +597,9 @@ export class SynchronizeService { return; } if (fileDigestMap[file.script!.name] === file.script!.digest) { - // 云端自上次同步未变:本地更新时间不比云端新则无事可做; + // 云端自上次同步未变:本地更新时间不比云端新(整秒对齐)则无事可做; // 否则本地编辑过(digest 相等只反映云端未变),需补偿上传(#1,队列 push 失败后的兜底) - if (updatetime <= file.script!.updatetime) { + if (!isNewerBySecond(updatetime, file.script!.updatetime)) { return; } addSyncTask(uuid, this.pushScript(fs, script)); @@ -608,22 +612,24 @@ export class SynchronizeService { 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") { - if (direction.unverified) { - const scriptName = i18nName(script); - this.logger.warn("sync overwrite", { action: "overwrite", direction: "pull", uuid, name: scriptName }); - overwriteScripts.push({ uuid, scriptName, direction: "pull" }); - } updateScript.set(uuid, true); - return await this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script); + await this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script); + recordOverwrite("pull"); + return; } if (direction.action === "push") { - if (direction.unverified) { - const scriptName = i18nName(script); - this.logger.warn("sync overwrite", { action: "overwrite", direction: "push", uuid, name: scriptName }); - overwriteScripts.push({ uuid, scriptName, direction: "push" }); - } - return await this.pushScript(fs, script); + const pushed = await this.pushScript(fs, script); + recordOverwrite("push"); + return pushed; } if (direction.action === "adopt") { // 两端内容一致只是基线过期:返回内容 md5 让 updateFileDigest 推进基线,不产生写操作 @@ -836,17 +842,10 @@ export class SynchronizeService { if (error.notFound) { return "stale_snapshot"; } - if (error.auth) { - return "fatal"; - } - return "fatal"; - } - if (isWarpTokenError(error)) { + // auth 等其余 typed 错误都属于 fatal return "fatal"; } - if (error instanceof Error && /\bunsupported\b/i.test(error.message)) { - return "unsupported"; - } + // WarpTokenError 及其他未分类错误一律 fatal return "fatal"; } @@ -1081,10 +1080,11 @@ export class SynchronizeService { > { const baselineMd5 = syncedContentMd5Map[cloudFile.name]; if (baselineMd5 === undefined) { - // 无内容基线(升级前旧数据/从未同步成功):只能退回跨时钟域的墙钟比较,无法确认落败一端是否真未改动, - // 因此标记 unverified——上层据此打 overwrite 日志并通知用户,让其自行确认(见 docs/cloud-sync.md 覆盖可见性)。 + // 无内容基线(升级前旧数据/从未同步成功):只能退回跨时钟域的墙钟比较(整秒对齐,同秒判 pull), + // 无法确认落败一端是否真未改动,因此标记 unverified——上层据此打 overwrite 日志并通知用户, + // 让其自行确认(见 docs/cloud-sync.md 覆盖可见性)。 const updatetime = script.updatetime || script.createtime; - return { action: updatetime > cloudFile.updatetime ? "push" : "pull", unverified: true }; + 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; From 227debfa9813238dfbcc7f5f8af57e28ba775093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 10:34:00 +0800 Subject: [PATCH 094/103] =?UTF-8?q?=F0=9F=90=9B=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9D=A1=EF=BC=9A=E9=87=8D=E8=AF=95=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E4=B8=8D=E5=86=8D=E8=A2=AB=E4=B8=8A=E4=B8=80?= =?UTF-8?q?=E8=BD=AE=20failed=20=E8=AE=A1=E6=95=B0=E8=AF=AF=E6=98=BE?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E5=90=8C=E6=AD=A5=E5=A4=B1=E8=B4=A5=E3=80=8D?= =?UTF-8?q?=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SW 起始写只清 error 不清 counts,failed>0 判定需让位于 syncing; error 字符串仍最高优先(起始写已清 error,实际不会与 syncing 并存) --- .../options/routes/Setting/sections/syncStatus.test.ts | 5 +++++ src/pages/options/routes/Setting/sections/syncStatus.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/options/routes/Setting/sections/syncStatus.test.ts b/src/pages/options/routes/Setting/sections/syncStatus.test.ts index 2b7fb1eb2..e294c64d7 100644 --- a/src/pages/options/routes/Setting/sections/syncStatus.test.ts +++ b/src/pages/options/routes/Setting/sections/syncStatus.test.ts @@ -28,6 +28,11 @@ describe("同步状态样式判定", () => { 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"); }); diff --git a/src/pages/options/routes/Setting/sections/syncStatus.ts b/src/pages/options/routes/Setting/sections/syncStatus.ts index 7670a1c13..ecd2b81bd 100644 --- a/src/pages/options/routes/Setting/sections/syncStatus.ts +++ b/src/pages/options/routes/Setting/sections/syncStatus.ts @@ -2,10 +2,12 @@ import { type CloudSyncState } from "@App/pkg/config/config"; export type SyncStatusVariant = "idle" | "syncing" | "warning" | "error"; -// 状态优先级:失败 > 同步中 > 有覆盖/冲突 > 正常 +// 状态优先级:失败(error) > 同步中 > 上轮文件失败 > 有覆盖/冲突 > 正常。 +// SW 起始写只清 error 不清 counts:重试进行中不能被旧 counts.failed 压成"失败已暂停" export function syncStatusVariant(state: CloudSyncState): SyncStatusVariant { - if (state.error || state.counts.failed > 0) return "error"; + if (state.error) return "error"; if (state.syncing) return "syncing"; + if (state.counts.failed > 0) return "error"; if (state.counts.overwrite > 0 || state.counts.conflict > 0) return "warning"; return "idle"; } From f0a3962ef5e91949e425f0f23af772203355bb84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 10:34:09 +0800 Subject: [PATCH 095/103] =?UTF-8?q?=F0=9F=90=9B=20provider=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=88=86=E7=B1=BB=E4=BF=AE=E6=AD=A3=EF=BC=9ADrive=204?= =?UTF-8?q?03=20=E9=99=90=E6=B5=81=20reason=20=E5=88=A4=20rateLimit?= =?UTF-8?q?=E3=80=81WebDAV=20409=20=E4=B8=8D=E5=88=A4=E5=86=B2=E7=AA=81=20?= =?UTF-8?q?#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google Drive 403 + reason=rateLimitExceeded/userRateLimitExceeded 转 typed rateLimit/retryable(官方建议指数退避,不能归为永久失败) - WebDAV 409 在 RFC 4918 中是父集合不存在等前置问题,不再判 conflict,仅保留 412 --- .../googledrive/googledrive.test.ts | 27 +++++++++++++++++++ .../filesystem/googledrive/googledrive.ts | 13 +++++++-- packages/filesystem/webdav/error.ts | 3 ++- packages/filesystem/webdav/webdav.test.ts | 19 ++++++++++++- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 5a445cae5..045ea76e0 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -99,6 +99,33 @@ describe("GoogleDriveFileSystem", () => { }); }); + 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"); diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index f1a1cb5f2..f1affbab1 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, @@ -141,9 +150,9 @@ export default class GoogleDriveFileSystem implements FileSystem { auth: googleStatus === 401, notFound: googleStatus === 404, conflict: googleStatus === 409 || googleStatus === 412, - rateLimit: googleStatus === 429, + rateLimit, // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 - retryable: googleStatus === 429 || (googleStatus !== undefined && [500, 502, 503, 504].includes(googleStatus)), + retryable: rateLimit || (googleStatus !== undefined && [500, 502, 503, 504].includes(googleStatus)), raw, }); } diff --git a/packages/filesystem/webdav/error.ts b/packages/filesystem/webdav/error.ts index 8db517429..bfad7c17a 100644 --- a/packages/filesystem/webdav/error.ts +++ b/packages/filesystem/webdav/error.ts @@ -24,7 +24,8 @@ export function createWebDAVFileSystemError(error: unknown): unknown { status, auth: status === 401 || status === 403, notFound: status === 404, - conflict: status === 409 || status === 412, + // RFC 4918 中 PUT/MKCOL 的 409 是父集合不存在等前置问题,不是资源版本冲突 + conflict: status === 412, rateLimit, retryable: rateLimit || transient, raw: error, diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 512eb8ec6..7802c1783 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 { @@ -121,6 +122,22 @@ describe("WebDAVFileSystem", () => { }); }); + 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); + }); + + it("412 应判定为冲突", () => { + const err = createWebDAVFileSystemError({ message: "Precondition Failed", response: { status: 412 } }); + + expect(err).toBeInstanceOf(FileSystemError); + expect((err as FileSystemError).conflict).toBe(true); + }); + }); + describe("openDir", () => { it("应当返回新实例并拼接路径", async () => { const fs = createTestFS(mockClient); From 38ded05bf43632ec1fbffaff35165b93f79e0750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 10:34:34 +0800 Subject: [PATCH 096/103] =?UTF-8?q?=F0=9F=93=9D=20cloud-sync=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=9A=E6=95=B4=E7=A7=92=E5=AF=B9=E9=BD=90=E4=B8=8E?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=99=BB=E8=AE=B0=E6=97=B6=E6=9C=BA=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=8C=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E7=9A=84=E6=97=A5=E5=BF=97=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 决策规则/回退规则补整秒对齐语义(同秒不判本地更新、回退同秒判 pull) - 覆盖日志说明改为写入成功后登记 - 删除「日志按 LogCleanCycle 自动清理」声明(LoggerDAO.deleteBefore 无调用点) - 错误分类表删除已无来源的 unsupported 行;Zip digest 表述改为空 --- docs/cloud-sync.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index d3df06aee..042c00380 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -159,13 +159,13 @@ digest 更新有两条路径,区别在于对账范围: - 云端缺 `.meta.json`(上一轮分片上传残留):push 本地脚本补齐 meta。 - 云端 digest 与 `file_digest` 一致(云端自上次同步未变): - - 本地更新时间不比云端新:跳过。 - - 本地更新时间更晚:补偿 push——云端 digest 检测不到本地编辑,队列 push 失败后也靠这里兜底。 + - 本地更新时间不比云端新(整秒对齐比较):跳过。 + - 本地更新时间更晚:补偿 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),同步层无法察觉。内容基线只能减少基于旧快照做出错误方向判断的概率,不能消除请求之间的并发窗口。 @@ -247,7 +247,6 @@ provider 应尽量抛 `FileSystemError`。同步层用 `classifySyncError()` 映 | `FileSystemError.rateLimit` 或 `retryable` | `transient` | 429、瞬时 5xx(500/502/503/504)等可重试错误 | | `FileSystemError.notFound` | `stale_snapshot` | list 到操作之间远端消失或缓存过期 | | `FileSystemError.auth` 或 `WarpTokenError` | `fatal` | 授权失败 | -| error message 包含 `unsupported` | `unsupported` | provider 不支持 | | 其他 | `fatal` | 未分类错误 | 错误分类主要用于日志、保留 digest、后续 retry 策略和 review 判断。它不是用户可见错误协议。 @@ -273,7 +272,7 @@ typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501、505、507 等 | 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 | 空或 zip metadata | 普通覆盖写入 | 备份用途 | +| Zip | 空 | 普通覆盖写入 | 备份用途 | ### WebDAV @@ -360,7 +359,7 @@ type CloudSyncState = { ### 覆盖日志(`action` 标签) -`decideDirectionOnRemoteChange()` 的**无内容基线兜底**分支(`baselineMd5 === undefined`)只能按跨时钟域墙钟比较 pull/push,可能覆盖未知改动。该分支返回 `{ action, unverified: true }`,调用点据此打警告日志: +`decideDirectionOnRemoteChange()` 的**无内容基线兜底**分支(`baselineMd5 === undefined`)只能按跨时钟域墙钟比较(整秒对齐)pull/push,可能覆盖未知改动。该分支返回 `{ action, unverified: true }`,调用点在**写入成功后**据此打警告日志(失败轮次不登记也不通知,避免谎报覆盖、且不让去重键把下一轮真覆盖静默掉): ```ts this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name }); @@ -376,7 +375,7 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name ### 边界 -- 日志按 `LogCleanCycle`(默认 7 天,`LoggerDAO.deleteBefore`)自动清理,回溯窗口约最近 7 天。 +- 日志目前没有自动清理机制(`LogCleanCycle` 设置与 `LoggerDAO.deleteBefore` 接口存在,但没有调用点执行清理),覆盖日志可长期回溯。 - `overwrite` 只覆盖「无基线兜底」这一**可检测**的静默覆盖;纯 TOCTOU last-writer-wins(见上文 push 一节)客户端无法察觉,不在本轮可见性范围内。 ## 生产兼容要求 @@ -416,7 +415,7 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name 7. 云端已变、本地内容基线未变时必须 pull——即使本地 updatetime 大于云端 mtime(同秒竞态 L4)。 8. 本地与云端都变(真冲突)时不 push 不 pull,保留旧 digest 与云端 status,一轮只发一条聚合通知,同一批冲突不重复通知。 9. 云端已变、本地也变但内容与云端一致时,收敛基线且不产生写操作。 -10. 无内容基线(升级前旧数据)时退回时间比较规则。 +10. 无内容基线(升级前旧数据)时退回整秒对齐的时间比较规则,同秒判 pull。 11. 队列路径 digest 只更新本次 uuid 文件,不全量盖章漏 pull。 12. 同步失败计数必须在设置页显示为失败,不能显示为同步正常。 13. 冲突或失败状态的日志深链不能被覆盖过滤条件隐藏。 From 706d8c4439ed3b71990bb75025a996a525c4448a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 10:45:46 +0800 Subject: [PATCH 097/103] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=B7=B2=E7=A7=BB=E9=99=A4=E6=9D=A1=E4=BB=B6=E5=86=99=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=E7=9A=84=E5=86=B2=E7=AA=81=E5=88=86=E7=B1=BB=E6=AE=8B?= =?UTF-8?q?=E7=95=99=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 1 - packages/filesystem/googledrive/googledrive.test.ts | 5 +++-- packages/filesystem/googledrive/googledrive.ts | 2 +- packages/filesystem/onedrive/onedrive.test.ts | 7 +++---- packages/filesystem/onedrive/onedrive.ts | 7 +------ packages/filesystem/s3/error.ts | 2 +- packages/filesystem/webdav/error.ts | 2 -- packages/filesystem/webdav/webdav.test.ts | 7 ------- 8 files changed, 9 insertions(+), 24 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 042c00380..18b7002bb 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -286,7 +286,6 @@ typed `retryable` 只覆盖瞬时 5xx(500/502/503/504)。501、505、507 等 维护时注意: - list 返回的 `ETag` 会去掉引号作为 digest。 -- `PreconditionFailed` / 412 转 typed conflict。 - `NoSuchKey` 删除视为成功。 ### OneDrive diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 045ea76e0..eb5915e92 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -414,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( @@ -426,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 f1affbab1..5f2c7401c 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -149,7 +149,7 @@ export default class GoogleDriveFileSystem implements FileSystem { code, auth: googleStatus === 401, notFound: googleStatus === 404, - conflict: googleStatus === 409 || googleStatus === 412, + conflict: googleStatus === 409, rateLimit, // 只重试瞬时 5xx;501 等属于永久失败,重试只会空转退避 retryable: rateLimit || (googleStatus !== undefined && [500, 502, 503, 504].includes(googleStatus)), diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index 358c7db8a..20beaffe8 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -303,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 ac5a45565..6da951ab0 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -92,12 +92,7 @@ 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); diff --git a/packages/filesystem/s3/error.ts b/packages/filesystem/s3/error.ts index e8be345f9..ea9cf2f66 100644 --- a/packages/filesystem/s3/error.ts +++ b/packages/filesystem/s3/error.ts @@ -17,7 +17,7 @@ export function createS3FileSystemError(error: unknown): unknown { code: error.code, auth: error.statusCode === 401 || error.statusCode === 403, notFound: error.statusCode === 404 || error.code === "NoSuchKey" || error.code === "NoSuchBucket", - conflict: error.statusCode === 409 || error.statusCode === 412 || error.code === "PreconditionFailed", + conflict: error.statusCode === 409, rateLimit, retryable: rateLimit || transient, raw: error, diff --git a/packages/filesystem/webdav/error.ts b/packages/filesystem/webdav/error.ts index bfad7c17a..e75f86751 100644 --- a/packages/filesystem/webdav/error.ts +++ b/packages/filesystem/webdav/error.ts @@ -24,8 +24,6 @@ export function createWebDAVFileSystemError(error: unknown): unknown { status, auth: status === 401 || status === 403, notFound: status === 404, - // RFC 4918 中 PUT/MKCOL 的 409 是父集合不存在等前置问题,不是资源版本冲突 - conflict: status === 412, rateLimit, retryable: rateLimit || transient, raw: error, diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 7802c1783..6106d59ac 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -129,13 +129,6 @@ describe("WebDAVFileSystem", () => { expect(err).toBeInstanceOf(FileSystemError); expect((err as FileSystemError).conflict).toBe(false); }); - - it("412 应判定为冲突", () => { - const err = createWebDAVFileSystemError({ message: "Precondition Failed", response: { status: 412 } }); - - expect(err).toBeInstanceOf(FileSystemError); - expect((err as FileSystemError).conflict).toBe(true); - }); }); describe("openDir", () => { From df0f7c39d14575b7da996b40db53680f2480917d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 11:34:30 +0800 Subject: [PATCH 098/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=9C=AA=E5=AE=8C=E6=88=90=E6=93=8D=E4=BD=9C=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E9=87=8D=E6=94=BE=EF=BC=9A=E5=8D=8A=E9=80=94?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8F=AF=E6=81=A2=E5=A4=8D=E3=80=81.meta.jso?= =?UTF-8?q?n=20=E5=A4=B1=E8=B4=A5=E5=8F=AF=E8=A1=A5=E4=BC=A0=E5=B9=B6?= =?UTF-8?q?=E8=81=94=E5=90=88=E5=AF=B9=E8=B4=A6=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 343 +++++++++++++++--- src/app/service/service_worker/synchronize.ts | 161 +++++++- 2 files changed, 451 insertions(+), 53 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index d4a3271c8..2411ab2a0 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -39,7 +39,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(); } @@ -2630,6 +2631,18 @@ console.log("ok");` }), })), } 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, @@ -2640,20 +2653,8 @@ console.log("ok");` {} as any, { scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// code" }) }, - all: vi.fn().mockResolvedValue([ - { - uuid: "u1", - name: "t", - origin: "", - downloadUrl: "", - checkUpdateUrl: "", - updatetime: 5, - createtime: 1, - status: 1, - sort: 0, - metadata: {}, - }, - ]), + all: vi.fn().mockResolvedValue([daoScript]), + get: vi.fn().mockResolvedValue(daoScript), } as any ); @@ -2671,17 +2672,20 @@ console.log("ok");` expect(cloud.has("u1.meta.json")).toBe(true); }); - it("编辑已同步脚本时 .meta.json 失败,下一轮同步应补传", async () => { - let failMeta = true; - const writes: string[] = []; + // 模拟共享状态的云端存储: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 cloud = new Map([ - ["u1.user.js", { digest: "cu1", content: "// v0", updatetime: 5000 }], - ["u1.meta.json", { digest: "cm1", content: "{}", updatetime: 5000 }], - ]); - const cloudFs = createFs({ + 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(cloud, ([name, f]) => ({ + Array.from(files, ([name, f]) => ({ name, path: name, size: 1, @@ -2690,20 +2694,48 @@ console.log("ok");` updatetime: f.updatetime, })) ), - create: vi.fn(async (name: string, opts: any) => ({ + 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) => { - const existing = cloud.get(name); - if (failMeta && name === "u1.meta.json") throw new Error("meta write failed"); - cloud.set(name, { - digest: `d${++seq}`, - content, - updatetime: opts?.modifiedDate ?? existing?.updatetime ?? 0, - }); - writes.push(name); + 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, @@ -2714,6 +2746,55 @@ console.log("ok");` { 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" }); + + // 第一轮:编辑发布生产形态消息(无 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"); + + expect(store.files.get("u1.user.js")!.content).toBe("// v1"); + expect(store.files.get("u1.meta.json")!.digest).toBe("cm1"); + + // 第二轮:provider 恢复。本地 updatetime(10s)早于云端写入时间(Date.now()), + // 「本地更新」的补偿推送不会触发,.meta.json 仍必须被补传 + store.failures.clear(); + await service.syncOnce({ ...syncConfig, syncStatus: false }, store.fs); + + expect(JSON.parse(store.files.get("u1.meta.json")!.content).uuid).toBe("u1"); + }); + + 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, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, { scriptCodeDAO: { get: vi.fn().mockResolvedValue({ code: "// v1" }) }, all: vi.fn().mockResolvedValue([ @@ -2723,35 +2804,199 @@ console.log("ok");` origin: "", downloadUrl: "", checkUpdateUrl: "", - updatetime: 20_000, + updatetime: 4000, createtime: 1, status: 1, sort: 0, metadata: {}, }, ]), + update, } as any ); - vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(cloudFs); + // .user.js digest 未变、本地时间不比云端新,旧逻辑会直接跳过并在收尾盖章 meta digest await (service as any).storage.set("file_digest", { "u1.user.js": "cu1", "u1.meta.json": "cm1" }); - // 第一轮:编辑触发 scriptInstall,.user.js 成功、.meta.json 失败 - await service.scriptInstall({ - script: { uuid: "u1", name: "t", origin: "", downloadUrl: "", checkUpdateUrl: "", updatetime: 10_000 } as any, - upsertBy: "user", - } as any); + await service.syncOnce({ ...syncConfig, syncStatus: false }, store.fs); + + 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), + }); + }); + + 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; + }; + + // 第一轮:删除 .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(); - expect(writes).toContain("u1.user.js"); - expect(cloud.get("u1.meta.json")!.digest).toBe("cm1"); + // SW 重启(新实例、共享 storage),provider 恢复:必须补写 tombstone 并清除 pending + store.failures.clear(); + const service2 = makeService(); + await service2.syncOnce({ ...syncConfig, syncStatus: false, syncDelete: true }, store.fs); - // 第二轮:meta 故障恢复,syncOnce 应补传两个文件 - failMeta = false; - writes.length = 0; - await service.syncOnce({ ...syncConfig, syncStatus: false }, cloudFs); + 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("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, + {} as any, + {} 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, + { + 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); + + // 第一轮:第一步删除 .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); + + // 第二轮:没有持久化删除意图时,「本地无脚本 + 云端有脚本」会被误判为新脚本拉回本地 + 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("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, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + await service.syncOnce({ ...syncConfig, syncStatus: false }, store.fs); - expect(writes).toContain("u1.user.js"); - expect(writes).toContain("u1.meta.json"); + expect(store.files.has("t1.meta.json")).toBe(true); + expect(store.files.has("t2.meta.json")).toBe(false); }); it("本地编辑经 syncOnce 真实上云,且推进 digest 后下一轮不再重复推送", async () => { diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index b706d6d80..515812708 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 { FileSystemError, 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"; @@ -125,7 +125,15 @@ class SyncBothChangedConflictError extends Error { } } +// 未完成同步操作的持久化登记。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"; const LAST_NOTIFIED_OVERWRITE_KEY = "last_notified_sync_overwrites"; @@ -477,6 +485,60 @@ export class SynchronizeService { 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 @@ -556,6 +618,11 @@ export class SynchronizeService { }); }; uuidMap.forEach((file, uuid) => { + if (pendingFailedUuids.has(uuid)) { + // 该 uuid 仍有未完成操作且本轮重放失败:跳过主流程决策,避免在半完成状态上误拉/误推 + scriptMap.delete(uuid); + return; + } const script = scriptMap.get(uuid); if (script) { scriptMap.delete(uuid); @@ -600,6 +667,30 @@ export class SynchronizeService { // 云端自上次同步未变:本地更新时间不比云端新(整秒对齐)则无事可做; // 否则本地编辑过(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)); @@ -653,6 +744,23 @@ export class SynchronizeService { } updateScript.set(uuid, true); 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] + ); } }); // 上传剩下的脚本 @@ -663,8 +771,10 @@ export class SynchronizeService { const syncResults = await Promise.allSettled(result.map((item) => item.promise)); const pushedFileDigestMap: FileDigestMap = {}; const preserveDigestFiles = new Set(); - const failedSyncUuids = 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); @@ -676,6 +786,9 @@ export class SynchronizeService { // 分片上传时已成功写入云端的文件(如 .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); @@ -687,6 +800,14 @@ export class SynchronizeService { }); } }); + // 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) @@ -963,6 +1084,14 @@ export class SynchronizeService { 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`; @@ -971,8 +1100,17 @@ export class SynchronizeService { 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(); @@ -988,7 +1126,7 @@ export class SynchronizeService { ); } else { // 直接删除所有相关文件 - await fs.delete(metaFilename); + await deleteIgnoreMissing(metaFilename); } logger.info("delete success"); } catch (e) { @@ -1217,6 +1355,12 @@ export class SynchronizeService { // 全部失败(无文件写入成功)则不动 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)) ); @@ -1244,10 +1388,18 @@ 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) { + 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`); @@ -1257,6 +1409,7 @@ export class SynchronizeService { }); } } + await this.setPendingSyncOps(pendingOps); await this.updateFileDigestForUuids( fs, items.map((item) => item.uuid), From 5b56309405db536c904ecb55fd501978dc76d3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 11:34:42 +0800 Subject: [PATCH 099/103] =?UTF-8?q?=F0=9F=93=9D=20cloud-sync=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=9Apending=5Fsync=5Fops=20=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E9=87=8D=E6=94=BE=E3=80=81meta=20=E8=81=94=E5=90=88=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E4=B8=8E=E5=AD=A4=E5=84=BF=20meta=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E8=AF=B4=E6=98=8E=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index 18b7002bb..b30f6df1b 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -22,6 +22,7 @@ 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)。 @@ -127,14 +128,33 @@ digest 更新有两条路径,区别在于对账范围: `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,已成功文件推进到云端最新值。 -- 非 sync 来源删除脚本后的 `scriptsDelete()`。 +- 非 sync 来源安装脚本后的 `scriptInstall()`。push 失败时按 `PushScriptPartialError` 只保留失败文件的旧 digest,已成功文件推进到云端最新值;部分成功还会登记 `pending_sync_ops` 的 push 意图(见上)。 +- 非 sync 来源删除脚本后的 `scriptsDelete()`。执行前写前登记删除意图,成功后清除。 串行队列很重要:安装、删除和定时同步都可能写同一批云端文件,如果并发执行,会扩大覆盖和 digest 污染风险。 @@ -142,6 +162,7 @@ digest 更新有两条路径,区别在于对账范围: `syncOnceInternal(syncConfig, fs)` 是主同步流程: +0. 重放 `pending_sync_ops` 中未完成的删除/push(见上),重放失败的 uuid 本轮跳过。 1. 调用 `fs.list()` 获取云端目录。 2. 按文件名把 `.user.js` 和 `.meta.json` 组装成 `uuidMap`。 3. 读取本地脚本列表,生成 `scriptMap`。 @@ -159,7 +180,7 @@ 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。 @@ -184,6 +205,12 @@ digest 更新有两条路径,区别在于对账范围: - 视为 orphan cloud script,跳过。 - 不删除、不覆盖、不清空对应远端 status。 +本地脚本不存在、云端只有 `.meta.json`: + +- tombstone:保留(删除标记须长期可见)。 +- 非 tombstone:他端分片删除的残留,清理掉——否则其他仍持有该脚本的设备会把它当「无效 meta」删除后重新上传,等于撤销删除。 +- 以 digest 变化为门:digest 与记录一致时不重复读取(tombstone 首轮读取盖章后不再产生 I/O)。 + 遍历结束后,剩余只存在于本地的脚本会 push 到云端。 ## push / pull / delete @@ -222,7 +249,7 @@ digest 更新有两条路径,区别在于对账范围: - 如果 `syncDelete` 为 true,写 tombstone meta。 - 如果 `syncDelete` 为 false,删除 `.meta.json`。 -失败会向上抛出。`scriptsDelete()` 必须逐条 catch,保证批量删除中一个 uuid 失败不影响后续 uuid。 +两步删除对 typed `notFound` 幂等:目标文件已不存在即视为达成目的(部分 provider 如 Google Drive 对缺失文件抛 notFound,重放半途失败的删除不能被它中断)。其余失败会向上抛出。`scriptsDelete()` 必须逐条 catch,保证批量删除中一个 uuid 失败不影响后续 uuid;未完成的 uuid 依赖 `pending_sync_ops` 登记由下一轮重放。 ## status 合并 @@ -418,5 +445,8 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name 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 结果宣称为真实云端验证。 From e98614db10e410532425dbf8a917d288fe19972e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 12:32:21 +0800 Subject: [PATCH 100/103] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E9=99=8D=E7=BA=A7=E4=B8=BA=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=BA=A7=EF=BC=9A=E4=B8=8D=E5=86=8D=E5=BC=B9=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=EF=BC=8C=E7=94=B1=E7=8A=B6=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E8=A1=8C=E4=B8=8E=E6=97=A5=E5=BF=97=E6=B7=B1?= =?UTF-8?q?=E9=93=BE=E6=89=BF=E8=BD=BD=EF=BC=9B=E7=8A=B6=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E6=8C=89=E5=B7=B2=E4=BF=9D=E5=AD=98=E9=85=8D=E7=BD=AE=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cloud-sync.md | 9 ++-- .../service_worker/synchronize.test.ts | 42 ++++--------------- src/app/service/service_worker/synchronize.ts | 36 ++-------------- src/locales/de-DE/settings.json | 7 ++-- src/locales/en-US/settings.json | 7 ++-- src/locales/ja-JP/settings.json | 7 ++-- src/locales/ru-RU/settings.json | 7 ++-- src/locales/tr-TR/settings.json | 7 ++-- src/locales/vi-VN/settings.json | 7 ++-- src/locales/zh-CN/settings.json | 7 ++-- src/locales/zh-TW/settings.json | 7 ++-- .../Setting/sections/SyncSection.test.tsx | 30 +++++++++++++ .../routes/Setting/sections/SyncSection.tsx | 28 ++++++++----- .../Setting/sections/syncStatus.test.ts | 21 +++++++--- .../routes/Setting/sections/syncStatus.ts | 7 ++-- 15 files changed, 108 insertions(+), 121 deletions(-) diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index b30f6df1b..c27821c02 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -380,7 +380,8 @@ type CloudSyncState = { ``` - 开始置 `syncing:true`,结束写 `counts`/`lastSyncAt`,异常写 `error`。注意:读旧值与写 `syncing` **不能** await 在 `syncOnceInternal` 之前,否则存储 I/O 会推迟内部起始,打乱测试的微任务门控。 -- 设置页「脚本同步」卡片顶部状态条(`SyncSection.tsx` + `syncStatus.ts`)读取并订阅 `chrome.storage.onChanged` 实时展示四态:正常 / 同步中 / 有覆盖或冲突(琥珀警示)/ 失败。单文件 best-effort 失败通过 `counts.failed` 显示为失败,不能回落成“同步正常”。 +- 设置页「脚本同步」卡片顶部状态条(`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` 标签) @@ -395,9 +396,11 @@ this.logger.warn("sync overwrite", { action: "overwrite", direction, uuid, name ### 通知与深链 -本轮有覆盖时聚合一条 `InfoNotification`,点击打开 `/src/options.html#/logs?query=...`。覆盖和冲突的已通知集合存入设备本地 sync storage,因此 MV3 Service Worker 重启后同一批问题也不会重复通知;集合或覆盖方向变化时重新提醒,问题消失时清空。 +本轮有**冲突**时聚合一条 `InfoNotification`(冲突脚本已暂停走,需用户处理):已通知集合(`LAST_NOTIFIED_CONFLICT_KEY`)存入设备本地 sync storage,MV3 Service Worker 重启后同一批冲突不重复通知;集合变化或冲突消失时重置。 -`?query` 载荷 `[{key,value}]` 由 Logger 页 `parseInitialQueries` 解析。只有纯覆盖状态才预过滤到 `service=synchronize` 且 `action=overwrite`;存在冲突或失败时只过滤 `service=synchronize`,避免把对应失败日志隐藏掉。覆盖通知与状态文案使用中性“同步时发生覆盖”,具体是本地覆盖云端还是云端覆盖本地以日志的 `direction` 标签为准。 +**覆盖(overwrite)不再弹桌面通知**:它是已发生、无需用户处理的信息级事件,只由 overwrite 日志与设置页状态条信息行 + 「查看日志」深链承载,避免首次同步/升级后无基线场景批量弹“N 个脚本被覆盖”的惊扰通知。 + +「查看日志」深链的 `?query` 载荷 `[{key,value}]` 由 Logger 页 `parseInitialQueries` 解析(状态条侧见 `syncStatus.ts` 的 `syncLogHref`):只有纯覆盖状态才预过滤到 `service=synchronize` 且 `action=overwrite`;存在冲突或失败时只过滤 `service=synchronize`,避免把对应失败日志隐藏掉。状态文案使用中性“发生覆盖”,具体是本地覆盖云端还是云端覆盖本地以日志的 `direction` 标签为准。 ### 边界 diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 2411ab2a0..31334bf42 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -3476,7 +3476,7 @@ console.log("ok");`; expect(overwriteLabel).toMatchObject({ action: "overwrite", direction: "push", uuid: "u1" }); }); - it("同一轮多个覆盖只发送一条聚合覆盖通知", async () => { + it("同一轮多个覆盖不再发送桌面通知(覆盖降级为信息级,只记录日志与计数)", async () => { const fs = createFs({ list: vi .fn() @@ -3509,40 +3509,14 @@ console.log("ok");`; await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - expect(notificationSpy).toHaveBeenCalledTimes(1); + expect(notificationSpy).not.toHaveBeenCalled(); + const state = await (service as any).storage.get("cloud_sync_state"); + expect(state.counts).toMatchObject({ overwrite: 2 }); }); - it("Service Worker 重启后同一批覆盖仍只通知一次", 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 scriptDAO = { - 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; - const makeService = () => - new SynchronizeService({} as any, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, scriptDAO); - const firstService = makeService(); - vi.spyOn(firstService, "pushScript").mockResolvedValue({}); - await firstService.syncOnce({ ...syncConfig, syncStatus: false }, fs); - const restartedService = makeService(); - vi.spyOn(restartedService, "pushScript").mockResolvedValue({}); - await restartedService.syncOnce({ ...syncConfig, syncStatus: false }, fs); - - expect(notificationSpy).toHaveBeenCalledTimes(1); - }); - - it("兜底覆盖写入失败时不通知不计数,下一轮真正覆盖成功才通知", async () => { - // 覆盖通知必须以"写入已发生"为前提:失败轮通知会谎报覆盖, - // 且去重键提前落库后,下一轮真覆盖发生时反而被静默 + 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 }, @@ -3577,7 +3551,7 @@ console.log("ok");`; await service.syncOnce({ ...syncConfig, syncStatus: false }, fs); - expect(notificationSpy).toHaveBeenCalledTimes(1); + expect(notificationSpy).not.toHaveBeenCalled(); state = await (service as any).storage.get("cloud_sync_state"); expect(state.counts).toMatchObject({ overwrite: 1, failed: 0 }); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 515812708..6a7a87b98 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -135,13 +135,6 @@ 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"; -const LAST_NOTIFIED_OVERWRITE_KEY = "last_notified_sync_overwrites"; - -// 「查看日志」深链的标签过滤载荷(供 Logger 页 parseInitialQueries 解析)。覆盖通知直接落到 overwrite 行。 -const SYNC_LOG_QUERY_OVERWRITE = JSON.stringify([ - { key: "service", value: "synchronize" }, - { key: "action", value: "overwrite" }, -]); function getScriptModifiedDate(script: PushScriptParam): number { return script.updatetime || script.createtime || Date.now(); @@ -825,26 +818,8 @@ export class SynchronizeService { await this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, conflictKey); } } - // 覆盖通知:无内容基线兜底覆盖可能覆盖了未知改动,聚合一轮一条;同批不重复(集合变化或消失后重置)。 - // 点击深链到已过滤 service=synchronize & action=overwrite 的日志,让用户自行确认改动。 - const overwriteKey = overwriteScripts - .map((c) => `${c.uuid}:${c.direction}`) - .sort() - .join(","); - if (overwriteScripts.length) { - const lastNotifiedOverwriteKey = ((await this.storage.get(LAST_NOTIFIED_OVERWRITE_KEY)) as string) || ""; - if (overwriteKey !== lastNotifiedOverwriteKey) { - InfoNotification( - i18n.t("settings:notification.script_sync_overwrite"), - i18n.t("settings:notification.script_sync_overwrite_desc", { - scriptNames: overwriteScripts.map((c) => c.scriptName).join(", "), - count: overwriteScripts.length, - }), - { url: `/src/options.html#/logs?query=${encodeURIComponent(SYNC_LOG_QUERY_OVERWRITE)}` } - ); - await this.storage.set(LAST_NOTIFIED_OVERWRITE_KEY, overwriteKey); - } - } + // 覆盖不再弹桌面通知:覆盖是已发生、无需用户处理的信息级事件,仅由上面的 overwrite 日志 + // 与设置页状态条信息行 + 日志深链承载(见 docs/cloud-sync.md 覆盖可见性)。 // 同步状态 if (syncConfig.syncStatus && canWriteScriptcatSync) { try { @@ -928,14 +903,9 @@ export class SynchronizeService { // 重新获取文件列表,保存文件摘要 this.logger.info("update file digest"); await this.updateFileDigest(fs, pushedFileDigestMap, preserveDigestFiles); - const notificationKeyResets: Promise[] = []; if (!conflictScripts.length) { - notificationKeyResets.push(this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, "")); - } - if (!overwriteScripts.length) { - notificationKeyResets.push(this.storage.set(LAST_NOTIFIED_OVERWRITE_KEY, "")); + await this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, ""); } - await Promise.all(notificationKeyResets); this.logger.info("sync complete"); // failedSyncUuids 含冲突(冲突走失败路径),failed 计数排除冲突以免与 conflict 重复 return { diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index e68ea7d1b..b21f77cfe 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -94,9 +94,7 @@ "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}}", - "script_sync_overwrite": "Cloud-Sync-Überschreibung", - "script_sync_overwrite_desc": "Bei der Synchronisierung wurden {{count}} Skript(e) überschrieben ({{scriptNames}}). Prüfen Sie Richtung und Details im Protokoll." + "subscribe_update_desc": "Neue Skripte: {{newScripts}}\nGelöschte Skripte: {{deletedScripts}}" }, "enable_background": { "title": "Hintergrundausführung aktivieren", @@ -135,7 +133,8 @@ "sync_never": "Noch nicht synchronisiert", "sync_state_syncing": "Synchronisiere…", "sync_state_attention": "Letzte Synchronisierung erfordert Ihre Prüfung", - "sync_state_attention_desc": "Bei der Synchronisierung wurden {{overwrite}} Skript(e) überschrieben und {{conflict}} Konflikt(e) pausiert. Prüfen Sie das Protokoll.", + "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 aa917d48a..4796dc942 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -94,9 +94,7 @@ "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}}", - "script_sync_overwrite": "Cloud sync overwrite", - "script_sync_overwrite_desc": "{{scriptNames}}: {{count}} script(s) were overwritten during sync. Open the logs to review the direction and details." + "subscribe_update_desc": "New scripts: {{newScripts}}\nDeleted scripts: {{deletedScripts}}" }, "enable_background": { "title": "Enable Background Running", @@ -135,7 +133,8 @@ "sync_never": "Not synced yet", "sync_state_syncing": "Syncing…", "sync_state_attention": "Last sync needs your review", - "sync_state_attention_desc": "{{overwrite}} script(s) overwritten during sync, {{conflict}} conflict(s) paused. Open the logs to 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 3864f992f..c599e7d47 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -94,9 +94,7 @@ "script_sync_conflict": "クラウド同期の競合", "script_sync_conflict_desc": "スクリプト {{scriptNames}} はローカルとクラウドの両方で変更されています。同期を一時停止しました。手動で解決してください", "subscribe_update": "サブスクリプション {{subscribeName}} が更新されました", - "subscribe_update_desc": "新しいスクリプト: {{newScripts}}\n削除されたスクリプト: {{deletedScripts}}", - "script_sync_overwrite": "クラウド同期の上書き通知", - "script_sync_overwrite_desc": "同期中に {{count}} 個のスクリプト({{scriptNames}})で上書きが発生しました。ログで方向と詳細を確認してください" + "subscribe_update_desc": "新しいスクリプト: {{newScripts}}\n削除されたスクリプト: {{deletedScripts}}" }, "enable_background": { "title": "バックグラウンド実行を有効にする", @@ -135,7 +133,8 @@ "sync_never": "未同期", "sync_state_syncing": "同期中…", "sync_state_attention": "前回の同期に確認が必要な変更があります", - "sync_state_attention_desc": "同期中に {{overwrite}} 個のスクリプトで上書きが発生し、{{conflict}} 個の競合が停止中です。ログを確認してください", + "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 3f01227e0..fd96cf342 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -94,9 +94,7 @@ "script_sync_conflict": "Конфликт облачной синхронизации", "script_sync_conflict_desc": "Скрипт {{scriptNames}} изменен и локально, и в облаке. Синхронизация этого скрипта приостановлена, разрешите конфликт вручную", "subscribe_update": "Подписка {{subscribeName}} была обновлена", - "subscribe_update_desc": "Новые скрипты: {{newScripts}}\nУдаленные скрипты: {{deletedScripts}}", - "script_sync_overwrite": "Перезапись при облачной синхронизации", - "script_sync_overwrite_desc": "Во время синхронизации перезаписано скриптов: {{count}} ({{scriptNames}}). Направление и подробности смотрите в журнале." + "subscribe_update_desc": "Новые скрипты: {{newScripts}}\nУдаленные скрипты: {{deletedScripts}}" }, "enable_background": { "title": "Включить фоновый режим", @@ -135,7 +133,8 @@ "sync_never": "Ещё не синхронизировано", "sync_state_syncing": "Синхронизация…", "sync_state_attention": "Последняя синхронизация требует проверки", - "sync_state_attention_desc": "Во время синхронизации перезаписано скриптов: {{overwrite}}, приостановлено конфликтов: {{conflict}}. Проверьте журнал.", + "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 6e780a848..af5648beb 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -94,9 +94,7 @@ "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}}", - "script_sync_overwrite": "Bulut eşitleme üzerine yazma", - "script_sync_overwrite_desc": "Eşitleme sırasında {{count}} betiğin üzerine yazıldı ({{scriptNames}}). Yön ve ayrıntılar için günlükleri açın." + "subscribe_update_desc": "Yeni betikler: {{newScripts}}\nSilinen betikler: {{deletedScripts}}" }, "enable_background": { "title": "Arka Planda Çalıştırmayı Etkinleştir", @@ -135,7 +133,8 @@ "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 {{overwrite}} betiğin üzerine yazıldı ve {{conflict}} çakışma duraklatıldı. Günlükleri inceleyin.", + "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 d6d477f7e..753b47c6f 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -94,9 +94,7 @@ "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}}", - "script_sync_overwrite": "Cảnh báo ghi đè đồng bộ đám mây", - "script_sync_overwrite_desc": "Có {{count}} script bị ghi đè trong khi đồng bộ ({{scriptNames}}). Mở nhật ký để xem hướng và chi tiết." + "subscribe_update_desc": "Script mới: {{newScripts}}\nScript đã xóa: {{deletedScripts}}" }, "enable_background": { "title": "Bật chạy nền", @@ -135,7 +133,8 @@ "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ó {{overwrite}} script bị ghi đè khi đồng bộ và {{conflict}} xung đột đã tạm dừng. Mở nhật ký để kiểm tra.", + "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 f092fb91d..01980b2ba 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -94,9 +94,7 @@ "script_sync_conflict": "云同步冲突", "script_sync_conflict_desc": "脚本 {{scriptNames}} 的本地与云端版本均已修改,已暂停同步该脚本,请手动处理冲突", "subscribe_update": "订阅 {{subscribeName}} 已更新", - "subscribe_update_desc": "新增脚本:{{newScripts}}\n删除脚本:{{deletedScripts}}", - "script_sync_overwrite": "云同步覆盖提醒", - "script_sync_overwrite_desc": "同步时有 {{count}} 个脚本发生覆盖({{scriptNames}}),请打开日志确认覆盖方向和详情" + "subscribe_update_desc": "新增脚本:{{newScripts}}\n删除脚本:{{deletedScripts}}" }, "enable_background": { "title": "启用后台运行", @@ -135,7 +133,8 @@ "sync_never": "尚未同步", "sync_state_syncing": "正在同步…", "sync_state_attention": "上次同步有需要确认的改动", - "sync_state_attention_desc": "同步时有 {{overwrite}} 个脚本发生覆盖、{{conflict}} 个冲突已暂停,请打开日志确认", + "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 566c8d6da..057234a98 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -94,9 +94,7 @@ "script_sync_conflict": "雲端同步衝突", "script_sync_conflict_desc": "腳本 {{scriptNames}} 的本機與雲端版本皆已修改,已暫停同步該腳本,請手動處理衝突", "subscribe_update": "訂閱 {{subscribeName}} 已更新", - "subscribe_update_desc": "新增腳本:{{newScripts}}\n刪除腳本:{{deletedScripts}}", - "script_sync_overwrite": "雲同步覆蓋提醒", - "script_sync_overwrite_desc": "同步時有 {{count}} 個腳本發生覆蓋({{scriptNames}}),請開啟日誌確認覆蓋方向與詳細資訊" + "subscribe_update_desc": "新增腳本:{{newScripts}}\n刪除腳本:{{deletedScripts}}" }, "enable_background": { "title": "啟用背景運行", @@ -135,7 +133,8 @@ "sync_never": "尚未同步", "sync_state_syncing": "正在同步…", "sync_state_attention": "上次同步有需要確認的變更", - "sync_state_attention_desc": "同步時有 {{overwrite}} 個腳本發生覆蓋、{{conflict}} 個衝突已暫停,請開啟日誌確認", + "sync_state_attention_desc": "同步時有 {{conflict}} 個腳本衝突已暫停,請開啟日誌確認", + "sync_state_overwrite_info": "上次同步有 {{overwrite}} 個腳本被覆蓋,可檢視日誌", "sync_state_error": "同步失敗,已暫停", "sync_state_failed_desc": "{{failed}} 個腳本同步失敗,請開啟日誌查看詳細資訊" } diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx index d0bc25b2f..f4d4dec68 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx @@ -190,6 +190,36 @@ describe("同步分区", () => { 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); + // 仅勾选草稿、尚未保存,不应立刻出现「同步正常」状态条 + 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 }); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index 1b60ce11e..d80a655ac 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -29,10 +29,16 @@ const VARIANT_META: Record (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) @@ -56,6 +62,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE } } systemConfig.set("cloud_sync", draft); + setSavedEnable(draft.enable); notify.success(t("save_success")); }; @@ -76,10 +83,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE switch (variant) { case "warning": title = t("settings:sync_state_attention"); - desc = t("settings:sync_state_attention_desc", { - overwrite: syncState.counts.overwrite, - conflict: syncState.counts.conflict, - }); + desc = t("settings:sync_state_attention_desc", { conflict: syncState.counts.conflict }); break; case "error": title = t("settings:sync_state_error"); @@ -90,10 +94,14 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE break; default: title = t("settings:sync_state_idle"); - desc = - syncState.lastSyncAt > 0 - ? t("settings:sync_last_at", { time: semTime(new Date(syncState.lastSyncAt)) }) - : t("settings:sync_never"); + // 覆盖降级为信息级:仅有覆盖(无冲突/失败)时状态仍是「同步正常」,附中性说明 + 日志深链 + 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 ( @@ -101,7 +109,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE {draft && (
- {draft.enable && ( + {savedEnable && (
{ expect(syncStatusVariant(base({ syncing: true, counts: { overwrite: 1 } }))).toBe("syncing"); }); - it("有覆盖或冲突为 warning", () => { + 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"); }); @@ -51,10 +56,14 @@ describe("同步状态样式判定", () => { expect(failedHref).not.toContain("overwrite"); }); - it("覆盖文案不应谎报固定方向", () => { - expect(enSettings.notification.script_sync_overwrite_desc).toBe( - "{{scriptNames}}: {{count}} script(s) were overwritten during sync. Open the logs to review the direction and details." - ); - expect(enSettings.sync_state_attention_desc).toContain("overwritten during sync"); + 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 index ecd2b81bd..5cbfd0559 100644 --- a/src/pages/options/routes/Setting/sections/syncStatus.ts +++ b/src/pages/options/routes/Setting/sections/syncStatus.ts @@ -2,13 +2,14 @@ import { type CloudSyncState } from "@App/pkg/config/config"; export type SyncStatusVariant = "idle" | "syncing" | "warning" | "error"; -// 状态优先级:失败(error) > 同步中 > 上轮文件失败 > 有覆盖/冲突 > 正常。 -// SW 起始写只清 error 不清 counts:重试进行中不能被旧 counts.failed 压成"失败已暂停" +// 状态优先级:失败(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.overwrite > 0 || state.counts.conflict > 0) return "warning"; + if (state.counts.conflict > 0) return "warning"; return "idle"; } From 9beae5a37da174fd7bb02c0790c5c4adf34a273d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 17:57:58 +0800 Subject: [PATCH 101/103] =?UTF-8?q?=F0=9F=90=9B=20=E4=BA=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=94=B6=E5=B0=BE=EF=BC=9AWebDAV=20verify=205xx=20?= =?UTF-8?q?=E8=BD=AC=20typed=20error=20=E5=8F=AF=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E3=80=81=E7=AB=8B=E5=8D=B3=E5=90=8C=E6=AD=A5=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=8C=89=E5=B7=B2=E4=BF=9D=E5=AD=98=E9=85=8D=E7=BD=AE=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=20#1504?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebDAV verify() 非 401 错误改抛 createWebDAVFileSystemError(与 list/read 一致): verify 在 RETRYABLE_TRANSIENT_OPS 内且每轮同步经 factory 必跑,此前抛普通 Error 会让瞬时 5xx 既不被 limiter 重试、又被 classifySyncError 判 fatal 而中止整轮同步 - 「立即同步」按钮 disabled 改用 savedEnable:SW cloudSyncOnce 用的是已保存配置, 勾选草稿未保存时点击会静默 return,按钮须随已保存配置禁用,避免点击无反馈 - 清理 deleteCloudScript tombstone 里 3 行注释死代码 --- packages/filesystem/webdav/webdav.test.ts | 24 ++++++++++++++----- packages/filesystem/webdav/webdav.ts | 4 +++- src/app/service/service_worker/synchronize.ts | 3 --- .../Setting/sections/SyncSection.test.tsx | 11 +++++++++ .../routes/Setting/sections/SyncSection.tsx | 2 +- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index 6106d59ac..ec4277604 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -103,22 +103,34 @@ 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); }); }); diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index 33cedada9..b3bc789f0 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -64,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); } } diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 6a7a87b98..f7882a499 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -1088,9 +1088,6 @@ export class SynchronizeService { await meta.write( JSON.stringify({ uuid: uuid, - // origin: script.origin, - // downloadUrl: script.downloadUrl, - // checkUpdateUrl: script.checkUpdateUrl, isDeleted: true, }) ); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx index f4d4dec68..fc04cbe26 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx @@ -190,6 +190,17 @@ describe("同步分区", () => { 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({}); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index d80a655ac..bcb08bee3 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -177,7 +177,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE variant="outline" size="sm" data-testid="cloud_sync_now" - disabled={!draft.enable} + disabled={!savedEnable} onClick={syncNow} > From af8164ef46ccd6ab81e2f83a8912159bb3c586f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 17 Jul 2026 18:01:57 +0800 Subject: [PATCH 102/103] =?UTF-8?q?=E2=9C=A8=20add=20cloud=20sync=20notifi?= =?UTF-8?q?cation=20deep=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/synchronize.test.ts | 5 +++++ src/app/service/service_worker/synchronize.ts | 5 ++++- .../options/layout/SettingsLayout.test.tsx | 17 +++++++++++++---- src/pages/options/layout/SettingsLayout.tsx | 7 +++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 31334bf42..0f3f76602 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -10,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(); @@ -3155,6 +3156,7 @@ console.log(1);`; })), }); const notificationSpy = vi.spyOn(chrome.notifications, "create"); + const cacheSpy = vi.spyOn(cacheInstance, "set"); const service = new SynchronizeService( {} as any, {} as any, @@ -3182,6 +3184,9 @@ console.log(1);`; 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"), + }); // 冲突文件保留旧 digest,下一轮仍能识别云端变化 await expect((service as any).storage.get("file_digest")).resolves.toMatchObject({ "u1.user.js": "cu1", diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index f7882a499..bd555d000 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -813,7 +813,10 @@ export class SynchronizeService { 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); } 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" }); From 89b6754a54501ab5dab029feab29e12c7729761c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:14:04 +0900 Subject: [PATCH 103/103] =?UTF-8?q?=F0=9F=90=9B=20wrap=20Setting/Tools=20p?= =?UTF-8?q?age=20tests=20in=20MemoryRouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SettingsLayout now calls useSearchParams() for the cloud sync deep link (af8164e), which requires a Router context the tests didn't provide. Co-Authored-By: Claude Sonnet 5 --- src/pages/options/routes/Setting/index.test.tsx | 7 ++++++- src/pages/options/routes/Tools/index.test.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) 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/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); });