Skip to content

Commit

Permalink
Add a session-interaction event to allow injected js to interact wi…
Browse files Browse the repository at this point in the history
…th apps Electron `session` object (PR nativefier#1132)

As discussed in nativefier#283 this PR will allow injected JS to do SOME interaction with the Electron session.

There is a full explanation of what this feature can, and cannot do, with examples in the api.md documentation.

This will provide a path for resolving many of our issues where users may "self-service" the solution by injecting JS that performs the task needed to meet their objectives.

Co-authored-by: Ronan Jouchet <ronan@jouchet.fr>
  • Loading branch information
TheCleric and ronjouch committed Mar 14, 2021
1 parent 47b12a1 commit 47fcfb7
Show file tree
Hide file tree
Showing 2 changed files with 275 additions and 0 deletions.
83 changes: 83 additions & 0 deletions app/src/components/mainWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path';

import { BrowserWindow, shell, ipcMain, dialog, Event } from 'electron';
import windowStateKeeper from 'electron-window-state';
import log from 'loglevel';

import {
isOSX,
Expand All @@ -20,6 +21,20 @@ import { createMenu } from './menu';
export const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json');
const ZOOM_INTERVAL = 0.1;

type SessionInteractionRequest = {
id?: string;
func?: string;
funcArgs?: any[];
property?: string;
propertyValue?: any;
};

type SessionInteractionResult = {
id?: string;
value?: any;
error?: Error;
};

function hideWindow(
window: BrowserWindow,
event: Event,
Expand Down Expand Up @@ -375,6 +390,74 @@ export function createMainWindow(
mainWindow.show();
});

// See API.md / "Accessing The Electron Session"
ipcMain.on(
'session-interaction',
(event, request: SessionInteractionRequest) => {
log.debug('session-interaction:event', event);
log.debug('session-interaction:request', request);

const result: SessionInteractionResult = { id: request.id };
let awaitingPromise = false;
try {
if (request.func !== undefined) {
// If no funcArgs provided, we'll just use an empty array
if (request.funcArgs === undefined || request.funcArgs === null) {
request.funcArgs = [];
}

// If funcArgs isn't an array, we'll be nice and make it a single item array
if (typeof request.funcArgs[Symbol.iterator] !== 'function') {
request.funcArgs = [request.funcArgs];
}

// Call func with funcArgs
result.value = mainWindow.webContents.session[request.func](
...request.funcArgs,
);

if (
result.value !== undefined &&
typeof result.value['then'] === 'function'
) {
// This is a promise. We'll resolve it here otherwise it will blow up trying to serialize it in the reply
result.value.then((trueResultValue) => {
result.value = trueResultValue;
log.debug('session-interaction:result', result);
event.reply('session-interaction-reply', result);
});
awaitingPromise = true;
}
} else if (request.property !== undefined) {
if (request.propertyValue !== undefined) {
// Set the property
mainWindow.webContents.session[request.property] =
request.propertyValue;
}

// Get the property value
result.value = mainWindow.webContents.session[request.property];
} else {
// Why even send the event if you're going to do this? You're just wasting time! ;)
throw Error(
'Received neither a func nor a property in the request. Unable to process.',
);
}

// If we are awaiting a promise, that will return the reply instead, else
if (!awaitingPromise) {
log.debug('session-interaction:result', result);
event.reply('session-interaction-reply', result);
}
} catch (error) {
log.error('session-interaction:error', error, event, request);
result.error = error;
result.value = undefined; // Clear out the value in case serializing the value is what got us into this mess in the first place
event.reply('session-interaction-reply', result);
}
},
);

mainWindow.webContents.on('new-window', onNewWindow);
mainWindow.webContents.on('will-navigate', onWillNavigate);
mainWindow.webContents.on('did-finish-load', () => {
Expand Down
192 changes: 192 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
- [[win32metadata]](#win32metadata)
- [Programmatic API](#programmatic-api-1)
- [[disable-old-build-warning-yesiknowitisinsecure]](#disable-old-build-warning-yesiknowitisinsecure)
- [Accessing The Electron Session](#accessing-the-electron-session)
- [Important Note On funcArgs](#important-note-on-funcargs)
- [session-interaction-reply](#session-interaction-reply)
- [Errors](#errors)
- [Complex Return Values](#complex-return-values)
- [Example](#example)

## Packaging Squirrel-based installers

Expand Down Expand Up @@ -948,3 +954,189 @@ Disables the warning shown when opening a Nativefier app made a long time ago, u
However, there are legitimate use cases to disable such a warning. For example, if you are using Nativefier to ship a kiosk app exposing an internal site (over which you have control). Under those circumstances, it is reasonable to disable this warning that you definitely don't want end-users to see.

More description about the options for `nativefier` can be found at the above [section](#command-line).

## Accessing The Electron Session

Sometimes there are Electron features that are exposed via the [Electron `session` API](https://www.electronjs.org/docs/api/session), that may not be exposed via Nativefier options. These can be accessed with an injected javascript file (via the `--inject` command line argument when building your application). Within that javascript file, you may send an ipcRenderer `session-interaction` event, and listen for a `session-interaction-reply` event to get any result. Session properties and functions can be accessed via this event. This event takes an object as an argument with the desired interaction to be performed.

**Warning**: using this feature in an `--inject` script means using Electron's `session` API, which is not a standard web API and subject to potential [Breaking Changes](https://www.electronjs.org/docs/breaking-changes) at each major Electron upgrade.

To get a `session` property:

```javascript
const electron = require('electron');

const request = {
property: 'availableSpellCheckerLanguages',
};
electron.ipcRenderer.send('session-interaction', request);
```

To set a `session` property:

```javascript
const electron = require('electron');

const request = {
property: 'spellCheckerEnabled',
propertyValue: true,
};
electron.ipcRenderer.send('session-interaction', request);
```

To call a `session` function:

```javascript
const electron = require('electron');

const request = {
func: 'clearCache',
};
electron.ipcRenderer.send('session-interaction', request);
```

To call a `session` function, with arguments:

```javascript
const electron = require('electron');

const request = {
func: 'setDownloadPath',
funcArgs: [
`/home/user/downloads`,
],
};
electron.ipcRenderer.send('session-interaction', request);
```

If neither a `func` nor a `property` is provided in the event, an error will be returned.

### Important Note On funcArgs

PLEASE NOTE: `funcArgs` is ALWAYS an array of arguments to be passed to the function, even if it is just one argument. If `funcArgs` is omitted from a request with a `func` provided, no arguments will be passed.

### session-interaction-reply

The results of the call, if desired, can be accessed one of two ways. Either you can listen for a `session-interaction-reply` event, and access the resulting value like so:

```javascript
const electron = require('electron');

const request = {
property: 'availableSpellCheckerLanguages',
};
electron.ipcRenderer.send('session-interaction', request);

electon.ipcRenderer.on('session-interaction-reply', (event, result) => {
console.log('session-interaction-reply', event, result.value)
});
```

Or the result can be retrieved synchronously, though this is not recommended as it may cause slowdowns and freezes in your apps while the app stops and waits for the result to be returned. Heed this [warning from Electron](https://www.electronjs.org/docs/api/ipc-renderer):

> ⚠️ WARNING: Sending a synchronous message will block the whole renderer process until the reply is received, so use this method only as a last resort. It's much better to use the asynchronous version.
```javascript
const electron = require('electron');

const request = {
property: 'availableSpellCheckerLanguages',
};
console.log(electron.ipcRenderer.sendSync('session-interaction', request).value);
```

### Request IDs

If desired, an id for the request may be provided to distinguish between event replies:

```javascript
const electron = require('electron');

const request = {
id: 'availableSpellCheckerLanguages',
property: 'availableSpellCheckerLanguages',
};
electron.ipcRenderer.send('session-interaction', request);

electon.ipcRenderer.on('session-interaction-reply', (event, result) => {
console.log('session-interaction-reply', event, result.id, result.value)
});
```

### Errors

If an error occurs while handling the interaction, it will be returned in the `session-interaction-reply` event inside the result:

```javascript
const electron = require('electron');

electron.ipcRenderer.on('session-interaction-reply', (event, result) => {
console.log('session-interaction-reply', event, result.error)
});

electron.ipcRenderer.send('session-interaction', { func: 'thisFunctionDoesNotExist' });
```

### Complex Return Values

Due to the nature of how these events are transmitted back and forth, session functions and properties that return full classes or class instances are not supported.

For example, the following code will return an error instead of the expected value:

```javascript

const electron = require('electron');

const request = {
id: 'cookies',
property: 'cookies',
};
electron.ipcRenderer.send('session-interaction', request);

electon.ipcRenderer.on('session-interaction-reply', (event, result) => {
console.log('session-interaction-reply', event, result)
});
```

### Example

This javascript, when injected as a file via `--inject`, will attempt to call the `isSpellCheckerEnabled` function to make sure the spell checker is enabled, enables it via the `spellCheckerEnabled` property, gets the value of the `availableSpellCheckerLanguages` property, and finally will call `setSpellCheckerLanguages` to set the `fr` language as the preferred spellcheck language if it's supported.

```javascript
const electron = require('electron');

electron.ipcRenderer.on('session-interaction-reply', (event, result) => {
console.log('session-interaction-reply', event, result);
switch (result.id) {
case 'isSpellCheckerEnabled':
console.log('SpellChecker enabled?', result.value);
if (result.value === true) {
console.log("Getting supported languages...");
electron.ipcRenderer.send('session-interaction', { id: 'availableSpellCheckerLanguages', property: 'availableSpellCheckerLanguages', });
} else {
console.log("SpellChecker disabled. Enabling...");
electron.ipcRenderer.send('session-interaction', { id: 'setSpellCheckerEnabled', property: 'spellCheckerEnabled', propertyValue: true, });
}
break;
case 'setSpellCheckerEnabled':
console.log('SpellChecker has now been enabled. Getting supported languages...');
electron.ipcRenderer.send('session-interaction', { id: 'availableSpellCheckerLanguages', property: 'availableSpellCheckerLanguages', });
break;
case 'availableSpellCheckerLanguages':
console.log('Avaliable spellChecker languages:', result.value);
if (result.value.indexOf('fr') > -1) {
electron.ipcRenderer.send('session-interaction', { id: 'setSpellCheckerLanguages', func: 'setSpellCheckerLanguages', funcArgs: [['fr']], });
} else {
console.log("Not changing spellChecker language. 'fr' is not supported.");
}
break;
case 'setSpellCheckerLanguages':
console.log('SpellChecker language was set.');
break;
default:
console.error("Unknown reply id:", result.id);
}
});

electron.ipcRenderer.send('session-interaction', { id: 'isSpellCheckerEnabled', func: 'isSpellCheckerEnabled', });
```

0 comments on commit 47fcfb7

Please sign in to comment.