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.js378
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(() => {});
-}