Migrate to electron-vite and electron-builder

- Switch from deprecated Vue CLI plugin to `electron-vite` (see
  nklayman/vue-cli-plugin-electron-builder#1982)
- Update main/preload scripts to use `index.cjs` filenames to support
  `"type": "module"`, resolving crash issue (#233). This crash was
  related to Electron not supporting ESM (see electron/asar#249,
  electron/electron#21457).
- This commit completes migration to Vite from Vue CLI (#230).

Structure changes:

- Introduce separate folders for Electron's main and preload processes.
- Move TypeHelpers to `src/` to mark tit as accessible by the rest of
  the code.

Config changes:

- Make `vite.config.ts` reusable by Electron configuration.
- On electron-builder, use `--publish` flag instead of `-p` for clarity.

Tests:

- Add log for preload script loading verification.
- Implement runtime environment sanity checks.
- Enhance logging in `check-desktop-runtime-errors`.
This commit is contained in:
undergroundwires
2023-08-24 20:01:53 +02:00
parent ec98d8417f
commit 75c9b51bf2
43 changed files with 1017 additions and 2600 deletions

11
src/TypeHelpers.ts Normal file
View File

@@ -0,0 +1,11 @@
export type Constructible<T, TArgs extends unknown[] = never> = {
prototype: T;
apply: (this: unknown, args: TArgs) => void;
};
export type PropertyKeys<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? never : K;
}[keyof T];
export type ConstructorArguments<T> =
T extends new (...args: infer U) => unknown ? U : never;

View File

@@ -8,17 +8,18 @@ import LinuxData from '@/application/collections/linux.yaml';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { Application } from '@/domain/Application';
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { ViteAppMetadata } from '@/infrastructure/Metadata/Vite/ViteAppMetadata';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { parseCategoryCollection } from './CategoryCollectionParser';
export function parseApplication(
parser = parseCategoryCollection,
metadata: IAppMetadata = new ViteAppMetadata(),
categoryParser = parseCategoryCollection,
informationParser = parseProjectInformation,
metadata: IAppMetadata = AppMetadataFactory.Current,
collectionsData = PreParsedCollections,
): IApplication {
validateCollectionsData(collectionsData);
const information = parseProjectInformation(metadata);
const collections = collectionsData.map((collection) => parser(collection, information));
const information = informationParser(metadata);
const collections = collectionsData.map((collection) => categoryParser(collection, information));
const app = new Application(information, collections);
return app;
}

View File

@@ -2,14 +2,20 @@ import { IProjectInformation } from '@/domain/IProjectInformation';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { Version } from '@/domain/Version';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { ConstructorArguments } from '@/TypeHelpers';
export function parseProjectInformation(
metadata: IAppMetadata,
export function
parseProjectInformation(
metadata: IAppMetadata = AppMetadataFactory.Current,
createProjectInformation: ProjectInformationFactory = (
...args
) => new ProjectInformation(...args),
): IProjectInformation {
const version = new Version(
metadata.version,
);
return new ProjectInformation(
return createProjectInformation(
metadata.name,
version,
metadata.slogan,
@@ -17,3 +23,7 @@ export function parseProjectInformation(
metadata.homepageUrl,
);
}
export type ProjectInformationFactory = (
...args: ConstructorArguments<typeof ProjectInformation>
) => IProjectInformation;

View File

@@ -0,0 +1,16 @@
import { IAppMetadata } from './IAppMetadata';
import { ViteAppMetadata } from './Vite/ViteAppMetadata';
export class AppMetadataFactory {
public static get Current(): IAppMetadata {
if (!this.instance) {
this.instance = new ViteAppMetadata();
}
return this.instance;
}
private static instance: IAppMetadata;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
}

View File

@@ -0,0 +1,3 @@
export interface ISanityCheckOptions {
readonly validateMetadata: boolean;
}

View File

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

View File

@@ -0,0 +1,28 @@
import { ISanityCheckOptions } from './ISanityCheckOptions';
import { ISanityValidator } from './ISanityValidator';
import { MetadataValidator } from './Validators/MetadataValidator';
const SanityValidators: ISanityValidator[] = [
new MetadataValidator(),
];
export function validateRuntimeSanity(
options: ISanityCheckOptions,
validators: readonly ISanityValidator[] = SanityValidators,
): void {
if (!options) {
throw new Error('missing options');
}
if (!validators?.length) {
throw new Error('missing validators');
}
const errorMessages = validators.reduce((errors, validator) => {
if (validator.shouldValidate(options)) {
errors.push(...validator.collectErrors());
}
return errors;
}, new Array<string>());
if (errorMessages.length > 0) {
throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`);
}
}

View File

@@ -0,0 +1,66 @@
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { ISanityCheckOptions } from '../ISanityCheckOptions';
import { ISanityValidator } from '../ISanityValidator';
export class MetadataValidator implements ISanityValidator {
private readonly metadata: IAppMetadata;
constructor(metadataFactory: () => IAppMetadata = () => AppMetadataFactory.Current) {
this.metadata = metadataFactory();
}
public shouldValidate(options: ISanityCheckOptions): boolean {
return options.validateMetadata;
}
public* collectErrors(): Iterable<string> {
if (!this.metadata) {
yield 'missing metadata';
return;
}
const keyValues = capturePropertyValues(this.metadata);
if (!Object.keys(keyValues).length) {
yield 'Unable to capture metadata key/value pairs';
return;
}
const keysMissingValue = getMissingMetadataKeys(keyValues);
if (keysMissingValue.length > 0) {
yield `Metadata keys missing: ${keysMissingValue.join(', ')}`;
}
}
}
function getMissingMetadataKeys(keyValuePairs: Record<string, unknown>): string[] {
return Object.entries(keyValuePairs)
.reduce((acc, [key, value]) => {
if (!value) {
acc.push(key);
}
return acc;
}, new Array<string>());
}
/**
* Captures values of properties and getters from the provided instance.
* Necessary because code transformations can make class getters non-enumerable during bundling.
* This ensures that even if getters are non-enumerable, their values are still captured and used.
*/
function capturePropertyValues(instance: unknown): Record<string, unknown> {
const obj: Record<string, unknown> = {};
const descriptors = Object.getOwnPropertyDescriptors(instance.constructor.prototype);
// Capture regular properties from the instance
for (const [key, value] of Object.entries(instance)) {
obj[key] = value;
}
// Capture getter properties from the instance's prototype
for (const [key, descriptor] of Object.entries(descriptors)) {
if (typeof descriptor.get === 'function') {
obj[key] = descriptor.get.call(instance);
}
}
return obj;
}

View File

@@ -18,6 +18,7 @@ import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeBu
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { provideDependencies } from '../bootstrapping/DependencyProvider';
const singletonAppContext = await buildContext();
@@ -32,6 +33,9 @@ export default defineComponent({
},
setup() {
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
validateRuntimeSanity({
validateMetadata: true,
});
},
});
</script>

View File

@@ -0,0 +1,16 @@
/**
* Abstraction for electron-vite specific logic and other Electron CLI helpers/wrappers.
* Allows for agnostic application design and centralizes adjustments when switching wrappers.
*/
/// <reference types="electron-vite/node" />
import { join } from 'path';
import appIcon from '@/presentation/public/icon.png?asset';
export const APP_ICON_PATH = appIcon;
export const RENDERER_URL = process.env.ELECTRON_RENDERER_URL;
export const RENDERER_HTML_PATH = join('file://', __dirname, '../renderer/index.html');
export const PRELOADER_SCRIPT_PATH = join(__dirname, '../preload/index.cjs');

View File

@@ -6,8 +6,8 @@ import log from 'electron-log';
import fetch from 'cross-fetch';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Version } from '@/domain/Version';
import { ViteAppMetadata } from '@/infrastructure/Metadata/Vite/ViteAppMetadata';
import { Version } from '@/domain/Version';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { UpdateProgressBar } from './UpdateProgressBar';

View File

@@ -1,21 +1,17 @@
// This is main process of Electron, started as first thing when app starts.
// This script is running through entire life of the application.
// It doesn't have any windows which you can see on screen, opens the main window from here.
// Initializes Electron's main process, always runs in the background, and manages the main window.
import path from 'path';
import {
app, protocol, BrowserWindow, shell, screen,
} from 'electron';
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib';
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer';
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { setupAutoUpdater } from './Update/Updater';
import {
APP_ICON_PATH, PRELOADER_SCRIPT_PATH, RENDERER_HTML_PATH, RENDERER_URL,
} from './ElectronConfig';
const isDevelopment = process.env.NODE_ENV !== 'production';
// Path of static assets, magic variable populated by electron
// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle
declare const __static: string; // https://github.com/electron-userland/electron-webpack/issues/172
const isDevelopment = !app.isPackaged;
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
@@ -27,6 +23,9 @@ protocol.registerSchemesAsPrivileged([
]);
setupLogger();
validateRuntimeSanity({
validateMetadata: true,
});
function createWindow() {
// Create the browser window.
@@ -35,14 +34,11 @@ function createWindow() {
width: size.width,
height: size.height,
webPreferences: {
contextIsolation: false, // To reach node https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1285
// Use pluginOptions.nodeIntegration, leave this alone
// See https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration
nodeIntegration: (process.env
.ELECTRON_NODE_INTEGRATION as unknown) as boolean,
nodeIntegration: true,
contextIsolation: false,
preload: PRELOADER_SCRIPT_PATH,
},
// https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/recipes.html#set-tray-icon
icon: path.join(__static, 'icon.png'),
icon: APP_ICON_PATH,
});
win.setMenuBarVisibility(false);
@@ -83,17 +79,12 @@ app.on('activate', () => {
}
});
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', async () => {
if (isDevelopment && !process.env.IS_TEST) {
// Install Vue Devtools
if (isDevelopment) {
try {
await installExtension(VUEJS_DEVTOOLS);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Vue Devtools failed to install:', e.toString());
log.error('Vue Devtools failed to install:', e.toString());
}
}
createWindow();
@@ -115,22 +106,19 @@ if (isDevelopment) {
}
function loadApplication(window: BrowserWindow) {
if (process.env.WEBPACK_DEV_SERVER_URL) {
// Load the url of the dev server if in development mode
loadUrlWithNodeWorkaround(win, process.env.WEBPACK_DEV_SERVER_URL as string);
if (!process.env.IS_TEST) {
window.webContents.openDevTools();
}
if (RENDERER_URL) { // Populated in a dev server during development
loadUrlWithNodeWorkaround(win, RENDERER_URL);
} else {
loadUrlWithNodeWorkaround(win, RENDERER_HTML_PATH);
}
if (isDevelopment) {
window.webContents.openDevTools();
} else {
createProtocol('app');
// Load the index.html when not in development
loadUrlWithNodeWorkaround(win, 'app://./index.html');
const updater = setupAutoUpdater();
updater.checkForUpdates();
}
// Do not remove [APP_INIT_SUCCESS]; it's a marker used in tests to verify
// app initialization.
log.info('[APP_INIT_SUCCESS] Main window initialized and content loading.');
// Do not remove [WINDOW_INIT]; it's a marker used in tests.
log.info('[WINDOW_INIT] Main window initialized and content loading.');
}
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
@@ -158,7 +146,7 @@ function getWindowSize(idealWidth: number, idealHeight: number) {
function setupLogger(): void {
log.transports.file.level = 'silly';
if (!process.env.IS_TEST) {
if (!isDevelopment) {
Object.assign(console, log.functions); // override console.log, console.warn etc.
}
}

View File

@@ -0,0 +1,11 @@
// This preload script serves as a placeholder to securely expose Electron APIs to the application.
// As of now, the application does not utilize any specific Electron APIs through this script.
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
validateRuntimeSanity({
validateMetadata: true,
});
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
log.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');