@rescript-tauri/plugin-shell¶
ReScript bindings for the Tauri 2.x shell plugin — spawn child processes, stream stdout / stderr, and open files / URLs with the OS-default app.
Note
This package is feature-complete in main. Its first npm publish is scheduled alongside the other packages. Until then, consume it via the source repository or a workspace link.
Install¶
pnpm add @rescript-tauri/plugin-shell @tauri-apps/plugin-shell
@rescript-tauri/plugin-shell declares both
@rescript-tauri/core and @tauri-apps/plugin-shell as
peerDependencies, so you control each upstream version.
Add the package to dependencies in your rescript.json:
{
"dependencies": [
"@rescript-tauri/core",
"@rescript-tauri/plugin-shell"
]
}
On the Rust side, add the plugin crate and register it on the builder:
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-shell = "2"
// src-tauri/src/main.rs
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())
.expect("error while running app");
}
Capabilities¶
Tauri 2.x requires every shell operation to be granted a
capability. The minimum set for spawning ls and opening http(s)
URLs is:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"shell:default",
{
"identifier": "shell:allow-execute",
"allow": [{ "name": "ls", "cmd": "ls", "args": true }]
},
{
"identifier": "shell:allow-open",
"allow": [{ "url": "^https?://" }]
}
]
}
Each program you intend to spawn must be listed under
shell:allow-execute with a matching name. URLs passed to
openPath must match one of the shell:allow-open regex
patterns. See the upstream scope and permissions
reference
for the full grammar.
Minimal example¶
open RescriptTauriPluginShell
await PluginShell.openPath("https://tauri.app/")
openPath returns promise<unit>; the underlying call rejects
when the URL fails the configured shell:allow-open regex.
Public API¶
Symbol |
Returns |
Purpose |
|---|---|---|
|
|
Open a URL / path with the OS-default app (or |
|
|
Build a UTF-8 command |
|
|
Build a bytes command ( |
|
|
Run to completion, collect output |
|
|
Start the command, return a handle |
|
|
Subscribe to lifecycle events |
|
|
Subscribe to streamed output |
|
|
Detach all command-level listeners |
|
|
Low-level access to the underlying event emitter |
|
|
Child accessors and operations |
|
|
9 generic methods ( |
The associated records (spawnOptions, childProcess<'o>,
terminatedPayload) are re-exported from PluginShell. See
packages/plugin-shell/src/PluginShell.resi
for the full doc comments and matching upstream URLs.
Running commands¶
One-shot execution¶
Command.execute resolves once the child has exited and gives
you the collected output. Best for short-lived commands:
module Shell = RescriptTauriPluginShell.PluginShell
let listFiles = async () => {
let cmd = Shell.Command.create("ls", ~args=["-la"])
let output = await cmd->Shell.Command.execute
Console.log(output.stdout)
}
childProcess<'o> carries {code, signal, stdout, stderr}. Both
code and signal are Nullable.t<int> — they’re mutually
exclusive on Unix (signal-terminated processes report code = null).
Background processes¶
Command.spawn starts the child and returns a Child.t handle
without waiting. Combine it with Child.write and Child.kill
for interactive use:
let runRepl = async () => {
let cmd = Shell.Command.create("python3", ~args=["-i"])
let child = await cmd->Shell.Command.spawn
await child->Shell.Child.write("print('hello')\n")
// ... later
await child->Shell.Child.kill
}
Child.pid returns the OS-level process id.
Raw byte output¶
Pass encoding: "raw" via the dedicated factory functions to
receive Uint8Array.t instead of string:
let readImage = async () => {
let cmd = Shell.Command.createRaw("cat", ~args=["icon.png"])
let output = await cmd->Shell.Command.execute
Console.log("byte count: " ++ Int.toString(TypedArray.length(output.stdout)))
}
The TypeScript-level conditional return type of upstream’s
Command.create({encoding: 'raw'}) is split into four ReScript
functions — Command.create / Command.createRaw /
Command.sidecar / Command.sidecarRaw — so the result type is
always statically known.
Streaming output¶
Use chained onStdoutData / onStderrData / onClose /
onError to stream events from a long-running command. Each
method returns the command, mirroring the upstream
Promise<this> pattern:
let tail = async () => {
let cmd =
Shell.Command.create("tail", ~args=["-f", "/var/log/system.log"])
->Shell.Command.onStdoutData(line => Console.log2("stdout:", line))
->Shell.Command.onStderrData(line => Console.log2("stderr:", line))
->Shell.Command.onClose(payload =>
switch (payload.code->Nullable.toOption, payload.signal->Nullable.toOption) {
| (Some(code), _) => Console.log("exited with code " ++ Int.toString(code))
| (_, Some(sig)) => Console.log("killed by signal " ++ Int.toString(sig))
| _ => Console.log("closed")
}
)
->Shell.Command.onError(err => Console.error2("error:", err))
let _child = await cmd->Shell.Command.spawn
}
For advanced subscription patterns (once, prependListener,
manual listenerCount, …) reach for Command.stdout /
Command.stderr directly — both return an
EventEmitter.t<{"data": 'o}> you can drive with the
EventEmitter module’s 9 generic methods.
Command.removeAllListeners detaches every command-level
listener (close / error). To clear stdout / stderr listeners,
use EventEmitter.removeAllListeners on the corresponding
accessor.
Sidecar binaries¶
Sidecars are binaries bundled alongside your app and looked up
through tauri.conf.json > bundle > externalBin instead of
PATH. Use Command.sidecar (or sidecarRaw for bytes):
let runHelper = async () => {
let cmd = Shell.Command.sidecar("my-sidecar", ~args=["--check"])
let output = await cmd->Shell.Command.execute
Console.log(output.stdout)
}
The string passed to sidecar matches the externalBin entry
without the target triple suffix.
Opening paths and URLs¶
openPath opens its argument with the system’s default
application:
await Shell.openPath("https://github.com/tauri-apps/tauri")
await Shell.openPath("/tmp/notes.md")
Pass ~openWith to force a specific opener (firefox,
chromium, safari, xdg-open, …). The path or URL must
match a shell:allow-open regex — otherwise the underlying call
rejects:
await Shell.openPath("/tmp/notes.md", ~openWith="firefox")
Pitfalls¶
openPath rename¶
Upstream exposes this function as open, but ReScript reserves
that identifier for module access (open MyModule). We re-export
it as openPath:
// ❌ syntax error: ReScript treats `open` as a keyword
await Shell.open("https://tauri.app/")
// ✅
await Shell.openPath("https://tauri.app/")
Uint8Array length¶
Uint8Array.t in @rescript/core is an alias for
TypedArray.t<int>. The length getter lives on the parent
module:
let output = await cmd->Shell.Command.execute
Console.log("byte count: " ++ Int.toString(TypedArray.length(output.stdout)))
Scope misconfiguration¶
Commands fail to spawn — and openPath fails to launch — when
the target is not allowlisted in src-tauri/capabilities/. The
name field on shell:allow-execute must match the first
argument to Command.create / Command.sidecar exactly:
{
"identifier": "shell:allow-execute",
"allow": [{ "name": "git", "cmd": "git", "args": true }]
}
Command.create("git", ...) then succeeds; Command.create("Git", ...)
or Command.create("/usr/bin/git", ...) rejects.
Compatibility¶
Component |
Supported range |
|---|---|
Upstream |
|
Rust |
|
|
|
ReScript |
|
|
|
OS |
Linux / macOS / Windows |
See also¶
Live demo:
examples/plugin-shell-demoSource:
packages/plugin-shellUpstream docs: Tauri 2.x shell plugin
Installation guide — set up
@rescript-tauri/corefirst