diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js | 378 |
1 files changed, 204 insertions, 174 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js index ffb07aa..1439c20 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js @@ -40,205 +40,239 @@ const LASTING = { permission: 'photosync.fail_permission', }; -// ── What the settings section shows ───────────────────────────────────────── +// ── A runner, for photos here and for chosen folders (drive-sync.js) ──────── -let state = { phase: 'idle', done: 0, total: 0 }; -const listeners = new Set(); +/** + * One backup's scheduling and sending: what is due, when, on which network, + * a file at a time under a slot, the ledger kept by the phone (`sync`, its + * bridge object). `configured(status)` says whether the person turned it on; + * `progress` is the notification line's key; `failures` holds the refusals + * that will hold tomorrow too, and `say(code)` the words for one. + */ +export function createBackup({ + name, sync, configured, progress, failures, say, +}) { + let state = { phase: 'idle', done: 0, total: 0 }; + const listeners = new Set(); + let ctx = null; // { hub, hubFetch, getUser, getBundleKey } + let running = null; + let timer = 0; + let lastAttempt = 0; + let unmeteredNow = null; -function setState(patch) { - state = { ...state, ...patch }; - for (const fn of [...listeners]) { - try { fn(state); } catch (e) { console.error('[MeshBay] photo backup listener threw:', e); } + // ── What the settings section shows ─────────────────────────────────────── + + function setState(patch) { + state = { ...state, ...patch }; + for (const fn of [...listeners]) { + try { fn(state); } catch (e) { console.error(`[MeshBay] ${name} backup listener threw:`, e); } + } } -} -export function subscribe(fn) { - listeners.add(fn); - fn(state); - return () => listeners.delete(fn); -} + function subscribe(fn) { + listeners.add(fn); + fn(state); + return () => listeners.delete(fn); + } -// ── When it runs ──────────────────────────────────────────────────────────── + // ── When it runs ────────────────────────────────────────────────────────── -let ctx = null; // { hub, hubFetch, getUser, getBundleKey } -let running = null; -let timer = 0; -let lastAttempt = 0; -let unmeteredNow = null; + /** True when `status` says a scheduled run should go now. */ + function isDue(status, now = Date.now()) { + if (!status || !configured(status) || !status.destination) return false; + // A lasting refusal waits a day, like a finished run; anything else is + // decided by the last run that finished. + if (status.failure && status.failureAt && now - status.failureAt < DAY_MS) return false; + const last = status.lastCompleted; + return last == null || now - last >= DAY_MS || now < last; + } -/** True when `status` says a scheduled run should go now. */ -export function isDue(status, now = Date.now()) { - if (!status || !status.config || !status.destination) return false; - // A lasting refusal waits a day, like a finished run; anything else is - // decided by the last run that finished. - if (status.failure && status.failureAt && now - status.failureAt < DAY_MS) return false; - const last = status.lastCompleted; - return last == null || now - last >= DAY_MS || now < last; -} + /** + * Checked at start, on coming back to the foreground, when the network turns + * unmetered, and once the next run falls due. Nothing polls. + */ + function start(context) { + if (!sync.available || ctx) return; + ctx = context; + setSyncContext(context); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('meshbay-network', onNetwork); + runNow(); + } -/** - * Checked at start, on coming back to the foreground, when the network turns - * unmetered, and once the next run falls due. Nothing polls. - */ -export function startPhotoSync(context) { - if (!platform.photoSync.available || ctx) return; - ctx = context; - setSyncContext(context); - document.addEventListener('visibilitychange', onVisible); - window.addEventListener('meshbay-network', onNetwork); - runPhotoSync(); -} + function stop() { + if (!ctx) return; + ctx = null; + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('meshbay-network', onNetwork); + } -export function stopPhotoSync() { - if (!ctx) return; - ctx = null; - clearTimeout(timer); - document.removeEventListener('visibilitychange', onVisible); - window.removeEventListener('meshbay-network', onNetwork); -} + function onVisible() { + if (document.visibilityState === 'visible') runNow(); + } -function onVisible() { - if (document.visibilityState === 'visible') runPhotoSync(); -} + function onNetwork(e) { + unmeteredNow = !!(e.detail && e.detail.unmetered); + if (unmeteredNow) runNow(); + } -function onNetwork(e) { - unmeteredNow = !!(e.detail && e.detail.unmetered); - if (unmeteredNow) runPhotoSync(); -} + function scheduleNext(status) { + clearTimeout(timer); + if (!ctx || !status || !configured(status) || !status.destination) return; + const base = Math.max(status.lastCompleted || 0, status.failure ? (status.failureAt || 0) : 0); + const wait = base + DAY_MS - Date.now(); + // A page left open past midnight runs on time; the cap keeps a timer from + // being parked for days on a clock that may move. + if (wait > 0) timer = setTimeout(runNow, Math.min(wait, DAY_MS)); + } -function scheduleNext(status) { - clearTimeout(timer); - if (!ctx || !status || !status.config || !status.destination) return; - const base = Math.max(status.lastCompleted || 0, status.failure ? (status.failureAt || 0) : 0); - const wait = base + DAY_MS - Date.now(); - // A page left open past midnight runs on time; the cap keeps a timer from - // being parked for days on a clock that may move. - if (wait > 0) timer = setTimeout(runPhotoSync, Math.min(wait, DAY_MS)); -} + /** + * A run, if one is due. `force` is "Back up now": due or not. `metered` is the + * person's yes to sending over mobile data, asked by the caller. + */ + function runNow({ force = false, metered = false } = {}) { + if (!ctx) return Promise.resolve(); + if (running) return running; + running = (async () => { + let status = null; + try { + status = await sync.status(); + await run(status, force, metered); + status = await sync.status(); + } catch (e) { + console.warn(`[MeshBay] ${name} backup:`, e.message); + setState({ phase: 'interrupted', detail: e.message }); + } finally { + running = null; + scheduleNext(status); + } + })(); + return running; + } -/** - * A run, if one is due. `force` is "Back up now": due or not. `metered` is the - * person's yes to sending over mobile data, asked by the caller. - */ -export function runPhotoSync({ force = false, metered = false } = {}) { - if (!ctx) return Promise.resolve(); - if (running) return running; - running = (async () => { - let status = null; - try { - status = await platform.photoSync.status(); - await run(status, force, metered); - status = await platform.photoSync.status(); - } catch (e) { - console.warn('[MeshBay] photo backup:', e.message); - setState({ phase: 'interrupted', detail: e.message }); - } finally { - running = null; - scheduleNext(status); + async function run(status, force, metered) { + const dest = status.destination; + const user = ctx && ctx.getUser(); + if (!configured(status) || !dest) { setState({ phase: 'off' }); return; } + if (!user || dest.account !== user.username) { setState({ phase: 'other_account' }); return; } + if (status.permission === 'denied') { await lasting('permission'); return; } + if (!force && !isDue(status)) { + setState({ phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' }); + return; } - })(); - return running; -} - -async function run(status, force, metered) { - const dest = status.destination; - const user = ctx && ctx.getUser(); - if (!status.config || !dest) { setState({ phase: 'off' }); return; } - if (!user || dest.account !== user.username) { setState({ phase: 'other_account' }); return; } - if (status.permission === 'denied') { await lasting('permission'); return; } - if (!force && !isDue(status)) { - setState({ phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' }); - return; - } - unmeteredNow = status.unmetered; - if (!status.unmetered && !metered) { setState({ phase: 'waiting_wifi' }); return; } - if (!force && Date.now() - lastAttempt < RETRY_MS) return; - lastAttempt = Date.now(); + unmeteredNow = status.unmetered; + if (!status.unmetered && !metered) { setState({ phase: 'waiting_wifi' }); return; } + if (!force && Date.now() - lastAttempt < RETRY_MS) return; + lastAttempt = Date.now(); - // Before anything is read: still this account's own group, and nobody else's. - setState({ phase: 'connecting', done: 0, total: 0 }); - const refused = await checkDestination(dest, user); - if (refused === 'offline') { setState({ phase: 'offline' }); return; } - if (refused) { await lasting(refused); return; } + // Before anything is read: still this account's own group, and nobody else's. + setState({ phase: 'connecting', done: 0, total: 0 }); + const refused = await checkDestination(dest, user); + if (refused === 'offline') { setState({ phase: 'offline' }); return; } + if (refused) { await lasting(refused); return; } - const { items } = await platform.photoSync.plan(); - if (!items.length) { - await platform.photoSync.completed(); - setState({ phase: 'up_to_date', done: 0, total: 0 }); - return; - } + const { items } = await sync.plan(); + if (!items.length) { + await sync.completed(); + setState({ phase: 'up_to_date', done: 0, total: 0 }); + return; + } - setState({ phase: 'connecting', done: 0, total: items.length }); - const pool = new ConnectionPool(ctx.hub); - let conn; - try { - const bundleKey = await ctx.getBundleKey(); - conn = await pool.connect(dest.groupId, user.token, bundleKey, user.username, user.userId); - } catch (e) { - pool.closeAll(); - if (e.reason === 'not_a_member') { await lasting('not_a_member'); return; } - setState({ phase: 'offline' }); - return; - } + setState({ phase: 'connecting', done: 0, total: items.length }); + const pool = new ConnectionPool(ctx.hub); + let conn; + try { + const bundleKey = await ctx.getBundleKey(); + conn = await pool.connect(dest.groupId, user.token, bundleKey, user.username, user.userId); + } catch (e) { + pool.closeAll(); + if (e.reason === 'not_a_member') { await lasting('not_a_member'); return; } + setState({ phase: 'offline' }); + return; + } - try { - await send(conn.transport, dest, items, metered); - } finally { - platform.photoSync.keepAlive(false).catch(() => {}); - pool.closeAll(); + try { + await send(conn.transport, dest, items, metered); + } finally { + sync.keepAlive(false).catch(() => {}); + pool.closeAll(); + } } -} -async function send(transport, dest, items, metered) { - const index = await transport.fetchIndex(); - const problem = destinationProblem(dest.folder, index); - if (problem) { await lasting(problem); return; } + async function send(transport, dest, items, metered) { + const index = await transport.fetchIndex(); + const problem = destinationProblem(dest.folder, index); + if (problem) { await lasting(problem); return; } - // What is already in each folder, by name and size: after a reinstall the - // ledger is empty, and a photo already there is recorded, not sent again. - const present = new Map(); - for (const e of index.entries || []) { - if (!present.has(e.path)) present.set(e.path, new Map()); - present.get(e.path).set(e.name, e.size); - } - const dirs = new Set(index.dirs || []); - for (const r of index.roots || []) dirs.add(r.name); + // What is already in each folder, by name and size: after a reinstall the + // ledger is empty, and a photo already there is recorded, not sent again. + const present = new Map(); + for (const e of index.entries || []) { + if (!present.has(e.path)) present.set(e.path, new Map()); + present.get(e.path).set(e.name, e.size); + } + const dirs = new Set(index.dirs || []); + for (const r of index.roots || []) dirs.add(r.name); - let done = 0; - const progress = () => { - setState({ phase: 'sending', done, total: items.length }); - return t('photosync.notif_progress', { done, total: items.length }); - }; - await platform.photoSync.keepAlive(true, progress()); + let done = 0; + const report = () => { + setState({ phase: 'sending', done, total: items.length }); + return t(progress, { done, total: items.length }); + }; + await sync.keepAlive(true, report()); - for (const item of items) { - // Off Wi-Fi mid-run: stop after the file in flight. The node keeps the - // partial upload, and the next unmetered moment carries on. - if (!metered && unmeteredNow === false) { setState({ phase: 'waiting_wifi' }); return; } - if (!ctx) return; + for (const item of items) { + // Off Wi-Fi mid-run: stop after the file in flight. The node keeps the + // partial upload, and the next unmetered moment carries on. + if (!metered && unmeteredNow === false) { setState({ phase: 'waiting_wifi' }); return; } + if (!ctx) return; - const there = present.get(item.dir); - const known = there && [item.name, item.alsoKnownAs] - .find((n) => n && there.get(n) === item.size); - if (known) { - await platform.photoSync.sent(item.token, item.dir, known); - } else { - try { - await makeDirs(transport, dest.folder, item.dir, dirs); - const ack = await upload(transport, item); - await platform.photoSync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name); - } catch (e) { - if (e.code && LASTING[e.code]) { await lasting(e.code); return; } - throw e; + const there = present.get(item.dir); + const known = there && [item.name, item.alsoKnownAs] + .find((n) => n && there.get(n) === item.size); + if (known) { + await sync.sent(item.token, item.dir, known); + } else { + try { + await makeDirs(transport, dest.folder, item.dir, dirs); + const ack = await upload(transport, item, sync.file); + await sync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name); + } catch (e) { + if (e.code && failures[e.code]) { await lasting(e.code); return; } + throw e; + } } + done += 1; + await sync.keepAlive(true, report()); } - done += 1; - await platform.photoSync.keepAlive(true, progress()); + await sync.completed(); + setState({ phase: 'up_to_date', done, total: items.length }); + } + + async function lasting(code) { + setState({ phase: 'failed', reason: code }); + await sync.failed(code, say(code)).catch(() => {}); } - await platform.photoSync.completed(); - setState({ phase: 'up_to_date', done, total: items.length }); + + return { start, stop, run: runNow, subscribe, isDue }; } +const photos = createBackup({ + name: 'photo', + sync: platform.photoSync, + configured: (status) => Boolean(status.config), + progress: 'photosync.notif_progress', + failures: LASTING, + say: (code) => t(LASTING[code] || 'photosync.fail_other'), +}); + +export const startPhotoSync = (context) => photos.start(context); +export const stopPhotoSync = () => photos.stop(); +export const runPhotoSync = (opts) => photos.run(opts); +export const subscribe = (fn) => photos.subscribe(fn); +export const isDue = (status, now) => photos.isDue(status, now); + /** * One photo, under a slot the node granted: the backup queues like anyone. * `file` is how its bytes come from the phone; contacts and messages pass theirs. @@ -290,7 +324,3 @@ export function destinationProblem(folder, index) { return null; } -async function lasting(code) { - setState({ phase: 'failed', reason: code }); - await platform.photoSync.failed(code, t(LASTING[code] || 'photosync.fail_other')).catch(() => {}); -} |