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:
undergroundwires
2024-09-26 16:07:37 +02:00
parent a05a600071
commit e17744faf0
77 changed files with 656 additions and 332 deletions

View File

@@ -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);
});
}

View File

@@ -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';

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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.',
};
}

View File

@@ -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;
},
};