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

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

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

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

View 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();
}