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

@@ -12,7 +12,19 @@ function collectException(
error = err;
}
if (!error) {
throw new Error('action did not throw');
throw new Error('Action did not throw');
}
return error;
}
export async function collectExceptionAsync(
action: () => Promise<unknown>,
): Promise<Error | undefined> {
let error: Error | undefined;
try {
await action();
} catch (err) {
error = err;
}
return error;
}

View File

@@ -0,0 +1,41 @@
import type {
DirectoryCreationOutcome,
ApplicationDirectoryProvider,
DirectoryType,
DirectoryCreationError,
} from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
export class ApplicationDirectoryProviderStub implements ApplicationDirectoryProvider {
private directoryPaths: Record<DirectoryType, string> = {
'update-installation-files': `[${ApplicationDirectoryProviderStub.name}]update installation files directory`,
'script-runs': `[${ApplicationDirectoryProviderStub.name}]scripts directory`,
};
private failure: DirectoryCreationError | undefined = undefined;
public withDirectoryPath(type: DirectoryType, directoryPath: string): this {
this.directoryPaths[type] = directoryPath;
return this;
}
public provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome> {
if (this.failure) {
return Promise.resolve({
success: false,
error: this.failure,
});
}
return Promise.resolve({
success: true,
directoryAbsolutePath: this.directoryPaths[type],
});
}
public withFailure(error?: DirectoryCreationError): this {
this.failure = error ?? {
type: 'DirectoryWriteError',
message: `[${ApplicationDirectoryProviderStub.name}]injected failure`,
};
return this;
}
}

View File

@@ -0,0 +1,21 @@
import type { FileSystemAccessorWithRetry } from '@/presentation/electron/main/Update/ManualUpdater/FileSystemAccessorWithRetry';
export class FileSystemAccessorWithRetryStub {
private retryAmount = 0;
public withAlwaysRetry(retryAmount: number): this {
this.retryAmount = retryAmount;
return this;
}
public get(): FileSystemAccessorWithRetry {
return async (fileOperation) => {
const result = await fileOperation();
for (let i = 0; i < this.retryAmount; i++) {
// eslint-disable-next-line no-await-in-loop
await fileOperation();
}
return result;
};
}
}

View File

@@ -0,0 +1,161 @@
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class FileSystemOperationsStub
extends StubWithObservableMethodCalls<FileSystemOperations>
implements FileSystemOperations {
private readonly writtenFiles: Map<string, string> = new Map();
private readonly fileAvailability: Map<string, boolean> = new Map();
private directoryContents: Map<string, string[]> = new Map();
private userDataDirectory = `/${FileSystemOperationsStub.name}-user-data-dir/`;
private combinePathSequence = new Array<string>();
private combinePathScenarios = new Map<string, string>();
private combinePathDefaultSeparator = `/[${FileSystemOperationsStub.name}]PATH-SEGMENT-SEPARATOR/`;
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
this.registerMethodCall({
methodName: 'setFilePermissions',
args: [filePath, mode],
});
return Promise.resolve();
}
public writeFile = (filePath: string, fileContents: string, encoding: NodeJS.BufferEncoding) => {
this.registerMethodCall({
methodName: 'writeFile',
args: [filePath, fileContents, encoding],
});
this.writtenFiles.set(filePath, fileContents);
return Promise.resolve();
};
public readFile = (filePath: string, encoding: NodeJS.BufferEncoding) => {
this.registerMethodCall({
methodName: 'readFile',
args: [filePath, encoding],
});
const fileContents = this.writtenFiles.get(filePath);
return Promise.resolve(fileContents ?? `[${FileSystemOperationsStub.name}] file-contents`);
};
public createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void> {
this.registerMethodCall({
methodName: 'createDirectory',
args: [directoryPath, isRecursive],
});
return Promise.resolve();
}
public isFileAvailable(filePath: string): Promise<boolean> {
this.registerMethodCall({
methodName: 'isFileAvailable',
args: [filePath],
});
const availability = this.fileAvailability.get(filePath);
if (availability !== undefined) {
return Promise.resolve(availability);
}
const fileContents = this.writtenFiles.get(filePath);
if (fileContents !== undefined) {
return Promise.resolve(true);
}
return Promise.resolve(true);
}
public isDirectoryAvailable(directoryPath: string): Promise<boolean> {
this.registerMethodCall({
methodName: 'isDirectoryAvailable',
args: [directoryPath],
});
return Promise.resolve(true);
}
public deletePath(filePath: string): Promise<void> {
this.registerMethodCall({
methodName: 'deletePath',
args: [filePath],
});
return Promise.resolve();
}
public withUserDirectoryResult(directory: string): this {
this.userDataDirectory = directory;
return this;
}
public getUserDataDirectory(): string {
this.registerMethodCall({
methodName: 'getUserDataDirectory',
args: [],
});
return this.userDataDirectory;
}
public listDirectoryContents(directoryPath: string): Promise<string[]> {
this.registerMethodCall({
methodName: 'listDirectoryContents',
args: [directoryPath],
});
const contents = this.directoryContents.get(directoryPath);
return Promise.resolve(contents ?? []);
}
public withDirectoryContents(
directoryPath: string,
fileOrFolderNames: readonly string[],
): this {
this.directoryContents.set(directoryPath, [...fileOrFolderNames]);
return this;
}
public withFileAvailability(
filePath: string,
isAvailable: boolean,
): this {
this.fileAvailability.set(filePath, isAvailable);
return this;
}
public withJoinResult(returnValue: string, ...paths: string[]): this {
this.combinePathScenarios.set(getCombinePathsScenarioKey(paths), returnValue);
return this;
}
public withJoinResultSequence(...valuesToReturn: string[]): this {
this.combinePathSequence.push(...valuesToReturn);
this.combinePathSequence.reverse();
return this;
}
public withDefaultSeparator(defaultSeparator: string): this {
this.combinePathDefaultSeparator = defaultSeparator;
return this;
}
public combinePaths(...pathSegments: string[]): string {
this.registerMethodCall({
methodName: 'combinePaths',
args: pathSegments,
});
const nextInSequence = this.combinePathSequence.pop();
if (nextInSequence) {
return nextInSequence;
}
const key = getCombinePathsScenarioKey(pathSegments);
const foundScenario = this.combinePathScenarios.get(key);
if (foundScenario) {
return foundScenario;
}
return pathSegments.join(this.combinePathDefaultSeparator);
}
}
function getCombinePathsScenarioKey(paths: string[]): string {
return paths.join('|');
}

