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 14:21:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 14:21:46 +0200
commit2f2a9a6542d2194e50ffba6f382e4fdffd481838 (patch)
tree5437330f9e8b963f8af0904f3c5d80d1afc6dc3b /packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
parent914d2f0af848fb80d70d517901f44420a4e47764 (diff)
downloadmeshbay-2f2a9a6542d2194e50ffba6f382e4fdffd481838.tar.gz
feat(android): one backup destination, a group the account owns alone
Chosen once at the top of Android Sync for every kind; photos and contacts go into <folder>/<account>-photos and -contacts. Owner and sole member are checked at set-up and before every run. 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.js71
1 files changed, 24 insertions, 47 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 77d050e..9c59c17 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
@@ -1,5 +1,6 @@
-// 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).
+// Photo backup: this phone's photos sent to `<folder>/<account>-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
@@ -14,6 +15,7 @@
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 —
@@ -34,6 +36,7 @@ const LASTING = {
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',
};
@@ -57,7 +60,7 @@ export function subscribe(fn) {
// ── When it runs ────────────────────────────────────────────────────────────
-let ctx = null; // { hub, getUser, getBundleKey }
+let ctx = null; // { hub, hubFetch, getUser, getBundleKey }
let running = null;
let timer = 0;
let lastAttempt = 0;
@@ -65,7 +68,7 @@ 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;
+ 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;
@@ -80,6 +83,7 @@ export function isDue(status, now = Date.now()) {
export function startPhotoSync(context) {
if (!platform.photoSync.available || ctx) return;
ctx = context;
+ setSyncContext(context);
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('meshbay-network', onNetwork);
runPhotoSync();
@@ -104,7 +108,7 @@ function onNetwork(e) {
function scheduleNext(status) {
clearTimeout(timer);
- if (!ctx || !status || !status.config) return;
+ 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
@@ -137,10 +141,10 @@ export function runPhotoSync({ force = false, metered = false } = {}) {
}
async function run(status, force, metered) {
- const cfg = status.config;
+ const dest = status.destination;
const user = ctx && ctx.getUser();
- if (!cfg) { setState({ phase: 'off' }); return; }
- if (!user || cfg.account !== user.username) { setState({ phase: 'other_account' }); return; }
+ 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 || '' });
@@ -151,6 +155,12 @@ async function run(status, force, metered) {
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 } = await platform.photoSync.plan();
if (!items.length) {
await platform.photoSync.completed();
@@ -163,7 +173,7 @@ async function run(status, force, metered) {
let conn;
try {
const bundleKey = await ctx.getBundleKey();
- conn = await pool.connect(cfg.groupId, user.token, bundleKey, user.username, user.userId);
+ 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; }
@@ -172,16 +182,16 @@ async function run(status, force, metered) {
}
try {
- await send(conn.transport, cfg, items, metered);
+ await send(conn.transport, dest, items, metered);
} finally {
platform.photoSync.keepAlive(false).catch(() => {});
pool.closeAll();
}
}
-async function send(transport, cfg, items, metered) {
+async function send(transport, dest, items, metered) {
const index = await transport.fetchIndex();
- const problem = destinationProblem(cfg.folder, index);
+ 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
@@ -214,7 +224,7 @@ async function send(transport, cfg, items, metered) {
await platform.photoSync.sent(item.token, item.dir, known);
} else {
try {
- await makeDirs(transport, cfg.folder, item.dir, dirs);
+ 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) {
@@ -252,7 +262,7 @@ export async function upload(transport, item, file = platform.photoSync.file) {
}
}
-/** `<folder>/YYYY/YYYY-MM`, one level at a time; a level that exists is fine. */
+/** `<folder>/<account>-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)) {
@@ -284,36 +294,3 @@ 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();
- }
-}