aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
diff options
context:
space:
mode:
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.js316
1 files changed, 316 insertions, 0 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
new file mode 100644
index 0000000..154a009
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
@@ -0,0 +1,316 @@
+// 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. */
+async function upload(transport, item) {
+ 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 file = await platform.photoSync.file(item.token, item.name);
+ return await transport.uploadFile(file, {
+ dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr,
+ });
+ } finally {
+ lease.release();
+ }
+}
+
+/** `<folder>/YYYY/MM`, one level at a time; a level that exists is fine. */
+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();
+ }
+}