// Photo backup: this phone's photos sent to one folder of one group, 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'; 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', permission: 'photosync.fail_permission', }; // ── What the settings section shows ───────────────────────────────────────── let state = { phase: 'idle', done: 0, total: 0 }; const listeners = new Set(); function setState(patch) { state = { ...state, ...patch }; for (const fn of [...listeners]) { try { fn(state); } catch (e) { console.error('[MeshBay] photo backup listener threw:', e); } } } export function subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn); } // ── When it runs ──────────────────────────────────────────────────────────── let ctx = null; // { hub, getUser, getBundleKey } let running = null; let timer = 0; let lastAttempt = 0; let unmeteredNow = null; /** True when `status` says a scheduled run should go now. */ export function isDue(status, now = Date.now()) { if (!status || !status.config) 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. */ export function startPhotoSync(context) { if (!platform.photoSync.available || ctx) return; ctx = context; document.addEventListener('visibilitychange', onVisible); window.addEventListener('meshbay-network', onNetwork); runPhotoSync(); } 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') runPhotoSync(); } function onNetwork(e) { unmeteredNow = !!(e.detail && e.detail.unmetered); if (unmeteredNow) runPhotoSync(); } function scheduleNext(status) { clearTimeout(timer); if (!ctx || !status || !status.config) 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. */ 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); } })(); return running; } async function run(status, force, metered) { const cfg = status.config; const user = ctx && ctx.getUser(); if (!cfg) { setState({ phase: 'off' }); return; } if (!user || cfg.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(); const { items } = await platform.photoSync.plan(); if (!items.length) { await platform.photoSync.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(cfg.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, cfg, items, metered); } finally { platform.photoSync.keepAlive(false).catch(() => {}); pool.closeAll(); } } async function send(transport, cfg, items, metered) { const index = await transport.fetchIndex(); const problem = destinationProblem(cfg.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 progress = () => { setState({ phase: 'sending', done, total: items.length }); return t('photosync.notif_progress', { done, total: items.length }); }; await platform.photoSync.keepAlive(true, progress()); 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, cfg.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; } } done += 1; await platform.photoSync.keepAlive(true, progress()); } await platform.photoSync.completed(); setState({ phase: 'up_to_date', done, total: items.length }); } /** * 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); return await transport.uploadFile(bytes, { dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr, }); } finally { lease.release(); } } /** `/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; } async function lasting(code) { setState({ phase: 'failed', reason: code }); await platform.photoSync.failed(code, t(LASTING[code] || 'photosync.fail_other')).catch(() => {}); } // ── For the settings section ──────────────────────────────────────────────── /** * The folders of a group this member can back up into: writable, available, * and shown by the group's Photos tab — a folder outside those would take the * photos and show them nowhere, which reads as a backup that failed. */ export function backupFolders(ack, index) { const photoDirs = (ack && ack.photo_directories) || []; const roots = new Map((index.roots || []) .filter((r) => r.writable && r.available !== false).map((r) => [r.name, r])); const under = (d) => photoDirs.some((p) => d === p || d.startsWith(`${p}/`)); const all = new Set([...(index.dirs || []), ...roots.keys()]); return [...all] .filter((d) => roots.has(d.split('/')[0]) && under(d)) .sort((a, b) => a.localeCompare(b)); } /** A one-off look at a group, for choosing a folder: connected, read, closed. */ export async function probeGroup(groupId) { const user = ctx && ctx.getUser(); if (!user) throw new Error('Not signed in'); const pool = new ConnectionPool(ctx.hub); try { const bundleKey = await ctx.getBundleKey(); const conn = await pool.connect(groupId, user.token, bundleKey, user.username, user.userId); const index = await conn.transport.fetchIndex(); return { folders: backupFolders(conn.ack, index) }; } finally { pool.closeAll(); } }