mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
1 parent
c748c8af4e
commit
7c5fa2180a
1275 files changed
+215074
-81246
No files matched your search
@@ -0,0 +1,450 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
ModulePackageHashMismatchError,
|
||||
ModulePackageIOError,
|
||||
ModulePackageMalformedError,
|
||||
} from '@electron/main/ModulePackage';
|
||||
import {
|
||||
type ModulePackageDownload,
|
||||
type ModuleStore,
|
||||
ModuleStoreDownloadHashMismatchError,
|
||||
} from '@electron/main/ModuleStore';
|
||||
import type {ModulePlanItem} from '@electron/main/ModuleUpdatePlanner';
|
||||
|
||||
const MODULE_PACKAGE_HASH_MISMATCH_RETRIES = 1;
|
||||
const MODULE_PACKAGE_HEADER_TIMEOUT_MS = 30000;
|
||||
const MODULE_PACKAGE_STALL_TIMEOUT_MS = 60000;
|
||||
const TRANSIENT_HTTP_STATUS_FLOOR = 500;
|
||||
const TRANSIENT_PACKAGE_HTTP_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
|
||||
const CAUSE_CHAIN_LIMIT = 8;
|
||||
const MODULE_LOOPBACK_HOSTS: ReadonlySet<string> = new Set(['127.0.0.1', 'localhost']);
|
||||
const DETERMINISTIC_PACKAGE_IO_CODES: ReadonlySet<string> = new Set(['EEXIST', 'EISDIR', 'ENOTDIR']);
|
||||
const HTTP_PARTIAL_CONTENT = 206;
|
||||
const HTTP_RANGE_NOT_SATISFIABLE = 416;
|
||||
const CONTENT_RANGE_PATTERN = /^bytes (\d+)-(\d+)\/(\d+|\*)$/u;
|
||||
|
||||
export const ModulePackageInstallPhase = Object.freeze({
|
||||
DOWNLOADING: 'downloading',
|
||||
INSTALLING: 'installing',
|
||||
} as const);
|
||||
|
||||
export type ModulePackageInstallPhase = (typeof ModulePackageInstallPhase)[keyof typeof ModulePackageInstallPhase];
|
||||
|
||||
interface ModulePackageInstallProgress {
|
||||
readonly phase: ModulePackageInstallPhase;
|
||||
readonly current: number;
|
||||
readonly total: number;
|
||||
readonly moduleName: string;
|
||||
readonly progress: number;
|
||||
}
|
||||
|
||||
export const ModulePackageInstallerReportType = Object.freeze({
|
||||
HASH_MISMATCH: 'hash-mismatch',
|
||||
PACKAGE_MISSING: 'package-missing',
|
||||
PACKAGE_REJECTED: 'package-rejected',
|
||||
NETWORK_ERROR: 'network-error',
|
||||
} as const);
|
||||
|
||||
export type ModulePackageInstallerReport =
|
||||
| {
|
||||
readonly type: typeof ModulePackageInstallerReportType.HASH_MISMATCH;
|
||||
readonly module: string;
|
||||
readonly message: string;
|
||||
readonly error: unknown;
|
||||
}
|
||||
| {
|
||||
readonly type: typeof ModulePackageInstallerReportType.PACKAGE_MISSING;
|
||||
readonly module: string;
|
||||
readonly message: string;
|
||||
readonly error: unknown;
|
||||
}
|
||||
| {
|
||||
readonly type: typeof ModulePackageInstallerReportType.PACKAGE_REJECTED;
|
||||
readonly module: string;
|
||||
readonly message: string;
|
||||
readonly error: unknown;
|
||||
}
|
||||
| {
|
||||
readonly type: typeof ModulePackageInstallerReportType.NETWORK_ERROR;
|
||||
readonly module: string;
|
||||
readonly message: string;
|
||||
readonly error: unknown;
|
||||
};
|
||||
|
||||
interface ModulePackageInstallerOptions {
|
||||
readonly store: ModuleStore;
|
||||
readonly packageOrigin: string;
|
||||
readonly fetch: typeof globalThis.fetch;
|
||||
readonly onProgress: (progress: ModulePackageInstallProgress) => void;
|
||||
readonly report: (report: ModulePackageInstallerReport) => void;
|
||||
}
|
||||
|
||||
export class ModulePackageFetchError extends Error {
|
||||
public readonly module: string;
|
||||
public readonly status: number | null;
|
||||
|
||||
public constructor(moduleName: string, status: number | null, message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'ModulePackageFetchError';
|
||||
this.module = moduleName;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
class ModulePackageUrlPolicyError extends Error {
|
||||
public readonly module: string;
|
||||
|
||||
public constructor(moduleName: string, url: string) {
|
||||
super(`module package url is not https: ${url}`);
|
||||
this.name = 'ModulePackageUrlPolicyError';
|
||||
this.module = moduleName;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function describeErrorChain(error: unknown): string {
|
||||
const messages: Array<string> = [];
|
||||
let cursor: unknown = error;
|
||||
for (let depth = 0; depth < CAUSE_CHAIN_LIMIT && cursor != null; depth += 1) {
|
||||
const message = errorMessage(cursor);
|
||||
if (message.length > 0 && !messages.includes(message)) {
|
||||
messages.push(message);
|
||||
}
|
||||
cursor = (cursor as {cause?: unknown}).cause;
|
||||
}
|
||||
return messages.join(': ');
|
||||
}
|
||||
|
||||
export function isTransientServerStatus(status: number | null): boolean {
|
||||
return status == null || status >= TRANSIENT_HTTP_STATUS_FLOOR || TRANSIENT_PACKAGE_HTTP_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function findCause<T>(error: unknown, kind: abstract new (...args: Array<never>) => T): T | null {
|
||||
let cursor: unknown = error;
|
||||
for (let depth = 0; depth < CAUSE_CHAIN_LIMIT && cursor != null; depth += 1) {
|
||||
if (cursor instanceof kind) {
|
||||
return cursor;
|
||||
}
|
||||
cursor = (cursor as {cause?: unknown}).cause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPackageHashMismatch(error: unknown): boolean {
|
||||
return (
|
||||
findCause(error, ModuleStoreDownloadHashMismatchError) != null ||
|
||||
findCause(error, ModulePackageHashMismatchError) != null
|
||||
);
|
||||
}
|
||||
|
||||
function isDeterministicPackageIOFailure(error: unknown): boolean {
|
||||
const failure = findCause(error, ModulePackageIOError);
|
||||
if (failure == null) {
|
||||
return false;
|
||||
}
|
||||
const code = (failure.cause as {code?: unknown} | null | undefined)?.code;
|
||||
return typeof code === 'string' && DETERMINISTIC_PACKAGE_IO_CODES.has(code);
|
||||
}
|
||||
|
||||
export function isDeterministicPackageFailure(error: unknown): boolean {
|
||||
if (
|
||||
isPackageHashMismatch(error) ||
|
||||
findCause(error, ModulePackageMalformedError) != null ||
|
||||
findCause(error, ModulePackageUrlPolicyError) != null ||
|
||||
isDeterministicPackageIOFailure(error)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const fetchError = findCause(error, ModulePackageFetchError);
|
||||
return fetchError != null && !isTransientServerStatus(fetchError.status);
|
||||
}
|
||||
|
||||
export class ModulePackageInstaller {
|
||||
private readonly store: ModuleStore;
|
||||
private readonly packageOrigin: string;
|
||||
private readonly fetchImplementation: typeof globalThis.fetch;
|
||||
private readonly onProgress: (progress: ModulePackageInstallProgress) => void;
|
||||
private readonly reporter: (report: ModulePackageInstallerReport) => void;
|
||||
|
||||
public constructor(options: ModulePackageInstallerOptions) {
|
||||
this.store = options.store;
|
||||
this.packageOrigin = options.packageOrigin;
|
||||
this.fetchImplementation = options.fetch;
|
||||
this.onProgress = options.onProgress;
|
||||
this.reporter = options.report;
|
||||
}
|
||||
|
||||
public async install(item: ModulePlanItem, current: number, total: number): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await this.store.installModule({
|
||||
module: item.module,
|
||||
sha256: item.entry.sha256,
|
||||
download: (resumeFrom) => this.openPackageStream(item, current, total, resumeFrom),
|
||||
});
|
||||
this.onProgress({
|
||||
phase: ModulePackageInstallPhase.INSTALLING,
|
||||
current,
|
||||
total,
|
||||
moduleName: item.module,
|
||||
progress: Math.round((current / total) * 100),
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isPackageHashMismatch(error)) {
|
||||
this.reporter({
|
||||
type: ModulePackageInstallerReportType.HASH_MISMATCH,
|
||||
module: item.module,
|
||||
message: errorMessage(error),
|
||||
error,
|
||||
});
|
||||
} else if (findCause(error, ModulePackageFetchError)?.status != null) {
|
||||
this.reporter({
|
||||
type: ModulePackageInstallerReportType.PACKAGE_MISSING,
|
||||
module: item.module,
|
||||
message: errorMessage(error),
|
||||
error,
|
||||
});
|
||||
} else if (isDeterministicPackageFailure(error)) {
|
||||
this.reporter({
|
||||
type: ModulePackageInstallerReportType.PACKAGE_REJECTED,
|
||||
module: item.module,
|
||||
message: describeErrorChain(error),
|
||||
error,
|
||||
});
|
||||
}
|
||||
if (attempt >= MODULE_PACKAGE_HASH_MISMATCH_RETRIES || !isPackageHashMismatch(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async openPackageStream(
|
||||
item: ModulePlanItem,
|
||||
current: number,
|
||||
total: number,
|
||||
resumeFrom: number,
|
||||
): Promise<ModulePackageDownload> {
|
||||
const url = this.requirePackageUrl(item);
|
||||
const offset = resumeFrom > 0 && resumeFrom <= item.entry.bytes ? resumeFrom : 0;
|
||||
this.onProgress({
|
||||
phase: ModulePackageInstallPhase.DOWNLOADING,
|
||||
current,
|
||||
total,
|
||||
moduleName: item.module,
|
||||
progress: item.entry.bytes === 0 ? 0 : Math.floor((offset / item.entry.bytes) * 100),
|
||||
});
|
||||
if (offset > 0 && offset === item.entry.bytes) {
|
||||
return {offset, chunks: []};
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const response = await this.requestPackage(item, url, controller, offset);
|
||||
const resumed = offset > 0 && response.status === HTTP_PARTIAL_CONTENT;
|
||||
if (resumed && parseContentRangeStart(response.headers.get('content-range')) !== offset) {
|
||||
const failure = new ModulePackageFetchError(
|
||||
item.module,
|
||||
null,
|
||||
`package response resumed at an unexpected range: ${response.headers.get('content-range') ?? 'none'}`,
|
||||
);
|
||||
await this.cancelFailedPackageResponse(response, controller, failure);
|
||||
return this.openPackageStream(item, current, total, 0);
|
||||
}
|
||||
if (offset > 0 && response.status === HTTP_RANGE_NOT_SATISFIABLE) {
|
||||
const failure = new ModulePackageFetchError(item.module, response.status, 'package range was not satisfiable');
|
||||
await this.cancelFailedPackageResponse(response, controller, failure);
|
||||
return this.openPackageStream(item, current, total, 0);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const failure = new ModulePackageFetchError(
|
||||
item.module,
|
||||
response.status,
|
||||
`package request returned ${response.status}`,
|
||||
);
|
||||
await this.cancelFailedPackageResponse(response, controller, failure);
|
||||
throw failure;
|
||||
}
|
||||
if (response.body == null) {
|
||||
const failure = new ModulePackageFetchError(item.module, response.status, 'package response had no body');
|
||||
await this.cancelFailedPackageResponse(response, controller, failure);
|
||||
throw failure;
|
||||
}
|
||||
const start = resumed ? offset : 0;
|
||||
return {offset: start, chunks: this.readPackageBody(response.body, controller, start, item, current, total)};
|
||||
}
|
||||
|
||||
private async requestPackage(
|
||||
item: ModulePlanItem,
|
||||
url: string,
|
||||
controller: AbortController,
|
||||
offset: number,
|
||||
): Promise<Response> {
|
||||
const headerTimeoutError = new ModulePackageFetchError(
|
||||
item.module,
|
||||
null,
|
||||
`package response headers exceeded ${MODULE_PACKAGE_HEADER_TIMEOUT_MS}ms`,
|
||||
);
|
||||
const headerTimeout = setTimeout(() => controller.abort(headerTimeoutError), MODULE_PACKAGE_HEADER_TIMEOUT_MS);
|
||||
const headers: Record<string, string> = {accept: 'application/octet-stream'};
|
||||
if (offset > 0) {
|
||||
headers['range'] = `bytes=${offset}-`;
|
||||
}
|
||||
try {
|
||||
return await this.fetchImplementation(url, {signal: controller.signal, headers});
|
||||
} catch (error) {
|
||||
const failure =
|
||||
controller.signal.reason instanceof ModulePackageFetchError
|
||||
? controller.signal.reason
|
||||
: new ModulePackageFetchError(item.module, null, `failed to reach ${url}`, {cause: error});
|
||||
controller.abort(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
clearTimeout(headerTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelFailedPackageResponse(
|
||||
response: Response,
|
||||
controller: AbortController,
|
||||
failure: ModulePackageFetchError,
|
||||
): Promise<void> {
|
||||
controller.abort(failure);
|
||||
if (response.body == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await response.body.cancel(failure);
|
||||
} catch (error) {
|
||||
this.reporter({
|
||||
type: ModulePackageInstallerReportType.NETWORK_ERROR,
|
||||
module: failure.module,
|
||||
message: `failed to cancel package response stream for ${failure.module}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async *readPackageBody(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: AbortController,
|
||||
start: number,
|
||||
item: ModulePlanItem,
|
||||
current: number,
|
||||
total: number,
|
||||
): AsyncGenerator<Uint8Array> {
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
let received = start;
|
||||
let reported = item.entry.bytes === 0 ? 0 : Math.floor((start / item.entry.bytes) * 100);
|
||||
let completed = false;
|
||||
let transferFailure: unknown = null;
|
||||
let stall: NodeJS.Timeout | null = null;
|
||||
const arm = (): void => {
|
||||
stall = setTimeout(() => {
|
||||
controller.abort(
|
||||
new ModulePackageFetchError(
|
||||
item.module,
|
||||
null,
|
||||
`package download stalled for ${MODULE_PACKAGE_STALL_TIMEOUT_MS}ms`,
|
||||
),
|
||||
);
|
||||
}, MODULE_PACKAGE_STALL_TIMEOUT_MS);
|
||||
};
|
||||
const disarm = (): void => {
|
||||
if (stall != null) {
|
||||
clearTimeout(stall);
|
||||
stall = null;
|
||||
}
|
||||
};
|
||||
try {
|
||||
reader = body.getReader();
|
||||
for (;;) {
|
||||
arm();
|
||||
const chunk = await reader.read();
|
||||
disarm();
|
||||
if (chunk.done) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
received += chunk.value.byteLength;
|
||||
if (received > item.entry.bytes) {
|
||||
throw new ModulePackageFetchError(
|
||||
item.module,
|
||||
null,
|
||||
`package body exceeded the declared ${item.entry.bytes} bytes`,
|
||||
);
|
||||
}
|
||||
const percent = item.entry.bytes === 0 ? 100 : Math.floor((received / item.entry.bytes) * 100);
|
||||
if (percent > reported) {
|
||||
reported = percent;
|
||||
this.onProgress({
|
||||
phase: ModulePackageInstallPhase.DOWNLOADING,
|
||||
current,
|
||||
total,
|
||||
moduleName: item.module,
|
||||
progress: percent,
|
||||
});
|
||||
}
|
||||
yield chunk.value;
|
||||
}
|
||||
} catch (error) {
|
||||
transferFailure =
|
||||
controller.signal.reason instanceof ModulePackageFetchError
|
||||
? controller.signal.reason
|
||||
: error instanceof ModulePackageFetchError
|
||||
? error
|
||||
: new ModulePackageFetchError(item.module, null, `package download failed for ${item.module}`, {
|
||||
cause: error,
|
||||
});
|
||||
throw transferFailure;
|
||||
} finally {
|
||||
disarm();
|
||||
if (completed && reader != null) {
|
||||
reader.releaseLock();
|
||||
} else {
|
||||
controller.abort(transferFailure ?? undefined);
|
||||
try {
|
||||
if (reader == null) {
|
||||
await body.cancel(transferFailure);
|
||||
} else {
|
||||
await reader.cancel(transferFailure);
|
||||
}
|
||||
} catch (error) {
|
||||
this.reporter({
|
||||
type: ModulePackageInstallerReportType.NETWORK_ERROR,
|
||||
module: item.module,
|
||||
message: `failed to cancel package response stream for ${item.module}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requirePackageUrl(item: ModulePlanItem): string {
|
||||
const url = new URL(item.entry.url);
|
||||
if (url.protocol === 'https:') {
|
||||
return url.toString();
|
||||
}
|
||||
const base = new URL(this.packageOrigin);
|
||||
if (url.protocol === 'http:' && url.origin === base.origin && MODULE_LOOPBACK_HOSTS.has(base.hostname)) {
|
||||
return url.toString();
|
||||
}
|
||||
throw new ModulePackageUrlPolicyError(item.module, item.entry.url);
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentRangeStart(value: string | null): number | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const match = CONTENT_RANGE_PATTERN.exec(value.trim());
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
return Number.isSafeInteger(start) ? start : null;
|
||||
}
|
||||
Reference in new issue
Block a user