Skip to content

Commit 3713066

Browse files
authored
refactor(core): rewrite shell execute API, closes #1229 (#1408)
1 parent b5cecbe commit 3713066

File tree

16 files changed

+567
-206
lines changed

16 files changed

+567
-206
lines changed

.changes/command-refactor.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"tauri": minor
3+
"api": minor
4+
---
5+
6+
The shell process spawning API was rewritten and now includes stream access.

api/src/shell.ts

Lines changed: 168 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,192 @@
11
import { invokeTauriCommand } from './helpers/tauri'
2+
import { transformCallback } from './tauri'
23

34
/**
45
* spawns a process
56
*
6-
* @param command the name of the cmd to execute e.g. 'mkdir' or 'node'
7+
* @param program the name of the program to execute e.g. 'mkdir' or 'node'
8+
* @param sidecar whether the program is a sidecar or a system program
79
* @param [args] command args
810
* @return promise resolving to the stdout text
911
*/
1012
async function execute(
11-
command: string,
13+
program: string,
14+
sidecar: boolean,
15+
onEvent: (event: CommandEvent) => void,
1216
args?: string | string[]
13-
): Promise<string> {
17+
): Promise<number> {
1418
if (typeof args === 'object') {
1519
Object.freeze(args)
1620
}
1721

18-
return invokeTauriCommand<string>({
22+
return invokeTauriCommand<number>({
1923
__tauriModule: 'Shell',
2024
message: {
2125
cmd: 'execute',
22-
command,
26+
program,
27+
sidecar,
28+
onEventFn: transformCallback(onEvent),
2329
args: typeof args === 'string' ? [args] : args
2430
}
2531
})
2632
}
2733

34+
interface ChildProcess {
35+
code: number | null
36+
signal: number | null
37+
stdout: string
38+
stderr: string
39+
}
40+
41+
class EventEmitter<E> {
42+
eventListeners: { [key: string]: Array<(arg: any) => void> } = {}
43+
44+
private addEventListener(event: string, handler: (arg: any) => void): void {
45+
if (event in this.eventListeners) {
46+
// eslint-disable-next-line security/detect-object-injection
47+
this.eventListeners[event].push(handler)
48+
} else {
49+
// eslint-disable-next-line security/detect-object-injection
50+
this.eventListeners[event] = [handler]
51+
}
52+
}
53+
54+
_emit(event: E, payload: any): void {
55+
if (event in this.eventListeners) {
56+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
57+
const listeners = this.eventListeners[event as any]
58+
for (const listener of listeners) {
59+
listener(payload)
60+
}
61+
}
62+
}
63+
64+
on(event: E, handler: (arg: any) => void): EventEmitter<E> {
65+
this.addEventListener(event as any, handler)
66+
return this
67+
}
68+
}
69+
70+
class Child {
71+
pid: number
72+
73+
constructor(pid: number) {
74+
this.pid = pid
75+
}
76+
77+
async write(data: string | number[]): Promise<void> {
78+
return invokeTauriCommand({
79+
__tauriModule: 'Shell',
80+
message: {
81+
cmd: 'stdinWrite',
82+
pid: this.pid,
83+
buffer: data
84+
}
85+
})
86+
}
87+
88+
async kill(): Promise<void> {
89+
return invokeTauriCommand({
90+
__tauriModule: 'Shell',
91+
message: {
92+
cmd: 'killChild',
93+
pid: this.pid
94+
}
95+
})
96+
}
97+
}
98+
99+
class Command extends EventEmitter<'close' | 'error'> {
100+
program: string
101+
args: string[]
102+
sidecar = false
103+
stdout = new EventEmitter<'data'>()
104+
stderr = new EventEmitter<'data'>()
105+
pid: number | null = null
106+
107+
constructor(program: string, args: string | string[] = []) {
108+
super()
109+
this.program = program
110+
this.args = typeof args === 'string' ? [args] : args
111+
}
112+
113+
/**
114+
* Creates a command to execute the given sidecar binary
115+
*
116+
* @param {string} program Binary name
117+
*
118+
* @return {Command}
119+
*/
120+
static sidecar(program: string, args: string | string[] = []): Command {
121+
const instance = new Command(program, args)
122+
instance.sidecar = true
123+
return instance
124+
}
125+
126+
async spawn(): Promise<Child> {
127+
return execute(
128+
this.program,
129+
this.sidecar,
130+
(event) => {
131+
switch (event.event) {
132+
case 'Error':
133+
this._emit('error', event.payload)
134+
break
135+
case 'Terminated':
136+
this._emit('close', event.payload)
137+
break
138+
case 'Stdout':
139+
this.stdout._emit('data', event.payload)
140+
break
141+
case 'Stderr':
142+
this.stderr._emit('data', event.payload)
143+
break
144+
}
145+
},
146+
this.args
147+
).then((pid) => new Child(pid))
148+
}
149+
150+
async execute(): Promise<ChildProcess> {
151+
return new Promise((resolve, reject) => {
152+
this.on('error', reject)
153+
const stdout: string[] = []
154+
const stderr: string[] = []
155+
this.stdout.on('data', (line) => {
156+
stdout.push(line)
157+
})
158+
this.stderr.on('data', (line) => {
159+
stderr.push(line)
160+
})
161+
this.on('close', (payload: TerminatedPayload) => {
162+
resolve({
163+
code: payload.code,
164+
signal: payload.signal,
165+
stdout: stdout.join('\n'),
166+
stderr: stderr.join('\n')
167+
})
168+
})
169+
this.spawn().catch(reject)
170+
})
171+
}
172+
}
173+
174+
interface Event<T, V> {
175+
event: T
176+
payload: V
177+
}
178+
179+
interface TerminatedPayload {
180+
code: number | null
181+
signal: number | null
182+
}
183+
184+
type CommandEvent =
185+
| Event<'Stdout', string>
186+
| Event<'Stderr', string>
187+
| Event<'Terminated', TerminatedPayload>
188+
| Event<'Error', string>
189+
28190
/**
29191
* opens a path or URL with the system's default app,
30192
* or the one specified with `openWith`
@@ -43,4 +205,4 @@ async function open(path: string, openWith?: string): Promise<void> {
43205
})
44206
}
45207

46-
export { execute, open }
208+
export { Command, Child, open }

examples/api/public/build/bundle.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/api/public/build/bundle.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/api/src/App.svelte

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { onMount } from "svelte";
33
import { open } from "@tauri-apps/api/shell";
44
5+
import Welcome from "./components/Welcome.svelte";
56
import Cli from "./components/Cli.svelte";
67
import Communication from "./components/Communication.svelte";
78
import Dialog from "./components/Dialog.svelte";
@@ -10,7 +11,7 @@
1011
import Notifications from "./components/Notifications.svelte";
1112
import Window from "./components/Window.svelte";
1213
import Shortcuts from "./components/Shortcuts.svelte";
13-
import Welcome from "./components/Welcome.svelte";
14+
import Shell from "./components/Shell.svelte";
1415
1516
const views = [
1617
{
@@ -49,6 +50,10 @@
4950
label: "Shortcuts",
5051
component: Shortcuts,
5152
},
53+
{
54+
label: "Shell",
55+
component: Shell,
56+
}
5257
];
5358
5459
let selected = views[0];
@@ -97,7 +102,7 @@
97102
<svelte:component this={selected.component} {onMessage} />
98103
</div>
99104
</div>
100-
<div id="response">
105+
<div id="response" style="white-space: pre-line">
101106
<p class="flex row just-around">
102107
<strong>Tauri Console</strong>
103108
<a class="nv" on:click={()=> {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<script>
2+
import { Command } from "@tauri-apps/api/shell"
3+
const windows = navigator.userAgent.includes('Windows')
4+
let cmd = windows ? 'cmd' : 'sh'
5+
let args = windows ? ['/C'] : ['-c']
6+
7+
export let onMessage;
8+
9+
let script = 'echo "hello world"'
10+
let stdin = ''
11+
let child
12+
13+
function spawn() {
14+
child = null
15+
const command = new Command(cmd, [...args, script])
16+
17+
command.on('close', data => {
18+
onMessage(`command finished with code ${data.code} and signal ${data.signal}`)
19+
child = null
20+
})
21+
command.on('error', error => onMessage(`command error: "${error}"`))
22+
23+
command.stdout.on('data', line => onMessage(`command stdout: "${line}"`))
24+
command.stderr.on('data', line => onMessage(`command stderr: "${line}"`))
25+
26+
command.spawn()
27+
.then(c => {
28+
child = c
29+
})
30+
.catch(onMessage)
31+
}
32+
33+
function kill() {
34+
child.kill().then(() => onMessage('killed child process')).error(onMessage)
35+
}
36+
37+
function writeToStdin() {
38+
child.write(stdin).catch(onMessage)
39+
}
40+
</script>
41+
42+
<div>
43+
<div>
44+
<input bind:value={script}>
45+
<button class="button" on:click={spawn}>Run</button>
46+
<button class="button" on:click={kill}>Kill</button>
47+
{#if child}
48+
<input placeholder="write to stdin" bind:value={stdin}>
49+
<button class="button" on:click={writeToStdin}>Write</button>
50+
{/if}
51+
</div>
52+
</div>

tauri-api/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ notify-rust = { version = "4.3.0", optional = true }
3838
once_cell = "1.7.2"
3939
tauri-hotkey = { git = "https://github.com/tauri-apps/tauri-hotkey-rs", branch = "dev", optional = true }
4040
open = "1.6.0"
41+
tokio = { version = "1.3", features = ["rt", "rt-multi-thread", "sync"] }
42+
shared_child = "0.3"
43+
os_pipe = "0.9"
4144

4245
[dev-dependencies]
4346
quickcheck = "1.0.3"

0 commit comments

Comments
 (0)