Files
fluxer/fluxer_app/src/features/updater/state/UpdaterStateMachine.ts
T

435 lines
12 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
import {assign, initialTransition, type SnapshotFrom, setup, transition} from 'xstate';
export type UpdaterState = 'idle' | 'checking' | 'available';
export type UpdateType = 'native' | 'web' | 'both' | null;
export interface NativeUpdateInfo {
available: boolean;
downloaded: boolean;
downloading: boolean;
installing: boolean;
version: string | null;
downloadSize: number | null;
}
export interface NativeDownloadProgress {
percent: number;
transferred: number;
total: number;
bytesPerSecond: number;
}
export interface WebUpdateInfo {
available: boolean;
version: string | null;
}
export interface UpdateInfo {
native: NativeUpdateInfo;
web: WebUpdateInfo;
}
export interface NativeUnsupportedUpdate {
reason: 'platform' | 'unpackaged' | 'managed-package';
downloadUrl: string | null;
}
export interface UpdaterMachineContext {
updateInfo: UpdateInfo;
downloadProgress: NativeDownloadProgress | null;
lastCheckedAt: number | null;
isChecking: boolean;
checkInProgress: boolean;
nativeCheckFailed: boolean;
manualNativeDownloadInFlight: boolean;
nativeUnsupported: NativeUnsupportedUpdate | null;
nativeManualDownloadUrl: string | null;
nativeManualDownloadOptions: ReadonlyArray<UpdaterDownloadOption>;
}
export type UpdaterMachineEvent =
| {type: 'check.started'}
| {type: 'check.finished'; now: number}
| {type: 'check.failed'; now: number}
| {type: 'web.checked'; available: boolean; version: string | null}
| {
type: 'native.available';
version: string | null;
downloadSize: number | null;
downloadStarted: boolean;
downloadUrl: string | null;
downloadOptions: ReadonlyArray<UpdaterDownloadOption>;
}
| {type: 'native.hidden'; reason: 'platform'; downloadUrl: string | null; now: number}
| {type: 'native.notAvailable'; now: number}
| {type: 'native.error'}
| {type: 'native.downloaded'; version: string | null}
| {type: 'native.progress'; progress: NativeDownloadProgress}
| {
type: 'native.unsupported';
reason: 'platform' | 'unpackaged' | 'managed-package';
downloadUrl: string | null;
now: number;
}
| {type: 'native.download.started'; progressSupported: boolean; total: number | null}
| {type: 'native.download.failed'}
| {type: 'native.install.started'}
| {type: 'native.install.failed'}
| {type: 'manualDownload.started'}
| {type: 'manualDownload.finished'}
| {type: 'reset'};
const EMPTY_DOWNLOAD_OPTIONS: ReadonlyArray<UpdaterDownloadOption> = Object.freeze([]);
function createEmptyNativeUpdateInfo(): NativeUpdateInfo {
return {
available: false,
downloaded: false,
downloading: false,
installing: false,
version: null,
downloadSize: null,
};
}
function createInitialUpdateInfo(): UpdateInfo {
return {
native: createEmptyNativeUpdateInfo(),
web: {available: false, version: null},
};
}
export function createInitialUpdaterContext(): UpdaterMachineContext {
return {
updateInfo: createInitialUpdateInfo(),
downloadProgress: null,
lastCheckedAt: null,
isChecking: false,
checkInProgress: false,
nativeCheckFailed: false,
manualNativeDownloadInFlight: false,
nativeUnsupported: null,
nativeManualDownloadUrl: null,
nativeManualDownloadOptions: EMPTY_DOWNLOAD_OPTIONS,
};
}
function hasAnyUpdate(context: UpdaterMachineContext): boolean {
return context.updateInfo.native.available || context.updateInfo.web.available;
}
function clearNativeUpdate(context: UpdaterMachineContext): UpdaterMachineContext {
return {
...context,
updateInfo: {
...context.updateInfo,
native: createEmptyNativeUpdateInfo(),
},
downloadProgress: null,
nativeManualDownloadUrl: null,
nativeManualDownloadOptions: EMPTY_DOWNLOAD_OPTIONS,
};
}
export const updaterStateMachine = setup({
types: {} as {
context: UpdaterMachineContext;
events: UpdaterMachineEvent;
},
actions: {
reset: assign(() => createInitialUpdaterContext()),
markChecking: assign(() => ({
isChecking: true,
checkInProgress: true,
nativeCheckFailed: false,
})),
markCheckFinished: assign(({context, event}) => ({
lastCheckedAt:
event.type === 'check.finished' || event.type === 'check.failed' ? event.now : context.lastCheckedAt,
isChecking: false,
checkInProgress: false,
})),
applyWebChecked: assign(({context, event}) => {
if (event.type !== 'web.checked') return {};
return {
updateInfo: {
...context.updateInfo,
web: {
available: event.available,
version: event.version,
},
},
};
}),
applyNativeAvailable: assign(({context, event}) => {
if (event.type !== 'native.available') return {};
const currentNative = context.updateInfo.native;
if (currentNative.downloaded && (event.version == null || event.version === currentNative.version)) {
return {
isChecking: false,
nativeUnsupported: null,
};
}
return {
updateInfo: {
...context.updateInfo,
native: {
available: true,
downloaded: false,
downloading: event.downloadStarted,
installing: false,
version: event.version,
downloadSize: event.downloadSize,
},
},
downloadProgress: null,
isChecking: false,
nativeUnsupported: null,
nativeManualDownloadUrl: event.downloadUrl,
nativeManualDownloadOptions: [...event.downloadOptions],
};
}),
hideNativeUpdate: assign(({context, event}) => {
if (event.type !== 'native.hidden') return {};
return {
...clearNativeUpdate(context),
lastCheckedAt: event.now,
isChecking: false,
checkInProgress: false,
nativeUnsupported: {
reason: event.reason,
downloadUrl: event.downloadUrl,
},
};
}),
applyNativeNotAvailable: assign(({context, event}) => {
if (event.type !== 'native.notAvailable') return {};
if (context.updateInfo.native.downloaded) {
return {
lastCheckedAt: event.now,
isChecking: false,
};
}
return {
...clearNativeUpdate(context),
lastCheckedAt: event.now,
isChecking: false,
nativeUnsupported: null,
};
}),
applyNativeError: assign(({context}) => ({
updateInfo: {
...context.updateInfo,
native: {
...context.updateInfo.native,
downloading: false,
installing: false,
},
},
downloadProgress: null,
isChecking: false,
checkInProgress: false,
nativeCheckFailed: true,
})),
applyNativeDownloaded: assign(({context, event}) => {
if (event.type !== 'native.downloaded') return {};
return {
updateInfo: {
...context.updateInfo,
native: {
available: true,
downloaded: true,
downloading: false,
installing: false,
version: event.version ?? context.updateInfo.native.version,
downloadSize: context.updateInfo.native.downloadSize,
},
},
downloadProgress: null,
isChecking: false,
nativeUnsupported: null,
nativeManualDownloadUrl: null,
nativeManualDownloadOptions: EMPTY_DOWNLOAD_OPTIONS,
};
}),
applyNativeProgress: assign(({context, event}) => {
if (event.type !== 'native.progress') return {};
return {
updateInfo: {
...context.updateInfo,
native: {
...context.updateInfo.native,
downloading: true,
},
},
downloadProgress: event.progress,
};
}),
applyNativeUnsupported: assign(({context, event}) => {
if (event.type !== 'native.unsupported') return {};
return {
...clearNativeUpdate(context),
lastCheckedAt: event.now,
isChecking: false,
checkInProgress: false,
nativeUnsupported: {
reason: event.reason,
downloadUrl: event.downloadUrl,
},
};
}),
startNativeDownload: assign(({context, event}) => {
if (event.type !== 'native.download.started') return {};
return {
updateInfo: {
...context.updateInfo,
native: {
...context.updateInfo.native,
downloading: true,
},
},
downloadProgress: event.progressSupported
? {
percent: 0,
transferred: 0,
total: event.total ?? 0,
bytesPerSecond: 0,
}
: null,
};
}),
failNativeDownload: assign(({context}) => ({
updateInfo: {
...context.updateInfo,
native: {
...context.updateInfo.native,
downloading: false,
},
},
downloadProgress: null,
})),
startNativeInstall: assign(({context}) => ({
updateInfo: {
...context.updateInfo,
native: {
...context.updateInfo.native,
installing: true,
},
},
})),
failNativeInstall: assign(({context}) => ({
updateInfo: {
...context.updateInfo,
native: {
...context.updateInfo.native,
installing: false,
},
},
})),
startManualDownload: assign(() => ({manualNativeDownloadInFlight: true})),
finishManualDownload: assign(() => ({manualNativeDownloadInFlight: false})),
},
guards: {
isChecking: ({context}) => context.isChecking,
isNotChecking: ({context}) => !context.isChecking,
isNotCheckingAndHasUpdate: ({context}) => !context.isChecking && hasAnyUpdate(context),
isNotCheckingAndHasNoUpdate: ({context}) => !context.isChecking && !hasAnyUpdate(context),
hasUpdate: ({context}) => hasAnyUpdate(context),
hasNoUpdate: ({context}) => !hasAnyUpdate(context),
},
}).createMachine({
id: 'updater',
context: () => createInitialUpdaterContext(),
initial: 'idle',
on: {
'check.started': {actions: 'markChecking'},
'check.finished': {actions: 'markCheckFinished'},
'check.failed': {actions: 'markCheckFinished'},
'web.checked': {actions: 'applyWebChecked'},
'native.available': {actions: 'applyNativeAvailable'},
'native.hidden': {actions: 'hideNativeUpdate'},
'native.notAvailable': {actions: 'applyNativeNotAvailable'},
'native.error': {actions: 'applyNativeError'},
'native.downloaded': {actions: 'applyNativeDownloaded'},
'native.progress': {actions: 'applyNativeProgress'},
'native.unsupported': {actions: 'applyNativeUnsupported'},
'native.download.started': {actions: 'startNativeDownload'},
'native.download.failed': {actions: 'failNativeDownload'},
'native.install.started': {actions: 'startNativeInstall'},
'native.install.failed': {actions: 'failNativeInstall'},
'manualDownload.started': {actions: 'startManualDownload'},
'manualDownload.finished': {actions: 'finishManualDownload'},
reset: {actions: 'reset'},
},
states: {
idle: {
always: [
{guard: 'isChecking', target: 'checking'},
{guard: 'hasUpdate', target: 'available'},
],
},
checking: {
always: [
{guard: 'isNotCheckingAndHasUpdate', target: 'available'},
{guard: 'isNotCheckingAndHasNoUpdate', target: 'idle'},
],
},
available: {
always: [
{guard: 'isChecking', target: 'checking'},
{guard: 'hasNoUpdate', target: 'idle'},
],
},
},
});
export type UpdaterMachineSnapshot = SnapshotFrom<typeof updaterStateMachine>;
export function createUpdaterMachineSnapshot(): UpdaterMachineSnapshot {
return initialTransition(updaterStateMachine)[0];
}
export function transitionUpdaterMachineSnapshot(
snapshot: UpdaterMachineSnapshot,
event: UpdaterMachineEvent,
): UpdaterMachineSnapshot {
return transition(updaterStateMachine, snapshot, event)[0] as UpdaterMachineSnapshot;
}
export function getUpdaterMachineStateValue(snapshot: UpdaterMachineSnapshot): UpdaterState {
switch (snapshot.value) {
case 'checking':
return 'checking';
case 'available':
return 'available';
default:
return 'idle';
}
}
export function getUpdaterUpdateType(snapshot: UpdaterMachineSnapshot): UpdateType {
const hasNative = snapshot.context.updateInfo.native.available;
const hasWeb = snapshot.context.updateInfo.web.available;
if (hasNative && hasWeb) return 'both';
if (hasNative) return 'native';
if (hasWeb) return 'web';
return null;
}
export function hasManualNativeDownload(snapshot: UpdaterMachineSnapshot): boolean {
return Boolean(snapshot.context.nativeManualDownloadUrl) || snapshot.context.nativeManualDownloadOptions.length > 0;
}
export function getUpdaterDisplayVersion(snapshot: UpdaterMachineSnapshot): string | null {
if (snapshot.context.updateInfo.native.available && snapshot.context.updateInfo.native.version) {
return snapshot.context.updateInfo.native.version;
}
if (snapshot.context.updateInfo.web.available) {
return snapshot.context.updateInfo.web.version;
}
return null;
}