1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
|
// Contacts, calendar and messages backup: a copy of this phone's address book
// and calendars, and the text messages added since the last copy, sent to
// `<folder>/<account>-contacts`, `-calendar` and `-messages` of the destination
// every backup shares (sync-destination.js), once a day when there is
// something new (docs/MESHBAY_DESIGN.md §9.13).
//
// The same split as photos (photo-sync.js): the phone writes what is new into
// one file and remembers what the node took; this module decides when a run
// is due and does the sending. Loaded only where the bridge offers it, the
// Android application.
//
// Additive by construction, like photos: every copy is a new dated file, and
// nothing here deletes, renames or replaces anything on the node.
// `test_phone_sync.py` reads this file for the operations that would.
import { ConnectionPool } from './connection-pool.js';
import * as platform from './platform.js';
import { t } from './i18n.js';
import { destinationProblem, makeDirs, upload } from './photo-sync.js';
import { setSyncContext, checkDestination } from './sync-destination.js';
const DAY_MS = 24 * 3600 * 1000;
// A run cut short by something passing is tried again at the next chance,
// but not more often than this.
const RETRY_MS = 15 * 60 * 1000;
/**
* Each kind of data: the bridge object that reads it, its heading, and the
* folder it goes into under the chosen one (`<account>-<suffix>`, as the
* phone names it in DocPlan.kt).
*/
export const KINDS = {
contacts: { sync: platform.contactSync, heading: 'contactsync.heading', suffix: 'contacts' },
calendar: { sync: platform.calendarSync, heading: 'calendarsync.heading', suffix: 'calendar' },
messages: { sync: platform.messageSync, heading: 'messagesync.heading', suffix: 'messages' },
};
// 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.
export const LASTING = {
disk_full: 'phonesync.fail_disk_full',
root_read_only: 'phonesync.fail_read_only',
no_writable_root: 'phonesync.fail_read_only',
no_such_root: 'phonesync.fail_no_folder',
no_such_directory: 'phonesync.fail_no_folder',
root_unavailable: 'phonesync.fail_unavailable',
not_a_member: 'phonesync.fail_not_member',
not_private: 'phonesync.fail_not_private',
permission: 'phonesync.fail_permission',
};
export function failureText(code) {
return t(LASTING[code] || 'phonesync.fail_other');
}
// ── What the sections show ──────────────────────────────────────────────────
const states = {};
const listeners = {};
function setState(kind, patch) {
states[kind] = { ...(states[kind] || { phase: 'idle' }), ...patch };
for (const fn of [...(listeners[kind] || [])]) {
try { fn(states[kind]); } catch (e) { console.error('[MeshBay] backup listener threw:', e); }
}
}
export function subscribe(kind, fn) {
(listeners[kind] = listeners[kind] || new Set()).add(fn);
fn(states[kind] || { phase: 'idle' });
return () => listeners[kind].delete(fn);
}
// ── When it runs ────────────────────────────────────────────────────────────
let ctx = null; // { hub, hubFetch, getUser, getBundleKey }
const running = {};
const timers = {};
const lastAttempt = {};
function available() {
return Object.keys(KINDS).filter((k) => KINDS[k].sync.available);
}
/** True when `status` says a scheduled run should go now. */
export function isDue(status, now = Date.now()) {
if (!status || !status.on || !status.destination) return false;
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, and once the next run falls due. */
export function startPhoneSync(context) {
if (ctx || !available().length) return;
ctx = context;
setSyncContext(context);
document.addEventListener('visibilitychange', onVisible);
for (const kind of available()) runDocSync(kind);
}
export function stopPhoneSync() {
if (!ctx) return;
ctx = null;
for (const kind of Object.keys(timers)) clearTimeout(timers[kind]);
document.removeEventListener('visibilitychange', onVisible);
}
function onVisible() {
if (document.visibilityState !== 'visible') return;
for (const kind of available()) runDocSync(kind);
}
function scheduleNext(kind, status) {
clearTimeout(timers[kind]);
if (!ctx || !status || !status.on || !status.destination) return;
const base = Math.max(status.lastCompleted || 0, status.failure ? (status.failureAt || 0) : 0);
const wait = base + DAY_MS - Date.now();
if (wait > 0) timers[kind] = setTimeout(() => runDocSync(kind), Math.min(wait, DAY_MS));
}
/** A run of one kind, if one is due; `force` is "Back up now". */
export function runDocSync(kind, { force = false } = {}) {
if (!ctx || !KINDS[kind]) return Promise.resolve();
if (running[kind]) return running[kind];
const sync = KINDS[kind].sync;
running[kind] = (async () => {
let status = null;
try {
status = await sync.status();
await run(kind, sync, status, force);
status = await sync.status();
} catch (e) {
console.warn(`[MeshBay] ${kind} backup:`, e.message);
setState(kind, { phase: 'interrupted', detail: e.message });
} finally {
running[kind] = null;
scheduleNext(kind, status);
}
})();
return running[kind];
}
async function run(kind, sync, status, force) {
const cfg = status.destination;
const user = ctx && ctx.getUser();
if (!status.on || !cfg) { setState(kind, { phase: 'off' }); return; }
if (!user || cfg.account !== user.username) { setState(kind, { phase: 'other_account' }); return; }
if (status.permission !== 'granted') { await lasting(kind, sync, 'permission'); return; }
if (!force && !isDue(status)) {
setState(kind, { phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' });
return;
}
if (!force && Date.now() - (lastAttempt[kind] || 0) < RETRY_MS) return;
lastAttempt[kind] = Date.now();
// Before anything is read: still this account's own group, and nobody else's.
setState(kind, { phase: 'connecting' });
const refused = await checkDestination(cfg, user);
if (refused === 'offline') { setState(kind, { phase: 'offline' }); return; }
if (refused) { await lasting(kind, sync, refused); return; }
const { item } = await sync.plan();
if (!item) {
await sync.completed();
setState(kind, { phase: 'up_to_date' });
return;
}
const pool = new ConnectionPool(ctx.hub);
let conn;
try {
const bundleKey = await ctx.getBundleKey();
conn = await pool.connect(cfg.groupId, user.token, bundleKey, user.username, user.userId);
} catch (e) {
pool.closeAll();
if (e.reason === 'not_a_member') { await lasting(kind, sync, 'not_a_member'); return; }
setState(kind, { phase: 'offline' });
return;
}
try {
const index = await conn.transport.fetchIndex();
const problem = destinationProblem(cfg.folder, index);
if (problem) { await lasting(kind, sync, problem); return; }
const dirs = new Set(index.dirs || []);
for (const r of index.roots || []) dirs.add(r.name);
setState(kind, { phase: 'sending', count: item.count });
try {
await makeDirs(conn.transport, cfg.folder, item.dir, dirs);
const ack = await upload(conn.transport, item, (token, name) => sync.file(token, name));
await sync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name);
} catch (e) {
if (e.code && LASTING[e.code]) { await lasting(kind, sync, e.code); return; }
throw e;
}
await sync.completed();
setState(kind, { phase: 'up_to_date' });
} finally {
pool.closeAll();
}
}
async function lasting(kind, sync, code) {
setState(kind, { phase: 'failed', reason: code });
const text = t('phonesync.notice', { what: t(KINDS[kind].heading), reason: failureText(code) });
await sync.failed(code, text).catch(() => {});
}
|