Improve security by isolating code execution more
This commit enhances application security against potential attacks by isolating dependencies that access the host system (like file operations) from the renderer process. It narrows the exposed functionality to script execution only, adding an extra security layer. The changes allow secure and scalable API exposure, preparing for future functionalities such as desktop notifications for script errors (#264), improved script execution handling (#296), and creating restore points (#50) in a secure and repeatable way. Changes include: - Inject `CodeRunner` into Vue components via dependency injection. - Move `CodeRunner` to the application layer as an abstraction for better domain-driven design alignment. - Refactor `SystemOperations` and related interfaces, removing the `I` prefix. - Update architecture documentation for clarity. - Update return types in `NodeSystemOperations` to match the Node APIs. - Improve `WindowVariablesProvider` integration tests for better error context. - Centralize type checks with common functions like `isArray` and `isNumber`. - Change `CodeRunner` to use `os` parameter, ensuring correct window variable injection. - Streamline API exposure to the renderer process: - Automatically bind function contexts to prevent loss of original context. - Implement a way to create facades (wrapper/proxy objects) for increased security.
This commit is contained in:
@@ -14,3 +14,35 @@ export type ConstructorArguments<T> =
|
||||
export type FunctionKeys<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
export function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number';
|
||||
}
|
||||
|
||||
export function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
export function isFunction(value: unknown): value is (...args: unknown[]) => unknown {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
||||
export function isArray(value: unknown): value is Array<unknown> {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isPlainObject(
|
||||
variable: unknown,
|
||||
): variable is object & Record<string, unknown> {
|
||||
return Boolean(variable) // the data type of null is an object
|
||||
&& typeof variable === 'object'
|
||||
&& !Array.isArray(variable);
|
||||
}
|
||||
|
||||
export function isNullOrUndefined(value: unknown): value is (null | undefined) {
|
||||
return typeof value === 'undefined' || value === null;
|
||||
}
|
||||
|
||||
7
src/application/CodeRunner.ts
Normal file
7
src/application/CodeRunner.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
|
||||
export interface CodeRunner {
|
||||
runCode(
|
||||
code: string, folderName: string, fileExtension: string, os: OperatingSystem,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isFunction } from '@/TypeHelpers';
|
||||
|
||||
/*
|
||||
Provides a unified and resilient way to extend errors across platforms.
|
||||
|
||||
@@ -50,8 +52,3 @@ function ensureStackTrace(target: Error) {
|
||||
}
|
||||
captureStackTrace(target, target.constructor);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
function isFunction(func: unknown): func is Function {
|
||||
return typeof func === 'function';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isString } from '@/TypeHelpers';
|
||||
|
||||
// Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611
|
||||
export type EnumType = number | string;
|
||||
export type EnumVariable<T extends EnumType, TEnumValue extends EnumType>
|
||||
@@ -23,7 +25,7 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
|
||||
if (!value) {
|
||||
throw new Error(`missing ${enumName}`);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
if (!isString(value)) {
|
||||
throw new Error(`unexpected type of ${enumName}: "${typeof value}"`);
|
||||
}
|
||||
const casedValue = getEnumNames(enumVariable)
|
||||
@@ -40,7 +42,7 @@ export function getEnumNames
|
||||
): string[] {
|
||||
return Object
|
||||
.values(enumVariable)
|
||||
.filter((enumMember) => typeof enumMember === 'string') as string[];
|
||||
.filter((enumMember): enumMember is string => isString(enumMember));
|
||||
}
|
||||
|
||||
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DocumentableData, DocumentationData } from '@/application/collections/';
|
||||
import { isString, isArray } from '@/TypeHelpers';
|
||||
|
||||
export function parseDocs(documentable: DocumentableData): readonly string[] {
|
||||
const { docs } = documentable;
|
||||
@@ -14,11 +15,9 @@ function addDocs(
|
||||
docs: DocumentationData,
|
||||
container: DocumentationContainer,
|
||||
): DocumentationContainer {
|
||||
if (docs instanceof Array) {
|
||||
if (docs.length > 0) {
|
||||
container.addParts(docs);
|
||||
}
|
||||
} else if (typeof docs === 'string') {
|
||||
if (isArray(docs)) {
|
||||
docs.forEach((doc) => container.addPart(doc));
|
||||
} else if (isString(docs)) {
|
||||
container.addPart(docs);
|
||||
} else {
|
||||
throwInvalidType();
|
||||
@@ -29,27 +28,21 @@ function addDocs(
|
||||
class DocumentationContainer {
|
||||
private readonly parts = new Array<string>();
|
||||
|
||||
public addPart(documentation: string) {
|
||||
public addPart(documentation: unknown): void {
|
||||
if (!documentation) {
|
||||
throw Error('missing documentation');
|
||||
}
|
||||
if (typeof documentation !== 'string') {
|
||||
if (!isString(documentation)) {
|
||||
throwInvalidType();
|
||||
}
|
||||
this.parts.push(documentation);
|
||||
}
|
||||
|
||||
public addParts(parts: readonly string[]) {
|
||||
for (const part of parts) {
|
||||
this.addPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
public getAll(): ReadonlyArray<string> {
|
||||
return this.parts;
|
||||
}
|
||||
}
|
||||
|
||||
function throwInvalidType() {
|
||||
function throwInvalidType(): never {
|
||||
throw new Error('docs field (documentation) must be an array of strings');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isString } from '@/TypeHelpers';
|
||||
import { INodeDataErrorContext, NodeDataError } from './NodeDataError';
|
||||
import { NodeData } from './NodeData';
|
||||
|
||||
@@ -13,7 +14,7 @@ export class NodeValidator {
|
||||
'missing name',
|
||||
)
|
||||
.assert(
|
||||
() => typeof nameValue === 'string',
|
||||
() => isString(nameValue),
|
||||
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FunctionCallData, FunctionCallsData, FunctionCallParametersData } from '@/application/collections/';
|
||||
import { isArray, isPlainObject } from '@/TypeHelpers';
|
||||
import { FunctionCall } from './FunctionCall';
|
||||
import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection';
|
||||
import { FunctionCallArgument } from './Argument/FunctionCallArgument';
|
||||
@@ -10,13 +11,13 @@ export function parseFunctionCalls(calls: FunctionCallsData): FunctionCall[] {
|
||||
}
|
||||
|
||||
function getCallSequence(calls: FunctionCallsData): FunctionCallData[] {
|
||||
if (typeof calls !== 'object') {
|
||||
throw new Error('called function(s) must be an object');
|
||||
if (!isPlainObject(calls) && !isArray(calls)) {
|
||||
throw new Error('called function(s) must be an object or array');
|
||||
}
|
||||
if (calls instanceof Array) {
|
||||
if (isArray(calls)) {
|
||||
return calls as FunctionCallData[];
|
||||
}
|
||||
const singleCall = calls;
|
||||
const singleCall = calls as FunctionCallData;
|
||||
return [singleCall];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValida
|
||||
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
||||
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
|
||||
import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
|
||||
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
|
||||
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
|
||||
import { SharedFunctionCollection } from './SharedFunctionCollection';
|
||||
import { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
||||
@@ -121,8 +122,11 @@ function ensureEitherCallOrCodeIsDefined(holders: readonly FunctionData[]) {
|
||||
}
|
||||
|
||||
function ensureExpectedParametersType(functions: readonly FunctionData[]) {
|
||||
const hasValidParameters = (
|
||||
func: FunctionData,
|
||||
) => isNullOrUndefined(func.parameters) || isArrayOfObjects(func.parameters);
|
||||
const unexpectedFunctions = functions
|
||||
.filter((func) => func.parameters && !isArrayOfObjects(func.parameters));
|
||||
.filter((func) => !hasValidParameters(func));
|
||||
if (unexpectedFunctions.length) {
|
||||
const errorMessage = `parameters must be an array of objects in function(s) ${printNames(unexpectedFunctions)}`;
|
||||
throw new Error(errorMessage);
|
||||
@@ -130,8 +134,7 @@ function ensureExpectedParametersType(functions: readonly FunctionData[]) {
|
||||
}
|
||||
|
||||
function isArrayOfObjects(value: unknown): boolean {
|
||||
return Array.isArray(value)
|
||||
&& value.every((item) => typeof item === 'object');
|
||||
return isArray(value) && value.every((item) => isPlainObject(item));
|
||||
}
|
||||
|
||||
function printNames(holders: readonly FunctionData[]) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { chmod, mkdir, writeFile } from 'fs/promises';
|
||||
import { exec } from 'child_process';
|
||||
import { ISystemOperations } from './ISystemOperations';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { exec } from 'node:child_process';
|
||||
import { SystemOperations } from './SystemOperations';
|
||||
|
||||
export function createNodeSystemOperations(): ISystemOperations {
|
||||
export function createNodeSystemOperations(): SystemOperations {
|
||||
return {
|
||||
operatingSystem: {
|
||||
getTempDirectory: () => tmpdir(),
|
||||
@@ -33,7 +33,14 @@ export function createNodeSystemOperations(): ISystemOperations {
|
||||
) => writeFile(filePath, data),
|
||||
},
|
||||
command: {
|
||||
execute: (command) => exec(command),
|
||||
execute: (command) => new Promise((resolve, reject) => {
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface SystemOperations {
|
||||
readonly operatingSystem: OperatingSystemOps;
|
||||
readonly location: LocationOps;
|
||||
readonly fileSystem: FileSystemOps;
|
||||
readonly command: CommandOps;
|
||||
}
|
||||
|
||||
export interface OperatingSystemOps {
|
||||
getTempDirectory(): string;
|
||||
}
|
||||
|
||||
export interface LocationOps {
|
||||
combinePaths(...pathSegments: string[]): string;
|
||||
}
|
||||
|
||||
export interface CommandOps {
|
||||
execute(command: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FileSystemOps {
|
||||
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
|
||||
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
|
||||
writeToFile(filePath: string, data: string): Promise<void>;
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { getWindowInjectedSystemOperations } from './SystemOperations/WindowInjectedSystemOperations';
|
||||
import { CodeRunner } from '@/application/CodeRunner';
|
||||
import { SystemOperations } from './SystemOperations/SystemOperations';
|
||||
import { createNodeSystemOperations } from './SystemOperations/NodeSystemOperations';
|
||||
|
||||
export class CodeRunner {
|
||||
export class TemporaryFileCodeRunner implements CodeRunner {
|
||||
constructor(
|
||||
private readonly system = getWindowInjectedSystemOperations(),
|
||||
private readonly environment = RuntimeEnvironment.CurrentEnvironment,
|
||||
private readonly system: SystemOperations = createNodeSystemOperations(),
|
||||
) { }
|
||||
|
||||
public async runCode(code: string, folderName: string, fileExtension: string): Promise<void> {
|
||||
const { os } = this.environment;
|
||||
if (os === undefined) {
|
||||
throw new Error('Unidentified operating system');
|
||||
}
|
||||
public async runCode(
|
||||
code: string,
|
||||
folderName: string,
|
||||
fileExtension: string,
|
||||
os: OperatingSystem,
|
||||
): Promise<void> {
|
||||
const dir = this.system.location.combinePaths(
|
||||
this.system.operatingSystem.getTempDirectory(),
|
||||
folderName,
|
||||
@@ -22,7 +23,7 @@ export class CodeRunner {
|
||||
await this.system.fileSystem.writeToFile(filePath, code);
|
||||
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
||||
const command = getExecuteCommand(filePath, os);
|
||||
this.system.command.execute(command);
|
||||
await this.system.command.execute(command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isNumber } from '@/TypeHelpers';
|
||||
import { IEntity } from './IEntity';
|
||||
|
||||
export abstract class BaseEntity<TId> implements IEntity<TId> {
|
||||
protected constructor(public id: TId) {
|
||||
if (typeof id !== 'number' && !id) {
|
||||
if (!isNumber(id) && !id) {
|
||||
throw new Error('Id cannot be null or empty');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isBoolean, isFunction } from '@/TypeHelpers';
|
||||
import { IEnvironmentVariables } from './IEnvironmentVariables';
|
||||
|
||||
/* Validation is externalized to keep the environment objects simple */
|
||||
@@ -15,7 +16,7 @@ export function validateEnvironmentVariables(environment: IEnvironmentVariables)
|
||||
function getKeysMissingValues(keyValuePairs: Record<string, unknown>): string[] {
|
||||
return Object.entries(keyValuePairs)
|
||||
.reduce((acc, [key, value]) => {
|
||||
if (!value && typeof value !== 'boolean') {
|
||||
if (!value && !isBoolean(value)) {
|
||||
acc.push(key);
|
||||
}
|
||||
return acc;
|
||||
@@ -38,7 +39,7 @@ function capturePropertyValues(instance: object): Record<string, unknown> {
|
||||
|
||||
// Capture getter properties from the instance's prototype
|
||||
for (const [key, descriptor] of Object.entries(descriptors)) {
|
||||
if (typeof descriptor.get === 'function') {
|
||||
if (isFunction(descriptor.get)) {
|
||||
obj[key] = descriptor.get.call(instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,8 @@ import log from 'electron-log/main';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import type { LogFunctions } from 'electron-log';
|
||||
|
||||
// Using plain-function rather than class so it can be used in Electron's context-bridging.
|
||||
export function createElectronLogger(logger: LogFunctions = log): Logger {
|
||||
return {
|
||||
info: (...params) => logger.info(...params),
|
||||
debug: (...params) => logger.debug(...params),
|
||||
warn: (...params) => logger.warn(...params),
|
||||
error: (...params) => logger.error(...params),
|
||||
};
|
||||
return logger;
|
||||
}
|
||||
|
||||
export const ElectronLogger = createElectronLogger();
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export interface ISystemOperations {
|
||||
readonly operatingSystem: IOperatingSystemOps;
|
||||
readonly location: ILocationOps;
|
||||
readonly fileSystem: IFileSystemOps;
|
||||
readonly command: ICommandOps;
|
||||
}
|
||||
|
||||
export interface IOperatingSystemOps {
|
||||
getTempDirectory(): string;
|
||||
}
|
||||
|
||||
export interface ILocationOps {
|
||||
combinePaths(...pathSegments: string[]): string;
|
||||
}
|
||||
|
||||
export interface ICommandOps {
|
||||
execute(command: string): void;
|
||||
}
|
||||
|
||||
export interface IFileSystemOps {
|
||||
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
|
||||
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
|
||||
writeToFile(filePath: string, data: string): Promise<void>;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { WindowVariables } from '../WindowVariables/WindowVariables';
|
||||
import { ISystemOperations } from './ISystemOperations';
|
||||
|
||||
export function getWindowInjectedSystemOperations(
|
||||
windowVariables: Partial<WindowVariables> = window,
|
||||
): ISystemOperations {
|
||||
if (!windowVariables) {
|
||||
throw new Error('missing window');
|
||||
}
|
||||
if (!windowVariables.system) {
|
||||
throw new Error('missing system');
|
||||
}
|
||||
return windowVariables.system;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { CodeRunner } from '@/application/CodeRunner';
|
||||
|
||||
/* Primary entry point for platform-specific injections */
|
||||
export interface WindowVariables {
|
||||
readonly isDesktop: boolean;
|
||||
readonly system?: ISystemOperations;
|
||||
readonly codeRunner?: CodeRunner;
|
||||
readonly os?: OperatingSystem;
|
||||
readonly log: Logger;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { PropertyKeys } from '@/TypeHelpers';
|
||||
import {
|
||||
PropertyKeys, isBoolean, isFunction, isNumber, isPlainObject,
|
||||
} from '@/TypeHelpers';
|
||||
import { WindowVariables } from './WindowVariables';
|
||||
|
||||
/**
|
||||
* Checks for consistency in runtime environment properties injected by Electron preloader.
|
||||
*/
|
||||
export function validateWindowVariables(variables: Partial<WindowVariables>) {
|
||||
if (!isObject(variables)) {
|
||||
if (!isPlainObject(variables)) {
|
||||
throw new Error('window is not an object');
|
||||
}
|
||||
const errors = [...testEveryProperty(variables)];
|
||||
@@ -21,7 +23,7 @@ function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<strin
|
||||
} = {
|
||||
os: testOperatingSystem(variables.os),
|
||||
isDesktop: testIsDesktop(variables.isDesktop),
|
||||
system: testSystem(variables),
|
||||
codeRunner: testCodeRunner(variables),
|
||||
log: testLogger(variables),
|
||||
};
|
||||
|
||||
@@ -49,14 +51,15 @@ function testLogger(variables: Partial<WindowVariables>): boolean {
|
||||
if (!variables.isDesktop) {
|
||||
return true;
|
||||
}
|
||||
return isObject(variables.log);
|
||||
return isPlainObject(variables.log);
|
||||
}
|
||||
|
||||
function testSystem(variables: Partial<WindowVariables>): boolean {
|
||||
function testCodeRunner(variables: Partial<WindowVariables>): boolean {
|
||||
if (!variables.isDesktop) {
|
||||
return true;
|
||||
}
|
||||
return isObject(variables.system);
|
||||
return isPlainObject(variables.codeRunner)
|
||||
&& isFunction(variables.codeRunner.runCode);
|
||||
}
|
||||
|
||||
function testIsDesktop(isDesktop: unknown): boolean {
|
||||
@@ -65,17 +68,3 @@ function testIsDesktop(isDesktop: unknown): boolean {
|
||||
}
|
||||
return isBoolean(isDesktop);
|
||||
}
|
||||
|
||||
function isNumber(variable: unknown): variable is number {
|
||||
return typeof variable === 'number';
|
||||
}
|
||||
|
||||
function isBoolean(variable: unknown): variable is boolean {
|
||||
return typeof variable === 'boolean';
|
||||
}
|
||||
|
||||
function isObject(variable: unknown): variable is object {
|
||||
return Boolean(variable) // the data type of null is an object
|
||||
&& typeof variable === 'object'
|
||||
&& !Array.isArray(variable);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { PropertyKeys } from '@/TypeHelpers';
|
||||
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
|
||||
import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner';
|
||||
|
||||
export function provideDependencies(
|
||||
context: IApplicationContext,
|
||||
@@ -62,6 +63,10 @@ export function provideDependencies(
|
||||
InjectionKeys.useLogger,
|
||||
useLogger,
|
||||
),
|
||||
useCodeRunner: (di) => di.provide(
|
||||
InjectionKeys.useCodeRunner,
|
||||
useCodeRunner,
|
||||
),
|
||||
};
|
||||
registerAll(Object.values(resolvers), api);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import IconButton from './IconButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -22,11 +20,19 @@ export default defineComponent({
|
||||
setup() {
|
||||
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
|
||||
const { os, isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
|
||||
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
|
||||
|
||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
||||
|
||||
async function executeCode() {
|
||||
await runCode(currentContext);
|
||||
if (!codeRunner) { throw new Error('missing code runner'); }
|
||||
if (os === undefined) { throw new Error('unidentified host operating system'); }
|
||||
await codeRunner.runCode(
|
||||
currentContext.state.code.current,
|
||||
currentContext.app.info.name,
|
||||
currentState.value.collection.scripting.fileExtension,
|
||||
os,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -45,13 +51,4 @@ function getCanRunState(
|
||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||
return isDesktopVersion && isRunningOnSelectedOs;
|
||||
}
|
||||
|
||||
async function runCode(context: IReadOnlyApplicationContext) {
|
||||
const runner = new CodeRunner();
|
||||
await runner.runCode(
|
||||
/* code: */ context.state.code.current,
|
||||
/* appName: */ context.app.info.name,
|
||||
/* fileExtension: */ context.state.collection.scripting.fileExtension,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isArray } from '@/TypeHelpers';
|
||||
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
|
||||
import { TreeNode } from '../../Node/TreeNode';
|
||||
import { TreeNodeManager } from '../../Node/TreeNodeManager';
|
||||
@@ -5,7 +6,7 @@ import { TreeNodeManager } from '../../Node/TreeNodeManager';
|
||||
export function parseTreeInput(
|
||||
input: readonly TreeInputNodeData[],
|
||||
): TreeNode[] {
|
||||
if (!Array.isArray(input)) {
|
||||
if (!isArray(input)) {
|
||||
throw new Error('input data must be an array');
|
||||
}
|
||||
const nodes = input.map((nodeData) => createNode(nodeData));
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
|
||||
export function useCodeRunner(
|
||||
window: WindowVariables = globalThis.window,
|
||||
) {
|
||||
return {
|
||||
codeRunner: window.codeRunner,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { contextBridge } from 'electron';
|
||||
import { bindObjectMethods } from './MethodContextBinder';
|
||||
import { provideWindowVariables } from './RendererApiProvider';
|
||||
|
||||
export function connectApisWithContextBridge(
|
||||
bridgeConnector: BridgeConnector = contextBridge.exposeInMainWorld,
|
||||
apiObject: object = provideWindowVariables(),
|
||||
methodContextBinder: MethodContextBinder = bindObjectMethods,
|
||||
) {
|
||||
Object.entries(apiObject).forEach(([key, value]) => {
|
||||
bridgeConnector(key, methodContextBinder(value));
|
||||
});
|
||||
}
|
||||
|
||||
export type BridgeConnector = typeof contextBridge.exposeInMainWorld;
|
||||
|
||||
export type MethodContextBinder = typeof bindObjectMethods;
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
isArray, isFunction, isNullOrUndefined, isPlainObject,
|
||||
} from '@/TypeHelpers';
|
||||
|
||||
/**
|
||||
* Binds method contexts to their original object instances and recursively processes
|
||||
* nested objects and arrays. This is particularly useful when exposing objects across
|
||||
* different contexts in Electron, such as from the main process to the renderer process
|
||||
* via the `contextBridge`.
|
||||
*
|
||||
* In Electron's context isolation environment, methods of objects passed through the
|
||||
* `contextBridge` lose their original context (`this` binding). This function ensures that
|
||||
* each method retains its binding to its original object, allowing it to work as intended
|
||||
* when invoked from the renderer process.
|
||||
*
|
||||
* This approach decouples context isolation concerns from class implementations, enabling
|
||||
* classes to operate normally without needing explicit binding or arrow functions to maintain
|
||||
* the context.
|
||||
*/
|
||||
export function bindObjectMethods<T>(obj: T): T {
|
||||
if (isNullOrUndefined(obj)) {
|
||||
return obj;
|
||||
}
|
||||
if (isPlainObject(obj)) {
|
||||
bindMethodsOfObject(obj);
|
||||
Object.values(obj).forEach((value) => {
|
||||
if (!isNullOrUndefined(value) && !isFunction(value)) {
|
||||
bindObjectMethods(value);
|
||||
}
|
||||
});
|
||||
} else if (isArray(obj)) {
|
||||
obj.forEach((item) => bindObjectMethods(item));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function bindMethodsOfObject<T>(obj: T): T {
|
||||
const prototype = Object.getPrototypeOf(obj);
|
||||
if (!prototype) {
|
||||
return obj;
|
||||
}
|
||||
Object.getOwnPropertyNames(prototype).forEach((property) => {
|
||||
if (!prototype.hasOwnProperty.call(obj, property)) {
|
||||
return; // Skip properties not directly on the prototype
|
||||
}
|
||||
const value = obj[property];
|
||||
if (isFunction(value)) {
|
||||
(obj as object)[property] = value.bind(obj);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner';
|
||||
import { CodeRunner } from '@/application/CodeRunner';
|
||||
import { convertPlatformToOs } from './NodeOsMapper';
|
||||
import { createSecureFacade } from './SecureFacadeCreator';
|
||||
|
||||
export function provideWindowVariables(
|
||||
createCodeRunner: CodeRunnerFactory = () => new TemporaryFileCodeRunner(),
|
||||
createLogger: LoggerFactory = () => createElectronLogger(),
|
||||
convertToOs = convertPlatformToOs,
|
||||
createApiFacade: ApiFacadeFactory = createSecureFacade,
|
||||
): WindowVariables {
|
||||
return {
|
||||
isDesktop: true,
|
||||
log: createApiFacade(createLogger(), ['info', 'debug', 'warn', 'error']),
|
||||
os: convertToOs(process.platform),
|
||||
codeRunner: createApiFacade(createCodeRunner(), ['runCode']),
|
||||
};
|
||||
}
|
||||
|
||||
export type LoggerFactory = () => Logger;
|
||||
|
||||
export type CodeRunnerFactory = () => CodeRunner;
|
||||
|
||||
export type ApiFacadeFactory = typeof createSecureFacade;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isFunction } from '@/TypeHelpers';
|
||||
|
||||
/**
|
||||
* Creates a secure proxy for the specified object, exposing only the public properties
|
||||
* of its interface.
|
||||
*
|
||||
* This approach prevents the full exposure of the object, thereby reducing the risk
|
||||
* of unintended access or misuse. For instance, creating a facade for a class rather
|
||||
* than exposing the class itself ensures that private members and dependencies
|
||||
* (such as file access or internal state) remain encapsulated and inaccessible.
|
||||
*/
|
||||
export function createSecureFacade<T>(
|
||||
originalObject: T,
|
||||
accessibleMembers: KeyTypeCombinations<T>,
|
||||
): T {
|
||||
const facade: Partial<T> = {};
|
||||
|
||||
accessibleMembers.forEach((key: keyof T) => {
|
||||
const member = originalObject[key];
|
||||
if (isFunction(member)) {
|
||||
facade[key] = ((...args: unknown[]) => {
|
||||
return member.apply(originalObject, args);
|
||||
}) as T[keyof T];
|
||||
} else {
|
||||
facade[key] = member;
|
||||
}
|
||||
});
|
||||
|
||||
return facade as T;
|
||||
}
|
||||
|
||||
type PrependTuple<H, T extends readonly unknown[]> = H extends unknown ? T extends unknown ?
|
||||
((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never : never : never;
|
||||
type RecursionDepthControl = [
|
||||
never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||
];
|
||||
type AllKeyCombinations<T, U = T, N extends number = 15> = T extends unknown ?
|
||||
PrependTuple<T, Exclude<U, T> extends infer X ? {
|
||||
0: [], 1: AllKeyCombinations<X, X, RecursionDepthControl[N]>
|
||||
}[[X] extends [never] ? 0 : 1] : never> :
|
||||
never;
|
||||
type KeyTypeCombinations<T> = AllKeyCombinations<keyof T>;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { createNodeSystemOperations } from '@/infrastructure/SystemOperations/NodeSystemOperations';
|
||||
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
import { convertPlatformToOs } from './NodeOsMapper';
|
||||
|
||||
export function provideWindowVariables(
|
||||
createSystem = createNodeSystemOperations,
|
||||
createLogger: () => Logger = () => createElectronLogger(),
|
||||
convertToOs = convertPlatformToOs,
|
||||
): WindowVariables {
|
||||
return {
|
||||
system: createSystem(),
|
||||
isDesktop: true,
|
||||
log: createLogger(),
|
||||
os: convertToOs(process.platform),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// This file is used to securely expose Electron APIs to the application.
|
||||
|
||||
import { contextBridge } from 'electron';
|
||||
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { provideWindowVariables } from './WindowVariablesProvider';
|
||||
import { connectApisWithContextBridge } from './ContextBridging/ApiContextBridge';
|
||||
|
||||
validateRuntimeSanity({
|
||||
// Validate metadata as a preventive measure for fail-fast,
|
||||
@@ -14,10 +13,7 @@ validateRuntimeSanity({
|
||||
validateWindowVariables: false,
|
||||
});
|
||||
|
||||
const windowVariables = provideWindowVariables();
|
||||
Object.entries(windowVariables).forEach(([key, value]) => {
|
||||
contextBridge.exposeInMainWorld(key, value);
|
||||
});
|
||||
connectApisWithContextBridge();
|
||||
|
||||
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
|
||||
ElectronLogger.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseC
|
||||
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import type { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
|
||||
import type { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner';
|
||||
|
||||
export const InjectionKeys = {
|
||||
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
|
||||
@@ -17,6 +18,7 @@ export const InjectionKeys = {
|
||||
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
|
||||
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
|
||||
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
|
||||
useCodeRunner: defineTransientKey<ReturnType<typeof useCodeRunner>>('useCodeRunner'),
|
||||
};
|
||||
|
||||
export interface InjectionKeyWithLifetime<T> {
|
||||
|
||||
Reference in New Issue
Block a user