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:
16
src/presentation/electron/main/ElectronConfig.ts
Normal file
16
src/presentation/electron/main/ElectronConfig.ts
Normal 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');
|
||||
74
src/presentation/electron/main/Update/AutoUpdater.ts
Normal file
74
src/presentation/electron/main/Update/AutoUpdater.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { app, dialog } from 'electron';
|
||||
import { autoUpdater, UpdateInfo } from 'electron-updater';
|
||||
import { ProgressInfo } from 'electron-builder';
|
||||
import log from 'electron-log';
|
||||
import { UpdateProgressBar } from './UpdateProgressBar';
|
||||
|
||||
export async function handleAutoUpdate() {
|
||||
if (await askDownloadAndInstall() === DownloadDialogResult.NotNow) {
|
||||
return;
|
||||
}
|
||||
startHandlingUpdateProgress();
|
||||
await autoUpdater.downloadUpdate();
|
||||
}
|
||||
|
||||
function startHandlingUpdateProgress() {
|
||||
const progressBar = new UpdateProgressBar();
|
||||
progressBar.showIndeterminateState();
|
||||
autoUpdater.on('error', (e) => {
|
||||
progressBar.showError(e);
|
||||
});
|
||||
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
|
||||
/*
|
||||
On macOS, download-progress event is not called.
|
||||
So the indeterminate progress will continue until download is finished.
|
||||
*/
|
||||
log.debug('@download-progress@\n', progress);
|
||||
progressBar.showProgress(progress);
|
||||
});
|
||||
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
|
||||
log.info('@update-downloaded@\n', info);
|
||||
progressBar.close();
|
||||
await handleUpdateDownloaded();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpdateDownloaded() {
|
||||
if (await askRestartAndInstall() === InstallDialogResult.NotNow) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => autoUpdater.quitAndInstall(), 1);
|
||||
}
|
||||
|
||||
enum DownloadDialogResult {
|
||||
Install = 0,
|
||||
NotNow = 1,
|
||||
}
|
||||
async function askDownloadAndInstall(): Promise<DownloadDialogResult> {
|
||||
const updateDialogResult = await dialog.showMessageBox({
|
||||
type: 'question',
|
||||
buttons: ['Install', 'Not now'],
|
||||
title: 'Confirm Update',
|
||||
message: 'Update available.\n\nWould you like to download and install new version?',
|
||||
detail: 'Application will automatically restart to apply update after download',
|
||||
defaultId: DownloadDialogResult.Install,
|
||||
cancelId: DownloadDialogResult.NotNow,
|
||||
});
|
||||
return updateDialogResult.response;
|
||||
}
|
||||
|
||||
enum InstallDialogResult {
|
||||
InstallAndRestart = 0,
|
||||
NotNow = 1,
|
||||
}
|
||||
async function askRestartAndInstall(): Promise<InstallDialogResult> {
|
||||
const installDialogResult = await dialog.showMessageBox({
|
||||
type: 'question',
|
||||
buttons: ['Install and restart', 'Later'],
|
||||
message: `A new version of ${app.name} has been downloaded.`,
|
||||
detail: 'It will be installed the next time you restart the application.',
|
||||
defaultId: InstallDialogResult.InstallAndRestart,
|
||||
cancelId: InstallDialogResult.NotNow,
|
||||
});
|
||||
return installDialogResult.response;
|
||||
}
|
||||
147
src/presentation/electron/main/Update/ManualUpdater.ts
Normal file
147
src/presentation/electron/main/Update/ManualUpdater.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import { UpdateInfo } from 'electron-updater';
|
||||
import log from 'electron-log';
|
||||
import fetch from 'cross-fetch';
|
||||
import { ProjectInformation } from '@/domain/ProjectInformation';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { ViteAppMetadata } from '@/infrastructure/Metadata/Vite/ViteAppMetadata';
|
||||
import { Version } from '@/domain/Version';
|
||||
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
|
||||
import { UpdateProgressBar } from './UpdateProgressBar';
|
||||
|
||||
export function requiresManualUpdate(): boolean {
|
||||
return process.platform === 'darwin';
|
||||
}
|
||||
|
||||
export async function handleManualUpdate(info: UpdateInfo) {
|
||||
const result = await askForVisitingWebsiteForManualUpdate();
|
||||
if (result === ManualDownloadDialogResult.NoAction) {
|
||||
return;
|
||||
}
|
||||
const project = getTargetProject(info.version);
|
||||
if (result === ManualDownloadDialogResult.VisitReleasesPage) {
|
||||
await shell.openExternal(project.releaseUrl);
|
||||
} else if (result === ManualDownloadDialogResult.UpdateNow) {
|
||||
await download(info, project);
|
||||
}
|
||||
}
|
||||
|
||||
function getTargetProject(targetVersion: string) {
|
||||
const existingProject = parseProjectInformation(new ViteAppMetadata());
|
||||
const targetProject = new ProjectInformation(
|
||||
existingProject.name,
|
||||
new Version(targetVersion),
|
||||
existingProject.slogan,
|
||||
existingProject.repositoryUrl,
|
||||
existingProject.homepage,
|
||||
);
|
||||
return targetProject;
|
||||
}
|
||||
|
||||
enum ManualDownloadDialogResult {
|
||||
NoAction = 0,
|
||||
UpdateNow = 1,
|
||||
VisitReleasesPage = 2,
|
||||
}
|
||||
async function askForVisitingWebsiteForManualUpdate(): Promise<ManualDownloadDialogResult> {
|
||||
const visitPageResult = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
buttons: [
|
||||
'Not now', // First button is shown at bottom after some space in macOS and has default cancel behavior
|
||||
'Download and manually update',
|
||||
'Visit releases page',
|
||||
],
|
||||
message: 'Update available\n\nWould you like to update manually?',
|
||||
detail:
|
||||
'There are new updates available.'
|
||||
+ ' privacy.sexy does not support fully auto-update for macOS due to code signing costs.'
|
||||
+ ' Please manually update your version, because newer versions fix issues and improve privacy and security.',
|
||||
defaultId: ManualDownloadDialogResult.UpdateNow,
|
||||
cancelId: ManualDownloadDialogResult.NoAction,
|
||||
});
|
||||
return visitPageResult.response;
|
||||
}
|
||||
|
||||
async function download(info: UpdateInfo, project: ProjectInformation) {
|
||||
log.info('Downloading update manually');
|
||||
const progressBar = new UpdateProgressBar();
|
||||
progressBar.showIndeterminateState();
|
||||
try {
|
||||
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${info.version}-installer.dmg`;
|
||||
const parentFolder = path.dirname(filePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
log.info('Update is already downloaded');
|
||||
await fs.promises.unlink(filePath);
|
||||
log.info(`Deleted ${filePath}`);
|
||||
} else {
|
||||
await fs.promises.mkdir(parentFolder, { recursive: true });
|
||||
}
|
||||
const dmgFileUrl = project.getDownloadUrl(OperatingSystem.macOS);
|
||||
await downloadFileWithProgress(
|
||||
dmgFileUrl,
|
||||
filePath,
|
||||
(percentage) => { progressBar.showPercentage(percentage); },
|
||||
);
|
||||
await shell.openPath(filePath);
|
||||
progressBar.close();
|
||||
app.quit();
|
||||
} catch (e) {
|
||||
progressBar.showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressCallback = (progress: number) => void;
|
||||
|
||||
async function downloadFileWithProgress(
|
||||
url: string,
|
||||
filePath: string,
|
||||
progressHandler: ProgressCallback,
|
||||
) {
|
||||
// We don't download through autoUpdater as it cannot download DMG but requires distributing ZIP
|
||||
log.info(`Fetching ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw Error(`Unable to download, server returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const contentLength = +response.headers.get('content-length');
|
||||
const writer = fs.createWriteStream(filePath);
|
||||
log.info(`Writing to ${filePath}, content length: ${contentLength}`);
|
||||
if (!contentLength) {
|
||||
log.error('Unknown content-length', Array.from(response.headers.entries()));
|
||||
progressHandler = () => { /* do nothing */ };
|
||||
}
|
||||
const reader = getReader(response);
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
await streamWithProgress(contentLength, reader, writer, progressHandler);
|
||||
}
|
||||
|
||||
async function streamWithProgress(
|
||||
totalLength: number,
|
||||
readStream: NodeJS.ReadableStream,
|
||||
writeStream: fs.WriteStream,
|
||||
progressHandler: ProgressCallback,
|
||||
): Promise<void> {
|
||||
let receivedLength = 0;
|
||||
for await (const chunk of readStream) {
|
||||
if (!chunk) {
|
||||
throw Error('Empty chunk received during download');
|
||||
}
|
||||
writeStream.write(Buffer.from(chunk));
|
||||
receivedLength += chunk.length;
|
||||
const percentage = Math.floor((receivedLength / totalLength) * 100);
|
||||
progressHandler(percentage);
|
||||
log.debug(`Received ${receivedLength} of ${totalLength}`);
|
||||
}
|
||||
log.info('Downloaded successfully');
|
||||
}
|
||||
|
||||
function getReader(response: Response): NodeJS.ReadableStream {
|
||||
// On browser, we could use browser API response.body.getReader()
|
||||
// But here, we use cross-fetch that gets node-fetch on a node application
|
||||
// This API is node-fetch specific, see https://github.com/node-fetch/node-fetch#streams
|
||||
return response.body as unknown as NodeJS.ReadableStream;
|
||||
}
|
||||
95
src/presentation/electron/main/Update/UpdateProgressBar.ts
Normal file
95
src/presentation/electron/main/Update/UpdateProgressBar.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import ProgressBar from 'electron-progressbar';
|
||||
import { ProgressInfo } from 'electron-builder';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
export class UpdateProgressBar {
|
||||
private progressBar: ProgressBar;
|
||||
|
||||
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) => {
|
||||
log.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;
|
||||
},
|
||||
};
|
||||
46
src/presentation/electron/main/Update/Updater.ts
Normal file
46
src/presentation/electron/main/Update/Updater.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { autoUpdater, UpdateInfo } from 'electron-updater';
|
||||
import log from 'electron-log';
|
||||
import { handleManualUpdate, requiresManualUpdate } from './ManualUpdater';
|
||||
import { handleAutoUpdate } from './AutoUpdater';
|
||||
|
||||
interface IUpdater {
|
||||
checkForUpdates(): Promise<void>;
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(): IUpdater {
|
||||
autoUpdater.logger = log;
|
||||
|
||||
// Disable autodownloads because "checking" and "downloading" are handled separately based on the
|
||||
// current platform and user's choice.
|
||||
autoUpdater.autoDownload = false;
|
||||
|
||||
autoUpdater.on('error', (error: Error) => {
|
||||
log.error('@error@\n', error);
|
||||
});
|
||||
|
||||
let isAlreadyHandled = false;
|
||||
autoUpdater.on('update-available', async (info: UpdateInfo) => {
|
||||
log.info('@update-available@\n', info);
|
||||
if (isAlreadyHandled) {
|
||||
log.info('Available updates is already handled');
|
||||
return;
|
||||
}
|
||||
isAlreadyHandled = true;
|
||||
await handleAvailableUpdate(info);
|
||||
});
|
||||
|
||||
return {
|
||||
checkForUpdates: async () => {
|
||||
// autoUpdater.emit('update-available'); // For testing
|
||||
await autoUpdater.checkForUpdates();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleAvailableUpdate(info: UpdateInfo) {
|
||||
if (requiresManualUpdate()) {
|
||||
await handleManualUpdate(info);
|
||||
return;
|
||||
}
|
||||
await handleAutoUpdate();
|
||||
}
|
||||
152
src/presentation/electron/main/index.ts
Normal file
152
src/presentation/electron/main/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Initializes Electron's main process, always runs in the background, and manages the main window.
|
||||
|
||||
import {
|
||||
app, protocol, BrowserWindow, shell, screen,
|
||||
} from 'electron';
|
||||
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 = !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.
|
||||
let win: BrowserWindow | null;
|
||||
|
||||
// Scheme must be registered before the app is ready
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'app', privileges: { secure: true, standard: true } },
|
||||
]);
|
||||
|
||||
setupLogger();
|
||||
validateRuntimeSanity({
|
||||
validateMetadata: true,
|
||||
});
|
||||
|
||||
function createWindow() {
|
||||
// Create the browser window.
|
||||
const size = getWindowSize(1650, 955);
|
||||
win = new BrowserWindow({
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
preload: PRELOADER_SCRIPT_PATH,
|
||||
},
|
||||
icon: APP_ICON_PATH,
|
||||
});
|
||||
|
||||
win.setMenuBarVisibility(false);
|
||||
configureExternalsUrlsOpenBrowser(win);
|
||||
loadApplication(win);
|
||||
|
||||
win.on('closed', () => {
|
||||
win = null;
|
||||
});
|
||||
}
|
||||
|
||||
let macOsQuit = false;
|
||||
// Quit when all windows are closed.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform === 'darwin'
|
||||
&& !macOsQuit) {
|
||||
// On macOS it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
return;
|
||||
}
|
||||
app.quit();
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// On macOS we application quit is stopped if user does not Cmd + Q
|
||||
// But we still want to be able to use app.quit() and quit the application
|
||||
// on menu bar, after updates etc.
|
||||
app.on('before-quit', () => {
|
||||
macOsQuit = true;
|
||||
});
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (win === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('ready', async () => {
|
||||
if (isDevelopment) {
|
||||
try {
|
||||
await installExtension(VUEJS_DEVTOOLS);
|
||||
} catch (e) {
|
||||
log.error('Vue Devtools failed to install:', e.toString());
|
||||
}
|
||||
}
|
||||
createWindow();
|
||||
});
|
||||
|
||||
// Exit cleanly on request from parent process in development mode.
|
||||
if (isDevelopment) {
|
||||
if (process.platform === 'win32') {
|
||||
process.on('message', (data) => {
|
||||
if (data === 'graceful-exit') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
process.on('SIGTERM', () => {
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loadApplication(window: BrowserWindow) {
|
||||
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 {
|
||||
const updater = setupAutoUpdater();
|
||||
updater.checkForUpdates();
|
||||
}
|
||||
// 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) {
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/electron/electron/issues/19554 otherwise fs does not work
|
||||
function loadUrlWithNodeWorkaround(window: BrowserWindow, url: string) {
|
||||
setTimeout(() => {
|
||||
window.loadURL(url);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function getWindowSize(idealWidth: number, idealHeight: number) {
|
||||
let { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
// To ensure not creating a screen bigger than current screen size
|
||||
// Not using "enableLargerThanScreen" as it's macOS only (see https://www.electronjs.org/docs/api/browser-window)
|
||||
width = Math.min(width, idealWidth);
|
||||
height = Math.min(height, idealHeight);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function setupLogger(): void {
|
||||
log.transports.file.level = 'silly';
|
||||
if (!isDevelopment) {
|
||||
Object.assign(console, log.functions); // override console.log, console.warn etc.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user