From 023f37f08fb025f48ffdfa9a74dee5b228bc4147 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:49:42 -0400 Subject: [PATCH] [sync] Unify NWC + Storage sync onto one shared implementation (step 1: backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit walletSyncService and storageSyncService were near-identical copies (the "two sync systems"). Extract the shared machinery into createEncryptedSyncItem() — per-pubkey enable flags, conflict-checked push, cheap remote-exists check, enable/disable/pull — so each service is now a thin config differing only in d-tag, kind, availability, and buildPayload/applyPayload. Public surface (isAvailable/isEnabled/hasRemote/push/pull/enable/disable + the *_DTAG export) preserved; both dedicated suites pass (8). Next (step 2): shared confirm dialog + consistent notifications + seamless auto-sync in the two profile controllers (the user-facing UX unification). Co-Authored-By: Claude Opus 4.8 --- js/services/encryptedSyncItem.js | 126 +++++++++++++++++++++++++++++ js/services/storageSyncService.js | 113 ++++---------------------- js/services/walletSyncService.js | 129 +++++++----------------------- 3 files changed, 172 insertions(+), 196 deletions(-) create mode 100644 js/services/encryptedSyncItem.js diff --git a/js/services/encryptedSyncItem.js b/js/services/encryptedSyncItem.js new file mode 100644 index 00000000..5307fcb6 --- /dev/null +++ b/js/services/encryptedSyncItem.js @@ -0,0 +1,126 @@ +// One encrypted cross-login sync implementation, shared by every syncable item +// (NWC wallet, storage credentials, …). Previously walletSyncService and +// storageSyncService were near-identical copies; the only per-item differences +// are the d-tag, the sync "kind", the availability check, and how the payload is +// built (push) / applied (pull). This factory captures the shared machinery — +// per-pubkey enable flags, conflict-checked push, cheap remote existence check, +// enable/disable — so the two "systems" become one. +// +// Item config: +// id "wallet" | "storage" — the per-pubkey sync-flag kind +// dtag stable d-tag for the encrypted note +// encryptedSync the encrypted-sync facade +// available () => boolean (extra availability beyond encryptedSync) +// buildPayload async (pubkey) => payload | null (null => "nothing-to-sync") +// applyPayload async (pubkey, payload) => void | { imported:false, error } +// logLabel short label for warnings + +import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; +import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; +import { pushWithConflictCheck } from "./syncConflict.js"; +import { userLogger } from "../utils/logger.js"; + +export function createEncryptedSyncItem({ + id, + dtag, + encryptedSync = defaultEncryptedSync, + available = () => true, + buildPayload, + applyPayload, + logLabel = id || "sync", +} = {}) { + function normalizePubkey(pubkey) { + return typeof pubkey === "string" ? pubkey.trim() : ""; + } + + function isAvailable() { + return Boolean(encryptedSync?.isAvailable?.()) && Boolean(available()); + } + + function isEnabled(pubkey) { + return isSyncEnabled(normalizePubkey(pubkey), id); + } + + function setEnabledFlag(pubkey, enabled) { + setSyncEnabled(normalizePubkey(pubkey), id, enabled); + } + + // Cheap existence check (no decrypt) for the login-restore offer. + async function hasRemote() { + if (typeof encryptedSync.exists !== "function") { + return false; + } + const result = await encryptedSync.exists(dtag); + return Boolean(result?.exists); + } + + async function push(pubkey, { confirmOverwrite } = {}) { + const key = normalizePubkey(pubkey); + if (!key) { + return { ok: false, error: "missing-pubkey" }; + } + let payload; + try { + payload = await buildPayload(key); + } catch (error) { + userLogger.warn(`[${logLabel}] Failed to build sync payload:`, error); + return { ok: false, error: "export-failed", cause: error }; + } + if (!payload) { + return { ok: false, error: "nothing-to-sync" }; + } + return pushWithConflictCheck({ + encryptedSync, + dTag: dtag, + kind: id, + pubkey: key, + payload, + confirmOverwrite, + }); + } + + async function pull(pubkey) { + const key = normalizePubkey(pubkey); + if (!key) { + return { found: false, error: "missing-pubkey" }; + } + const result = await encryptedSync.pull(dtag); + if (!result?.found) { + return result; + } + try { + const applied = await applyPayload(key, result.payload || {}); + if (applied && applied.imported === false) { + return { ...result, imported: false, error: applied.error }; + } + } catch (error) { + userLogger.warn(`[${logLabel}] Failed to apply pulled settings:`, error); + return { ...result, imported: false, error: "import-failed", cause: error }; + } + return { ...result, imported: true }; + } + + async function enable(pubkey, options = {}) { + setEnabledFlag(pubkey, true); + return push(pubkey, options); + } + + async function disable(pubkey) { + setEnabledFlag(pubkey, false); + return encryptedSync.clear(dtag); + } + + return { + id, + dtag, + isAvailable, + isEnabled, + hasRemote, + push, + pull, + enable, + disable, + }; +} + +export default createEncryptedSyncItem; diff --git a/js/services/storageSyncService.js b/js/services/storageSyncService.js index e1b1bff8..5c23d4a6 100644 --- a/js/services/storageSyncService.js +++ b/js/services/storageSyncService.js @@ -9,9 +9,7 @@ import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; import defaultStorageService from "./storageService.js"; -import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; -import { pushWithConflictCheck } from "./syncConflict.js"; -import { userLogger } from "../utils/logger.js"; +import { createEncryptedSyncItem } from "./encryptedSyncItem.js"; export const STORAGE_SYNC_DTAG = "bitvid:storage-connections"; const SYNC_KIND = "storage"; @@ -20,100 +18,23 @@ export function createStorageSyncService({ encryptedSync = defaultEncryptedSync, storage = defaultStorageService, } = {}) { - function normalizePubkey(pubkey) { - return typeof pubkey === "string" ? pubkey.trim() : ""; - } + const item = createEncryptedSyncItem({ + id: SYNC_KIND, + dtag: STORAGE_SYNC_DTAG, + encryptedSync, + logLabel: "storageSync", + // Export the local storage account record (self-protected). null => nothing + // to sync. An export error surfaces as { error: "export-failed" }. + buildPayload: async (pubkey) => { + const record = await storage.exportAccountRecord(pubkey); + return record || null; + }, + applyPayload: async (pubkey, payload) => { + await storage.importAccountRecord(pubkey, payload); + }, + }); - function isAvailable() { - return Boolean(encryptedSync?.isAvailable?.()); - } - - function isEnabled(pubkey) { - return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND); - } - - function setEnabledFlag(pubkey, enabled) { - setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled); - } - - // Cheap check (no decrypt) for whether a synced note exists — for the - // login-restore prompt. - async function hasRemote() { - if (typeof encryptedSync.exists !== "function") { - return false; - } - const result = await encryptedSync.exists(STORAGE_SYNC_DTAG); - return Boolean(result?.exists); - } - - // Push the current local storage account record to the user's encrypted note. - async function push(pubkey, { confirmOverwrite } = {}) { - const key = normalizePubkey(pubkey); - if (!key) { - return { ok: false, error: "missing-pubkey" }; - } - let record; - try { - record = await storage.exportAccountRecord(key); - } catch (error) { - userLogger.warn("[storageSync] Failed to export storage record:", error); - return { ok: false, error: "export-failed", cause: error }; - } - if (!record) { - return { ok: false, error: "nothing-to-sync" }; - } - return pushWithConflictCheck({ - encryptedSync, - dTag: STORAGE_SYNC_DTAG, - kind: SYNC_KIND, - pubkey: key, - payload: record, - confirmOverwrite, - }); - } - - // Pull the encrypted note and import it into local storage. Returns the - // encrypted-sync pull result with `imported: true` when a record was applied. - async function pull(pubkey) { - const key = normalizePubkey(pubkey); - if (!key) { - return { found: false, error: "missing-pubkey" }; - } - const result = await encryptedSync.pull(STORAGE_SYNC_DTAG); - if (!result?.found) { - return result; - } - try { - await storage.importAccountRecord(key, result.payload); - } catch (error) { - userLogger.warn("[storageSync] Failed to import storage record:", error); - return { ...result, imported: false, error: "import-failed", cause: error }; - } - return { ...result, imported: true }; - } - - // Turn sync on: remember the choice and push immediately. - async function enable(pubkey, options = {}) { - setEnabledFlag(pubkey, true); - return push(pubkey, options); - } - - // Turn sync off: remember the choice and wipe the published note. - async function disable(pubkey) { - setEnabledFlag(pubkey, false); - return encryptedSync.clear(STORAGE_SYNC_DTAG); - } - - return { - STORAGE_SYNC_DTAG, - isAvailable, - isEnabled, - hasRemote, - push, - pull, - enable, - disable, - }; + return { STORAGE_SYNC_DTAG, ...item }; } export const storageSyncService = createStorageSyncService(); diff --git a/js/services/walletSyncService.js b/js/services/walletSyncService.js index 583e76bc..f7215e6e 100644 --- a/js/services/walletSyncService.js +++ b/js/services/walletSyncService.js @@ -11,9 +11,7 @@ // factory — the caller injects the live service. import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js"; -import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js"; -import { pushWithConflictCheck } from "./syncConflict.js"; -import { userLogger } from "../utils/logger.js"; +import { createEncryptedSyncItem } from "./encryptedSyncItem.js"; export const WALLET_SYNC_DTAG = "bitvid:nwc"; const SYNC_KIND = "wallet"; @@ -22,109 +20,40 @@ export function createWalletSyncService({ encryptedSync = defaultEncryptedSync, nwcSettings, } = {}) { - function normalizePubkey(pubkey) { - return typeof pubkey === "string" ? pubkey.trim() : ""; - } - - function isAvailable() { - return Boolean(encryptedSync?.isAvailable?.()) && Boolean(nwcSettings); - } - - function isEnabled(pubkey) { - return isSyncEnabled(normalizePubkey(pubkey), SYNC_KIND); - } - - function setEnabledFlag(pubkey, enabled) { - setSyncEnabled(normalizePubkey(pubkey), SYNC_KIND, enabled); - } - - // Build the minimal syncable payload from current wallet settings. - function buildPayload() { - const settings = nwcSettings?.getActiveNwcSettings?.() || {}; - const nwcUri = typeof settings.nwcUri === "string" ? settings.nwcUri.trim() : ""; - const payload = { nwcUri }; - if (settings.defaultZap !== undefined) { - payload.defaultZap = settings.defaultZap; - } - return payload; - } - - // Cheap check (no decrypt) for whether a synced note exists — for the - // login-restore prompt. - async function hasRemote() { - if (typeof encryptedSync.exists !== "function") { - return false; - } - const result = await encryptedSync.exists(WALLET_SYNC_DTAG); - return Boolean(result?.exists); - } - - async function push(pubkey, { confirmOverwrite } = {}) { - const key = normalizePubkey(pubkey); - if (!key) { - return { ok: false, error: "missing-pubkey" }; - } - const payload = buildPayload(); - if (!payload.nwcUri) { - return { ok: false, error: "nothing-to-sync" }; - } - return pushWithConflictCheck({ - encryptedSync, - dTag: WALLET_SYNC_DTAG, - kind: SYNC_KIND, - pubkey: key, - payload, - confirmOverwrite, - }); - } - - async function pull(pubkey) { - const key = normalizePubkey(pubkey); - if (!key) { - return { found: false, error: "missing-pubkey" }; - } - const result = await encryptedSync.pull(WALLET_SYNC_DTAG); - if (!result?.found) { - return result; - } - const payload = result.payload || {}; - const nwcUri = typeof payload.nwcUri === "string" ? payload.nwcUri.trim() : ""; - if (!nwcUri) { - return { ...result, imported: false, error: "empty-payload" }; - } - try { + const item = createEncryptedSyncItem({ + id: SYNC_KIND, + dtag: WALLET_SYNC_DTAG, + encryptedSync, + logLabel: "walletSync", + available: () => Boolean(nwcSettings), + // Minimal syncable payload from current wallet settings. null => nothing to + // sync (no wallet connected). + buildPayload: () => { + const settings = nwcSettings?.getActiveNwcSettings?.() || {}; + const nwcUri = typeof settings.nwcUri === "string" ? settings.nwcUri.trim() : ""; + if (!nwcUri) { + return null; + } + const payload = { nwcUri }; + if (settings.defaultZap !== undefined) { + payload.defaultZap = settings.defaultZap; + } + return payload; + }, + applyPayload: async (pubkey, payload) => { + const nwcUri = typeof payload.nwcUri === "string" ? payload.nwcUri.trim() : ""; + if (!nwcUri) { + return { imported: false, error: "empty-payload" }; + } const partial = { nwcUri }; if (payload.defaultZap !== undefined) { partial.defaultZap = payload.defaultZap; } await nwcSettings.updateActiveNwcSettings(partial); - } catch (error) { - userLogger.warn("[walletSync] Failed to apply pulled wallet settings:", error); - return { ...result, imported: false, error: "import-failed", cause: error }; - } - return { ...result, imported: true }; - } + }, + }); - async function enable(pubkey, options = {}) { - setEnabledFlag(pubkey, true); - return push(pubkey, options); - } - - async function disable(pubkey) { - setEnabledFlag(pubkey, false); - return encryptedSync.clear(WALLET_SYNC_DTAG); - } - - return { - WALLET_SYNC_DTAG, - isAvailable, - isEnabled, - hasRemote, - push, - pull, - enable, - disable, - }; + return { WALLET_SYNC_DTAG, ...item }; } export default createWalletSyncService;