aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js48
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js122
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js378
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js62
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py12
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py8
-rw-r--r--packages/meshbay-hub/tests/test_drive_sync.py154
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py1
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py8
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py4
24 files changed, 735 insertions, 221 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
index b4d85d0..af59605 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
@@ -11,6 +11,8 @@ import { ask } from './ask.js';
import * as platform from './platform.js';
import { PhotoSyncSection } from './photo-sync-settings.js';
import { DocSyncSection } from './phone-sync-settings.js';
+import { DriveSyncSection } from './drive-sync-settings.js';
+import { runDriveSync } from './drive-sync.js';
import { runPhotoSync } from './photo-sync.js';
import { runDocSync, KINDS } from './phone-sync.js';
import {
@@ -22,6 +24,7 @@ function kindDirs(folder, account) {
const suffixes = [
...(platform.photoSync.available ? ['photos'] : []),
...Object.values(KINDS).filter((k) => k.sync.available).map((k) => k.suffix),
+ ...(platform.driveSync.available ? ['drive'] : []),
];
return suffixes.map((s) => `${folder}/${account}-${s}`);
}
@@ -42,6 +45,7 @@ export function AndroidSyncPage({ user, groups }) {
// Each kind that is on starts again in the new place, now.
if (platform.photoSync.available) runPhotoSync();
for (const kind of Object.keys(KINDS)) if (KINDS[kind].sync.available) runDocSync(kind);
+ if (platform.driveSync.available) runDriveSync();
};
return html`
@@ -52,6 +56,8 @@ export function AndroidSyncPage({ user, groups }) {
<${PhotoSyncSection} key=${'photos' + place} destination=${mine} />`}
${Object.keys(KINDS).filter((k) => KINDS[k].sync.available).map((kind) => html`
<${DocSyncSection} key=${kind + place} kind=${kind} destination=${mine} />`)}
+ ${platform.driveSync.available && html`
+ <${DriveSyncSection} key=${'drive' + place} destination=${mine} />`}
</div>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 031e276..d8818d0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -919,6 +919,19 @@ function App() {
pullOnce(user.userId).catch(() => {});
}, [user, groups.length]);
+ // What every phone backup runs with: the live user and bundle key, read
+ // when a run starts, never ones captured here.
+ const backupContext = () => ({
+ hub: HUB,
+ hubFetch,
+ getUser: () => userRef.current,
+ getBundleKey: async () => {
+ const key = session.bundleKey || await _loadBundleKey();
+ if (key) session.bundleKey = key;
+ return key;
+ },
+ });
+
// Photo backup (docs/MESHBAY_DESIGN.md §9.12), on the Android application
// only: loaded and started once signed in, stopped on sign-out. A run reads
// the live user and bundle key when it starts, never ones captured here.
@@ -929,21 +942,25 @@ function App() {
let gone = false;
import('./photo-sync.js').then((m) => {
if (gone) return;
- m.startPhotoSync({
- hub: HUB,
- hubFetch,
- getUser: () => userRef.current,
- getBundleKey: async () => {
- const key = session.bundleKey || await _loadBundleKey();
- if (key) session.bundleKey = key;
- return key;
- },
- });
+ m.startPhotoSync(backupContext());
stop = m.stopPhotoSync;
}).catch((e) => console.warn('[MeshBay] photo backup did not load:', e.message));
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
+ // Files backup (§9.13), the same way.
+ useEffect(() => {
+ if (!syncAccount || !platform.driveSync.available) return undefined;
+ let stop = null;
+ let gone = false;
+ import('./drive-sync.js').then((m) => {
+ if (gone) return;
+ m.startDriveSync(backupContext());
+ stop = m.stopDriveSync;
+ }).catch((e) => console.warn('[MeshBay] files backup did not load:', e.message));
+ return () => { gone = true; if (stop) stop(); };
+ }, [syncAccount]);
+
// Contacts, calendar and messages backup (§9.13), the same way.
useEffect(() => {
if (!syncAccount || !(platform.contactSync.available || platform.calendarSync.available
@@ -954,16 +971,7 @@ function App() {
let gone = false;
import('./phone-sync.js').then((m) => {
if (gone) return;
- m.startPhoneSync({
- hub: HUB,
- hubFetch,
- getUser: () => userRef.current,
- getBundleKey: async () => {
- const key = session.bundleKey || await _loadBundleKey();
- if (key) session.bundleKey = key;
- return key;
- },
- });
+ m.startPhoneSync(backupContext());
stop = m.stopPhoneSync;
}).catch((e) => console.warn('[MeshBay] phone backups did not load:', e.message));
return () => { gone = true; if (stop) stop(); };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js
new file mode 100644
index 0000000..3fbc65e
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js
@@ -0,0 +1,122 @@
+// The files section of the Android Sync page, on the Android application only
+// (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other client has it.
+// Where the files go is the page's destination section; this one chooses the
+// folders, through the system's own picker.
+
+import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ask } from './ask.js';
+import * as platform from './platform.js';
+import { subscribe, runDriveSync } from './drive-sync.js';
+import { failureText } from './phone-sync.js';
+
+// What the phone says when it refuses a folder, in the person's words.
+const REFUSED = {
+ media_folder: 'drivesync.refused_media',
+ whole_storage: 'drivesync.refused_root',
+};
+
+function refusal(message) {
+ const code = /Refused: (\w+)/.exec(message || '');
+ return code && REFUSED[code[1]] ? t(REFUSED[code[1]]) : message;
+}
+
+function stateLine(st, status) {
+ switch (st.phase) {
+ case 'waiting_wifi': return t('photosync.state_waiting_wifi');
+ case 'connecting': return t('photosync.state_connecting');
+ case 'sending': return t('photosync.state_sending', { done: st.done, total: st.total });
+ case 'offline': return t('photosync.state_offline');
+ case 'interrupted': return t('photosync.state_interrupted');
+ case 'up_to_date': return t('photosync.state_up_to_date');
+ default: return status && status.lastCompleted ? t('photosync.state_up_to_date') : '';
+ }
+}
+
+export function DriveSyncSection({ destination }) {
+ const sync = platform.driveSync;
+ const [status, setStatus] = useState(null);
+ const [run, setRun] = useState({ phase: 'idle' });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]);
+ useEffect(() => { refresh(); }, [refresh]);
+ useEffect(() => subscribe((st) => {
+ setRun(st);
+ if (st.phase !== 'sending' && st.phase !== 'connecting') refresh();
+ }), [refresh]);
+
+ if (!status) return null;
+ const folders = status.folders || [];
+ const running = run.phase === 'sending' || run.phase === 'connecting';
+
+ const act = async (fn) => {
+ setBusy(true);
+ setError('');
+ try { const next = await fn(); if (next) setStatus(next); } catch (e) { setError(refusal(e.message)); }
+ finally { setBusy(false); }
+ };
+
+ const backUpNow = () => act(async () => {
+ if (!status.unmetered && !await ask(t('drivesync.metered_confirm'))) return null;
+ await runDriveSync({ force: true, metered: !status.unmetered });
+ return null;
+ });
+
+ return html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('drivesync.heading')}</h3>
+ ${!status.on && html`<p class="settings-hint">${t('drivesync.intro')}</p>`}
+ ${!destination && html`<p class="settings-hint">${t('androidsync.dest_needed')}</p>`}
+ ${destination && html`
+ <p class="settings-label" style="margin-top:6px">${t('drivesync.folders')}</p>
+ ${!folders.length && html`<p class="settings-hint">${t('drivesync.no_folders')}</p>`}
+ ${folders.map((f) => html`
+ <div class="settings-row" key=${f.id}>
+ <span class="settings-label settings-name">${f.name}</span>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.removeFolder(f.id))}>${t('drivesync.remove')}</button>
+ </div>
+ `)}
+ <p class="settings-hint">${t('drivesync.folders_hint')}</p>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.addFolder())}>${t('drivesync.add_folder')}</button>
+ `}
+ ${destination && !status.on && html`
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; margin-top:8px">
+ <button class="admin-btn" disabled=${busy || !folders.length}
+ onClick=${() => act(async () => {
+ const next = await sync.configure({});
+ runDriveSync();
+ return next;
+ })}>${t('phonesync.turn_on')}</button>
+ </div>
+ `}
+ ${destination && status.on && html`
+ <div class="settings-row" style="margin-top:8px">
+ <span class="settings-label settings-name">
+ ${t('phonesync.summary', { group: destination.groupName, dir: status.dir })}
+ </span>
+ </div>
+ <p class="settings-hint">
+ ${t('drivesync.sent_total', { n: status.sent })}
+ ${' · '}
+ ${status.lastCompleted
+ ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
+ : t('photosync.never')}
+ </p>
+ ${status.failure
+ ? html`<p class="error-msg">${failureText(status.failure)}</p>`
+ : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`}
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap">
+ <button class="admin-btn" disabled=${running || busy || !folders.length}
+ onClick=${backUpNow}>${t('photosync.now')}</button>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.configure(null))}>${t('photosync.turn_off')}</button>
+ </div>
+ `}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js
new file mode 100644
index 0000000..5ddf188
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js
@@ -0,0 +1,29 @@
+// Files backup: the folders the person chose through the system's picker,
+// sent into `<folder>/<account>-drive/<chosen folder>/…` of the destination
+// every backup shares (sync-destination.js), once a day, on an unmetered
+// network, like photos (docs/MESHBAY_DESIGN.md §9.13).
+//
+// The phone (platform.driveSync) lists the files, leaves out what the photo
+// backup sends, keeps the ledger and hands each file over a range at a time;
+// the running is the photo backup's own (createBackup). Additive by
+// construction: a new version of a file goes beside the one sent, and nothing
+// here deletes, renames or replaces anything on the node.
+
+import * as platform from './platform.js';
+import { t } from './i18n.js';
+import { createBackup } from './photo-sync.js';
+import { LASTING, failureText } from './phone-sync.js';
+
+const drive = createBackup({
+ name: 'files',
+ sync: platform.driveSync,
+ configured: (status) => Boolean(status.on) && (status.folders || []).length > 0,
+ progress: 'drivesync.notif_progress',
+ failures: LASTING,
+ say: (code) => t('phonesync.notice', { what: t('drivesync.heading'), reason: failureText(code) }),
+});
+
+export const startDriveSync = (context) => drive.start(context);
+export const stopDriveSync = () => drive.stop();
+export const runDriveSync = (opts) => drive.run(opts);
+export const subscribe = (fn) => drive.subscribe(fn);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 38cadef..59c7869 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1372,6 +1372,18 @@ export default {
'phonesync.fail_not_private': "Gestoppt: Jemand anderes ist der Gruppe beigetreten.",
'phonesync.fail_permission': "Gestoppt: MeshBay hat keinen Zugriff mehr.",
'phonesync.fail_other': "Gestoppt.",
+ 'drivesync.heading': "Datei-Sicherung",
+ 'drivesync.intro': "Die Dateien der Ordner, die du auf diesem Telefon auswählst, einmal täglich über WLAN senden. Eine auf dem Telefon geänderte Datei wird neben der früheren Kopie erneut gesendet; nichts wird je ersetzt.",
+ 'drivesync.folders': "Gesicherte Ordner",
+ 'drivesync.no_folders': "Noch kein Ordner ausgewählt.",
+ 'drivesync.folders_hint': "Fotos und Videos übernimmt die Foto-Sicherung: DCIM, Pictures und Movies können nicht gewählt werden. Android lässt eine App nicht den ganzen Download-Ordner nehmen, nur Ordner darin.",
+ 'drivesync.add_folder': "Ordner hinzufügen",
+ 'drivesync.remove': "Entfernen",
+ 'drivesync.refused_media': "Dieser Ordner enthält Fotos und Videos, die die Foto-Sicherung sendet: Wähle einen anderen.",
+ 'drivesync.refused_root': "Android erlaubt nicht, einen ganzen Speicher zu wählen: Wähle einen Ordner darin.",
+ 'drivesync.notif_progress': "Dateien werden gesichert: {done} / {total}",
+ 'drivesync.metered_confirm': "Du bist im Mobilfunknetz. Die Dateien trotzdem senden?",
+ 'drivesync.sent_total': "Gesendete Dateien: {n}",
'androidsync.dest_heading': "Ziel der Sicherungen",
'androidsync.dest_intro': "Sicherungen landen in einem Ordner einer Gruppe, die dir gehört und deren einziges Mitglied du bist. Jede Art von Sicherung bekommt dort einen eigenen Ordner.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 9dff21d..ce2b246 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1353,6 +1353,18 @@ export default {
'phonesync.fail_not_private': "Stopped: somebody else has joined the group.",
'phonesync.fail_permission': "Stopped: MeshBay no longer has access.",
'phonesync.fail_other': "Stopped.",
+ 'drivesync.heading': "Files backup",
+ 'drivesync.intro': "Send the files of folders you choose on this phone, once a day, on Wi-Fi. A file changed on the phone is sent again beside the earlier copy; nothing is ever replaced.",
+ 'drivesync.folders': "Folders backed up",
+ 'drivesync.no_folders': "No folder chosen yet.",
+ 'drivesync.folders_hint': "Photos and videos are left to the photo backup: DCIM, Pictures and Movies cannot be chosen. Android does not let an application take the whole of Download, only folders inside it.",
+ 'drivesync.add_folder': "Add a folder",
+ 'drivesync.remove': "Remove",
+ 'drivesync.refused_media': "This folder holds photos and videos, which the photo backup sends: choose another one.",
+ 'drivesync.refused_root': "Android does not let a whole storage be chosen: choose a folder inside it.",
+ 'drivesync.notif_progress': "Backing up files: {done} / {total}",
+ 'drivesync.metered_confirm': "You are on mobile data. Send the files anyway?",
+ 'drivesync.sent_total': "Files sent: {n}",
'androidsync.dest_heading': "Where backups go",
'androidsync.dest_intro': "Backups go into a folder of a group you own and are the only member of. Each kind of backup gets its own folder there.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 6f4252a..b9324e2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1366,6 +1366,18 @@ export default {
'phonesync.fail_not_private': "Detenida: otra persona se ha unido al grupo.",
'phonesync.fail_permission': "Detenida: MeshBay ya no tiene acceso.",
'phonesync.fail_other': "Detenida.",
+ 'drivesync.heading': "Copia de seguridad de archivos",
+ 'drivesync.intro': "Enviar los archivos de las carpetas que elijas en este teléfono, una vez al día, por Wi-Fi. Un archivo modificado en el teléfono se envía de nuevo junto a la copia anterior; nunca se reemplaza nada.",
+ 'drivesync.folders': "Carpetas con copia",
+ 'drivesync.no_folders': "Aún no has elegido ninguna carpeta.",
+ 'drivesync.folders_hint': "Las fotos y los vídeos son cosa de la copia de fotos: no se pueden elegir DCIM, Pictures ni Movies. Android no deja que una aplicación tome todo Download, solo carpetas dentro.",
+ 'drivesync.add_folder': "Añadir una carpeta",
+ 'drivesync.remove': "Quitar",
+ 'drivesync.refused_media': "Esta carpeta contiene fotos y vídeos, que envía la copia de fotos: elige otra.",
+ 'drivesync.refused_root': "Android no permite elegir un almacenamiento entero: elige una carpeta dentro.",
+ 'drivesync.notif_progress': "Copia de archivos: {done} / {total}",
+ 'drivesync.metered_confirm': "Estás usando datos móviles. ¿Enviar los archivos de todos modos?",
+ 'drivesync.sent_total': "Archivos enviados: {n}",
'androidsync.dest_heading': "Destino de las copias de seguridad",
'androidsync.dest_intro': "Las copias de seguridad van a una carpeta de un grupo del que eres propietario y único miembro. Cada tipo de copia tiene allí su propia carpeta.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 65956ae..8fa4f40 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1381,6 +1381,18 @@ export default {
'phonesync.fail_not_private': "Arrêtée : quelqu'un d'autre a rejoint le groupe.",
'phonesync.fail_permission': "Arrêtée : MeshBay n'y a plus accès.",
'phonesync.fail_other': "Arrêtée.",
+ 'drivesync.heading': "Sauvegarde des fichiers",
+ 'drivesync.intro': "Envoyer les fichiers des dossiers que vous choisissez sur ce téléphone, une fois par jour, en Wi-Fi. Un fichier modifié sur le téléphone est renvoyé à côté de la copie précédente ; rien n'est jamais remplacé.",
+ 'drivesync.folders': "Dossiers sauvegardés",
+ 'drivesync.no_folders': "Aucun dossier choisi pour l'instant.",
+ 'drivesync.folders_hint': "Les photos et vidéos relèvent de la sauvegarde des photos : DCIM, Pictures et Movies ne peuvent pas être choisis. Android ne laisse pas une appli prendre tout Download, seulement des dossiers à l'intérieur.",
+ 'drivesync.add_folder': "Ajouter un dossier",
+ 'drivesync.remove': "Retirer",
+ 'drivesync.refused_media': "Ce dossier contient des photos et vidéos, que la sauvegarde des photos envoie : choisissez-en un autre.",
+ 'drivesync.refused_root': "Android ne permet pas de choisir un stockage entier : choisissez un dossier à l'intérieur.",
+ 'drivesync.notif_progress': "Sauvegarde des fichiers : {done} / {total}",
+ 'drivesync.metered_confirm': "Vous êtes en données mobiles. Envoyer les fichiers quand même ?",
+ 'drivesync.sent_total': "Fichiers envoyés : {n}",
'androidsync.dest_heading': "Destination des sauvegardes",
'androidsync.dest_intro': "Les sauvegardes vont dans un dossier d'un groupe dont vous êtes le propriétaire et le seul membre. Chaque type de sauvegarde y a son propre dossier.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index a37d65c..013f014 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1380,6 +1380,18 @@ export default {
'phonesync.fail_not_private': "Interrotto: qualcun altro si è unito al gruppo.",
'phonesync.fail_permission': "Interrotto: MeshBay non ha più accesso.",
'phonesync.fail_other': "Interrotto.",
+ 'drivesync.heading': "Backup dei file",
+ 'drivesync.intro': "Invia i file delle cartelle che scegli su questo telefono, una volta al giorno, in Wi-Fi. Un file modificato sul telefono viene inviato di nuovo accanto alla copia precedente; non viene mai sostituito nulla.",
+ 'drivesync.folders': "Cartelle salvate",
+ 'drivesync.no_folders': "Nessuna cartella scelta per ora.",
+ 'drivesync.folders_hint': "Foto e video spettano al backup delle foto: DCIM, Pictures e Movies non si possono scegliere. Android non lascia che un'app prenda tutto Download, solo le cartelle al suo interno.",
+ 'drivesync.add_folder': "Aggiungi una cartella",
+ 'drivesync.remove': "Rimuovi",
+ 'drivesync.refused_media': "Questa cartella contiene foto e video, che invia il backup delle foto: scegline un'altra.",
+ 'drivesync.refused_root': "Android non permette di scegliere un'intera memoria: scegli una cartella al suo interno.",
+ 'drivesync.notif_progress': "Backup dei file: {done} / {total}",
+ 'drivesync.metered_confirm': "Stai usando i dati mobili. Inviare comunque i file?",
+ 'drivesync.sent_total': "File inviati: {n}",
'androidsync.dest_heading': "Destinazione dei backup",
'androidsync.dest_intro': "I backup vanno in una cartella di un gruppo di cui sei proprietario e unico membro. Ogni tipo di backup vi ha la sua cartella.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 4910630..6950d4e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1364,6 +1364,18 @@ export default {
'phonesync.fail_not_private': "停止しました: 他の人がグループに参加しました。",
'phonesync.fail_permission': "停止しました: MeshBay はアクセスできなくなりました。",
'phonesync.fail_other': "停止しました。",
+ 'drivesync.heading': "ファイルのバックアップ",
+ 'drivesync.intro': "このスマートフォンで選んだフォルダーのファイルを、1日1回 Wi-Fi で送ります。スマートフォンで変更したファイルは、以前のコピーの横にもう一度送られます。何も置き換えられません。",
+ 'drivesync.folders': "バックアップするフォルダー",
+ 'drivesync.no_folders': "まだフォルダーが選ばれていません。",
+ 'drivesync.folders_hint': "写真と動画は写真のバックアップが扱います。DCIM、Pictures、Movies は選べません。Android ではアプリが Download 全体を選ぶことはできず、その中のフォルダーだけを選べます。",
+ 'drivesync.add_folder': "フォルダーを追加",
+ 'drivesync.remove': "削除",
+ 'drivesync.refused_media': "このフォルダーには写真のバックアップが送る写真や動画が入っています。別のフォルダーを選んでください。",
+ 'drivesync.refused_root': "Android ではストレージ全体を選べません。その中のフォルダーを選んでください。",
+ 'drivesync.notif_progress': "ファイルをバックアップ中: {done} / {total}",
+ 'drivesync.metered_confirm': "モバイルデータ通信中です。それでもファイルを送りますか?",
+ 'drivesync.sent_total': "送信したファイル: {n}",
'androidsync.dest_heading': "バックアップの保存先",
'androidsync.dest_intro': "バックアップは、あなたが所有者で唯一のメンバーであるグループのフォルダーに保存されます。バックアップの種類ごとに専用のフォルダーが作られます。",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 603f69d..1327e77 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1382,6 +1382,18 @@ export default {
'phonesync.fail_not_private': "Gestopt: iemand anders is lid geworden van de groep.",
'phonesync.fail_permission': "Gestopt: MeshBay heeft geen toegang meer.",
'phonesync.fail_other': "Gestopt.",
+ 'drivesync.heading': "Back-up van bestanden",
+ 'drivesync.intro': "Stuur de bestanden van mappen die je op deze telefoon kiest, eenmaal per dag, via wifi. Een bestand dat op de telefoon is gewijzigd, wordt opnieuw verstuurd naast de eerdere kopie; er wordt nooit iets vervangen.",
+ 'drivesync.folders': "Geback-upte mappen",
+ 'drivesync.no_folders': "Nog geen map gekozen.",
+ 'drivesync.folders_hint': "Foto's en video's zijn voor de fotoback-up: DCIM, Pictures en Movies kunnen niet gekozen worden. Android laat een app niet de hele map Download nemen, alleen mappen daarin.",
+ 'drivesync.add_folder': "Map toevoegen",
+ 'drivesync.remove': "Verwijderen",
+ 'drivesync.refused_media': "Deze map bevat foto's en video's, die de fotoback-up verstuurt: kies een andere.",
+ 'drivesync.refused_root': "Android laat niet toe een hele opslag te kiezen: kies een map daarin.",
+ 'drivesync.notif_progress': "Bestanden back-uppen: {done} / {total}",
+ 'drivesync.metered_confirm': "Je gebruikt mobiele data. De bestanden toch versturen?",
+ 'drivesync.sent_total': "Verzonden bestanden: {n}",
'androidsync.dest_heading': "Bestemming van back-ups",
'androidsync.dest_intro': "Back-ups gaan naar een map van een groep waarvan jij eigenaar en het enige lid bent. Elk soort back-up krijgt daar een eigen map.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 52b6a25..ee858e5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1410,6 +1410,18 @@ export default {
'phonesync.fail_not_private': "Zatrzymano: ktoś inny dołączył do grupy.",
'phonesync.fail_permission': "Zatrzymano: MeshBay nie ma już dostępu.",
'phonesync.fail_other': "Zatrzymano.",
+ 'drivesync.heading': "Kopia plików",
+ 'drivesync.intro': "Wysyłaj pliki z folderów wybranych w tym telefonie raz dziennie przez Wi-Fi. Plik zmieniony w telefonie jest wysyłany ponownie obok wcześniejszej kopii; nic nie jest nigdy zastępowane.",
+ 'drivesync.folders': "Kopiowane foldery",
+ 'drivesync.no_folders': "Nie wybrano jeszcze żadnego folderu.",
+ 'drivesync.folders_hint': "Zdjęcia i filmy należą do kopii zdjęć: nie można wybrać DCIM, Pictures ani Movies. Android nie pozwala aplikacji wziąć całego folderu Download, tylko foldery w nim.",
+ 'drivesync.add_folder': "Dodaj folder",
+ 'drivesync.remove': "Usuń",
+ 'drivesync.refused_media': "Ten folder zawiera zdjęcia i filmy, które wysyła kopia zdjęć: wybierz inny.",
+ 'drivesync.refused_root': "Android nie pozwala wybrać całej pamięci: wybierz folder w niej.",
+ 'drivesync.notif_progress': "Kopia plików: {done} / {total}",
+ 'drivesync.metered_confirm': "Używasz danych komórkowych. Wysłać pliki mimo to?",
+ 'drivesync.sent_total': "Wysłane pliki: {n}",
'androidsync.dest_heading': "Miejsce kopii zapasowych",
'androidsync.dest_intro': "Kopie trafiają do folderu grupy, której jesteś właścicielem i jedynym członkiem. Każdy rodzaj kopii ma tam własny folder.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 0371dfe..e2c7860 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1367,6 +1367,18 @@ export default {
'phonesync.fail_not_private': "Parado: outra pessoa entrou no grupo.",
'phonesync.fail_permission': "Parado: o MeshBay não tem mais acesso.",
'phonesync.fail_other': "Parado.",
+ 'drivesync.heading': "Backup de arquivos",
+ 'drivesync.intro': "Enviar os arquivos das pastas que você escolher neste celular, uma vez por dia, pelo Wi-Fi. Um arquivo alterado no celular é enviado de novo ao lado da cópia anterior; nada é substituído.",
+ 'drivesync.folders': "Pastas com backup",
+ 'drivesync.no_folders': "Nenhuma pasta escolhida ainda.",
+ 'drivesync.folders_hint': "Fotos e vídeos ficam com o backup de fotos: DCIM, Pictures e Movies não podem ser escolhidas. O Android não deixa um app pegar toda a pasta Download, só pastas dentro dela.",
+ 'drivesync.add_folder': "Adicionar uma pasta",
+ 'drivesync.remove': "Remover",
+ 'drivesync.refused_media': "Esta pasta contém fotos e vídeos, que o backup de fotos envia: escolha outra.",
+ 'drivesync.refused_root': "O Android não permite escolher um armazenamento inteiro: escolha uma pasta dentro dele.",
+ 'drivesync.notif_progress': "Backup de arquivos: {done} / {total}",
+ 'drivesync.metered_confirm': "Você está usando dados móveis. Enviar os arquivos mesmo assim?",
+ 'drivesync.sent_total': "Arquivos enviados: {n}",
'androidsync.dest_heading': "Destino dos backups",
'androidsync.dest_intro': "Os backups vão para uma pasta de um grupo do qual você é dono e único membro. Cada tipo de backup tem a sua própria pasta lá.",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 7dd09bf..303a09d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1353,6 +1353,18 @@ export default {
'phonesync.fail_not_private': "已停止:有其他人加入了群组。",
'phonesync.fail_permission': "已停止:MeshBay 已无法访问。",
'phonesync.fail_other': "已停止。",
+ 'drivesync.heading': "文件备份",
+ 'drivesync.intro': "每天一次通过 Wi-Fi 发送你在本机选择的文件夹中的文件。在手机上修改过的文件会在之前的副本旁边再次发送;不会替换任何内容。",
+ 'drivesync.folders': "已备份的文件夹",
+ 'drivesync.no_folders': "尚未选择文件夹。",
+ 'drivesync.folders_hint': "照片和视频由照片备份负责:不能选择 DCIM、Pictures 和 Movies。Android 不允许应用选择整个 Download 文件夹,只能选择其中的文件夹。",
+ 'drivesync.add_folder': "添加文件夹",
+ 'drivesync.remove': "移除",
+ 'drivesync.refused_media': "这个文件夹包含照片备份会发送的照片和视频:请选择其他文件夹。",
+ 'drivesync.refused_root': "Android 不允许选择整个存储空间:请选择其中的文件夹。",
+ 'drivesync.notif_progress': "正在备份文件:{done} / {total}",
+ 'drivesync.metered_confirm': "你正在使用移动数据。仍要发送这些文件吗?",
+ 'drivesync.sent_total': "已发送文件:{n}",
'androidsync.dest_heading': "备份保存位置",
'androidsync.dest_intro': "备份会保存到一个由你创建且只有你一个成员的群组的文件夹中。每种备份在那里都有自己的文件夹。",
'androidsync.dest_summary': "{group} → {folder}",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
index 4e9245a..dcfb6f9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
@@ -37,7 +37,7 @@ export const KINDS = {
// 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 = {
+export const LASTING = {
disk_full: 'phonesync.fail_disk_full',
root_read_only: 'phonesync.fail_read_only',
no_writable_root: 'phonesync.fail_read_only',
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(() => {});
-}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 8effc21..04e26e6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -247,26 +247,48 @@ export const photoSync = {
completed() { return bridge.photoSync.completed(); },
failed(code, text) { return bridge.photoSync.failed(code, text); },
keepAlive(on, text) { return bridge.photoSync.keepAlive(on, text); },
- /**
- * A photo or video the last plan issued, as what an upload reads (`name`,
- * `size`, `slice(a, b).arrayBuffer()`): each slice is fetched from the phone
- * when it is read, so a video of gigabytes is never whole in this page.
- */
- file(token, name, size) {
- const url = `/photosync/${encodeURIComponent(token)}`;
- const part = (start, end) => ({
- async arrayBuffer() {
- if (end <= start) return new ArrayBuffer(0);
- const r = await fetch(url, { cache: 'no-store', headers: { Range: `bytes=${start}-${end - 1}` } });
- if (r.status !== 206) throw new Error('This photo is no longer on the phone');
- return r.arrayBuffer();
- },
- });
- return {
- name, size,
- slice: (a = 0, b = size) => part(Math.max(0, a), Math.min(b, size)),
- };
- },
+ file: (token, name, size) => rangedFile('/photosync/', token, name, size),
+};
+
+/**
+ * A file the phone issued under `token`, as what an upload reads (`name`,
+ * `size`, `slice(a, b).arrayBuffer()`): each slice is fetched from the phone
+ * when it is read, so a video of gigabytes is never whole in this page.
+ */
+function rangedFile(prefix, token, name, size) {
+ const url = `${prefix}${encodeURIComponent(token)}`;
+ const part = (start, end) => ({
+ async arrayBuffer() {
+ if (end <= start) return new ArrayBuffer(0);
+ const r = await fetch(url, { cache: 'no-store', headers: { Range: `bytes=${start}-${end - 1}` } });
+ if (r.status !== 206) throw new Error('This file is no longer on the phone');
+ return r.arrayBuffer();
+ },
+ });
+ return {
+ name, size,
+ slice: (a = 0, b = size) => part(Math.max(0, a), Math.min(b, size)),
+ };
+}
+
+/**
+ * Backing up folders the person chose through the system's picker
+ * (docs/MESHBAY_DESIGN.md §9.13), to the destination every kind shares.
+ * Android only, like `photoSync`. See drive-sync.js.
+ */
+const driveBridge = bridge && bridge.driveSync;
+export const driveSync = {
+ available: Boolean(driveBridge),
+ status() { return driveBridge.status(); },
+ addFolder() { return driveBridge.addFolder(); },
+ removeFolder(id) { return driveBridge.removeFolder(id); },
+ configure(settings) { return driveBridge.configure(settings); },
+ plan() { return driveBridge.plan(); },
+ sent(token, dir, name) { return driveBridge.sent(token, dir, name); },
+ completed() { return driveBridge.completed(); },
+ failed(code, text) { return driveBridge.failed(code, text); },
+ keepAlive(on, text) { return driveBridge.keepAlive(on, text); },
+ file: (token, name, size) => rangedFile('/drivesync/', token, name, size),
};
/**
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
index 6c1a4d6..2a75749 100644
--- a/packages/meshbay-hub/tests/harness/photo_sync_probe.py
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -106,6 +106,18 @@ window.meshbay = {
permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }),
sent: async () => true, completed: async () => ({}), failed: async () => true,
},
+ driveSync: {
+ status: async () => ({
+ on: MODE === 'configured', unmetered: true, destination: dest,
+ dir: dest && '__FOLDER__/bob-drive', sent: 31, lastCompleted: Date.now(), failure: null,
+ folders: [{ id: 't1', name: 'Documents' },
+ { id: 't2', name: 'Scans_from_the_office_printer_with_a_very_long_name_2026' }],
+ }),
+ addFolder: async () => { throw new Error('Refused: media_folder'); },
+ removeFolder: async () => ({}), configure: async () => ({}), plan: async () => ({ items: [] }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ keepAlive: async () => true,
+ },
messageSync: {
status: async () => ({
permission: 'granted', on: MODE === 'configured', destination: dest,
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index b5b0fa0..9275d67 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -136,7 +136,8 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
native = set()
for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt",
- SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt"):
+ SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt",
+ SRC / "drive" / "DriveChannels.kt"):
native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
# Contacts and messages share one class, its channels named by a prefix.
docs = set(re.findall(r'^\s*"(\w+)" ->', _read(SRC / "phonesync" / "DocChannels.kt"), flags=re.M))
@@ -148,7 +149,7 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
# address book to back up, and a desktop has none of them; those families are the ones the desktop lacks
# rather than the ones it shares.
phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:",
- "calendarsync:", "messagesync:"))}
+ "calendarsync:", "messagesync:", "drivesync:"))}
assert any(c.startswith("push:") for c in phone_only), "the push channels are gone"
assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone"
assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
@@ -267,9 +268,10 @@ def test_only_the_personal_profile_is_backed_up():
assert "Profiles.isPersonal(this)" in activity and "const BACKUP = $backups;" in activity
shim = _strip_js_comments(_read(SHIM))
gated = shim.split("...(BACKUP ? {", 1)[1].split("} : {}),", 1)[0]
- for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:"):
+ for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:", "driveSync:"):
assert name in gated, name
for path in [*(SRC / "phonesync").rglob("*.kt"), *(SRC / "photos").rglob("*.kt"),
+ *(SRC / "drive").rglob("*.kt"),
*(APP / "src" / "full").rglob("*.kt")]:
assert not re.search(r"\.ENTERPRISE_\w+", _read(path)), path.name
diff --git a/packages/meshbay-hub/tests/test_drive_sync.py b/packages/meshbay-hub/tests/test_drive_sync.py
new file mode 100644
index 0000000..d46ac66
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_drive_sync.py
@@ -0,0 +1,154 @@
+"""
+The phone's files backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.13).
+
+Run under Node against the real `drive-sync.js`, which is the photo backup's
+own runner (`createBackup` in photo-sync.js) over the files bridge: the same
+promises hold, once a day, on an unmetered network, each file under a slot
+and read from the phone a range at a time, nothing removed from the node,
+and only into a group the account owns alone.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SYNC = STATIC / "drive-sync.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SYNC.exists(),
+ reason="node or the SPA sources are not available")
+
+FAKE_POOL = r"""
+export class ConnectionPool {
+ async connect(groupId) {
+ globalThis.fake.log.push(['connect', groupId]);
+ return { transport: globalThis.fake.transport, ack: {} };
+ }
+ closeAll() { globalThis.fake.log.push(['close']); }
+}
+"""
+
+PRELUDE = r"""
+const fake = globalThis.fake = { log: [], uploadError: null, members: [{ user_id: 'u-bob' }] };
+const win = new EventTarget();
+const doc = new EventTarget();
+doc.visibilityState = 'visible';
+globalThis.window = win;
+globalThis.document = doc;
+globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
+globalThis.fetch = async (url, opts) => {
+ const range = opts && opts.headers && opts.headers.Range;
+ fake.log.push(['fetch', url, range || null]);
+ const m = /bytes=(\d+)-(\d+)/.exec(range || '');
+ return { ok: true, status: m ? 206 : 200,
+ arrayBuffer: async () => new ArrayBuffer(m ? Number(m[2]) - Number(m[1]) + 1 : 0) };
+};
+fake.status = {
+ on: true, unmetered: true, sent: 0, now: Date.now(),
+ destination: { account: 'bob', groupId: 'g1', groupName: 'Mine', folder: 'Backups' },
+ dir: 'Backups/bob-drive', folders: [{ id: 'tree1', name: 'Documents' }],
+ lastCompleted: null, failure: null, failureAt: null,
+};
+fake.items = [
+ { token: 'd1', name: 'notes.txt', dir: 'Backups/bob-drive/Documents', size: 6, edited: false },
+ { token: 'd2', name: 'tax.pdf', dir: 'Backups/bob-drive/Documents/Tax/2026', size: 12, edited: false },
+];
+fake.index = { roots: [{ name: 'Backups', writable: true, available: true }], dirs: [], entries: [] };
+fake.transport = {
+ async fetchIndex() { return fake.index; },
+ async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; },
+ openTransfer({ bytes }) {
+ return { tr: 'tr-' + bytes, acquire: async () => {}, release: () => fake.log.push(['release', bytes]) };
+ },
+ async uploadFile(file, opts) {
+ fake.log.push(['upload', opts.dir, file.name]);
+ if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; }
+ await file.slice(0, file.size).arrayBuffer();
+ return { stored_as: file.name, dir: opts.dir };
+ },
+};
+const hubFetch = async () => ({ admin_id: 'u-bob', members: fake.members, invited: [] });
+win.meshbay = { driveSync: {
+ status: async () => fake.status,
+ plan: async () => { fake.log.push(['plan']); return { items: fake.items }; },
+ sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; },
+ completed: async () => { fake.log.push(['completed']); fake.status.lastCompleted = Date.now(); return fake.status; },
+ failed: async (code, text) => { fake.log.push(['failed', code, text]); return true; },
+ keepAlive: async (on) => { fake.log.push(['keep', on]); return true; },
+} };
+"""
+
+
+def _run(body, tmp_path):
+ root = tmp_path / "static"
+ shutil.copytree(STATIC, root)
+ (root / "package.json").write_text('{"type":"module"}', encoding="utf-8")
+ (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8")
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ PRELUDE
+ + f"const m = await import('{(root / 'drive-sync.js').as_uri()}');\n"
+ + "const ctx = { hub: 'https://hub.invalid', hubFetch, getUser: () => ({ username: 'bob', "
+ "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n"
+ + body
+ + "\nm.stopDriveSync();\nconsole.log(JSON.stringify(fake.log));\n",
+ encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ encoding="utf-8", timeout=30)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+def _start(extra=""):
+ return extra + "\nm.startDriveSync(ctx);\nawait m.runDriveSync();\n"
+
+
+def _of(log, kind):
+ return [e[1:] for e in log if e[0] == kind]
+
+
+def test_a_due_run_sends_each_file_into_its_folder_a_range_at_a_time(tmp_path):
+ log = _run(_start(), tmp_path)
+ assert _of(log, "mkdir") == [["Backups/bob-drive"], ["Backups/bob-drive/Documents"],
+ ["Backups/bob-drive/Documents/Tax"],
+ ["Backups/bob-drive/Documents/Tax/2026"]]
+ assert _of(log, "fetch") == [["/drivesync/d1", "bytes=0-5"], ["/drivesync/d2", "bytes=0-11"]]
+ assert _of(log, "sent") == [["d1", "Backups/bob-drive/Documents", "notes.txt"],
+ ["d2", "Backups/bob-drive/Documents/Tax/2026", "tax.pdf"]]
+ assert ["completed"] in log
+
+
+def test_on_mobile_data_a_scheduled_run_waits(tmp_path):
+ log = _run(_start("fake.status.unmetered = false;"), tmp_path)
+ assert ["plan"] not in log
+
+
+def test_no_folder_chosen_means_nothing_runs(tmp_path):
+ log = _run(_start("fake.status.folders = [];"), tmp_path)
+ assert ["plan"] not in log and _of(log, "connect") == []
+
+
+def test_a_group_with_somebody_else_in_it_stops_it_before_anything_is_read(tmp_path):
+ log = _run(_start("fake.members.push({ user_id: 'u-carol' });"), tmp_path)
+ assert [e[0] for e in _of(log, "failed")] == ["not_private"]
+ assert ["plan"] not in log
+
+
+def test_a_lasting_refusal_names_the_files_backup(tmp_path):
+ log = _run(_start("fake.uploadError = 'disk_full';"), tmp_path)
+ failed = _of(log, "failed")
+ assert [e[0] for e in failed] == ["disk_full"] and failed[0][1]
+ assert ["completed"] not in log
+
+
+def test_the_backup_has_no_way_to_remove_anything_on_the_node():
+ for path in (SYNC, STATIC / "photo-sync.js"):
+ src = path.read_text(encoding="utf-8")
+ for op in ("deleteFile", "deleteDirectory", "removeRoot", "file_delete", "dir_delete",
+ "root_remove", "rename"):
+ assert not re.search(rf"\b{op}\b", src), f"{path.name} reaches {op}"
diff --git a/packages/meshbay-hub/tests/test_first_load_is_lean.py b/packages/meshbay-hub/tests/test_first_load_is_lean.py
index 84f5179..854d755 100644
--- a/packages/meshbay-hub/tests/test_first_load_is_lean.py
+++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py
@@ -27,6 +27,7 @@ ON_DEMAND = {
# The Android application's backups: no browser or desktop has them.
"photo-sync.js", "photo-sync-settings.js", "android-sync-page.js",
"phone-sync.js", "phone-sync-settings.js", "sync-destination.js",
+ "drive-sync.js", "drive-sync-settings.js",
}
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 6dddcc3..3a01e53 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -56,7 +56,7 @@ STATIC_FILES = [
"ask.js",
# The Android application's backups, on their own page loaded on demand.
"settings-page.js", "android-sync-page.js", "photo-sync-settings.js",
- "phone-sync-settings.js",
+ "phone-sync-settings.js", "drive-sync-settings.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
index 8cc9417..b3ab59e 100644
--- a/packages/meshbay-hub/tests/test_photo_sync_ui.py
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -57,7 +57,7 @@ def test_limited_access_is_said_rather_than_failing_quietly(probe):
def test_where_backups_go_lists_each_kinds_folder(probe):
text = probe["dest360"]["text"]
- assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages"))
+ assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages", "drive"))
def test_photos_the_photos_tab_will_not_show_are_said(probe):
@@ -79,3 +79,9 @@ def test_the_calendar_backup_says_where_its_copies_go(probe):
def test_videos_are_offered_and_off_by_default(probe):
assert "Videos too" in probe["setup360"]["text"]
assert "Videos too" not in probe["setup360"]["checked"]
+
+
+def test_the_files_backup_lists_its_folders_and_what_it_leaves_out(probe):
+ text = probe["configured360"]["text"]
+ assert "Documents" in text and "Scans_from_the_office_printer" in text
+ assert "DCIM, Pictures and Movies cannot be chosen" in text
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index bb39966..0f0edb8 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -295,7 +295,9 @@ def test_no_setter_survives_the_state_it_belonged_to():
# `useState` behind it by design. Without this the rule reports every
# such helper, and a rule that cries wolf is one someone eventually
# silences.
- defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M))
+ # Nested too: a runner built by a factory (createBackup) keeps its
+ # own `setState` inside it.
+ defined = set(re.findall(r"^\s*(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M))
defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M))
# A setter named in a `function Component({ ..., setX, ... })` prop
# list is handed down from wherever it is really declared.