This commit introduces native operating system file dialogs in the desktop application replacing the existing web-based dialogs. It lays the foundation for future enhancements such as: - Providing error messages when saving or executing files, addressing #264. - Creating system restore points, addressing #50. Documentation updates: - Update `desktop-vs-web-features.md` with added functionality. - Update `README.md` with security feature highlights. - Update home page documentation to emphasize security features. Other supporting changes include: - Integrate IPC communication channels for secure Electron dialog API interactions. - Refactor `IpcRegistration` for more type-safety and simplicity. - Introduce a Vue hook to encapsulate dialog functionality. - Improve errors during IPC registration for easier troubleshooting. - Move `ClientLoggerFactory` for consistency in hooks organization and remove `LoggerFactory` interface for simplicity. - Add tests for the save file dialog in the browser context. - Add `Blob` polyfill in tests to compensate for the missing `blob.text()` function in `jsdom` (see jsdom/jsdom#2555). Improve environment detection logic: - Treat test environment as browser environments to correctly activate features based on the environment. This resolves issues where the environment is misidentified as desktop, but Electron preloader APIs are missing. - Rename `isDesktop` environment identification variable to `isRunningAsDesktopApplication` for better clarity and to avoid confusion with desktop environments in web/browser/test environments. - Simplify `BrowserRuntimeEnvironment` to consistently detect non-desktop application environments. - Improve environment detection for Electron main process (electron/electron#2288).
This commit is contained in:
@@ -1,24 +1,37 @@
|
||||
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
|
||||
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
||||
import { Dialog } from '@/presentation/common/Dialog';
|
||||
import { ElectronDialog } from '@/infrastructure/Dialog/Electron/ElectronDialog';
|
||||
import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel';
|
||||
import { registerIpcChannel } from '../shared/IpcBridging/IpcProxy';
|
||||
import { IpcChannelDefinitions } from '../shared/IpcBridging/IpcChannelDefinitions';
|
||||
import { ChannelDefinitionKey, IpcChannelDefinitions } from '../shared/IpcBridging/IpcChannelDefinitions';
|
||||
|
||||
export function registerAllIpcChannels(
|
||||
createCodeRunner: CodeRunnerFactory = () => new ScriptFileCodeRunner(),
|
||||
registrar: IpcRegistrar = registerIpcChannel,
|
||||
createDialog: DialogFactory = () => new ElectronDialog(),
|
||||
registrar: IpcChannelRegistrar = registerIpcChannel,
|
||||
) {
|
||||
const registrars: Record<keyof typeof IpcChannelDefinitions, () => void> = {
|
||||
CodeRunner: () => registrar(IpcChannelDefinitions.CodeRunner, createCodeRunner()),
|
||||
const ipcInstanceCreators: IpcChannelRegistrars = {
|
||||
CodeRunner: () => createCodeRunner(),
|
||||
Dialog: () => createDialog(),
|
||||
};
|
||||
Object.entries(registrars).forEach(([name, register]) => {
|
||||
Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => {
|
||||
try {
|
||||
register();
|
||||
const definition = IpcChannelDefinitions[name];
|
||||
const instance = instanceFactory();
|
||||
registrar(definition, instance);
|
||||
} catch (err) {
|
||||
throw new AggregateError(`main: Failed to register IPC channel "${name}"`, err);
|
||||
throw new AggregateError([err], `main: Failed to register IPC channel "${name}":\n${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type CodeRunnerFactory = () => CodeRunner;
|
||||
export type DialogFactory = () => Dialog;
|
||||
export type IpcChannelRegistrar = typeof registerIpcChannel;
|
||||
|
||||
export type IpcRegistrar = typeof registerIpcChannel;
|
||||
type RegistrationChannel<T extends ChannelDefinitionKey> = (typeof IpcChannelDefinitions)[T];
|
||||
type ExtractChannelServiceType<T> = T extends IpcChannel<infer U> ? U : never;
|
||||
type IpcChannelRegistrars = {
|
||||
[K in ChannelDefinitionKey]: () => ExtractChannelServiceType<RegistrationChannel<K>>;
|
||||
};
|
||||
|
||||
@@ -12,14 +12,20 @@ export function provideWindowVariables(
|
||||
createApiFacade: ApiFacadeFactory = createSecureFacade,
|
||||
ipcConsumerCreator: IpcConsumerProxyCreator = createIpcConsumerProxy,
|
||||
): WindowVariables {
|
||||
return {
|
||||
isDesktop: true,
|
||||
// Enforces mandatory variable availability at compile time
|
||||
const variables: RequiredWindowVariables = {
|
||||
isRunningAsDesktopApplication: true,
|
||||
log: createApiFacade(createLogger(), ['info', 'debug', 'warn', 'error']),
|
||||
os: convertToOs(process.platform),
|
||||
codeRunner: ipcConsumerCreator(IpcChannelDefinitions.CodeRunner),
|
||||
dialog: ipcConsumerCreator(IpcChannelDefinitions.Dialog),
|
||||
};
|
||||
return variables;
|
||||
}
|
||||
|
||||
type RequiredWindowVariables = PartiallyRequired<WindowVariables, 'os' /* | 'anotherOptionalKey'.. */>;
|
||||
type PartiallyRequired<T, K extends keyof T> = Required<Omit<T, K>> & Pick<T, K>;
|
||||
|
||||
export type LoggerFactory = () => Logger;
|
||||
|
||||
export type ApiFacadeFactory = typeof createSecureFacade;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { FunctionKeys } from '@/TypeHelpers';
|
||||
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
||||
import { Dialog } from '@/presentation/common/Dialog';
|
||||
import { IpcChannel } from './IpcChannel';
|
||||
|
||||
export const IpcChannelDefinitions = {
|
||||
CodeRunner: defineElectronIpcChannel<CodeRunner>('code-run', ['runCode']),
|
||||
Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['saveFile']),
|
||||
} as const;
|
||||
|
||||
export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions;
|
||||
|
||||
function defineElectronIpcChannel<T>(
|
||||
name: string,
|
||||
functionNames: readonly FunctionKeys<T>[],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ipcMain } from 'electron/main';
|
||||
import { ipcRenderer } from 'electron/renderer';
|
||||
import { isFunction } from '@/TypeHelpers';
|
||||
import { FunctionKeys, isFunction } from '@/TypeHelpers';
|
||||
import { IpcChannel } from './IpcChannel';
|
||||
|
||||
export function createIpcConsumerProxy<T>(
|
||||
@@ -25,9 +25,7 @@ export function registerIpcChannel<T>(
|
||||
) {
|
||||
channel.accessibleMembers.forEach((functionKey) => {
|
||||
const originalFunction = originalObject[functionKey];
|
||||
if (!isFunction(originalFunction)) {
|
||||
throw new Error('Non-function members are not yet supported');
|
||||
}
|
||||
validateIpcFunction(functionKey, originalFunction, channel);
|
||||
const ipcChannel = getIpcChannelIdentifier(channel.namespace, functionKey as string);
|
||||
electronIpcMain.handle(ipcChannel, (_event, ...args: unknown[]) => {
|
||||
return originalFunction.apply(originalObject, args);
|
||||
@@ -35,6 +33,28 @@ export function registerIpcChannel<T>(
|
||||
});
|
||||
}
|
||||
|
||||
function validateIpcFunction<T>(
|
||||
functionKey: FunctionKeys<T>,
|
||||
functionValue: T[FunctionKeys<T>],
|
||||
channel: IpcChannel<T>,
|
||||
): asserts functionValue is T[FunctionKeys<T>] & ((...args: unknown[]) => unknown) {
|
||||
const functionName = functionKey.toString();
|
||||
if (functionValue === undefined) {
|
||||
throwErrorWithContext(`The function "${functionName}" is not found on the target object.`);
|
||||
}
|
||||
if (!isFunction(functionValue)) {
|
||||
throwErrorWithContext('Non-function members are not yet supported.');
|
||||
}
|
||||
function throwErrorWithContext(message: string): never {
|
||||
throw new Error([
|
||||
message,
|
||||
`Channel: ${JSON.stringify(channel)}.`,
|
||||
`Function key: ${functionName}.`,
|
||||
`Value: ${JSON.stringify(functionValue)}`,
|
||||
].join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
function getIpcChannelIdentifier(namespace: string, key: string) {
|
||||
return `proxy:${namespace}:${key}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user