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

@@ -11,10 +11,11 @@ export type CodeRunErrorType =
| 'FileWriteError'
| 'FileReadbackVerificationError'
| 'FilePathGenerationError'
| 'UnsupportedOperatingSystem'
| 'FileExecutionError'
| 'UnsupportedPlatform'
| 'DirectoryCreationError'
| 'UnexpectedError';
| 'FilePermissionChangeError'
| 'FileExecutionError'
| 'ExternalProcessTermination';
interface CodeRunStatus {
readonly success: boolean;

View File

@@ -0,0 +1,5 @@
export interface CommandDefinition {
buildShellCommand(filePath: string): string;
isExecutionTerminatedExternally(exitCode: number): boolean;
isExecutablePermissionsRequiredOnFile(): boolean;
}

View File

@@ -0,0 +1,61 @@
import { PosixShellArgumentEscaper } from './ShellArgument/PosixShellArgumentEscaper';
import type { CommandDefinition } from '../CommandDefinition';
import type { ShellArgumentEscaper } from './ShellArgument/ShellArgumentEscaper';
export const LinuxTerminalEmulator = 'x-terminal-emulator';
export class LinuxVisibleTerminalCommand implements CommandDefinition {
constructor(
private readonly escaper: ShellArgumentEscaper = new PosixShellArgumentEscaper(),
) { }
public buildShellCommand(filePath: string): string {
return `${LinuxTerminalEmulator} -e ${this.escaper.escapePathArgument(filePath)}`;
/*
🤔 Potential improvements:
Use user-friendly GUI sudo prompt (not terminal-based).
If `pkexec` exists, we could do `x-terminal-emulator -e pkexec 'path'`, which always
prompts with user-friendly GUI sudo prompt.
📝 Options:
`x-terminal-emulator -e 'path'`:
✅ Visible terminal window
❌ Terminal-based (not GUI) sudo prompt.
`x-terminal-emulator -e pkexec 'path'
✅ Visible terminal window
✅ Always prompts with user-friendly GUI sudo prompt.
🤔 Not using `pkexec` as it is not in all Linux distributions. It should have smarter
logic to handle if it does not exist.
`electron.shell.openPath`:
❌ Opens the script in the default text editor, verified on
Debian/Ubuntu-based distributions.
`child_process.execFile()`:
❌ Script execution in the background without a visible terminal.
*/
}
public isExecutionTerminatedExternally(exitCode: number): boolean {
return exitCode === 137;
/*
`x-terminal-emulator` may return exit code `137` under specific circumstances like when the
user closes the terminal (observed with `gnome-terminal` on Pop!_OS). This exit code (128 +
Unix signal 9) indicates the process was terminated by a SIGKILL signal, which can occur due
to user action (cancelling the progress) or the system (e.g., due to memory shortages).
Additional exit codes noted for future consideration (currently not handled as they have not
been reproduced):
- 130 (130 = 128 + Unix signal 2): Indicates the script was terminated by the user
(Control-C), corresponding to a SIGINT signal.
- 143 (128 + Unix signal 15): Indicates termination by a SIGTERM signal, suggesting a request
to gracefully terminate the process.
*/
}
public isExecutablePermissionsRequiredOnFile(): boolean {
/*
On Linux, a script file without executable permissions cannot be run directly by its path
without specifying a shell explicitly.
*/
return true;
}
}

View File

@@ -0,0 +1,46 @@
import { PosixShellArgumentEscaper } from './ShellArgument/PosixShellArgumentEscaper';
import type { CommandDefinition } from '../CommandDefinition';
import type { ShellArgumentEscaper } from './ShellArgument/ShellArgumentEscaper';
export class MacOsVisibleTerminalCommand implements CommandDefinition {
constructor(
private readonly escaper: ShellArgumentEscaper = new PosixShellArgumentEscaper(),
) { }
public buildShellCommand(filePath: string): string {
return `open -a Terminal.app ${this.escaper.escapePathArgument(filePath)}`;
/*
📝 Options:
`child_process.execFile()`
"path", `cmd.exe /c "path"`
❌ Script execution in the background without a visible terminal.
This occurs only when the user runs the application as administrator, as seen
in Windows Pro VMs on Azure.
`PowerShell Start -Verb RunAs "path"`
✅ Visible terminal window
✅ GUI sudo prompt (through `RunAs` option)
`PowerShell Start "path"`
`explorer.exe "path"`
`electron.shell.openPath`
`start cmd.exe /c "$path"`
✅ Visible terminal window
✅ GUI sudo prompt (through `RunAs` option)
👍 Among all options `start` command is the most explicit one, being the most resilient
against the potential changes in Windows or Electron framework (e.g. https://github.com/electron/electron/issues/36765).
`%COMSPEC%` environment variable should be checked before defaulting to `cmd.exe.
Related docs: https://web.archive.org/web/20240106002357/https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
*/
}
public isExecutionTerminatedExternally(): boolean {
return false;
}
public isExecutablePermissionsRequiredOnFile(): boolean {
/*
On macOS, a script file without executable permissions cannot be run directly by its path
without specifying a shell explicitly.
*/
return true;
}
}

View File

@@ -0,0 +1,14 @@
import type { ShellArgumentEscaper } from './ShellArgumentEscaper';
export class CmdShellArgumentEscaper implements ShellArgumentEscaper {
public escapePathArgument(pathArgument: string): string {
return cmdShellPathArgumentEscape(pathArgument);
}
}
function cmdShellPathArgumentEscape(pathArgument: string): string {
// - Encloses the path in double quotes, which is necessary for Windows command line (cmd.exe)
// to correctly handle paths containing spaces.
// - Paths in Windows cannot include double quotes `"` themselves, so these are not escaped.
return `"${pathArgument}"`;
}

View File

@@ -0,0 +1,18 @@
import type { ShellArgumentEscaper } from './ShellArgumentEscaper';
export class PosixShellArgumentEscaper implements ShellArgumentEscaper {
public escapePathArgument(pathArgument: string): string {
return posixShellPathArgumentEscape(pathArgument);
}
}
function posixShellPathArgumentEscape(pathArgument: string): string {
/*
- Wraps the path in single quotes, which is a standard practice in POSIX shells
(like bash and zsh) found on macOS/Linux to ensure that characters like spaces, '*', and
'?' are treated as literals, not as special characters.
- Escapes any single quotes within the path itself. This allows paths containing single
quotes to be correctly interpreted in POSIX-compliant systems, such as Linux and macOS.
*/
return `'${pathArgument.replaceAll('\'', '\'\\\'\'')}'`;
}

View File

@@ -0,0 +1,3 @@
export interface ShellArgumentEscaper {
escapePathArgument(pathArgument: string): string;
}

View File

@@ -0,0 +1,52 @@
import { CmdShellArgumentEscaper } from './ShellArgument/CmdShellArgumentEscaper';
import type { ShellArgumentEscaper } from './ShellArgument/ShellArgumentEscaper';
import type { CommandDefinition } from '../CommandDefinition';
export class WindowsVisibleTerminalCommand implements CommandDefinition {
constructor(
private readonly escaper: ShellArgumentEscaper = new CmdShellArgumentEscaper(),
) { }
public buildShellCommand(filePath: string): string {
const command = [
'PowerShell',
'Start-Process',
'-Verb RunAs', // Run as administrator with GUI sudo prompt
`-FilePath ${this.escaper.escapePathArgument(filePath)}`,
].join(' ');
return command;
/*
📝 Options:
`child_process.execFile()`
"path", `cmd.exe /c "path"`
❌ Script execution in the background without a visible terminal.
This occurs only when the user runs the application as administrator, as seen
in Windows Pro VMs on Azure.
`PowerShell Start -Verb RunAs "path"`
✅ Visible terminal window
✅ GUI sudo prompt (through `RunAs` option)
`PowerShell Start "path"`
`explorer.exe "path"`
`electron.shell.openPath`
`start cmd.exe /c "$path"`
✅ Visible terminal window
✅ GUI sudo prompt (through `RunAs` option)
👍 Among all options `start` command is the most explicit one, being the most resilient
against the potential changes in Windows or Electron framework (e.g. https://github.com/electron/electron/issues/36765).
`%COMSPEC%` environment variable should be checked before defaulting to `cmd.exe.
Related docs: https://web.archive.org/web/20240106002357/https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
*/
}
public isExecutionTerminatedExternally(): boolean {
return false;
}
public isExecutablePermissionsRequiredOnFile(): boolean {
/*
In Windows, whether a file can be executed is determined by its file extension
(.exe, .bat, .cmd, etc.) rather than executable permissions set on the file.
*/
return false;
}
}

View File

@@ -0,0 +1,5 @@
import type { CommandDefinition } from '../CommandDefinition';
export interface CommandDefinitionFactory {
provideCommandDefinition(): CommandDefinition;
}

View File

@@ -0,0 +1,40 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { WindowsVisibleTerminalCommand } from '../Commands/WindowsVisibleTerminalCommand';
import { LinuxVisibleTerminalCommand } from '../Commands/LinuxVisibleTerminalCommand';
import { MacOsVisibleTerminalCommand } from '../Commands/MacOsVisibleTerminalCommand';
import type { CommandDefinitionFactory } from './CommandDefinitionFactory';
import type { CommandDefinition } from '../CommandDefinition';
export class OsSpecificTerminalLaunchCommandFactory implements CommandDefinitionFactory {
constructor(
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
) { }
public provideCommandDefinition(): CommandDefinition {
const { os } = this.environment;
if (os === undefined) {
throw new Error('Operating system could not be identified from environment.');
}
return getOperatingSystemCommandDefinition(os);
}
}
function getOperatingSystemCommandDefinition(
operatingSystem: OperatingSystem,
): CommandDefinition {
const definition = SupportedDesktopCommandDefinitions[operatingSystem];
if (!definition) {
throw new Error(`Unsupported operating system: ${OperatingSystem[operatingSystem]}`);
}
return definition;
}
const SupportedDesktopCommandDefinitions: Readonly<Partial<Record<
OperatingSystem,
CommandDefinition>>> = {
[OperatingSystem.Windows]: new WindowsVisibleTerminalCommand(),
[OperatingSystem.Linux]: new LinuxVisibleTerminalCommand(),
[OperatingSystem.macOS]: new MacOsVisibleTerminalCommand(),
} as const;

View File

@@ -0,0 +1,9 @@
import type { ScriptFileExecutionOutcome } from '../../ScriptFileExecutor';
import type { CommandDefinition } from '../CommandDefinition';
export interface CommandDefinitionRunner {
runCommandDefinition(
commandDefinition: CommandDefinition,
filePath: string,
): Promise<ScriptFileExecutionOutcome>;
}

View File

@@ -0,0 +1,80 @@
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileSystemExecutablePermissionSetter } from './PermissionSetter/FileSystemExecutablePermissionSetter';
import { LoggingNodeShellCommandRunner } from './ShellRunner/LoggingNodeShellCommandRunner';
import type { FailedScriptFileExecution, ScriptFileExecutionOutcome } from '../../ScriptFileExecutor';
import type { CommandDefinition } from '../CommandDefinition';
import type { CommandDefinitionRunner } from './CommandDefinitionRunner';
import type { ExecutablePermissionSetter } from './PermissionSetter/ExecutablePermissionSetter';
import type { ShellCommandOutcome, ShellCommandRunner } from './ShellRunner/ShellCommandRunner';
export class ExecutableFileShellCommandDefinitionRunner implements CommandDefinitionRunner {
constructor(
private readonly executablePermissionSetter: ExecutablePermissionSetter
= new FileSystemExecutablePermissionSetter(),
private readonly shellCommandRunner: ShellCommandRunner
= new LoggingNodeShellCommandRunner(),
) { }
public async runCommandDefinition(
commandDefinition: CommandDefinition,
filePath: string,
): Promise<ScriptFileExecutionOutcome> {
if (commandDefinition.isExecutablePermissionsRequiredOnFile()) {
const filePermissionsResult = await this.executablePermissionSetter
.makeFileExecutable(filePath);
if (!filePermissionsResult.success) {
return filePermissionsResult;
}
}
const command = commandDefinition.buildShellCommand(filePath);
const shellOutcome = await this.shellCommandRunner.runShellCommand(command);
return interpretShellOutcome(shellOutcome, commandDefinition);
}
}
function interpretShellOutcome(
outcome: ShellCommandOutcome,
commandDefinition: CommandDefinition,
): ScriptFileExecutionOutcome {
switch (outcome.type) {
case 'RegularProcessExit':
if (outcome.exitCode === 0) {
return { success: true };
}
if (commandDefinition.isExecutionTerminatedExternally(outcome.exitCode)) {
return createFailureOutcome(
'ExternalProcessTermination',
`Process terminated externally: Exit code ${outcome.exitCode}.`,
);
}
return createFailureOutcome(
'FileExecutionError',
`Unexpected exit code: ${outcome.exitCode}.`,
);
case 'ExternallyTerminated':
return createFailureOutcome(
'ExternalProcessTermination',
`Process terminated by signal ${outcome.terminationSignal}.`,
);
case 'ExecutionError':
return createFailureOutcome(
'FileExecutionError',
`Execution error: ${outcome.error.message}.`,
);
default:
throw new Error(`Unknown outcome type: ${outcome}`);
}
}
function createFailureOutcome(
type: CodeRunErrorType,
errorMessage: string,
): FailedScriptFileExecution {
return {
success: false,
error: {
type,
message: `Error during command execution: ${errorMessage}`,
},
};
}

