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