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:
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
) { }
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
20
src/infrastructure/FileSystem/FileSystemOperations.ts
Normal file
20
src/infrastructure/FileSystem/FileSystemOperations.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user