aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 15:50:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 15:50:39 +0200
commit1c97ffb91bb4928022e995709d7e55b19cfd18eb (patch)
treeaaa05d17adcd6db4256c01ba79b27dfc7ec96d70 /packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
parent1521cc1ac0cca7ee8786056f84fa399d5cc268f9 (diff)
downloadmeshbay-1c97ffb91bb4928022e995709d7e55b19cfd18eb.tar.gz
feat(android): back up the files of folders the person chooses
A Files section takes folders through the system picker (no storage permission), refuses DCIM, Pictures and Movies, skips what the photo backup sends, and runs on the photo backup's own runner into <folder>/<account>-drive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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(() => {});
-}