Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 2 additions & 12 deletions src/app/service/service_worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { onTabRemoved, onUrlNavigated, setOnUserActionDomainChanged } from "./ur
import { LocalStorageDAO } from "@App/app/repo/localStorage";
import { FaviconDAO } from "@App/app/repo/favicon";
import { onRegularUpdateCheckAlarm } from "./regular_updatecheck";
import { cacheInstance } from "@App/app/cache";
import { InfoNotification, shouldAutoOpenChangelog } from "./utils";
import { AgentService } from "@App/app/service/agent/service_worker/agent";
import { extensionEnv, getExtensionUserAgentData } from "../extension/extension_env";
Expand Down Expand Up @@ -251,8 +250,8 @@ export default class ServiceWorkerManager {
});

// 云同步
systemConfig.watch("cloud_sync", (value) => {
synchronize.cloudSyncConfigChange(value);
systemConfig.watch("cloud_sync", (value, previous) => {
synchronize.cloudSyncConfigChange(value, previous);
});

// 定期清理过期的临时安装信息
Expand Down Expand Up @@ -295,15 +294,6 @@ export default class ServiceWorkerManager {
}
});

// 一些只需启动时运行一次的任务
cacheInstance.getOrSet("extension_initialized", () => {
// 启动一次云同步
systemConfig.getCloudSync().then((config) => {
synchronize.cloudSyncConfigChange(config);
});
return true;
});

