Show native save dialogs in desktop app #50, #264

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:
undergroundwires
2024-01-13 18:04:23 +01:00
parent da4be500da
commit c546a33eff
65 changed files with 1384 additions and 345 deletions

View File

@@ -11,9 +11,10 @@ import {
} from '@/presentation/injectionSymbols';
import { PropertyKeys } from '@/TypeHelpers';
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
import { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger';
import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { useDialog } from '@/presentation/components/Shared/Hooks/Dialog/UseDialog';
export function provideDependencies(
context: IApplicationContext,
@@ -67,6 +68,10 @@ export function provideDependencies(
InjectionKeys.useCodeRunner,
useCodeRunner,
),
useDialog: (di) => di.provide(
InjectionKeys.useDialog,
useDialog,
),
};
registerAll(Object.values(resolvers), api);
}

View File

@@ -1,6 +1,6 @@
import { Logger } from '@/application/Common/Log/Logger';
import { ClientLoggerFactory } from '@/presentation/components/Shared/Hooks/Log/ClientLoggerFactory';
import { Bootstrapper } from '../Bootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements Bootstrapper {
constructor(

View File

@@ -0,0 +1,8 @@
export interface Dialog {
saveFile(fileContents: string, fileName: string, type: FileType): Promise<void>;
}
export enum FileType {
BatchFile,
ShellScript,
}

View File

@@ -19,10 +19,14 @@ export default defineComponent({
},
setup() {
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
const { os, isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
const canRun = computed<boolean>(() => getCanRunState(
currentState.value.os,
isRunningAsDesktopApplication,
os,
));
async function executeCode() {
if (!codeRunner) { throw new Error('missing code runner'); }
@@ -33,7 +37,6 @@ export default defineComponent({
}
return {
isDesktopVersion: isDesktop,
canRun,
executeCode,
};
@@ -42,10 +45,10 @@ export default defineComponent({
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
isRunningAsDesktopApplication: boolean,
hostOs: OperatingSystem | undefined,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
return isRunningAsDesktopApplication && isRunningOnSelectedOs;
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
:text="isRunningAsDesktopApplication ? 'Save' : 'Download'"
:icon-name="isRunningAsDesktopApplication ? 'floppy-disk' : 'file-arrow-down'"
@click="saveCode"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
@@ -16,12 +16,11 @@ import {
defineComponent, ref, computed,
} from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
import { FileType } from '@/presentation/common/Dialog';
import IconButton from '../IconButton.vue';
import InstructionList from './Instructions/InstructionList.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
@@ -35,7 +34,8 @@ export default defineComponent({
},
setup() {
const { currentState } = injectKey((keys) => keys.useCollectionState);
const { isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const { dialog } = injectKey((keys) => keys.useDialog);
const areInstructionsVisible = ref(false);
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
@@ -44,13 +44,17 @@ export default defineComponent({
fileName.value,
));
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
async function saveCode() {
await dialog.saveFile(
currentState.value.code.current,
fileName.value,
getType(currentState.value.collection.scripting.language),
);
areInstructionsVisible.value = true;
}
return {
isDesktopVersion: isDesktop,
isRunningAsDesktopApplication,
instructions,
fileName,
areInstructionsVisible,
@@ -59,12 +63,6 @@ export default defineComponent({
},
});
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:

View File

@@ -188,6 +188,7 @@ function getDefaultCode(language: ScriptingLanguage): string {
.appendCommentLine(' ✔️ No need to run any compiled software on your system, just run the generated scripts.')
.appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.')
.appendCommentLine(' ✔️ Open-source and free (both free as in beer and free as in speech).')
.appendCommentLine(' ✔️ Committed to your safety with strong security measures.')
.toString();
}

View File

@@ -0,0 +1,25 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { Dialog } from '@/presentation/common/Dialog';
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
export function determineDialogBasedOnEnvironment(
environment: RuntimeEnvironment,
windowInjectedDialogFactory: WindowDialogCreationFunction = () => globalThis.window.dialog,
browserDialogFactory: BrowserDialogCreationFunction = () => new BrowserDialog(),
): Dialog {
if (!environment.isRunningAsDesktopApplication) {
return browserDialogFactory();
}
const dialog = windowInjectedDialogFactory();
if (!dialog) {
throw new Error([
'The Dialog API could not be retrieved from the window object.',
'This may indicate that the Dialog API is either not implemented or not correctly exposed in the current desktop environment.',
].join('\n'));
}
return dialog;
}
export type WindowDialogCreationFunction = () => Dialog | undefined;
export type BrowserDialogCreationFunction = () => Dialog;

View File

@@ -0,0 +1,14 @@
import { Dialog } from '@/presentation/common/Dialog';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { determineDialogBasedOnEnvironment } from './ClientDialogFactory';
export function useDialog(
factory: DialogFactory = () => determineDialogBasedOnEnvironment(CurrentEnvironment),
) {
const dialog = factory();
return {
dialog,
};
}
export type DialogFactory = () => Dialog;

View File

@@ -1,10 +1,10 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { LoggerFactory } from './LoggerFactory';
export class ClientLoggerFactory implements LoggerFactory {
public static readonly Current: LoggerFactory = new ClientLoggerFactory();
@@ -22,7 +22,7 @@ export class ClientLoggerFactory implements LoggerFactory {
this.logger = noopLoggerFactory(); // keep the test outputs clean
return;
}
if (environment.isDesktop) {
if (environment.isRunningAsDesktopApplication) {
this.logger = windowInjectedLoggerFactory();
return;
}
@@ -49,5 +49,5 @@ function isUnitOrIntegrationTests(
the global window object. If we're in a desktop (Node) environment and the logger isn't
injected, it indicates a testing environment.
*/
return environment.isDesktop && !windowAccessor()?.log;
return environment.isRunningAsDesktopApplication && !windowAccessor()?.log;
}

View File

@@ -0,0 +1,5 @@
import { Logger } from '@/application/Common/Log/Logger';
export interface LoggerFactory {
readonly logger: Logger;
}

View File

@@ -0,0 +1,8 @@
import { ClientLoggerFactory } from './ClientLoggerFactory';
import { LoggerFactory } from './LoggerFactory';
export function useLogger(factory: LoggerFactory = ClientLoggerFactory.Current) {
return {
log: factory.logger,
};
}

View File

@@ -1,8 +0,0 @@
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { ClientLoggerFactory } from '@/presentation/bootstrapping/ClientLoggerFactory';
export function useLogger(factory: LoggerFactory = ClientLoggerFactory.Current) {
return {
log: factory.logger,
};
}

View File

@@ -1,12 +1,12 @@
<template>
<div class="privacy-policy">
<div v-if="!isDesktop" class="line">
<div v-if="!isRunningAsDesktopApplication" class="line">
<div class="line__emoji">
🚫🍪
</div>
<div>No cookies!</div>
</div>
<div v-if="isDesktop" class="line">
<div v-if="isRunningAsDesktopApplication" class="line">
<div class="line__emoji">
🚫🌐
</div>
@@ -30,7 +30,7 @@
of the <a :href="repositoryUrl" target="_blank" rel="noopener noreferrer">source code</a> with no changes.
</div>
</div>
<div v-if="!isDesktop" class="line">
<div v-if="!isRunningAsDesktopApplication" class="line">
<div class="line__emoji">
📈
</div>
@@ -60,7 +60,7 @@ import { injectKey } from '@/presentation/injectionSymbols';
export default defineComponent({
setup() {
const { info } = injectKey((keys) => keys.useApplication);
const { isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const repositoryUrl = computed<string>(() => info.repositoryUrl);
const feedbackUrl = computed<string>(() => info.feedbackUrl);
@@ -68,7 +68,7 @@ export default defineComponent({
return {
repositoryUrl,
feedbackUrl,
isDesktop,
isRunningAsDesktopApplication,
};
},
});

View File

@@ -2,7 +2,7 @@
<div>
<div class="footer">
<div class="footer__section">
<span v-if="isDesktop" class="footer__section__item">
<span v-if="isRunningAsDesktopApplication" class="footer__section__item">
<AppIcon class="icon" icon="globe" />
<span>
Online version at <a :href="homepageUrl" target="_blank" rel="noopener noreferrer">{{ homepageUrl }}</a>
@@ -68,7 +68,7 @@ export default defineComponent({
},
setup() {
const { info } = injectKey((keys) => keys.useApplication);
const { isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const isPrivacyDialogVisible = ref(false);
@@ -87,7 +87,7 @@ export default defineComponent({
}
return {
isDesktop,
isRunningAsDesktopApplication,
isPrivacyDialogVisible,
showPrivacyDialog,
version,

View File

@@ -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>>;
};

View File

@@ -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;

View File

@@ -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>[],

View File

@@ -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}`;
}

View File

@@ -6,8 +6,9 @@ import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipbo
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
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 { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger';
import type { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner';
import type { useDialog } from './components/Shared/Hooks/Dialog/UseDialog';
export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
@@ -19,6 +20,7 @@ export const InjectionKeys = {
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
useCodeRunner: defineTransientKey<ReturnType<typeof useCodeRunner>>('useCodeRunner'),
useDialog: defineTransientKey<ReturnType<typeof useDialog>>('useDialog'),
};
export interface InjectionKeyWithLifetime<T> {