mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-13 16:19:48 +00:00
689 lines
23 KiB
TypeScript
689 lines
23 KiB
TypeScript
/**
|
|
* Files on this computer: the engine for every root of the signed-in accounts. It keeps each
|
|
* account's FileNode tree current (a listing once, then FileNode/changes on push and on a slow
|
|
* poll), runs a RootSync per root when either side changed, and answers the drive's fetches.
|
|
*
|
|
* Main only does the disk (lib/desktop/types.ts FilesControls); everything that decides is here,
|
|
* in the page, with the app's own JMAP session. The host (host.ts) starts it in the main window
|
|
* and tells it which accounts are signed in.
|
|
*
|
|
* When a pass runs:
|
|
* - main's `onChange` for a root: soon after, so a burst of writes is one pass. `paths: null` is
|
|
* what a new, resumed or re-armed watch says first. A root id nobody knows: ask for the roots.
|
|
* - FileNode changes that touch a root: at once.
|
|
* - offline: again after 5 s, then longer, up to 5 minutes, and at once when the host hears the
|
|
* network is back.
|
|
* - every 10 minutes regardless, in case a change went unheard.
|
|
*/
|
|
|
|
import type { FilesControls, FilesFetchRequest, FilesRoot } from '@/lib/desktop/types';
|
|
import { isOffline, type FilesSyncErrorName } from './errors';
|
|
import type { DiskNaming } from './names';
|
|
import { RemoteTree, type FilesRemote } from './remote';
|
|
import { RootSync, type AccountLink, type ActivityInput } from './root-sync';
|
|
import type { FilesSyncActivity, FilesSyncCoverage, FilesSyncItemStatus, FilesSyncRootStatus } from './types';
|
|
|
|
export const ACTIVITY_LIMIT = 200;
|
|
const PIECE = 1024 * 1024;
|
|
const MOST_SOON = 5;
|
|
|
|
export interface EngineTiming {
|
|
/** After main says something changed on disk. */
|
|
localDelayMs: number;
|
|
/** After a push says Files changed. */
|
|
remoteDelayMs: number;
|
|
/** /changes without a push, per account. */
|
|
pollMs: number;
|
|
/** A full pass of every root regardless. */
|
|
rescanMs: number;
|
|
/** Waits while offline, one after another; the last repeats. */
|
|
retryMs: number[];
|
|
/** Again after a pass where something changed while it was read. */
|
|
soonMs: number;
|
|
/** How long a drive fetch waits for the first listing. */
|
|
fetchWaitMs: number;
|
|
}
|
|
|
|
export const DEFAULT_TIMING: EngineTiming = {
|
|
localDelayMs: 1500,
|
|
remoteDelayMs: 300,
|
|
pollMs: 60_000,
|
|
rescanMs: 10 * 60_000,
|
|
retryMs: [5_000, 15_000, 30_000, 60_000, 120_000, 300_000],
|
|
soonMs: 3_000,
|
|
fetchWaitMs: 20_000,
|
|
};
|
|
|
|
export interface EngineOptions {
|
|
controls: FilesControls;
|
|
naming: DiskNaming;
|
|
device: string;
|
|
readUrl?: (url: string) => Promise<Uint8Array>;
|
|
now?: () => number;
|
|
timing?: Partial<EngineTiming>;
|
|
/** The organisation's word on a root: null when it may sync. */
|
|
blocked?: (root: FilesRoot) => FilesSyncErrorName | null;
|
|
/** May this account's files be downloaded to this computer? */
|
|
downloadsAllowed?: (account: string) => boolean;
|
|
/** Something the UI shows changed. */
|
|
onUpdate?: () => void;
|
|
}
|
|
|
|
async function fetchBytes(url: string): Promise<Uint8Array> {
|
|
const response = await fetch(url, { cache: 'no-store' });
|
|
if (!response.ok) throw new Error(`read failed: ${response.status}`);
|
|
return new Uint8Array(await response.arrayBuffer());
|
|
}
|
|
|
|
class Account implements AccountLink {
|
|
readonly tree = new RemoteTree();
|
|
private loaded = false;
|
|
private running: Promise<void> | null = null;
|
|
private again = false;
|
|
private poll: ReturnType<typeof setInterval> | null = null;
|
|
private readyWaiters: (() => void)[] = [];
|
|
|
|
constructor(
|
|
readonly account: string,
|
|
public remote: FilesRemote,
|
|
private readonly changed: (account: Account, ids: Set<string>, origin: string | null) => void,
|
|
) {}
|
|
|
|
get isLoaded(): boolean {
|
|
return this.loaded;
|
|
}
|
|
|
|
startPolling(ms: number, run: () => void): void {
|
|
this.stopPolling();
|
|
this.poll = setInterval(run, ms);
|
|
(this.poll as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
stopPolling(): void {
|
|
if (this.poll) clearInterval(this.poll);
|
|
this.poll = null;
|
|
}
|
|
|
|
/** Waits for the first listing, up to `ms`. */
|
|
ready(ms: number): Promise<boolean> {
|
|
if (this.loaded) return Promise.resolve(true);
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => resolve(false), ms);
|
|
this.readyWaiters.push(() => {
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
refresh(origin: string | null = null): Promise<void> {
|
|
if (this.running) {
|
|
this.again = true;
|
|
return this.running;
|
|
}
|
|
this.running = (async () => {
|
|
try {
|
|
do {
|
|
this.again = false;
|
|
await this.catchUp(origin);
|
|
} while (this.again);
|
|
} finally {
|
|
this.running = null;
|
|
}
|
|
})();
|
|
return this.running;
|
|
}
|
|
|
|
private async catchUp(origin: string | null): Promise<void> {
|
|
const ids = new Set<string>();
|
|
if (!this.loaded || !this.tree.state) {
|
|
await this.listAll(ids);
|
|
} else {
|
|
const written = [...this.tree.written];
|
|
const heard = new Set<string>();
|
|
const outcome = await this.remote.changes(this.tree.state, (batch, state) => {
|
|
for (const node of batch.changed) {
|
|
ids.add(node.id);
|
|
heard.add(node.id);
|
|
}
|
|
for (const id of batch.destroyed) {
|
|
ids.add(id);
|
|
heard.add(id);
|
|
}
|
|
this.tree.apply(batch);
|
|
this.tree.state = state;
|
|
});
|
|
// The server can't say what changed since then: read it all again. Only here.
|
|
if (outcome.outcome === 'resync') {
|
|
await this.listAll(ids);
|
|
} else {
|
|
for (const id of written) this.tree.written.delete(id);
|
|
// Made here and deleted elsewhere since: /changes says neither.
|
|
const unheard = written.filter((id) => !heard.has(id) && this.tree.get(id));
|
|
if (unheard.length > 0) {
|
|
const still = new Set((await this.remote.get(unheard)).map((node) => node.id));
|
|
for (const id of unheard) {
|
|
if (still.has(id)) continue;
|
|
this.tree.remove(id);
|
|
ids.add(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (ids.size > 0) this.changed(this, ids, origin);
|
|
}
|
|
|
|
private async listAll(ids: Set<string>): Promise<void> {
|
|
const before = new Map(this.tree.nodes);
|
|
const { nodes, state } = await this.remote.listAll();
|
|
this.tree.reset(nodes, state);
|
|
// Whatever changed while it was read.
|
|
await this.remote.changes(state, (batch, next) => {
|
|
this.tree.apply(batch);
|
|
this.tree.state = next;
|
|
}).then((outcome) => {
|
|
if (outcome.outcome === 'resync') throw new Error('The FileNode state was refused right after a listing');
|
|
});
|
|
for (const [id, node] of this.tree.nodes) {
|
|
const old = before.get(id);
|
|
if (!old || old.parentId !== node.parentId || old.name !== node.name || old.blobId !== node.blobId) ids.add(id);
|
|
}
|
|
for (const id of before.keys()) if (!this.tree.nodes.has(id)) ids.add(id);
|
|
const first = !this.loaded;
|
|
this.loaded = true;
|
|
if (first) for (const waiter of this.readyWaiters.splice(0)) waiter();
|
|
}
|
|
}
|
|
|
|
class Runner {
|
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
private due = 0;
|
|
private running: Promise<void> | null = null;
|
|
private again = false;
|
|
private retryAt = 0;
|
|
private soon = 0;
|
|
private stopped = false;
|
|
|
|
constructor(readonly sync: RootSync, private readonly timing: EngineTiming) {}
|
|
|
|
get busy(): boolean {
|
|
return this.running !== null || (this.timer !== null && this.due - Date.now() <= Math.max(this.timing.localDelayMs, this.timing.soonMs));
|
|
}
|
|
|
|
get offline(): boolean {
|
|
return this.sync.status.state === 'offline';
|
|
}
|
|
|
|
request(delay: number): void {
|
|
if (this.stopped) return;
|
|
if (this.running) {
|
|
this.again = true;
|
|
return;
|
|
}
|
|
const due = Date.now() + delay;
|
|
if (this.timer) {
|
|
if (this.due <= due) return;
|
|
clearTimeout(this.timer);
|
|
}
|
|
this.due = due;
|
|
this.timer = setTimeout(() => {
|
|
this.timer = null;
|
|
void this.run();
|
|
}, delay);
|
|
}
|
|
|
|
private async run(): Promise<void> {
|
|
if (this.stopped || this.running) return;
|
|
const pass = this.sync.pass().catch((error: unknown) => {
|
|
console.warn('[files-sync] A pass stopped:', error);
|
|
return 'retry' as const;
|
|
});
|
|
this.running = pass.then(() => {});
|
|
const outcome = await pass;
|
|
this.running = null;
|
|
if (this.stopped) return;
|
|
if (this.again) {
|
|
this.again = false;
|
|
this.request(0);
|
|
return;
|
|
}
|
|
if (outcome === 'retry') {
|
|
const waits = this.timing.retryMs;
|
|
this.request(waits[Math.min(this.retryAt, waits.length - 1)]);
|
|
this.retryAt += 1;
|
|
return;
|
|
}
|
|
this.retryAt = 0;
|
|
if (outcome === 'soon' && this.soon < MOST_SOON) {
|
|
this.soon += 1;
|
|
this.request(this.timing.soonMs);
|
|
return;
|
|
}
|
|
this.soon = 0;
|
|
}
|
|
|
|
/** Waits for what's running and whatever is due within the short delays. For tests and shutdown. */
|
|
async settle(): Promise<void> {
|
|
for (let i = 0; i < 1000; i += 1) {
|
|
if (this.running) {
|
|
await this.running;
|
|
continue;
|
|
}
|
|
if (this.timer && this.due - Date.now() <= Math.max(this.timing.localDelayMs, this.timing.soonMs, this.timing.remoteDelayMs)) {
|
|
await new Promise((resolve) => setTimeout(resolve, Math.max(0, this.due - Date.now()) + 1));
|
|
continue;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopped = true;
|
|
this.sync.stopped = true;
|
|
if (this.timer) clearTimeout(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
|
|
export class FilesSyncEngine {
|
|
roots: FilesRoot[] = [];
|
|
activity: FilesSyncActivity[] = [];
|
|
private readonly accounts = new Map<string, Account>();
|
|
private readonly runners = new Map<string, Runner>();
|
|
private readonly timing: EngineTiming;
|
|
private readonly fetches = new Map<string, { cancelled: boolean }>();
|
|
private unsubscribe: (() => void)[] = [];
|
|
private rescan: ReturnType<typeof setInterval> | null = null;
|
|
private rootsRun: Promise<void> | null = null;
|
|
private rootsAgain = false;
|
|
private activitySeq = 0;
|
|
private stopped = false;
|
|
private readonly readUrl: (url: string) => Promise<Uint8Array>;
|
|
private readonly now: () => number;
|
|
|
|
constructor(private readonly options: EngineOptions) {
|
|
this.timing = { ...DEFAULT_TIMING, ...options.timing };
|
|
this.readUrl = options.readUrl ?? fetchBytes;
|
|
this.now = options.now ?? Date.now;
|
|
}
|
|
|
|
get controls(): FilesControls {
|
|
return this.options.controls;
|
|
}
|
|
|
|
start(): void {
|
|
const { controls } = this.options;
|
|
this.unsubscribe.push(controls.onChange((event) => {
|
|
const runner = this.runners.get(event.rootId);
|
|
if (!runner) {
|
|
void this.refreshRoots();
|
|
return;
|
|
}
|
|
runner.request(event.paths === null ? 0 : this.timing.localDelayMs);
|
|
}));
|
|
const drive = controls.drive;
|
|
if (drive) {
|
|
this.unsubscribe.push(drive.onFetch((request) => void this.fetch(request)));
|
|
this.unsubscribe.push(drive.onFetchCancel((fetchId) => {
|
|
const fetch = this.fetches.get(fetchId);
|
|
if (fetch) fetch.cancelled = true;
|
|
}));
|
|
}
|
|
this.rescan = setInterval(() => {
|
|
for (const runner of this.runners.values()) runner.request(0);
|
|
}, this.timing.rescanMs);
|
|
(this.rescan as { unref?: () => void }).unref?.();
|
|
void this.refreshRoots();
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopped = true;
|
|
for (const stop of this.unsubscribe.splice(0)) stop();
|
|
if (this.rescan) clearInterval(this.rescan);
|
|
for (const runner of this.runners.values()) runner.stop();
|
|
this.runners.clear();
|
|
for (const account of this.accounts.values()) account.stopPolling();
|
|
this.accounts.clear();
|
|
for (const fetch of this.fetches.values()) fetch.cancelled = true;
|
|
}
|
|
|
|
// ─── Accounts ───────────────────────────────────────────────
|
|
|
|
/** A signed-in account and its way to Files. Its roots start. */
|
|
setAccount(account: string, remote: FilesRemote): void {
|
|
if (this.stopped) return;
|
|
const known = this.accounts.get(account);
|
|
if (known) {
|
|
known.remote = remote;
|
|
return;
|
|
}
|
|
const entry = new Account(account, remote, (from, ids, origin) => this.remoteTouched(from, ids, origin));
|
|
this.accounts.set(account, entry);
|
|
entry.startPolling(this.timing.pollMs, () => void this.pollAccount(account));
|
|
this.applyRoots();
|
|
}
|
|
|
|
/** Signed out, or gone: its roots stop. Nothing on disk changes. */
|
|
dropAccount(account: string): void {
|
|
const entry = this.accounts.get(account);
|
|
if (!entry) return;
|
|
entry.stopPolling();
|
|
this.accounts.delete(account);
|
|
for (const [id, runner] of this.runners) {
|
|
if (runner.sync.root.account !== account) continue;
|
|
runner.stop();
|
|
this.runners.delete(id);
|
|
}
|
|
this.update();
|
|
}
|
|
|
|
hasAccount(account: string): boolean {
|
|
return this.accounts.has(account);
|
|
}
|
|
|
|
/** A push said FileNode changed in this account. */
|
|
remoteChanged(account: string): void {
|
|
void this.pollAccount(account);
|
|
}
|
|
|
|
private async pollAccount(account: string): Promise<void> {
|
|
const entry = this.accounts.get(account);
|
|
if (!entry?.isLoaded) {
|
|
// Nothing listed yet: a root's pass lists it.
|
|
for (const runner of this.runnersOf(account)) runner.request(this.timing.remoteDelayMs);
|
|
return;
|
|
}
|
|
try {
|
|
await entry.refresh();
|
|
} catch (error) {
|
|
if (!isOffline(error)) console.warn('[files-sync] Reading changes failed:', error);
|
|
}
|
|
}
|
|
|
|
/** The network is back: whatever waits goes now. */
|
|
retryNow(): void {
|
|
for (const runner of this.runners.values()) if (runner.offline) runner.request(0);
|
|
}
|
|
|
|
private runnersOf(account: string): Runner[] {
|
|
return [...this.runners.values()].filter((runner) => runner.sync.root.account === account);
|
|
}
|
|
|
|
private remoteTouched(account: Account, ids: Set<string>, origin: string | null): void {
|
|
for (const runner of this.runnersOf(account.account)) {
|
|
if (runner.sync.root.id === origin) continue;
|
|
const remoteId = runner.sync.root.remoteId;
|
|
const touched = [...ids].some((id) => !!runner.sync.item(id) || account.tree.isUnder(id, remoteId) || id === remoteId);
|
|
if (touched) runner.request(this.timing.remoteDelayMs);
|
|
}
|
|
}
|
|
|
|
// ─── Roots ──────────────────────────────────────────────────
|
|
|
|
/** Asks main for the roots again and starts, changes or stops runners to match. */
|
|
refreshRoots(): Promise<void> {
|
|
if (this.rootsRun) {
|
|
this.rootsAgain = true;
|
|
return this.rootsRun;
|
|
}
|
|
this.rootsRun = (async () => {
|
|
try {
|
|
do {
|
|
this.rootsAgain = false;
|
|
try {
|
|
this.roots = await this.options.controls.roots();
|
|
} catch {
|
|
this.roots = [];
|
|
}
|
|
this.applyRoots();
|
|
} while (this.rootsAgain && !this.stopped);
|
|
} finally {
|
|
this.rootsRun = null;
|
|
}
|
|
})();
|
|
return this.rootsRun;
|
|
}
|
|
|
|
private applyRoots(): void {
|
|
if (this.stopped) return;
|
|
const live = new Map(this.roots.map((root) => [root.id, root]));
|
|
for (const [id, runner] of this.runners) {
|
|
const root = live.get(id);
|
|
if (!root || !this.accounts.has(root.account)) {
|
|
runner.stop();
|
|
this.runners.delete(id);
|
|
continue;
|
|
}
|
|
const wasPaused = runner.sync.root.paused;
|
|
runner.sync.setRoot(root);
|
|
if (wasPaused && !root.paused) runner.request(0);
|
|
}
|
|
for (const root of this.roots) {
|
|
if (this.runners.has(root.id)) continue;
|
|
const account = this.accounts.get(root.account);
|
|
if (!account) continue;
|
|
const sync = new RootSync(root, {
|
|
account: account.account,
|
|
tree: account.tree,
|
|
get remote() {
|
|
return account.remote;
|
|
},
|
|
refresh: () => account.refresh(root.id),
|
|
}, {
|
|
controls: this.options.controls,
|
|
naming: this.options.naming,
|
|
device: this.options.device,
|
|
now: this.now,
|
|
readUrl: this.readUrl,
|
|
activity: (entry) => this.addActivity(entry),
|
|
changed: () => this.update(),
|
|
blocked: (current) => this.options.blocked?.(current) ?? null,
|
|
});
|
|
const runner = new Runner(sync, this.timing);
|
|
this.runners.set(root.id, runner);
|
|
runner.request(0);
|
|
}
|
|
this.update();
|
|
}
|
|
|
|
root(rootId: string): RootSync | null {
|
|
return this.runners.get(rootId)?.sync ?? null;
|
|
}
|
|
|
|
/** Runs a pass of a root (all of them without an id) now. */
|
|
syncNow(rootId?: string): void {
|
|
for (const [id, runner] of this.runners) if (!rootId || id === rootId) runner.request(0);
|
|
}
|
|
|
|
/** Waits until nothing runs or is due shortly. For tests and the sign-out. */
|
|
async settled(): Promise<void> {
|
|
for (let i = 0; i < 50; i += 1) {
|
|
if (this.rootsRun) await this.rootsRun;
|
|
await Promise.all([...this.runners.values()].map((runner) => runner.settle()));
|
|
if (!this.rootsRun && [...this.runners.values()].every((runner) => !runner.busy)) return;
|
|
}
|
|
}
|
|
|
|
async setPaused(rootId: string, paused: boolean): Promise<void> {
|
|
await this.options.controls.setPaused(rootId, paused);
|
|
await this.refreshRoots();
|
|
}
|
|
|
|
async removeRoot(rootId: string): Promise<void> {
|
|
await this.options.controls.removeRoot(rootId);
|
|
await this.refreshRoots();
|
|
}
|
|
|
|
confirmMassDelete(rootId: string, allow: boolean): void {
|
|
const runner = this.runners.get(rootId);
|
|
if (!runner) return;
|
|
runner.sync.answerMassDelete(allow);
|
|
runner.request(0);
|
|
}
|
|
|
|
/** The drive's record goes before its folder does (sign-out): the next sign-in starts from an empty folder. */
|
|
async forgetRoot(rootId: string): Promise<void> {
|
|
const runner = this.runners.get(rootId);
|
|
if (!runner) return;
|
|
runner.stop();
|
|
await runner.sync.forget();
|
|
this.runners.delete(rootId);
|
|
}
|
|
|
|
// ─── What the UI reads ──────────────────────────────────────
|
|
|
|
status(): Record<string, FilesSyncRootStatus> {
|
|
const out: Record<string, FilesSyncRootStatus> = {};
|
|
for (const [id, runner] of this.runners) out[id] = runner.sync.status;
|
|
return out;
|
|
}
|
|
|
|
items(account: string): Record<string, FilesSyncItemStatus> {
|
|
const out: Record<string, FilesSyncItemStatus> = {};
|
|
for (const runner of this.runnersOf(account)) Object.assign(out, runner.sync.items);
|
|
return out;
|
|
}
|
|
|
|
coverage(account: string, nodeId: string): FilesSyncCoverage | null {
|
|
for (const runner of this.runnersOf(account)) {
|
|
const { root } = runner.sync;
|
|
if (root.remoteId === nodeId) return { rootId: root.id, kind: root.kind, path: '' };
|
|
const item = runner.sync.item(nodeId);
|
|
if (item) return { rootId: root.id, kind: root.kind, path: item.path };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
clearActivity(): void {
|
|
this.activity = [];
|
|
this.update();
|
|
}
|
|
|
|
private addActivity(entry: ActivityInput): void {
|
|
this.activitySeq += 1;
|
|
this.activity = [{ id: `${this.now()}-${this.activitySeq}`, at: this.now(), ...entry }, ...this.activity].slice(0, ACTIVITY_LIMIT);
|
|
this.update();
|
|
}
|
|
|
|
private update(): void {
|
|
this.options.onUpdate?.();
|
|
}
|
|
|
|
// ─── The drive's fetches ────────────────────────────────────
|
|
|
|
/**
|
|
* Someone opened a file on the drive that isn't on the disk: stream its content to main, from
|
|
* where Windows asked (rounded down to 4 KB) to the end, in order. Main fails a fetch it hears
|
|
* nothing of for 30 s, and Windows cancels after 60 s.
|
|
*/
|
|
async fetch(request: FilesFetchRequest): Promise<void> {
|
|
const drive = this.options.controls.drive;
|
|
if (!drive) return;
|
|
const handle = { cancelled: false };
|
|
this.fetches.set(request.fetchId, handle);
|
|
try {
|
|
let runner = this.runners.get(request.rootId);
|
|
if (!runner) {
|
|
await this.refreshRoots();
|
|
runner = this.runners.get(request.rootId);
|
|
}
|
|
if (!runner || runner.sync.root.kind !== 'drive') {
|
|
drive.fail(request.fetchId, this.accountsKnown(request.rootId) ? 'failed' : 'offline');
|
|
return;
|
|
}
|
|
const { root } = runner.sync;
|
|
if (this.options.downloadsAllowed && !this.options.downloadsAllowed(root.account)) {
|
|
drive.fail(request.fetchId, 'failed');
|
|
return;
|
|
}
|
|
const account = this.accounts.get(root.account);
|
|
if (!account || !(await account.ready(this.timing.fetchWaitMs))) {
|
|
drive.fail(request.fetchId, 'offline');
|
|
return;
|
|
}
|
|
// The newest version, not one deleted or replaced since the last push. Offline: what's known.
|
|
await account.refresh().catch(() => {});
|
|
const node = account.tree.get(request.identity);
|
|
if (!node || node.kind !== 'file' || account.tree.inTrash(node.id)) {
|
|
drive.fail(request.fetchId, 'missing');
|
|
runner.request(this.timing.remoteDelayMs);
|
|
return;
|
|
}
|
|
if (node.size !== request.total) {
|
|
// The placeholder is older than the file: the pass puts the new one there.
|
|
drive.fail(request.fetchId, 'failed');
|
|
runner.request(0);
|
|
return;
|
|
}
|
|
await this.stream(request, node, account, handle);
|
|
} catch (error) {
|
|
if (!handle.cancelled) drive.fail(request.fetchId, isOffline(error) ? 'offline' : 'failed');
|
|
} finally {
|
|
this.fetches.delete(request.fetchId);
|
|
}
|
|
}
|
|
|
|
private accountsKnown(rootId: string): boolean {
|
|
const root = this.roots.find((entry) => entry.id === rootId);
|
|
return !!root && this.accounts.has(root.account);
|
|
}
|
|
|
|
private async stream(request: FilesFetchRequest, node: { blobId: string | null; type: string; name: string; size: number }, account: Account, handle: { cancelled: boolean }): Promise<void> {
|
|
const drive = this.options.controls.drive;
|
|
if (!drive) return;
|
|
const start = Math.max(0, request.offset);
|
|
let at = 0;
|
|
let sent = start;
|
|
let pending: Uint8Array[] = [];
|
|
let pendingBytes = 0;
|
|
let chain: Promise<boolean> = Promise.resolve(true);
|
|
let refused = false;
|
|
const STOP = 'files-sync:fetch-stopped';
|
|
const flush = (all: boolean) => {
|
|
if (pendingBytes === 0 || (!all && pendingBytes < PIECE)) return;
|
|
const piece = joinParts(pending, pendingBytes);
|
|
pending = [];
|
|
pendingBytes = 0;
|
|
const offset = sent;
|
|
sent += piece.length;
|
|
chain = chain.then(async (good) => {
|
|
if (!good || handle.cancelled) return false;
|
|
const taken = await drive.supply(request.fetchId, offset, piece);
|
|
if (!taken) refused = true;
|
|
return taken;
|
|
});
|
|
};
|
|
try {
|
|
await account.remote.download(node, (part) => {
|
|
if (handle.cancelled || refused) throw new Error(STOP);
|
|
let slice = part;
|
|
if (at + part.length <= start) {
|
|
at += part.length;
|
|
return;
|
|
}
|
|
if (at < start) slice = part.subarray(start - at);
|
|
at += part.length;
|
|
pending.push(slice);
|
|
pendingBytes += slice.length;
|
|
flush(false);
|
|
});
|
|
} catch (error) {
|
|
if ((error as Error)?.message === STOP) return;
|
|
throw error;
|
|
}
|
|
flush(true);
|
|
await chain;
|
|
}
|
|
}
|
|
|
|
function joinParts(parts: Uint8Array[], size: number): Uint8Array {
|
|
if (parts.length === 1) return parts[0];
|
|
const out = new Uint8Array(size);
|
|
let at = 0;
|
|
for (const part of parts) {
|
|
out.set(part, at);
|
|
at += part.length;
|
|
}
|
|
return out;
|
|
}
|