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

@@ -0,0 +1,53 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { Logger } from '@/application/Common/Log/Logger';
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();
public readonly logger: Logger;
protected constructor(
environment: RuntimeEnvironment = CurrentEnvironment,
windowAccessor: WindowAccessor = () => globalThis.window,
noopLoggerFactory: LoggerCreationFunction = () => new NoopLogger(),
windowInjectedLoggerFactory: LoggerCreationFunction = () => new WindowInjectedLogger(),
consoleLoggerFactory: LoggerCreationFunction = () => new ConsoleLogger(),
) {
if (isUnitOrIntegrationTests(environment, windowAccessor)) {
this.logger = noopLoggerFactory(); // keep the test outputs clean
return;
}
if (environment.isRunningAsDesktopApplication) {
this.logger = windowInjectedLoggerFactory();
return;
}
if (environment.isNonProduction) {
this.logger = consoleLoggerFactory();
return;
}
this.logger = noopLoggerFactory();
}
}
export type WindowAccessor = () => OptionalWindow;
export type LoggerCreationFunction = () => Logger;
type OptionalWindow = Window | undefined | null;
function isUnitOrIntegrationTests(
environment: RuntimeEnvironment,
windowAccessor: WindowAccessor,
): boolean {
/*
In a desktop application context, Electron preloader process inject a logger into
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.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,
};
}