Improve script error dialogs #304

- Include the script's directory path #304.
- Exclude Windows-specific instructions on non-Windows OS.
- Standardize language across dialogs for consistency.

Other supporting changes:

- Add script diagnostics data collection from main process.
- Document script file storage and execution tamper protection in
  SECURITY.md.
- Remove redundant comment in `NodeReadbackFileWriter`.
- Centralize error display for uniformity and simplicity.
- Simpify `WindowVariablesValidator` to omit checks when not on the
  renderer process.
- Improve and centralize Electron environment detection.
- Use more emphatic language (don't worry) in error messages.
This commit is contained in:
undergroundwires
2024-01-17 23:59:05 +01:00
parent f03fc24098
commit 6ada8d425c
34 changed files with 1182 additions and 450 deletions

View File

@@ -27,11 +27,6 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
const fileWritePipelineActions: ReadonlyArray<() => Promise<FileWriteOutcome>> = [
() => this.createOrOverwriteFile(filePath, fileContents),
() => this.verifyFileExistsWithoutReading(filePath),
/*
Reading the file contents back, we can detect if the file has been altered or
removed post-creation. Removal of scripts when reading back is seen by some antivirus
software when it falsely identifies a script as harmful.
*/
() => this.verifyFileContentsByReading(filePath, fileContents),
];
for (const action of fileWritePipelineActions) {

View File

@@ -0,0 +1,54 @@
import { ElectronEnvironmentDetector, ElectronProcessType } from './ElectronEnvironmentDetector';
export class ContextIsolatedElectronDetector implements ElectronEnvironmentDetector {
constructor(
private readonly nodeProcessAccessor: NodeProcessAccessor = () => globalThis?.process,
private readonly userAgentAccessor: UserAgentAccessor = () => globalThis?.navigator?.userAgent,
) { }
public isRunningInsideElectron(): boolean {
return isNodeProcessElectronBased(this.nodeProcessAccessor)
|| isUserAgentElectronBased(this.userAgentAccessor);
}
public determineElectronProcessType(): ElectronProcessType {
const isNodeAccessible = isNodeProcessElectronBased(this.nodeProcessAccessor);
const isBrowserAccessible = isUserAgentElectronBased(this.userAgentAccessor);
if (!isNodeAccessible && !isBrowserAccessible) {
throw new Error('Unable to determine the Electron process type. Neither Node.js nor browser-based Electron contexts were detected.');
}
if (isNodeAccessible && isBrowserAccessible) {
return 'preloader'; // Only preloader can access both Node.js and browser contexts in Electron with context isolation.
}
if (isNodeAccessible) {
return 'main';
}
return 'renderer';
}
}
export type NodeProcessAccessor = () => NodeJS.Process | undefined;
function isNodeProcessElectronBased(nodeProcessAccessor: NodeProcessAccessor): boolean {
const nodeProcess = nodeProcessAccessor();
if (!nodeProcess) {
return false;
}
if (nodeProcess.versions.electron) {
// Electron populates `nodeProcess.versions.electron` with its version, see https://web.archive.org/web/20240113162837/https://www.electronjs.org/docs/latest/api/process#processversionselectron-readonly.
return true;
}
return false;
}
export type UserAgentAccessor = () => string | undefined;
function isUserAgentElectronBased(
userAgentAccessor: UserAgentAccessor,
): boolean {
const userAgent = userAgentAccessor();
if (userAgent?.includes('Electron')) {
return true;
}
return false;
}

View File

@@ -0,0 +1,6 @@
export interface ElectronEnvironmentDetector {
isRunningInsideElectron(): boolean;
determineElectronProcessType(): ElectronProcessType;
}
export type ElectronProcessType = 'main' | 'preloader' | 'renderer';

View File

@@ -1,49 +1,32 @@
import { ElectronEnvironmentDetector } from './Electron/ElectronEnvironmentDetector';
import { BrowserRuntimeEnvironment } from './Browser/BrowserRuntimeEnvironment';
import { NodeRuntimeEnvironment } from './Node/NodeRuntimeEnvironment';
import { RuntimeEnvironment } from './RuntimeEnvironment';
import { ContextIsolatedElectronDetector } from './Electron/ContextIsolatedElectronDetector';
export const CurrentEnvironment = determineAndCreateRuntimeEnvironment({
window: globalThis.window,
process: globalThis.process,
});
export const CurrentEnvironment = determineAndCreateRuntimeEnvironment(globalThis.window);
export function determineAndCreateRuntimeEnvironment(
globalAccessor: GlobalPropertiesAccessor,
globalWindow: Window | undefined | null = globalThis.window,
electronDetector: ElectronEnvironmentDetector = new ContextIsolatedElectronDetector(),
browserEnvironmentFactory: BrowserRuntimeEnvironmentFactory = (
window,
) => new BrowserRuntimeEnvironment(window),
nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = (
process: NodeJS.Process,
) => new NodeRuntimeEnvironment(process),
nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = () => new NodeRuntimeEnvironment(),
): RuntimeEnvironment {
if (isElectronMainProcess(globalAccessor.process)) {
return nodeEnvironmentFactory(globalAccessor.process);
if (
electronDetector.isRunningInsideElectron()
&& electronDetector.determineElectronProcessType() === 'main') {
return nodeEnvironmentFactory();
}
const { window } = globalAccessor;
if (!window) {
if (!globalWindow) {
throw new Error('Unsupported runtime environment: The current context is neither a recognized browser nor a desktop environment.');
}
return browserEnvironmentFactory(window);
}
function isElectronMainProcess(
nodeProcess: NodeJS.Process | undefined,
): nodeProcess is NodeJS.Process {
// Electron populates `nodeProcess.versions.electron` with its version, see https://web.archive.org/web/20240113162837/https://www.electronjs.org/docs/latest/api/process#processversionselectron-readonly.
if (!nodeProcess) {
return false;
}
if (nodeProcess.versions.electron) {
return true;
}
return false;
return browserEnvironmentFactory(globalWindow);
}
export type BrowserRuntimeEnvironmentFactory = (window: Window) => RuntimeEnvironment;
export type NodeRuntimeEnvironmentFactory = (process: NodeJS.Process) => NodeRuntimeEnvironment;
export type NodeRuntimeEnvironmentFactory = () => NodeRuntimeEnvironment;
export interface GlobalPropertiesAccessor {
readonly window: Window | undefined;
readonly process: NodeJS.Process | undefined;
}
export type GlobalWindowAccessor = Window | undefined;

View File

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

View File

@@ -2,6 +2,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { Logger } from '@/application/Common/Log/Logger';
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog';
import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
/* Primary entry point for platform-specific injections */
export interface WindowVariables {
@@ -10,4 +11,5 @@ export interface WindowVariables {
readonly os?: OperatingSystem;
readonly log?: Logger;
readonly dialog?: Dialog;
readonly scriptDiagnosticsCollector?: ScriptDiagnosticsCollector;
}

View File

@@ -1,3 +1,5 @@
import { ContextIsolatedElectronDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector';
import { ElectronEnvironmentDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector';
import { OperatingSystem } from '@/domain/OperatingSystem';
import {
PropertyKeys, isBoolean, isFunction, isNumber, isPlainObject,
@@ -7,7 +9,14 @@ import { WindowVariables } from './WindowVariables';
/**
* Checks for consistency in runtime environment properties injected by Electron preloader.
*/
export function validateWindowVariables(variables: Partial<WindowVariables>) {
export function validateWindowVariables(
variables: Partial<WindowVariables>,
electronDetector: ElectronEnvironmentDetector = new ContextIsolatedElectronDetector(),
) {
if (!electronDetector.isRunningInsideElectron()
|| electronDetector.determineElectronProcessType() !== 'renderer') {
return;
}
if (!isPlainObject(variables)) {
throw new Error('window is not an object');
}
@@ -20,12 +29,11 @@ export function validateWindowVariables(variables: Partial<WindowVariables>) {
function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<string> {
const tests: Record<PropertyKeys<Required<WindowVariables>>, boolean> = {
os: testOperatingSystem(variables.os),
isRunningAsDesktopApplication: testIsRunningAsDesktopApplication(
variables.isRunningAsDesktopApplication,
),
isRunningAsDesktopApplication: testIsRunningAsDesktopApplication(variables),
codeRunner: testCodeRunner(variables),
log: testLogger(variables),
dialog: testDialog(variables),
scriptDiagnosticsCollector: testScriptDiagnosticsCollector(variables),
};
for (const [propertyName, testResult] of Object.entries(tests)) {
@@ -49,30 +57,30 @@ function testOperatingSystem(os: unknown): boolean {
}
function testLogger(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.log);
return isPlainObject(variables.log)
&& isFunction(variables.log.debug)
&& isFunction(variables.log.info)
&& isFunction(variables.log.error)
&& isFunction(variables.log.warn);
}
function testCodeRunner(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.codeRunner)
&& isFunction(variables.codeRunner.runCode);
}
function testIsRunningAsDesktopApplication(isRunningAsDesktopApplication: unknown): boolean {
if (isRunningAsDesktopApplication === undefined) {
return true;
}
return isBoolean(isRunningAsDesktopApplication);
function testIsRunningAsDesktopApplication(variables: Partial<WindowVariables>): boolean {
return isBoolean(variables.isRunningAsDesktopApplication)
&& variables.isRunningAsDesktopApplication === true;
}
function testDialog(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.dialog);
return isPlainObject(variables.dialog)
&& isFunction(variables.dialog.saveFile)
&& isFunction(variables.dialog.showError);
}
function testScriptDiagnosticsCollector(variables: Partial<WindowVariables>): boolean {
return isPlainObject(variables.scriptDiagnosticsCollector)
&& isFunction(variables.scriptDiagnosticsCollector.collectDiagnosticInformation);
}