shell.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. // Copyright 2019-2021 Tauri Programme within The Commons Conservancy
  2. // SPDX-License-Identifier: Apache-2.0
  3. // SPDX-License-Identifier: MIT
  4. import { invokeTauriCommand } from './helpers/tauri'
  5. import { transformCallback } from './tauri'
  6. /**
  7. * Access the system shell.
  8. * Allows you to spawn child processes and manage files and URLs using their default application.
  9. *
  10. * This package is also accessible with `window.__TAURI__.shell` when `tauri.conf.json > build > withGlobalTauri` is set to true.
  11. *
  12. * The APIs must be allowlisted on `tauri.conf.json`:
  13. * ```json
  14. * {
  15. * "tauri": {
  16. * "allowlist": {
  17. * "shell": {
  18. * "all": true, // enable all shell APIs
  19. * "execute": true, // enable process spawn APIs
  20. * "open": true // enable opening files/URLs using the default program
  21. * }
  22. * }
  23. * }
  24. * }
  25. * ```
  26. * It is recommended to allowlist only the APIs you use for optimal bundle size and security.
  27. * @module
  28. */
  29. interface SpawnOptions {
  30. /** Current working directory. */
  31. cwd?: string
  32. /** Environment variables. set to `null` to clear the process env. */
  33. env?: { [name: string]: string }
  34. }
  35. /** @ignore */
  36. interface InternalSpawnOptions extends SpawnOptions {
  37. sidecar?: boolean
  38. }
  39. interface ChildProcess {
  40. /** Exit code of the process. `null` if the process was terminated by a signal on Unix. */
  41. code: number | null
  42. /** If the process was terminated by a signal, represents that signal. */
  43. signal: number | null
  44. /** The data that the process wrote to `stdout`. */
  45. stdout: string
  46. /** The data that the process wrote to `stderr`. */
  47. stderr: string
  48. }
  49. /**
  50. * Spawns a process.
  51. *
  52. * @ignore
  53. * @param program The name of the scoped command.
  54. * @param onEvent Event handler.
  55. * @param args Program arguments.
  56. * @param options Configuration for the process spawn.
  57. * @returns A promise resolving to the process id.
  58. */
  59. async function execute(
  60. onEvent: (event: CommandEvent) => void,
  61. program: string,
  62. args?: string | string[] | { [key: string]: string },
  63. options?: InternalSpawnOptions
  64. ): Promise<number> {
  65. if (typeof args === 'object') {
  66. Object.freeze(args)
  67. }
  68. return invokeTauriCommand<number>({
  69. __tauriModule: 'Shell',
  70. message: {
  71. cmd: 'execute',
  72. program,
  73. args,
  74. options,
  75. onEventFn: transformCallback(onEvent)
  76. }
  77. })
  78. }
  79. class EventEmitter<E extends string> {
  80. /** @ignore */
  81. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  82. private eventListeners: {
  83. [key: string]: Array<(arg: any) => void>
  84. } = Object.create(null)
  85. /** @ignore */
  86. private addEventListener(event: string, handler: (arg: any) => void): void {
  87. if (event in this.eventListeners) {
  88. // eslint-disable-next-line security/detect-object-injection
  89. this.eventListeners[event].push(handler)
  90. } else {
  91. // eslint-disable-next-line security/detect-object-injection
  92. this.eventListeners[event] = [handler]
  93. }
  94. }
  95. /** @ignore */
  96. _emit(event: E, payload: any): void {
  97. if (event in this.eventListeners) {
  98. // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
  99. const listeners = this.eventListeners[event as any]
  100. for (const listener of listeners) {
  101. listener(payload)
  102. }
  103. }
  104. }
  105. /**
  106. * Listen to an event from the child process.
  107. *
  108. * @param event The event name.
  109. * @param handler The event handler.
  110. *
  111. * @return The `this` instance for chained calls.
  112. */
  113. on(event: E, handler: (arg: any) => void): EventEmitter<E> {
  114. this.addEventListener(event, handler)
  115. return this
  116. }
  117. }
  118. class Child {
  119. /** The child process `pid`. */
  120. pid: number
  121. constructor(pid: number) {
  122. this.pid = pid
  123. }
  124. /**
  125. * Writes `data` to the `stdin`.
  126. *
  127. * @param data The message to write, either a string or a byte array.
  128. * @example
  129. * ```typescript
  130. * const command = new Command('node')
  131. * const child = await command.spawn()
  132. * await child.write('message')
  133. * await child.write([0, 1, 2, 3, 4, 5])
  134. * ```
  135. *
  136. * @return A promise indicating the success or failure of the operation.
  137. */
  138. async write(data: string | number[]): Promise<void> {
  139. return invokeTauriCommand({
  140. __tauriModule: 'Shell',
  141. message: {
  142. cmd: 'stdinWrite',
  143. pid: this.pid,
  144. buffer: data
  145. }
  146. })
  147. }
  148. /**
  149. * Kills the child process.
  150. *
  151. * @return A promise indicating the success or failure of the operation.
  152. */
  153. async kill(): Promise<void> {
  154. return invokeTauriCommand({
  155. __tauriModule: 'Shell',
  156. message: {
  157. cmd: 'killChild',
  158. pid: this.pid
  159. }
  160. })
  161. }
  162. }
  163. /**
  164. * The entry point for spawning child processes.
  165. * It emits the `close` and `error` events.
  166. * @example
  167. * ```typescript
  168. * const command = new Command('node')
  169. * command.on('close', data => {
  170. * console.log(`command finished with code ${data.code} and signal ${data.signal}`)
  171. * })
  172. * command.on('error', error => console.error(`command error: "${error}"`))
  173. * command.stdout.on('data', line => console.log(`command stdout: "${line}"`))
  174. * command.stderr.on('data', line => console.log(`command stderr: "${line}"`))
  175. *
  176. * const child = await command.spawn()
  177. * console.log('pid:', child.pid)
  178. * ```
  179. */
  180. class Command extends EventEmitter<'close' | 'error'> {
  181. /** @ignore Program to execute. */
  182. private readonly program: string
  183. /** @ignore Program arguments */
  184. private readonly args: string[]
  185. /** @ignore Spawn options. */
  186. private readonly options: InternalSpawnOptions
  187. /** Event emitter for the `stdout`. Emits the `data` event. */
  188. readonly stdout = new EventEmitter<'data'>()
  189. /** Event emitter for the `stderr`. Emits the `data` event. */
  190. readonly stderr = new EventEmitter<'data'>()
  191. /**
  192. * Creates a new `Command` instance.
  193. *
  194. * @param program The program to execute.
  195. * @param args Program arguments.
  196. * @param options Spawn options.
  197. */
  198. constructor(
  199. program: string,
  200. args: string | string[] = [],
  201. options?: SpawnOptions
  202. ) {
  203. super()
  204. this.program = program
  205. this.args = typeof args === 'string' ? [args] : args
  206. this.options = options ?? {}
  207. }
  208. /**
  209. * Creates a command to execute the given sidecar program.
  210. * @example
  211. * ```typescript
  212. * const command = Command.sidecar('my-sidecar')
  213. * const output = await command.execute()
  214. * ```
  215. *
  216. * @param program The program to execute.
  217. * @param args Program arguments.
  218. * @param options Spawn options.
  219. * @returns
  220. */
  221. static sidecar(
  222. program: string,
  223. args: string | string[] = [],
  224. options?: SpawnOptions
  225. ): Command {
  226. const instance = new Command(program, args, options)
  227. instance.options.sidecar = true
  228. return instance
  229. }
  230. /**
  231. * Executes the command as a child process, returning a handle to it.
  232. *
  233. * @return A promise resolving to the child process handle.
  234. */
  235. async spawn(): Promise<Child> {
  236. return execute(
  237. (event) => {
  238. switch (event.event) {
  239. case 'Error':
  240. this._emit('error', event.payload)
  241. break
  242. case 'Terminated':
  243. this._emit('close', event.payload)
  244. break
  245. case 'Stdout':
  246. this.stdout._emit('data', event.payload)
  247. break
  248. case 'Stderr':
  249. this.stderr._emit('data', event.payload)
  250. break
  251. }
  252. },
  253. this.program,
  254. this.args,
  255. this.options
  256. ).then((pid) => new Child(pid))
  257. }
  258. /**
  259. * Executes the command as a child process, waiting for it to finish and collecting all of its output.
  260. * @example
  261. * ```typescript
  262. * const output = await new Command('echo', 'message').execute()
  263. * assert(output.code === 0)
  264. * assert(output.signal === null)
  265. * assert(output.stdout === 'message')
  266. * assert(output.stderr === '')
  267. * ```
  268. *
  269. * @return A promise resolving to the child process output.
  270. */
  271. async execute(): Promise<ChildProcess> {
  272. return new Promise((resolve, reject) => {
  273. this.on('error', reject)
  274. const stdout: string[] = []
  275. const stderr: string[] = []
  276. this.stdout.on('data', (line: string) => {
  277. stdout.push(line)
  278. })
  279. this.stderr.on('data', (line: string) => {
  280. stderr.push(line)
  281. })
  282. this.on('close', (payload: TerminatedPayload) => {
  283. resolve({
  284. code: payload.code,
  285. signal: payload.signal,
  286. stdout: stdout.join('\n'),
  287. stderr: stderr.join('\n')
  288. })
  289. })
  290. this.spawn().catch(reject)
  291. })
  292. }
  293. }
  294. /**
  295. * Describes the event message received from the command.
  296. */
  297. interface Event<T, V> {
  298. event: T
  299. payload: V
  300. }
  301. /**
  302. * Payload for the `Terminated` command event.
  303. */
  304. interface TerminatedPayload {
  305. /** Exit code of the process. `null` if the process was terminated by a signal on Unix. */
  306. code: number | null
  307. /** If the process was terminated by a signal, represents that signal. */
  308. signal: number | null
  309. }
  310. /** Events emitted by the child process. */
  311. type CommandEvent =
  312. | Event<'Stdout', string>
  313. | Event<'Stderr', string>
  314. | Event<'Terminated', TerminatedPayload>
  315. | Event<'Error', string>
  316. /**
  317. * Opens a path or URL with the system's default app,
  318. * or the one specified with `openWith`.
  319. *
  320. * The `openWith` value must be one of `firefox`, `google chrome`, `chromium` `safari`,
  321. * `open`, `start`, `xdg-open`, `gio`, gnome-open`, `kde-open` or `wslview`.
  322. *
  323. * @example
  324. * ```typescript
  325. * // opens the given URL on the default browser:
  326. * await open('https://github.com/tauri-apps/tauri')
  327. * // opens the given URL using `firefox`:
  328. * await open('https://github.com/tauri-apps/tauri', 'firefox')
  329. * // opens a file using the default program:
  330. * await open('/path/to/file')
  331. * ```
  332. *
  333. * @param path The path or URL to open.
  334. * @param openWith The app to open the file or URL with. Defaults to the system default application for the specified path type.
  335. * @returns
  336. */
  337. async function open(path: string, openWith?: string): Promise<void> {
  338. return invokeTauriCommand({
  339. __tauriModule: 'Shell',
  340. message: {
  341. cmd: 'open',
  342. path,
  343. with: openWith
  344. }
  345. })
  346. }
  347. export { Command, Child, open }
  348. export type { ChildProcess, SpawnOptions }