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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ElectronEnvironmentDetector {
|
||||
isRunningInsideElectron(): boolean;
|
||||
determineElectronProcessType(): ElectronProcessType;
|
||||
}
|
||||
|
||||
export type ElectronProcessType = 'main' | 'preloader' | 'renderer';
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user