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

@@ -0,0 +1,19 @@
import { Dialog, FileType } from '@/presentation/common/Dialog';
import { FileSaverDialog } from './FileSaverDialog';
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
export class BrowserDialog implements Dialog {
constructor(private readonly saveFileDialog: BrowserSaveFileDialog = new FileSaverDialog()) {
}
public saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void> {
return Promise.resolve(
this.saveFileDialog.saveFile(fileContents, fileName, type),
);
}
}

View File

@@ -0,0 +1,9 @@
import { FileType } from '@/presentation/common/Dialog';
export interface BrowserSaveFileDialog {
saveFile(
fileContents: string,
fileName: string,
fileType: FileType,
): void;
}

View File

@@ -0,0 +1,39 @@
import fileSaver from 'file-saver';
import { FileType } from '@/presentation/common/Dialog';
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
export type SaveAsFunction = (data: Blob, filename?: string) => void;
export type WindowOpenFunction = (url: string, target: string, features: string) => void;
export class FileSaverDialog implements BrowserSaveFileDialog {
constructor(
private readonly fileSaverSaveAs: SaveAsFunction = fileSaver.saveAs,
private readonly windowOpen: WindowOpenFunction = window.open.bind(window),
) { }
public saveFile(
fileContents: string,
fileName: string,
fileType: FileType,
): void {
const mimeType = MimeTypes[fileType];
this.saveBlob(fileContents, mimeType, fileName);
}
private saveBlob(file: BlobPart, mimeType: string, fileName: string): void {
try {
const blob = new Blob([file], { type: mimeType });
this.fileSaverSaveAs(blob, fileName);
} catch (e) {
this.windowOpen(`data:${mimeType},${encodeURIComponent(file.toString())}`, '_blank', '');
}
}
}
const MimeTypes: Record<FileType, string> = {
// Some browsers (including firefox + IE) require right mime type
// otherwise they ignore extension and save the file as text.
[FileType.BatchFile]: 'application/bat', // https://en.wikipedia.org/wiki/Batch_file
[FileType.ShellScript]: 'text/x-shellscript', // https://de.wikipedia.org/wiki/Shellskript#MIME-Typ
} as const;

View File

@@ -0,0 +1,17 @@
import { Dialog, FileType } from '@/presentation/common/Dialog';
import { NodeElectronSaveFileDialog } from './NodeElectronSaveFileDialog';
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class ElectronDialog implements Dialog {
constructor(
private readonly fileSaveDialog: ElectronSaveFileDialog = new NodeElectronSaveFileDialog(),
) { }
public async saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void> {
await this.fileSaveDialog.saveFile(fileContents, fileName, type);
}
}

View File

@@ -0,0 +1,9 @@
import { FileType } from '@/presentation/common/Dialog';
export interface ElectronSaveFileDialog {
saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void>;
}

View File

@@ -0,0 +1,98 @@
import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { app, dialog } from 'electron/main';
import { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { FileType } from '@/presentation/common/Dialog';
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export interface ElectronFileDialogOperations {
getUserDownloadsPath(): string;
showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue>;
}
export interface NodeFileOperations {
readonly join: typeof join;
writeFile(file: string, data: string): Promise<void>;
}
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
constructor(
private readonly logger: Logger = ElectronLogger,
private readonly electron: ElectronFileDialogOperations = {
getUserDownloadsPath: () => app.getPath('downloads'),
showSaveDialog: dialog.showSaveDialog.bind(dialog),
},
private readonly node: NodeFileOperations = {
join,
writeFile,
},
) { }
public async saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void> {
const userSelectedFilePath = await this.showSaveFileDialog(fileName, type);
if (!userSelectedFilePath) {
this.logger.info(`File save cancelled by user: ${fileName}`);
return;
}
await this.writeFile(userSelectedFilePath, fileContents);
}
private async writeFile(filePath: string, fileContents: string): Promise<void> {
try {
this.logger.info(`Saving file: ${filePath}`);
await this.node.writeFile(filePath, fileContents);
this.logger.info(`File saved: ${filePath}`);
} catch (error) {
this.logger.error(`Error saving file: ${error.message}`);
}
}
private async showSaveFileDialog(fileName: string, type: FileType): Promise<string | undefined> {
const downloadsFolder = this.electron.getUserDownloadsPath();
const defaultFilePath = this.node.join(downloadsFolder, fileName);
const dialogResult = await this.electron.showSaveDialog({
title: fileName,
defaultPath: defaultFilePath,
filters: getDialogFileFilters(type),
properties: [
'createDirectory', // Enables directory creation on macOS.
'showOverwriteConfirmation', // Shows overwrite confirmation on Linux.
],
});
if (dialogResult.canceled) {
return undefined;
}
return dialogResult.filePath;
}
}
function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] {
const filters = FileTypeSpecificFilters[fileType];
return [
...filters,
{
name: 'All Files',
extensions: ['*'],
},
];
}
const FileTypeSpecificFilters: Record<FileType, Electron.FileFilter[]> = {
[FileType.BatchFile]: [
{
name: 'Batch Files',
extensions: ['bat', 'cmd'],
},
],
[FileType.ShellScript]: [
{
name: 'Shell Scripts',
extensions: ['sh', 'bash', 'zsh'],
},
],
};

