Skip to content

Commit

Permalink
Provide a way for extensions to start other processes (#1093)
Browse files Browse the repository at this point in the history
  • Loading branch information
lyonsil authored Aug 27, 2024
1 parent 66b7662 commit dbdec16
Show file tree
Hide file tree
Showing 9 changed files with 325 additions and 5 deletions.
6 changes: 3 additions & 3 deletions extensions/src/platform-scripture/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ class InventoryWebViewProvider implements IWebViewProvider {

// We know that the projectId (if present in the state) will be a string.
const projectId =
getWebViewOptions.projectId ||
// eslint-disable-next-line no-type-assertion/no-type-assertion
(savedWebView.state?.projectId as string) ||
(getWebViewOptions.projectId ??
// eslint-disable-next-line no-type-assertion/no-type-assertion
(savedWebView.state?.projectId as string)) ||
undefined;

const title: string = await papi.localization.getLocalizedString({
Expand Down
113 changes: 112 additions & 1 deletion lib/papi-dts/papi.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/// <reference types="react" />
/// <reference types="node" />
/// <reference types="node" />
/// <reference types="node" />
/// <reference types="node" />
declare module 'shared/models/web-view.model' {
/** The type of code that defines a webview's content */
export enum WebViewContentType {
Expand Down Expand Up @@ -4475,11 +4477,19 @@ declare module 'node/utils/crypto-util' {
buffer: Buffer,
): string;
}
declare module 'shared/models/extension-basic-data.model' {
/** Represents an object that contains the most basic information about an extension */
export type ExtensionBasicData = {
/** Name of an extension */
name: string;
};
}
declare module 'node/models/execution-token.model' {
import { ExtensionBasicData } from 'shared/models/extension-basic-data.model';
/** For now this is just for extensions, but maybe we will want to expand this in the future */
export type ExecutionTokenType = 'extension';
/** Execution tokens can be passed into API calls to provide context about their identity */
export class ExecutionToken {
export class ExecutionToken implements ExtensionBasicData {
readonly type: ExecutionTokenType;
readonly name: string;
readonly nonce: string;
Expand Down Expand Up @@ -4831,6 +4841,103 @@ declare module 'shared/services/dialog.service' {
const dialogService: DialogService;
export default dialogService;
}
declare module 'shared/models/create-process-privilege.model' {
import {
ChildProcess,
ChildProcessByStdio,
ForkOptions,
SpawnOptionsWithStdioTuple,
StdioPipe,
} from 'child_process';
import { Readable, Writable } from 'stream';
import { ExtensionBasicData } from 'shared/models/extension-basic-data.model';
/**
* Run {@link spawn} to create a child process. The platform will automatically kill all child
* processes created this way in packaged builds. Child processes are not killed when running in
* development.
*
* @example The following example assumes there are subdirectories in the extension's files for
* win32, linux, and macOS that include appropriate executables.
*
* ```@typescript
* export async function activate(context: ExecutionActivationContext) {
* const { executionToken } = context;
* const { createProcess } = context.elevatedPrivileges;
* if (!createProcess)
* throw new Error('Forgot to add "createProcess" to "elevatedPrivileges" in manifest.json');
* switch (createProcess.osData.platform) {
* case 'win32':
* createProcess.spawn(executionToken, 'win32/RunMe.exe', [], { stdio: [null, null, null] });
* break;
* case 'linux':
* createProcess.spawn(executionToken, 'linux/runMe', [], { stdio: [null, null, null] });
* break;
* case 'darwin':
* createProcess.spawn(executionToken, 'macOS/runMe', [], { stdio: [null, null, null] });
* break;
* default:
* throw new Error(`Unsupported platform: ${createProcess.osData.platform}`);
* }
* ```
*
* @param executionToken ExecutionToken object provided when an extension was activated
* @param command Command to run to start the process
* @param args Arguments to pass to the command
* @param options Options to pass to `spawn`. The `cwd` option will be overridden to the extension's
* root directory.
* @returns A {@link ChildProcessByStdio} object representing the command
*/
export type PlatformSpawn = (
executionToken: ExtensionBasicData,
command: string,
args: readonly string[],
options: SpawnOptionsWithStdioTuple<StdioPipe, StdioPipe, StdioPipe>,
) => ChildProcessByStdio<Writable, Readable, Readable>;
/**
* Run {@link fork} to create a child process. The platform will automatically kill all child
* processes created this way in packaged builds. Child processes are not killed when running in
* development.
*
* @example The following example assumes there is a file named `childProcess.js` in the extension's
* `assets` subdirectory
*
* ```@typescript
* export async function activate(context: ExecutionActivationContext) {
* const { executionToken } = context;
* const { createProcess } = context.elevatedPrivileges;
* if (!createProcess)
* throw new Error('Forgot to add "createProcess" to "elevatedPrivileges" in manifest.json');
* createProcess.fork(executionToken, 'assets/childProcess.js');
* ```
*
* @param executionToken ExecutionToken object provided when an extension was activated
* @param modulePath The module to run in the child
* @param args Arguments to pass when creating the node process
* @param options Options to pass to `fork`. The `cwd` option will be overridden to the extension's
* root directory.
* @returns A {@link ChildProcess} object representing the process running the module
*/
export type PlatformFork = (
executionToken: ExtensionBasicData,
modulePath: string,
args?: readonly string[],
options?: ForkOptions,
) => ChildProcess;
/** Data about the operating system on which this process is running */
export type OperatingSystemData = {
/** Value of `os.platform()` in Node */
platform: string;
/** Value of `os.type()` in Node */
type: string;
/** Value of `os.release()` in Node */
release: string;
};
export type CreateProcess = {
spawn: PlatformSpawn;
fork: PlatformFork;
osData: OperatingSystemData;
};
}
declare module 'shared/models/manage-extensions-privilege.model' {
/** Base64 encoded hash values */
export type HashValues = Partial<{
Expand Down Expand Up @@ -4913,13 +5020,17 @@ declare module 'shared/models/manage-extensions-privilege.model' {
};
}
declare module 'shared/models/elevated-privileges.model' {
import { CreateProcess } from 'shared/models/create-process-privilege.model';
import { ManageExtensions } from 'shared/models/manage-extensions-privilege.model';
/** String constants that are listed in an extension's manifest.json to state needed privileges */
export enum ElevatedPrivilegeNames {
createProcess = 'createProcess',
manageExtensions = 'manageExtensions',
}
/** Object that contains properties with special capabilities for extensions that required them */
export type ElevatedPrivileges = {
/** Functions that can be run to start new processes */
createProcess: CreateProcess | undefined;
/** Functions that can be run to manage what extensions are running */
manageExtensions: ManageExtensions | undefined;
};
Expand Down
6 changes: 6 additions & 0 deletions src/extension-host/extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { initialize as initializeSettingsService } from '@extension-host/service
import { startProjectSettingsService } from '@extension-host/services/project-settings.service-host';
import { initialize as initializeLocalizationService } from '@extension-host/services/localization.service-host';
import { gracefulShutdownMessage } from '@node/models/interprocess-messages.model';
import { killChildProcessesFromExtensions } from './services/create-process.service';

logger.info(
`Starting extension-host${globalThis.isNoisyDevModeEnabled ? ' in noisy dev mode' : ''}`,
Expand All @@ -29,6 +30,11 @@ process.on('message', (message) => {
}
});

// Try to kill child processes that extensions created
process.on('exit', () => {
killChildProcessesFromExtensions();
});

// #region Services setup

(async () => {
Expand Down
71 changes: 71 additions & 0 deletions src/extension-host/services/create-process.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { logger } from '@extension-host/services/papi-backend.service';
import { PlatformFork, PlatformSpawn } from '@shared/models/create-process-privilege.model';
import {
ChildProcess,
ChildProcessByStdio,
fork,
ForkOptions,
spawn,
SpawnOptionsWithStdioTuple,
StdioPipe,
} from 'child_process';
import { Readable, Writable } from 'stream';
import { buildExtensionPathFromName } from '@extension-host/services/extension-storage.service';
import { getPathFromUri } from '@node/utils/util';
import { ExtensionBasicData } from '@shared/models/extension-basic-data.model';
import executionTokenService from '@node/services/execution-token.service';
import { ExecutionToken } from '@node/models/execution-token.model';

const childProcesses: ChildProcess[] = [];

export const wrappedSpawn: PlatformSpawn = (
executionToken: ExtensionBasicData,
command: string,
args: readonly string[],
options: SpawnOptionsWithStdioTuple<StdioPipe, StdioPipe, StdioPipe>,
): ChildProcessByStdio<Writable, Readable, Readable> => {
// Can't specify the argument as ExecutionToken because it would create cyclical type dependencies
// eslint-disable-next-line no-type-assertion/no-type-assertion
if (!executionTokenService.tokenIsValid(executionToken as ExecutionToken))
throw new Error('Invalid execution token');
const extensionRootUri = buildExtensionPathFromName(executionToken.name, '/');
const extensionRootPath = getPathFromUri(extensionRootUri);
options.cwd = extensionRootPath;
const childProcess = spawn(command, args, options);
childProcesses.push(childProcess);
return childProcess;
};

export const wrappedFork: PlatformFork = (
executionToken: ExtensionBasicData,
modulePath: string,
args?: readonly string[],
options?: ForkOptions,
): ChildProcess => {
// Can't specify the argument as ExecutionToken because it would create cyclical type dependencies
// eslint-disable-next-line no-type-assertion/no-type-assertion
if (!executionTokenService.tokenIsValid(executionToken as ExecutionToken))
throw new Error('Invalid execution token');
const extensionRootUri = buildExtensionPathFromName(executionToken.name, '/');
const extensionRootPath = getPathFromUri(extensionRootUri);
if (options) options.cwd = extensionRootPath;
const childProcess = fork(modulePath, args, options ?? { cwd: extensionRootPath });
childProcesses.push(childProcess);
return childProcess;
};

/** Hard kills all child processes that were created by {@link wrappedSpawn} and {@link wrappedFork} */
export const killChildProcessesFromExtensions = () => {
childProcesses.forEach((process) => {
// Need an explicit 'null' check here since non-null (including 0) has a different meaning
// Non-null means the process is no longer running
// eslint-disable-next-line no-null/no-null
if (process.exitCode !== null) return;

// On POSIX systems, SIGKILL should immediately terminate the process by the OS.
// On Windows the signal is ignored. Node.js tries to hard kill the process in some other way.
const processInfo = `child process '${process.spawnfile}' (PID ${process.pid})`;
if (!process.kill('SIGKILL')) logger.warn(`Could not send kill signal to ${processInfo}`);
else logger.info(`Sent kill signal to ${processInfo}`);
});
};
17 changes: 17 additions & 0 deletions src/extension-host/services/extension.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ import {
InstalledExtensions,
ManageExtensions,
} from '@shared/models/manage-extensions-privilege.model';
import { CreateProcess } from '@shared/models/create-process-privilege.model';
import { wrappedFork, wrappedSpawn } from '@extension-host/services/create-process.service';
import os from 'os';

/**
* The way to use `require` directly - provided by webpack because they overwrite normal `require`.
Expand Down Expand Up @@ -787,8 +790,22 @@ async function getInstalledExtensions(): Promise<InstalledExtensions> {

function prepareElevatedPrivileges(manifest: ExtensionManifest): Readonly<ElevatedPrivileges> {
const retVal: ElevatedPrivileges = {
createProcess: undefined,
manageExtensions: undefined,
};
if (manifest.elevatedPrivileges?.find((p) => p === ElevatedPrivilegeNames.createProcess)) {
const createProcess: CreateProcess = {
fork: wrappedFork,
spawn: wrappedSpawn,
osData: {
platform: os.platform(),
type: os.type(),
release: os.release(),
},
};
Object.freeze(createProcess);
retVal.createProcess = createProcess;
}
if (manifest.elevatedPrivileges?.find((p) => p === ElevatedPrivilegeNames.manageExtensions)) {
const manageExtensions: ManageExtensions = {
installExtension,
Expand Down
3 changes: 2 additions & 1 deletion src/node/models/execution-token.model.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import crypto from 'crypto';
import { createNonce } from '@node/utils/crypto-util';
import { stringLength } from 'platform-bible-utils';
import { ExtensionBasicData } from '@shared/models/extension-basic-data.model';

/** For now this is just for extensions, but maybe we will want to expand this in the future */
export type ExecutionTokenType = 'extension';

/** Execution tokens can be passed into API calls to provide context about their identity */
export class ExecutionToken {
export class ExecutionToken implements ExtensionBasicData {
readonly type: ExecutionTokenType;
readonly name: string;
readonly nonce: string;
Expand Down
Loading

0 comments on commit dbdec16

Please sign in to comment.