Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
5b54a88
Create sync-research.md
cyfung1031 Jun 11, 2026
b4fda86
📄 docs(sync): document cloud sync correctness design
cyfung1031 Jun 11, 2026
db2d563
✅ test(sync): cover failed task digest preservation
cyfung1031 Jun 11, 2026
85c32dd
🐛 fix(sync): preserve failed task digests
cyfung1031 Jun 11, 2026
a6042cd
✅ test(sync): cover status merge before write
cyfung1031 Jun 11, 2026
7af330a
🐛 fix(sync): merge latest status before write
cyfung1031 Jun 11, 2026
2cc67e4
✅ test(sync): cover status sync best effort
cyfung1031 Jun 11, 2026
8386702
✅ test(sync): cover legacy sync status file
cyfung1031 Jun 11, 2026
0d14a0d
🐛 fix(sync): tolerate legacy sync status file
cyfung1031 Jun 11, 2026
0da9e0e
✅ test(sync): cover status write failure isolation
cyfung1031 Jun 11, 2026
5e5f39b
🐛 fix(sync): isolate status file write failures
cyfung1031 Jun 11, 2026
5d3fd07
✅ test(sync): cover corrupt status file isolation
cyfung1031 Jun 11, 2026
b6788e3
🐛 fix(sync): isolate unreadable status file
cyfung1031 Jun 11, 2026
841ab63
✅ test(fs): cover dropbox typed errors
cyfung1031 Jun 11, 2026
0322251
🐛 fix(fs): use typed dropbox errors
cyfung1031 Jun 11, 2026
caf4c3b
✅ test(fs): cover baidu errno classification
cyfung1031 Jun 11, 2026
d0a89d5
🐛 fix(fs): classify baidu errno precisely
cyfung1031 Jun 11, 2026
fa29bb3
✅ test(fs): cover raw response typed errors
cyfung1031 Jun 11, 2026
6a65acb
🐛 fix(fs): type raw provider response errors
cyfung1031 Jun 11, 2026
0713644
✅ test(fs): cover typed transient retries
cyfung1031 Jun 11, 2026
cd6f2d4
🐛 fix(fs): retry typed transient errors
cyfung1031 Jun 11, 2026
355e0a6
✅ test(sync): cover delete notification throttling
cyfung1031 Jun 11, 2026
b1ad94f
🐛 fix(sync): throttle delete notifications
cyfung1031 Jun 11, 2026
b107035
✅ test(fs): cover filesystem capabilities
cyfung1031 Jun 11, 2026
abbd80d
🐛 fix(fs): expose filesystem capabilities
cyfung1031 Jun 11, 2026
b26a2c5
✅ test(fs): cover dropbox raw read errors
cyfung1031 Jun 11, 2026
90ba062
🐛 fix(fs): type dropbox raw read errors
cyfung1031 Jun 11, 2026
aa007ac
✅ test(fs): cover s3 transient errors
cyfung1031 Jun 11, 2026
feaf735
🐛 fix(fs): type s3 transient errors
cyfung1031 Jun 11, 2026
089d8ed
✅ test(fs): cover webdav transient errors
cyfung1031 Jun 11, 2026
3f23955
🐛 fix(fs): type webdav transient errors
cyfung1031 Jun 11, 2026
dfd23f7
✅ test(fs): cover s3 conditional operations
cyfung1031 Jun 11, 2026
889d08d
🐛 fix(fs): support s3 conditional operations
cyfung1031 Jun 11, 2026
5ba7db8
✅ test(fs): cover webdav conditional operations
cyfung1031 Jun 11, 2026
8778b19
🐛 fix(fs): support webdav conditional operations
cyfung1031 Jun 11, 2026
a52998c
✅ test(fs): cover onedrive conditional operations
cyfung1031 Jun 11, 2026
41c07ed
🐛 fix(fs): support onedrive conditional operations
cyfung1031 Jun 11, 2026
a3ec5e1
✅ test(sync): cover conditional push options
cyfung1031 Jun 11, 2026
4d4355f
🐛 fix(sync): use provider conditional writes when safe
cyfung1031 Jun 11, 2026
03250cd
✅ test(sync): cover conditional cloud delete
cyfung1031 Jun 11, 2026
1aa8b4b
🐛 fix(sync): use provider conditional delete when safe
cyfung1031 Jun 11, 2026
5aad551
✅ test(fs): cover webdav create-only conflict
cyfung1031 Jun 11, 2026
e59ea1f
🐛 fix(fs): type webdav create-only conflict
cyfung1031 Jun 11, 2026
c63bfb4
✅ test(sync): cover status write after file failure
cyfung1031 Jun 11, 2026
1a2ab69
🐛 fix(sync): preserve failed script status while syncing others
cyfung1031 Jun 11, 2026
4a8380c
✅ test(fs): cover s3 conditional conflicts
cyfung1031 Jun 11, 2026
74c2ce2
docs(sync): update implemented provider status
cyfung1031 Jun 11, 2026
ce0400c
✅ test(sync): cover conflict error classification
cyfung1031 Jun 11, 2026
2b4ab24
🐛 fix(sync): classify per-file sync failures
cyfung1031 Jun 11, 2026
683f4b8
✅ test(fs): cover conditional write retry
cyfung1031 Jun 11, 2026
3eae169
🐛 fix(fs): retry protected conditional writes
cyfung1031 Jun 11, 2026
0f61332
docs(sync): reflect protected write retry
cyfung1031 Jun 11, 2026
b87a55c
✅ test(sync): cover sync error kind mapping
cyfung1031 Jun 11, 2026
41b9110
✅ test(sync): cover install push transient failure
cyfung1031 Jun 11, 2026
28c50a8
🐛 fix(sync): classify queued sync failures
cyfung1031 Jun 11, 2026
70c6a59
docs(sync): record queued failure classification audit
cyfung1031 Jun 11, 2026
0828472
docs(sync): refine remaining rollout plan
cyfung1031 Jun 12, 2026
93bad78
✅ test(sync): cover queued delete typed failures
cyfung1031 Jun 12, 2026
a8a1487
✅ test(fs): cover dropbox opaque digest
cyfung1031 Jun 12, 2026
0b3d134
docs(sync): document manual verification path
cyfung1031 Jun 12, 2026
aedc3a8
🐛 fix(fs): classify dropbox structured not found
cyfung1031 Jun 12, 2026
f96958d
✅ test(fs): cover dropbox structured conflict
cyfung1031 Jun 12, 2026
7c1bd37
docs(sync): keep rollout checklist current
cyfung1031 Jun 12, 2026
d1be557
✅ test(fs): cover non-atomic provider capabilities
cyfung1031 Jun 12, 2026
178eea1
docs(sync): record non-atomic provider coverage
cyfung1031 Jun 12, 2026
2264d9e
fix(sync/fs): address remaining provider verification gaps
cyfung1031 Jun 12, 2026
917a30c
docs(sync): record provider gap closure
cyfung1031 Jun 12, 2026
39cf65b
docs(sync): record manual verification result
cyfung1031 Jun 12, 2026
2cdf928
docs(sync): record real provider verification
cyfung1031 Jun 12, 2026
34d6c5f
fix(sync/fs): address real provider verification findings
cyfung1031 Jun 12, 2026
dec82aa
docs(sync): finalize rollout status
cyfung1031 Jun 12, 2026
be5e794
Delete sync-research.md
cyfung1031 Jun 12, 2026
eac7b8d
📄 docs(sync): document cloud sync implementation
cyfung1031 Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 374 additions & 0 deletions docs/CLOUD-SYNC.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/DOC-MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| [`DEVELOP.md`](./DEVELOP.md) | 开发规范:命令、目录结构、编码风格、UI/主题、测试机制、i18n、提交/PR 流程。**写代码前先读。** |
| [`VERIFICATION.md`](./VERIFICATION.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例)。**验证改动是否真正跑通时读。** |
| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 内部原理深入:多进程模型、消息传递、服务/数据层、GM API、脚本执行、构建管线。 |
| [`CLOUD-SYNC.md`](./CLOUD-SYNC.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 |
| [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** |

## 翻译 / Translation
Expand Down
105 changes: 104 additions & 1 deletion packages/filesystem/baidu/baidu.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
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();

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({
ok: true,
status: 200,
json: async () => ({ errno: 0 }),
});
vi.stubGlobal("fetch", fetchMock);

// 监视 updateDynamicRules,确保不再依赖全局 DNR 规则
const updateDynamicRulesMock = vi.fn();
(chrome as any).declarativeNetRequest.updateDynamicRules = updateDynamicRulesMock;
vi.stubGlobal("chrome", {
declarativeNetRequest: {
updateDynamicRules: updateDynamicRulesMock,
},
});

const fs = new BaiduFileSystem("/apps", "token");

Expand All @@ -33,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");

Expand All @@ -52,4 +105,54 @@ describe("BaiduFileSystem", () => {
`async=0&filelist=${encodeURIComponent(JSON.stringify(["/apps/ScriptCat/dir/file.user.js"]))}`
);
});

it("创建目录遇到明确已存在 errno 时才标记为冲突", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "request").mockResolvedValue({ errno: 31061, errmsg: "file already exists" });

await expect(fs.createDir("ScriptCat")).rejects.toMatchObject({
provider: "baidu",
code: "31061",
conflict: true,
});
});

