mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import {isRecord as isPlainObject} from '@fluxer/record_utils/src/RecordGuards';
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
|
import {getLegacyAppOrigin} from '@electron/common/DesktopConfig';
|
|
import {errorMessage as describeError} from '@electron/common/ErrorMessage';
|
|
import {createChildLogger} from '@electron/common/Logger';
|
|
import {type DesktopAppStorage, getDesktopAppStorage} from '@electron/main/DesktopAppStorage';
|
|
import {getDesktopDistributionPath} from '@electron/main/DesktopDistributionPath';
|
|
import {seedDesktopLastRouteFromLegacyStorage} from '@electron/main/DesktopLastRoute';
|
|
import {rekeyHarvestToOfficialInstance} from '@electron/main/LegacyHarvestOfficialInstance';
|
|
import {
|
|
parseStagedLegacyHarvest,
|
|
type StagedLegacyHarvest,
|
|
writeStagedHarvest,
|
|
} from '@electron/main/LegacyOriginHarvestStore';
|
|
import {
|
|
DESKTOP_LEGACY_HARVEST_CHANNELS,
|
|
DESKTOP_LEGACY_HARVEST_FAILURE_MARKER_KEY,
|
|
DESKTOP_LEGACY_HARVEST_MARKER_KEY,
|
|
DESKTOP_LEGACY_HARVEST_MAX_FAILURES,
|
|
DESKTOP_LEGACY_HARVEST_SENTINEL_PATH,
|
|
DesktopLegacyHarvestStatus,
|
|
} from '@fluxer/desktop_ipc/src/LegacyHarvestContract';
|
|
import {app, BrowserWindow, ipcMain, net, session} from 'electron';
|
|
|
|
const LEGACY_HARVEST_TIMEOUT_MS = 60_000;
|
|
const LEGACY_HARVEST_MARKER_VERSION = 1;
|
|
const SENTINEL_DOCUMENT = '<!doctype html><meta charset="utf-8"><title>.</title>';
|
|
const SENTINEL_HEADERS: Readonly<Record<string, string>> = {
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'cache-control': 'no-store',
|
|
};
|
|
|
|
const logger = createChildLogger('LegacyHarvest');
|
|
|
|
class LegacyOriginHarvestUnavailableError extends Error {
|
|
public constructor(reason: string) {
|
|
super(`Legacy origin harvest is unavailable: ${reason}`);
|
|
this.name = 'LegacyOriginHarvestUnavailableError';
|
|
}
|
|
}
|
|
|
|
class LegacyOriginHarvestAbortedError extends Error {
|
|
public constructor() {
|
|
super('Legacy origin harvest was aborted');
|
|
this.name = 'LegacyOriginHarvestAbortedError';
|
|
}
|
|
}
|
|
|
|
class LegacyOriginHarvestTimeoutError extends Error {
|
|
public constructor(timeoutMs: number) {
|
|
super(`Legacy origin harvest timed out after ${timeoutMs}ms`);
|
|
this.name = 'LegacyOriginHarvestTimeoutError';
|
|
}
|
|
}
|
|
|
|
class LegacyOriginHarvestFailedError extends Error {
|
|
public constructor(reason: string) {
|
|
super(`Legacy origin harvest failed in the renderer: ${reason}`);
|
|
this.name = 'LegacyOriginHarvestFailedError';
|
|
}
|
|
}
|
|
|
|
interface LegacyHarvestResult {
|
|
readonly harvest: StagedLegacyHarvest;
|
|
readonly blobs: Map<string, Uint8Array>;
|
|
}
|
|
|
|
interface LegacyHarvestMarker {
|
|
readonly status: DesktopLegacyHarvestStatus;
|
|
readonly capturedAt: number;
|
|
readonly origin: string;
|
|
}
|
|
|
|
function parseHarvestMarker(value: string | null): LegacyHarvestMarker | null {
|
|
if (value === null) return null;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!isPlainObject(parsed)) return null;
|
|
const {capturedAt, origin, status} = parsed;
|
|
if (typeof status !== 'string' || typeof origin !== 'string') return null;
|
|
if (typeof capturedAt !== 'number' || !Number.isFinite(capturedAt)) return null;
|
|
const known = Object.values(DesktopLegacyHarvestStatus).find((candidate) => candidate === status);
|
|
if (known === undefined) return null;
|
|
return {status: known, capturedAt, origin};
|
|
}
|
|
|
|
function parseFailureCount(value: string | null): number {
|
|
if (value === null) return 0;
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
}
|
|
|
|
function writeHarvestMarker(
|
|
storage: DesktopAppStorage,
|
|
status: DesktopLegacyHarvestStatus,
|
|
origin: string,
|
|
): Promise<void> {
|
|
return storage.setMarker(
|
|
DESKTOP_LEGACY_HARVEST_MARKER_KEY,
|
|
JSON.stringify({version: LEGACY_HARVEST_MARKER_VERSION, status, capturedAt: Date.now(), origin}),
|
|
);
|
|
}
|
|
|
|
function parseHarvestBlobs(value: unknown): Map<string, Uint8Array> {
|
|
const blobs = new Map<string, Uint8Array>();
|
|
if (!Array.isArray(value)) {
|
|
throw new LegacyOriginHarvestFailedError('the submitted blob list is not an array');
|
|
}
|
|
for (const entry of value) {
|
|
if (!isPlainObject(entry)) {
|
|
throw new LegacyOriginHarvestFailedError('a submitted blob entry is not an object');
|
|
}
|
|
const {blobId, bytes} = entry;
|
|
if (typeof blobId !== 'string' || !(bytes instanceof Uint8Array)) {
|
|
throw new LegacyOriginHarvestFailedError('a submitted blob entry is malformed');
|
|
}
|
|
blobs.set(blobId, bytes);
|
|
}
|
|
return blobs;
|
|
}
|
|
|
|
function parseHarvestSubmission(origin: string, submission: unknown): LegacyHarvestResult {
|
|
if (!isPlainObject(submission)) {
|
|
throw new LegacyOriginHarvestFailedError('the submission is not an object');
|
|
}
|
|
if (submission.ok !== true) {
|
|
const reason = typeof submission.error === 'string' ? submission.error : 'unknown reason';
|
|
throw new LegacyOriginHarvestFailedError(reason);
|
|
}
|
|
const payload = submission.payload;
|
|
if (!isPlainObject(payload)) {
|
|
throw new LegacyOriginHarvestFailedError('the submitted payload is not an object');
|
|
}
|
|
const harvest = parseStagedLegacyHarvest({
|
|
version: LEGACY_HARVEST_MARKER_VERSION,
|
|
origin,
|
|
capturedAt: Date.now(),
|
|
localStorage: payload.localStorage,
|
|
stores: payload.stores,
|
|
mediaDevices: payload.mediaDevices,
|
|
serviceWorkersUnregistered: payload.serviceWorkersUnregistered,
|
|
cachesDeleted: payload.cachesDeleted,
|
|
truncated: payload.truncated,
|
|
});
|
|
if (harvest === null) {
|
|
throw new LegacyOriginHarvestFailedError('the submitted payload did not match the harvest shape');
|
|
}
|
|
return {harvest, blobs: parseHarvestBlobs(payload.blobs)};
|
|
}
|
|
|
|
function createHarvestWindow(): BrowserWindow {
|
|
return new BrowserWindow({
|
|
show: false,
|
|
width: 640,
|
|
height: 480,
|
|
webPreferences: {
|
|
preload: getDesktopDistributionPath('preload', 'legacy-harvest.cjs'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
spellcheck: false,
|
|
backgroundThrottling: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
function awaitHarvestSubmission(
|
|
harvestWindow: BrowserWindow,
|
|
origin: string,
|
|
sentinelUrl: string,
|
|
signal: AbortSignal,
|
|
): Promise<LegacyHarvestResult> {
|
|
return new Promise<LegacyHarvestResult>((resolve, reject) => {
|
|
let settled = false;
|
|
const finish = (action: () => void): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
signal.removeEventListener('abort', onAbort);
|
|
action();
|
|
};
|
|
const onAbort = (): void => finish(() => reject(new LegacyOriginHarvestAbortedError()));
|
|
const timer = setTimeout(
|
|
() => finish(() => reject(new LegacyOriginHarvestTimeoutError(LEGACY_HARVEST_TIMEOUT_MS))),
|
|
LEGACY_HARVEST_TIMEOUT_MS,
|
|
);
|
|
signal.addEventListener('abort', onAbort, {once: true});
|
|
if (signal.aborted) {
|
|
finish(() => reject(new LegacyOriginHarvestAbortedError()));
|
|
return;
|
|
}
|
|
ipcMain.handle(DESKTOP_LEGACY_HARVEST_CHANNELS.submit, (event, submission: unknown): void => {
|
|
if (event.sender !== harvestWindow.webContents) {
|
|
throw new LegacyOriginHarvestUnavailableError('a foreign renderer attempted to submit a harvest');
|
|
}
|
|
try {
|
|
const result = parseHarvestSubmission(origin, submission);
|
|
finish(() => resolve(result));
|
|
} catch (error) {
|
|
finish(() => reject(error));
|
|
}
|
|
});
|
|
harvestWindow.webContents.on('render-process-gone', (_event, details) => {
|
|
finish(() => reject(new LegacyOriginHarvestFailedError(`the harvest renderer exited: ${details.reason}`)));
|
|
});
|
|
harvestWindow.webContents.on('did-fail-load', (_event, _errorCode, errorDescription, _url, isMainFrame) => {
|
|
if (!isMainFrame) return;
|
|
finish(() =>
|
|
reject(new LegacyOriginHarvestFailedError(`the sentinel document failed to load: ${errorDescription}`)),
|
|
);
|
|
});
|
|
harvestWindow.loadURL(sentinelUrl).catch((error: unknown) => {
|
|
finish(() => reject(new LegacyOriginHarvestFailedError(describeError(error))));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runLegacyOriginHarvest(origin: string, signal: AbortSignal): Promise<LegacyHarvestResult> {
|
|
const {protocol} = session.defaultSession;
|
|
if (protocol.isProtocolHandled('https')) {
|
|
throw new LegacyOriginHarvestUnavailableError('another handler already owns the https scheme');
|
|
}
|
|
const sentinelUrl = `${origin}${DESKTOP_LEGACY_HARVEST_SENTINEL_PATH}`;
|
|
protocol.handle('https', (request) => {
|
|
if (request.url === sentinelUrl) {
|
|
return new Response(SENTINEL_DOCUMENT, {status: 200, headers: SENTINEL_HEADERS});
|
|
}
|
|
return net.fetch(request, {bypassCustomProtocolHandlers: true});
|
|
});
|
|
const harvestWindow = createHarvestWindow();
|
|
try {
|
|
return await awaitHarvestSubmission(harvestWindow, origin, sentinelUrl, signal);
|
|
} finally {
|
|
ipcMain.removeHandler(DESKTOP_LEGACY_HARVEST_CHANNELS.submit);
|
|
if (!harvestWindow.isDestroyed()) harvestWindow.destroy();
|
|
protocol.unhandle('https');
|
|
}
|
|
}
|
|
|
|
function isEmptyHarvest(harvest: StagedLegacyHarvest): boolean {
|
|
if (Object.keys(harvest.localStorage).length > 0) return false;
|
|
return harvest.stores.every((store) => store.records.length === 0);
|
|
}
|
|
|
|
async function recordHarvestFailure(
|
|
storage: DesktopAppStorage,
|
|
origin: string,
|
|
failures: number,
|
|
error: unknown,
|
|
): Promise<void> {
|
|
if (error instanceof LegacyOriginHarvestAbortedError || error instanceof LegacyOriginHarvestUnavailableError) {
|
|
logger.warn('Deferring the legacy origin harvest to the next launch', {origin, reason: describeError(error)});
|
|
return;
|
|
}
|
|
const attempts = failures + 1;
|
|
logger.error('Legacy origin harvest attempt failed', {origin, attempts, error});
|
|
try {
|
|
await storage.setMarker(DESKTOP_LEGACY_HARVEST_FAILURE_MARKER_KEY, String(attempts));
|
|
if (attempts >= DESKTOP_LEGACY_HARVEST_MAX_FAILURES) {
|
|
await writeHarvestMarker(storage, DesktopLegacyHarvestStatus.ABANDONED, origin);
|
|
logger.error('Abandoning the legacy origin harvest after repeated failures', {origin, attempts});
|
|
}
|
|
} catch (markerError) {
|
|
logger.error('Failed to record the legacy origin harvest failure', markerError);
|
|
}
|
|
}
|
|
|
|
export async function initializeLegacyOriginHarvest(signal: AbortSignal): Promise<void> {
|
|
if (BUILD_CHANNEL === 'development') {
|
|
logger.info('Skipping the legacy origin harvest because the development channel never shipped a remote origin');
|
|
return;
|
|
}
|
|
const storage = getDesktopAppStorage();
|
|
if (storage === null) {
|
|
logger.warn('Skipping the legacy origin harvest because the desktop app store is unavailable');
|
|
return;
|
|
}
|
|
const origin = getLegacyAppOrigin();
|
|
let failures = 0;
|
|
try {
|
|
const marker = parseHarvestMarker(await storage.getMarker(DESKTOP_LEGACY_HARVEST_MARKER_KEY));
|
|
if (marker !== null && marker.status !== DesktopLegacyHarvestStatus.PENDING) return;
|
|
failures = parseFailureCount(await storage.getMarker(DESKTOP_LEGACY_HARVEST_FAILURE_MARKER_KEY));
|
|
if (failures >= DESKTOP_LEGACY_HARVEST_MAX_FAILURES) {
|
|
await writeHarvestMarker(storage, DesktopLegacyHarvestStatus.ABANDONED, origin);
|
|
return;
|
|
}
|
|
if (signal.aborted) return;
|
|
const startedAt = Date.now();
|
|
const harvested = await runLegacyOriginHarvest(origin, signal);
|
|
const harvest = rekeyHarvestToOfficialInstance(harvested.harvest);
|
|
const blobs = harvested.blobs;
|
|
await writeStagedHarvest(harvest, blobs);
|
|
seedDesktopLastRouteFromLegacyStorage(app.getPath('userData'), harvest.localStorage);
|
|
const status = isEmptyHarvest(harvest) ? DesktopLegacyHarvestStatus.EMPTY : DesktopLegacyHarvestStatus.HARVESTED;
|
|
await writeHarvestMarker(storage, status, origin);
|
|
await storage.setMarker(DESKTOP_LEGACY_HARVEST_FAILURE_MARKER_KEY, '0');
|
|
logger.info('Legacy origin harvest completed', {
|
|
origin,
|
|
status,
|
|
elapsedMs: Date.now() - startedAt,
|
|
localStorageKeys: Object.keys(harvest.localStorage).length,
|
|
stores: harvest.stores.length,
|
|
blobs: blobs.size,
|
|
mediaDevices: harvest.mediaDevices.length,
|
|
serviceWorkersUnregistered: harvest.serviceWorkersUnregistered,
|
|
cachesDeleted: harvest.cachesDeleted,
|
|
truncated: harvest.truncated,
|
|
});
|
|
} catch (error) {
|
|
await recordHarvestFailure(storage, origin, failures, error);
|
|
}
|
|
}
|