tauri/tooling/api/src/shell.ts
2022-02-11 15:19:01 -03:00

429 lines
12 KiB
TypeScript

// Copyright 2019-2021 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
/**
* Access the system shell.
* Allows you to spawn child processes and manage files and URLs using their default application.
*
* This package is also accessible with `window.__TAURI__.shell` when `tauri.conf.json > build > withGlobalTauri` is set to true.
*
* The APIs must be allowlisted on `tauri.conf.json`:
* ```json
* {
* "tauri": {
* "allowlist": {
* "shell": {
* "all": true, // enable all shell APIs
* "execute": true, // enable process spawn APIs
* "sidecar": true, // enable spawning sidecars
* "open": true // enable opening files/URLs using the default program
* }
* }
* }
* }
* ```
* It is recommended to allowlist only the APIs you use for optimal bundle size and security.
*
* ## Security
*
* This API has a scope configuration that forces you to restrict the programs and arguments that can be used.
*
* ### Restricting access to the [[`open`]] API
*
* On the allowlist, `open: true` means that the [[open]] API can be used with any URL,
* as the argument is validated with the `^https?://` regex.
* You can change that regex by changing the boolean value to a string, e.g. `open: ^https://github.com/`.
*
* ### Restricting access to the [[`Command`]] APIs
*
* The `shell` allowlist object has a `scope` field that defines an array of CLIs that can be used.
* Each CLI is a configuration object `{ name: string, command: string, sidecar?: bool, args?: boolean | Arg[] }`.
*
* - `name`: the unique identifier of the command, passed to the [[Command.constructor | Command constructor]].
* If it's a sidecar, this must be the value defined on `tauri.conf.json > tauri > bundle > externalBin`.
* - `command`: the program that is executed on this configuration. If it's a sidecar, it must be the same as `name`.
* - `sidecar`: whether the object configures a sidecar or a system program.
* - `args`: the arguments that can be passed to the program. By default no arguments are allowed.
* - `true` means that any argument list is allowed.
* - `false` means that no arguments are allowed.
* - otherwise an array can be configured. Each item is either a string representing the fixed argument value
* or a `{ validator: string }` that defines a regex validating the argument value.
*
* #### Example scope configuration
*
* CLI: `git commit -m "the commit message"`
*
* Configuration:
* ```json
* {
* "scope": {
* "name": "run-git-commit",
* "command": "git",
* "args": ["commit", "-m", { "validator": "\\S+" }]
* }
* }
* ```
* Usage:
* ```typescript
* import { Command } from '@tauri-apps/api/shell'
* new Command('run-git-commit', ['commit', '-m', 'the commit message'])
* ```
*
* Trying to execute any API with a program not configured on the scope results in a promise rejection due to denied access.
*
* @module
*/
import { invokeTauriCommand } from './helpers/tauri'
import { transformCallback } from './tauri'
interface SpawnOptions {
/** Current working directory. */
cwd?: string
/** Environment variables. set to `null` to clear the process env. */
env?: { [name: string]: string }
}
/** @ignore */
interface InternalSpawnOptions extends SpawnOptions {
sidecar?: boolean
}
interface ChildProcess {
/** Exit code of the process. `null` if the process was terminated by a signal on Unix. */
code: number | null
/** If the process was terminated by a signal, represents that signal. */
signal: number | null
/** The data that the process wrote to `stdout`. */
stdout: string
/** The data that the process wrote to `stderr`. */
stderr: string
}
/**
* Spawns a process.
*
* @ignore
* @param program The name of the scoped command.
* @param onEvent Event handler.
* @param args Program arguments.
* @param options Configuration for the process spawn.
* @returns A promise resolving to the process id.
*/
async function execute(
onEvent: (event: CommandEvent) => void,
program: string,
args: string | string[] = [],
options?: InternalSpawnOptions
): Promise<number> {
if (typeof args === 'object') {
Object.freeze(args)
}
return invokeTauriCommand<number>({
__tauriModule: 'Shell',
message: {
cmd: 'execute',
program,
args,
options,
onEventFn: transformCallback(onEvent)
}
})
}
class EventEmitter<E extends string> {
/** @ignore */
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
private eventListeners: {
[key: string]: Array<(arg: any) => void>
} = Object.create(null)
/** @ignore */
private addEventListener(event: string, handler: (arg: any) => void): void {
if (event in this.eventListeners) {
// eslint-disable-next-line security/detect-object-injection
this.eventListeners[event].push(handler)
} else {
// eslint-disable-next-line security/detect-object-injection
this.eventListeners[event] = [handler]
}
}
/** @ignore */
_emit(event: E, payload: any): void {
if (event in this.eventListeners) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const listeners = this.eventListeners[event as any]
for (const listener of listeners) {
listener(payload)
}
}
}
/**
* Listen to an event from the child process.
*
* @param event The event name.
* @param handler The event handler.
*
* @return The `this` instance for chained calls.
*/
on(event: E, handler: (arg: any) => void): EventEmitter<E> {
this.addEventListener(event, handler)
return this
}
}
class Child {
/** The child process `pid`. */
pid: number
constructor(pid: number) {
this.pid = pid
}
/**
* Writes `data` to the `stdin`.
*
* @param data The message to write, either a string or a byte array.
* @example
* ```typescript
* const command = new Command('node')
* const child = await command.spawn()
* await child.write('message')
* await child.write([0, 1, 2, 3, 4, 5])
* ```
*
* @return A promise indicating the success or failure of the operation.
*/
async write(data: string | Uint8Array): Promise<void> {
return invokeTauriCommand({
__tauriModule: 'Shell',
message: {
cmd: 'stdinWrite',
pid: this.pid,
// correctly serialize Uint8Arrays
buffer: typeof data === 'string' ? data : Array.from(data)
}
})
}
/**
* Kills the child process.
*
* @return A promise indicating the success or failure of the operation.
*/
async kill(): Promise<void> {
return invokeTauriCommand({
__tauriModule: 'Shell',
message: {
cmd: 'killChild',
pid: this.pid
}
})
}
}
/**
* The entry point for spawning child processes.
* It emits the `close` and `error` events.
* @example
* ```typescript
* const command = new Command('node')
* command.on('close', data => {
* console.log(`command finished with code ${data.code} and signal ${data.signal}`)
* })
* command.on('error', error => console.error(`command error: "${error}"`))
* command.stdout.on('data', line => console.log(`command stdout: "${line}"`))
* command.stderr.on('data', line => console.log(`command stderr: "${line}"`))
*
* const child = await command.spawn()
* console.log('pid:', child.pid)
* ```
*/
class Command extends EventEmitter<'close' | 'error'> {
/** @ignore Program to execute. */
private readonly program: string
/** @ignore Program arguments */
private readonly args: string[]
/** @ignore Spawn options. */
private readonly options: InternalSpawnOptions
/** Event emitter for the `stdout`. Emits the `data` event. */
readonly stdout = new EventEmitter<'data'>()
/** Event emitter for the `stderr`. Emits the `data` event. */
readonly stderr = new EventEmitter<'data'>()
/**
* Creates a new `Command` instance.
*
* @param program The program name to execute.
* It must be configured on `tauri.conf.json > tauri > allowlist > shell > scope`.
* @param args Program arguments.
* @param options Spawn options.
*/
constructor(
program: string,
args: string | string[] = [],
options?: SpawnOptions
) {
super()
this.program = program
this.args = typeof args === 'string' ? [args] : args
this.options = options ?? {}
}
/**
* Creates a command to execute the given sidecar program.
* @example
* ```typescript
* const command = Command.sidecar('my-sidecar')
* const output = await command.execute()
* ```
*
* @param program The program to execute.
* It must be configured on `tauri.conf.json > tauri > allowlist > shell > scope`.
* @param args Program arguments.
* @param options Spawn options.
* @returns
*/
static sidecar(
program: string,
args: string | string[] = [],
options?: SpawnOptions
): Command {
const instance = new Command(program, args, options)
instance.options.sidecar = true
return instance
}
/**
* Executes the command as a child process, returning a handle to it.
*
* @return A promise resolving to the child process handle.
*/
async spawn(): Promise<Child> {
return execute(
(event) => {
switch (event.event) {
case 'Error':
this._emit('error', event.payload)
break
case 'Terminated':
this._emit('close', event.payload)
break
case 'Stdout':
this.stdout._emit('data', event.payload)
break
case 'Stderr':
this.stderr._emit('data', event.payload)
break
}
},
this.program,
this.args,
this.options
).then((pid) => new Child(pid))
}
/**
* Executes the command as a child process, waiting for it to finish and collecting all of its output.
* @example
* ```typescript
* const output = await new Command('echo', 'message').execute()
* assert(output.code === 0)
* assert(output.signal === null)
* assert(output.stdout === 'message')
* assert(output.stderr === '')
* ```
*
* @return A promise resolving to the child process output.
*/
async execute(): Promise<ChildProcess> {
return new Promise((resolve, reject) => {
this.on('error', reject)
const stdout: string[] = []
const stderr: string[] = []
this.stdout.on('data', (line: string) => {
stdout.push(line)
})
this.stderr.on('data', (line: string) => {
stderr.push(line)
})
this.on('close', (payload: TerminatedPayload) => {
resolve({
code: payload.code,
signal: payload.signal,
stdout: stdout.join('\n'),
stderr: stderr.join('\n')
})
})
this.spawn().catch(reject)
})
}
}
/**
* Describes the event message received from the command.
*/
interface Event<T, V> {
event: T
payload: V
}
/**
* Payload for the `Terminated` command event.
*/
interface TerminatedPayload {
/** Exit code of the process. `null` if the process was terminated by a signal on Unix. */
code: number | null
/** If the process was terminated by a signal, represents that signal. */
signal: number | null
}
/** Events emitted by the child process. */
type CommandEvent =
| Event<'Stdout', string>
| Event<'Stderr', string>
| Event<'Terminated', TerminatedPayload>
| Event<'Error', string>
/**
* Opens a path or URL with the system's default app,
* or the one specified with `openWith`.
*
* The `openWith` value must be one of `firefox`, `google chrome`, `chromium` `safari`,
* `open`, `start`, `xdg-open`, `gio`, gnome-open`, `kde-open` or `wslview`.
*
* @example
* ```typescript
* // opens the given URL on the default browser:
* await open('https://github.com/tauri-apps/tauri')
* // opens the given URL using `firefox`:
* await open('https://github.com/tauri-apps/tauri', 'firefox')
* // opens a file using the default program:
* await open('/path/to/file')
* ```
*
* @param path The path or URL to open.
* This value is matched against the string regex defined on `tauri.conf.json > tauri > allowlist > shell > open`,
* which defaults to `^https?://`.
* @param openWith The app to open the file or URL with.
* Defaults to the system default application for the specified path type.
* @returns
*/
async function open(path: string, openWith?: string): Promise<void> {
return invokeTauriCommand({
__tauriModule: 'Shell',
message: {
cmd: 'open',
path,
with: openWith
}
})
}
export { Command, Child, EventEmitter, open }
export type { ChildProcess, SpawnOptions }