if (process.env.NODE_ENV === "production") {
chrome.runtime.onInstalled.addListener((details) => {
const lastError = chrome.runtime.lastError;
Expand Down
127 changes: 126 additions & 1 deletion src/app/service/service_worker/synchronize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ 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 type { CloudSyncConfig, SystemConfig } from "@App/pkg/config/config";
import type { ScriptDAO } from "@App/app/repo/scripts";
import { stackAsyncTask } from "@App/pkg/utils/async_queue";
import { md5OfText } from "@App/pkg/utils/crypto";
import FileSystemFactory from "@Packages/filesystem/factory";
Expand Down Expand Up @@ -2454,6 +2455,130 @@ console.log("ok");`
expect(buildSpy).not.toHaveBeenCalled();
});

describe("云同步配置变更调度", () => {
const alarmGet = vi.fn((_name: string | undefined, callback: (alarm?: chrome.alarms.Alarm) => void) => callback());
const alarmCreate = vi.fn(
(_name: string | undefined, _info: chrome.alarms.AlarmCreateInfo, callback?: () => void) => callback?.()
);
const alarmClear = vi.fn((_name: string | undefined, callback?: (wasCleared: boolean) => void) => callback?.(true));

const createService = () => {
const setCloudSync = vi.fn();
const systemConfig = Object.assign(Object.create(null) as SystemConfig, { setCloudSync });
const scriptDAO = Object.assign(Object.create(null) as ScriptDAO, {
scriptCodeDAO: Object.create(null) as ScriptDAO["scriptCodeDAO"],
all: vi.fn().mockResolvedValue([]),
});
const service = new SynchronizeService(null!, null!, null!, null!, null!, null!, systemConfig, scriptDAO);
const fs = createFs();
const buildFileSystem = vi.spyOn(service, "buildFileSystem").mockResolvedValue(fs);
const syncOnce = vi.spyOn(service, "syncOnce").mockResolvedValue(undefined);
return { service, setCloudSync, fs, buildFileSystem, syncOnce };
};

beforeEach(() => {
chrome.alarms = Object.assign(Object.create(null) as typeof chrome.alarms, {
get: alarmGet,
create: alarmCreate,
clear: alarmClear,
});
});

it("启动时配置已启用会同步一次并确保小时闹钟存在", async () => {
const { service, buildFileSystem, syncOnce } = createService();

service.cloudSyncConfigChange(syncConfig, undefined);
await flushMicrotasks();

expect(buildFileSystem).toHaveBeenCalledTimes(1);
expect(syncOnce).toHaveBeenCalledTimes(1);
expect(alarmGet).toHaveBeenCalledWith("cloudSync", expect.any(Function));
expect(alarmCreate).toHaveBeenCalledWith("cloudSync", { periodInMinutes: 60 }, expect.any(Function));
});

it("从关闭到启用会同步一次并确保小时闹钟存在", async () => {
const { service, buildFileSystem, syncOnce } = createService();
const disabled = { ...syncConfig, enable: false };

service.cloudSyncConfigChange(syncConfig, disabled);
await flushMicrotasks();

expect(buildFileSystem).toHaveBeenCalledTimes(1);
expect(syncOnce).toHaveBeenCalledTimes(1);
expect(alarmGet).toHaveBeenCalledTimes(1);
});

it("从启用到关闭只清除小时闹钟", async () => {
const { service, buildFileSystem, syncOnce } = createService();

service.cloudSyncConfigChange({ ...syncConfig, enable: false }, syncConfig);
await flushMicrotasks();

expect(alarmClear).toHaveBeenCalledWith("cloudSync");
expect(buildFileSystem).not.toHaveBeenCalled();
expect(syncOnce).not.toHaveBeenCalled();
});

it("启用时只修改同步策略不会立即执行全量同步", async () => {
const { service, buildFileSystem, syncOnce } = createService();

service.cloudSyncConfigChange({ ...syncConfig, syncDelete: !syncConfig.syncDelete }, syncConfig);
await flushMicrotasks();

expect(buildFileSystem).not.toHaveBeenCalled();
expect(syncOnce).not.toHaveBeenCalled();
expect(alarmGet).not.toHaveBeenCalled();
expect(alarmClear).not.toHaveBeenCalled();
});

it("等价配置的重复写入不会触发任何动作", async () => {
const { service, buildFileSystem, syncOnce } = createService();
const previous = {
...syncConfig,
params: { webdav: { username: "cat", url: "https://dav.example.com" } },
};
const equivalent = {
...syncConfig,
params: { webdav: { url: "https://dav.example.com", username: "cat" } },
};

service.cloudSyncConfigChange(equivalent, previous);
await flushMicrotasks();

expect(buildFileSystem).not.toHaveBeenCalled();
expect(syncOnce).not.toHaveBeenCalled();
expect(alarmGet).not.toHaveBeenCalled();
expect(alarmClear).not.toHaveBeenCalled();
});

it("外部写入未提供旧值的启用连接变更也会被防御性暂停且不触发同步", async () => {
const { service, setCloudSync, buildFileSystem, syncOnce } = createService();
const previous = {
...syncConfig,
params: { webdav: { url: "https://old.example.com" } },
};
const changed = {
...syncConfig,
params: { webdav: { url: "https://new.example.com" } },
};

service.cloudSyncConfigChange(previous, undefined);
await flushMicrotasks();
buildFileSystem.mockClear();
syncOnce.mockClear();
alarmGet.mockClear();
alarmCreate.mockClear();

service.cloudSyncConfigChange(changed, undefined);
await flushMicrotasks();

expect(setCloudSync).toHaveBeenCalledWith({ ...changed, enable: false });
expect(alarmClear).toHaveBeenCalledWith("cloudSync");
expect(buildFileSystem).not.toHaveBeenCalled();
expect(syncOnce).not.toHaveBeenCalled();
});
});

it("cloudSyncConfigChange swallows buildFileSystem error", async () => {
const service = new SynchronizeService(
{} as any,
Expand Down
128 changes: 94 additions & 34 deletions src/app/service/service_worker/synchronize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,36 @@ const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue";
const PENDING_SYNC_OPS_KEY = "pending_sync_ops";
const LAST_NOTIFIED_CONFLICT_KEY = "last_notified_sync_conflicts";

function 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();
}
Expand All @@ -147,6 +177,8 @@ export class SynchronizeService {

storage: ChromeStorage = new ChromeStorage("sync", false);

private lastCloudSyncConfig?: CloudSyncConfig;

constructor(
private msgSender: MessageSend,
private group: Group,
Expand Down Expand Up @@ -1266,43 +1298,71 @@ export class SynchronizeService {
}
}

cloudSyncConfigChange(value: CloudSyncConfig) {
if (value.enable) {
// 开启云同步同步
this.buildFileSystem(value)
.then(async (fs) => {
await this.syncOnce(value, fs);
// 开启定时器, 一小时一次
chrome.alarms.get("cloudSync", (alarm) => {
private ensureCloudSyncAlarm() {
chrome.alarms.get("cloudSync", (alarm) => {
const lastError = chrome.runtime.lastError;
if (lastError) {
console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError);
}
if (!alarm) {
chrome.alarms.create(
"cloudSync",
{
periodInMinutes: 60,
},
() => {
const lastError = chrome.runtime.lastError;
if (lastError) {
console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError);
// 非预期的异常API错误,停止处理
}
if (!alarm) {
chrome.alarms.create(
"cloudSync",
{
periodInMinutes: 60,
},
() => {
const lastError = chrome.runtime.lastError;
if (lastError) {
console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError);
// Starting in Chrome 117, the number of active alarms is limited to 500. Once this limit is reached, chrome.alarms.create() will fail.
console.error("Chrome alarm is unable to create. Please check whether limit is reached.");
}
}
);
console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError);
// Starting in Chrome 117, the number of active alarms is limited to 500. Once this limit is reached, chrome.alarms.create() will fail.
console.error("Chrome alarm is unable to create. Please check whether limit is reached.");
}
});
})
.catch((e) => {
this.logger.error("cloud sync config change error", Logger.E(e));
});
} else {
// 停止计时器
chrome.alarms.clear("cloudSync");
}
);
}
});
}

private clearCloudSyncAlarm() {
chrome.alarms.clear("cloudSync");
}

private startCloudSync(value: CloudSyncConfig) {
this.ensureCloudSyncAlarm();
this.buildFileSystem(value)
.then((fs) => this.syncOnce(value, fs))
.catch((e) => {
this.logger.error("cloud sync config change error", Logger.E(e));
});
}

cloudSyncConfigChange(value: CloudSyncConfig, previous?: CloudSyncConfig) {
const knownPrevious = this.lastCloudSyncConfig || previous;
this.lastCloudSyncConfig = value;
if (knownPrevious && isCloudSyncConfigEquivalent(value, knownPrevious)) return;

// 启动时首次处理配置:按当前值恢复运行状态,避免 SW 重启后漏掉同步或小时闹钟。
if (!knownPrevious) {
if (value.enable) {
this.startCloudSync(value);
} else {
this.clearCloudSyncAlarm();
}
return;
}

const connectionChanged = !isCloudSyncConnectionEquivalent(value, knownPrevious);
if (value.enable && connectionChanged) {
// 防御非设置页写入:连接变化不得在启用状态下生效,否则连续凭据写入会触发同步风暴。
this.clearCloudSyncAlarm();
this.systemConfig.setCloudSync({ ...value, enable: false });
return;
}

if (!knownPrevious.enable && value.enable) {
this.startCloudSync(value);
} else if (knownPrevious.enable && !value.enable) {
this.clearCloudSyncAlarm();
}
}

Expand Down
1 change: 1 addition & 0 deletions src/locales/de-DE/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "Lokal abrufen",
"cloud_sync_account_verification": "Cloud-Sync-Kontoinformationen werden überprüft...",
"cloud_sync_verification_failed": "Cloud-Sync-Kontoinformationen-Überprüfung fehlgeschlagen",
"cloud_sync_connection_changed": "Die Synchronisationsverbindung wurde geändert. Die Synchronisierung wurde pausiert. Prüfen Sie die Konfiguration und aktivieren Sie die Synchronisierung erneut.",
"trash_retention": "Papierkorb-Aufbewahrung",
"trash_retention_desc": "Wie lange gelöschte Skripte im Papierkorb aufbewahrt werden, bevor sie automatisch endgültig gelöscht werden",
"trash_retention_7": "7 Tage",
Expand Down
1 change: 1 addition & 0 deletions src/locales/en-US/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "Local Fetch",
"cloud_sync_account_verification": "Cloud Sync Account Verification in Progress...",
"cloud_sync_verification_failed": "Cloud Sync Account Verification Failed",
"cloud_sync_connection_changed": "The sync connection configuration changed, so sync has been paused. Review the configuration and enable sync again.",
"trash_retention": "Trash Retention",
"trash_retention_desc": "How long deleted scripts stay in Trash before being automatically deleted permanently",
"trash_retention_7": "7 Days",
Expand Down
1 change: 1 addition & 0 deletions src/locales/ja-JP/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "ローカル取得",
"cloud_sync_account_verification": "クラウド同期アカウント情報を確認中...",
"cloud_sync_verification_failed": "クラウド同期アカウント情報の確認に失敗しました",
"cloud_sync_connection_changed": "同期接続の設定が変更されたため、同期を一時停止しました。設定を確認してから、もう一度有効にしてください。",
"trash_retention": "ゴミ箱の保存期間",
"trash_retention_desc": "削除したスクリプトをゴミ箱でどのくらいの期間保持してから自動的に完全に削除するか",
"trash_retention_7": "7日",
Expand Down
1 change: 1 addition & 0 deletions src/locales/pt-BR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "Busca local",
"cloud_sync_account_verification": "Verificação da conta de sincronização na nuvem em andamento...",
"cloud_sync_verification_failed": "Falha na verificação da conta de sincronização na nuvem",
"cloud_sync_connection_changed": "A configuração da conexão de sincronização foi alterada, então a sincronização foi pausada. Revise a configuração e ative a sincronização novamente.",
"trash_retention": "Período de retenção da lixeira",
"trash_retention_desc": "Por quanto tempo os scripts excluídos permanecem na lixeira antes de serem excluídos permanentemente de forma automática",
"trash_retention_7": "7 dias",
Expand Down
1 change: 1 addition & 0 deletions src/locales/ru-RU/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "Локальное получение",
"cloud_sync_account_verification": "Проверка учетной записи облачной синхронизации...",
"cloud_sync_verification_failed": "Ошибка проверки учетной записи облачной синхронизации",
"cloud_sync_connection_changed": "Конфигурация подключения для синхронизации изменена, поэтому синхронизация приостановлена. Проверьте настройки и снова включите синхронизацию.",
"trash_retention": "Хранение в корзине",
"trash_retention_desc": "Сколько времени удалённые скрипты хранятся в корзине, прежде чем будут автоматически удалены окончательно",
"trash_retention_7": "7 дней",
Expand Down
1 change: 1 addition & 0 deletions src/locales/tr-TR/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "Yerel Getirme",
"cloud_sync_account_verification": "Bulut eşitleme hesabı doğrulaması devam ediyor...",
"cloud_sync_verification_failed": "Bulut eşitleme hesabı doğrulaması başarısız",
"cloud_sync_connection_changed": "Eşitleme bağlantısı yapılandırması değiştiği için eşitleme duraklatıldı. Yapılandırmayı kontrol edip eşitlemeyi yeniden etkinleştirin.",
"trash_retention": "Çöp Kutusu Saklama Süresi",
"trash_retention_desc": "Silinen betiklerin otomatik olarak kalıcı olarak silinmeden önce çöp kutusunda ne kadar süre saklanacağı",
"trash_retention_7": "7 Gün",
Expand Down
1 change: 1 addition & 0 deletions src/locales/vi-VN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "Lấy cục bộ",
"cloud_sync_account_verification": "Đang xác minh tài khoản đồng bộ đám mây...",
"cloud_sync_verification_failed": "Xác minh tài khoản đồng bộ đám mây thất bại",
"cloud_sync_connection_changed": "Cấu hình kết nối đồng bộ đã thay đổi nên quá trình đồng bộ đã tạm dừng. Hãy kiểm tra cấu hình rồi bật lại đồng bộ.",
"trash_retention": "Thời gian lưu trong thùng rác",
"trash_retention_desc": "Script đã xóa được lưu trong thùng rác bao lâu trước khi tự động bị xóa vĩnh viễn",
"trash_retention_7": "7 ngày",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "本地获取",
"cloud_sync_account_verification": "云同步账号信息验证中...",
"cloud_sync_verification_failed": "云同步账号信息验证失败",
"cloud_sync_connection_changed": "同步连接配置已更改,已暂停同步。请确认配置后重新启用。",
"trash_retention": "回收站保留时间",
"trash_retention_desc": "删除的脚本在回收站中保留多久后自动彻底删除",
"trash_retention_7": "7天",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"favicon_service_local": "本地取得",
"cloud_sync_account_verification": "雲端同步帳號資訊驗證中...",
"cloud_sync_verification_failed": "雲端同步帳號資訊驗證失敗",
"cloud_sync_connection_changed": "同步連線設定已變更,已暫停同步。請確認設定後重新啟用。",
"trash_retention": "回收筒保留時間",
"trash_retention_desc": "刪除的腳本在回收筒中保留多久後自動永久刪除",
"trash_retention_7": "7天",
Expand Down
Loading
Loading