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:
undergroundwires
2023-12-04 18:28:43 +01:00
parent 25e23c89c3
commit 4765752ee3
14 changed files with 653 additions and 176 deletions

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@ node_modules
# draw.io # draw.io
*.bkp *.bkp
*.dtmp *.dtmp
# macOS
.DS_Store

View File

@@ -179,4 +179,6 @@ Check [architecture.md](./docs/architecture.md) for an overview of design and ho
## Security ## Security
Security is a top priority at privacy.sexy. An extensive commitment to security verification ensures this priority. For any security concerns or vulnerabilities, please consult the [Security Policy](./SECURITY.md). Security is a top priority at privacy.sexy.
An extensive commitment to security verification ensures this priority.
For any security concerns or vulnerabilities, please consult the [Security Policy](./SECURITY.md).

View File

@@ -1,6 +1,7 @@
# Security Policy # Security Policy
privacy.sexy takes security seriously. Commitment is made to address all security issues with urgency. Responsible reporting of any discovered vulnerabilities in the project is highly encouraged. Security is a top priority at privacy.sexy.
Please report any discovered vulnerabilities responsibly.
## Reporting a Vulnerability ## Reporting a Vulnerability
@@ -11,20 +12,31 @@ Efforts to responsibly disclose findings are greatly appreciated. To report a se
## Security Report Handling ## Security Report Handling
Upon receipt of a security report, the following actions will be taken: Upon receiving a security report, the process involves:
- The report will be confirmed, identifying the affected components. - Confirming the report and identifying affected components.
- The impact and severity of the issue will be assessed. - Assessing the impact and severity of the issue.
- Work on a fix and plan a release to address the vulnerability will be initiated. - Fixing the vulnerability and planning a release to address it.
- The reporter will be kept updated about the progress. - Keeping the reporter informed about progress.
## Testing ## Security Practices
Regular and extensive testing is conducted to ensure robust security in the project. Information about testing practices can be found in the [Testing Documentation](./docs/tests.md). ### Update Security and Integrity
privacy.sexy benefits from automated update processes including security tests. Automated deployments from source code ensure immediate and secure updates, mirroring the latest source code. This aligns the deployed application with the expected source code, enhancing transparency and trust. For more details, see [CI/CD Documentation](./docs/ci-cd.md).
Every desktop update undergoes a thorough verification process. Updates are cryptographically signed to ensure authenticity and integrity, preventing tampered versions from reaching your device. Version checks are conducted to prevent downgrade attacks.
### Testing
privacy.sexy employs a comprehensive testing strategy that integrates extensive automated testing with manual community-driven tests.
Details on testing practices are available in the [Testing Documentation](./docs/tests.md).
## Support ## Support
For additional assistance or any unanswered questions, [submit a GitHub issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose). Security concerns are a priority, and necessary support to address them is assured. For help or any questions, [submit a GitHub issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose). Addressing security concerns is a priority, and we ensure the necessary support.
Support privacy.sexy's commitment to security by [making a donation ❤️](https://github.com/sponsors/undergroundwires). Your contributions aid in maintaining and enhancing the project's security features.
--- ---

View File

