mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
1130 lines
39 KiB
TypeScript
1130 lines
39 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {createHash} from 'node:crypto';
|
|
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
|
import type {DesktopUpdateProbe} from '@electron/main/DesktopUpdateRun';
|
|
import {ModuleDownloadRate} from '@electron/main/ModuleDownloadRate';
|
|
import {
|
|
blockingModuleNames,
|
|
type DesktopLinuxSecurityMinimum,
|
|
type DesktopModuleUpdateManifest,
|
|
resolveDesktopModuleArchitecture,
|
|
} from '@electron/main/ModuleManifest';
|
|
import {
|
|
type ModuleManifestDocument,
|
|
ModuleManifestFetchError,
|
|
ModuleManifestRepository,
|
|
} from '@electron/main/ModuleManifestRepository';
|
|
import {
|
|
describeErrorChain,
|
|
isDeterministicPackageFailure,
|
|
isModulePackageStall,
|
|
isTransientServerStatus,
|
|
ModulePackageFetchError,
|
|
ModulePackageInstaller,
|
|
type ModulePackageInstallerReport,
|
|
ModulePackageInstallerReportType,
|
|
ModulePackageInstallPhase,
|
|
} from '@electron/main/ModulePackageInstaller';
|
|
import {
|
|
type LinuxModuleSecurityMinimum,
|
|
MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD,
|
|
type ModuleLaunchAttempt,
|
|
ModuleManifestEquivocationError,
|
|
type ModuleManifestFeedIdentity,
|
|
type ModuleManifestFeedObservation,
|
|
ModuleManifestRollbackError,
|
|
type ModuleStore,
|
|
sameModuleMap,
|
|
} from '@electron/main/ModuleStore';
|
|
import {
|
|
type ModulePlan,
|
|
type ModulePlanItem,
|
|
ModulePlanItemRequirement,
|
|
ModuleUnreachableLaunchDecision,
|
|
ModuleUpdatePlanner,
|
|
ModuleUpdaterBlockReason,
|
|
type ServedModuleSelection,
|
|
} from '@electron/main/ModuleUpdatePlanner';
|
|
import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
|
|
import {resolveDesktopPackageOrigin} from '@electron/main/ShellDownloadFormats';
|
|
import type {DesktopModuleEnsureResult} from '@fluxer/desktop_ipc/src/ModuleContract';
|
|
import {DESKTOP_RENDERER_MODULE_NAME, DesktopModuleEnsureStatus} from '@fluxer/desktop_ipc/src/ModuleContract';
|
|
|
|
const MODULE_UPDATER_BACKOFF_BASE_MS = 1000;
|
|
const MODULE_UPDATER_BACKOFF_CAP_MS = 30000;
|
|
const MODULE_UPDATER_NETWORK_ERROR_SAMPLE_RATE = 0.01;
|
|
|
|
const MODULE_ENSURE_MANIFEST_BACKOFF_MS = 30000;
|
|
const CAUSE_CHAIN_LIMIT = 8;
|
|
const STORAGE_FULL_CODES = new Set(['ENOSPC', 'EDQUOT', 'EROFS', 'EFBIG']);
|
|
|
|
export const ModuleUpdaterStatus = Object.freeze({
|
|
CHECKING: 'checking',
|
|
DOWNLOADING: 'downloading',
|
|
INSTALLING: 'installing',
|
|
VERIFYING: 'verifying',
|
|
RETRY_WAIT: 'retry-wait',
|
|
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
|
|
BLOCKED_SHELL_UPDATE: 'blocked-shell-update',
|
|
LAUNCHING: 'launching',
|
|
UNREACHABLE_LAUNCH: 'unreachable-launch',
|
|
} as const);
|
|
|
|
export type ModuleUpdaterStatus = (typeof ModuleUpdaterStatus)[keyof typeof ModuleUpdaterStatus];
|
|
|
|
const ModuleUpdaterReportType = Object.freeze({
|
|
NETWORK_ERROR: 'network-error',
|
|
MANIFEST_ROLLBACK: 'manifest-rollback',
|
|
MANIFEST_EQUIVOCATION: 'manifest-equivocation',
|
|
PACKAGE_HASH_MISMATCH: 'package-hash-mismatch',
|
|
PACKAGE_MISSING: 'package-missing',
|
|
PACKAGE_REJECTED: 'package-rejected',
|
|
STORAGE_ERROR: 'storage-error',
|
|
ROLLBACK: 'rollback',
|
|
ROLLBACK_FLOOR_BYPASS: 'rollback-floor-bypass',
|
|
BUNDLED_RENDERER_PREFERRED: 'bundled-renderer-preferred',
|
|
BLOCKED: 'blocked',
|
|
} as const);
|
|
|
|
type ModuleUpdaterReportType = (typeof ModuleUpdaterReportType)[keyof typeof ModuleUpdaterReportType];
|
|
|
|
const MODULE_UPDATER_REPORT_SAMPLE_RATES: Readonly<Record<ModuleUpdaterReportType, number>> = Object.freeze({
|
|
[ModuleUpdaterReportType.NETWORK_ERROR]: MODULE_UPDATER_NETWORK_ERROR_SAMPLE_RATE,
|
|
[ModuleUpdaterReportType.MANIFEST_ROLLBACK]: 1,
|
|
[ModuleUpdaterReportType.MANIFEST_EQUIVOCATION]: 1,
|
|
[ModuleUpdaterReportType.PACKAGE_HASH_MISMATCH]: 1,
|
|
[ModuleUpdaterReportType.PACKAGE_MISSING]: 1,
|
|
[ModuleUpdaterReportType.PACKAGE_REJECTED]: 1,
|
|
[ModuleUpdaterReportType.STORAGE_ERROR]: 1,
|
|
[ModuleUpdaterReportType.ROLLBACK]: 1,
|
|
[ModuleUpdaterReportType.ROLLBACK_FLOOR_BYPASS]: 1,
|
|
[ModuleUpdaterReportType.BUNDLED_RENDERER_PREFERRED]: 1,
|
|
[ModuleUpdaterReportType.BLOCKED]: 1,
|
|
});
|
|
|
|
export {ModuleUpdaterBlockReason};
|
|
|
|
export interface ModuleUpdaterSplashState {
|
|
readonly status: ModuleUpdaterStatus;
|
|
readonly requiredSecurityUpdate: boolean;
|
|
readonly current: number | null;
|
|
readonly total: number | null;
|
|
readonly progress: number | null;
|
|
readonly seconds: number | null;
|
|
readonly moduleName: string | null;
|
|
readonly detail: string | null;
|
|
readonly receivedBytes: number | null;
|
|
readonly totalBytes: number | null;
|
|
readonly bytesPerSecond: number | null;
|
|
readonly stalled: boolean;
|
|
}
|
|
|
|
export interface ModuleUpdaterDiagnostics {
|
|
readonly state: ModuleUpdaterSplashState;
|
|
readonly packageOrigin: string;
|
|
readonly lastError: string | null;
|
|
readonly lastErrorAt: number | null;
|
|
}
|
|
|
|
interface ModuleUpdaterReport {
|
|
readonly type: ModuleUpdaterReportType;
|
|
readonly module: string | null;
|
|
readonly message: string;
|
|
readonly sampleRate: number;
|
|
readonly error: unknown;
|
|
}
|
|
|
|
export type ModuleUpdaterOutcome =
|
|
| {
|
|
readonly status: typeof ModuleUpdaterStatus.LAUNCHING;
|
|
readonly committed: Readonly<Record<string, string>>;
|
|
readonly rolledBack: boolean;
|
|
readonly belowFloor: ReadonlyArray<string>;
|
|
readonly launchAttempt: ModuleLaunchAttempt;
|
|
}
|
|
| {
|
|
readonly status: typeof ModuleUpdaterStatus.UNREACHABLE_LAUNCH;
|
|
readonly committed: Readonly<Record<string, string>>;
|
|
readonly launchAttempt: ModuleLaunchAttempt;
|
|
}
|
|
| {
|
|
readonly status: typeof ModuleUpdaterStatus.BLOCKED_UPDATE_REQUIRED;
|
|
readonly reason: ModuleUpdaterBlockReason;
|
|
readonly modules: ReadonlyArray<string>;
|
|
readonly message: string;
|
|
readonly requiredSecurityUpdate: boolean;
|
|
readonly updateServerUnreachable: boolean;
|
|
}
|
|
| {
|
|
readonly status: typeof ModuleUpdaterStatus.BLOCKED_SHELL_UPDATE;
|
|
readonly latestVersion: string;
|
|
readonly minimumVersion: string;
|
|
readonly requiredSecurityUpdate: boolean;
|
|
};
|
|
|
|
class ModuleUpdaterStorageFullError extends Error {
|
|
public constructor(message: string, options?: ErrorOptions) {
|
|
super(message, options);
|
|
this.name = 'ModuleUpdaterStorageFullError';
|
|
}
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function hasErrorCode(error: unknown, codes: ReadonlySet<string>): boolean {
|
|
let cursor: unknown = error;
|
|
for (let depth = 0; depth < CAUSE_CHAIN_LIMIT && cursor != null; depth += 1) {
|
|
const code = (cursor as {code?: unknown}).code;
|
|
if (typeof code === 'string' && codes.has(code)) {
|
|
return true;
|
|
}
|
|
cursor = (cursor as {cause?: unknown}).cause;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isStorageFullError(error: unknown): boolean {
|
|
return hasErrorCode(error, STORAGE_FULL_CODES);
|
|
}
|
|
|
|
function isUpdateServerUnreachable(error: unknown): boolean {
|
|
let cursor: unknown = error;
|
|
for (let depth = 0; depth < CAUSE_CHAIN_LIMIT && cursor != null; depth += 1) {
|
|
if (cursor instanceof ModuleManifestFetchError || cursor instanceof ModulePackageFetchError) {
|
|
return isTransientServerStatus(cursor.status);
|
|
}
|
|
cursor = (cursor as {cause?: unknown}).cause;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function parseBundledRendererVersion(source: string | null): ModuleVersion | null {
|
|
if (source == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
return parseModuleVersion(source, 'bundled renderer version');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function defaultSleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
class ModuleUpdaterBackoff {
|
|
private readonly random: () => number;
|
|
private attempt = 0;
|
|
private ceiling = false;
|
|
|
|
public constructor(random: () => number) {
|
|
this.random = random;
|
|
}
|
|
|
|
public get reachedCeiling(): boolean {
|
|
return this.ceiling;
|
|
}
|
|
|
|
public fail(): number {
|
|
const exponential = MODULE_UPDATER_BACKOFF_BASE_MS * 2 ** this.attempt;
|
|
this.attempt += 1;
|
|
if (exponential >= MODULE_UPDATER_BACKOFF_CAP_MS) {
|
|
this.ceiling = true;
|
|
}
|
|
const capped = Math.min(exponential, MODULE_UPDATER_BACKOFF_CAP_MS);
|
|
return Math.round(capped * (0.5 + this.random() * 0.5));
|
|
}
|
|
|
|
public reset(): void {
|
|
this.attempt = 0;
|
|
this.ceiling = false;
|
|
}
|
|
}
|
|
|
|
interface ModulePlanResult {
|
|
readonly desired: Readonly<Record<string, string>>;
|
|
readonly installed: ReadonlyArray<string>;
|
|
readonly blocked: ReadonlyArray<string>;
|
|
readonly blockedError: unknown;
|
|
}
|
|
|
|
const ModuleStartupUpdatePolicy = Object.freeze({
|
|
REQUIRED: 'required',
|
|
OPTIONAL: 'optional',
|
|
REQUIRED_SECURITY: 'required-security',
|
|
} as const);
|
|
|
|
type ModuleStartupUpdatePolicy = (typeof ModuleStartupUpdatePolicy)[keyof typeof ModuleStartupUpdatePolicy];
|
|
|
|
const ModuleManifestObservationMode = Object.freeze({
|
|
READ_ONLY: 'read-only',
|
|
RECORD_FETCH: 'record-fetch',
|
|
} as const);
|
|
|
|
type ModuleManifestObservationMode = (typeof ModuleManifestObservationMode)[keyof typeof ModuleManifestObservationMode];
|
|
|
|
interface ModuleUpdaterOptions {
|
|
readonly store: ModuleStore;
|
|
readonly shellVersion: string;
|
|
readonly releaseChannel?: string;
|
|
readonly platform?: string;
|
|
readonly arch?: NodeJS.Architecture;
|
|
readonly packageOrigin?: string;
|
|
readonly hasOfflineRenderer?: boolean;
|
|
readonly bundledRendererVersion?: string | null;
|
|
readonly preferUnversionedBundle?: boolean;
|
|
readonly forceStartupUpdate?: boolean;
|
|
readonly selfUpdateShellFirst?: (latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>;
|
|
readonly onState?: (state: ModuleUpdaterSplashState) => void;
|
|
readonly report?: (report: ModuleUpdaterReport) => void;
|
|
readonly fetch: typeof globalThis.fetch;
|
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
readonly random?: () => number;
|
|
readonly now?: () => number;
|
|
}
|
|
|
|
function isRejectedManifestError(error: unknown): boolean {
|
|
return error instanceof ModuleManifestRollbackError || error instanceof ModuleManifestEquivocationError;
|
|
}
|
|
|
|
export class ModuleUpdater {
|
|
public readonly store: ModuleStore;
|
|
private readonly platform: string;
|
|
private readonly shellVersion: ModuleVersion;
|
|
private readonly hasOfflineRenderer: boolean;
|
|
private readonly forceStartupUpdate: boolean;
|
|
private readonly selfUpdateShellFirst:
|
|
| ((latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>)
|
|
| null;
|
|
private readonly shellFirstAttempted = new Set<string>();
|
|
private readonly manifestFeed: ModuleManifestFeedIdentity;
|
|
private readonly manifestRepository: ModuleManifestRepository;
|
|
private readonly planner: ModuleUpdatePlanner;
|
|
private readonly packageInstaller: ModulePackageInstaller;
|
|
private readonly onState: (state: ModuleUpdaterSplashState) => void;
|
|
private readonly reporter: (report: ModuleUpdaterReport) => void;
|
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
private readonly random: () => number;
|
|
private readonly now: () => number;
|
|
private lastState: ModuleUpdaterSplashState;
|
|
private readonly packageOrigin: string;
|
|
private readonly downloadRate = new ModuleDownloadRate();
|
|
private downloadProgressed = false;
|
|
private lastError: string | null = null;
|
|
private lastErrorAt: number | null = null;
|
|
private ensureBlockedUntil = 0;
|
|
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
|
private manifestObservationTail: Promise<void> = Promise.resolve();
|
|
private recordedManifestSha256: string | null = null;
|
|
|
|
public constructor(options: ModuleUpdaterOptions) {
|
|
this.store = options.store;
|
|
this.shellVersion = parseModuleVersion(options.shellVersion, 'desktop shell version');
|
|
this.hasOfflineRenderer = options.hasOfflineRenderer ?? false;
|
|
this.forceStartupUpdate = options.forceStartupUpdate ?? false;
|
|
this.selfUpdateShellFirst = options.selfUpdateShellFirst ?? null;
|
|
const releaseChannel = options.releaseChannel ?? BUILD_CHANNEL;
|
|
this.platform = options.platform ?? process.platform;
|
|
const arch = resolveDesktopModuleArchitecture(options.arch ?? process.arch);
|
|
this.manifestFeed = {releaseChannel, platform: this.platform, arch};
|
|
const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, '');
|
|
this.packageOrigin = packageOrigin;
|
|
this.planner = new ModuleUpdatePlanner(
|
|
this.store,
|
|
this.shellVersion,
|
|
this.hasOfflineRenderer,
|
|
parseBundledRendererVersion(options.bundledRendererVersion ?? null),
|
|
options.preferUnversionedBundle ?? false,
|
|
);
|
|
this.onState = options.onState ?? (() => {});
|
|
this.reporter = options.report ?? (() => {});
|
|
this.sleep = options.sleep ?? defaultSleep;
|
|
this.random = options.random ?? Math.random;
|
|
this.now = options.now ?? Date.now;
|
|
this.manifestRepository = new ModuleManifestRepository({
|
|
store: this.store,
|
|
target: {releaseChannel, platform: this.platform, arch},
|
|
packageOrigin,
|
|
fetch: options.fetch,
|
|
now: this.now,
|
|
onResponseCancellationError: (message, error) => {
|
|
this.report({type: ModuleUpdaterReportType.NETWORK_ERROR, message, error});
|
|
},
|
|
});
|
|
this.packageInstaller = new ModulePackageInstaller({
|
|
store: this.store,
|
|
packageOrigin,
|
|
fetch: options.fetch,
|
|
now: () => this.now(),
|
|
onProgress: (progress) => {
|
|
switch (progress.phase) {
|
|
case ModulePackageInstallPhase.DOWNLOADING:
|
|
this.recordDownloadProgress(progress.receivedBytes);
|
|
this.emit(ModuleUpdaterStatus.DOWNLOADING, {
|
|
...progress,
|
|
bytesPerSecond: this.downloadRate.sample(progress.receivedBytes, this.now()) ?? undefined,
|
|
});
|
|
break;
|
|
case ModulePackageInstallPhase.INSTALLING:
|
|
this.emit(ModuleUpdaterStatus.INSTALLING, progress);
|
|
break;
|
|
}
|
|
},
|
|
report: (report) => this.handlePackageInstallerReport(report),
|
|
});
|
|
this.startupUpdatePolicy =
|
|
this.platform === 'linux' ? ModuleStartupUpdatePolicy.OPTIONAL : ModuleStartupUpdatePolicy.REQUIRED;
|
|
this.lastState = {
|
|
status: ModuleUpdaterStatus.CHECKING,
|
|
requiredSecurityUpdate: false,
|
|
current: null,
|
|
total: null,
|
|
progress: null,
|
|
seconds: null,
|
|
moduleName: null,
|
|
detail: null,
|
|
receivedBytes: null,
|
|
totalBytes: null,
|
|
bytesPerSecond: null,
|
|
stalled: false,
|
|
};
|
|
}
|
|
|
|
public diagnostics(): ModuleUpdaterDiagnostics {
|
|
return {
|
|
state: this.lastState,
|
|
packageOrigin: this.packageOrigin,
|
|
lastError: this.lastError,
|
|
lastErrorAt: this.lastErrorAt,
|
|
};
|
|
}
|
|
|
|
private recordDownloadProgress(receivedBytes: number): void {
|
|
const previous = this.lastState.status === ModuleUpdaterStatus.DOWNLOADING ? this.lastState.receivedBytes : null;
|
|
if (previous != null && receivedBytes > previous) {
|
|
this.downloadProgressed = true;
|
|
}
|
|
}
|
|
|
|
public getLastState(): ModuleUpdaterSplashState {
|
|
return this.lastState;
|
|
}
|
|
|
|
public async run(): Promise<ModuleUpdaterOutcome> {
|
|
let attempt = await this.store.beginBootAttempt({bundled: this.planner.bundledModules()});
|
|
const retiredRenderer = await this.retireRendererCoveredByBundle();
|
|
const retiredMissing = await this.retireUninstalledModules();
|
|
if (retiredRenderer || retiredMissing) {
|
|
attempt = {...attempt, committed: this.store.getCommitted()};
|
|
}
|
|
await this.restoreCachedUpdatePolicy();
|
|
if (attempt.rolledBack) {
|
|
this.report({
|
|
type: ModuleUpdaterReportType.ROLLBACK,
|
|
message: `reverted to the previous module set after ${MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD} launches without a ready window`,
|
|
});
|
|
const reverted = await this.launchRevertedSetBypassingFloor(attempt.committed);
|
|
if (reverted != null) {
|
|
return reverted;
|
|
}
|
|
}
|
|
if (this.platform === 'linux' && !(await this.startupUpdateRequired())) {
|
|
return await this.launchIfAtFloor();
|
|
}
|
|
const backoff = new ModuleUpdaterBackoff(this.random);
|
|
for (;;) {
|
|
this.emit(ModuleUpdaterStatus.CHECKING);
|
|
this.downloadRate.reset();
|
|
this.downloadProgressed = false;
|
|
let fetched = false;
|
|
try {
|
|
const document = await this.manifestRepository.fetchLatest();
|
|
fetched = true;
|
|
const manifest = await this.observeManifest(document);
|
|
const startupUpdateRequired = await this.startupUpdateRequired();
|
|
if (!startupUpdateRequired) {
|
|
return await this.launchIfAtFloor();
|
|
}
|
|
if (this.planner.shellUpdateRequired(manifest) && (await this.onlyForcedByShellChange())) {
|
|
return await this.launchIfAtFloor();
|
|
}
|
|
if (this.planner.shellUpdateRequired(manifest)) {
|
|
this.emit(ModuleUpdaterStatus.BLOCKED_SHELL_UPDATE);
|
|
return {
|
|
status: ModuleUpdaterStatus.BLOCKED_SHELL_UPDATE,
|
|
latestVersion: manifest.shell.latestVersion.source,
|
|
minimumVersion: manifest.shell.minimumVersion.source,
|
|
requiredSecurityUpdate: this.isSecurityUpdateRequired(),
|
|
};
|
|
}
|
|
if (await this.updateShellFirst(manifest)) {
|
|
this.emit(ModuleUpdaterStatus.CHECKING);
|
|
}
|
|
const plan = await this.planner.plan(manifest);
|
|
if (plan.items.length === 0) {
|
|
await this.commitIfChanged(plan.base);
|
|
return await this.launchIfAtFloor();
|
|
}
|
|
const executed = await this.execute(plan);
|
|
if (executed.blocked.length > 0) {
|
|
return this.blockUpdateRequired(
|
|
ModuleUpdaterBlockReason.REQUIRED_MODULE_UNAVAILABLE,
|
|
executed.blocked,
|
|
`required module ${executed.blocked.join(', ')} could not be installed: ${describeErrorChain(executed.blockedError)}`,
|
|
{error: executed.blockedError},
|
|
);
|
|
}
|
|
if (executed.installed.length > 0) {
|
|
this.emit(ModuleUpdaterStatus.VERIFYING, {current: plan.items.length, total: plan.items.length});
|
|
}
|
|
await this.commitIfChanged(executed.desired);
|
|
await this.applyUpdatePolicy(manifest);
|
|
if (executed.installed.length === 0) {
|
|
return await this.launchIfAtFloor();
|
|
}
|
|
backoff.reset();
|
|
} catch (error) {
|
|
this.reportFailure(error);
|
|
this.lastError = describeErrorChain(error);
|
|
this.lastErrorAt = this.now();
|
|
const detail = isStorageFullError(error) ? 'Not enough disk space to install the update' : null;
|
|
const updateServerUnreachable = !isRejectedManifestError(error) && isUpdateServerUnreachable(error);
|
|
if (this.downloadProgressed) {
|
|
backoff.reset();
|
|
}
|
|
const delayMs = backoff.fail();
|
|
const exhausted =
|
|
backoff.reachedCeiling || isRejectedManifestError(error) || (await this.onlyForcedByShellChange());
|
|
if (exhausted || !fetched) {
|
|
const decision = await this.planner.evaluateUnreachableLaunch(
|
|
this.isStartupUpdateEnforced(),
|
|
this.isSecurityUpdateRequired(),
|
|
);
|
|
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
|
|
const launchAttempt = await this.store.recordLaunchAttempt();
|
|
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
|
|
return {
|
|
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
|
|
committed: this.store.getCommitted(),
|
|
launchAttempt,
|
|
};
|
|
}
|
|
if (exhausted) {
|
|
return this.blockUpdateRequired(decision.reason, [], describeErrorChain(error), {
|
|
detail,
|
|
error,
|
|
updateServerUnreachable,
|
|
});
|
|
}
|
|
}
|
|
const stalled = isModulePackageStall(error);
|
|
const position = stalled && this.lastState.status === ModuleUpdaterStatus.DOWNLOADING ? this.lastState : null;
|
|
this.emit(ModuleUpdaterStatus.RETRY_WAIT, {
|
|
seconds: Math.max(1, Math.round(delayMs / 1000)),
|
|
detail,
|
|
stalled,
|
|
current: position?.current ?? undefined,
|
|
total: position?.total ?? undefined,
|
|
progress: position?.progress ?? undefined,
|
|
moduleName: position?.moduleName ?? undefined,
|
|
receivedBytes: position?.receivedBytes ?? undefined,
|
|
totalBytes: position?.totalBytes ?? undefined,
|
|
});
|
|
await this.sleep(delayMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async ensureModule(moduleName: string): Promise<DesktopModuleEnsureResult> {
|
|
if (this.ensureBlockedUntil > this.now()) {
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
|
|
}
|
|
let manifest: DesktopModuleUpdateManifest;
|
|
try {
|
|
manifest = await this.resolveManifestForEnsure();
|
|
} catch (error) {
|
|
this.ensureBlockedUntil = this.now() + MODULE_ENSURE_MANIFEST_BACKOFF_MS;
|
|
this.reportFailure(error);
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
|
|
}
|
|
if (this.planner.shellUpdateRequired(manifest)) {
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
|
|
}
|
|
if (!Object.hasOwn(manifest.modules, moduleName)) {
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
|
|
}
|
|
const entry = manifest.modules[moduleName];
|
|
if (!this.planner.isShellCompatible(entry)) {
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
|
|
}
|
|
const committed = this.store.getCommitted()[moduleName];
|
|
if (committed != null && (await this.planner.isInstalled(moduleName, committed))) {
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.ALREADY_INSTALLED};
|
|
}
|
|
if (this.store.isRejected(moduleName, entry.sha256)) {
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
|
|
}
|
|
const item: ModulePlanItem = {module: moduleName, entry, requirement: ModulePlanItemRequirement.OPTIONAL};
|
|
try {
|
|
await this.packageInstaller.install(item, 1, 1);
|
|
} catch (error) {
|
|
if (isStorageFullError(error) || !isDeterministicPackageFailure(error)) {
|
|
throw error;
|
|
}
|
|
this.report({
|
|
type: ModuleUpdaterReportType.PACKAGE_MISSING,
|
|
module: moduleName,
|
|
message: errorMessage(error),
|
|
error,
|
|
});
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
|
|
}
|
|
await this.store.mergeCommitted({[moduleName]: entry.sha256});
|
|
return {module: moduleName, status: DesktopModuleEnsureStatus.INSTALLED};
|
|
}
|
|
|
|
private async retireRendererCoveredByBundle(): Promise<boolean> {
|
|
const stale = await this.planner.staleRendererModule();
|
|
if (stale == null) {
|
|
return false;
|
|
}
|
|
if (!(await this.store.retireModule(DESKTOP_RENDERER_MODULE_NAME))) {
|
|
return false;
|
|
}
|
|
this.report({
|
|
type: ModuleUpdaterReportType.BUNDLED_RENDERER_PREFERRED,
|
|
module: DESKTOP_RENDERER_MODULE_NAME,
|
|
message: `retired installed renderer ${stale} because the renderer bundled with this shell is at least as new`,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
private async retireUninstalledModules(): Promise<boolean> {
|
|
let retired = false;
|
|
for (const moduleName of await this.planner.uninstalledCommittedModules()) {
|
|
if (await this.store.retireModule(moduleName)) {
|
|
retired = true;
|
|
this.report({
|
|
type: ModuleUpdaterReportType.PACKAGE_MISSING,
|
|
module: moduleName,
|
|
message: 'dropped a committed module whose files are gone, the bundled renderer does not need it to launch',
|
|
});
|
|
}
|
|
}
|
|
return retired;
|
|
}
|
|
|
|
private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
|
|
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
|
|
}
|
|
|
|
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
|
|
const latestVersion = manifest.shell.latestVersion.source;
|
|
if (
|
|
this.selfUpdateShellFirst == null ||
|
|
compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0 ||
|
|
this.shellFirstAttempted.has(latestVersion) ||
|
|
(this.platform === 'linux' && !this.isSecurityUpdateRequired())
|
|
) {
|
|
return false;
|
|
}
|
|
this.shellFirstAttempted.add(latestVersion);
|
|
await this.selfUpdateShellFirst(latestVersion, this.isSecurityUpdateRequired());
|
|
return true;
|
|
}
|
|
|
|
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
|
|
const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY);
|
|
const shellLatestVersion = manifest.shell.latestVersion.source;
|
|
const shellNewer = compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) > 0;
|
|
if (this.planner.shellUpdateRequired(manifest)) {
|
|
return {shellLatestVersion, shellNewer, modulesChanged: false};
|
|
}
|
|
return {
|
|
shellLatestVersion,
|
|
shellNewer,
|
|
modulesChanged: (await this.collectPendingItems(manifest, this.planner)).length > 0,
|
|
};
|
|
}
|
|
|
|
public async installPending(): Promise<ModuleLaunchAttempt | null> {
|
|
this.emit(ModuleUpdaterStatus.CHECKING);
|
|
this.downloadRate.reset();
|
|
const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.RECORD_FETCH);
|
|
if (this.planner.shellUpdateRequired(manifest)) {
|
|
return null;
|
|
}
|
|
const items = await this.collectPendingItems(manifest, this.planner);
|
|
const {installed, unresolved} = await this.installItems(items);
|
|
if (Object.keys(installed).length > 0) {
|
|
this.emit(ModuleUpdaterStatus.VERIFYING, {current: items.length, total: items.length});
|
|
}
|
|
return await this.store.activateMergedForRendererReload(installed, unresolved);
|
|
}
|
|
|
|
private async fetchFreshManifest(mode: ModuleManifestObservationMode): Promise<DesktopModuleUpdateManifest> {
|
|
this.manifestRepository.clearMemo();
|
|
return await this.observeManifest(await this.manifestRepository.fetchLatest(), mode);
|
|
}
|
|
|
|
private async collectPendingItems(
|
|
manifest: DesktopModuleUpdateManifest,
|
|
planner: ModuleUpdatePlanner,
|
|
): Promise<Array<ModulePlanItem>> {
|
|
const committed = this.store.getCommitted();
|
|
const names = new Set<string>([...Object.keys(committed), ...blockingModuleNames(manifest)]);
|
|
const wanted: Array<ModulePlanItem> = [];
|
|
for (const moduleName of Array.from(names).sort()) {
|
|
const entry = manifest.modules[moduleName];
|
|
if (entry == null || !planner.isShellCompatible(entry) || planner.bundleCoversModule(moduleName, manifest)) {
|
|
continue;
|
|
}
|
|
if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) {
|
|
continue;
|
|
}
|
|
if (this.store.isRejected(moduleName, entry.sha256)) {
|
|
continue;
|
|
}
|
|
wanted.push({module: moduleName, entry, requirement: ModulePlanItemRequirement.OPTIONAL});
|
|
}
|
|
return wanted;
|
|
}
|
|
|
|
private async installItems(
|
|
items: ReadonlyArray<ModulePlanItem>,
|
|
): Promise<{readonly installed: Readonly<Record<string, string>>; readonly unresolved: ReadonlySet<string>}> {
|
|
const committed = this.store.getCommitted();
|
|
const installed: Record<string, string> = {};
|
|
const unresolved = new Set<string>();
|
|
for (let index = 0; index < items.length; index += 1) {
|
|
const item = items[index];
|
|
try {
|
|
await this.packageInstaller.install(item, index + 1, items.length);
|
|
} catch (error) {
|
|
if (isStorageFullError(error) || !isDeterministicPackageFailure(error)) {
|
|
throw error;
|
|
}
|
|
this.report({
|
|
type: ModuleUpdaterReportType.PACKAGE_MISSING,
|
|
module: item.module,
|
|
message: errorMessage(error),
|
|
error,
|
|
});
|
|
const committedSha = committed[item.module];
|
|
if (committedSha === undefined || !(await this.planner.isInstalled(item.module, committedSha))) {
|
|
unresolved.add(item.module);
|
|
}
|
|
continue;
|
|
}
|
|
installed[item.module] = item.entry.sha256;
|
|
}
|
|
return {installed, unresolved};
|
|
}
|
|
|
|
public async selectServedModules(modules: Readonly<Record<string, string>>): Promise<ServedModuleSelection> {
|
|
return await this.planner.selectServedModules(modules);
|
|
}
|
|
|
|
public async revertLaunch(attempt: ModuleLaunchAttempt): Promise<Readonly<Record<string, string>> | null> {
|
|
const reverted = await this.store.revertLaunchAttempt(attempt, this.planner.bundledModules());
|
|
if (reverted != null) {
|
|
this.report({
|
|
type: ModuleUpdaterReportType.ROLLBACK,
|
|
message: 'reverted an in place module update whose renderer never confirmed it started',
|
|
});
|
|
}
|
|
return reverted;
|
|
}
|
|
|
|
public async markLaunchSucceeded(attempt: ModuleLaunchAttempt): Promise<void> {
|
|
if (!(await this.store.markLaunchAttemptSucceeded(attempt))) {
|
|
return;
|
|
}
|
|
await this.store.collectGarbage({now: this.now()});
|
|
}
|
|
|
|
public async canLaunchWhileUnreachable(): Promise<boolean> {
|
|
return (
|
|
(await this.planner.evaluateUnreachableLaunch(this.isStartupUpdateEnforced(), this.isSecurityUpdateRequired()))
|
|
.kind === ModuleUnreachableLaunchDecision.LAUNCH
|
|
);
|
|
}
|
|
|
|
private async launchIfAtFloor(): Promise<ModuleUpdaterOutcome> {
|
|
if (this.isSecurityUpdateRequired()) {
|
|
return this.blockUpdateRequired(
|
|
ModuleUpdaterBlockReason.BELOW_FLOOR,
|
|
[],
|
|
'the installed shell or module set is below the required Linux security minimum',
|
|
);
|
|
}
|
|
const below = await this.planner.modulesBelowFloor(this.isStartupUpdateEnforced());
|
|
if (below.length > 0) {
|
|
return this.blockUpdateRequired(
|
|
ModuleUpdaterBlockReason.BELOW_FLOOR,
|
|
below,
|
|
`the installed set is below the required floor for ${below.join(', ')}`,
|
|
);
|
|
}
|
|
if (!this.planner.hasSomethingToRender(this.store.getCommitted())) {
|
|
return this.blockUpdateRequired(
|
|
ModuleUpdaterBlockReason.NOTHING_INSTALLED,
|
|
[],
|
|
'no module is committed and this build carries no offline renderer',
|
|
);
|
|
}
|
|
await this.store.recordShellVersionConverged();
|
|
const launchAttempt = await this.store.recordLaunchAttempt();
|
|
this.emit(ModuleUpdaterStatus.LAUNCHING);
|
|
return {
|
|
status: ModuleUpdaterStatus.LAUNCHING,
|
|
committed: this.store.getCommitted(),
|
|
rolledBack: false,
|
|
belowFloor: [],
|
|
launchAttempt,
|
|
};
|
|
}
|
|
|
|
private async launchRevertedSetBypassingFloor(
|
|
committed: Readonly<Record<string, string>>,
|
|
): Promise<ModuleUpdaterOutcome | null> {
|
|
if (this.isSecurityUpdateRequired()) {
|
|
return null;
|
|
}
|
|
const names = Object.keys(committed);
|
|
if (names.length === 0) {
|
|
return null;
|
|
}
|
|
for (const moduleName of names) {
|
|
if (!(await this.planner.isInstalled(moduleName, committed[moduleName]))) {
|
|
return null;
|
|
}
|
|
}
|
|
const belowFloor = await this.planner.modulesBelowFloor(this.isStartupUpdateEnforced());
|
|
if (belowFloor.length > 0) {
|
|
this.report({
|
|
type: ModuleUpdaterReportType.ROLLBACK_FLOOR_BYPASS,
|
|
message: `launching the reverted set below the persisted floor for ${belowFloor.join(', ')}, because recovering a bricked install outranks enforcing the forced update`,
|
|
});
|
|
}
|
|
const launchAttempt = await this.store.recordLaunchAttempt();
|
|
this.emit(ModuleUpdaterStatus.LAUNCHING);
|
|
return {
|
|
status: ModuleUpdaterStatus.LAUNCHING,
|
|
committed,
|
|
rolledBack: true,
|
|
belowFloor,
|
|
launchAttempt,
|
|
};
|
|
}
|
|
|
|
private blockUpdateRequired(
|
|
reason: ModuleUpdaterBlockReason,
|
|
modules: ReadonlyArray<string>,
|
|
message: string,
|
|
{
|
|
detail = null,
|
|
error = null,
|
|
updateServerUnreachable = false,
|
|
}: {readonly detail?: string | null; readonly error?: unknown; readonly updateServerUnreachable?: boolean} = {},
|
|
): ModuleUpdaterOutcome {
|
|
this.report({type: ModuleUpdaterReportType.BLOCKED, message: `${reason}: ${message}`, error});
|
|
this.emit(ModuleUpdaterStatus.BLOCKED_UPDATE_REQUIRED, {detail});
|
|
return {
|
|
status: ModuleUpdaterStatus.BLOCKED_UPDATE_REQUIRED,
|
|
reason,
|
|
modules,
|
|
message,
|
|
requiredSecurityUpdate: this.isSecurityUpdateRequired(),
|
|
updateServerUnreachable,
|
|
};
|
|
}
|
|
|
|
private async applyUpdatePolicy(manifest: DesktopModuleUpdateManifest): Promise<void> {
|
|
if (this.platform !== 'linux') {
|
|
this.startupUpdatePolicy = ModuleStartupUpdatePolicy.REQUIRED;
|
|
return;
|
|
}
|
|
await this.applyLinuxUpdatePolicy(manifest.linuxSecurityMinimum);
|
|
}
|
|
|
|
private persistedLinuxSecurityMinimum(): DesktopLinuxSecurityMinimum | null {
|
|
const persisted = this.store.getState().linux_security_minimum;
|
|
if (persisted == null) {
|
|
return null;
|
|
}
|
|
return {
|
|
version: parseModuleVersion(persisted.version, 'persisted Linux security minimum'),
|
|
requiredModules: persisted.requiredModules,
|
|
};
|
|
}
|
|
|
|
private effectiveLinuxSecurityMinimum(
|
|
manifestMinimum: DesktopLinuxSecurityMinimum | null,
|
|
): DesktopLinuxSecurityMinimum | null {
|
|
const persisted = this.persistedLinuxSecurityMinimum();
|
|
if (manifestMinimum == null) {
|
|
return persisted;
|
|
}
|
|
if (persisted == null) {
|
|
return manifestMinimum;
|
|
}
|
|
const comparison = compareModuleVersions(manifestMinimum.version, persisted.version);
|
|
if (comparison < 0) {
|
|
throw new Error(
|
|
`manifest attempts to lower the Linux security minimum from ${persisted.version.source} to ${manifestMinimum.version.source}`,
|
|
);
|
|
}
|
|
if (comparison === 0 && manifestMinimum.requiredModules.join('\0') !== persisted.requiredModules.join('\0')) {
|
|
throw new Error(`manifest changes the required modules at Linux security minimum ${persisted.version.source}`);
|
|
}
|
|
return manifestMinimum;
|
|
}
|
|
|
|
private async applyLinuxUpdatePolicy(manifestMinimum: DesktopLinuxSecurityMinimum | null): Promise<void> {
|
|
const minimum = this.effectiveLinuxSecurityMinimum(manifestMinimum);
|
|
if (minimum != null) {
|
|
this.startupUpdatePolicy = (await this.planner.securityUpdateRequired(minimum))
|
|
? ModuleStartupUpdatePolicy.REQUIRED_SECURITY
|
|
: ModuleStartupUpdatePolicy.OPTIONAL;
|
|
return;
|
|
}
|
|
this.startupUpdatePolicy = ModuleStartupUpdatePolicy.OPTIONAL;
|
|
}
|
|
|
|
private isStartupUpdateEnforced(): boolean {
|
|
return this.startupUpdatePolicy !== ModuleStartupUpdatePolicy.OPTIONAL;
|
|
}
|
|
|
|
private isSecurityUpdateRequired(): boolean {
|
|
return this.startupUpdatePolicy === ModuleStartupUpdatePolicy.REQUIRED_SECURITY;
|
|
}
|
|
|
|
private async restoreCachedUpdatePolicy(): Promise<void> {
|
|
if (this.platform !== 'linux') {
|
|
return;
|
|
}
|
|
await this.applyLinuxUpdatePolicy(null);
|
|
const document = await this.manifestRepository.readCached();
|
|
if (document == null) {
|
|
return;
|
|
}
|
|
try {
|
|
await this.observeManifest(document, ModuleManifestObservationMode.READ_ONLY);
|
|
} catch (error) {
|
|
this.reportFailure(error);
|
|
}
|
|
}
|
|
|
|
private observeManifest(
|
|
document: ModuleManifestDocument,
|
|
mode: ModuleManifestObservationMode = ModuleManifestObservationMode.RECORD_FETCH,
|
|
): Promise<DesktopModuleUpdateManifest> {
|
|
const observe = async (): Promise<DesktopModuleUpdateManifest> => {
|
|
const manifest = this.manifestRepository.parse(document);
|
|
const observation: ModuleManifestFeedObservation = {
|
|
feed: this.manifestFeed,
|
|
metadataVersion: manifest.metadataVersion,
|
|
manifestSha256: createHash('sha256').update(document.bytes).digest('hex'),
|
|
};
|
|
this.store.requireManifestFresh(observation);
|
|
await this.applyUpdatePolicy(manifest);
|
|
if (
|
|
mode === ModuleManifestObservationMode.RECORD_FETCH &&
|
|
this.recordedManifestSha256 !== observation.manifestSha256
|
|
) {
|
|
await this.persistManifest(document, manifest, observation);
|
|
this.recordedManifestSha256 = observation.manifestSha256;
|
|
}
|
|
return manifest;
|
|
};
|
|
const observed = this.manifestObservationTail.then(observe, observe);
|
|
this.manifestObservationTail = observed.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return observed;
|
|
}
|
|
|
|
private async onlyForcedByShellChange(): Promise<boolean> {
|
|
return (
|
|
this.forceStartupUpdate && !this.isStartupUpdateEnforced() && (await this.planner.canLaunchCommittedModules())
|
|
);
|
|
}
|
|
|
|
private async startupUpdateRequired(): Promise<boolean> {
|
|
return (
|
|
this.forceStartupUpdate || this.isStartupUpdateEnforced() || !(await this.planner.canLaunchCommittedModules())
|
|
);
|
|
}
|
|
|
|
private async execute(plan: ModulePlan): Promise<ModulePlanResult> {
|
|
const desired: Record<string, string> = {...plan.base};
|
|
const installed: Array<string> = [];
|
|
const blocked: Array<string> = [];
|
|
let blockedError: unknown = null;
|
|
const total = plan.items.length;
|
|
for (let index = 0; index < total; index += 1) {
|
|
const item = plan.items[index];
|
|
try {
|
|
await this.packageInstaller.install(item, index + 1, total);
|
|
desired[item.module] = item.entry.sha256;
|
|
installed.push(item.module);
|
|
} catch (error) {
|
|
if (isStorageFullError(error)) {
|
|
this.report({
|
|
type: ModuleUpdaterReportType.STORAGE_ERROR,
|
|
module: item.module,
|
|
message: errorMessage(error),
|
|
error,
|
|
});
|
|
throw new ModuleUpdaterStorageFullError(`no space left to install ${item.module}`, {cause: error});
|
|
}
|
|
if (!isDeterministicPackageFailure(error)) {
|
|
throw error;
|
|
}
|
|
if (item.requirement === ModulePlanItemRequirement.REQUIRED) {
|
|
blocked.push(item.module);
|
|
blockedError = error;
|
|
break;
|
|
}
|
|
await this.planner.retainCommitted(desired, item.module);
|
|
}
|
|
}
|
|
return {desired, installed, blocked, blockedError};
|
|
}
|
|
|
|
private async persistManifest(
|
|
document: ModuleManifestDocument,
|
|
manifest: DesktopModuleUpdateManifest,
|
|
observation: ModuleManifestFeedObservation,
|
|
): Promise<void> {
|
|
const floor: Record<string, string> = {};
|
|
const advertised: Record<string, string> = {};
|
|
for (const [moduleName, entry] of Object.entries(manifest.modules)) {
|
|
advertised[moduleName] = entry.sha256;
|
|
}
|
|
let linuxSecurityMinimum: LinuxModuleSecurityMinimum | undefined;
|
|
if (this.platform === 'linux') {
|
|
if (manifest.linuxSecurityMinimum != null) {
|
|
linuxSecurityMinimum = {
|
|
version: manifest.linuxSecurityMinimum.version.source,
|
|
requiredModules: manifest.linuxSecurityMinimum.requiredModules,
|
|
};
|
|
}
|
|
} else {
|
|
for (const moduleName of blockingModuleNames(manifest)) {
|
|
if (this.planner.bundleCoversModule(moduleName, manifest)) {
|
|
continue;
|
|
}
|
|
floor[moduleName] = manifest.modules[moduleName].sha256;
|
|
}
|
|
}
|
|
await this.manifestRepository.persist(document);
|
|
await this.store.recordManifestFetch({
|
|
etag: document.etag,
|
|
fetchedAt: new Date(this.now()).toISOString(),
|
|
manifest: observation,
|
|
floor,
|
|
linuxSecurityMinimum,
|
|
advertised,
|
|
});
|
|
}
|
|
|
|
private async commitIfChanged(desired: Readonly<Record<string, string>>): Promise<void> {
|
|
if (sameModuleMap(this.store.getCommitted(), desired)) {
|
|
return;
|
|
}
|
|
await this.store.commit(desired);
|
|
}
|
|
|
|
private reportFailure(error: unknown): void {
|
|
if (error instanceof ModuleManifestRollbackError) {
|
|
this.report({type: ModuleUpdaterReportType.MANIFEST_ROLLBACK, message: error.message, error});
|
|
return;
|
|
}
|
|
if (error instanceof ModuleManifestEquivocationError) {
|
|
this.report({type: ModuleUpdaterReportType.MANIFEST_EQUIVOCATION, message: error.message, error});
|
|
return;
|
|
}
|
|
if (isStorageFullError(error)) {
|
|
this.report({type: ModuleUpdaterReportType.STORAGE_ERROR, message: errorMessage(error), error});
|
|
return;
|
|
}
|
|
this.report({type: ModuleUpdaterReportType.NETWORK_ERROR, message: errorMessage(error), error});
|
|
}
|
|
|
|
private handlePackageInstallerReport(report: ModulePackageInstallerReport): void {
|
|
switch (report.type) {
|
|
case ModulePackageInstallerReportType.HASH_MISMATCH:
|
|
this.report({...report, type: ModuleUpdaterReportType.PACKAGE_HASH_MISMATCH});
|
|
break;
|
|
case ModulePackageInstallerReportType.PACKAGE_MISSING:
|
|
this.report({...report, type: ModuleUpdaterReportType.PACKAGE_MISSING});
|
|
break;
|
|
case ModulePackageInstallerReportType.PACKAGE_REJECTED:
|
|
this.report({...report, type: ModuleUpdaterReportType.PACKAGE_REJECTED});
|
|
break;
|
|
case ModulePackageInstallerReportType.NETWORK_ERROR:
|
|
this.report({...report, type: ModuleUpdaterReportType.NETWORK_ERROR});
|
|
break;
|
|
}
|
|
}
|
|
|
|
private report({
|
|
type,
|
|
module: moduleName = null,
|
|
message,
|
|
error = null,
|
|
}: {
|
|
readonly type: ModuleUpdaterReportType;
|
|
readonly module?: string | null;
|
|
readonly message: string;
|
|
readonly error?: unknown;
|
|
}): void {
|
|
const sampleRate = MODULE_UPDATER_REPORT_SAMPLE_RATES[type];
|
|
if (sampleRate < 1 && this.random() >= sampleRate) {
|
|
return;
|
|
}
|
|
this.reporter({type, module: moduleName, message, sampleRate, error});
|
|
}
|
|
|
|
private emit(
|
|
status: ModuleUpdaterStatus,
|
|
patch: {
|
|
readonly current?: number;
|
|
readonly total?: number;
|
|
readonly progress?: number;
|
|
readonly seconds?: number;
|
|
readonly moduleName?: string;
|
|
readonly detail?: string | null;
|
|
readonly receivedBytes?: number;
|
|
readonly totalBytes?: number;
|
|
readonly bytesPerSecond?: number;
|
|
readonly stalled?: boolean;
|
|
} = {},
|
|
): void {
|
|
this.lastState = {
|
|
status,
|
|
requiredSecurityUpdate: this.isSecurityUpdateRequired(),
|
|
current: patch.current ?? null,
|
|
total: patch.total ?? null,
|
|
progress: patch.progress ?? null,
|
|
seconds: patch.seconds ?? null,
|
|
moduleName: patch.moduleName ?? null,
|
|
detail: patch.detail ?? null,
|
|
receivedBytes: patch.receivedBytes ?? null,
|
|
totalBytes: patch.totalBytes ?? null,
|
|
bytesPerSecond: patch.bytesPerSecond ?? null,
|
|
stalled: patch.stalled === true,
|
|
};
|
|
this.onState(this.lastState);
|
|
}
|
|
}
|