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

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

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

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,