diff --git a/.gitignore b/.gitignore index 389bddc68..595350d0a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ node_modules # draw.io *.bkp *.dtmp + +# macOS +.DS_Store diff --git a/README.md b/README.md index c46f131a5..96246d8df 100644 --- a/README.md +++ b/README.md @@ -179,4 +179,6 @@ Check [architecture.md](./docs/architecture.md) for an overview of design and ho ## 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). diff --git a/SECURITY.md b/SECURITY.md index 62b90e5b1..19cc5f52c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,7 @@ # 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 @@ -11,20 +12,31 @@ Efforts to responsibly disclose findings are greatly appreciated. To report a se ## 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. -- The impact and severity of the issue will be assessed. -- Work on a fix and plan a release to address the vulnerability will be initiated. -- The reporter will be kept updated about the progress. +- Confirming the report and identifying affected components. +- Assessing the impact and severity of the issue. +- Fixing the vulnerability and planning a release to address it. +- 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](./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 combines extensive automated testing approach with manual tests with community. +Details on testing practices are available in the [Testing Documentation](./docs/tests.md). ## 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. --- diff --git a/docs/desktop-vs-web-features.md b/docs/desktop-vs-web-features.md index 22b00c18f..035b216fd 100644 --- a/docs/desktop-vs-web-features.md +++ b/docs/desktop-vs-web-features.md @@ -1,34 +1,52 @@ # 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 | -| ------- |---------|-----| +| ------- | ------- | --- | | [Usage without installation](#usage-without-installation) | 🔴 Not available | 🟢 Available | | [Offline usage](#offline-usage) | 🟢 Available | 🟡 Partially available | | [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available | | [Logging](#logging) | 🟢 Available | 🔴 Not available | | [Script execution](#script-execution) | 🟢 Available | 🔴 Not available | -## Feature Descriptions +## Feature descriptions ### 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 -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 -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 -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 diff --git a/src/presentation/electron/main/Update/AutoUpdater.ts b/src/presentation/electron/main/Update/AutomaticUpdateCoordinator.ts similarity index 100% rename from src/presentation/electron/main/Update/AutoUpdater.ts rename to src/presentation/electron/main/Update/AutomaticUpdateCoordinator.ts diff --git a/src/presentation/electron/main/Update/ManualUpdater.ts b/src/presentation/electron/main/Update/ManualUpdater.ts deleted file mode 100644 index e91ebb5cd..000000000 --- a/src/presentation/electron/main/Update/ManualUpdater.ts +++ /dev/null @@ -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 { - 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 { - 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; -} diff --git a/src/presentation/electron/main/Update/ManualUpdater/Dialogs.ts b/src/presentation/electron/main/Update/ManualUpdater/Dialogs.ts new file mode 100644 index 000000000..7d24b1cd7 --- /dev/null +++ b/src/presentation/electron/main/Update/ManualUpdater/Dialogs.ts @@ -0,0 +1,123 @@ +import { dialog } from 'electron'; + +export enum ManualDownloadDialogResult { + NoAction = 0, + UpdateNow = 1, + VisitReleasesPage = 2, +} +export async function askForVisitingWebsiteForManualUpdate(): Promise { + 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: ManualDownloadDialogResult.UpdateNow, + cancelId: ManualDownloadDialogResult.NoAction, + }); + return visitPageResult.response; +} + +export enum IntegrityCheckDialogResult { + Cancel = 0, + RetryDownload = 1, + ContinueAnyway = 2, +} + +export async function showIntegrityCheckFailureDialog(): Promise { + 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: IntegrityCheckDialogResult.RetryDownload, + cancelId: IntegrityCheckDialogResult.Cancel, + noLink: true, + }); + return integrityResult.response; +} + +export enum InstallerExecutionErrorResult { + Cancel = 0, + RetryDownload = 1, + RetryOpen = 2, +} + +export async function showInstallerCannotBeOpenedError(): Promise { + 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: InstallerExecutionErrorResult.RetryOpen, + cancelId: InstallerExecutionErrorResult.Cancel, + noLink: true, + }); + return result.response; +} + +export enum UpdateDownloadErrorDialogResult { + Cancel = 0, + RetryDownload = 1, +} + +export async function showUpdatesCannotBeDownloadedError(): Promise< +UpdateDownloadErrorDialogResult> { + 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: UpdateDownloadErrorDialogResult.RetryDownload, + cancelId: UpdateDownloadErrorDialogResult.Cancel, + noLink: true, + }); + return result.response; +} + +export enum UnexpectedErrorDialogResult { + Cancel = 0, + RetryUpdate = 1, +} + +export async function showUnexpectedError(): Promise { + 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: UnexpectedErrorDialogResult.RetryUpdate, + cancelId: UnexpectedErrorDialogResult.Cancel, + noLink: true, + }); + return result.response; +} diff --git a/src/presentation/electron/main/Update/ManualUpdater/Downloader.ts b/src/presentation/electron/main/Update/ManualUpdater/Downloader.ts new file mode 100644 index 000000000..422216810 --- /dev/null +++ b/src/presentation/electron/main/Update/ManualUpdater/Downloader.ts @@ -0,0 +1,208 @@ +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 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 { + 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`; + 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 }); + } + await downloadFileWithProgress( + remoteFileUrl, + filePath, + progressHandler, + ); + return { filePath }; +} + +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, +) { + const reader = createReader(response); + try { + await handler(reader); + } finally { + reader.destroy(); + } +} + +async function withWriteStream( + filePath: string, + handler: (writeStream: WriteStream) => Promise, +) { + const writer = createWriteStream(filePath); + try { + await handler(writer); + } finally { + writer.end(); + } +} + +async function streamWithProgress( + contentLength: ResponseContentLength, + readStream: Readable, + writeStream: WriteStream, + progressHandler: ProgressCallback, +): Promise { + 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; +} diff --git a/src/presentation/electron/main/Update/ManualUpdater/Installer.ts b/src/presentation/electron/main/Update/ManualUpdater/Installer.ts new file mode 100644 index 000000000..67d831287 --- /dev/null +++ b/src/presentation/electron/main/Update/ManualUpdater/Installer.ts @@ -0,0 +1,13 @@ +import { app, shell } from 'electron'; +import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; + +export async function startInstallation(filePath: string): Promise { + 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; +} diff --git a/src/presentation/electron/main/Update/ManualUpdater/Integrity.ts b/src/presentation/electron/main/Update/ManualUpdater/Integrity.ts new file mode 100644 index 000000000..9e710df08 --- /dev/null +++ b/src/presentation/electron/main/Update/ManualUpdater/Integrity.ts @@ -0,0 +1,66 @@ +import { createHash } from 'crypto'; +import { createReadStream } from 'fs'; +import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; +import { sleep } from '@/infrastructure/Threading/AsyncSleep'; + +const INITIAL_DELAY_MS = 500; +const TOTAL_RETRIES = 3; + +export async function checkIntegrity( + filePath: string, + base64Sha512: string, +): Promise { + return retryWithExponentialDelay( + 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; + }, + TOTAL_RETRIES, + INITIAL_DELAY_MS, + ); +} + +async function computeSha512(filePath: string): Promise { + 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 + } +} + +// Retry with increasing delay to handle file system and other transient issues. +async function retryWithExponentialDelay( + operation: () => Promise, + maxAttempts: number, + delayInMs: number, + currentAttempt = 1, +): Promise { + 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 retryWithExponentialDelay( + operation, + maxAttempts, + exponentialDelayInMs, + nextAttempt, + ); +} diff --git a/src/presentation/electron/main/Update/ManualUpdater/ManualUpdateCoordinator.ts b/src/presentation/electron/main/Update/ManualUpdater/ManualUpdateCoordinator.ts new file mode 100644 index 000000000..e3e704b06 --- /dev/null +++ b/src/presentation/electron/main/Update/ManualUpdater/ManualUpdateCoordinator.ts @@ -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 { + askForVisitingWebsiteForManualUpdate, showInstallerCannotBeOpenedError, + showIntegrityCheckFailureDialog, showUpdatesCannotBeDownloadedError, + UpdateDownloadErrorDialogResult, InstallerExecutionErrorResult, IntegrityCheckDialogResult, + ManualDownloadDialogResult, showUnexpectedError, UnexpectedErrorDialogResult, +} 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 askForVisitingWebsiteForManualUpdate(); + if (updateAction === ManualDownloadDialogResult.NoAction) { + ElectronLogger.info('User cancelled the update.'); + return; + } + const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version); + if (updateAction === ManualDownloadDialogResult.VisitReleasesPage) { + ElectronLogger.info(`Navigating to release page: ${releaseUrl}`); + await shell.openExternal(releaseUrl); + } else if (updateAction === ManualDownloadDialogResult.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 showIntegrityCheckFailureDialog(); + if (userAction === IntegrityCheckDialogResult.RetryDownload) { + await startManualUpdateProcess(info); + } else if (userAction === IntegrityCheckDialogResult.ContinueAnyway) { + ElectronLogger.warn('Proceeding to install with failed integrity check.'); + await openInstaller(download.installerPath, info); + } +} + +async function handleFailedDownload(info: UpdateInfo) { + const userAction = await showUpdatesCannotBeDownloadedError(); + if (userAction === UpdateDownloadErrorDialogResult.Cancel) { + ElectronLogger.info('Update download canceled.'); + } else if (userAction === UpdateDownloadErrorDialogResult.RetryDownload) { + ElectronLogger.info('Retrying update download.'); + await startManualUpdateProcess(info); + } +} + +async function handleUnexpectedError(info: UpdateInfo) { + const userAction = await showUnexpectedError(); + if (userAction === UnexpectedErrorDialogResult.Cancel) { + ElectronLogger.info('Unexpected error handling canceled.'); + } else if (userAction === UnexpectedErrorDialogResult.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 showInstallerCannotBeOpenedError(); + if (userAction === InstallerExecutionErrorResult.RetryDownload) { + await startManualUpdateProcess(info); + } else if (userAction === InstallerExecutionErrorResult.RetryOpen) { + await openInstaller(installerPath, info); + } +} + +async function withProgressBar( + action: (progressBar: UpdateProgressBar) => Promise, +) { + const progressBar = new UpdateProgressBar(); + await action(progressBar); + progressBar.close(); +} + +async function isIntegrityPreserved( + filePath: string, + fileUrl: string, + info: UpdateInfo, +): Promise { + 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), + }; +} diff --git a/src/presentation/electron/main/Update/Updater.ts b/src/presentation/electron/main/Update/UpdateInitializer.ts similarity index 69% rename from src/presentation/electron/main/Update/Updater.ts rename to src/presentation/electron/main/Update/UpdateInitializer.ts index 77e788295..afd848caf 100644 --- a/src/presentation/electron/main/Update/Updater.ts +++ b/src/presentation/electron/main/Update/UpdateInitializer.ts @@ -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; } -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(); diff --git a/src/presentation/electron/main/index.ts b/src/presentation/electron/main/index.ts index 46a54da8f..d439f52e2 100644 --- a/src/presentation/electron/main/index.ts +++ b/src/presentation/electron/main/index.ts @@ -7,7 +7,7 @@ import log from 'electron-log/main'; import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; -import { setupAutoUpdater } from './Update/Updater'; +import { setupAutoUpdater } from './Update/UpdateInitializer'; import { APP_ICON_PATH, PRELOADER_SCRIPT_PATH, RENDERER_HTML_PATH, RENDERER_URL, } from './ElectronConfig';