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

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