View File

@@ -1,22 +0,0 @@
import type { FileSystemOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class FileSystemOpsStub
extends StubWithObservableMethodCalls<FileSystemOps>
implements FileSystemOps {
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
this.registerMethodCall({
methodName: 'setFilePermissions',
args: [filePath, mode],
});
return Promise.resolve();
}
public createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void> {
this.registerMethodCall({
methodName: 'createDirectory',
args: [directoryPath, isRecursive],
});
return Promise.resolve();
}
}

View File

@@ -1,49 +0,0 @@
import type { LocationOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class LocationOpsStub
extends StubWithObservableMethodCalls<LocationOps>
implements LocationOps {
private sequence = new Array<string>();
private scenarios = new Map<string, string>();
private defaultSeparator = `/[${LocationOpsStub.name}]PATH-SEGMENT-SEPARATOR/`;
public withJoinResult(returnValue: string, ...paths: string[]): this {
this.scenarios.set(LocationOpsStub.getScenarioKey(paths), returnValue);
return this;
}
public withJoinResultSequence(...valuesToReturn: string[]): this {
this.sequence.push(...valuesToReturn);
this.sequence.reverse();
return this;
}
public withDefaultSeparator(defaultSeparator: string): this {
this.defaultSeparator = defaultSeparator;
return this;
}
public combinePaths(...pathSegments: string[]): string {
this.registerMethodCall({
methodName: 'combinePaths',
args: pathSegments,
});
const nextInSequence = this.sequence.pop();
if (nextInSequence) {
return nextInSequence;
}
const key = LocationOpsStub.getScenarioKey(pathSegments);
const foundScenario = this.scenarios.get(key);
if (foundScenario) {
return foundScenario;
}
return pathSegments.join(this.defaultSeparator);
}
private static getScenarioKey(paths: string[]): string {
return paths.join('|');
}
}

View File

@@ -1,21 +0,0 @@
import type { OperatingSystemOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class OperatingSystemOpsStub
extends StubWithObservableMethodCalls<OperatingSystemOps>
implements OperatingSystemOps {
private userDataDirectory = `/${OperatingSystemOpsStub.name}-user-data-dir/`;
public withUserDirectoryResult(directory: string): this {
this.userDataDirectory = directory;
return this;
}
public getUserDataDirectory(): string {
this.registerMethodCall({
methodName: 'getUserDataDirectory',
args: [],
});
return this.userDataDirectory;
}
}

View File

@@ -1,4 +1,4 @@
import type { FileWriteErrorType, FileWriteOutcome, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import type { FileWriteErrorType, FileWriteOutcome, ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class ReadbackFileWriterStub

View File

@@ -1,17 +0,0 @@
import type { ScriptDirectoryOutcome, ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
export class ScriptDirectoryProviderStub implements ScriptDirectoryProvider {
private directoryPath = `[${ScriptDirectoryProviderStub.name}]scriptDirectory`;
public withDirectoryPath(directoryPath: string): this {
this.directoryPath = directoryPath;
return this;
}
public provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
return Promise.resolve({
success: true,
directoryAbsolutePath: this.directoryPath,
});
}
}

View File

@@ -1,35 +1,17 @@
import type {
CommandOps,
FileSystemOps,
OperatingSystemOps,
LocationOps,
SystemOperations,
} from '@/infrastructure/CodeRunner/System/SystemOperations';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import { CommandOpsStub } from './CommandOpsStub';
import { FileSystemOpsStub } from './FileSystemOpsStub';
import { LocationOpsStub } from './LocationOpsStub';
import { OperatingSystemOpsStub } from './OperatingSystemOpsStub';
import { FileSystemOperationsStub } from './FileSystemOperationsStub';
export class SystemOperationsStub implements SystemOperations {
public operatingSystem: OperatingSystemOps = new OperatingSystemOpsStub();
public location: LocationOps = new LocationOpsStub();
public fileSystem: FileSystemOps = new FileSystemOpsStub();
public fileSystem: FileSystemOperations = new FileSystemOperationsStub();
public command: CommandOps = new CommandOpsStub();
public withOperatingSystem(operatingSystemOps: OperatingSystemOps): this {
this.operatingSystem = operatingSystemOps;
return this;
}
public withLocation(location: LocationOps): this {
this.location = location;
return this;
}
public withFileSystem(fileSystem: FileSystemOps): this {
public withFileSystem(fileSystem: FileSystemOperations): this {
this.fileSystem = fileSystem;
return this;
}