View File

@@ -0,0 +1,5 @@
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
export interface ExecutablePermissionSetter {
makeFileExecutable(filePath: string): Promise<ScriptFileExecutionOutcome>;
}

View File

@@ -0,0 +1,35 @@
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly logger: Logger = ElectronLogger,
) { }
public async makeFileExecutable(filePath: string): Promise<ScriptFileExecutionOutcome> {
/*
This is required on macOS and Linux otherwise the terminal emulators will refuse to
execute the script. It's not needed on Windows.
*/
try {
this.logger.info(`Setting execution permissions for file at ${filePath}`);
await this.system.fileSystem.setFilePermissions(filePath, '755');
this.logger.info(`Execution permissions set successfully for ${filePath}`);
return { success: true };
} catch (error) {
this.logger.error(error);
return {
success: false,
error: {
type: 'FilePermissionChangeError',
message: `Error setting script file permission: ${error.message}`,
},
};
}
}
}

View File

@@ -0,0 +1,47 @@
import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
import type { ShellCommandOutcome, ShellCommandRunner } from './ShellCommandRunner';
export class LoggingNodeShellCommandRunner implements ShellCommandRunner {
constructor(
private readonly logger: Logger = ElectronLogger,
private readonly systemOps: SystemOperations = new NodeElectronSystemOperations(),
) {
}
public runShellCommand(command: string): Promise<ShellCommandOutcome> {
this.logger.info(`Executing command: ${command}`);
return new Promise((resolve) => {
this.systemOps.command.exec(command)
// https://archive.today/2024.01.19-004011/https://nodejs.org/api/child_process.html#child_process_event_exit
.on('exit', (
code, // The exit code if the child exited on its own.
signal, // The signal by which the child process was terminated.
) => {
// One of `code` or `signal` will always be non-null.
// If the process exited, code is the final exit code of the process, otherwise null.
if (code !== null) {
this.logger.info(`Command completed with exit code ${code}.`);
resolve({ type: 'RegularProcessExit', exitCode: code });
return; // Prevent further execution to avoid multiple promise resolutions and logs.
}
// If the process terminated due to receipt of a signal, signal is the string name of
// the signal, otherwise null.
resolve({ type: 'ExternallyTerminated', terminationSignal: signal as NodeJS.Signals });
this.logger.warn(`Command terminated by signal: ${signal}`);
})
.on('error', (error) => {
// https://archive.ph/20200912193803/https://nodejs.org/api/child_process.html#child_process_event_error
// The 'error' event is emitted whenever:
// - The process could not be spawned, or
// - The process could not be killed, or
// - Sending a message to the child process failed.
// The 'exit' event may or may not fire after an error has occurred.
this.logger.error('Command execution failed:', error);
resolve({ type: 'ExecutionError', error });
});
});
}
}

