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

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

View File

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

View File

@@ -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',
],

View File

@@ -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',
],

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
import type { ISanityCheckOptions } from './ISanityCheckOptions';
export interface ISanityValidator {
readonly name: string;
shouldValidate(options: ISanityCheckOptions): boolean;
collectErrors(): Iterable<string>;
}

View File

@@ -1,4 +1,4 @@
export interface ISanityCheckOptions {
export interface SanityCheckOptions {
readonly validateEnvironmentVariables: boolean;
readonly validateWindowVariables: boolean;
}

View File

@@ -0,0 +1,7 @@
import type { SanityCheckOptions } from './SanityCheckOptions';
export interface SanityValidator {
readonly name: string;
shouldValidate(options: SanityCheckOptions): boolean;
collectErrors(): Iterable<string>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

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

View File

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

View File

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