it("创建目录遇到普通 errno 时不能误标记为冲突", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "request").mockResolvedValue({ errno: 2, errmsg: "access denied" });

await expect(fs.createDir("ScriptCat")).rejects.toMatchObject({
provider: "baidu",
code: "2",
conflict: false,
});
});

it("写入预创建失败时保留普通 errno 的非冲突语义", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "request").mockResolvedValue({ errno: 2, errmsg: "access denied" });
const writer = await fs.create("dir/file.user.js");

await expect(writer.write("code")).rejects.toMatchObject({
provider: "baidu",
code: "2",
conflict: false,
});
});

it("读取文件元数据缺失时应抛出 typed not found 错误", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "request").mockResolvedValue({ errno: -9, errmsg: "file not found" });
const reader = await fs.open({
fsid: 123,
name: "missing.user.js",
path: "/apps",
size: 0,
digest: "",
createtime: 0,
updatetime: 0,
});

await expect(reader.read("string")).rejects.toSatisfy(isNotFoundError);
});
});
22 changes: 16 additions & 6 deletions packages/filesystem/baidu/baidu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -52,7 +53,7 @@ export default class BaiduFileSystem implements FileSystem {
}
);
if (data.errno) {
throw new Error(JSON.stringify(data));
throw createBaiduFileSystemError(data);
}
}

