Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
169 lines
6.3 KiB
JavaScript
169 lines
6.3 KiB
JavaScript
const pull = require('../server/node_modules/pull-stream');
|
|
const Abortable = require('../server/node_modules/pull-abortable');
|
|
|
|
const JOB_TTL_MS = 6 * 60 * 60 * 1000;
|
|
const PROBE_TIMEOUT_MS = 8000;
|
|
const RECHECK_MS = 15000;
|
|
|
|
module.exports = ({ cooler }) => {
|
|
const jobs = new Map();
|
|
let recheckTimer = null;
|
|
|
|
const openSsb = () => cooler.open();
|
|
const withTimeout = (p, ms) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))]);
|
|
|
|
const peerRpcs = (ssb) => {
|
|
const out = [];
|
|
const live = ssb && ssb.peers && typeof ssb.peers === 'object' ? ssb.peers : {};
|
|
for (const id of Object.keys(live)) {
|
|
if (id === ssb.id) continue;
|
|
for (const rpc of (Array.isArray(live[id]) ? live[id] : [])) {
|
|
if (rpc && rpc.blobs && typeof rpc.blobs.get === 'function' && !rpc.closed) out.push({ id, rpc });
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const hasLocal = (ssb, blobId) => new Promise((resolve) => {
|
|
try { ssb.blobs.has(blobId, (err, has) => resolve(!err && !!has)); } catch (_) { resolve(false); }
|
|
});
|
|
|
|
const remoteHas = (peer, blobId) => withTimeout(new Promise((resolve) => {
|
|
try { peer.rpc.blobs.has(blobId, (err, has) => resolve(!err && !!has)); } catch (_) { resolve(false); }
|
|
}), PROBE_TIMEOUT_MS).catch(() => false);
|
|
|
|
const findSeeds = async (ssb, blobId) => {
|
|
const peers = peerRpcs(ssb);
|
|
const flags = await Promise.all(peers.map((p) => remoteHas(p, blobId)));
|
|
return peers.filter((_, i) => flags[i]);
|
|
};
|
|
|
|
const fetchFrom = (ssb, seed, job) => new Promise((resolve, reject) => {
|
|
const abortable = Abortable();
|
|
job.abort = () => abortable.abort(new Error('cancelled'));
|
|
let received = 0;
|
|
const cap = job.size > 0 ? job.size : 75 * 1024 * 1024;
|
|
const opts = { key: job.blobId, max: cap };
|
|
pull(
|
|
seed.rpc.blobs.get(opts),
|
|
abortable,
|
|
pull.through((buf) => { received += buf.length; job.received = received; if (received > cap) abortable.abort(new Error('too large')); }),
|
|
ssb.blobs.add(job.blobId, (err) => { job.abort = null; err ? reject(err) : resolve(received); })
|
|
);
|
|
});
|
|
|
|
const finish = (job, state, error) => {
|
|
job.state = state;
|
|
job.error = error ? String(error.message || error) : '';
|
|
job.finishedAt = Date.now();
|
|
job.abort = null;
|
|
if (state === 'done' && job.size > 0) job.received = job.size;
|
|
};
|
|
|
|
const ensureRecheck = () => {
|
|
if (recheckTimer) return;
|
|
recheckTimer = setInterval(async () => {
|
|
const pending = Array.from(jobs.values()).filter((j) => j.state === 'nosource');
|
|
if (!pending.length) { clearInterval(recheckTimer); recheckTimer = null; return; }
|
|
let ssb;
|
|
try { ssb = await openSsb(); } catch (_) { return; }
|
|
for (const job of pending) {
|
|
if (await hasLocal(ssb, job.blobId)) { finish(job, 'done'); continue; }
|
|
const seeds = await findSeeds(ssb, job.blobId);
|
|
if (seeds.length) run(job);
|
|
}
|
|
}, RECHECK_MS);
|
|
if (recheckTimer.unref) recheckTimer.unref();
|
|
};
|
|
|
|
const run = async (job) => {
|
|
if (job.state === 'downloading' || job.state === 'probing') return;
|
|
let ssb;
|
|
try { ssb = await openSsb(); } catch (err) { finish(job, 'failed', err); return; }
|
|
if (await hasLocal(ssb, job.blobId)) { finish(job, 'done'); return; }
|
|
job.state = 'probing';
|
|
job.error = '';
|
|
const seeds = await findSeeds(ssb, job.blobId);
|
|
job.seeds = seeds.map((s) => s.id);
|
|
if (!seeds.length) {
|
|
job.state = 'nosource';
|
|
try { ssb.blobs.want(job.blobId, () => {}); } catch (_) {}
|
|
ensureRecheck();
|
|
return;
|
|
}
|
|
job.state = 'downloading';
|
|
job.startedAt = job.startedAt || Date.now();
|
|
let lastErr = null;
|
|
for (const seed of seeds) {
|
|
if (job.cancelled) { finish(job, 'cancelled'); return; }
|
|
job.peer = seed.id;
|
|
job.received = 0;
|
|
try {
|
|
await fetchFrom(ssb, seed, job);
|
|
finish(job, 'done');
|
|
return;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
if (job.cancelled) { finish(job, 'cancelled'); return; }
|
|
}
|
|
}
|
|
finish(job, 'failed', lastErr);
|
|
};
|
|
|
|
const sweep = () => {
|
|
const now = Date.now();
|
|
for (const [key, job] of jobs) if (job.finishedAt && now - job.finishedAt > JOB_TTL_MS) jobs.delete(key);
|
|
};
|
|
|
|
const snapshot = (job) => ({
|
|
blobId: job.blobId,
|
|
name: job.name,
|
|
size: job.size,
|
|
received: Math.min(job.received || 0, job.size || job.received || 0),
|
|
state: job.state,
|
|
seeds: Array.isArray(job.seeds) ? job.seeds.length : 0,
|
|
peer: job.peer || '',
|
|
error: job.error || '',
|
|
torrentKey: job.torrentKey || '',
|
|
startedAt: job.startedAt || 0,
|
|
finishedAt: job.finishedAt || 0,
|
|
active: job.state === 'queued' || job.state === 'probing' || job.state === 'downloading' || job.state === 'nosource'
|
|
});
|
|
|
|
return {
|
|
start({ blobId, size, name, torrentKey } = {}) {
|
|
const id = String(blobId || '');
|
|
if (!id.startsWith('&')) throw new Error('torrentFromContentMissing');
|
|
sweep();
|
|
let job = jobs.get(id);
|
|
if (job && (job.state === 'downloading' || job.state === 'probing' || job.state === 'done')) return snapshot(job);
|
|
job = { blobId: id, size: Number(size) || 0, name: String(name || ''), torrentKey: torrentKey || '', received: 0, state: 'queued', seeds: [], error: '', cancelled: false, startedAt: 0, finishedAt: 0, abort: null, peer: '' };
|
|
jobs.set(id, job);
|
|
run(job);
|
|
return snapshot(job);
|
|
},
|
|
cancel(blobId) {
|
|
const job = jobs.get(String(blobId || ''));
|
|
if (!job) return false;
|
|
job.cancelled = true;
|
|
if (typeof job.abort === 'function') { try { job.abort(); } catch (_) {} }
|
|
else if (job.state !== 'done') finish(job, 'cancelled');
|
|
return true;
|
|
},
|
|
remove(blobId) { return jobs.delete(String(blobId || '')); },
|
|
get(blobId) {
|
|
const job = jobs.get(String(blobId || ''));
|
|
return job ? snapshot(job) : null;
|
|
},
|
|
list() {
|
|
sweep();
|
|
return Array.from(jobs.values()).map(snapshot).sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
|
|
},
|
|
async hasLocal(blobId) {
|
|
try { return await hasLocal(await openSsb(), String(blobId || '')); } catch (_) { return false; }
|
|
},
|
|
async seedsFor(blobId) {
|
|
try { return (await findSeeds(await openSsb(), String(blobId || ''))).map((s) => s.id); } catch (_) { return []; }
|
|
}
|
|
};
|
|
};
|