diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 4db971b19..5d805c3f0 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -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"; @@ -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); }); // 定期清理过期的临时安装信息 @@ -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; diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 0f3f76602..bf1c9569c 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -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"; @@ -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, diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index bd555d000..120432bf3 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -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; + const rightRecord = right as Record; + 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(); } @@ -147,6 +177,8 @@ export class SynchronizeService { storage: ChromeStorage = new ChromeStorage("sync", false); + private lastCloudSyncConfig?: CloudSyncConfig; + constructor( private msgSender: MessageSend, private group: Group, @@ -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(); } } diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index b21f77cfe..a498bf4ce 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -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", diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index 4796dc942..7e510444f 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -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", diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index c599e7d47..fdeb7fd99 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -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日", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index f1d30d1ef..adae10a60 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -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", diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index fd96cf342..fada9333c 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -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 дней", diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index af5648beb..31d16384b 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -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", diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index 753b47c6f..e39eddd5f 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -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", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 01980b2ba..dba0e851b 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -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天", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 057234a98..44deb3004 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -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天", diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx index fc04cbe26..e267a7f71 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; +import type { CloudSyncConfig } from "@App/pkg/config/config"; const { t } = vi.hoisted(() => ({ t: vi.fn((key: string, opts?: Record) => @@ -34,9 +35,39 @@ vi.mock("@Packages/filesystem/auth", () => ({ ClearNetDiskToken: vi.fn(() => Promise.resolve()), })); -const { get, set } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); +const { get, set, externalStore, updateCloudSyncSnapshot, resetCloudSyncStore } = vi.hoisted(() => { + let snapshot: CloudSyncConfig | undefined; + const listeners = new Set<() => void>(); + const get = vi.fn((key: string) => Promise.resolve(key === "cloud_sync" ? snapshot : "")); + const set = vi.fn((key: string, value: CloudSyncConfig) => { + if (key !== "cloud_sync") return; + snapshot = value; + listeners.forEach((listener) => listener()); + }); + const store = { + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getSnapshot: () => snapshot, + set: (value: CloudSyncConfig) => set("cloud_sync", value), + }; + return { + get, + set, + externalStore: vi.fn(() => store), + updateCloudSyncSnapshot: (value: CloudSyncConfig) => { + snapshot = value; + listeners.forEach((listener) => listener()); + }, + resetCloudSyncStore: () => { + snapshot = undefined; + listeners.clear(); + }, + }; +}); vi.mock("@App/pages/store/global", () => ({ - systemConfig: { get, set }, + systemConfig: { get, set, externalStore }, subscribeMessage: () => () => {}, })); @@ -68,21 +99,27 @@ function mockState(over: Record = {}) { }); } -function mockCloudSync(over: Record = {}) { - get.mockImplementation((key: string) => { - if (key === "cloud_sync") - return Promise.resolve({ - enable: false, - syncDelete: false, - syncStatus: true, - filesystem: "webdav", - params: {}, - ...over, - }); - return Promise.resolve(""); +function mockCloudSync(over: Partial = {}) { + updateCloudSyncSnapshot({ + enable: false, + syncDelete: false, + syncStatus: true, + filesystem: "webdav", + params: {}, + ...over, }); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + beforeEach(() => { // 默认:状态读取返回空闲态,订阅返回空清理函数,避免未显式 mock 的用例在 effect 中崩溃 mockState(); @@ -92,12 +129,15 @@ beforeEach(() => { notify.info.mockClear(); notify.success.mockClear(); notify.error.mockClear(); + notify.warning.mockClear(); }); afterEach(() => { cleanup(); - get.mockReset(); - set.mockReset(); + get.mockClear(); + set.mockClear(); + externalStore.mockClear(); + resetCloudSyncStore(); create.mockReset(); create.mockResolvedValue({}); fetchCloudSyncState.mockReset(); @@ -106,50 +146,231 @@ afterEach(() => { }); describe("同步分区", () => { - it("未启用同步时保存直接写入配置且不做账号校验", async () => { - mockCloudSync({ enable: false }); + it("启用时切换同步删除会立即写入并保留启用状态", async () => { + mockCloudSync({ enable: true, syncDelete: false }); render( () => {}} />); - const save = await screen.findByTestId("cloud_sync_save"); - await act(async () => fireEvent.click(save)); - expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: false })); - expect(create).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByTestId("cloud_sync_sync_delete")); + expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: true, syncDelete: true })); + expect(notify.info).not.toHaveBeenCalledWith("settings:cloud_sync_connection_changed"); + }); + + it("启用时切换同步状态会立即写入并保留启用状态", async () => { + mockCloudSync({ enable: true, syncStatus: true }); + render( () => {}} />); + fireEvent.click(await screen.findByTestId("cloud_sync_sync_status")); + expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: true, syncStatus: false })); + expect(notify.info).not.toHaveBeenCalledWith("settings:cloud_sync_connection_changed"); + }); + + it("启用时编辑 WebDAV 参数会在一次写入中保存新参数并暂停同步", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "" } } }); + render( () => {}} />); + fireEvent.change(await screen.findByLabelText("url"), { target: { value: "https://dav.example.com" } }); + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith( + "cloud_sync", + expect.objectContaining({ + enable: false, + params: { webdav: { url: "https://dav.example.com" } }, + }) + ); + expect(notify.info).toHaveBeenCalledTimes(1); + expect(notify.info).toHaveBeenCalledWith("settings:cloud_sync_connection_changed"); + expect(screen.getByTestId("cloud_sync_enable")).toHaveAttribute("data-state", "unchecked"); + }); + + it("启用时切换文件系统会在一次写入中保存新类型并暂停同步", async () => { + mockCloudSync({ enable: true }); + render( () => {}} />); + fireEvent.click(await screen.findByTestId("filesystem_type")); + fireEvent.click(await screen.findByText("Amazon S3")); + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: false, filesystem: "s3" })); + expect(notify.info).toHaveBeenCalledTimes(1); + expect(notify.info).toHaveBeenCalledWith("settings:cloud_sync_connection_changed"); + }); + + it("暂停后继续编辑连接参数会正常自动保存且不重复提示", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "" } } }); + render( () => {}} />); + const url = await screen.findByLabelText("url"); + + fireEvent.change(url, { target: { value: "https://d" } }); + fireEvent.change(url, { target: { value: "https://dav.example.com" } }); + + expect(set).toHaveBeenCalledTimes(2); + expect(set).toHaveBeenLastCalledWith( + "cloud_sync", + expect.objectContaining({ + enable: false, + params: { webdav: { url: "https://dav.example.com" } }, + }) + ); + expect(notify.info).toHaveBeenCalledTimes(1); + expect(notify.info).toHaveBeenCalledWith("settings:cloud_sync_connection_changed"); }); - it("启用同步时保存先校验账号再写入配置", async () => { - mockCloudSync({ enable: true, filesystem: "webdav", params: { webdav: { url: "https://dav" } } }); + it("连接配置自动暂停后必须重新校验才能启用", async () => { + const verification = deferred(); + create.mockReturnValue(verification.promise); + mockCloudSync({ enable: true, params: { webdav: { url: "https://old.example.com" } } }); render( () => {}} />); - const save = await screen.findByTestId("cloud_sync_save"); - await act(async () => fireEvent.click(save)); + + fireEvent.change(await screen.findByLabelText("url"), { target: { value: "https://new.example.com" } }); + set.mockClear(); + fireEvent.click(screen.getByTestId("cloud_sync_enable")); + + expect(create).toHaveBeenCalledWith("webdav", { url: "https://new.example.com" }); + expect(set).not.toHaveBeenCalled(); + await act(async () => verification.resolve({})); + expect(set).toHaveBeenCalledWith( + "cloud_sync", + expect.objectContaining({ + enable: true, + params: { webdav: { url: "https://new.example.com" } }, + }) + ); + }); + + it("开启同步时显示校验状态且校验成功后才保存启用", async () => { + const verification = deferred(); + create.mockReturnValue(verification.promise); + mockCloudSync({ params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const enable = await screen.findByTestId("cloud_sync_enable"); + + fireEvent.click(enable); + expect(create).toHaveBeenCalledWith("webdav", { url: "https://dav" }); + expect(set).not.toHaveBeenCalled(); + expect(enable).toHaveAttribute("data-state", "unchecked"); + expect(enable).toBeDisabled(); + expect(enable).toHaveAttribute("aria-busy", "true"); + expect(screen.getByTestId("cloud_sync_verifying")).toHaveAttribute( + "aria-label", + "settings:cloud_sync_account_verification" + ); + expect(screen.getByTestId("cloud_sync_now")).toBeDisabled(); + expect(screen.queryByTestId("cloud_sync_status")).toBeNull(); + + await act(async () => verification.resolve({})); + expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: true })); + expect(enable).toHaveAttribute("data-state", "checked"); + expect(enable).not.toBeDisabled(); + expect(screen.getByTestId("cloud_sync_now")).not.toBeDisabled(); + expect(screen.getByTestId("cloud_sync_status")).not.toBeNull(); + expect(notify.success).toHaveBeenCalledWith("save_success"); }); - it("校验失败时不写入配置", async () => { + it("开启校验失败时提示准确错误并保持未启用", async () => { create.mockRejectedValue(new Error("bad credentials")); - mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + mockCloudSync({ params: { webdav: { url: "https://dav" } } }); render( () => {}} />); - const save = await screen.findByTestId("cloud_sync_save"); - await act(async () => fireEvent.click(save)); - expect(create).toHaveBeenCalled(); + const enable = await screen.findByTestId("cloud_sync_enable"); + + await act(async () => fireEvent.click(enable)); + expect(set).not.toHaveBeenCalled(); + expect(enable).toHaveAttribute("data-state", "unchecked"); + expect(enable).not.toBeDisabled(); + expect(enable).toHaveAttribute("aria-busy", "false"); + expect(notify.error).toHaveBeenCalledWith("settings:cloud_sync_verification_failed: bad credentials"); + }); + + it("关闭同步时直接保存未启用且不校验连接", async () => { + mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + + fireEvent.click(await screen.findByTestId("cloud_sync_enable")); + + expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: false })); + expect(create).not.toHaveBeenCalled(); + expect(screen.getByTestId("cloud_sync_now")).toBeDisabled(); + }); + + it("校验中禁用启用控件并忽略重复触发", async () => { + const verification = deferred(); + create.mockReturnValue(verification.promise); + mockCloudSync({ params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const enable = await screen.findByTestId("cloud_sync_enable"); + + fireEvent.click(enable); + fireEvent.click(enable); + + expect(enable).toBeDisabled(); + expect(create).toHaveBeenCalledTimes(1); + await act(async () => verification.resolve({})); + }); + + it("校验期间配置变化后忽略旧校验结果", async () => { + const verification = deferred(); + create.mockReturnValue(verification.promise); + mockCloudSync({ params: { webdav: { url: "https://old.example.com" } } }); + render( () => {}} />); + + fireEvent.click(await screen.findByTestId("cloud_sync_enable")); + fireEvent.change(screen.getByLabelText("url"), { target: { value: "https://new.example.com" } }); + expect(screen.queryByTestId("cloud_sync_verifying")).toBeNull(); + + await act(async () => verification.resolve({})); + + expect(set).toHaveBeenCalledTimes(1); + expect(set).toHaveBeenCalledWith( + "cloud_sync", + expect.objectContaining({ enable: false, params: { webdav: { url: "https://new.example.com" } } }) + ); + expect(notify.success).not.toHaveBeenCalled(); }); - it("切换同步删除复选框后保存写入新值", async () => { - mockCloudSync({ enable: false, syncDelete: false }); + it("外部配置变化应更新启用状态并淘汰进行中的校验", async () => { + const verification = deferred(); + create.mockReturnValue(verification.promise); + mockCloudSync({ params: { webdav: { url: "https://dav" } } }); render( () => {}} />); - const cb = await screen.findByTestId("cloud_sync_sync_delete"); - fireEvent.click(cb); - await act(async () => fireEvent.click(screen.getByTestId("cloud_sync_save"))); - expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ syncDelete: true })); + const enable = await screen.findByTestId("cloud_sync_enable"); + fireEvent.click(enable); + + act(() => + updateCloudSyncSnapshot({ + enable: true, + syncDelete: false, + syncStatus: true, + filesystem: "webdav", + params: { webdav: { url: "https://external.example.com" } }, + }) + ); + + expect(enable).toHaveAttribute("data-state", "checked"); + expect(screen.queryByTestId("cloud_sync_verifying")).toBeNull(); + expect(screen.getByTestId("cloud_sync_now")).not.toBeDisabled(); + + await act(async () => verification.reject(new Error("stale credentials"))); + expect(set).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); }); - it("切换同步状态复选框后保存写入新值", async () => { - mockCloudSync({ enable: false, syncStatus: true }); + it("组件卸载后忽略未完成校验结果", async () => { + const verification = deferred(); + create.mockReturnValue(verification.promise); + mockCloudSync({ params: { webdav: { url: "https://dav" } } }); + const { unmount } = render( () => {}} />); + fireEvent.click(await screen.findByTestId("cloud_sync_enable")); + + unmount(); + await act(async () => verification.resolve({})); + + expect(set).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); + }); + + it("不再显示保存按钮", async () => { + mockCloudSync(); render( () => {}} />); - const cb = await screen.findByTestId("cloud_sync_sync_status"); - fireEvent.click(cb); - await act(async () => fireEvent.click(screen.getByTestId("cloud_sync_save"))); - expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ syncStatus: false })); + await screen.findByTestId("cloud_sync_enable"); + expect(screen.queryByTestId("cloud_sync_save")).toBeNull(); }); it("启用同步且上次有覆盖/冲突时显示警示状态条与查看日志深链", async () => { @@ -186,41 +407,10 @@ describe("同步分区", () => { mockCloudSync({ enable: false }); mockState({}); render( () => {}} />); - await screen.findByTestId("cloud_sync_save"); - expect(screen.queryByTestId("cloud_sync_status")).toBeNull(); - }); - - it("勾选启用但未保存时立即同步按钮保持禁用(按已保存配置门控,避免点击静默无响应)", async () => { - mockCloudSync({ enable: false }); - mockState({}); - render( () => {}} />); - const enable = await screen.findByTestId("cloud_sync_enable"); - fireEvent.click(enable); - // 勾选草稿 enable 但未保存:SW cloudSyncOnce 用的是已保存配置(enable=false 时静默 return), - // 按钮须随已保存配置禁用,否则点击毫无反馈 - expect((screen.getByTestId("cloud_sync_now") as HTMLButtonElement).disabled).toBe(true); - }); - - it("勾选启用但未保存时不显示状态条(状态条只反映已保存配置)", async () => { - mockCloudSync({ enable: false }); - mockState({}); - render( () => {}} />); - const enable = await screen.findByTestId("cloud_sync_enable"); - fireEvent.click(enable); - // 仅勾选草稿、尚未保存,不应立刻出现「同步正常」状态条 + await screen.findByTestId("cloud_sync_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 } }); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index bcb08bee3..39d4d75d0 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from "react"; -import { RefreshCw, TriangleAlert, CircleAlert, CircleCheckBig, ExternalLink } from "lucide-react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { RefreshCw, TriangleAlert, CircleAlert, CircleCheckBig, ExternalLink, Loader2 } from "lucide-react"; import { SettingCard } from "../../../components/SettingCard"; import FileSystemParams from "../../../components/FileSystemParams"; +import { useSystemConfig } from "../../../hooks/useSystemConfig"; import { Checkbox } from "@App/pages/components/ui/checkbox"; import { Button } from "@App/pages/components/ui/button"; import { systemConfig } from "@App/pages/store/global"; @@ -28,42 +29,92 @@ const VARIANT_META: Record (el: HTMLElement | null) => void }) { const { t } = useTranslation(); - const [draft, setDraft] = useState(undefined); - // 状态条只反映已保存/实际生效的同步配置:勾选草稿(draft.enable)尚未保存时不应展示运行状态。 - const [savedEnable, setSavedEnable] = useState(false); + const [config, setConfig] = useSystemConfig("cloud_sync"); const [syncState, setSyncState] = useState(DEFAULT_CLOUD_SYNC_STATE); + const [verification, setVerification] = useState<{ config: CloudSyncConfig } | null>(null); + const verificationRef = useRef<{ config: CloudSyncConfig } | null>(null); useEffect(() => { - void Promise.resolve(systemConfig.get("cloud_sync")).then((v) => { - const cfg = v as CloudSyncConfig; - setDraft(cfg); - setSavedEnable(cfg.enable); - }); + return () => { + verificationRef.current = null; + }; }, []); + // 外部配置更新在提交阶段立即淘汰旧校验,避免旧成功或失败结果覆盖/干扰更新后的用户意图。 + useLayoutEffect(() => { + if (verificationRef.current?.config !== config) { + verificationRef.current = null; + } + }, [config]); + // 读取并订阅设备本地同步状态(SW 每轮同步写入 chrome.storage) useEffect(() => { void fetchCloudSyncState().then(setSyncState); return subscribeCloudSyncState(setSyncState); }, []); - const patch = (next: Partial) => setDraft((d) => (d ? { ...d, ...next } : d)); - - const save = async () => { - if (!draft) return; - // 启用同步时先校验账号连通性 - if (draft.enable) { - notify.info(t("settings:cloud_sync_account_verification")); - try { - await FileSystemFactory.create(draft.filesystem, draft.params[draft.filesystem]); - } catch (e) { - notify.error(`${t("settings:cloud_sync_verification_failed")}: ${e instanceof Error ? e.message : String(e)}`); + const verifying = verification !== null && verification.config === config; + + const cancelVerification = () => { + if (verificationRef.current === null) return; + verificationRef.current = null; + setVerification(null); + }; + + const patch = (next: Partial) => { + if (!config) return; + cancelVerification(); + const updated = { ...config, ...next }; + setConfig(updated); + }; + + const patchConnection = (next: Partial>) => { + if (!config) return; + cancelVerification(); + const shouldPause = config.enable; + const updated = { ...config, ...next, enable: shouldPause ? false : config.enable }; + setConfig(updated); + if (shouldPause) { + notify.info(t("settings:cloud_sync_connection_changed")); + } + }; + + const toggleEnable = async (enable: boolean) => { + if (!config) return; + + if (!enable) { + cancelVerification(); + if (config.enable) patch({ enable: false }); + return; + } + + if (config.enable || verificationRef.current?.config === config) return; + + const request = { config }; + verificationRef.current = request; + setVerification(request); + notify.info(t("settings:cloud_sync_account_verification")); + + try { + await FileSystemFactory.create(config.filesystem, config.params[config.filesystem]); + const savedConfig = await Promise.resolve(systemConfig.get("cloud_sync")); + if (verificationRef.current !== request) return; + if (savedConfig !== request.config) { + cancelVerification(); return; } + + verificationRef.current = null; + setVerification(null); + const updated = { ...savedConfig, enable: true }; + setConfig(updated); + notify.success(t("save_success")); + } catch (e) { + if (verificationRef.current !== request) return; + verificationRef.current = null; + setVerification(null); + notify.error(`${t("settings:cloud_sync_verification_failed")}: ${e instanceof Error ? e.message : String(e)}`); } - systemConfig.set("cloud_sync", draft); - setSavedEnable(draft.enable); - notify.success(t("save_success")); }; const syncNow = async () => { @@ -107,9 +158,9 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE return (
- {draft && ( + {config && (
- {savedEnable && ( + {config.enable && (
(el: HTMLE patch({ syncDelete: c === true })} /> {t("settings:sync_delete")} @@ -148,7 +199,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE patch({ syncStatus: c === true })} /> {t("settings:sync_status")} @@ -161,32 +212,43 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE patch({ enable: c === true })} + aria-busy={verifying} + checked={config.enable} + disabled={verifying} + onCheckedChange={(c) => { + void toggleEnable(c === true); + }} /> + {verifying && ( + + + )} {t("settings:enable_script_sync_to")} } - fileSystemType={draft.filesystem} - fileSystemParams={draft.params[draft.filesystem] || {}} - onChangeFileSystemType={(type) => patch({ filesystem: type })} - onChangeFileSystemParams={(params) => patch({ params: { ...draft.params, [draft.filesystem]: params } })} + fileSystemType={config.filesystem} + fileSystemParams={config.params[config.filesystem] || {}} + onChangeFileSystemType={(type) => patchConnection({ filesystem: type })} + onChangeFileSystemParams={(params) => + patchConnection({ params: { ...config.params, [config.filesystem]: params } }) + } > -
- - -
+
)} diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index fee90273e..d83a312e2 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, vi } from "vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { SystemConfig } from "./config"; import { MessageQueue } from "@Packages/message/message_queue"; @@ -14,6 +14,10 @@ describe("SystemConfig 双 storage 与懒迁移", () => { config = new SystemConfig(mq); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + describe("local key 读写", () => { it("cloud_sync 应写入 local storage 而非 sync", async () => { const cloudSync = { @@ -37,6 +41,38 @@ describe("SystemConfig 双 storage 与懒迁移", () => { expect(syncData["system_cloud_sync"]).toBeUndefined(); }); + it("同一配置键连续写入应按调用顺序持久化,避免旧值后完成覆盖新值", async () => { + const callbacks: Array<() => void> = []; + const set = vi.spyOn(chrome.storage.local, "set").mockImplementation((( + _items: Record, + callback?: () => void + ) => { + callbacks.push(callback || (() => {})); + }) as never); + const first = { + enable: false, + syncDelete: false, + syncStatus: true, + filesystem: "webdav" as const, + params: { webdav: { url: "h" } }, + }; + const second = { + ...first, + params: { webdav: { url: "ht" } }, + }; + + config.setCloudSync(first); + config.setCloudSync(second); + + expect(set).toHaveBeenCalledTimes(1); + callbacks.shift()?.(); + await vi.waitFor(() => expect(set).toHaveBeenCalledTimes(2)); + callbacks.shift()?.(); + await vi.waitFor(() => expect(callbacks).toHaveLength(0)); + await expect(config.getCloudSync()).resolves.toEqual(second); + set.mockRestore(); + }); + it("language 应写入 local storage", async () => { config.setLanguage("zh-CN"); diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 31efaea74..e86e54d9d 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -153,6 +153,7 @@ interface SystemConfigEntry { hasValue: boolean; value: unknown; version: number; + pendingWrite?: Promise; store?: unknown; } @@ -318,18 +319,25 @@ export class SystemConfig { const entry = this.cacheEntry(key); const prev = entry.value as SystemConfigValueType | undefined; entry.version += 1; + const writeVersion = entry.version; const storage = this.getStorage(key); - let asyncOp; + const persist = () => (value === undefined ? storage.remove(key) : storage.set(key, value)); if (value === undefined) { - entry.hasValue = false; + entry.hasValue = true; entry.value = undefined; - asyncOp = storage.remove(key); } else { entry.hasValue = true; entry.value = value; - asyncOp = storage.set(key, value); } + // 同一配置键可能在输入框逐字编辑时被高频写入。chrome.storage 的异步回调 + // 不保证多次并发写入按调用顺序完成,旧写入后完成会把新值覆盖掉;按键串行化 + // 持久化可确保最终落盘值与内存中的最新快照一致,不影响不同配置键并行保存。 + const asyncOp = entry.pendingWrite ? entry.pendingWrite.then(persist, persist) : persist(); + entry.pendingWrite = asyncOp; asyncOp.then(() => { + if (entry.pendingWrite === asyncOp) entry.pendingWrite = undefined; + // 后续写入已更新了内存快照时,不再广播这个中间值,避免旧通知把最新输入覆盖。 + if (entry.version !== writeVersion) return; // 发送消息通知更新 this.mq.publish>(SystemConfigChange, { key,