@@ -1,34 +1,52 @@
# Desktop vs. Web Features # Desktop vs. Web Features
This table outlines the differences between the desktop and web versions of `privacy.sexy`. This table highlights differences between the desktop and web versions of `privacy.sexy`.
| Feature | Desktop | Web | | Feature | Desktop | Web |
| ------- |---------|-----| | ------- | ------- | --- |
| [Usage without installation](#usage-without-installation) | 🔴 Not available | 🟢 Available | | [Usage without installation](#usage-without-installation) | 🔴 Not available | 🟢 Available |
| [Offline usage](#offline-usage) | 🟢 Available | 🟡 Partially available | | [Offline usage](#offline-usage) | 🟢 Available | 🟡 Partially available |
| [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available | | [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available |
| [Logging](#logging) | 🟢 Available | 🔴 Not available | | [Logging](#logging) | 🟢 Available | 🔴 Not available |
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available | | [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
## Feature Descriptions ## Feature descriptions
### Usage without installation ### Usage without installation
The web version can be used directly in a browser without any installation, whereas the desktop version requires downloading and installing the software. You can use the web version directly in a browser without installation.
The desktop version requires download and installation.
> **Note for Linux:** For Linux users, privacy.sexy is available as an AppImage, which is a portable format that does not require traditional installation. This means Linux users can use the desktop version without installation, similar to the web version. > **Note for Linux users:** On Linux, privacy.sexy is available as an `AppImage`, a portable format that doesn't need traditional installation.
> This allows Linux users to use the desktop version without full installation, akin to the web version.
### Offline usage ### Offline usage
Once loaded, the web version can be used offline. The desktop version inherently supports offline usage. The web version, once loaded, supports offline use.
Desktop version inherently allows offline usage.
### Auto-updates ### Auto-updates
Both versions automatically update to ensure you have the latest features and security enhancements. Both the desktop and web versions of privacy.sexy provide timely access to the latest features and security improvements. The updates are automatically deployed from source code, reflecting the latest changes for enhanced security and reliability. For more details, see [CI/CD documentation](./ci-cd.md).
The desktop version ensures secure delivery through cryptographic signatures and version checks.
[Security is a top priority](./../SECURITY.md#update-security-and-integrity) at privacy.sexy.
> **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> Users get notified about updates but might need to complete the installation manually.
> Your [support through donations](https://github.com/sponsors/undergroundwires) can help improve this process ❤️.
### Logging ### Logging
The desktop version supports logging of activities to aid in troubleshooting. This feature is not available in the web version. The desktop version supports logging of activities to aid in troubleshooting.
This feature is not available in the web version.
Log file locations vary by operating system:
- macOS: `$HOME/Library/Logs/privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/logs`
- Windows: `%APPDATA%\privacy.sexy\logs`
### Script execution ### Script execution

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
import { autoUpdater, UpdateInfo } from 'electron-updater'; import { autoUpdater, UpdateInfo } from 'electron-updater';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { handleManualUpdate, requiresManualUpdate } from './ManualUpdater'; import { requiresManualUpdate, startManualUpdateProcess } from './ManualUpdater/ManualUpdateCoordinator';
import { handleAutoUpdate } from './AutoUpdater'; import { handleAutoUpdate } from './AutomaticUpdateCoordinator';
interface IUpdater { interface Updater {
checkForUpdates(): Promise<void>; checkForUpdates(): Promise<void>;
} }
export function setupAutoUpdater(): IUpdater { export function setupAutoUpdater(): Updater {
autoUpdater.logger = ElectronLogger; autoUpdater.logger = ElectronLogger;
// Disable autodownloads because "checking" and "downloading" are handled separately based on the // Auto-downloads are disabled to allow separate handling of 'check' and 'download' actions,
// current platform and user's choice. // which vary based on the specific platform and user preferences.
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
autoUpdater.on('error', (error: Error) => { autoUpdater.on('error', (error: Error) => {
@@ -39,7 +39,7 @@ export function setupAutoUpdater(): IUpdater {
async function handleAvailableUpdate(info: UpdateInfo) { async function handleAvailableUpdate(info: UpdateInfo) {
if (requiresManualUpdate()) { if (requiresManualUpdate()) {
await handleManualUpdate(info); await startManualUpdateProcess(info);
return; return;
} }
await handleAutoUpdate(); await handleAutoUpdate();

View File

@@ -7,7 +7,7 @@ import log from 'electron-log/main';
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks'; import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { setupAutoUpdater } from './Update/Updater'; import { setupAutoUpdater } from './Update/UpdateInitializer';
import { import {
APP_ICON_PATH, PRELOADER_SCRIPT_PATH, RENDERER_HTML_PATH, RENDERER_URL, APP_ICON_PATH, PRELOADER_SCRIPT_PATH, RENDERER_HTML_PATH, RENDERER_URL,
} from './ElectronConfig'; } from './ElectronConfig';