Fix script cancellation with new dialog on Linux

This commit improves the management of script execution process by
enhancing the way terminal commands are handled, paving the way for
easier future modifications and providing clearer feedback to users when
scripts are cancelled.

Previously, the UI displayed a generic error message which could lead to
confusion if the user intentionally cancelled the script execution. Now,
a specific error dialog will appear, improving the user experience by
accurately reflecting the action taken by the user.

This change affects code execution on Linux where closing GNOME terminal
returns exit code `137` which is then treated by script cancellation by
privacy.sexy to show the accurate error dialog. It does not affect macOS
and Windows as curret commands result in success (`0`) exit code on
cancellation.

Additionally, this update encapsulates OS-specific logic into dedicated
classes, promoting better separation of concerns and increasing the
modularity of the codebase. This makes it simpler to maintain and extend
the application.

Key changes:

- Display a specific error message for script cancellations.
- Refactor command execution into dedicated classes.
- Improve file permission setting flexibility and avoid setting file
  permissions on Windows as it's not required to execute files.
- Introduce more granular error types for script execution.
- Increase logging for shell commands to aid in debugging.
- Expand test coverage to ensure reliability.
- Fix error dialogs not showing the error messages due to incorrect
  propagation of errors.

Other supported changes:

- Update `SECURITY.md` with details on script readback and verification.
- Fix a typo in `IpcRegistration.spec.ts`.
- Document antivirus scans in `desktop-vs-web-features.md`.
This commit is contained in:
undergroundwires
2024-04-30 15:04:59 +02:00
parent 694bf1a74d
commit 8c17396285
49 changed files with 2097 additions and 606 deletions

View File

@@ -0,0 +1,43 @@
import { type ChildProcess } from 'node:child_process';
export class ChildProcessStub implements Partial<ChildProcess> {
private readonly eventListeners: Record<string, ((...args: unknown[]) => void)[]> = {};
private autoEmitExit = true;
public on(event: string, listener: (...args: never[]) => void): ChildProcess {
if (!this.eventListeners[event]) {
this.eventListeners[event] = [];
}
this.eventListeners[event].push(listener);
if (event === 'exit' && this.autoEmitExit) {
this.emitExit(0, null);
}
return this.asChildProcess();
}
public emitExit(code: number | null, signal: NodeJS.Signals | null) {
this.emitEvent('exit', code, signal);
}
public emitError(error: Error): void {
this.emitEvent('error', error);
}
public withAutoEmitExit(autoEmitExit: boolean): this {
this.autoEmitExit = autoEmitExit;
return this;
}
public asChildProcess(): ChildProcess {
return this as unknown as ChildProcess;
}
private emitEvent(event: string, ...args: unknown[]): void {
if (this.eventListeners[event]) {
this.eventListeners[event].forEach((listener) => {
listener(...args);
});
}
}
}

View File

@@ -0,0 +1,16 @@
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
import type { CommandDefinitionFactory } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Factory/CommandDefinitionFactory';
import { CommandDefinitionStub } from './CommandDefinitionStub';
export class CommandDefinitionFactoryStub implements CommandDefinitionFactory {
private definition: CommandDefinition = new CommandDefinitionStub();
public withDefinition(definition: CommandDefinition): this {
this.definition = definition;
return this;
}
public provideCommandDefinition(): CommandDefinition {
return this.definition;
}
}

View File

