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:
19
src/infrastructure/Dialog/Browser/BrowserDialog.ts
Normal file
19
src/infrastructure/Dialog/Browser/BrowserDialog.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
|
||||
export interface BrowserSaveFileDialog {
|
||||
saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
fileType: FileType,
|
||||
): void;
|
||||
}
|
||||
39
src/infrastructure/Dialog/Browser/FileSaverDialog.ts
Normal file
39
src/infrastructure/Dialog/Browser/FileSaverDialog.ts
Normal 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;
|
||||
17
src/infrastructure/Dialog/Electron/ElectronDialog.ts
Normal file
17
src/infrastructure/Dialog/Electron/ElectronDialog.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
|
||||
export interface ElectronSaveFileDialog {
|
||||
saveFile(
|
||||
fileContents: string,
|
||||
fileName: string,
|
||||
type: FileType,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user