View File

@@ -84,4 +84,23 @@ export const BrowserConditions: readonly BrowserCondition[] = [
notExistingPartsInUserAgent: ['like Mac OS X'], // Eliminate iOS and iPadOS for Safari
touchSupport: TouchSupportExpectation.MustNotExist, // Distinguish from iPadOS for Safari
},
...generateJsdomBrowserConditions(),
] as const;
function generateJsdomBrowserConditions(): readonly BrowserCondition[] {
// jsdom user agent format: `Mozilla/5.0 (${process.platform || "unknown OS"}) ...` (https://archive.ph/2023.02.14-193200/https://github.com/jsdom/jsdom#advanced-configuration)
const operatingSystemPlatformMap: Partial<Record<
OperatingSystem,
NodeJS.Platform> // Enforce right platform constants at compile time
> = {
[OperatingSystem.Linux]: 'linux',
[OperatingSystem.Windows]: 'win32',
[OperatingSystem.macOS]: 'darwin',
} as const;
return Object
.entries(operatingSystemPlatformMap)
.map(([operatingSystemKey, platformString]): BrowserCondition => ({
operatingSystem: Number(operatingSystemKey),
existingPartsInSameUserAgent: ['jsdom', platformString],
}));
}

View File

@@ -1,5 +1,4 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { RuntimeEnvironment } from '../RuntimeEnvironment';
@@ -8,7 +7,7 @@ import { BrowserEnvironment, BrowserOsDetector } from './BrowserOs/BrowserOsDete
import { isTouchEnabledDevice } from './TouchSupportDetection';
export class BrowserRuntimeEnvironment implements RuntimeEnvironment {
public readonly isDesktop: boolean;
public readonly isRunningAsDesktopApplication: boolean;
public readonly os: OperatingSystem | undefined;
@@ -18,31 +17,34 @@ export class BrowserRuntimeEnvironment implements RuntimeEnvironment {
window: Partial<Window>,
environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance,
browserOsDetector: BrowserOsDetector = new ConditionBasedOsDetector(),
touchDetector = isTouchEnabledDevice,
touchDetector: TouchDetector = isTouchEnabledDevice,
) {
if (!window) { throw new Error('missing window'); } // do not trust strictNullChecks for global objects
this.isNonProduction = environmentVariables.isNonProduction;
this.isDesktop = isDesktop(window);
if (this.isDesktop) {
this.os = window?.os;
} else {
this.os = undefined;
const userAgent = getUserAgent(window);
if (userAgent) {
const browserEnvironment: BrowserEnvironment = {
userAgent,
isTouchSupported: touchDetector(),
};
this.os = browserOsDetector.detect(browserEnvironment);
}
}
this.isRunningAsDesktopApplication = isElectronRendererProcess(window);
this.os = determineOperatingSystem(window, touchDetector, browserOsDetector);
}
}
function getUserAgent(window: Partial<Window>): string | undefined {
return window?.navigator?.userAgent;
function isElectronRendererProcess(globalWindow: Partial<Window>): boolean {
return globalWindow.isRunningAsDesktopApplication === true; // Preloader injects this
// We could also do `globalWindow?.navigator?.userAgent?.includes('Electron') === true;`
}
function isDesktop(window: Partial<WindowVariables>): boolean {
return window?.isDesktop === true;
function determineOperatingSystem(
globalWindow: Partial<Window>,
touchDetector: TouchDetector,
browserOsDetector: BrowserOsDetector,
): OperatingSystem | undefined {
const userAgent = globalWindow?.navigator?.userAgent;
if (!userAgent) {
return undefined;
}
const browserEnvironment: BrowserEnvironment = {
userAgent,
isTouchSupported: touchDetector(),
};
return browserOsDetector.detect(browserEnvironment);
}
type TouchDetector = () => boolean;

View File

@@ -3,7 +3,7 @@ import { RuntimeEnvironment } from '../RuntimeEnvironment';
import { convertPlatformToOs } from './NodeOsMapper';
export class NodeRuntimeEnvironment implements RuntimeEnvironment {
public readonly isDesktop: boolean;
public readonly isRunningAsDesktopApplication: boolean;
public readonly os: OperatingSystem | undefined;
@@ -14,7 +14,7 @@ export class NodeRuntimeEnvironment implements RuntimeEnvironment {
convertToOs: PlatformToOperatingSystemConverter = convertPlatformToOs,
) {
if (!nodeProcess) { throw new Error('missing process'); } // do not trust strictNullChecks for global objects
this.isDesktop = true;
this.isRunningAsDesktopApplication = true;
this.os = convertToOs(nodeProcess.platform);
this.isNonProduction = nodeProcess.env.NODE_ENV !== 'production'; // populated by Vite
}

View File

@@ -1,7 +1,7 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface RuntimeEnvironment {
readonly isDesktop: boolean;
readonly isRunningAsDesktopApplication: boolean;
readonly os: OperatingSystem | undefined;
readonly isNonProduction: boolean;
}

View File

@@ -3,32 +3,47 @@ import { NodeRuntimeEnvironment } from './Node/NodeRuntimeEnvironment';
import { RuntimeEnvironment } from './RuntimeEnvironment';
export const CurrentEnvironment = determineAndCreateRuntimeEnvironment({
getGlobalWindow: () => globalThis.window,
getGlobalProcess: () => globalThis.process,
window: globalThis.window,
process: globalThis.process,
});
export function determineAndCreateRuntimeEnvironment(
globalAccessor: GlobalAccessor,
globalAccessor: GlobalPropertiesAccessor,
browserEnvironmentFactory: BrowserRuntimeEnvironmentFactory = (
window,
) => new BrowserRuntimeEnvironment(window),
nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = () => new NodeRuntimeEnvironment(),
nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = (
process: NodeJS.Process,
) => new NodeRuntimeEnvironment(process),
): RuntimeEnvironment {
if (globalAccessor.getGlobalProcess()) {
return nodeEnvironmentFactory();
if (isElectronMainProcess(globalAccessor.process)) {
return nodeEnvironmentFactory(globalAccessor.process);
}
const window = globalAccessor.getGlobalWindow();
if (window) {
return browserEnvironmentFactory(window);
const { window } = globalAccessor;
if (!window) {
throw new Error('Unsupported runtime environment: The current context is neither a recognized browser nor a desktop environment.');
}
throw new Error('Unsupported runtime environment: The current context is neither a recognized browser nor a Node.js 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;
}
export type BrowserRuntimeEnvironmentFactory = (window: Window) => RuntimeEnvironment;
export type NodeRuntimeEnvironmentFactory = () => NodeRuntimeEnvironment;
export type NodeRuntimeEnvironmentFactory = (process: NodeJS.Process) => NodeRuntimeEnvironment;
export interface GlobalAccessor {
getGlobalWindow(): Window | undefined;
getGlobalProcess(): NodeJS.Process | undefined;
export interface GlobalPropertiesAccessor {
readonly window: Window | undefined;
readonly process: NodeJS.Process | undefined;
}

View File

@@ -1,33 +0,0 @@
import fileSaver from 'file-saver';
export enum FileType {
BatchFile,
ShellScript,
}
export class SaveFileDialog {
public static saveFile(
text: string,
fileName: string,
type: FileType,
): void {
const mimeType = this.mimeTypes[type];
this.saveBlob(text, mimeType, fileName);
}
private static readonly mimeTypes: Record<FileType, string> = {
// Some browsers (including firefox + IE) require right mime type
// otherwise they ignore extension and save the file as text.
[FileType.BatchFile]: 'application/bat', // https://en.wikipedia.org/wiki/Batch_file
[FileType.ShellScript]: 'text/x-shellscript', // https://de.wikipedia.org/wiki/Shellskript#MIME-Typ
};
private static saveBlob(file: BlobPart, fileType: string, fileName: string): void {
try {
const blob = new Blob([file], { type: fileType });
fileSaver.saveAs(blob, fileName);
} catch (e) {
window.open(`data:${fileType},${encodeURIComponent(file.toString())}`, '_blank', '');
}
}
}

View File

@@ -1,11 +1,13 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Logger } from '@/application/Common/Log/Logger';
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog';
/* Primary entry point for platform-specific injections */
export interface WindowVariables {
readonly isDesktop: boolean;
readonly isRunningAsDesktopApplication?: true;
readonly codeRunner?: CodeRunner;
readonly os?: OperatingSystem;
readonly log: Logger;
readonly log?: Logger;
readonly dialog?: Dialog;
}

View File

@@ -18,13 +18,14 @@ export function validateWindowVariables(variables: Partial<WindowVariables>) {
}
function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<string> {
const tests: {
[K in PropertyKeys<Required<WindowVariables>>]: boolean;
} = {
const tests: Record<PropertyKeys<Required<WindowVariables>>, boolean> = {
os: testOperatingSystem(variables.os),
isDesktop: testIsDesktop(variables.isDesktop),
isRunningAsDesktopApplication: testIsRunningAsDesktopApplication(
variables.isRunningAsDesktopApplication,
),
codeRunner: testCodeRunner(variables),
log: testLogger(variables),
dialog: testDialog(variables),
};
for (const [propertyName, testResult] of Object.entries(tests)) {
@@ -48,23 +49,30 @@ function testOperatingSystem(os: unknown): boolean {
}
function testLogger(variables: Partial<WindowVariables>): boolean {
if (!variables.isDesktop) {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.log);
}
function testCodeRunner(variables: Partial<WindowVariables>): boolean {
if (!variables.isDesktop) {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.codeRunner)
&& isFunction(variables.codeRunner.runCode);
}
function testIsDesktop(isDesktop: unknown): boolean {
if (isDesktop === undefined) {
function testIsRunningAsDesktopApplication(isRunningAsDesktopApplication: unknown): boolean {
if (isRunningAsDesktopApplication === undefined) {
return true;
}
return isBoolean(isDesktop);
return isBoolean(isRunningAsDesktopApplication);
}
function testDialog(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.dialog);
}

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,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> {