@@ -0,0 +1,28 @@
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
import type { CommandDefinitionRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/CommandDefinitionRunner';
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class CommandDefinitionRunnerStub
extends StubWithObservableMethodCalls<CommandDefinitionRunner>
implements CommandDefinitionRunner {
private outcome: ScriptFileExecutionOutcome = {
success: true,
};
public withOutcome(outcome: ScriptFileExecutionOutcome): this {
this.outcome = outcome;
return this;
}
public runCommandDefinition(
commandDefinition: CommandDefinition,
filePath: string,
): Promise<ScriptFileExecutionOutcome> {
this.registerMethodCall({
methodName: 'runCommandDefinition',
args: [commandDefinition, filePath],
});
return Promise.resolve(this.outcome);
}
}

View File

@@ -0,0 +1,33 @@
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
export class CommandDefinitionStub implements CommandDefinition {
private requireExecutablePermissions = false;
private exitCodeToTerminationStatus: Map<number, boolean> = new Map<number, boolean>();
public withExecutablePermissionsRequirement(requireExecutablePermissions: boolean): this {
this.requireExecutablePermissions = requireExecutablePermissions;
return this;
}
public withExternalTerminationStatusForExitCode(exitCode: number, state: boolean): this {
this.exitCodeToTerminationStatus.set(exitCode, state);
return this;
}
public buildShellCommand(filePath: string): string {
return `[${CommandDefinitionStub.name}] ${filePath}`;
}
public isExecutionTerminatedExternally(exitCode: number): boolean {
const status = this.exitCodeToTerminationStatus.get(exitCode);
if (status === undefined) {
return false;
}
return status;
}
public isExecutablePermissionsRequiredOnFile(): boolean {
return this.requireExecutablePermissions;
}
}

View File

@@ -1,14 +1,25 @@
import { type ChildProcess } from 'node:child_process';
import type { CommandOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
import { ChildProcessStub } from './ChildProcesssStub';
export class CommandOpsStub
extends StubWithObservableMethodCalls<CommandOps>
implements CommandOps {
public exec(command: string): Promise<void> {
private childProcess: ChildProcess = new ChildProcessStub()
.withAutoEmitExit(true)
.asChildProcess();
public withChildProcess(childProcess: ChildProcess): this {
this.childProcess = childProcess;
return this;
}
public exec(command: string): ChildProcess {
this.registerMethodCall({
methodName: 'exec',
args: [command],
});
return Promise.resolve();
return this.childProcess;
}
}

View File

@@ -0,0 +1,24 @@
import type { ExecutablePermissionSetter } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/PermissionSetter/ExecutablePermissionSetter';
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class ExecutablePermissionSetterStub
extends StubWithObservableMethodCalls<ExecutablePermissionSetter>
implements ExecutablePermissionSetter {
private outcome: ScriptFileExecutionOutcome = {
success: true,
};
public withOutcome(outcome: ScriptFileExecutionOutcome): this {
this.outcome = outcome;
return this;
}
public makeFileExecutable(filePath: string): Promise<ScriptFileExecutionOutcome> {
this.registerMethodCall({
methodName: 'makeFileExecutable',
args: [filePath],
});
return Promise.resolve(this.outcome);
}
}

View File

@@ -0,0 +1,14 @@
import type { ShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/ShellArgumentEscaper';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class ShellArgumentEscaperStub
extends StubWithObservableMethodCalls<ShellArgumentEscaper>
implements ShellArgumentEscaper {
public escapePathArgument(pathArgument: string): string {
this.registerMethodCall({
methodName: 'escapePathArgument',
args: [pathArgument],
});
return `[${ShellArgumentEscaperStub.name}] ${pathArgument}`;
}
}

View File

@@ -0,0 +1,24 @@
import type { ShellCommandOutcome, ShellCommandRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/ShellRunner/ShellCommandRunner';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class ShellCommandRunnerStub
extends StubWithObservableMethodCalls<ShellCommandRunner>
implements ShellCommandRunner {
private outcome: ShellCommandOutcome = {
type: 'RegularProcessExit',
exitCode: 0,
};
public withOutcome(outcome: ShellCommandOutcome): this {
this.outcome = outcome;
return this;
}
public runShellCommand(command: string): Promise<ShellCommandOutcome> {
this.registerMethodCall({
methodName: 'runShellCommand',
args: [command],
});
return Promise.resolve(this.outcome);
}
}