View File

@@ -0,0 +1,23 @@
export interface ShellCommandRunner {
runShellCommand(command: string): Promise<ShellCommandOutcome>;
}
export type ShellCommandOutcome = ProcessStatus & ({
readonly type: 'RegularProcessExit',
readonly exitCode: number;
} | {
readonly type: 'ExternallyTerminated';
readonly terminationSignal: NodeJS.Signals;
} | {
readonly type: 'ExecutionError';
readonly error: Error;
});
type ProcessOutcomeType = 'RegularProcessExit' | 'ExternallyTerminated' | 'ExecutionError';
interface ProcessStatus {
readonly type: ProcessOutcomeType;
readonly error?: Error;
readonly terminationSignal?: NodeJS.Signals;
readonly exitCode?: number;
}

View File

@@ -0,0 +1,71 @@
import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { OsSpecificTerminalLaunchCommandFactory } from './CommandDefinition/Factory/OsSpecificTerminalLaunchCommandFactory';
import { ExecutableFileShellCommandDefinitionRunner } from './CommandDefinition/Runner/ExecutableFileShellCommandDefinitionRunner';
import type { ScriptFileExecutionOutcome, ScriptFileExecutor } from './ScriptFileExecutor';
import type { CommandDefinitionFactory } from './CommandDefinition/Factory/CommandDefinitionFactory';
import type { CommandDefinitionRunner } from './CommandDefinition/Runner/CommandDefinitionRunner';
import type { CommandDefinition } from './CommandDefinition/CommandDefinition';
export class VisibleTerminalFileRunner implements ScriptFileExecutor {
constructor(
private readonly logger: Logger = ElectronLogger,
private readonly commandFactory: CommandDefinitionFactory
= new OsSpecificTerminalLaunchCommandFactory(),
private readonly commandRunner: CommandDefinitionRunner
= new ExecutableFileShellCommandDefinitionRunner(),
) { }
public async executeScriptFile(
filePath: string,
): Promise<ScriptFileExecutionOutcome> {
this.logger.info(`Executing script file: ${filePath}.`);
const outcome = await this.findAndExecuteCommand(filePath);
this.logOutcome(outcome);
return outcome;
}
private async findAndExecuteCommand(
filePath: string,
): Promise<ScriptFileExecutionOutcome> {
try {
let commandDefinition: CommandDefinition;
try {
commandDefinition = this.commandFactory.provideCommandDefinition();
} catch (error) {
return {
success: false,
error: {
type: 'UnsupportedPlatform',
message: `Error finding command: ${error.message}`,
},
};
}
const runOutcome = await this.commandRunner.runCommandDefinition(
commandDefinition,
filePath,
);
return runOutcome;
} catch (error) {
return {
success: false,
error: {
type: 'FileExecutionError',
message: `Unexpected error: ${error.message}`,
},
};
}
}
private logOutcome(outcome: ScriptFileExecutionOutcome) {
if (outcome.success) {
this.logger.info('Executed script file in terminal successfully.');
return;
}
this.logger.error(
'Failed to execute the script file in terminal.',
outcome.error.type,
outcome.error.message,
);
}
}

