Fix file retention after updates on macOS #417

This fixes issue #417 where autoupdate installer files were not deleted
on macOS, leading to accumulation of old installers.

Key changes:

- Store update files in application-specific directory
- Clear update files directory on every app launch

Other supporting changes:

- Refactor file system operations to be more testable and reusable
- Improve separation of concerns in directory management
- Enhance dependency injection for auto-update logic
- Fix async completion to support `await` operations
- Add additional logging and revise some log messages during updates
This commit is contained in:
undergroundwires
2024-10-07 17:33:47 +02:00
parent 4e06d543b3
commit 2f31bc7b06
44 changed files with 1484 additions and 590 deletions

View File

@@ -1,23 +0,0 @@
import type { CodeRunError } from '@/application/CodeRunner/CodeRunner';
export interface ScriptDirectoryProvider {
provideScriptDirectory(): Promise<ScriptDirectoryOutcome>;
}
export type ScriptDirectoryOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
interface ScriptDirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: CodeRunError;
}
interface SuccessfulDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: false;
readonly error: CodeRunError;
}

View File

@@ -1,21 +1,22 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
import type { SystemOperations } from '../System/SystemOperations';
import type { FilenameGenerator } from './Filename/FilenameGenerator';
import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
import type { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(),
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(),
private readonly directoryProvider: ApplicationDirectoryProvider
= new PersistentApplicationDirectoryProvider(),
private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
private readonly logger: Logger = ElectronLogger,
) { }
@@ -26,9 +27,12 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): Promise<ScriptFileCreationOutcome> {
const {
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
} = await this.directoryProvider.provideScriptDirectory();
} = await this.directoryProvider.provideDirectory('script-runs');
if (!isDirectoryCreated) {
return createFailure(directoryCreationError);
return createFailure({
type: 'DirectoryCreationError',
message: `[${directoryCreationError.type}] ${directoryCreationError.message}`,
});
}
const {
success: isFilePathConstructed, error: filePathGenerationError, filePath,
@@ -54,7 +58,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): FilePathConstructionOutcome {
try {
const filename = this.filenameGenerator.generateFilename(scriptFilenameParts);
const filePath = this.system.location.combinePaths(directoryPath, filename);
const filePath = this.fileSystem.combinePaths(directoryPath, filename);
return { success: true, filePath };
} catch (error) {
return {

View File

@@ -7,7 +7,7 @@ import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly system: SystemOperations = NodeElectronSystemOperations,
private readonly logger: Logger = ElectronLogger,
) { }

View File

@@ -7,7 +7,7 @@ import type { ShellCommandOutcome, ShellCommandRunner } from './ShellCommandRunn
export class LoggingNodeShellCommandRunner implements ShellCommandRunner {
constructor(
private readonly logger: Logger = ElectronLogger,
private readonly systemOps: SystemOperations = new NodeElectronSystemOperations(),
private readonly systemOps: SystemOperations = NodeElectronSystemOperations,
) {
}

View File

@@ -1,57 +1,13 @@
import { join } from 'node:path';
import { chmod, mkdir } from 'node:fs/promises';
import { exec } from 'node:child_process';
import { app } from 'electron/main';
import type {
CommandOps, FileSystemOps, LocationOps, OperatingSystemOps, SystemOperations,
} from './SystemOperations';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import type { SystemOperations } from './SystemOperations';
/**
* Thin wrapper for Node and Electron APIs.
*/
export class NodeElectronSystemOperations implements SystemOperations {
public readonly operatingSystem: OperatingSystemOps = {
/*
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
getUserDataDirectory: () => {
return app.getPath('userData');
},
};
public readonly location: LocationOps = {
combinePaths: (...pathSegments) => join(...pathSegments),
};
public readonly fileSystem: FileSystemOps = {
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
};
public readonly command: CommandOps = {
export const NodeElectronSystemOperations: SystemOperations = {
fileSystem: NodeElectronFileSystemOperations,
command: {
exec,
};
}
},
};

View File

@@ -1,25 +1,11 @@
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { exec } from 'node:child_process';
export interface SystemOperations {
readonly operatingSystem: OperatingSystemOps;
readonly location: LocationOps;
readonly fileSystem: FileSystemOps;
readonly fileSystem: FileSystemOperations;
readonly command: CommandOps;
}
export interface OperatingSystemOps {
getUserDataDirectory(): string;
}
export interface LocationOps {
combinePaths(...pathSegments: string[]): string;
}
export interface CommandOps {
exec(command: string): ReturnType<typeof exec>;
}
export interface FileSystemOps {
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
}

View File

@@ -5,8 +5,8 @@ import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import {
FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome,
} from '@/presentation/common/Dialog';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {

View File

@@ -0,0 +1,30 @@
export interface ApplicationDirectoryProvider {
provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome>;
}
export type DirectoryType = 'update-installation-files' | 'script-runs';
export type DirectoryCreationOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
export type DirectoryCreationErrorType = 'PathConstructionError' | 'DirectoryWriteError' | 'UserDataFolderRetrievalError';
export interface DirectoryCreationError {
readonly type: DirectoryCreationErrorType;
readonly message: string;
}
interface DirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: DirectoryCreationError;
}
interface SuccessfulDirectoryCreation extends DirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends DirectoryCreationStatus {
readonly success: false;
readonly error: DirectoryCreationError;
}

View File

@@ -1,32 +1,37 @@
import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations';
import type { SystemOperations } from '../../System/SystemOperations';
import type { ScriptDirectoryOutcome, ScriptDirectoryProvider } from './ScriptDirectoryProvider';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import type {
DirectoryCreationOutcome, ApplicationDirectoryProvider, DirectoryType,
DirectoryCreationError, DirectoryCreationErrorType,
} from './ApplicationDirectoryProvider';
import type { FileSystemOperations } from '../FileSystemOperations';
export const ExecutionSubdirectory = 'runs';
export const SubdirectoryNames: Record<DirectoryType, string> = {
'script-runs': 'runs',
'update-installation-files': 'updates',
};
/**
* Provides a dedicated directory for script execution.
* Provides persistent directories.
* Benefits of using a persistent directory:
* - Antivirus Exclusions: Easier antivirus configuration.
* - Auditability: Stores script execution history for troubleshooting.
* - Reliability: Avoids issues with directory clean-ups during execution,
* seen in Windows Pro Azure VMs when stored on Windows temporary directory.
*/
export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
export class PersistentApplicationDirectoryProvider implements ApplicationDirectoryProvider {
constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
private readonly logger: Logger = ElectronLogger,
) { }
public async provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
public async provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome> {
const {
success: isPathConstructed,
error: pathConstructionError,
directoryPath,
} = this.constructScriptDirectoryPath();
} = this.constructScriptDirectoryPath(type);
if (!isPathConstructed) {
return {
success: false,
@@ -52,7 +57,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> {
try {
this.logger.info(`Attempting to create script directory at path: ${directoryPath}`);
await this.system.fileSystem.createDirectory(directoryPath, true);
await this.fileSystem.createDirectory(directoryPath, true);
this.logger.info(`Script directory successfully created at: ${directoryPath}`);
return {
success: true,
@@ -60,17 +65,26 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
} catch (error) {
return {
success: false,
error: this.handleException(error, 'DirectoryCreationError'),
error: this.handleError(error, 'DirectoryWriteError'),
};
}
}
private constructScriptDirectoryPath(): DirectoryPathConstructionOutcome {
private constructScriptDirectoryPath(type: DirectoryType): DirectoryPathConstructionOutcome {
let parentDirectory: string;
try {
const parentDirectory = this.system.operatingSystem.getUserDataDirectory();
const scriptDirectory = this.system.location.combinePaths(
parentDirectory = this.fileSystem.getUserDataDirectory();
} catch (error) {
return {
success: false,
error: this.handleError(error, 'UserDataFolderRetrievalError'),
};
}
try {
const subdirectoryName = SubdirectoryNames[type];
const scriptDirectory = this.fileSystem.combinePaths(
parentDirectory,
ExecutionSubdirectory,
subdirectoryName,
);
return {
success: true,
@@ -79,15 +93,15 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
} catch (error) {
return {
success: false,
error: this.handleException(error, 'DirectoryCreationError'),
error: this.handleError(error, 'PathConstructionError'),
};
}
}
private handleException(
private handleError(
exception: Error,
errorType: CodeRunErrorType,
): CodeRunError {
errorType: DirectoryCreationErrorType,
): DirectoryCreationError {
const errorMessage = 'Error during script directory creation';
this.logger.error(errorType, errorMessage, exception);
return {
@@ -99,7 +113,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
type DirectoryPathConstructionOutcome = {
readonly success: false;
readonly error: CodeRunError;
readonly error: DirectoryCreationError;
readonly directoryPath?: undefined;
} | {
readonly success: true;
@@ -109,7 +123,7 @@ type DirectoryPathConstructionOutcome = {
type DirectoryPathCreationOutcome = {
readonly success: false;
readonly error: CodeRunError;
readonly error: DirectoryCreationError;
} | {
readonly success: true;
readonly error?: undefined;

View File

@@ -0,0 +1,20 @@
export interface FileSystemOperations {
getUserDataDirectory(): string;
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
isFileAvailable(filePath: string): Promise<boolean>;
isDirectoryAvailable(filePath: string): Promise<boolean>;
deletePath(filePath: string): Promise<void>;
listDirectoryContents(directoryPath: string): Promise<string[]>;
combinePaths(...pathSegments: string[]): string;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
writeFile: (
filePath: string,
fileContents: string,
encoding: NodeJS.BufferEncoding,
) => Promise<void>;
}

View File

@@ -0,0 +1,68 @@
import { join } from 'node:path';
import {
chmod, mkdir,
readdir, rm, stat,
readFile, writeFile,
} from 'node:fs/promises';
import { app } from 'electron/main';
import type { FileSystemOperations } from './FileSystemOperations';
import type { Stats } from 'node:original-fs';
/**
* Thin wrapper for Node and Electron APIs.
*/
export const NodeElectronFileSystemOperations: FileSystemOperations = {
combinePaths: (...pathSegments) => join(...pathSegments),
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
isFileAvailable: async (path) => isPathAvailable(path, (stats) => stats.isFile()),
isDirectoryAvailable: async (path) => isPathAvailable(path, (stats) => stats.isDirectory()),
deletePath: (path) => rm(path, { recursive: true, force: true }),
listDirectoryContents: (directoryPath) => readdir(directoryPath),
getUserDataDirectory: () => {
/*
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
return app.getPath('userData');
},
writeFile,
readFile,
};
async function isPathAvailable(
path: string,
condition: (stats: Stats) => boolean,
): Promise<boolean> {
try {
const stats = await stat(path);
return condition(stats);
} catch (error) {
if (error.code === 'ENOENT') {
return false; // path does not exist
}
throw error;
}
}

View File

@@ -1,22 +1,18 @@
import { writeFile, access, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '../Log/ElectronLogger';
import { ElectronLogger } from '../../Log/ElectronLogger';
import { NodeElectronFileSystemOperations } from '../NodeElectronFileSystemOperations';
import type {
FailedFileWrite, ReadbackFileWriter, FileWriteErrorType,
FileWriteOutcome, SuccessfulFileWrite,
} from './ReadbackFileWriter';
import type { FileSystemOperations } from '../FileSystemOperations';
const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8';
export class NodeReadbackFileWriter implements ReadbackFileWriter {
constructor(
private readonly logger: Logger = ElectronLogger,
private readonly fileSystem: FileReadWriteOperations = {
writeFile,
readFile: (path, encoding) => readFile(path, encoding),
access,
},
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
) { }
public async writeAndVerifyFile(
@@ -55,7 +51,9 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
filePath: string,
): Promise<FileWriteOutcome> {
try {
await this.fileSystem.access(filePath, constants.F_OK);
if (!(await this.fileSystem.isFileAvailable(filePath))) {
return this.reportFailure('FileExistenceVerificationFailed', 'File does not exist.');
}
return this.reportSuccess('Verified file existence without reading.');
} catch (error) {
return this.reportFailure('FileExistenceVerificationFailed', error);
@@ -107,9 +105,3 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
};
}
}
export interface FileReadWriteOperations {
readonly writeFile: typeof writeFile;
readonly access: typeof access;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
}

View File

@@ -1,19 +1,22 @@
import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { PersistentDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider';
import type { ScriptDirectoryProvider } from '../CodeRunner/Creation/Directory/ScriptDirectoryProvider';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector {
constructor(
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(),
private readonly directoryProvider: ApplicationDirectoryProvider
= new PersistentApplicationDirectoryProvider(),
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
) { }
public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> {
const { directoryAbsolutePath } = await this.directoryProvider.provideScriptDirectory();
const {
directoryAbsolutePath: scriptsDirectory,
} = await this.directoryProvider.provideDirectory('script-runs');
return {
scriptsDirectoryAbsolutePath: directoryAbsolutePath,
scriptsDirectoryAbsolutePath: scriptsDirectory,
currentOperatingSystem: this.environment.os,
};
}

View File

@@ -7,63 +7,100 @@ import type { ProgressInfo } from 'electron-builder';
export async function handleAutoUpdate() {
const autoUpdater = getAutoUpdater();
if (await askDownloadAndInstall() === DownloadDialogResult.NotNow) {
if (await askDownloadAndInstall() === UpdateDialogResult.Postpone) {
ElectronLogger.info('User chose to postpone update');
return;
}
startHandlingUpdateProgress(autoUpdater);
await autoUpdater.downloadUpdate();
}
function startHandlingUpdateProgress(autoUpdater: AppUpdater) {
const progressBar = new UpdateProgressBar();
progressBar.showIndeterminateState();
autoUpdater.on('error', (e) => {
progressBar.showError(e);
});
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
/*
On macOS, download-progress event is not called.
So the indeterminate progress will continue until download is finished.
*/
ElectronLogger.debug('@download-progress@\n', progress);
if (progressBar.isOpen) { // May be closed by the user
progressBar.showProgress(progress);
}
});
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
ElectronLogger.info('@update-downloaded@\n', info);
progressBar.closeIfOpen();
await handleUpdateDownloaded(autoUpdater);
});
}
async function handleUpdateDownloaded(autoUpdater: AppUpdater) {
if (await askRestartAndInstall() === InstallDialogResult.NotNow) {
return;
ElectronLogger.info('User chose to download and install update');
try {
await startHandlingUpdateProgress(autoUpdater);
} catch (error) {
ElectronLogger.error('Failed to handle auto-update process', { error });
}
setTimeout(() => autoUpdater.quitAndInstall(), 1);
}
enum DownloadDialogResult {
Install = 0,
NotNow = 1,
function startHandlingUpdateProgress(autoUpdater: AppUpdater): Promise<void> {
return new Promise((resolve, reject) => { // Block until update process completes
const progressBar = new UpdateProgressBar();
progressBar.showIndeterminateState();
autoUpdater.on('error', (e) => {
progressBar.showError(e);
reject(e);
});
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
/*
On macOS, download-progress event is not called.
So the indeterminate progress will continue until download is finished.
*/
ElectronLogger.debug('Update download progress', { progress });
if (progressBar.isOpen) { // May be closed by the user
progressBar.showProgress(progress);
}
});
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
ElectronLogger.info('Update downloaded successfully', { version: info.version });
progressBar.closeIfOpen();
try {
await handleUpdateDownloaded(autoUpdater);
} catch (error) {
ElectronLogger.error('Failed to handle downloaded update', { error });
reject(error);
}
resolve();
});
autoUpdater.downloadUpdate();
});
}
async function askDownloadAndInstall(): Promise<DownloadDialogResult> {
async function handleUpdateDownloaded(
autoUpdater: AppUpdater,
): Promise<void> {
return new Promise((resolve, reject) => { // Block until update download process completes
askRestartAndInstall()
.then((result) => {
if (result === InstallDialogResult.InstallAndRestart) {
ElectronLogger.info('User chose to install and restart for update');
setTimeout(() => {
try {
autoUpdater.quitAndInstall();
resolve();
} catch (error) {
ElectronLogger.error('Failed to quit and install update', { error });
reject(error);
}
}, 1);
} else {
ElectronLogger.info('User chose to postpone update installation');
resolve();
}
})
.catch((error) => {
ElectronLogger.error('Failed to prompt user for restart and install', { error });
reject(error);
});
});
}
enum UpdateDialogResult {
Update = 0,
Postpone = 1,
}
async function askDownloadAndInstall(): Promise<UpdateDialogResult> {
const updateDialogResult = await dialog.showMessageBox({
type: 'question',
buttons: ['Install', 'Not now'],
title: 'Confirm Update',
message: 'Update available.\n\nWould you like to download and install new version?',
detail: 'Application will automatically restart to apply update after download',
defaultId: DownloadDialogResult.Install,
cancelId: DownloadDialogResult.NotNow,
defaultId: UpdateDialogResult.Update,
cancelId: UpdateDialogResult.Postpone,
});
return updateDialogResult.response;
}
enum InstallDialogResult {
InstallAndRestart = 0,
NotNow = 1,
Postpone = 1,
}
async function askRestartAndInstall(): Promise<InstallDialogResult> {
const installDialogResult = await dialog.showMessageBox({
@@ -72,7 +109,7 @@ async function askRestartAndInstall(): Promise<InstallDialogResult> {
message: `A new version of ${app.name} has been downloaded.`,
detail: 'It will be installed the next time you restart the application.',
defaultId: InstallDialogResult.InstallAndRestart,
cancelId: InstallDialogResult.NotNow,
cancelId: InstallDialogResult.Postpone,
});
return installDialogResult.response;
}

View File

@@ -1,10 +1,8 @@
import { existsSync, createWriteStream, type WriteStream } from 'node:fs';
import { unlink, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { app } from 'electron/main';
import { createWriteStream, type WriteStream } from 'node:fs';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger';
import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
import { retryFileSystemAccess } from './RetryFileSystemAccess';
import { provideUpdateInstallationFilepath, type InstallationFilepathProvider } from './InstallationFiles/InstallationFilepathProvider';
import type { UpdateInfo } from 'electron-updater';
import type { ReadableStream } from 'node:stream/web';
@@ -18,18 +16,25 @@ export type DownloadUpdateResult = {
readonly installerPath: string;
};
interface UpdateDownloadUtilities {
readonly logger: Logger;
readonly provideInstallationFilePath: InstallationFilepathProvider;
}
export async function downloadUpdate(
info: UpdateInfo,
remoteFileUrl: string,
progressBar: UpdateProgressBar,
utilities: UpdateDownloadUtilities = DefaultUtilities,
): Promise<DownloadUpdateResult> {
ElectronLogger.info('Starting manual update download.');
utilities.logger.info('Starting manual update download.');
progressBar.showIndeterminateState();
try {
const { filePath } = await downloadInstallerFile(
info.version,
remoteFileUrl,
(percentage) => { progressBar.showPercentage(percentage); },
utilities,
);
return {
success: true,
@@ -47,58 +52,40 @@ async function downloadInstallerFile(
version: string,
remoteFileUrl: string,
progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
): 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}`);
}
const filePath = await utilities.provideInstallationFilePath(version);
await downloadFileWithProgress(
remoteFileUrl,
filePath,
progressHandler,
utilities,
);
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,
utilities: UpdateDownloadUtilities,
) {
// autoUpdater cannot handle DMG files, requiring manual download management for these file types.
ElectronLogger.info(`Retrieving update from ${url}.`);
utilities.logger.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);
const contentLength = getContentLengthFromResponse(response, utilities);
await withWriteStream(filePath, async (writer) => {
ElectronLogger.info(contentLength.isValid
? `Saving file to ${filePath} (Size: ${contentLength.totalLength} bytes).`
: `Saving file to ${filePath}.`);
utilities.logger.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);
await streamWithProgress(contentLength, reader, writer, progressHandler, utilities);
});
ElectronLogger.info(`Successfully saved the file: '${filePath}'`);
});
}
@@ -109,16 +96,19 @@ type ResponseContentLength = {
readonly isValid: false;
};
function getContentLengthFromResponse(response: Response): ResponseContentLength {
function getContentLengthFromResponse(
response: Response,
utilities: UpdateDownloadUtilities,
): 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);
utilities.logger.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);
utilities.logger.error('Unable to determine download size from server response.', headersInfo);
return { isValid: false };
}
return { totalLength: contentLength, isValid: true };
@@ -153,6 +143,7 @@ async function streamWithProgress(
readStream: ReadableStream,
writeStream: WriteStream,
progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
): Promise<void> {
let receivedLength = 0;
let logThreshold = 0;
@@ -163,22 +154,23 @@ async function streamWithProgress(
writeStream.write(Buffer.from(chunk));
receivedLength += chunk.length;
notifyProgress(contentLength, receivedLength, progressHandler);
const progressLog = logProgress(receivedLength, contentLength, logThreshold);
const progressLog = logProgress(receivedLength, contentLength, logThreshold, utilities);
logThreshold = progressLog.nextLogThreshold;
}
ElectronLogger.info('Update download completed successfully.');
utilities.logger.info('Update download completed successfully.');
}
function logProgress(
receivedLength: number,
contentLength: ResponseContentLength,
logThreshold: number,
utilities: UpdateDownloadUtilities,
): { readonly nextLogThreshold: number; } {
const {
shouldLog, nextLogThreshold,
} = shouldLogProgress(receivedLength, contentLength, logThreshold);
if (shouldLog) {
ElectronLogger.debug(`Download progress: ${receivedLength} bytes received.`);
utilities.logger.debug(`Download progress: ${receivedLength} bytes received.`);
}
return { nextLogThreshold };
}
@@ -220,3 +212,8 @@ function createReader(response: Response): ReadableStream {
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/65542#discussioncomment-6071004
return response.body as ReadableStream;
}
const DefaultUtilities: UpdateDownloadUtilities = {
logger: ElectronLogger,
provideInstallationFilePath: provideUpdateInstallationFilepath,
};

View File

@@ -1,15 +1,21 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
export function retryFileSystemAccess(
fileOperation: () => Promise<boolean>,
): Promise<boolean> {
export interface FileSystemAccessorWithRetry {
(
fileOperation: () => Promise<boolean>,
): Promise<boolean>;
}
export const retryFileSystemAccess: FileSystemAccessorWithRetry = (
fileOperation,
) => {
return retryWithExponentialBackoff(
fileOperation,
TOTAL_RETRIES,
INITIAL_DELAY_MS,
);
}
};
// These values provide a balanced approach for handling transient file system
// issues without excessive waiting.

View File

@@ -0,0 +1,107 @@
import type { Logger } from '@/application/Common/Log/Logger';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
export interface InstallationFileCleaner {
(
utilities?: UpdateFileUtilities,
): Promise<void>;
}
interface UpdateFileUtilities {
readonly logger: Logger;
readonly directoryProvider: ApplicationDirectoryProvider;
readonly fileSystem: FileSystemOperations;
}
export const clearUpdateInstallationFiles: InstallationFileCleaner = async (
utilities = DefaultUtilities,
) => {
utilities.logger.info('Starting update installation files cleanup');
const { success, error, directoryAbsolutePath } = await utilities.directoryProvider.provideDirectory('update-installation-files');
if (!success) {
utilities.logger.error('Failed to locate installation files directory', { error });
throw new Error('Cannot locate the installation files directory path');
}
const installationFileNames = await readDirectoryContents(directoryAbsolutePath, utilities);
if (installationFileNames.length === 0) {
utilities.logger.info('No update installation files found');
return;
}
utilities.logger.debug(`Found ${installationFileNames.length} installation files to delete`);
utilities.logger.info('Deleting installation files');
const errors = await executeIndependentTasksAndCollectErrors(
installationFileNames.map(async (fileOrFolderName) => {
await deleteItemFromDirectory(directoryAbsolutePath, fileOrFolderName, utilities);
}),
);
if (errors.length > 0) {
utilities.logger.error('Failed to delete some installation files', { errors });
throw new Error(`Failed to delete some items:\n${errors.join('\n')}`);
}
utilities.logger.info('Update installation files cleanup completed successfully');
};
async function deleteItemFromDirectory(
directoryPath: string,
fileOrFolderName: string,
utilities: UpdateFileUtilities,
): Promise<void> {
const itemPath = utilities.fileSystem.combinePaths(
directoryPath,
fileOrFolderName,
);
try {
utilities.logger.debug(`Deleting installation artifact: ${itemPath}`);
await utilities.fileSystem.deletePath(itemPath);
utilities.logger.debug(`Successfully deleted installation artifact: ${itemPath}`);
} catch (error) {
utilities.logger.error(`Failed to delete installation artifact: ${itemPath}`, { error });
throw error;
}
}
async function readDirectoryContents(
directoryPath: string,
utilities: UpdateFileUtilities,
): Promise<string[]> {
try {
utilities.logger.debug(`Reading directory contents: ${directoryPath}`);
const items = await utilities.fileSystem.listDirectoryContents(directoryPath);
utilities.logger.debug(`Read ${items.length} items from directory: ${directoryPath}`);
return items;
} catch (error) {
utilities.logger.error('Failed to read directory contents', { directoryPath, error });
throw new Error('Failed to read directory contents', { cause: error });
}
}
async function executeIndependentTasksAndCollectErrors(
tasks: (Promise<void>)[],
): Promise<string[]> {
const results = await Promise.allSettled(tasks);
const errors = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason);
return errors.map((error) => {
if (!error) {
return 'unknown error';
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return String(error);
});
}
const DefaultUtilities: UpdateFileUtilities = {
logger: ElectronLogger,
directoryProvider: new PersistentApplicationDirectoryProvider(),
fileSystem: NodeElectronFileSystemOperations,
};

View File

@@ -0,0 +1,73 @@
import type { Logger } from '@/application/Common/Log/Logger';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess, type FileSystemAccessorWithRetry } from '../FileSystemAccessorWithRetry';
export interface InstallationFilepathProvider {
(
version: string,
utilities?: InstallationFilepathProviderUtilities,
): Promise<string>;
}
interface InstallationFilepathProviderUtilities {
readonly logger: Logger;
readonly directoryProvider: ApplicationDirectoryProvider;
readonly fileSystem: FileSystemOperations;
readonly accessFileSystemWithRetry: FileSystemAccessorWithRetry;
}
export const InstallerFileSuffix = '-installer.dmg';
export const provideUpdateInstallationFilepath: InstallationFilepathProvider = async (
version,
utilities = DefaultUtilities,
) => {
const {
success, error, directoryAbsolutePath,
} = await utilities.directoryProvider.provideDirectory('update-installation-files');
if (!success) {
utilities.logger.error('Error when providing download directory', error);
throw new Error('Failed to provide download directory.');
}
const filepath = utilities.fileSystem.combinePaths(directoryAbsolutePath, `${version}${InstallerFileSuffix}`);
if (!await makeFilepathAvailable(filepath, utilities)) {
throw new Error(`Failed to prepare the file path for the installer: ${filepath}`);
}
return filepath;
};
async function makeFilepathAvailable(
filePath: string,
utilities: InstallationFilepathProviderUtilities,
): Promise<boolean> {
let isFileAvailable = false;
try {
isFileAvailable = await utilities.fileSystem.isFileAvailable(filePath);
} catch (error) {
throw new Error('File availability check failed');
}
if (!isFileAvailable) {
return true;
}
return utilities.accessFileSystemWithRetry(async () => {
try {
utilities.logger.info(`Existing update file found and will be replaced: ${filePath}`);
await utilities.fileSystem.deletePath(filePath);
return true;
} catch (error) {
utilities.logger.error(`Failed to prepare file path for update: ${filePath}`, error);
return false;
}
});
}
const DefaultUtilities: InstallationFilepathProviderUtilities = {
logger: ElectronLogger,
directoryProvider: new PersistentApplicationDirectoryProvider(),
fileSystem: NodeElectronFileSystemOperations,
accessFileSystemWithRetry: retryFileSystemAccess,
};

View File

@@ -1,7 +1,7 @@
import { app } from 'electron/main';
import { shell } from 'electron/common';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess';
import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
export async function startInstallation(filePath: string): Promise<boolean> {
return retryFileSystemAccess(async () => {

View File

@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess';
import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
export async function checkIntegrity(
filePath: string,

View File

@@ -14,29 +14,43 @@ import {
import { type DownloadUpdateResult, downloadUpdate } from './Downloader';
import { checkIntegrity } from './Integrity';
import { startInstallation } from './Installer';
import { clearUpdateInstallationFiles } from './InstallationFiles/InstallationFileCleaner';
import type { UpdateInfo } from 'electron-updater';
export function requiresManualUpdate(): boolean {
return process.platform === 'darwin';
export function requiresManualUpdate(
nodePlatform: string = process.platform,
): boolean {
// autoUpdater cannot handle DMG files, requiring manual download management for these file types.
return nodePlatform === 'darwin';
}
export async function startManualUpdateProcess(info: UpdateInfo) {
try {
await clearUpdateInstallationFiles();
} catch (error) {
ElectronLogger.warn('Failed to clear previous update installation files', { error });
} finally {
await executeManualUpdateProcess(info);
}
}
async function executeManualUpdateProcess(info: UpdateInfo): Promise<void> {
try {
const updateAction = await promptForManualUpdate();
if (updateAction === ManualUpdateChoice.NoAction) {
ElectronLogger.info('User cancelled the update.');
ElectronLogger.info('User chose to cancel the update');
return;
}
const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version);
if (updateAction === ManualUpdateChoice.VisitReleasesPage) {
ElectronLogger.info(`Navigating to release page: ${releaseUrl}`);
ElectronLogger.info('User chose to visit release page', { url: releaseUrl });
await shell.openExternal(releaseUrl);
} else if (updateAction === ManualUpdateChoice.UpdateNow) {
ElectronLogger.info('Initiating update download and installation.');
ElectronLogger.info('User chose to download and install update');
await downloadAndInstallUpdate(downloadUrl, info);
}
} catch (err) {
ElectronLogger.error('Unexpected error during updates', err);
ElectronLogger.error('Failed to execute auto-update process', { error: err });
await handleUnexpectedError(info);
}
}
@@ -56,9 +70,10 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
}
const userAction = await promptIntegrityCheckFailure();
if (userAction === IntegrityCheckChoice.RetryDownload) {
ElectronLogger.info('User chose to retry download after integrity check failure');
await startManualUpdateProcess(info);
} else if (userAction === IntegrityCheckChoice.ContinueAnyway) {
ElectronLogger.warn('Proceeding to install with failed integrity check.');
ElectronLogger.warn('User chose to proceed with installation despite failed integrity check');
await openInstaller(download.installerPath, info);
}
}
@@ -66,9 +81,9 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
async function handleFailedDownload(info: UpdateInfo) {
const userAction = await promptDownloadError();
if (userAction === DownloadErrorChoice.Cancel) {
ElectronLogger.info('Update download canceled.');
ElectronLogger.info('User chose to cancel update download');
} else if (userAction === DownloadErrorChoice.RetryDownload) {
ElectronLogger.info('Retrying update download.');
ElectronLogger.info('User chose to retry update download');
await startManualUpdateProcess(info);
}
}
@@ -76,9 +91,9 @@ async function handleFailedDownload(info: UpdateInfo) {
async function handleUnexpectedError(info: UpdateInfo) {
const userAction = await showUnexpectedError();
if (userAction === UnexpectedErrorChoice.Cancel) {
ElectronLogger.info('Unexpected error handling canceled.');
ElectronLogger.info('User chose to cancel update process after unexpected error');
} else if (userAction === UnexpectedErrorChoice.RetryUpdate) {
ElectronLogger.info('Retrying the update process.');
ElectronLogger.info('User chose to retry update process after unexpected error');
await startManualUpdateProcess(info);
}
}
@@ -89,8 +104,10 @@ async function openInstaller(installerPath: string, info: UpdateInfo) {
}
const userAction = await promptInstallerOpenError();
if (userAction === InstallerErrorChoice.RetryDownload) {
ElectronLogger.info('User chose to retry download after installer open error');
await startManualUpdateProcess(info);
} else if (userAction === InstallerErrorChoice.RetryOpen) {
ElectronLogger.info('User chose to retry opening installer');
await openInstaller(installerPath, info);
}
}
@@ -119,16 +136,16 @@ async function isIntegrityPreserved(
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);
ElectronLogger.error('Failed to find remote hash for download URL', { url: fileUrl, files: info.files });
if (info.files.length > 0) {
const firstHash = info.files[0].sha512;
ElectronLogger.info(`Selecting the first available hash: ${firstHash}`);
ElectronLogger.info('Using first available hash due to missing match', { hash: firstHash });
return firstHash;
}
return undefined;
}
if (fileInfos.length > 1) {
ElectronLogger.error(`Found multiple file entries for the URL: ${fileUrl}`, fileInfos);
ElectronLogger.warn('Multiple file entries found for download URL', { url: fileUrl, entries: fileInfos });
}
return fileInfos[0].sha512;
}

View File

@@ -97,7 +97,7 @@ if (isDevelopment) {
}
}
function loadApplication(window: BrowserWindow) {
function loadApplication(window: BrowserWindow): void {
if (RENDERER_URL) { // Populated in a dev server during development
loadUrlWithNodeWorkaround(window, RENDERER_URL);
} else {