-
Notifications
You must be signed in to change notification settings - Fork 387
Expand file tree
/
Copy pathsynchronize.ts
More file actions
1559 lines (1487 loc) · 64.5 KB
/
Copy pathsynchronize.ts
File metadata and controls
1559 lines (1487 loc) · 64.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import type { Resource, ResourceType } from "@App/app/repo/resource";
import {
type Script,
SCRIPT_STATUS_DISABLE,
SCRIPT_STATUS_ENABLE,
type ScriptDAO,
type ScriptCodeDAO,
} 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 FileSystem from "@Packages/filesystem/filesystem";
import ZipFileSystem from "@Packages/filesystem/zip/zip";
import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory";
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";
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 CloudSyncState,
type SystemConfig,
CLOUD_SYNC_STATE_KEY,
DEFAULT_CLOUD_SYNC_STATE,
} from "@App/pkg/config/config";
import { CLOUD_SYNC_QUEUE_KEY, type TDeleteScript, type TInstallScript, type TInstallScriptParams } from "../queue";
import { errorMsg, makeBlobURL } from "@App/pkg/utils/utils";
import { t } from "i18next";
import ChromeStorage from "@App/pkg/config/chrome_storage";
import { AgentModelRepo } from "@App/app/repo/agent_model";
import { MCPServerRepo } from "@App/app/repo/mcp_server_repo";
import { AgentTaskRepo } from "@App/app/repo/agent_task";
import { CONFIG_BUNDLE_VERSION, toBundleConfig, type ConfigBundle } from "@App/pkg/backup/config_bundle";
import { type ScriptService } from "./script";
import { prepareScriptByCode } from "@App/pkg/utils/script";
import { ExtVersion } from "@App/app/const";
import { dayFormat } from "@App/pkg/utils/day_format";
import i18n, { i18nName } from "@App/locales/locales";
import { InfoNotification } from "./utils";
import { stackAsyncTask } from "@App/pkg/utils/async_queue";
import { md5OfText } from "@App/pkg/utils/crypto";
import { startDownload } from "./download";
import type { TSortedScript } from "../queue";
// type SynchronizeTarget = "local";
type SyncFiles = {
script: FileInfo;
meta: FileInfo;
};
type SyncMeta = {
uuid: string;
origin?: string; // 脚本来源
downloadUrl?: string;
checkUpdateUrl?: string;
isDeleted?: boolean;
};
type ScriptcatSync = {
version: string; // 脚本猫版本
status: {
scripts: {
[key: string]: ScriptcatSyncStatus | undefined;
};
};
};
type ScriptcatSyncStatus = {
enable: boolean;
sort: number;
updatetime: number; // 更新时间
sortUpdatetime?: number;
};
type PendingSortStatus = { [uuid: string]: { sort: number; sortUpdatetime: number } | undefined };
type PushScriptParam = TInstallScriptParams & Partial<Pick<Script, "createtime" | "updatetime">>;
export type LocalBackupExport = {
url: string;
filename: string;
};
type FileDigestMap = {
[key: string]: string;
};
type SyncTask = {
uuid: string;
promise: Promise<FileDigestMap | void>;
preserveDigestFiles: string[];
};
type SyncErrorKind = "conflict" | "stale_snapshot" | "transient" | "fatal";
// 本地 updatetime 是客户端毫秒时钟,云端 mtime 是服务端时钟(WebDAV 等仅整秒精度)。
// 跨时钟域比较前两侧都截断到整秒:同一秒内的毫秒余数不构成"本地更新"的证据(L4 同秒竞态)
const isNewerBySecond = (localMs: number, cloudMs: number) => Math.floor(localMs / 1000) > Math.floor(cloudMs / 1000);
// pushScript 分两次写 .user.js / .meta.json,前者成功后者失败时抛出本错误,
// 带出已成功写入的文件名,让调用方只保留真正失败文件的旧 digest、推进成功文件的 digest,
// 避免已成功文件继续保留旧 digest,让下一轮只重试真正失败的文件。
class PushScriptPartialError extends Error {
constructor(
readonly originalError: unknown,
readonly writtenFiles: string[]
) {
super(originalError instanceof Error ? originalError.message : String(originalError));
this.name = "PushScriptPartialError";
}
}
// 本地与云端在上次同步后都发生了修改(真冲突):不自动覆盖任何一端,
// 本轮跳过该脚本(沿用失败路径保留旧 digest 与云端状态),并聚合通知用户手动处理
class SyncBothChangedConflictError extends Error {
constructor(
readonly uuid: string,
readonly scriptName: string
) {
super(`sync conflict: both local and cloud changed for ${uuid}`);
this.name = "SyncBothChangedConflictError";
}
}
// 未完成同步操作的持久化登记。file_digest 只是文件快照,表达不了「删除做到一半」
// 「.meta.json 还欠一次写入」这类未完成意图:部分失败后下一轮的方向判定不一定会
// 再生成重试任务(如「本地无脚本 + 云端只剩 .meta.json」不命中任何决策分支),
// 须由本记录在 syncOnce 开头驱动重放,全部步骤成功才清除。
type PendingSyncOp = { op: "delete"; syncDelete: boolean } | { op: "push" };
type PendingSyncOps = { [uuid: string]: PendingSyncOp };
const PENDING_SYNC_OPS_KEY = "pending_sync_ops";
const PENDING_SORT_STATUS_KEY = "pending_sort_status";
const LAST_NOTIFIED_CONFLICT_KEY = "last_notified_sync_conflicts";
function isEquivalentConfigValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => isEquivalentConfigValue(value, right[index]));
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every(
(key) => Object.hasOwn(rightRecord, key) && isEquivalentConfigValue(leftRecord[key], rightRecord[key])
);
}
function isCloudSyncConnectionEquivalent(left: CloudSyncConfig, right: CloudSyncConfig): boolean {
return left.filesystem === right.filesystem && isEquivalentConfigValue(left.params, right.params);
}
function isCloudSyncConfigEquivalent(left: CloudSyncConfig, right: CloudSyncConfig): boolean {
return (
left.enable === right.enable &&
left.syncDelete === right.syncDelete &&
left.syncStatus === right.syncStatus &&
isCloudSyncConnectionEquivalent(left, right)
);
}
function getScriptModifiedDate(script: PushScriptParam): number {
return script.updatetime || script.createtime || Date.now();
}
export class SynchronizeService {
logger: Logger;
scriptCodeDAO: ScriptCodeDAO;
storage: ChromeStorage = new ChromeStorage("sync", false);
private lastCloudSyncConfig?: CloudSyncConfig;
constructor(
private msgSender: MessageSend,
private group: Group,
private script: ScriptService,
private value: ValueService,
private resource: ResourceService,
private mq: IMessageQueue,
private systemConfig: SystemConfig,
private scriptDAO: ScriptDAO
) {
this.logger = LoggerCore.logger().with({ service: "synchronize" });
this.scriptCodeDAO = this.scriptDAO.scriptCodeDAO;
}
// 生成备份文件到文件系统(includeConfig=true 时附带 ScriptCat 设置 bundle,#1533)
async backup(fs: FileSystem, uuids?: string[], includeConfig = false) {
// 生成导出数据
const data: BackupData = {
script: await this.getScriptBackupData(uuids),
subscribe: [],
config: includeConfig ? await this.getConfigBundle() : undefined,
};
await new BackupExport(fs).export(data);
}
// 读取 ScriptCat 设置 bundle(SystemConfig 仅 sync 键 + agent 模型/MCP/任务)
async getConfigBundle(): Promise<ConfigBundle> {
const modelRepo = new AgentModelRepo();
const [sync, models, mcp, tasks, defaultModelId, summaryModelId] = await Promise.all([
new ChromeStorage("system", true).keys(),
modelRepo.listModels(),
new MCPServerRepo().listServers(),
new AgentTaskRepo().listTasks(),
modelRepo.getDefaultModelId(),
modelRepo.getSummaryModelId(),
]);
return {
version: CONFIG_BUNDLE_VERSION,
systemConfig: toBundleConfig(sync),
agent: { models, mcp, tasks, defaultModelId, summaryModelId },
};
}
// 还原设置 bundle:合并语义=以备份值覆盖(逐键 set/save);只写 sync storage
async restoreConfigBundle(bundle: ConfigBundle): Promise<void> {
if (!bundle) return;
const sync = new ChromeStorage("system", true);
const modelRepo = new AgentModelRepo();
const mcpRepo = new MCPServerRepo();
const taskRepo = new AgentTaskRepo();
await Promise.all([
...Object.entries(bundle.systemConfig || {}).map(([k, v]) => sync.set(k, v)),
...(bundle.agent?.models || []).map((m) => modelRepo.saveModel(m)),
...(bundle.agent?.mcp || []).map((m) => mcpRepo.saveServer(m)),
...(bundle.agent?.tasks || []).map((t) => taskRepo.importTask(t)),
]);
// 仅在备份带出模型选择时覆盖(部分还原未选"AI 模型"时保留本机当前默认/摘要模型)
if (bundle.agent?.defaultModelId) await modelRepo.setDefaultModelId(bundle.agent.defaultModelId);
if (bundle.agent?.summaryModelId) await modelRepo.setSummaryModelId(bundle.agent.summaryModelId);
}
// 获取脚本备份数据
async getScriptBackupData(uuids?: string[]) {
if (uuids) {
const rets: Promise<ScriptBackupData>[] = [];
uuids.forEach((uuid) => {
rets.push(
this.scriptDAO.get(uuid).then((script) => {
if (script) {
return this.generateScriptBackupData(script);
}
return Promise.reject(new Error(`Script ${uuid} not found`));
})
);
});
return Promise.all(rets); // 不处理 Promise.reject ?
}
// 获取所有脚本
const list = await this.scriptDAO.all();
return Promise.all(list.map((script) => this.generateScriptBackupData(script)));
}
async generateScriptBackupData(script: Script): Promise<ScriptBackupData> {
const code = await this.scriptCodeDAO.get(script.uuid);
if (!code) {
throw new Error(`Script ${script.uuid} code not found`);
}
const lastModificationDate = script.updatetime || script.createtime || undefined;
const [values, valueRet] = await this.value.getScriptValueDetails(script);
const [requires, requiresCss, resources] = await this.resource.getResourceByTypes(script, [
"require",
"require-css",
"resource",
]);
const storage: ValueStorage = {
data: { ...values },
ts: valueRet?.updatetime || lastModificationDate || Date.now(),
};
const ret = {
code: code.code,
options: {
options: this.scriptOption(script),
settings: {
enabled: script.status === SCRIPT_STATUS_ENABLE,
position: script.sort,
},
meta: {
name: script.name,
uuid: script.uuid,
sc_uuid: script.uuid,
modified: script.updatetime!,
file_url: script.downloadUrl!,
subscribe_url: script.subscribeUrl,
},
selfMeta: script.selfMetadata && Object.keys(script.selfMetadata).length > 0 ? script.selfMetadata : undefined,
},
// storage,
requires: this.resourceToBackdata(requires),
requiresCss: this.resourceToBackdata(requiresCss),
resources: this.resourceToBackdata(resources),
storage,
lastModificationDate,
} satisfies ScriptBackupData;
return ret;
}
resourceToBackdata(resource: { [key: string]: Resource }) {
const ret: ResourceBackup[] = [];
for (const key of Object.keys(resource)) {
const resourceValue = resource[key];
ret.push({
meta: {
name: this.getUrlName(resourceValue.url),
url: resourceValue.url,
ts: resourceValue.updatetime || resourceValue.createtime,
mimetype: resourceValue.contentType,
},
source: resourceValue.content || undefined,
base64: resourceValue.base64,
});
}
return ret;
}
// 导入脚本资源;返回失败的资源名列表(不因单个资源失败而整体 reject,供导入页逐项展示)
async importResources(data: {
uuid: string;
requires: ResourceBackup[];
resources: ResourceBackup[];
requiresCss: ResourceBackup[];
}): Promise<string[]> {
const { uuid, requires, resources, requiresCss } = data;
const items: Array<{ res: ResourceBackup; type: ResourceType }> = [
...requires.map((res) => ({ res, type: "require" as ResourceType })),
...resources.map((res) => ({ res, type: "resource" as ResourceType })),
...requiresCss.map((res) => ({ res, type: "require-css" as ResourceType })),
];
const settled = await Promise.allSettled(
items.map(({ res, type }) => this.resource.importResource(uuid, res, type))
);
const failed: string[] = [];
settled.forEach((r, i) => {
if (r.status === "rejected") {
const { res } = items[i];
failed.push(res.meta.name || res.meta.url);
this.logger.error("import resource failed", { uuid, url: res.meta.url }, Logger.E(r.reason));
}
});
return failed;
}
getUrlName(url: string): string {
let index = url.indexOf("?");
if (index !== -1) {
url = url.substring(0, index);
}
index = url.lastIndexOf("/");
if (index !== -1) {
url = url.substring(index + 1);
}
return url;
}
// 为了兼容tm
scriptOption(script: Script): ScriptOptions {
return {
check_for_updates: false,
comment: null,
compat_foreach: false,
compat_metadata: false,
compat_prototypes: false,
compat_wrappedjsobject: false,
compatopts_for_requires: true,
noframes: null,
override: {
merge_connects: true,
merge_excludes: true,
merge_includes: true,
merge_matches: true,
orig_connects: script.metadata.connect || [],
orig_excludes: script.metadata.exclude || [],
orig_includes: script.metadata.include || [],
orig_matches: script.metadata.match || [],
orig_noframes: script.metadata.noframes ? true : null,
orig_run_at: (script.metadata.run_at && script.metadata.run_at[0]) || "document-idle",
use_blockers: [],
use_connects: [],
use_excludes: [],
use_includes: [],
use_matches: [],
},
run_at: null,
};
}
// 请求导出文件(本地文件导出附带设置 bundle,#1533/#684)
async requestExport(uuids?: string[]): Promise<LocalBackupExport> {
const zipFile = createJSZip();
const fs = new ZipFileSystem(zipFile);
await this.backup(fs, uuids, true);
// 生成文件,并下载
const zipOutput = await zipFile.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 9,
},
comment: "Created by Scriptcat",
});
const url = await makeBlobURL({ blob: zipOutput, persistence: false }, (params) =>
createObjectURL(this.msgSender, params)
);
const filename = `scriptcat-backup-${dayFormat(new Date(), "YYYY-MM-DDTHH-mm-ss")}.zip`;
void startDownload({
url,
saveAs: true,
filename,
});
return { url, filename };
}
// 备份到云端
async backupToCloud({ type, params }: { type: FileSystemType; params: any }) {
// 首先生成zip文件
const zipFile = createJSZip();
const fs = new ZipFileSystem(zipFile);
await this.backup(fs, undefined, true);
this.logger.info("backup to cloud");
// 然后创建云端文件系统
let cloudFs = await FileSystemFactory.create(type, params);
try {
await cloudFs.createDir("ScriptCat");
cloudFs = await cloudFs.openDir("ScriptCat");
// 云端文件系统写入文件
const file = await cloudFs.create(`scriptcat-backup-${dayFormat(new Date(), "YYYY-MM-DDTHH-mm-ss")}.zip`);
await file.write(
await zipFile.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 9,
},
comment: "Created by Scriptcat",
})
);
} catch (e) {
this.logger.error("backup to cloud error", Logger.E(e));
throw e;
}
return;
}
// 开始一次云同步
async buildFileSystem(config: CloudSyncConfig) {
let fs: FileSystem;
try {
fs = await FileSystemFactory.create(config.filesystem, config.params[config.filesystem]);
// 创建base目录
await FileSystemFactory.mkdirAll(fs, "ScriptCat/sync");
fs = await fs.openDir("ScriptCat/sync");
} catch (e: any) {
this.logger.error("create filesystem error", Logger.E(e), {
type: config.filesystem,
});
// 判断错误是不是网络类型的错误, 网络类型的错误不做任何处理
// 如果是token失效之类的错误,通知用户并关闭云同步
if (isWarpTokenError(e)) {
InfoNotification(
`${t("settings:sync_system_connect_failed")}, ${t("settings:sync_system_closed")}`,
`${t("settings:sync_system_closed_description")}\n${errorMsg(e)}`
);
this.systemConfig.setCloudSync({
...config,
enable: false,
});
}
throw e;
}
return fs;
}
// 同步一次
async syncOnce(syncConfig: CloudSyncConfig, fs: FileSystem) {
return stackAsyncTask(CLOUD_SYNC_QUEUE_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 {
const counts = await this.syncOnceInternal(syncConfig, fs);
await prevStatePromise; // 保证 syncing 起始写在结束写之前
await this.storage.set(CLOUD_SYNC_STATE_KEY, { syncing: false, lastSyncAt: Date.now(), counts });
} catch (e) {
this.logger.error("sync once error", Logger.E(e));
const prevState = await prevStatePromise.catch(() => DEFAULT_CLOUD_SYNC_STATE);
await this.storage.set(CLOUD_SYNC_STATE_KEY, {
...prevState,
syncing: false,
error: e instanceof Error ? e.message : String(e),
});
}
});
}
private async syncOnceInternal(syncConfig: CloudSyncConfig, fs: FileSystem) {
this.logger.info("start sync once");
// 重放上一轮未完成的操作(半途失败的删除、欠写的 .meta.json)。
// 必须在主流程对账前先落地,否则「本地无脚本 + 云端有 .user.js」会把删到一半的脚本拉回本地
const pendingOps = await this.getPendingSyncOps();
const pendingSortStatus = await this.getPendingSortStatus();
const pendingFailedUuids = new Set<string>();
{
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<string, string>();
const digestRecord = ((await this.storage.get("file_digest")) as FileDigestMap) || {};
const replayedUuids: string[] = [];
const replayedDigests: FileDigestMap = {};
for (const uuid of pendingUuids) {
const op = pendingOps[uuid];
try {
if (op.op === "delete") {
await this.deleteCloudScript(fs, uuid, op.syncDelete);
} else {
const script = await this.scriptDAO.get(uuid);
if (!script) {
// 本地已删,云端文件交给删除事件或主流程处理
delete pendingOps[uuid];
continue;
}
const name = `${uuid}.user.js`;
const cloudDigest = cloudDigests.get(name);
if (cloudDigest === undefined || cloudDigest !== digestRecord[name]) {
// 云端已被他端改写或删除,盲目补推会覆盖对端更新:交回主流程方向判定
delete pendingOps[uuid];
continue;
}
Object.assign(replayedDigests, await this.pushScript(fs, script));
}
delete pendingOps[uuid];
replayedUuids.push(uuid);
} catch (e) {
// 重放仍失败:保留登记待下一轮,本轮主流程跳过该 uuid,防止半完成状态被误判
pendingFailedUuids.add(uuid);
this.logger.warn("replay pending sync op failed", Logger.E(e), {
uuid,
op: op.op,
errorKind: this.classifySyncError(e),
});
}
}
await this.setPendingSyncOps(pendingOps);
if (replayedUuids.length) {
await this.updateFileDigestForUuids(fs, replayedUuids, replayedDigests);
}
}
}
// 获取文件列表
const list = await fs.list();
// 根据文件名生成一个map
const uuidMap = new Map<string, Partial<SyncFiles>>();
// 储存文件摘要,用于检测文件是否有变化
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")) {
const uuid = file.name.substring(0, file.name.length - 8);
let files = uuidMap.get(uuid);
if (!files) {
files = {};
uuidMap.set(uuid, files);
}
files.script = file;
} else if (file.name.endsWith(".meta.json")) {
const uuid = file.name.substring(0, file.name.length - 10);
let files = uuidMap.get(uuid);
if (!files) {
files = {};
uuidMap.set(uuid, files);
}
files.meta = file;
}
}
// 获取脚本列表
const scriptList = await this.scriptDAO.all();
// 遍历脚本列表生成一个map
const scriptMap = new Map<string, Script>();
scriptList.forEach((script) => {
scriptMap.set(script.uuid, script);
});
// 判断文件系统是否有脚本猫同步文件
const file = list.find((file) => file.name === "scriptcat-sync.json");
const scriptcatSync = {
version: ExtVersion,
status: {
scripts: {},
},
} as ScriptcatSync;
let cloudStatus: ScriptcatSync["status"]["scripts"] = {};
let canWriteScriptcatSync = true;
if (file) {
try {
// 如果有,则读取文件内容
const cloudScriptCatSync = JSON.parse(
await fs.open(file).then((f) => f.read("string"))
) as Partial<ScriptcatSync>;
cloudStatus = cloudScriptCatSync.status?.scripts || {};
} catch (e) {
canWriteScriptcatSync = false;
this.logger.warn("read scriptcat-sync.json file failed", Logger.E(e));
}
}
// 对比脚本列表和文件列表,进行同步
const result: SyncTask[] = [];
const updateScript: Map<string, boolean> = new Map();
// 无内容基线兜底覆盖:记录本轮"可能覆盖了未知改动"的脚本,供覆盖日志与聚合通知使用
const overwriteScripts: { uuid: string; scriptName: string; direction: "pull" | "push" }[] = [];
// 记录被跳过的孤儿云端脚本(仅 .user.js 无 .meta.json)
// 避免本机回写 scriptcat-sync.json 时丢失对应 uuid 的云端 status
const skippedOrphanUuids = new Set<string>();
let hasNotifiedSyncDelete = false;
// 需要是同步操作,后续上传剩下的脚本
// 最后使用 Promise.allSettled 进行等待
const addSyncTask = (uuid: string, promise: Promise<FileDigestMap | void>, files?: string[]) => {
result.push({
uuid,
promise,
preserveDigestFiles: files || [`${uuid}.user.js`, `${uuid}.meta.json`],
});
};
uuidMap.forEach((file, uuid) => {
if (pendingFailedUuids.has(uuid)) {
// 该 uuid 仍有未完成操作且本轮重放失败:跳过主流程决策,避免在半完成状态上误拉/误推
scriptMap.delete(uuid);
return;
}
const script = scriptMap.get(uuid);
if (script) {
scriptMap.delete(uuid);
// 脚本存在但是文件不存在,则读取.meta.json内容判断是否需要删除脚本
if (!file.script) {
addSyncTask(
uuid,
(async () => {
// 读取meta文件
const meta = await fs.open(file.meta!);
const metaJson = (await meta.read("string")) as string;
const metaObj = JSON.parse(metaJson) as SyncMeta;
if (metaObj.isDeleted) {
// 删除脚本
await this.script.deleteScript(script.uuid, "sync");
if (!hasNotifiedSyncDelete) {
hasNotifiedSyncDelete = true;
InfoNotification(
i18n.t("settings:notification.script_sync_delete"),
i18n.t("settings:notification.script_sync_delete_desc", {
scriptName: i18nName(script),
})
);
}
} else {
// 否则认为是一个无效的.meta文件,进行删除,并进行同步
await fs.delete(file.meta!.name);
return await this.pushScript(fs, script);
}
})(),
[file.meta!.name, `${uuid}.user.js`]
);
return;
}
const updatetime = script.updatetime || script.createtime;
// 云端缺 .meta.json(上一轮分片上传残留):无论方向判定如何都需补传修复
if (!file.meta) {
addSyncTask(uuid, this.pushScript(fs, script));
return;
}
if (fileDigestMap[file.script!.name] === file.script!.digest) {
// 云端自上次同步未变:本地更新时间不比云端新(整秒对齐)则无事可做;
// 否则本地编辑过(digest 相等只反映云端未变),需补偿上传(#1,队列 push 失败后的兜底)
if (!isNewerBySecond(updatetime, file.script!.updatetime)) {
// .user.js 未变不代表 .meta.json 未变:他端可能只改了 meta 字段(downloadUrl 等)。
// 不检查就跳过,收尾的全量盖章会把这份未处理的 meta 标成已同步,之后永不再处理。
// 无记录(从未成功同步过该 meta)时无法判定变化,交由收尾盖章建立基线
const metaRecord = fileDigestMap[file.meta!.name];
if (metaRecord !== undefined && metaRecord !== file.meta!.digest) {
addSyncTask(
uuid,
(async () => {
const metaJson = (await fs.open(file.meta!).then((r) => r.read("string"))) as string;
const metaObj = JSON.parse(metaJson) as SyncMeta;
if (metaObj.isDeleted) {
// .user.js 仍在时的 tombstone 是他端部分推送的残留,等对端补完 meta 后再重新判定
return;
}
await this.scriptDAO.update(uuid, {
origin: metaObj.origin ?? script.origin,
downloadUrl: metaObj.downloadUrl ?? script.downloadUrl,
checkUpdateUrl: metaObj.checkUpdateUrl ?? script.checkUpdateUrl,
});
await this.recordSyncedContentMd5({ [file.meta!.name]: md5OfText(metaJson) });
})(),
[file.meta!.name]
);
}
return;
}
addSyncTask(uuid, this.pushScript(fs, script));
return;
}
// 云端自上次同步已变(或本机无记录)。本地毫秒时钟与服务端整秒 mtime 属于两个时钟域,
// 对端更新落在同一秒内时"本地时间戳更大"是误报(L4 同秒竞态),
// 方向判定优先用本地内容基线:本地内容自上次同步未变 → pull;双方都变 → 冲突,不自动覆盖任何一端
addSyncTask(
uuid,
(async () => {
const direction = await this.decideDirectionOnRemoteChange(fs, file.script!, script, syncedContentMd5Map);
// 覆盖日志/通知只在写入成功后登记:失败轮通知会谎报覆盖,
// 且去重键提前落库后,下一轮真覆盖发生时反而被静默
const recordOverwrite = (dir: "pull" | "push") => {
if (direction.action === "adopt" || !direction.unverified) return;
const scriptName = i18nName(script);
this.logger.warn("sync overwrite", { action: "overwrite", direction: dir, uuid, name: scriptName });
overwriteScripts.push({ uuid, scriptName, direction: dir });
};
if (direction.action === "pull") {
updateScript.set(uuid, true);
await this.pullScript(fs, file as SyncFiles, cloudStatus[uuid], script);
recordOverwrite("pull");
return;
}
if (direction.action === "push") {
const pushed = await this.pushScript(fs, script);
recordOverwrite("push");
return pushed;
}
if (direction.action === "adopt") {
// 两端内容一致只是基线过期:返回内容 md5 让 updateFileDigest 推进基线,不产生写操作
return direction.digestMap;
}
throw new SyncBothChangedConflictError(uuid, i18nName(script));
})()
);
return;
}
// 如果脚本不存在,但文件存在,则安装脚本
if (file.script) {
if (!file.meta) {
// .meta 文件可能尚未上传完成,跳过本次以避免误删云端脚本
this.logger.warn("skip orphan cloud script without meta", {
uuid,
file: file.script.name,
});
skippedOrphanUuids.add(uuid);
return;
}
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]
);
}
});
// 上传剩下的脚本
scriptMap.forEach((script) => {
addSyncTask(script.uuid, this.pushScript(fs, script));
});
// 忽略错误
const syncResults = await Promise.allSettled(result.map((item) => item.promise));
const pushedFileDigestMap: FileDigestMap = {};
const preserveDigestFiles = new Set<string>();
// 重放失败的 uuid 也计入失败:状态条如实显示、status 写回保留云端原值
const failedSyncUuids = new Set<string>(pendingFailedUuids);
const conflictScripts: SyncBothChangedConflictError[] = [];
const partialPushUuids: string[] = [];
syncResults.forEach((ret, index) => {
if (ret.status === "fulfilled" && ret.value) {
Object.assign(pushedFileDigestMap, ret.value);
} else if (ret.status === "rejected") {
failedSyncUuids.add(result[index].uuid);
if (ret.reason instanceof SyncBothChangedConflictError) {
conflictScripts.push(ret.reason);
}
// 分片上传时已成功写入云端的文件(如 .user.js 成功、.meta.json 失败)不保留旧 digest,
// 让 updateFileDigest 记录其云端最新 digest,下一轮只重试真正失败的文件。
const writtenFiles = ret.reason instanceof PushScriptPartialError ? ret.reason.writtenFiles : [];
if (writtenFiles.length > 0) {
partialPushUuids.push(result[index].uuid);
}
result[index].preserveDigestFiles.forEach((name) => {
if (!writtenFiles.includes(name)) {
preserveDigestFiles.add(name);
}
});
this.logger.warn("sync task failed", Logger.E(ret.reason), {
errorKind: this.classifySyncError(ret.reason),
files: result[index].preserveDigestFiles,
});
}
});
// push 部分成功的 uuid:.user.js digest 会被推进,下一轮方向判定不会再生成 .meta.json
// 的重试任务,登记 pending push 由下一轮 syncOnce 开头重放
if (partialPushUuids.length) {
for (const uuid of partialPushUuids) {
pendingOps[uuid] = { op: "push" };
}
await this.setPendingSyncOps(pendingOps);
}
// 冲突通知:一轮只发一条;同一批脚本持续冲突时不重复轰炸,集合变化或冲突消失后重置
const conflictKey = conflictScripts
.map((c) => c.uuid)
.sort()
.join(",");
if (conflictScripts.length) {
const lastNotifiedConflictKey = ((await this.storage.get(LAST_NOTIFIED_CONFLICT_KEY)) as string) || "";
if (conflictKey !== lastNotifiedConflictKey) {
InfoNotification(
i18n.t("settings:notification.script_sync_conflict"),
i18n.t("settings:notification.script_sync_conflict_desc", {
scriptNames: conflictScripts.map((c) => c.scriptName).join(", "),
}),
{
url: chrome.runtime.getURL("/src/options.html#/settings?section=sync"),
}
);
await this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, conflictKey);
}
}
// 覆盖不再弹桌面通知:覆盖是已发生、无需用户处理的信息级事件,仅由上面的 overwrite 日志
// 与设置页状态条信息行 + 日志深链承载(见 docs/cloud-sync.md 覆盖可见性)。
// 同步状态
if (syncConfig.syncStatus && canWriteScriptcatSync) {
try {
const scriptlist = await this.scriptDAO.all();
const activeScriptUuids = new Set(scriptlist.map((script) => script.uuid));
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;
if (!status) {
scriptcatSync.status.scripts[script.uuid] = {
enable: script.status === SCRIPT_STATUS_ENABLE,
sort: script.sort,
updatetime: updatetime,
...(pendingSortStatus[script.uuid]
? { sortUpdatetime: pendingSortStatus[script.uuid]!.sortUpdatetime }
: {}),
};
} else {
if (updateScript.has(script.uuid)) {
// 脚本已经更新过了,跳过状态同步
scriptcatSync.status.scripts[script.uuid] = status;
return;
}
const localEnableWins = status.updatetime < updatetime;
const pendingSort = pendingSortStatus[script.uuid];
const cloudSortUpdatetime = status.sortUpdatetime ?? status.updatetime;
const localSortWins = pendingSort
? pendingSort.sortUpdatetime >= cloudSortUpdatetime
: status.sortUpdatetime === undefined && status.updatetime < updatetime;
const nextStatus: ScriptcatSyncStatus = {
enable: localEnableWins ? script.status === SCRIPT_STATUS_ENABLE : status.enable,
sort: localSortWins ? script.sort : status.sort,
updatetime: localEnableWins ? updatetime : status.updatetime,
...(localSortWins && pendingSort
? { sortUpdatetime: pendingSort.sortUpdatetime }
: status.sortUpdatetime !== undefined
? { sortUpdatetime: status.sortUpdatetime }
: {}),
};
scriptcatSync.status.scripts[script.uuid] = nextStatus;
if (!localSortWins && status.sort !== script.sort) {
await this.scriptDAO.update(script.uuid, {
sort: status.sort,
});
}
if (!localEnableWins && 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;
}
});
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));
const remainingPendingSortStatus: PendingSortStatus = {};
for (const [uuid, pending] of Object.entries(pendingSortStatus)) {
if (!pending) continue;
if (!activeScriptUuids.has(uuid)) continue;
const writtenStatus = scriptcatSync.status.scripts[uuid];
const writtenSortTime = writtenStatus?.sortUpdatetime ?? writtenStatus?.updatetime ?? 0;
const sortWasWritten =
writtenSortTime > pending.sortUpdatetime ||
(writtenSortTime === pending.sortUpdatetime && writtenStatus?.sort === pending.sort);
if (!sortWasWritten) {
remainingPendingSortStatus[uuid] = pending;
}
}
await this.setPendingSortStatus(remainingPendingSortStatus);
this.logger.info("sync scriptcat-sync.json file success");
} catch (e) {
this.logger.warn("sync scriptcat-sync.json file failed", Logger.E(e));
}
} else if (syncConfig.syncStatus && !canWriteScriptcatSync) {
this.logger.warn("skip scriptcat-sync.json write because cloud status could not be read");
}
// 重新获取文件列表,保存文件摘要
this.logger.info("update file digest");
await this.updateFileDigest(fs, pushedFileDigestMap, preserveDigestFiles);
if (!conflictScripts.length) {
await this.storage.set(LAST_NOTIFIED_CONFLICT_KEY, "");
}
this.logger.info("sync complete");
// failedSyncUuids 含冲突(冲突走失败路径),failed 计数排除冲突以免与 conflict 重复
return {
total: scriptList.length,
overwrite: overwriteScripts.length,
conflict: conflictScripts.length,
failed: Math.max(0, failedSyncUuids.size - conflictScripts.length),
};
}
private classifySyncError(error: unknown): SyncErrorKind {
if (error instanceof PushScriptPartialError) {
return this.classifySyncError(error.originalError);
}
if (error instanceof SyncBothChangedConflictError) {
return "conflict";
}
if (error instanceof FileSystemError) {
if (error.conflict) {
return "conflict";
}
if (error.rateLimit || error.retryable) {
return "transient";
}
if (error.notFound) {
return "stale_snapshot";
}
// auth 等其余 typed 错误都属于 fatal
return "fatal";
}
// WarpTokenError 及其他未分类错误一律 fatal