View File

@@ -1,214 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { CommandOps, SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { isString } from '@/TypeHelpers';
import type { FailedScriptFileExecution, ScriptFileExecutionOutcome, ScriptFileExecutor } from './ScriptFileExecutor';
export class VisibleTerminalScriptExecutor implements ScriptFileExecutor {
constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly logger: Logger = ElectronLogger,
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
) { }
public async executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome> {
const { os } = this.environment;
if (os === undefined) {
return this.handleError('UnsupportedOperatingSystem', 'Operating system could not be identified from environment.');
}
const filePermissionsResult = await this.setFileExecutablePermissions(filePath);
if (!filePermissionsResult.success) {
return filePermissionsResult;
}
const scriptExecutionResult = await this.runFileWithRunner(filePath, os);
if (!scriptExecutionResult.success) {
return scriptExecutionResult;
}
return {
success: true,
};
}
private async setFileExecutablePermissions(
filePath: string,
): Promise<ScriptFileExecutionOutcome> {
/*
This is required on macOS and Linux otherwise the terminal emulators will refuse to
execute the script. It's not needed on Windows.
*/
try {
this.logger.info(`Setting execution permissions for file at ${filePath}`);
await this.system.fileSystem.setFilePermissions(filePath, '755');
this.logger.info(`Execution permissions set successfully for ${filePath}`);
return { success: true };
} catch (error) {
return this.handleError('FileExecutionError', error);
}
}
private async runFileWithRunner(
filePath: string,
os: OperatingSystem,
): Promise<ScriptFileExecutionOutcome> {
this.logger.info(`Executing script file: ${filePath} on ${OperatingSystem[os]}.`);
const runner = TerminalRunners[os];
if (!runner) {
return this.handleError('UnsupportedOperatingSystem', `Unsupported operating system: ${OperatingSystem[os]}`);
}
const context: TerminalExecutionContext = {
scriptFilePath: filePath,
commandOps: this.system.command,
logger: this.logger,
};
try {
await runner(context);
this.logger.info('Command script file successfully.');
return { success: true };
} catch (error) {
return this.handleError('FileExecutionError', error);
}
}
private handleError(
type: CodeRunErrorType,
error: Error | string,
): FailedScriptFileExecution {
const errorMessage = 'Error during script file execution';
this.logger.error([type, errorMessage, ...(error ? [error] : [])]);
return {
success: false,
error: {
type,
message: `${errorMessage}: ${isString(error) ? error : errorMessage}`,
},
};
}
}
interface TerminalExecutionContext {
readonly scriptFilePath: string;
readonly commandOps: CommandOps;
readonly logger: Logger;
}
type TerminalRunner = (context: TerminalExecutionContext) => Promise<void>;
export const LinuxTerminalEmulator = 'x-terminal-emulator';
const TerminalRunners: Partial<Record<OperatingSystem, TerminalRunner>> = {
[OperatingSystem.Windows]: async (context) => {
const command = [
'PowerShell',
'Start-Process',
'-Verb RunAs', // Run as administrator with GUI sudo prompt
`-FilePath ${cmdShellPathArgumentEscape(context.scriptFilePath)}`,
].join(' ');
/*
📝 Options:
`child_process.execFile()`
"path", `cmd.exe /c "path"`
❌ Script execution in the background without a visible terminal.
This occurs only when the user runs the application as administrator, as seen
in Windows Pro VMs on Azure.
`PowerShell Start -Verb RunAs "path"`
✅ Visible terminal window
✅ GUI sudo prompt (through `RunAs` option)
`PowerShell Start "path"`
`explorer.exe "path"`
`electron.shell.openPath`
`start cmd.exe /c "$path"`
✅ Visible terminal window
✅ GUI sudo prompt (through `RunAs` option)
👍 Among all options `start` command is the most explicit one, being the most resilient
against the potential changes in Windows or Electron framework (e.g. https://github.com/electron/electron/issues/36765).
`%COMSPEC%` environment variable should be checked before defaulting to `cmd.exe.
Related docs: https://web.archive.org/web/20240106002357/https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
*/
await runCommand(command, context);
},
[OperatingSystem.Linux]: async (context) => {
const command = `${LinuxTerminalEmulator} -e ${posixShellPathArgumentEscape(context.scriptFilePath)}`;
/*
🤔 Potential improvements:
Use user-friendly GUI sudo prompt (not terminal-based).
If `pkexec` exists, we could do `x-terminal-emulator -e pkexec 'path'`, which always
prompts with user-friendly GUI sudo prompt.
📝 Options:
`x-terminal-emulator -e 'path'`:
✅ Visible terminal window
❌ Terminal-based (not GUI) sudo prompt.
`x-terminal-emulator -e pkexec 'path'
✅ Visible terminal window
✅ Always prompts with user-friendly GUI sudo prompt.
🤔 Not using `pkexec` as it is not in all Linux distributions. It should have smarter
logic to handle if it does not exist.
`electron.shell.openPath`:
❌ Opens the script in the default text editor, verified on
Debian/Ubuntu-based distributions.
`child_process.execFile()`:
❌ Script execution in the background without a visible terminal.
*/
await runCommand(command, context);
},
[OperatingSystem.macOS]: async (context) => {
const command = `open -a Terminal.app ${posixShellPathArgumentEscape(context.scriptFilePath)}`;
// -a Specifies the application to use for opening the file
/* eslint-disable vue/max-len */
/*
🤔 Potential improvements:
Use user-friendly GUI sudo prompt for running the script.
📝 Options:
`open -a Terminal.app 'path'`
✅ Visible terminal window
❌ Terminal-based (not GUI) sudo prompt.
❌ Terminal app requires many privileges to execute the script, this prompts user
to grant privileges to the Terminal app.
`osascript -e 'do shell script "'/tmp/test.sh'" with administrator privileges'`
✅ Script as root
✅ GUI sudo prompt.
❌ Script execution in the background without a visible terminal.
`osascript -e 'do shell script "open -a 'Terminal.app' '/tmp/test.sh'" with administrator privileges'`
❌ Script as user, not root
✅ GUI sudo prompt.
✅ Visible terminal window
`osascript -e 'do shell script "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal '/tmp/test.sh'" with administrator privileges'`
✅ Script as root
✅ GUI sudo prompt.
✅ Visible terminal window
Useful resources about `do shell script .. with administrator privileges`:
- Change "osascript wants to make changes" prompt: https://web.archive.org/web/20240109191128/https://apple.stackexchange.com/questions/283353/how-to-rename-osascript-in-the-administrator-privileges-dialog
- More about `do shell script`: https://web.archive.org/web/20100906222226/http://developer.apple.com/mac/library/technotes/tn2002/tn2065.html
*/
/* eslint-enable vue/max-len */
await runCommand(command, context);
},
} as const;
async function runCommand(command: string, context: TerminalExecutionContext): Promise<void> {
context.logger.info(`Executing command:\n${command}`);
await context.commandOps.exec(command);
context.logger.info('Executed command successfully.');
}
function posixShellPathArgumentEscape(pathArgument: string): string {
/*
- Wraps the path in single quotes, which is a standard practice in POSIX shells
(like bash and zsh) found on macOS/Linux to ensure that characters like spaces, '*', and
'?' are treated as literals, not as special characters.
- Escapes any single quotes within the path itself. This allows paths containing single
quotes to be correctly interpreted in POSIX-compliant systems, such as Linux and macOS.
*/
return `'${pathArgument.replaceAll('\'', '\'\\\'\'')}'`;
}
function cmdShellPathArgumentEscape(pathArgument: string): string {
// - Encloses the path in double quotes, which is necessary for Windows command line (cmd.exe)
// to correctly handle paths containing spaces.
// - Paths in Windows cannot include double quotes `"` themselves, so these are not escaped.
return `"${pathArgument}"`;
}

