Bump to TypeScript 5.5 and enable noImplicitAny
This commit upgrades TypeScript from 5.4 to 5.5 and enables the
`noImplicitAny` option for stricter type checking. It refactors code to
comply with `noImplicitAny` and adapts to new TypeScript features and
limitations.
Key changes:
- Migrate from TypeScript 5.4 to 5.5
- Enable `noImplicitAny` for stricter type checking
- Refactor code to comply with new TypeScript features and limitations
Other supporting changes:
- Refactor progress bar handling for type safety
- Drop 'I' prefix from interfaces to align with new code convention
- Update TypeScript target from `ES2017` and `ES2018`.
This allows named capturing groups. Otherwise, new TypeScript compiler
does not compile the project and shows the following error:
```
...
TimestampedFilenameGenerator.spec.ts:105:23 - error TS1503: Named capturing groups are only available when targeting 'ES2018' or later
const pattern = /^(?<timestamp>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(?<scriptName>[^.]+?)(?:\.(?<extension>[^.]+))?$/;// timestamp-scriptName.extension
...
```
- Refactor usage of `electron-progressbar` for type safety and
less complexity.
This commit is contained in:
@@ -33,23 +33,25 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
|
||||
if (!casedValue) {
|
||||
throw new Error(`unknown ${enumName}: "${value}"`);
|
||||
}
|
||||
return enumVariable[casedValue as keyof typeof enumVariable];
|
||||
return enumVariable[casedValue as keyof EnumVariable<T, TEnumValue>];
|
||||
}
|
||||
|
||||
export function getEnumNames
|
||||
<T extends EnumType, TEnumValue extends EnumType>(
|
||||
enumVariable: EnumVariable<T, TEnumValue>,
|
||||
): string[] {
|
||||
): (string & keyof EnumVariable<T, TEnumValue>)[] {
|
||||
return Object
|
||||
.values(enumVariable)
|
||||
.filter((enumMember): enumMember is string => isString(enumMember));
|
||||
.filter((
|
||||
enumMember,
|
||||
): enumMember is string & (keyof EnumVariable<T, TEnumValue>) => isString(enumMember));
|
||||
}
|
||||
|
||||
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
|
||||
enumVariable: EnumVariable<T, TEnumValue>,
|
||||
): TEnumValue[] {
|
||||
return getEnumNames(enumVariable)
|
||||
.map((level) => enumVariable[level]) as TEnumValue[];
|
||||
.map((name) => enumVariable[name]) as TEnumValue[];
|
||||
}
|
||||
|
||||
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(
|
||||
|
||||
@@ -17,7 +17,7 @@ export class ApplicationContext implements IApplicationContext {
|
||||
public currentOs: OperatingSystem;
|
||||
|
||||
public get state(): ICategoryCollectionState {
|
||||
return this.states[this.collection.os];
|
||||
return this.getState(this.collection.os);
|
||||
}
|
||||
|
||||
private readonly states: StateMachine;
|
||||
@@ -26,30 +26,51 @@ export class ApplicationContext implements IApplicationContext {
|
||||
public readonly app: IApplication,
|
||||
initialContext: OperatingSystem,
|
||||
) {
|
||||
this.setContext(initialContext);
|
||||
this.states = initializeStates(app);
|
||||
this.changeContext(initialContext);
|
||||
}
|
||||
|
||||
public changeContext(os: OperatingSystem): void {
|
||||
assertInRange(os, OperatingSystem);
|
||||
if (this.currentOs === os) {
|
||||
return;
|
||||
}
|
||||
const collection = this.app.getCollection(os);
|
||||
this.collection = collection;
|
||||
const event: IApplicationContextChangedEvent = {
|
||||
newState: this.states[os],
|
||||
oldState: this.states[this.currentOs],
|
||||
newState: this.getState(os),
|
||||
oldState: this.getState(this.currentOs),
|
||||
};
|
||||
this.setContext(os);
|
||||
this.contextChanged.notify(event);
|
||||
}
|
||||
|
||||
private setContext(os: OperatingSystem): void {
|
||||
validateOperatingSystem(os, this.app);
|
||||
this.collection = this.app.getCollection(os);
|
||||
this.currentOs = os;
|
||||
}
|
||||
|
||||
private getState(os: OperatingSystem): ICategoryCollectionState {
|
||||
const state = this.states.get(os);
|
||||
if (!state) {
|
||||
throw new Error(`Operating system "${OperatingSystem[os]}" state is unknown.`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function validateOperatingSystem(
|
||||
os: OperatingSystem,
|
||||
app: IApplication,
|
||||
): void {
|
||||
assertInRange(os, OperatingSystem);
|
||||
if (!app.getSupportedOsList().includes(os)) {
|
||||
throw new Error(`Operating system "${OperatingSystem[os]}" is not supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeStates(app: IApplication): StateMachine {
|
||||
const machine = new Map<OperatingSystem, ICategoryCollectionState>();
|
||||
for (const collection of app.collections) {
|
||||
machine[collection.os] = new CategoryCollectionState(collection);
|
||||
machine.set(collection.os, new CategoryCollectionState(collection));
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ function ensureValidCategory(
|
||||
});
|
||||
validator.assertType((v) => v.assertObject({
|
||||
value: category,
|
||||
valueName: `Category '${category.category}'` ?? 'Category',
|
||||
valueName: category.category ? `Category '${category.category}'` : 'Category',
|
||||
allowedProperties: [
|
||||
'docs', 'children', 'category',
|
||||
],
|
||||
|
||||
@@ -106,7 +106,7 @@ function validateScript(
|
||||
): asserts script is NonNullable<ScriptData> {
|
||||
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
|
||||
value: script,
|
||||
valueName: `Script '${script.name}'` ?? 'Script',
|
||||
valueName: script.name ? `Script '${script.name}'` : 'Script',
|
||||
allowedProperties: [
|
||||
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
|
||||
],
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DuplicateLinesAnalyzer = CodeValidationAnalyzer & {
|
||||
export const analyzeDuplicateLines: DuplicateLinesAnalyzer = (
|
||||
lines: readonly CodeLine[],
|
||||
language: ScriptingLanguage,
|
||||
syntaxFactory = createSyntax,
|
||||
syntaxFactory: SyntaxFactory = createSyntax,
|
||||
) => {
|
||||
const syntax = syntaxFactory(language);
|
||||
return lines
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ISanityValidator } from './ISanityValidator';
|
||||
import type { ISanityCheckOptions } from './ISanityCheckOptions';
|
||||
import type { SanityValidator } from './SanityValidator';
|
||||
import type { SanityCheckOptions } from './SanityCheckOptions';
|
||||
|
||||
export type FactoryFunction<T> = () => T;
|
||||
|
||||
export abstract class FactoryValidator<T> implements ISanityValidator {
|
||||
export abstract class FactoryValidator<T> implements SanityValidator {
|
||||
private readonly factory: FactoryFunction<T>;
|
||||
|
||||
protected constructor(factory: FactoryFunction<T>) {
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
public abstract shouldValidate(options: ISanityCheckOptions): boolean;
|
||||
public abstract shouldValidate(options: SanityCheckOptions): boolean;
|
||||
|
||||
public abstract name: string;
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ISanityCheckOptions } from './ISanityCheckOptions';
|
||||
|
||||
export interface ISanityValidator {
|
||||
readonly name: string;
|
||||
shouldValidate(options: ISanityCheckOptions): boolean;
|
||||
collectErrors(): Iterable<string>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface ISanityCheckOptions {
|
||||
export interface SanityCheckOptions {
|
||||
readonly validateEnvironmentVariables: boolean;
|
||||
readonly validateWindowVariables: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { SanityCheckOptions } from './SanityCheckOptions';
|
||||
|
||||
export interface SanityValidator {
|
||||
readonly name: string;
|
||||
shouldValidate(options: SanityCheckOptions): boolean;
|
||||
collectErrors(): Iterable<string>;
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import { EnvironmentVariablesValidator } from './Validators/EnvironmentVariablesValidator';
|
||||
import type { ISanityCheckOptions } from './Common/ISanityCheckOptions';
|
||||
import type { ISanityValidator } from './Common/ISanityValidator';
|
||||
import type { SanityCheckOptions } from './Common/SanityCheckOptions';
|
||||
import type { SanityValidator } from './Common/SanityValidator';
|
||||
|
||||
const DefaultSanityValidators: ISanityValidator[] = [
|
||||
const DefaultSanityValidators: SanityValidator[] = [
|
||||
new EnvironmentVariablesValidator(),
|
||||
];
|
||||
|
||||
export interface RuntimeSanityValidator {
|
||||
(
|
||||
options: SanityCheckOptions,
|
||||
validators?: readonly SanityValidator[],
|
||||
): void;
|
||||
}
|
||||
|
||||
/* Helps to fail-fast on errors */
|
||||
export function validateRuntimeSanity(
|
||||
options: ISanityCheckOptions,
|
||||
validators: readonly ISanityValidator[] = DefaultSanityValidators,
|
||||
): void {
|
||||
export const validateRuntimeSanity: RuntimeSanityValidator = (
|
||||
options: SanityCheckOptions,
|
||||
validators: readonly SanityValidator[] = DefaultSanityValidators,
|
||||
) => {
|
||||
if (!validators.length) {
|
||||
throw new Error('missing validators');
|
||||
}
|
||||
@@ -26,9 +33,9 @@ export function validateRuntimeSanity(
|
||||
if (errorMessages.length > 0) {
|
||||
throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getErrorMessage(validator: ISanityValidator): string | undefined {
|
||||
function getErrorMessage(validator: SanityValidator): string | undefined {
|
||||
const errorMessages = [...validator.collectErrors()];
|
||||
if (!errorMessages.length) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
|
||||
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
|
||||
import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator';
|
||||
import type { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
|
||||
import type { SanityCheckOptions } from '../Common/SanityCheckOptions';
|
||||
|
||||
export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironmentVariables> {
|
||||
constructor(
|
||||
@@ -14,7 +14,7 @@ export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironment
|
||||
|
||||
public override name = 'environment variables';
|
||||
|
||||
public override shouldValidate(options: ISanityCheckOptions): boolean {
|
||||
public override shouldValidate(options: SanityCheckOptions): boolean {
|
||||
return options.validateEnvironmentVariables;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator';
|
||||
import type { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
|
||||
import type { SanityCheckOptions } from '../Common/SanityCheckOptions';
|
||||
|
||||
export class WindowVariablesValidator extends FactoryValidator<WindowVariables> {
|
||||
constructor(factory: FactoryFunction<WindowVariables> = () => window) {
|
||||
@@ -9,7 +9,7 @@ export class WindowVariablesValidator extends FactoryValidator<WindowVariables>
|
||||
|
||||
public override name = 'window variables';
|
||||
|
||||
public override shouldValidate(options: ISanityCheckOptions): boolean {
|
||||
public override shouldValidate(options: SanityCheckOptions): boolean {
|
||||
return options.validateWindowVariables;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
|
||||
import { RuntimeSanityBootstrapper } from './Modules/RuntimeSanityBootstrapper';
|
||||
import { AppInitializationLogger } from './Modules/AppInitializationLogger';
|
||||
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
|
||||
import { MobileSafariActivePseudoClassEnabler } from './Modules/MobileSafariActivePseudoClassEnabler';
|
||||
@@ -17,7 +17,7 @@ export class ApplicationBootstrapper implements Bootstrapper {
|
||||
|
||||
private static getAllBootstrappers(): Bootstrapper[] {
|
||||
return [
|
||||
new RuntimeSanityValidator(),
|
||||
new RuntimeSanityBootstrapper(),
|
||||
new DependencyBootstrapper(),
|
||||
new AppInitializationLogger(),
|
||||
new MobileSafariActivePseudoClassEnabler(),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { validateRuntimeSanity, type RuntimeSanityValidator } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
||||
import type { Bootstrapper } from '../Bootstrapper';
|
||||
|
||||
export class RuntimeSanityBootstrapper implements Bootstrapper {
|
||||
constructor(private readonly validator: RuntimeSanityValidator = validateRuntimeSanity) {
|
||||
|
||||
}
|
||||
|
||||
public async bootstrap(): Promise<void> {
|
||||
this.validator({
|
||||
validateEnvironmentVariables: true,
|
||||
validateWindowVariables: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
||||
import type { Bootstrapper } from '../Bootstrapper';
|
||||
|
||||
export class RuntimeSanityValidator implements Bootstrapper {
|
||||
constructor(private readonly validator = validateRuntimeSanity) {
|
||||
|
||||
}
|
||||
|
||||
public async bootstrap(): Promise<void> {
|
||||
this.validator({
|
||||
validateEnvironmentVariables: true,
|
||||
validateWindowVariables: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
:tree-root="treeRoot"
|
||||
:rendering-strategy="renderingStrategy"
|
||||
>
|
||||
<template #node-content="slotProps">
|
||||
<template #node-content="slotProps: NodeMetadata">
|
||||
<slot name="node-content" v-bind="slotProps" />
|
||||
</template>
|
||||
</HierarchicalTreeNode>
|
||||
@@ -55,6 +55,7 @@ import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||
import { useNodeState } from './UseNodeState';
|
||||
import LeafTreeNode from './LeafTreeNode.vue';
|
||||
import InteractableNode from './InteractableNode.vue';
|
||||
import type { NodeMetadata } from '../../NodeContent/NodeMetadata';
|
||||
import type { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||
import type { TreeNode, TreeNodeId } from './TreeNode';
|
||||
import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
|
||||
@@ -107,6 +108,7 @@ export default defineComponent({
|
||||
);
|
||||
|
||||
return {
|
||||
NodeMetadata: Object as PropType<NodeMetadata>,
|
||||
renderedNodeIds,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
|
||||
@@ -11,11 +11,17 @@ export class TreeRootManager implements TreeRoot {
|
||||
|
||||
constructor(
|
||||
collection: TreeNodeCollection = new TreeNodeInitializerAndUpdater(),
|
||||
createFocusManager: (
|
||||
collection: TreeNodeCollection
|
||||
) => SingleNodeFocusManager = (nodes) => new SingleNodeCollectionFocusManager(nodes),
|
||||
createFocusManager: FocusManagerFactory = (
|
||||
nodes,
|
||||
) => new SingleNodeCollectionFocusManager(nodes),
|
||||
) {
|
||||
this.collection = collection;
|
||||
this.focus = createFocusManager(this.collection);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FocusManagerFactory {
|
||||
(
|
||||
collection: TreeNodeCollection
|
||||
): SingleNodeFocusManager;
|
||||
}
|
||||
|
||||
@@ -120,17 +120,15 @@ function getArrowPositionStyles(
|
||||
coordinations: Partial<Coords>,
|
||||
placement: Placement,
|
||||
): CSSProperties {
|
||||
const style: CSSProperties = {};
|
||||
style.position = 'absolute';
|
||||
const { x, y } = coordinations;
|
||||
if (x) {
|
||||
style.left = `${x}px`;
|
||||
} else if (y) { // either X or Y is calculated
|
||||
style.top = `${y}px`;
|
||||
}
|
||||
const { x, y } = coordinations; // either X or Y is calculated
|
||||
const oppositeSide = getCounterpartBoxOffsetProperty(placement);
|
||||
style[oppositeSide.toString()] = `-${ARROW_SIZE_IN_PX}px`;
|
||||
return style;
|
||||
const newStyle: CSSProperties = {
|
||||
[oppositeSide]: `-${ARROW_SIZE_IN_PX}px`,
|
||||
position: 'absolute',
|
||||
left: x ? `${x}px` : undefined,
|
||||
top: y ? `${y}px` : undefined,
|
||||
};
|
||||
return newStyle;
|
||||
}
|
||||
|
||||
function getCounterpartBoxOffsetProperty(placement: Placement): keyof CSSProperties {
|
||||
|
||||
@@ -22,7 +22,8 @@ export function registerAllIpcChannels(
|
||||
};
|
||||
Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => {
|
||||
try {
|
||||
const definition = IpcChannelDefinitions[name];
|
||||
const definitionKey = name as keyof typeof IpcChannelDefinitions;
|
||||
const definition = IpcChannelDefinitions[definitionKey] as IpcChannel<unknown>;
|
||||
const instance = instanceFactory();
|
||||
registrar(definition, instance);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, dialog } from 'electron/main';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { UpdateProgressBar } from './UpdateProgressBar';
|
||||
import { UpdateProgressBar } from './ProgressBar/UpdateProgressBar';
|
||||
import { getAutoUpdater } from './ElectronAutoUpdaterFactory';
|
||||
import type { AppUpdater, UpdateInfo } from 'electron-updater';
|
||||
import type { ProgressInfo } from 'electron-builder';
|
||||
@@ -26,11 +26,13 @@ function startHandlingUpdateProgress(autoUpdater: AppUpdater) {
|
||||
So the indeterminate progress will continue until download is finished.
|
||||
*/
|
||||
ElectronLogger.debug('@download-progress@\n', progress);
|
||||
progressBar.showProgress(progress);
|
||||
if (progressBar.isOpen) { // May be closed by the user
|
||||
progressBar.showProgress(progress);
|
||||
}
|
||||
});
|
||||
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
|
||||
ElectronLogger.info('@update-downloaded@\n', info);
|
||||
progressBar.close();
|
||||
progressBar.closeIfOpen();
|
||||
await handleUpdateDownloaded(autoUpdater);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { unlink, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron/main';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { UpdateProgressBar } from '../UpdateProgressBar';
|
||||
import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
|
||||
import { retryFileSystemAccess } from './RetryFileSystemAccess';
|
||||
import type { UpdateInfo } from 'electron-updater';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GitHubProjectDetails } from '@/domain/Project/GitHubProjectDetails';
|
||||
import { Version } from '@/domain/Version';
|
||||
import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { UpdateProgressBar } from '../UpdateProgressBar';
|
||||
import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
|
||||
import {
|
||||
promptForManualUpdate, promptInstallerOpenError,
|
||||
promptIntegrityCheckFailure, promptDownloadError,
|
||||
@@ -100,7 +100,7 @@ async function withProgressBar(
|
||||
) {
|
||||
const progressBar = new UpdateProgressBar();
|
||||
await action(progressBar);
|
||||
progressBar.close();
|
||||
progressBar.closeIfOpen();
|
||||
}
|
||||
|
||||
async function isIntegrityPreserved(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// @ts-expect-error Outdated `@types/electron-progressbar` causes build failure on macOS
|
||||
import ProgressBar from 'electron-progressbar';
|
||||
import { BrowserWindow } from 'electron/main';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||
import type {
|
||||
InitialProgressBarWindowOptions,
|
||||
ProgressBarUpdater,
|
||||
ProgressBarStatus,
|
||||
ProgressBarUpdateCallback, ProgressBarWithLifecycle,
|
||||
} from './ProgressBarWithLifecycle';
|
||||
|
||||
/**
|
||||
* It provides a type-safe way to manage `electron-progressbar` instance,
|
||||
* through its lifecycle, ensuring correct usage, state transitions, and cleanup.
|
||||
*/
|
||||
export class ElectronProgressBarWithLifecycle implements ProgressBarWithLifecycle {
|
||||
private state: ProgressBarWithState = { status: 'closed' };
|
||||
|
||||
public readonly statusChanged = new EventSource<ProgressBarStatus>();
|
||||
|
||||
private readyCallbacks = new Array<ProgressBarUpdateCallback>();
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
) { }
|
||||
|
||||
public update(handler: ProgressBarUpdateCallback): void {
|
||||
switch (this.state.status) { // eslint-disable-line default-case
|
||||
case 'closed':
|
||||
// Throwing an error here helps catch bugs early in the development process.
|
||||
throw new Error('Cannot update the progress bar because it is not currently open.');
|
||||
case 'ready':
|
||||
handler(wrapForUpdate(this.state));
|
||||
break;
|
||||
case 'loading':
|
||||
this.readyCallbacks.push(handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public resetAndOpen(options: InitialProgressBarWindowOptions): void {
|
||||
this.closeIfOpen();
|
||||
const bar = createElectronProgressBar(options);
|
||||
this.state = { status: 'loading', progressBar: bar };
|
||||
bar.on('ready', () => this.handleReadyEvent(bar));
|
||||
bar.on('aborted' /* closed by user */, (value: number) => {
|
||||
this.changeState({ status: 'closed' });
|
||||
this.logger.info(`Progress bar window closed by user. State: ${this.state.status}, Value: ${value}.`);
|
||||
});
|
||||
}
|
||||
|
||||
public closeIfOpen() {
|
||||
if (this.state.status === 'closed') {
|
||||
return;
|
||||
}
|
||||
this.state.progressBar.close();
|
||||
this.changeState({
|
||||
status: 'closed',
|
||||
});
|
||||
}
|
||||
|
||||
private handleReadyEvent(bar: ProgressBar): void {
|
||||
if (this.state.status !== 'loading' || this.state.progressBar !== bar) {
|
||||
// Handle race conditions if `open` called rapidly without closing to avoid leaks
|
||||
this.logger.warn('Unexpected state when handling ready event. Closing the progress bar.');
|
||||
bar.close();
|
||||
return;
|
||||
}
|
||||
const readyBar: ReadyProgressBar = {
|
||||
status: 'ready',
|
||||
progressBar: bar,
|
||||
browserWindow: getWindow(bar),
|
||||
};
|
||||
this.readyCallbacks.forEach((callback) => callback(wrapForUpdate(readyBar)));
|
||||
this.changeState(readyBar);
|
||||
}
|
||||
|
||||
private changeState(newState: ProgressBarWithState): void {
|
||||
if (isSameState(this.state, newState)) {
|
||||
return;
|
||||
}
|
||||
this.readyCallbacks = [];
|
||||
this.state = newState;
|
||||
this.statusChanged.notify(newState.status);
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressBarWithState = { readonly status: 'closed' }
|
||||
| { readonly status: 'loading', readonly progressBar: ProgressBar }
|
||||
| ReadyProgressBar;
|
||||
|
||||
interface ReadyProgressBar {
|
||||
readonly status: 'ready';
|
||||
readonly progressBar: ProgressBar;
|
||||
readonly browserWindow: BrowserWindow;
|
||||
}
|
||||
|
||||
function getWindow(bar: ProgressBar): BrowserWindow {
|
||||
// Note: The ProgressBar library does not provide a public method or event
|
||||
// to access the BrowserWindow, so we access the internal `_window` property directly.
|
||||
if (!('_window' in bar)) {
|
||||
throw new Error('Unable to access the progress bar window.');
|
||||
}
|
||||
const browserWindow = bar._window as BrowserWindow; // eslint-disable-line no-underscore-dangle
|
||||
if (!browserWindow) {
|
||||
throw new Error('Missing internal browser window');
|
||||
}
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
function isSameState( // eslint-disable-line consistent-return
|
||||
first: ProgressBarWithState,
|
||||
second: ProgressBarWithState,
|
||||
): boolean {
|
||||
switch (first.status) { // eslint-disable-line default-case
|
||||
case 'closed':
|
||||
return second.status === 'closed';
|
||||
case 'loading':
|
||||
return second.status === 'loading'
|
||||
&& second.progressBar === first.progressBar;
|
||||
case 'ready':
|
||||
return second.status === 'ready'
|
||||
&& second.progressBar === first.progressBar
|
||||
&& second.browserWindow === first.browserWindow;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapForUpdate(bar: ReadyProgressBar): ProgressBarUpdater {
|
||||
return {
|
||||
setText: (text: string) => {
|
||||
bar.progressBar.detail = text;
|
||||
},
|
||||
setClosable: (closable: boolean) => {
|
||||
bar.browserWindow.setClosable(closable);
|
||||
},
|
||||
setProgress: (progress: number) => {
|
||||
bar.progressBar.value = progress;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createElectronProgressBar(
|
||||
options: InitialProgressBarWindowOptions,
|
||||
): ProgressBar {
|
||||
const bar = new ProgressBar({
|
||||
indeterminate: options.type === 'indeterminate',
|
||||
title: options.title,
|
||||
text: options.initialText,
|
||||
});
|
||||
if (options.type === 'percentile') { // Indeterminate progress bar does not fire `completed` event, see `electron-progressbar` docs
|
||||
bar.on('completed', () => {
|
||||
bar.detail = options.textOnCompleted;
|
||||
});
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||
|
||||
/*
|
||||
Defines interfaces to abstract the progress bar implementation,
|
||||
serving as an anti-corruption layer. This approach allows for
|
||||
flexibility in switching implementations if needed in the future,
|
||||
while maintaining a consistent API for the rest of the application.
|
||||
*/
|
||||
export interface ProgressBarWithLifecycle {
|
||||
resetAndOpen(options: InitialProgressBarWindowOptions): void;
|
||||
closeIfOpen(): void;
|
||||
update(handler: ProgressBarUpdateCallback): void;
|
||||
readonly statusChanged: IEventSource<ProgressBarStatus>;
|
||||
}
|
||||
|
||||
export type ProgressBarStatus = 'closed' | 'loading' | 'ready';
|
||||
|
||||
export type ProgressBarUpdateCallback = (bar: ProgressBarUpdater) => void;
|
||||
|
||||
export interface InitialProgressBarWindowOptions {
|
||||
readonly type: 'indeterminate' | 'percentile';
|
||||
readonly title: string,
|
||||
readonly initialText: string;
|
||||
readonly textOnCompleted: string;
|
||||
}
|
||||
|
||||
export interface ProgressBarUpdater {
|
||||
setText(text: string): void;
|
||||
setClosable(closable: boolean): void;
|
||||
setProgress(progress: number): void;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { app } from 'electron/main';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronProgressBarWithLifecycle } from './ElectronProgressBarWithLifecycle';
|
||||
import type { ProgressInfo } from 'electron-builder';
|
||||
import type { InitialProgressBarWindowOptions, ProgressBarWithLifecycle } from './ProgressBarWithLifecycle';
|
||||
|
||||
export class UpdateProgressBar {
|
||||
private visibilityState: ProgressBarVisibilityState = 'closed';
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
private readonly currentApp: Electron.App = app,
|
||||
private readonly progressBar: ProgressBarWithLifecycle = new ElectronProgressBarWithLifecycle(),
|
||||
) {
|
||||
this.progressBar.statusChanged.on((status) => {
|
||||
if (status === 'closed') {
|
||||
this.visibilityState = 'closed';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public get isOpen(): boolean {
|
||||
return this.visibilityState !== 'closed';
|
||||
}
|
||||
|
||||
public showIndeterminateState() {
|
||||
if (this.visibilityState === 'showingIndeterminate') {
|
||||
return;
|
||||
}
|
||||
this.progressBar.resetAndOpen(createInitialProgressBarWindowOptions('indeterminate', this.currentApp.name));
|
||||
this.visibilityState = 'showingIndeterminate';
|
||||
}
|
||||
|
||||
public showProgress(progress: ProgressInfo) {
|
||||
const percentage = getUpdatePercent(progress);
|
||||
this.showPercentage(percentage);
|
||||
}
|
||||
|
||||
public showPercentage(percentage: number) {
|
||||
if (this.visibilityState !== 'showingPercentile') {
|
||||
this.progressBar.resetAndOpen(createInitialProgressBarWindowOptions('percentile', this.currentApp.name));
|
||||
this.visibilityState = 'showingPercentile';
|
||||
}
|
||||
this.progressBar.update((bar) => {
|
||||
bar.setProgress(percentage);
|
||||
bar.setText(`${percentage}% ...`);
|
||||
});
|
||||
}
|
||||
|
||||
public showError(e: Error) {
|
||||
this.logger.warn(`Error displayed in progress bar. Visibility state: ${this.visibilityState}. Error message: ${e.message}`);
|
||||
if (this.visibilityState === 'closed') {
|
||||
throw new Error('Cannot display error because the progress bar is not visible.');
|
||||
}
|
||||
this.progressBar.update((bar) => {
|
||||
bar.setText('An error occurred while downloading updates.'
|
||||
+ `\n${e && e.message ? e.message : e}`);
|
||||
bar.setClosable(true);
|
||||
});
|
||||
}
|
||||
|
||||
public closeIfOpen() {
|
||||
if (this.visibilityState === 'closed') {
|
||||
return;
|
||||
}
|
||||
this.progressBar.closeIfOpen();
|
||||
this.visibilityState = 'closed';
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressBarVisibilityState = 'closed' | 'showingIndeterminate' | 'showingPercentile';
|
||||
|
||||
function getUpdatePercent(progress: ProgressInfo) {
|
||||
let { percent } = progress;
|
||||
if (percent) {
|
||||
percent = Math.round(percent * 100) / 100;
|
||||
}
|
||||
return percent;
|
||||
}
|
||||
|
||||
function createInitialProgressBarWindowOptions(
|
||||
type: 'indeterminate' | 'percentile',
|
||||
appName: string,
|
||||
): InitialProgressBarWindowOptions {
|
||||
return {
|
||||
type,
|
||||
title: `${appName} Update`,
|
||||
initialText: `Downloading ${appName} update...`,
|
||||
textOnCompleted: 'Download completed.',
|
||||
};
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import ProgressBar from 'electron-progressbar';
|
||||
import { app, BrowserWindow } from 'electron/main';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { ProgressInfo } from 'electron-builder';
|
||||
|
||||
export class UpdateProgressBar {
|
||||
private progressBar: ProgressBar | undefined;
|
||||
|
||||
private get innerProgressBarWindow(): BrowserWindow {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
return this.progressBar._window;
|
||||
}
|
||||
|
||||
private showingProgress = false;
|
||||
|
||||
public showIndeterminateState() {
|
||||
this.progressBar?.close();
|
||||
this.progressBar = progressBarFactory.createWithIndeterminateState();
|
||||
}
|
||||
|
||||
public showProgress(progress: ProgressInfo) {
|
||||
const percentage = getUpdatePercent(progress);
|
||||
this.showPercentage(percentage);
|
||||
}
|
||||
|
||||
public showPercentage(percentage: number) {
|
||||
if (!this.showingProgress) { // First time showing progress
|
||||
this.progressBar?.close();
|
||||
this.showingProgress = true;
|
||||
this.progressBar = progressBarFactory.createWithPercentile(percentage);
|
||||
} else {
|
||||
this.progressBar.value = percentage;
|
||||
}
|
||||
}
|
||||
|
||||
public showError(e: Error) {
|
||||
const reportUpdateError = () => {
|
||||
this.progressBar.detail = 'An error occurred while fetching updates.'
|
||||
+ `\n${e && e.message ? e.message : e}`;
|
||||
this.innerProgressBarWindow.setClosable(true);
|
||||
};
|
||||
if (this.progressBar?.innerProgressBarWindow) {
|
||||
reportUpdateError();
|
||||
} else {
|
||||
this.progressBar?.on('ready', () => reportUpdateError());
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (!this.progressBar?.isCompleted()) {
|
||||
this.progressBar?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdatePercent(progress: ProgressInfo) {
|
||||
let { percent } = progress;
|
||||
if (percent) {
|
||||
percent = Math.round(percent * 100) / 100;
|
||||
}
|
||||
return percent;
|
||||
}
|
||||
|
||||
const progressBarFactory = {
|
||||
createWithIndeterminateState: () => {
|
||||
return new ProgressBar({
|
||||
title: `${app.name} Update`,
|
||||
text: `Downloading ${app.name} update...`,
|
||||
});
|
||||
},
|
||||
createWithPercentile: (initialPercentage: number) => {
|
||||
const progressBar = new ProgressBar({
|
||||
indeterminate: false,
|
||||
title: `${app.name} Update`,
|
||||
text: `Downloading ${app.name} update...`,
|
||||
detail: `${initialPercentage}% ...`,
|
||||
initialValue: initialPercentage,
|
||||
});
|
||||
progressBar
|
||||
.on('completed', () => {
|
||||
progressBar.detail = 'Download completed.';
|
||||
})
|
||||
.on('aborted', (value: number) => {
|
||||
ElectronLogger.info(`Progress aborted... ${value}`);
|
||||
})
|
||||
.on('progress', (value: number) => {
|
||||
progressBar.detail = `${value}% ...`;
|
||||
})
|
||||
.on('ready', () => {
|
||||
// initialValue doesn't set the UI, so this is needed to render it correctly
|
||||
progressBar.value = initialPercentage;
|
||||
});
|
||||
return progressBar;
|
||||
},
|
||||
};
|
||||
@@ -43,9 +43,10 @@ function bindMethodsOfObject<T>(obj: T): T {
|
||||
if (!prototype.hasOwnProperty.call(obj, property)) {
|
||||
return; // Skip properties not directly on the prototype
|
||||
}
|
||||
const value = obj[property];
|
||||
const propertyKey = property as keyof (typeof obj);
|
||||
const value = obj[propertyKey];
|
||||
if (isFunction(value)) {
|
||||
(obj as object)[property] = value.bind(obj);
|
||||
obj[propertyKey] = value.bind(obj);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
|
||||
@@ -7,9 +7,9 @@ export function createIpcConsumerProxy<T>(
|
||||
channel: IpcChannel<T>,
|
||||
electronIpcRenderer: Electron.IpcRenderer = ipcRenderer,
|
||||
): AsyncMethods<T> {
|
||||
const facade: Partial<T> = {};
|
||||
const facade: Partial<AsyncMethods<T>> = {};
|
||||
channel.accessibleMembers.forEach((member) => {
|
||||
const functionKey = member as string;
|
||||
const functionKey = member as (keyof T & string);
|
||||
const ipcChannel = getIpcChannelIdentifier(channel.namespace, functionKey);
|
||||
facade[functionKey] = ((...args: unknown[]) => {
|
||||
return electronIpcRenderer.invoke(ipcChannel, ...args);
|
||||
|
||||
Reference in New Issue
Block a user