diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index f9b19c2bd0..7e751f1d56 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -29,6 +29,7 @@ import { useDataGridActions } from "@/composables/useDataGridActions"; import { useTauriEvents } from "@/composables/useTauriEvents"; import { useCloseActionPrompt } from "@/composables/useCloseActionPrompt"; import { useVisibilityChange } from "@/composables/useVisibilityChange"; +import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload"; import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import * as api from "@/lib/api"; @@ -243,6 +244,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ }); const { showCloseActionPrompt, chooseQuit, chooseMinimize, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt(); useVisibilityChange(); +useWebDavAutoUpload(); const appVersion = ref(""); const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic"); diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 8e80f9c0b3..8a134aba17 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -77,6 +77,12 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { currentLocale, setLocale, type Locale } from "@/i18n"; import { LOCALE_OPTIONS } from "@/lib/localeOptions"; +import { + DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, + DEFAULT_WEB_DAV_REMOTE_PATH, + normalizedWebDavAutoUploadInterval, + writeWebDavAutoUploadFields, +} from "@/lib/webdavAutoUploadConfig"; const { t } = useI18n(); const settingsStore = useSettingsStore(); @@ -1196,15 +1202,14 @@ const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || ""); const webdavPassword = ref(""); const webdavRememberPassword = ref(localStorage.getItem("dbx-webdav-remember-password") === "true"); const webdavHasSavedPassword = ref(false); -const webdavRemotePath = ref(localStorage.getItem("dbx-webdav-remote-path") || "DBX/sync/snapshot.json"); +const webdavRemotePath = ref(localStorage.getItem("dbx-webdav-remote-path") || DEFAULT_WEB_DAV_REMOTE_PATH); const webdavSyncSecrets = ref(false); const webdavSecretsPassphrase = ref(""); const webdavAutoUploadEnabled = ref(localStorage.getItem("dbx-webdav-auto-upload-enabled") === "true"); -const webdavAutoUploadIntervalMinutes = ref(Number(localStorage.getItem("dbx-webdav-auto-upload-interval-minutes") || "30")); +const webdavAutoUploadIntervalMinutes = ref(Number(localStorage.getItem("dbx-webdav-auto-upload-interval-minutes") || String(DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES))); const webdavBusy = ref<"" | "test" | "upload" | "download">(""); const webdavMessage = ref(""); const webdavError = ref(false); -let webdavAutoUploadTimer: ReturnType | undefined; const webdavReady = computed(() => !!webdavEndpoint.value.trim() && !webdavBusy.value && (!webdavSyncSecrets.value || !!webdavSecretsPassphrase.value.trim())); @@ -1213,7 +1218,7 @@ function currentWebDavConfig(): WebDavConfig { endpoint: webdavEndpoint.value.trim(), username: webdavUsername.value.trim() || undefined, password: webdavPassword.value || undefined, - remotePath: webdavRemotePath.value.trim() || "DBX/sync/snapshot.json", + remotePath: webdavRemotePath.value.trim() || DEFAULT_WEB_DAV_REMOTE_PATH, }; } @@ -1223,11 +1228,11 @@ function currentWebDavAccountConfig(): WebDavConfig { } function rememberWebDavFields() { - localStorage.setItem("dbx-webdav-endpoint", webdavEndpoint.value.trim()); - localStorage.setItem("dbx-webdav-username", webdavUsername.value.trim()); - localStorage.setItem("dbx-webdav-remote-path", webdavRemotePath.value.trim() || "DBX/sync/snapshot.json"); - localStorage.setItem("dbx-webdav-auto-upload-enabled", String(webdavAutoUploadEnabled.value)); - localStorage.setItem("dbx-webdav-auto-upload-interval-minutes", String(normalizedWebDavAutoUploadInterval())); + writeWebDavAutoUploadFields(currentWebDavConfig(), { + enabled: webdavAutoUploadEnabled.value, + intervalMinutes: webdavAutoUploadIntervalMinutes.value, + }); + window.dispatchEvent(new Event("dbx:webdav-auto-upload-config-changed")); } function setWebDavResult(message: string, error = false) { @@ -1292,43 +1297,6 @@ async function uploadWebDavSnapshot() { }); } -function normalizedWebDavAutoUploadInterval() { - const value = Number(webdavAutoUploadIntervalMinutes.value); - if (!Number.isFinite(value)) return 30; - return Math.max(1, Math.min(1440, Math.round(value))); -} - -function scheduleWebDavAutoUpload() { - if (webdavAutoUploadTimer) { - window.clearInterval(webdavAutoUploadTimer); - webdavAutoUploadTimer = undefined; - } - if (!webdavAutoUploadEnabled.value) return; - - const intervalMinutes = normalizedWebDavAutoUploadInterval(); - webdavAutoUploadIntervalMinutes.value = intervalMinutes; - webdavAutoUploadTimer = window.setInterval(() => { - void runWebDavAutoUpload(); - }, intervalMinutes * 60_000); -} - -async function runWebDavAutoUpload() { - if (!webdavEndpoint.value.trim() || webdavBusy.value) return; - webdavBusy.value = "upload"; - webdavMessage.value = ""; - webdavError.value = false; - try { - rememberWebDavFields(); - await applyWebDavPasswordPreference(); - const summary = await webdavSyncUpload(currentWebDavConfig(), settingsStore.editorSettings, webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined); - setWebDavResult(t("settings.syncAutoUploadSuccess", { bytes: summary.bytes, path: summary.remotePath })); - } catch (e: any) { - setWebDavResult(e?.message || String(e), true); - } finally { - webdavBusy.value = ""; - } -} - async function downloadWebDavSnapshot() { if (!window.confirm(t("settings.syncDownloadConfirm"))) return; await runWebDavAction("download", async () => { @@ -1408,8 +1376,8 @@ watch(webdavRememberPassword, (val) => { localStorage.setItem("dbx-webdav-remember-password", String(val)); }); watch([webdavAutoUploadEnabled, webdavAutoUploadIntervalMinutes], () => { + webdavAutoUploadIntervalMinutes.value = normalizedWebDavAutoUploadInterval(webdavAutoUploadIntervalMinutes.value); rememberWebDavFields(); - scheduleWebDavAutoUpload(); }); watch(activeSettingsTab, (tab) => { @@ -1423,17 +1391,12 @@ watch(activeSettingsTab, (tab) => { onMounted(() => { void refreshWebDavPasswordStatus(); - scheduleWebDavAutoUpload(); checkLayoutDescTruncation(); checkIconThemeDescTruncation(); initTruncationObservers(); }); onUnmounted(() => { - if (webdavAutoUploadTimer) { - window.clearInterval(webdavAutoUploadTimer); - webdavAutoUploadTimer = undefined; - } cleanupTableColumnTemplatePointerDrag(); cleanupTruncationObservers(); }); diff --git a/apps/desktop/src/composables/useWebDavAutoUpload.ts b/apps/desktop/src/composables/useWebDavAutoUpload.ts new file mode 100644 index 0000000000..2de8b1c565 --- /dev/null +++ b/apps/desktop/src/composables/useWebDavAutoUpload.ts @@ -0,0 +1,69 @@ +import { onMounted, onUnmounted } from "vue"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { appendDebugLog } from "@/lib/debugLog"; +import { webdavSyncUpload } from "@/lib/api"; +import { readWebDavAutoUploadConfig, WEB_DAV_AUTO_UPLOAD_STORAGE_KEYS } from "@/lib/webdavAutoUploadConfig"; + +export function useWebDavAutoUpload() { + const settingsStore = useSettingsStore(); + let timer: ReturnType | undefined; + let uploading = false; + + function clearTimer() { + if (!timer) return; + window.clearInterval(timer); + timer = undefined; + } + + function schedule() { + clearTimer(); + const config = readWebDavAutoUploadConfig(); + if (!config.enabled || !config.webDavConfig) return; + + timer = window.setInterval(() => { + void runAutoUpload(); + }, config.intervalMinutes * 60_000); + } + + async function runAutoUpload() { + if (uploading) return; + const config = readWebDavAutoUploadConfig(); + if (!config.enabled || !config.webDavConfig) return; + + uploading = true; + try { + const summary = await webdavSyncUpload(config.webDavConfig, settingsStore.editorSettings); + appendDebugLog("info", "[DBX][webdav:auto-upload:success]", { + bytes: summary.bytes, + remotePath: summary.remotePath, + exportedAt: summary.exportedAt, + }); + } catch (error) { + appendDebugLog("error", "[DBX][webdav:auto-upload:error]", error); + } finally { + uploading = false; + } + } + + function onStorage(event: StorageEvent) { + if (event.key && !WEB_DAV_AUTO_UPLOAD_STORAGE_KEYS.includes(event.key as (typeof WEB_DAV_AUTO_UPLOAD_STORAGE_KEYS)[number])) return; + schedule(); + } + + onMounted(() => { + schedule(); + window.addEventListener("storage", onStorage); + window.addEventListener("dbx:webdav-auto-upload-config-changed", schedule); + }); + + onUnmounted(() => { + clearTimer(); + window.removeEventListener("storage", onStorage); + window.removeEventListener("dbx:webdav-auto-upload-config-changed", schedule); + }); + + return { + scheduleWebDavAutoUpload: schedule, + runWebDavAutoUpload: runAutoUpload, + }; +} diff --git a/apps/desktop/src/lib/webdavAutoUploadConfig.ts b/apps/desktop/src/lib/webdavAutoUploadConfig.ts new file mode 100644 index 0000000000..27ffe87c44 --- /dev/null +++ b/apps/desktop/src/lib/webdavAutoUploadConfig.ts @@ -0,0 +1,51 @@ +import type { WebDavConfig } from "@/lib/api"; +import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; + +export const WEB_DAV_AUTO_UPLOAD_STORAGE_KEYS = [ + "dbx-webdav-endpoint", + "dbx-webdav-username", + "dbx-webdav-remote-path", + "dbx-webdav-auto-upload-enabled", + "dbx-webdav-auto-upload-interval-minutes", +] as const; + +export const DEFAULT_WEB_DAV_REMOTE_PATH = "DBX/sync/snapshot.json"; +export const DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES = 30; + +export interface WebDavAutoUploadConfig { + enabled: boolean; + intervalMinutes: number; + webDavConfig: WebDavConfig | null; +} + +export function normalizedWebDavAutoUploadInterval(value: unknown): number { + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) return DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES; + return Math.max(1, Math.min(1440, Math.round(numberValue))); +} + +export function readWebDavAutoUploadConfig(): WebDavAutoUploadConfig { + const endpoint = safeLocalStorageGet("dbx-webdav-endpoint")?.trim() || ""; + const username = safeLocalStorageGet("dbx-webdav-username")?.trim() || ""; + const remotePath = safeLocalStorageGet("dbx-webdav-remote-path")?.trim() || DEFAULT_WEB_DAV_REMOTE_PATH; + + return { + enabled: safeLocalStorageGet("dbx-webdav-auto-upload-enabled") === "true", + intervalMinutes: normalizedWebDavAutoUploadInterval(safeLocalStorageGet("dbx-webdav-auto-upload-interval-minutes")), + webDavConfig: endpoint + ? { + endpoint, + username: username || undefined, + remotePath, + } + : null, + }; +} + +export function writeWebDavAutoUploadFields(config: WebDavConfig, autoUpload: { enabled: boolean; intervalMinutes: unknown }) { + safeLocalStorageSet("dbx-webdav-endpoint", config.endpoint.trim()); + safeLocalStorageSet("dbx-webdav-username", config.username?.trim() || ""); + safeLocalStorageSet("dbx-webdav-remote-path", config.remotePath?.trim() || DEFAULT_WEB_DAV_REMOTE_PATH); + safeLocalStorageSet("dbx-webdav-auto-upload-enabled", String(autoUpload.enabled)); + safeLocalStorageSet("dbx-webdav-auto-upload-interval-minutes", String(normalizedWebDavAutoUploadInterval(autoUpload.intervalMinutes))); +} diff --git a/packages/app-tests/webdavAutoUpload.test.ts b/packages/app-tests/webdavAutoUpload.test.ts new file mode 100644 index 0000000000..440b7d4ce6 --- /dev/null +++ b/packages/app-tests/webdavAutoUpload.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload"; +import { readWebDavAutoUploadConfig } from "@/lib/webdavAutoUploadConfig"; + +const { webdavSyncUploadMock } = vi.hoisted(() => ({ + webdavSyncUploadMock: vi.fn(), +})); + +vi.mock("vue", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + onMounted: (hook: () => void) => hook(), + onUnmounted: vi.fn(), + }; +}); + +vi.mock("@/lib/api", () => ({ + webdavSyncUpload: webdavSyncUploadMock, +})); + +function installLocalStorage() { + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + const store = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + }, + }); + return () => { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }; +} + +let restoreLocalStorage: (() => void) | undefined; +let restoreWindow: (() => void) | undefined; + +function installWindow() { + const original = Object.getOwnPropertyDescriptor(globalThis, "window"); + const listeners = new Map void>>(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + setInterval: globalThis.setInterval.bind(globalThis), + clearInterval: globalThis.clearInterval.bind(globalThis), + addEventListener: (type: string, listener: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(listener); + listeners.set(type, set); + }, + removeEventListener: (type: string, listener: (event: Event) => void) => { + listeners.get(type)?.delete(listener); + }, + dispatchEvent: (event: Event) => { + listeners.get(event.type)?.forEach((listener) => listener(event)); + return true; + }, + }, + }); + return () => { + if (original) Object.defineProperty(globalThis, "window", original); + else Reflect.deleteProperty(globalThis, "window"); + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + webdavSyncUploadMock.mockResolvedValue({ bytes: 42, remotePath: "DBX/sync/snapshot.json" }); + restoreLocalStorage = installLocalStorage(); + restoreWindow = installWindow(); + setActivePinia(createPinia()); +}); + +afterEach(() => { + restoreWindow?.(); + restoreWindow = undefined; + restoreLocalStorage?.(); + restoreLocalStorage = undefined; + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +test("reads normalized WebDAV auto-upload config from localStorage", () => { + localStorage.setItem("dbx-webdav-endpoint", " https://dav.example.com/ "); + localStorage.setItem("dbx-webdav-username", " alice "); + localStorage.setItem("dbx-webdav-auto-upload-enabled", "true"); + localStorage.setItem("dbx-webdav-auto-upload-interval-minutes", "0"); + + const config = readWebDavAutoUploadConfig(); + + assert.equal(config.enabled, true); + assert.equal(config.intervalMinutes, 1); + assert.deepEqual(config.webDavConfig, { + endpoint: "https://dav.example.com/", + username: "alice", + remotePath: "DBX/sync/snapshot.json", + }); +}); + +test("keeps WebDAV auto-upload running outside the settings dialog", async () => { + localStorage.setItem("dbx-webdav-endpoint", "https://dav.example.com/"); + localStorage.setItem("dbx-webdav-auto-upload-enabled", "true"); + localStorage.setItem("dbx-webdav-auto-upload-interval-minutes", "1"); + + useWebDavAutoUpload(); + + await vi.advanceTimersByTimeAsync(60_000); + + assert.equal(webdavSyncUploadMock.mock.calls.length, 1); + assert.deepEqual(webdavSyncUploadMock.mock.calls[0][0], { + endpoint: "https://dav.example.com/", + username: undefined, + remotePath: "DBX/sync/snapshot.json", + }); + + localStorage.setItem("dbx-webdav-auto-upload-enabled", "false"); + window.dispatchEvent(new Event("dbx:webdav-auto-upload-config-changed")); + await vi.advanceTimersByTimeAsync(60_000); + + assert.equal(webdavSyncUploadMock.mock.calls.length, 1); +});