View File

@@ -4,15 +4,15 @@ import type {
CodeRunError, CodeRunOutcome, CodeRunner, FailedCodeRun,
} from '@/application/CodeRunner/CodeRunner';
import { ElectronLogger } from '../Log/ElectronLogger';
import { VisibleTerminalScriptExecutor } from './Execution/VisibleTerminalScriptFileExecutor';
import { ScriptFileCreationOrchestrator } from './Creation/ScriptFileCreationOrchestrator';
import { VisibleTerminalFileRunner } from './Execution/VisibleTerminalFileRunner';
import type { ScriptFileExecutor } from './Execution/ScriptFileExecutor';
import type { ScriptFileCreator } from './Creation/ScriptFileCreator';
export class ScriptFileCodeRunner implements CodeRunner {
constructor(
private readonly scriptFileExecutor
: ScriptFileExecutor = new VisibleTerminalScriptExecutor(),
: ScriptFileExecutor = new VisibleTerminalFileRunner(),
private readonly scriptFileCreator: ScriptFileCreator = new ScriptFileCreationOrchestrator(),
private readonly logger: Logger = ElectronLogger,
) { }

View File

@@ -6,6 +6,9 @@ import type {
CommandOps, FileSystemOps, LocationOps, OperatingSystemOps, SystemOperations,
} from './SystemOperations';
/**
* Thin wrapper for Node and Electron APIs.
*/
export class NodeElectronSystemOperations implements SystemOperations {
public readonly operatingSystem: OperatingSystemOps = {
/*
@@ -49,13 +52,6 @@ export class NodeElectronSystemOperations implements SystemOperations {
};
public readonly command: CommandOps = {
exec: (command) => new Promise((resolve, reject) => {
exec(command, (error) => {
if (error) {
reject(error);
}
resolve();
});
}),
exec,
};
}

View File

@@ -1,3 +1,5 @@
import type { exec } from 'node:child_process';
export interface SystemOperations {
readonly operatingSystem: OperatingSystemOps;
readonly location: LocationOps;
@@ -14,7 +16,7 @@ export interface LocationOps {
}
export interface CommandOps {
exec(command: string): Promise<void>;
exec(command: string): ReturnType<typeof exec>;
}
export interface FileSystemOps {

View File

@@ -11,6 +11,7 @@
import { defineComponent, computed } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { CodeRunError } from '@/application/CodeRunner/CodeRunner';
import IconButton from './IconButton.vue';
import { createScriptErrorDialog } from './ScriptErrorDialog';
@@ -38,15 +39,19 @@ export default defineComponent({
currentContext.state.collection.scripting.fileExtension,
);
if (!success) {
dialog.showError(...(await createScriptErrorDialog({
errorContext: 'run',
errorType: error.type,
errorMessage: error.message,
isFileReadbackError: error.type === 'FileReadbackVerificationError',
}, scriptDiagnosticsCollector)));
await handleCodeRunFailure(error);
}
}
async function handleCodeRunFailure(error: CodeRunError) {
dialog.showError(...(await createScriptErrorDialog({
errorContext: 'run',
errorType: error.type,
errorMessage: error.message,
isFileReadbackError: error.type === 'FileReadbackVerificationError',
}, scriptDiagnosticsCollector)));
}
return {
canRun,
runCode,

View File

@@ -1,21 +1,28 @@
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { Dialog } from '@/presentation/common/Dialog';
import type { Dialog, SaveFileErrorType } from '@/presentation/common/Dialog';
type ErrorDialogParameters = Parameters<Dialog['showError']>;
export async function createScriptErrorDialog(
information: ScriptErrorDetails,
scriptDiagnosticsCollector: ScriptDiagnosticsCollector | undefined,
): Promise<Parameters<Dialog['showError']>> {
): Promise<ErrorDialogParameters> {
const diagnostics = await scriptDiagnosticsCollector?.collectDiagnosticInformation();
if (information.isFileReadbackError) {
return createAntivirusErrorDialog(information, diagnostics);
}
if (information.errorContext === 'run'
&& information.errorType === 'ExternalProcessTermination') {
return createScriptInterruptedDialog(information);
}
return createGenericErrorDialog(information, diagnostics);
}
export interface ScriptErrorDetails {
readonly errorContext: 'run' | 'save';
readonly errorType: string;
readonly errorType: CodeRunErrorType | SaveFileErrorType;
readonly errorMessage: string;
readonly isFileReadbackError: boolean;
}
@@ -23,7 +30,7 @@ export interface ScriptErrorDetails {
function createGenericErrorDialog(
information: ScriptErrorDetails,
diagnostics: ScriptDiagnosticData | undefined,
): Parameters<Dialog['showError']> {
): ErrorDialogParameters {
return [
selectBasedOnErrorContext({
runningScript: 'Error Running Script',
@@ -66,7 +73,7 @@ function createGenericErrorDialog(
function createAntivirusErrorDialog(
information: ScriptErrorDetails,
diagnostics: ScriptDiagnosticData | undefined,
): Parameters<Dialog['showError']> {
): ErrorDialogParameters {
const defenderSteps = generateDefenderSteps(information, diagnostics);
return [
'Possible Antivirus Script Block',
@@ -117,6 +124,33 @@ function createAntivirusErrorDialog(
];
}
function createScriptInterruptedDialog(
information: ScriptErrorDetails,
): ErrorDialogParameters {
return [
'Script Stopped',
[
'The script stopped before it could finish.',
'This happens if the script is cancelled manually or if the system terminates the process.',
'\n',
generateUnorderedSolutionList({
title: 'To ensure successful script completion:',
solutions: [
'Keep the terminal window open during script execution.',
'If the script closed unexpectedly, try running it again.',
'Check for sufficient memory (RAM) and system resources.',
'Avoid running tasks that might disrupt the script.',
],
}),
'\n',
'If you intentionally stopped the script, ignore this message.',
'Reach out to the community for further assistance.',
'\n',
generateTechnicalDetails(information),
].join('\n'),
];
}
interface SolutionListOptions {
readonly solutions: readonly string[];
readonly title: string;