From 4186b4c4ba4ad9ae2fb484bb9b50502872a2f7eb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:08:43 -0400 Subject: [PATCH] feat(sync): offer-to-pull encrypted settings on login (one-time, non-blocking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes todo #15. On login, if an encrypted copy of the user's storage and/or wallet settings exists on their Nostr account — and this device hasn't already opted in — offer to restore it. - js/nostr/encryptedSync.js: exists(dTag) — cheap list-only existence peek (NO decrypt, so it doesn't spend the signer budget); decryption only on accept. - storage/walletSyncService: hasRemote() wrappers over exists(). - js/services/settingsRestorePrompt.js: one-time per-pubkey offer. Only prompts for items with a remote copy not already enabled locally; if nothing remote exists it does NOT consume the one-time offer (a note published later can still prompt next login). On accept, pulls + applies. - app.js handleLoginModalSuccess: fires maybeOfferSettingsRestore(pubkey) after login, deferred ~2s so relays connect; best-effort, never blocks login; toasts on restore. tests/settings-restore-prompt.test.mjs: offer+restore-only-remote-items, decline, prompt-once, skip-already-enabled, don't-consume-when-empty. Mutation-verified. Co-Authored-By: Claude Opus 4.8 --- js/app.js | 54 +++++++++++ js/nostr/encryptedSync.js | 44 ++++++++- js/services/settingsRestorePrompt.js | 127 ++++++++++++++++++++++++ js/services/storageSyncService.js | 11 +++ js/services/walletSyncService.js | 11 +++ tests/settings-restore-prompt.test.mjs | 128 +++++++++++++++++++++++++ 6 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 js/services/settingsRestorePrompt.js create mode 100644 tests/settings-restore-prompt.test.mjs diff --git a/js/app.js b/js/app.js index 0fd7a10a..82dd525f 100644 --- a/js/app.js +++ b/js/app.js @@ -57,6 +57,9 @@ import { import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; import storageService from "./services/storageService.js"; +import { storageSyncService } from "./services/storageSyncService.js"; +import { createWalletSyncService } from "./services/walletSyncService.js"; +import { createSettingsRestorePrompt } from "./services/settingsRestorePrompt.js"; import { getVideoNoteErrorMessage, normalizeVideoNotePayload, @@ -1624,6 +1627,57 @@ class Application { ); } } + + if (pubkey) { + // Best-effort, never blocks login: offer to restore encrypted settings + // synced from another device. + this.maybeOfferSettingsRestore(pubkey); + } + } + + // One-time offer to restore encrypted storage/wallet settings on login (todo + // #15). Existence check is list-only (no decrypt); decryption happens only if + // the user accepts. Deferred so relays can connect first; failures are silent. + maybeOfferSettingsRestore(pubkey) { + const key = + this.normalizeHexPubkey(pubkey) || + (typeof pubkey === "string" ? pubkey : ""); + if (!key) { + return; + } + setTimeout(() => { + try { + if (!this._settingsRestorePrompt) { + this._settingsRestorePrompt = createSettingsRestorePrompt({ + storageSync: storageSyncService, + walletSync: createWalletSyncService({ + nwcSettings: this.nwcSettingsService, + }), + }); + } + Promise.resolve( + this._settingsRestorePrompt.maybeOffer(key, { + onRestored: (items) => { + const hasStorage = items.includes("storage"); + const hasWallet = items.includes("wallet"); + const label = + hasStorage && hasWallet + ? "storage and wallet settings" + : hasWallet + ? "wallet connection" + : "storage settings"; + this.showSuccess( + `Restored your ${label} from your Nostr account.`, + ); + }, + }), + ).catch((error) => { + devLogger.warn("[settingsRestore] offer failed:", error); + }); + } catch (error) { + devLogger.warn("[settingsRestore] offer setup failed:", error); + } + }, 2000); } async handleLoginModalError(payload = {}) { diff --git a/js/nostr/encryptedSync.js b/js/nostr/encryptedSync.js index 2aaf9ea6..540b8e8d 100644 --- a/js/nostr/encryptedSync.js +++ b/js/nostr/encryptedSync.js @@ -260,6 +260,48 @@ export function createEncryptedSyncManager(deps = {}) { return { found: true, payload, createdAt: newest.created_at, event: newest }; } + // Cheap existence peek for the login-restore prompt: list-only, NO decrypt + // (does not spend the signer's decrypt budget). Returns whether a non-cleared + // note is present for the d-tag. + async function exists(dTag) { + const tag = normalizeDTag(dTag); + const pubkey = resolvePubkey(); + if (!tag || !pubkey) { + return { exists: false }; + } + const relays = + (typeof getReadRelays === "function" ? getReadRelays() : []) || []; + if (typeof listEvents !== "function" || !relays.length) { + return { exists: false }; + } + let events = []; + try { + events = await listEvents(relays, [ + { kinds: [APP_DATA_KIND], authors: [pubkey], "#d": [tag], limit: 20 }, + ]); + } catch (err) { + return { exists: false, error: "fetch-failed" }; + } + const newest = selectNewest(Array.isArray(events) ? events.flat() : []); + if (!newest) { + return { exists: false }; + } + const rawContent = + typeof newest.content === "string" ? newest.content.trim() : ""; + if (!rawContent) { + return { exists: false, cleared: true }; + } + try { + const parsed = JSON.parse(rawContent); + if (parsed && parsed.cleared === true) { + return { exists: false, cleared: true }; + } + } catch (err) { + // Unparseable content still counts as "something is there". + } + return { exists: true, createdAt: newest.created_at }; + } + async function clear(dTag, options = {}) { const tag = normalizeDTag(dTag); if (!tag) { @@ -287,7 +329,7 @@ export function createEncryptedSyncManager(deps = {}) { return publishTemplate(template); } - return { isAvailable, push, pull, clear, APP_DATA_KIND }; + return { isAvailable, push, pull, exists, clear, APP_DATA_KIND }; } export default createEncryptedSyncManager; diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js new file mode 100644 index 00000000..d24eaa09 --- /dev/null +++ b/js/services/settingsRestorePrompt.js @@ -0,0 +1,127 @@ +// One-time "offer to pull on login" for encrypted settings sync (todo #15). +// On login, if an encrypted copy of the user's storage and/or wallet settings +// exists on their Nostr account — and this device hasn't already opted in — offer +// to restore it. Runs AFTER login (never blocks it) and respects the signer +// budget: the existence check is list-only (no decrypt); decryption happens only +// if the user accepts. + +import { storageSyncService as defaultStorageSync } from "./storageSyncService.js"; + +const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; + +function readOffered() { + try { + if (typeof localStorage === "undefined") { + return {}; + } + const raw = localStorage.getItem(OFFERED_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + return {}; + } +} + +function markOffered(pubkey) { + try { + if (typeof localStorage === "undefined") { + return; + } + const map = readOffered(); + map[pubkey] = true; + localStorage.setItem(OFFERED_KEY, JSON.stringify(map)); + } catch (error) { + // Best-effort. + } +} + +function hasOffered(pubkey) { + return readOffered()[pubkey] === true; +} + +const ITEM_LABELS = { + storage: "storage settings", + wallet: "wallet connection", +}; + +export function createSettingsRestorePrompt({ + storageSync = defaultStorageSync, + walletSync = null, + confirm = (message) => + typeof window !== "undefined" && typeof window.confirm === "function" + ? window.confirm(message) + : false, + logger = null, +} = {}) { + // Items not already enabled on THIS device that have a remote copy to restore. + async function collectCandidates(pubkey) { + const candidates = []; + const services = [ + ["storage", storageSync], + ["wallet", walletSync], + ]; + for (const [kind, service] of services) { + if (!service?.isAvailable?.() || service.isEnabled(pubkey)) { + continue; + } + try { + if (await service.hasRemote()) { + candidates.push(kind); + } + } catch (error) { + logger?.warn?.(`[settingsRestore] ${kind} hasRemote check failed`, error); + } + } + return candidates; + } + + async function maybeOffer(pubkey, { onRestored } = {}) { + const key = typeof pubkey === "string" ? pubkey.trim() : ""; + if (!key) { + return { offered: false, reason: "no-pubkey" }; + } + if (hasOffered(key)) { + return { offered: false, reason: "already-offered" }; + } + + const candidates = await collectCandidates(key); + if (!candidates.length) { + // Don't mark offered — a note may be published later from another device; + // the existence check is cheap (list-only) so re-checking next login is fine. + return { offered: false, reason: "no-remote" }; + } + + // We are about to prompt — mark so we never nag again on this device. + markOffered(key); + + const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and "); + const accepted = confirm( + `Found an encrypted copy of your ${list} on your Nostr account. ` + + `Restore ${candidates.length > 1 ? "them" : "it"} to this device?` + ); + if (!accepted) { + return { offered: true, accepted: false }; + } + + const restored = []; + const serviceByKind = { storage: storageSync, wallet: walletSync }; + for (const kind of candidates) { + try { + const result = await serviceByKind[kind].pull(key); + if (result?.imported) { + restored.push(kind); + } + } catch (error) { + logger?.warn?.(`[settingsRestore] ${kind} pull failed`, error); + } + } + if (restored.length && typeof onRestored === "function") { + onRestored(restored); + } + return { offered: true, accepted: true, restored }; + } + + return { maybeOffer }; +} + +export default createSettingsRestorePrompt; diff --git a/js/services/storageSyncService.js b/js/services/storageSyncService.js index 20e369e9..37716ea5 100644 --- a/js/services/storageSyncService.js +++ b/js/services/storageSyncService.js @@ -35,6 +35,16 @@ export function createStorageSyncService({ 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) { const key = normalizePubkey(pubkey); @@ -90,6 +100,7 @@ export function createStorageSyncService({ STORAGE_SYNC_DTAG, isAvailable, isEnabled, + hasRemote, push, pull, enable, diff --git a/js/services/walletSyncService.js b/js/services/walletSyncService.js index e1671a1e..915789f5 100644 --- a/js/services/walletSyncService.js +++ b/js/services/walletSyncService.js @@ -48,6 +48,16 @@ export function createWalletSyncService({ 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) { const key = normalizePubkey(pubkey); if (!key) { @@ -101,6 +111,7 @@ export function createWalletSyncService({ WALLET_SYNC_DTAG, isAvailable, isEnabled, + hasRemote, push, pull, enable, diff --git a/tests/settings-restore-prompt.test.mjs b/tests/settings-restore-prompt.test.mjs new file mode 100644 index 00000000..3c49b3e2 --- /dev/null +++ b/tests/settings-restore-prompt.test.mjs @@ -0,0 +1,128 @@ +// Offer-to-pull on login (todo #15): after login, if an encrypted copy exists on +// the user's account and this device hasn't opted in, offer to restore — once. +// Scenarios assert observable outcomes: +// - prompts and restores only the items that have a remote copy + are not +// already enabled locally +// - declining does not restore +// - it never nags twice once it has prompted (per pubkey) +// - if nothing remote exists, it does NOT consume the one-time offer + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js"; + +const PUBKEY = "c".repeat(64); + +function makeSyncStub({ available = true, enabled = false, remote = false } = {}) { + return { + pulls: 0, + isAvailable: () => available, + isEnabled: () => enabled, + hasRemote: async () => remote, + pull: async function () { + this.pulls += 1; + return { found: true, imported: true }; + }, + }; +} + +test("offers and restores items that have a remote copy and aren't already enabled", async () => { + localStorage.clear(); + const storageSync = makeSyncStub({ remote: true }); + const walletSync = makeSyncStub({ remote: false }); // no wallet note + const restoredCalls = []; + const prompt = createSettingsRestorePrompt({ + storageSync, + walletSync, + confirm: () => true, + }); + + const result = await prompt.maybeOffer(PUBKEY, { + onRestored: (items) => restoredCalls.push(items), + }); + + assert.equal(result.offered, true); + assert.equal(result.accepted, true); + assert.deepEqual(result.restored, ["storage"]); + assert.equal(storageSync.pulls, 1, "storage (has remote) must be pulled"); + assert.equal(walletSync.pulls, 0, "wallet (no remote) must NOT be pulled"); + assert.deepEqual(restoredCalls, [["storage"]]); +}); + +test("declining the prompt restores nothing", async () => { + localStorage.clear(); + const storageSync = makeSyncStub({ remote: true }); + const prompt = createSettingsRestorePrompt({ + storageSync, + walletSync: makeSyncStub({ remote: false }), + confirm: () => false, + }); + + const result = await prompt.maybeOffer(PUBKEY); + assert.equal(result.offered, true); + assert.equal(result.accepted, false); + assert.equal(storageSync.pulls, 0, "decline must not pull"); +}); + +test("does not offer twice once it has prompted (per pubkey)", async () => { + localStorage.clear(); + let confirmCount = 0; + const prompt = createSettingsRestorePrompt({ + storageSync: makeSyncStub({ remote: true }), + walletSync: makeSyncStub({ remote: false }), + confirm: () => { + confirmCount += 1; + return false; + }, + }); + + await prompt.maybeOffer(PUBKEY); + const second = await prompt.maybeOffer(PUBKEY); + assert.equal(confirmCount, 1, "must prompt only once"); + assert.equal(second.offered, false); + assert.equal(second.reason, "already-offered"); +}); + +test("skips items already enabled on this device", async () => { + localStorage.clear(); + const storageSync = makeSyncStub({ remote: true, enabled: true }); // already opted in here + const walletSync = makeSyncStub({ remote: true, enabled: false }); + const prompt = createSettingsRestorePrompt({ + storageSync, + walletSync, + confirm: () => true, + }); + + const result = await prompt.maybeOffer(PUBKEY); + assert.deepEqual(result.restored, ["wallet"], "only the not-yet-enabled item is offered"); + assert.equal(storageSync.pulls, 0); + assert.equal(walletSync.pulls, 1); +}); + +test("nothing remote: does NOT consume the one-time offer (can prompt later)", async () => { + localStorage.clear(); + const storageSync = makeSyncStub({ remote: false }); + const walletSync = makeSyncStub({ remote: false }); + let confirmCount = 0; + const prompt = createSettingsRestorePrompt({ + storageSync, + walletSync, + confirm: () => { + confirmCount += 1; + return true; + }, + }); + + const first = await prompt.maybeOffer(PUBKEY); + assert.equal(first.offered, false); + assert.equal(first.reason, "no-remote"); + assert.equal(confirmCount, 0); + + // Later a note appears; the offer is still available. + storageSync.hasRemote = async () => true; + const later = await prompt.maybeOffer(PUBKEY); + assert.equal(later.offered, true); + assert.equal(later.accepted, true); + assert.deepEqual(later.restored, ["storage"]); +});