Expand All @@ -62,18 +63,27 @@ 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 new Error(JSON.stringify(data2));
throw createBaiduFileSystemError(data2);
}
return data2;
});
Expand All @@ -95,7 +105,7 @@ export default class BaiduFileSystem implements FileSystem {
}
).then((data) => {
if (data.errno) {
throw new Error(JSON.stringify(data));
throw createBaiduFileSystemError(data);
}
return data;
});
Expand Down Expand Up @@ -126,7 +136,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) {
Expand Down
35 changes: 35 additions & 0 deletions packages/filesystem/baidu/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { FileSystemError } from "../error";

export type BaiduErrorResponse = {
errno?: number;
httpStatus?: number;
errmsg?: string;
error_msg?: string;
[key: string]: unknown;
};

const BAIDU_FILE_EXISTS_ERRNOS = new Set([31061]);

export function createBaiduFileSystemError(data: BaiduErrorResponse): FileSystemError {
const code = typeof data.errno === "number" ? String(data.errno) : undefined;
const status = data.httpStatus;
const message =
data.errmsg || data.error_msg || (code ? `Baidu request failed with errno ${code}` : "Baidu request failed");
const conflict = typeof data.errno === "number" && BAIDU_FILE_EXISTS_ERRNOS.has(data.errno);
const auth = data.errno === 111 || data.errno === -6 || status === 401;
const notFound = data.errno === -9 || status === 404;
const rateLimit = status === 429;

return new FileSystemError({
provider: "baidu",
message,
status,
code,
conflict,
auth,
notFound,
rateLimit,
retryable: rateLimit || (status !== undefined && status >= 500),
raw: data,
});
}
14 changes: 9 additions & 5 deletions packages/filesystem/baidu/rw.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,8 +20,11 @@ export class BaiduFileReader implements FileReader {
this.fs.accessToken
}&fsids=[${this.file.fsid!}]&dlink=1`
);
if (!data.list.length) {
throw new Error("file not found");
if (data.errno) {
throw createBaiduFileSystemError(data);
}
if (!data.list?.length) {
throw createBaiduFileSystemError({ errno: -9, errmsg: "file not found" });
}
const resp = await fetch(`${data.list[0].dlink}&access_token=${this.fs.accessToken}`);
switch (type) {
Expand Down Expand Up @@ -80,7 +84,7 @@ export class BaiduFileWriter implements FileWriter {
}
);
if (data.errno) {
throw new Error(JSON.stringify(data));
throw createBaiduFileSystemError(data);
}
const uploadid = data.uploadid;
const body = new FormData();
Expand All @@ -102,7 +106,7 @@ export class BaiduFileWriter implements FileWriter {
}
);
if (data.errno) {
throw new Error(JSON.stringify(data));
throw createBaiduFileSystemError(data);
}
// 创建文件
urlencoded = new URLSearchParams();
Expand All @@ -121,7 +125,7 @@ export class BaiduFileWriter implements FileWriter {
}
);
if (data.errno) {
throw new Error(JSON.stringify(data));
throw createBaiduFileSystemError(data);
}
}
}
Loading
Loading