mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(desktop): update from the splash only after the user clicks (#3280)
This commit is contained in:
1 parent
f1373a63c3
commit
7937d5f802
90 files changed
+6888
-11255
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
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,
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
ModuleUpdatePlanner,
|
||||
ModuleUpdaterBlockReason,
|
||||
} from '@electron/main/ModuleUpdatePlanner';
|
||||
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
|
||||
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 {DesktopModuleEnsureStatus} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
@@ -71,21 +72,6 @@ export const ModuleUpdaterStatus = Object.freeze({
|
||||
|
||||
export type ModuleUpdaterStatus = (typeof ModuleUpdaterStatus)[keyof typeof ModuleUpdaterStatus];
|
||||
|
||||
const ModuleRefreshStatus = Object.freeze({
|
||||
UNCHANGED: 'unchanged',
|
||||
AWAITING_RENDERER: 'awaiting-renderer',
|
||||
ACTIVATED: 'activated',
|
||||
} as const);
|
||||
|
||||
type ModuleRefreshResult =
|
||||
| {readonly status: typeof ModuleRefreshStatus.UNCHANGED}
|
||||
| {readonly status: typeof ModuleRefreshStatus.AWAITING_RENDERER}
|
||||
| {
|
||||
readonly status: typeof ModuleRefreshStatus.ACTIVATED;
|
||||
readonly modules: ReadonlyArray<string>;
|
||||
readonly launchAttempt: ModuleLaunchAttempt;
|
||||
};
|
||||
|
||||
const ModuleUpdaterReportType = Object.freeze({
|
||||
NETWORK_ERROR: 'network-error',
|
||||
MANIFEST_ROLLBACK: 'manifest-rollback',
|
||||
@@ -277,6 +263,8 @@ interface ModuleUpdaterOptions {
|
||||
readonly arch?: NodeJS.Architecture;
|
||||
readonly packageOrigin?: string;
|
||||
readonly hasOfflineRenderer?: 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;
|
||||
@@ -292,6 +280,13 @@ function isRejectedManifestError(error: unknown): boolean {
|
||||
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;
|
||||
@@ -311,18 +306,20 @@ export class ModuleUpdater {
|
||||
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
||||
private manifestObservationTail: Promise<void> = Promise.resolve();
|
||||
private recordedManifestSha256: string | null = null;
|
||||
private pendingRendererLaunch: ModuleLaunchAttempt | null = null;
|
||||
|
||||
public constructor(options: ModuleUpdaterOptions) {
|
||||
this.store = options.store;
|
||||
const shellVersion = parseModuleVersion(options.shellVersion, 'desktop shell version');
|
||||
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, shellVersion, options.hasOfflineRenderer ?? false);
|
||||
this.planner = new ModuleUpdatePlanner(this.store, this.shellVersion, this.hasOfflineRenderer);
|
||||
this.onState = options.onState ?? (() => {});
|
||||
this.reporter = options.report ?? (() => {});
|
||||
this.sleep = options.sleep ?? defaultSleep;
|
||||
@@ -427,6 +424,9 @@ export class ModuleUpdater {
|
||||
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 {
|
||||
@@ -436,6 +436,9 @@ export class ModuleUpdater {
|
||||
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);
|
||||
@@ -469,14 +472,15 @@ export class ModuleUpdater {
|
||||
backoff.reset();
|
||||
}
|
||||
const delayMs = backoff.fail();
|
||||
const exhausted = backoff.reachedCeiling || isRejectedManifestError(error);
|
||||
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.recordRendererLaunchAttempt();
|
||||
const launchAttempt = await this.store.recordLaunchAttempt();
|
||||
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
|
||||
return {
|
||||
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
|
||||
@@ -562,24 +566,67 @@ export class ModuleUpdater {
|
||||
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
|
||||
}
|
||||
|
||||
public async refreshInstalledModules(): Promise<ModuleRefreshResult> {
|
||||
if (this.pendingRendererLaunch != null) {
|
||||
return {status: ModuleRefreshStatus.AWAITING_RENDERER};
|
||||
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)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.manifestRepository.clearMemo();
|
||||
const manifest = await this.observeManifest(await this.manifestRepository.fetchLatest());
|
||||
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 {status: ModuleRefreshStatus.UNCHANGED};
|
||||
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 || !this.planner.isShellCompatible(entry)) {
|
||||
if (entry == null || !planner.isShellCompatible(entry)) {
|
||||
continue;
|
||||
}
|
||||
if (committed[moduleName] === entry.sha256 && (await this.planner.isInstalled(moduleName, entry.sha256))) {
|
||||
if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) {
|
||||
continue;
|
||||
}
|
||||
if (this.store.isRejected(moduleName, entry.sha256)) {
|
||||
@@ -587,15 +634,19 @@ export class ModuleUpdater {
|
||||
}
|
||||
wanted.push({module: moduleName, entry, requirement: ModulePlanItemRequirement.OPTIONAL});
|
||||
}
|
||||
if (wanted.length === 0) {
|
||||
return {status: ModuleRefreshStatus.UNCHANGED};
|
||||
}
|
||||
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 < wanted.length; index += 1) {
|
||||
const item = wanted[index];
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
try {
|
||||
await this.packageInstaller.install(item, index + 1, wanted.length);
|
||||
await this.packageInstaller.install(item, index + 1, items.length);
|
||||
} catch (error) {
|
||||
if (isStorageFullError(error) || !isDeterministicPackageFailure(error)) {
|
||||
throw error;
|
||||
@@ -614,34 +665,16 @@ export class ModuleUpdater {
|
||||
}
|
||||
installed[item.module] = item.entry.sha256;
|
||||
}
|
||||
const activated = await this.store.activateMergedForRendererReload(installed, unresolved);
|
||||
if (activated == null) {
|
||||
return {status: ModuleRefreshStatus.UNCHANGED};
|
||||
}
|
||||
this.pendingRendererLaunch = activated;
|
||||
return {
|
||||
status: ModuleRefreshStatus.ACTIVATED,
|
||||
modules: Object.keys(installed),
|
||||
launchAttempt: activated,
|
||||
};
|
||||
return {installed, unresolved};
|
||||
}
|
||||
|
||||
public async markLaunchSucceeded(attempt: ModuleLaunchAttempt): Promise<void> {
|
||||
if (!(await this.store.markLaunchAttemptSucceeded(attempt))) {
|
||||
return;
|
||||
}
|
||||
if (this.pendingRendererLaunch === attempt) {
|
||||
this.pendingRendererLaunch = null;
|
||||
}
|
||||
await this.store.collectGarbage({now: this.now()});
|
||||
}
|
||||
|
||||
public abandonPendingLaunch(attempt: ModuleLaunchAttempt): void {
|
||||
if (this.pendingRendererLaunch === attempt) {
|
||||
this.pendingRendererLaunch = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async canLaunchWhileUnreachable(): Promise<boolean> {
|
||||
return (
|
||||
(await this.planner.evaluateUnreachableLaunch(this.isStartupUpdateEnforced(), this.isSecurityUpdateRequired()))
|
||||
@@ -672,7 +705,8 @@ export class ModuleUpdater {
|
||||
'no module is committed and this build carries no offline renderer',
|
||||
);
|
||||
}
|
||||
const launchAttempt = await this.recordRendererLaunchAttempt();
|
||||
await this.store.recordShellVersionConverged();
|
||||
const launchAttempt = await this.store.recordLaunchAttempt();
|
||||
this.emit(ModuleUpdaterStatus.LAUNCHING);
|
||||
return {
|
||||
status: ModuleUpdaterStatus.LAUNCHING,
|
||||
@@ -705,7 +739,7 @@ export class ModuleUpdater {
|
||||
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.recordRendererLaunchAttempt();
|
||||
const launchAttempt = await this.store.recordLaunchAttempt();
|
||||
this.emit(ModuleUpdaterStatus.LAUNCHING);
|
||||
return {
|
||||
status: ModuleUpdaterStatus.LAUNCHING,
|
||||
@@ -844,8 +878,16 @@ export class ModuleUpdater {
|
||||
return observed;
|
||||
}
|
||||
|
||||
private async onlyForcedByShellChange(): Promise<boolean> {
|
||||
return (
|
||||
this.forceStartupUpdate && !this.isStartupUpdateEnforced() && (await this.planner.canLaunchCommittedModules())
|
||||
);
|
||||
}
|
||||
|
||||
private async startupUpdateRequired(): Promise<boolean> {
|
||||
return this.isStartupUpdateEnforced() || !(await this.planner.canLaunchCommittedModules());
|
||||
return (
|
||||
this.forceStartupUpdate || this.isStartupUpdateEnforced() || !(await this.planner.canLaunchCommittedModules())
|
||||
);
|
||||
}
|
||||
|
||||
private async execute(plan: ModulePlan): Promise<ModulePlanResult> {
|
||||
@@ -884,15 +926,6 @@ export class ModuleUpdater {
|
||||
return {desired, installed, blocked, blockedError};
|
||||
}
|
||||
|
||||
private async recordRendererLaunchAttempt(): Promise<ModuleLaunchAttempt> {
|
||||
if (this.pendingRendererLaunch != null) {
|
||||
throw new Error('cannot begin a module renderer launch while another launch is awaiting readiness');
|
||||
}
|
||||
const launchAttempt = await this.store.recordLaunchAttempt();
|
||||
this.pendingRendererLaunch = launchAttempt;
|
||||
return launchAttempt;
|
||||
}
|
||||
|
||||
private async persistManifest(
|
||||
document: ModuleManifestDocument,
|
||||
manifest: DesktopModuleUpdateManifest,
|
||||
|
||||
Reference in new issue
Block a user