// Photo backup: this phone's photos sent to `/-photos` of the // destination every backup shares (sync-destination.js), once a day, on an // unmetered network (docs/MESHBAY_DESIGN.md §9.12). // // The phone (platform.photoSync) lists the photos, keeps the ledger of what was // sent and hands each photo's bytes over; this module decides when a run is // due and does the sending, because the transport and the group key are here. // Loaded only where the bridge offers it — the Android application — so no // browser and no desktop pays for it. // // Additive by construction: nothing here deletes, renames or replaces anything // on the node. `test_photo_sync.py` reads this file for the operations that // would, and refuses them. import { ConnectionPool } from './connection-pool.js'; import * as platform from './platform.js'; import { t } from './i18n.js'; import { setSyncContext, checkDestination } from './sync-destination.js'; const DAY_MS = 24 * 3600 * 1000; // A run cut short by something passing — the node offline, the network gone — // is tried again at the next chance, but not more often than this: every // return to the foreground would otherwise be a connection attempt. const RETRY_MS = 15 * 60 * 1000; // A slot the node keeps queued this long means it is busy with others; the // backup can wait for tomorrow's run, they cannot. const LEASE_WAIT_MS = 10 * 60 * 1000; // Refusals that will hold at the next run too. Said once (a notification, from // the phone) and retried a day later, never at every opening. const LASTING = { disk_full: 'photosync.fail_disk_full', root_read_only: 'photosync.fail_read_only', no_writable_root: 'photosync.fail_read_only', no_such_root: 'photosync.fail_no_folder', no_such_directory: 'photosync.fail_no_folder', root_unavailable: 'photosync.fail_unavailable', not_a_member: 'photosync.fail_not_member', not_private: 'photosync.fail_not_private', permission: 'photosync.fail_permission', }; // ── A runner, for photos here and for chosen folders (drive-sync.js) ──────── /** * 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; // ── 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); } } } function subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn); } // ── When it runs ────────────────────────────────────────────────────────── /** 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; } /** * 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(); } function stop() { if (!ctx) return; ctx = null; clearTimeout(timer); document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('meshbay-network', onNetwork); } function onVisible() { if (document.visibilityState === 'visible') runNow(); } function onNetwork(e) { unmeteredNow = !!(e.detail && e.detail.unmetered); if (unmeteredNow) runNow(); } 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)); } /** * 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; } 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; } 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; } const { items, manifest } = await sync.plan(); if (!items.length && !manifest) { 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; } 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; } // 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 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; 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()); } if (!await sendManifest(transport, dest, dirs)) return; await sync.completed(); setState({ phase: 'up_to_date', done, total: items.length }); } // What the phone knew of what was sent (its path and album, its dates and // hash), for a restore or a merge later: one file per run that sent // anything, beside the files, kept by the phone until the node has it. // False when a lasting refusal stopped it, which is then said. async function sendManifest(transport, dest, dirs) { const { item } = await sync.manifest(); if (!item) return true; try { await makeDirs(transport, dest.folder, item.dir, dirs); await upload(transport, item, sync.file); } catch (e) { if (e.code && failures[e.code]) { await lasting(e.code); return false; } throw e; } await sync.manifestSent(item.token); return true; } async function lasting(code) { setState({ phase: 'failed', reason: code }); await sync.failed(code, say(code)).catch(() => {}); } 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. */ export async function upload(transport, item, file = platform.photoSync.file) { const lease = transport.openTransfer({ kind: 'upload', bytes: item.size }); try { let timeout; await Promise.race([ lease.acquire(), new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('The node is busy')), LEASE_WAIT_MS); }), ]).finally(() => clearTimeout(timeout)); const bytes = await file(item.token, item.name, item.size); return await transport.uploadFile(bytes, { dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr, }); } finally { lease.release(); } } /** `/-photos/YYYY/YYYY-MM`, one level at a time; a level that exists is fine. */ export async function makeDirs(transport, folder, dir, dirs) { let at = folder; for (const name of dir.slice(folder.length + 1).split('/').filter(Boolean)) { const next = `${at}/${name}`; if (!dirs.has(next)) { try { await transport.createDirectory(at, name); } catch (e) { // Somebody else's backup made it a moment ago: what was wanted. if (!/already exists/i.test(e.message || '')) throw e; } dirs.add(next); } at = next; } } /** A reason the chosen folder cannot take photos now, or null. */ export function destinationProblem(folder, index) { const root = (index.roots || []).find((r) => r.name === folder.split('/')[0]); if (!root) return 'no_such_root'; if (!root.writable) return 'root_read_only'; if (root.available === false) return 'root_unavailable'; if (folder !== root.name && !(index.dirs || []).includes(folder)) return 'no_such_directory'; return null; }