Improve security and reliability of macOS updates
This commit introduces several improvements to the macOS update process, primarily focusing on enhancing security and reliability: - Add data integrity checks to ensure downloaded updates haven't been tampered with. - Optimize update progress logging in `streamWithProgress` by limiting amount of logs during the download process. - Improve resource management by ensuring proper closure of file read/write streams. - Add retry logic with exponential back-off during file access to handle occassionally seen file system preparation delays on macOS. - Improve decision-making based on user responses. - Improve clarity and informativeness of log messages. - Update error dialogs for better user guidance when updates fail to download, unexpected errors occur or the installer can't be opened. - Add handling for unexpected errors during the update process. - Move to asynchronous functions for more efficient operation. - Move to scoped imports for better code clarity. - Update `Readable` stream type to a more modern variant in Node. - Refactor `ManualUpdater` for improved separation of concerns. - Document the secure update process, and log directory locations. - Rename files to more accurately reflect their purpose. - Add `.DS_Store` in `.gitignore` to avoid unintended files in commits.
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import { UpdateInfo } from 'electron-updater';
|
||||
import fetch from 'cross-fetch';
|
||||
import { ProjectInformation } from '@/domain/ProjectInformation';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { Version } from '@/domain/Version';
|
||||
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
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();
|
||||
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) {
|
||||
ElectronLogger.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)) {
|
||||
ElectronLogger.info('Update is already downloaded');
|
||||
await fs.promises.unlink(filePath);
|
||||
ElectronLogger.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
|
||||
ElectronLogger.info(`Fetching ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw Error(`Unable to download, server returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const contentLengthString = response.headers.get('content-length');
|
||||
if (contentLengthString === null || contentLengthString === undefined) {
|
||||
ElectronLogger.error('Content-Length header is missing');
|
||||
}
|
||||
const contentLength = +(contentLengthString ?? 0);
|
||||
const writer = fs.createWriteStream(filePath);
|
||||
ElectronLogger.info(`Writing to ${filePath}, content length: ${contentLength}`);
|
||||
if (Number.isNaN(contentLength) || contentLength <= 0) {
|
||||
ElectronLogger.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);
|
||||
ElectronLogger.debug(`Received ${receivedLength} of ${totalLength}`);
|
||||
}
|
||||
ElectronLogger.info('Downloaded successfully');
|
||||
}
|
||||
|
||||
function getReader(response: Response): NodeJS.ReadableStream | undefined {
|
||||
// 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;
|
||||
}
|
||||
122
src/presentation/electron/main/Update/ManualUpdater/Dialogs.ts
Normal file
122
src/presentation/electron/main/Update/ManualUpdater/Dialogs.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { dialog } from 'electron';
|
||||
|
||||
export enum ManualUpdateChoice {
|
||||
NoAction = 0,
|
||||
UpdateNow = 1,
|
||||
VisitReleasesPage = 2,
|
||||
}
|
||||
export async function promptForManualUpdate(): Promise<ManualUpdateChoice> {
|
||||
const visitPageResult = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
buttons: [
|
||||
'Not Now',
|
||||
'Download Update',
|
||||
'Visit Release Page',
|
||||
],
|
||||
message: [
|
||||
'A new version is available.',
|
||||
'Would you like to download it now?',
|
||||
].join('\n\n'),
|
||||
detail: [
|
||||
'Updates are highly recommended because they improve your privacy, security and safety.',
|
||||
'\n\n',
|
||||
'Auto-updates are not fully supported on macOS due to code signing costs.',
|
||||
'Consider donating ❤️.',
|
||||
].join(' '),
|
||||
defaultId: ManualUpdateChoice.UpdateNow,
|
||||
cancelId: ManualUpdateChoice.NoAction,
|
||||
});
|
||||
return visitPageResult.response;
|
||||
}
|
||||
|
||||
export enum IntegrityCheckChoice {
|
||||
Cancel = 0,
|
||||
RetryDownload = 1,
|
||||
ContinueAnyway = 2,
|
||||
}
|
||||
|
||||
export async function promptIntegrityCheckFailure(): Promise<IntegrityCheckChoice> {
|
||||
const integrityResult = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
buttons: [
|
||||
'Cancel Update',
|
||||
'Retry Download',
|
||||
'Continue Anyway',
|
||||
],
|
||||
message: 'Integrity check failed',
|
||||
detail:
|
||||
'The integrity check for the installer has failed,'
|
||||
+ ' which means the file may be corrupted or has been tampered with.'
|
||||
+ ' It is recommended to retry the download or cancel the installation for your safety.'
|
||||
+ '\n\nContinuing the installation might put your system at risk.',
|
||||
defaultId: IntegrityCheckChoice.RetryDownload,
|
||||
cancelId: IntegrityCheckChoice.Cancel,
|
||||
noLink: true,
|
||||
});
|
||||
return integrityResult.response;
|
||||
}
|
||||
|
||||
export enum InstallerErrorChoice {
|
||||
Cancel = 0,
|
||||
RetryDownload = 1,
|
||||
RetryOpen = 2,
|
||||
}
|
||||
|
||||
export async function promptInstallerOpenError(): Promise<InstallerErrorChoice> {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
buttons: [
|
||||
'Cancel Update',
|
||||
'Retry Download',
|
||||
'Retry Installation',
|
||||
],
|
||||
message: 'Installation Error',
|
||||
detail: 'The installer could not be launched. Please try again.',
|
||||
defaultId: InstallerErrorChoice.RetryOpen,
|
||||
cancelId: InstallerErrorChoice.Cancel,
|
||||
noLink: true,
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
|
||||
export enum DownloadErrorChoice {
|
||||
Cancel = 0,
|
||||
RetryDownload = 1,
|
||||
}
|
||||
|
||||
export async function promptDownloadError(): Promise<DownloadErrorChoice> {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
buttons: [
|
||||
'Cancel Update',
|
||||
'Retry Download',
|
||||
],
|
||||
message: 'Download Error',
|
||||
detail: 'Unable to download the update. Check your internet connection or try again later.',
|
||||
defaultId: DownloadErrorChoice.RetryDownload,
|
||||
cancelId: DownloadErrorChoice.Cancel,
|
||||
noLink: true,
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
|
||||
export enum UnexpectedErrorChoice {
|
||||
Cancel = 0,
|
||||
RetryUpdate = 1,
|
||||
}
|
||||
|
||||
export async function showUnexpectedError(): Promise<UnexpectedErrorChoice> {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
buttons: [
|
||||
'Cancel',
|
||||
'Retry Update',
|
||||
],
|
||||
message: 'Unexpected Error',
|
||||
detail: 'An unexpected error occurred. Would you like to retry updating?',
|
||||
defaultId: UnexpectedErrorChoice.RetryUpdate,
|
||||
cancelId: UnexpectedErrorChoice.Cancel,
|
||||
noLink: true,
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { existsSync, createWriteStream } from 'fs';
|
||||
import { unlink, mkdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { UpdateInfo } from 'electron-updater';
|
||||
import fetch from 'cross-fetch';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { UpdateProgressBar } from '../UpdateProgressBar';
|
||||
import { retryFileSystemAccess } from './RetryFileSystemAccess';
|
||||
import type { WriteStream } from 'fs';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
const MAX_PROGRESS_LOG_ENTRIES = 10;
|
||||
const UNKNOWN_SIZE_LOG_INTERVAL_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
export type DownloadUpdateResult = {
|
||||
readonly success: false;
|
||||
} | {
|
||||
readonly success: true;
|
||||
readonly installerPath: string;
|
||||
};
|
||||
|
||||
export async function downloadUpdate(
|
||||
info: UpdateInfo,
|
||||
remoteFileUrl: string,
|
||||
progressBar: UpdateProgressBar,
|
||||
): Promise<DownloadUpdateResult> {
|
||||
ElectronLogger.info('Starting manual update download.');
|
||||
progressBar.showIndeterminateState();
|
||||
try {
|
||||
const { filePath } = await downloadInstallerFile(
|
||||
info.version,
|
||||
remoteFileUrl,
|
||||
(percentage) => { progressBar.showPercentage(percentage); },
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
installerPath: filePath,
|
||||
};
|
||||
} catch (e) {
|
||||
progressBar.showError(e);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadInstallerFile(
|
||||
version: string,
|
||||
remoteFileUrl: string,
|
||||
progressHandler: ProgressCallback,
|
||||
): Promise<{ readonly filePath: string; }> {
|
||||
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${version}-installer.dmg`;
|
||||
if (!await ensureFilePathReady(filePath)) {
|
||||
throw new Error(`Failed to prepare the file path for the installer: ${filePath}`);
|
||||
}
|
||||
await downloadFileWithProgress(
|
||||
remoteFileUrl,
|
||||
filePath,
|
||||
progressHandler,
|
||||
);
|
||||
return { filePath };
|
||||
}
|
||||
|
||||
async function ensureFilePathReady(filePath: string): Promise<boolean> {
|
||||
return retryFileSystemAccess(async () => {
|
||||
try {
|
||||
const parentFolder = path.dirname(filePath);
|
||||
if (existsSync(filePath)) {
|
||||
ElectronLogger.info(`Existing update file found and will be replaced: ${filePath}`);
|
||||
await unlink(filePath);
|
||||
} else {
|
||||
await mkdir(parentFolder, { recursive: true });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
ElectronLogger.error(`Failed to prepare file path for update: ${filePath}`, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type ProgressCallback = (progress: number) => void;
|
||||
|
||||
async function downloadFileWithProgress(
|
||||
url: string,
|
||||
filePath: string,
|
||||
progressHandler: ProgressCallback,
|
||||
) {
|
||||
// autoUpdater cannot handle DMG files, requiring manual download management for these file types.
|
||||
ElectronLogger.info(`Retrieving update from ${url}.`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw Error(`Download failed: Server responded with ${response.status} ${response.statusText}.`);
|
||||
}
|
||||
const contentLength = getContentLengthFromResponse(response);
|
||||
await withWriteStream(filePath, async (writer) => {
|
||||
ElectronLogger.info(contentLength.isValid
|
||||
? `Saving file to ${filePath} (Size: ${contentLength.totalLength} bytes).`
|
||||
: `Saving file to ${filePath}.`);
|
||||
await withReadableStream(response, async (reader) => {
|
||||
await streamWithProgress(contentLength, reader, writer, progressHandler);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type ResponseContentLength = {
|
||||
readonly isValid: true;
|
||||
readonly totalLength: number;
|
||||
} | {
|
||||
readonly isValid: false;
|
||||
};
|
||||
|
||||
function getContentLengthFromResponse(response: Response): ResponseContentLength {
|
||||
const contentLengthString = response.headers.get('content-length');
|
||||
const headersInfo = Array.from(response.headers.entries());
|
||||
if (!contentLengthString) {
|
||||
ElectronLogger.warn('Missing \'Content-Length\' header in the response.', headersInfo);
|
||||
return { isValid: false };
|
||||
}
|
||||
const contentLength = Number(contentLengthString);
|
||||
if (Number.isNaN(contentLength) || contentLength <= 0) {
|
||||
ElectronLogger.error('Unable to determine download size from server response.', headersInfo);
|
||||
return { isValid: false };
|
||||
}
|
||||
return { totalLength: contentLength, isValid: true };
|
||||
}
|
||||
|
||||
async function withReadableStream(
|
||||
response: Response,
|
||||
handler: (readStream: Readable) => Promise<void>,
|
||||
) {
|
||||
const reader = createReader(response);
|
||||
try {
|
||||
await handler(reader);
|
||||
} finally {
|
||||
reader.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function withWriteStream(
|
||||
filePath: string,
|
||||
handler: (writeStream: WriteStream) => Promise<void>,
|
||||
) {
|
||||
const writer = createWriteStream(filePath);
|
||||
try {
|
||||
await handler(writer);
|
||||
} finally {
|
||||
writer.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function streamWithProgress(
|
||||
contentLength: ResponseContentLength,
|
||||
readStream: Readable,
|
||||
writeStream: WriteStream,
|
||||
progressHandler: ProgressCallback,
|
||||
): Promise<void> {
|
||||
let receivedLength = 0;
|
||||
let logThreshold = 0;
|
||||
for await (const chunk of readStream) {
|
||||
if (!chunk) {
|
||||
throw Error('Received empty data chunk during download.');
|
||||
}
|
||||
writeStream.write(Buffer.from(chunk));
|
||||
receivedLength += chunk.length;
|
||||
notifyProgress(contentLength, receivedLength, progressHandler);
|
||||
const progressLog = logProgress(receivedLength, contentLength, logThreshold);
|
||||
logThreshold = progressLog.nextLogThreshold;
|
||||
}
|
||||
ElectronLogger.info('Update download completed successfully.');
|
||||
}
|
||||
|
||||
function logProgress(
|
||||
receivedLength: number,
|
||||
contentLength: ResponseContentLength,
|
||||
logThreshold: number,
|
||||
): { readonly nextLogThreshold: number; } {
|
||||
const {
|
||||
shouldLog, nextLogThreshold,
|
||||
} = shouldLogProgress(receivedLength, contentLength, logThreshold);
|
||||
if (shouldLog) {
|
||||
ElectronLogger.debug(`Download progress: ${receivedLength} bytes received.`);
|
||||
}
|
||||
return { nextLogThreshold };
|
||||
}
|
||||
|
||||
function notifyProgress(
|
||||
contentLength: ResponseContentLength,
|
||||
receivedLength: number,
|
||||
progressHandler: ProgressCallback,
|
||||
) {
|
||||
if (!contentLength.isValid) {
|
||||
return;
|
||||
}
|
||||
const percentage = Math.floor((receivedLength / contentLength.totalLength) * 100);
|
||||
progressHandler(percentage);
|
||||
}
|
||||
|
||||
function shouldLogProgress(
|
||||
receivedLength: number,
|
||||
contentLength: ResponseContentLength,
|
||||
previousLogThreshold: number,
|
||||
): { shouldLog: boolean, nextLogThreshold: number } {
|
||||
const logInterval = contentLength.isValid
|
||||
? Math.ceil(contentLength.totalLength / MAX_PROGRESS_LOG_ENTRIES)
|
||||
: UNKNOWN_SIZE_LOG_INTERVAL_BYTES;
|
||||
|
||||
if (receivedLength >= previousLogThreshold + logInterval) {
|
||||
return { shouldLog: true, nextLogThreshold: previousLogThreshold + logInterval };
|
||||
}
|
||||
return { shouldLog: false, nextLogThreshold: previousLogThreshold };
|
||||
}
|
||||
|
||||
function createReader(response: Response): Readable {
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is empty, cannot proceed with download.');
|
||||
}
|
||||
// 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 Readable;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { app, shell } from 'electron';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { retryFileSystemAccess } from './RetryFileSystemAccess';
|
||||
|
||||
export async function startInstallation(filePath: string): Promise<boolean> {
|
||||
return retryFileSystemAccess(async () => {
|
||||
ElectronLogger.info(`Attempting to open the installer at: ${filePath}.`);
|
||||
const error = await shell.openPath(filePath);
|
||||
if (!error) {
|
||||
app.quit();
|
||||
return true;
|
||||
}
|
||||
ElectronLogger.error(`Failed to open the installer at ${filePath}.`, error);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { createReadStream } from 'fs';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { retryFileSystemAccess } from './RetryFileSystemAccess';
|
||||
|
||||
export async function checkIntegrity(
|
||||
filePath: string,
|
||||
base64Sha512: string,
|
||||
): Promise<boolean> {
|
||||
return retryFileSystemAccess(
|
||||
async () => {
|
||||
const hash = await computeSha512(filePath);
|
||||
if (hash === base64Sha512) {
|
||||
ElectronLogger.info(`Integrity check passed for file: ${filePath}.`);
|
||||
return true;
|
||||
}
|
||||
ElectronLogger.warn([
|
||||
`Integrity check failed for file: ${filePath}`,
|
||||
`Expected hash: ${base64Sha512}, but found: ${hash}`,
|
||||
].join('\n'));
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function computeSha512(filePath: string): Promise<string> {
|
||||
try {
|
||||
const hash = createHash('sha512');
|
||||
const stream = createReadStream(filePath);
|
||||
for await (const chunk of stream) {
|
||||
hash.update(chunk);
|
||||
}
|
||||
return hash.digest('base64');
|
||||
} catch (error) {
|
||||
ElectronLogger.error(`Failed to compute SHA512 hash for file: ${filePath}`, error);
|
||||
throw error; // Rethrow to handle it in the calling context if necessary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { shell } from 'electron';
|
||||
import { UpdateInfo } from 'electron-updater';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { ProjectInformation } from '@/domain/ProjectInformation';
|
||||
import { Version } from '@/domain/Version';
|
||||
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { UpdateProgressBar } from '../UpdateProgressBar';
|
||||
import {
|
||||
promptForManualUpdate, promptInstallerOpenError,
|
||||
promptIntegrityCheckFailure, promptDownloadError,
|
||||
DownloadErrorChoice, InstallerErrorChoice, IntegrityCheckChoice,
|
||||
ManualUpdateChoice, showUnexpectedError, UnexpectedErrorChoice,
|
||||
} from './Dialogs';
|
||||
import { DownloadUpdateResult, downloadUpdate } from './Downloader';
|
||||
import { checkIntegrity } from './Integrity';
|
||||
import { startInstallation } from './Installer';
|
||||
|
||||
export function requiresManualUpdate(): boolean {
|
||||
return process.platform === 'darwin';
|
||||
}
|
||||
|
||||
export async function startManualUpdateProcess(info: UpdateInfo) {
|
||||
try {
|
||||
const updateAction = await promptForManualUpdate();
|
||||
if (updateAction === ManualUpdateChoice.NoAction) {
|
||||
ElectronLogger.info('User cancelled the update.');
|
||||
return;
|
||||
}
|
||||
const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version);
|
||||
if (updateAction === ManualUpdateChoice.VisitReleasesPage) {
|
||||
ElectronLogger.info(`Navigating to release page: ${releaseUrl}`);
|
||||
await shell.openExternal(releaseUrl);
|
||||
} else if (updateAction === ManualUpdateChoice.UpdateNow) {
|
||||
ElectronLogger.info('Initiating update download and installation.');
|
||||
await downloadAndInstallUpdate(downloadUrl, info);
|
||||
}
|
||||
} catch (err) {
|
||||
ElectronLogger.error('Unexpected error during updates', err);
|
||||
await handleUnexpectedError(info);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
|
||||
let download: DownloadUpdateResult | undefined;
|
||||
await withProgressBar(async (progressBar) => {
|
||||
download = await downloadUpdate(info, fileUrl, progressBar);
|
||||
});
|
||||
if (!download?.success) {
|
||||
await handleFailedDownload(info);
|
||||
return;
|
||||
}
|
||||
if (await isIntegrityPreserved(download.installerPath, fileUrl, info)) {
|
||||
await openInstaller(download.installerPath, info);
|
||||
return;
|
||||
}
|
||||
const userAction = await promptIntegrityCheckFailure();
|
||||
if (userAction === IntegrityCheckChoice.RetryDownload) {
|
||||
await startManualUpdateProcess(info);
|
||||
} else if (userAction === IntegrityCheckChoice.ContinueAnyway) {
|
||||
ElectronLogger.warn('Proceeding to install with failed integrity check.');
|
||||
await openInstaller(download.installerPath, info);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFailedDownload(info: UpdateInfo) {
|
||||
const userAction = await promptDownloadError();
|
||||
if (userAction === DownloadErrorChoice.Cancel) {
|
||||
ElectronLogger.info('Update download canceled.');
|
||||
} else if (userAction === DownloadErrorChoice.RetryDownload) {
|
||||
ElectronLogger.info('Retrying update download.');
|
||||
await startManualUpdateProcess(info);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnexpectedError(info: UpdateInfo) {
|
||||
const userAction = await showUnexpectedError();
|
||||
if (userAction === UnexpectedErrorChoice.Cancel) {
|
||||
ElectronLogger.info('Unexpected error handling canceled.');
|
||||
} else if (userAction === UnexpectedErrorChoice.RetryUpdate) {
|
||||
ElectronLogger.info('Retrying the update process.');
|
||||
await startManualUpdateProcess(info);
|
||||
}
|
||||
}
|
||||
|
||||
async function openInstaller(installerPath: string, info: UpdateInfo) {
|
||||
if (await startInstallation(installerPath)) {
|
||||
return;
|
||||
}
|
||||
const userAction = await promptInstallerOpenError();
|
||||
if (userAction === InstallerErrorChoice.RetryDownload) {
|
||||
await startManualUpdateProcess(info);
|
||||
} else if (userAction === InstallerErrorChoice.RetryOpen) {
|
||||
await openInstaller(installerPath, info);
|
||||
}
|
||||
}
|
||||
|
||||
async function withProgressBar(
|
||||
action: (progressBar: UpdateProgressBar) => Promise<void>,
|
||||
) {
|
||||
const progressBar = new UpdateProgressBar();
|
||||
await action(progressBar);
|
||||
progressBar.close();
|
||||
}
|
||||
|
||||
async function isIntegrityPreserved(
|
||||
filePath: string,
|
||||
fileUrl: string,
|
||||
info: UpdateInfo,
|
||||
): Promise<boolean> {
|
||||
const sha512Hash = getRemoteSha512Hash(info, fileUrl);
|
||||
if (!sha512Hash) {
|
||||
return false;
|
||||
}
|
||||
const integrityCheckResult = await checkIntegrity(filePath, sha512Hash);
|
||||
return integrityCheckResult;
|
||||
}
|
||||
|
||||
function getRemoteSha512Hash(info: UpdateInfo, fileUrl: string): string | undefined {
|
||||
const fileInfos = info.files.filter((file) => fileUrl.includes(file.url));
|
||||
if (!fileInfos.length) {
|
||||
ElectronLogger.error(`Remote hash not found for the URL: ${fileUrl}`, info.files);
|
||||
if (info.files.length > 0) {
|
||||
const firstHash = info.files[0].sha512;
|
||||
ElectronLogger.info(`Selecting the first available hash: ${firstHash}`);
|
||||
return firstHash;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (fileInfos.length > 1) {
|
||||
ElectronLogger.error(`Found multiple file entries for the URL: ${fileUrl}`, fileInfos);
|
||||
}
|
||||
return fileInfos[0].sha512;
|
||||
}
|
||||
|
||||
interface UpdateUrls {
|
||||
readonly releaseUrl: string;
|
||||
readonly downloadUrl: string;
|
||||
}
|
||||
|
||||
function getRemoteUpdateUrls(targetVersion: string): UpdateUrls {
|
||||
const existingProject = parseProjectInformation();
|
||||
const targetProject = new ProjectInformation(
|
||||
existingProject.name,
|
||||
new Version(targetVersion),
|
||||
existingProject.slogan,
|
||||
existingProject.repositoryUrl,
|
||||
existingProject.homepage,
|
||||
);
|
||||
return {
|
||||
releaseUrl: targetProject.releaseUrl,
|
||||
downloadUrl: targetProject.getDownloadUrl(OperatingSystem.macOS),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||
|
||||
export function retryFileSystemAccess(
|
||||
fileOperation: () => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
return retryWithExponentialBackoff(
|
||||
fileOperation,
|
||||
TOTAL_RETRIES,
|
||||
INITIAL_DELAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
// These values provide a balanced approach for handling transient file system
|
||||
// issues without excessive waiting.
|
||||
const INITIAL_DELAY_MS = 500;
|
||||
const TOTAL_RETRIES = 3;
|
||||
|
||||
async function retryWithExponentialBackoff(
|
||||
operation: () => Promise<boolean>,
|
||||
maxAttempts: number,
|
||||
delayInMs: number,
|
||||
currentAttempt = 1,
|
||||
): Promise<boolean> {
|
||||
const result = await operation();
|
||||
if (result || currentAttempt === maxAttempts) {
|
||||
return result;
|
||||
}
|
||||
ElectronLogger.info(`Attempting retry (${currentAttempt}/${TOTAL_RETRIES}) in ${delayInMs} ms.`);
|
||||
await sleep(delayInMs);
|
||||
const exponentialDelayInMs = delayInMs * 2;
|
||||
const nextAttempt = currentAttempt + 1;
|
||||
return retryWithExponentialBackoff(
|
||||
operation,
|
||||
maxAttempts,
|
||||
exponentialDelayInMs,
|
||||
nextAttempt,
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { autoUpdater, UpdateInfo } from 'electron-updater';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { handleManualUpdate, requiresManualUpdate } from './ManualUpdater';
|
||||
import { handleAutoUpdate } from './AutoUpdater';
|
||||
import { requiresManualUpdate, startManualUpdateProcess } from './ManualUpdater/ManualUpdateCoordinator';
|
||||
import { handleAutoUpdate } from './AutomaticUpdateCoordinator';
|
||||
|
||||
interface IUpdater {
|
||||
interface Updater {
|
||||
checkForUpdates(): Promise<void>;
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(): IUpdater {
|
||||
export function setupAutoUpdater(): Updater {
|
||||
autoUpdater.logger = ElectronLogger;
|
||||
|
||||
// Disable autodownloads because "checking" and "downloading" are handled separately based on the
|
||||
// current platform and user's choice.
|
||||
// Auto-downloads are disabled to allow separate handling of 'check' and 'download' actions,
|
||||
// which vary based on the specific platform and user preferences.
|
||||
autoUpdater.autoDownload = false;
|
||||
|
||||
autoUpdater.on('error', (error: Error) => {
|
||||
@@ -39,7 +39,7 @@ export function setupAutoUpdater(): IUpdater {
|
||||
|
||||
async function handleAvailableUpdate(info: UpdateInfo) {
|
||||
if (requiresManualUpdate()) {
|
||||
await handleManualUpdate(info);
|
||||
await startManualUpdateProcess(info);
|
||||
return;
|
||||
}
|
||||
await handleAutoUpdate();
|
||||
Reference in New Issue
Block a user