diff --git a/Cargo.lock b/Cargo.lock
index bb690eb6..4f4f107b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2292,6 +2292,15 @@ version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f"
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
[[package]]
name = "is-terminal"
version = "0.4.7"
@@ -2304,6 +2313,16 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
[[package]]
name = "itertools"
version = "0.10.5"
@@ -3000,6 +3019,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
+[[package]]
+name = "open"
+version = "4.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "873240a4a404d44c8cd1bf394359245d466a5695771fea15a79cafbc5e5cf4d7"
+dependencies = [
+ "is-wsl",
+ "pathdiff",
+]
+
[[package]]
name = "openssl"
version = "0.10.49"
@@ -3054,6 +3083,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a53dbb20faf34b16087a931834cba2d7a73cc74af2b7ef345a4c8324e2409a12"
+dependencies = [
+ "libc",
+ "windows-sys 0.45.0",
+]
+
[[package]]
name = "overload"
version = "0.1.1"
@@ -3146,6 +3185,12 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79"
+[[package]]
+name = "pathdiff"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
+
[[package]]
name = "pbkdf2"
version = "0.11.0"
@@ -4081,6 +4126,16 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "shared_child"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
[[package]]
name = "siphasher"
version = "0.3.10"
@@ -4791,6 +4846,22 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "tauri-plugin-shell"
+version = "0.1.0"
+dependencies = [
+ "encoding_rs",
+ "log",
+ "open",
+ "os_pipe",
+ "regex",
+ "serde",
+ "serde_json",
+ "shared_child",
+ "tauri",
+ "thiserror",
+]
+
[[package]]
name = "tauri-plugin-single-instance"
version = "0.1.0"
diff --git a/plugins/shell/Cargo.toml b/plugins/shell/Cargo.toml
new file mode 100644
index 00000000..e3df745d
--- /dev/null
+++ b/plugins/shell/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "tauri-plugin-shell"
+version = "0.1.0"
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+tauri.workspace = true
+log.workspace = true
+thiserror.workspace = true
+shared_child = "1"
+regex = "1"
+open = "4"
+encoding_rs = "0.8"
+os_pipe = "1"
\ No newline at end of file
diff --git a/plugins/shell/LICENSE.spdx b/plugins/shell/LICENSE.spdx
new file mode 100644
index 00000000..cdd0df5a
--- /dev/null
+++ b/plugins/shell/LICENSE.spdx
@@ -0,0 +1,20 @@
+SPDXVersion: SPDX-2.1
+DataLicense: CC0-1.0
+PackageName: tauri
+DataFormat: SPDXRef-1
+PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
+PackageHomePage: https://tauri.app
+PackageLicenseDeclared: Apache-2.0
+PackageLicenseDeclared: MIT
+PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
+PackageSummary: Tauri is a rust project that enables developers to make secure
+and small desktop applications using a web frontend.
+
+PackageComment: The package includes the following libraries; see
+Relationship information.
+
+Created: 2019-05-20T09:00:00Z
+PackageDownloadLocation: git://github.com/tauri-apps/tauri
+PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
+PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
+Creator: Person: Daniel Thompson-Yvetot
\ No newline at end of file
diff --git a/plugins/shell/LICENSE_APACHE-2.0 b/plugins/shell/LICENSE_APACHE-2.0
new file mode 100644
index 00000000..4947287f
--- /dev/null
+++ b/plugins/shell/LICENSE_APACHE-2.0
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/plugins/shell/LICENSE_MIT b/plugins/shell/LICENSE_MIT
new file mode 100644
index 00000000..4d754725
--- /dev/null
+++ b/plugins/shell/LICENSE_MIT
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 - Present Tauri Apps Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/plugins/shell/README.md b/plugins/shell/README.md
new file mode 100644
index 00000000..ed5545a2
--- /dev/null
+++ b/plugins/shell/README.md
@@ -0,0 +1,65 @@
+
+
+
+
+## Install
+
+_This plugin requires a Rust version of at least **1.64**_
+
+There are three general methods of installation that we can recommend.
+
+1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked)
+2. Pull sources directly from Github using git tags / revision hashes (most secure)
+3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use)
+
+Install the Core plugin by adding the following to your `Cargo.toml` file:
+
+`src-tauri/Cargo.toml`
+
+```toml
+[dependencies]
+ = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
+```
+
+You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
+
+> Note: Since most JavaScript package managers are unable to install packages from git monorepos we provide read-only mirrors of each plugin. This makes installation option 2 more ergonomic to use.
+
+```sh
+pnpm add
+# or
+npm add
+# or
+yarn add
+```
+
+## Usage
+
+First you need to register the core plugin with Tauri:
+
+`src-tauri/src/main.rs`
+
+```rust
+fn main() {
+ tauri::Builder::default()
+ .plugin()
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
+```
+
+Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
+
+```javascript
+
+```
+
+## Contributing
+
+PRs accepted. Please make sure to read the Contributing Guide before making a pull request.
+
+## License
+
+Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy.
+
+MIT or MIT/Apache 2.0 where applicable.
diff --git a/plugins/shell/guest-js/index.ts b/plugins/shell/guest-js/index.ts
new file mode 100644
index 00000000..8a9ac210
--- /dev/null
+++ b/plugins/shell/guest-js/index.ts
@@ -0,0 +1,657 @@
+// Copyright 2019-2023 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.
+ *
+ * The APIs must be added to [`tauri.allowlist.shell`](https://tauri.app/v1/api/config/#allowlistconfig.shell) in `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 {@link open | `open`} API
+ *
+ * On the allowlist, `open: true` means that the {@link open} API can be used with any URL,
+ * as the argument is validated with the `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+` regex.
+ * You can change that regex by changing the boolean value to a string, e.g. `open: ^https://github.com/`.
+ *
+ * ### Restricting access to the {@link Command | `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, cmd: string, sidecar?: bool, args?: boolean | Arg[] }`.
+ *
+ * - `name`: the unique identifier of the command, passed to the {@link Command.create | Command.create function}.
+ * If it's a sidecar, this must be the value defined on `tauri.conf.json > tauri > bundle > externalBin`.
+ * - `cmd`: the program that is executed on this configuration. If it's a sidecar, this value is ignored.
+ * - `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",
+ * "cmd": "git",
+ * "args": ["commit", "-m", { "validator": "\\S+" }]
+ * }
+ * ]
+ * }
+ * ```
+ * Usage:
+ * ```typescript
+ * import { Command } from 'tauri-plugin-shell-api'
+ * Command.create('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 { invoke, transformCallback } from '@tauri-apps/api/tauri'
+
+/**
+ * @since 1.0.0
+ */
+interface SpawnOptions {
+ /** Current working directory. */
+ cwd?: string
+ /** Environment variables. set to `null` to clear the process env. */
+ env?: Record
+ /**
+ * Character encoding for stdout/stderr
+ *
+ * @since 1.1.0
+ * */
+ encoding?: string
+}
+
+/** @ignore */
+interface InternalSpawnOptions extends SpawnOptions {
+ sidecar?: boolean
+}
+
+/**
+ * @since 1.0.0
+ */
+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: O
+ /** The data that the process wrote to `stderr`. */
+ stderr: O
+}
+
+/**
+ * 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 {
+ if (typeof args === 'object') {
+ Object.freeze(args)
+ }
+
+ return invoke('plugin:shell|execute', {
+ program,
+ args,
+ options,
+ onEventFn: transformCallback(onEvent)
+ })
+}
+
+/**
+ * @since 1.0.0
+ */
+class EventEmitter> {
+ /** @ignore */
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ private eventListeners: Record void>> =
+ Object.create(null)
+
+ /**
+ * Alias for `emitter.on(eventName, listener)`.
+ *
+ * @since 1.1.0
+ */
+ addListener(
+ eventName: N,
+ listener: (arg: E[typeof eventName]) => void
+ ): this {
+ return this.on(eventName, listener)
+ }
+
+ /**
+ * Alias for `emitter.off(eventName, listener)`.
+ *
+ * @since 1.1.0
+ */
+ removeListener(
+ eventName: N,
+ listener: (arg: E[typeof eventName]) => void
+ ): this {
+ return this.off(eventName, listener)
+ }
+
+ /**
+ * Adds the `listener` function to the end of the listeners array for the
+ * event named `eventName`. No checks are made to see if the `listener` has
+ * already been added. Multiple calls passing the same combination of `eventName`and `listener` will result in the `listener` being added, and called, multiple
+ * times.
+ *
+ * Returns a reference to the `EventEmitter`, so that calls can be chained.
+ *
+ * @since 1.0.0
+ */
+ on(
+ eventName: N,
+ listener: (arg: E[typeof eventName]) => void
+ ): this {
+ if (eventName in this.eventListeners) {
+ // eslint-disable-next-line security/detect-object-injection
+ this.eventListeners[eventName].push(listener)
+ } else {
+ // eslint-disable-next-line security/detect-object-injection
+ this.eventListeners[eventName] = [listener]
+ }
+ return this
+ }
+
+ /**
+ * Adds a **one-time**`listener` function for the event named `eventName`. The
+ * next time `eventName` is triggered, this listener is removed and then invoked.
+ *
+ * Returns a reference to the `EventEmitter`, so that calls can be chained.
+ *
+ * @since 1.1.0
+ */
+ once(
+ eventName: N,
+ listener: (arg: E[typeof eventName]) => void
+ ): this {
+ const wrapper = (arg: E[typeof eventName]): void => {
+ this.removeListener(eventName, wrapper)
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ listener(arg)
+ }
+ return this.addListener(eventName, wrapper)
+ }
+
+ /**
+ * Removes the all specified listener from the listener array for the event eventName
+ * Returns a reference to the `EventEmitter`, so that calls can be chained.
+ *
+ * @since 1.1.0
+ */
+ off(
+ eventName: N,
+ listener: (arg: E[typeof eventName]) => void
+ ): this {
+ if (eventName in this.eventListeners) {
+ // eslint-disable-next-line security/detect-object-injection
+ this.eventListeners[eventName] = this.eventListeners[eventName].filter(
+ (l) => l !== listener
+ )
+ }
+ return this
+ }
+
+ /**
+ * Removes all listeners, or those of the specified eventName.
+ *
+ * Returns a reference to the `EventEmitter`, so that calls can be chained.
+ *
+ * @since 1.1.0
+ */
+ removeAllListeners(event?: N): this {
+ if (event) {
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete,security/detect-object-injection
+ delete this.eventListeners[event]
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ this.eventListeners = Object.create(null)
+ }
+ return this
+ }
+
+ /**
+ * @ignore
+ * Synchronously calls each of the listeners registered for the event named`eventName`, in the order they were registered, passing the supplied arguments
+ * to each.
+ *
+ * @returns `true` if the event had listeners, `false` otherwise.
+ */
+ emit(eventName: N, arg: E[typeof eventName]): boolean {
+ if (eventName in this.eventListeners) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,security/detect-object-injection
+ const listeners = this.eventListeners[eventName]
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ for (const listener of listeners) listener(arg)
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Returns the number of listeners listening to the event named `eventName`.
+ *
+ * @since 1.1.0
+ */
+ listenerCount(eventName: N): number {
+ if (eventName in this.eventListeners)
+ // eslint-disable-next-line security/detect-object-injection
+ return this.eventListeners[eventName].length
+ return 0
+ }
+
+ /**
+ * Adds the `listener` function to the _beginning_ of the listeners array for the
+ * event named `eventName`. No checks are made to see if the `listener` has
+ * already been added. Multiple calls passing the same combination of `eventName`and `listener` will result in the `listener` being added, and called, multiple
+ * times.
+ *
+ * Returns a reference to the `EventEmitter`, so that calls can be chained.
+ *
+ * @since 1.1.0
+ */
+ prependListener(
+ eventName: N,
+ listener: (arg: E[typeof eventName]) => void
+ ): this {
+ if (eventName in this.eventListeners) {
+ // eslint-disable-next-line security/detect-object-injection
+ this.eventListeners[eventName].unshift(listener)
+ } else {
+ // eslint-disable-next-line security/detect-object-injection
+ this.eventListeners[eventName] = [listener]
+ }
+ return this
+ }
+
+ /**
+ * Adds a **one-time**`listener` function for the event named `eventName` to the_beginning_ of the listeners array. The next time `eventName` is triggered, this
+ * listener is removed, and then invoked.
+ *
+ * Returns a reference to the `EventEmitter`, so that calls can be chained.
+ *
+ * @since 1.1.0
+ */
+ prependOnceListener(
+ eventName: N,
+ listener: (arg: E[typeof eventName]) => void
+ ): this {
+ const wrapper = (arg: any): void => {
+ this.removeListener(eventName, wrapper)
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ listener(arg)
+ }
+ return this.prependListener(eventName, wrapper)
+ }
+}
+
+/**
+ * @since 1.1.0
+ */
+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
+ * import { Command } from 'tauri-plugin-shell-api';
+ * const command = Command.create('node');
+ * const child = await command.spawn();
+ * await child.write('message');
+ * await child.write([0, 1, 2, 3, 4, 5]);
+ * ```
+ *
+ * @returns A promise indicating the success or failure of the operation.
+ */
+ async write(data: IOPayload): Promise {
+ return invoke('plugin:shell|stdin_write', {
+ pid: this.pid,
+ // correctly serialize Uint8Arrays
+ buffer: typeof data === 'string' ? data : Array.from(data)
+ })
+ }
+
+ /**
+ * Kills the child process.
+ *
+ * @returns A promise indicating the success or failure of the operation.
+ */
+ async kill(): Promise {
+ return invoke('plugin:shell|kill', {
+ cmd: 'killChild',
+ pid: this.pid
+ })
+ }
+}
+
+interface CommandEvents {
+ close: TerminatedPayload
+ error: string
+}
+
+interface OutputEvents {
+ data: O
+}
+
+/**
+ * The entry point for spawning child processes.
+ * It emits the `close` and `error` events.
+ * @example
+ * ```typescript
+ * import { Command } from 'tauri-plugin-shell-api';
+ * const command = Command.create('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);
+ * ```
+ *
+ * @since 1.1.0
+ *
+ */
+class Command extends EventEmitter {
+ /** @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>()
+ /** Event emitter for the `stderr`. Emits the `data` event. */
+ readonly stderr = new EventEmitter>()
+
+ /**
+ * @ignore
+ * 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.
+ */
+ private constructor(
+ program: string,
+ args: string | string[] = [],
+ options?: SpawnOptions
+ ) {
+ super()
+ this.program = program
+ this.args = typeof args === 'string' ? [args] : args
+ this.options = options ?? {}
+ }
+
+ static create(program: string, args?: string | string[]): Command
+ static create(
+ program: string,
+ args?: string | string[],
+ options?: SpawnOptions & { encoding: 'raw' }
+ ): Command
+ static create(
+ program: string,
+ args?: string | string[],
+ options?: SpawnOptions
+ ): Command
+
+ /**
+ * Creates a command to execute the given program.
+ * @example
+ * ```typescript
+ * import { Command } from 'tauri-plugin-shell-api';
+ * const command = Command.create('my-app', ['run', 'tauri']);
+ * const output = await command.execute();
+ * ```
+ *
+ * @param program The program to execute.
+ * It must be configured on `tauri.conf.json > tauri > allowlist > shell > scope`.
+ */
+ static create(
+ program: string,
+ args: string | string[] = [],
+ options?: SpawnOptions
+ ): Command {
+ return new Command(program, args, options)
+ }
+
+ static sidecar(program: string, args?: string | string[]): Command
+ static sidecar(
+ program: string,
+ args?: string | string[],
+ options?: SpawnOptions & { encoding: 'raw' }
+ ): Command
+ static sidecar(
+ program: string,
+ args?: string | string[],
+ options?: SpawnOptions
+ ): Command
+
+ /**
+ * Creates a command to execute the given sidecar program.
+ * @example
+ * ```typescript
+ * import { Command } from 'tauri-plugin-shell-api';
+ * 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`.
+ */
+ 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.
+ *
+ * @returns A promise resolving to the child process handle.
+ */
+ async spawn(): Promise {
+ 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
+ * import { Command } from 'tauri-plugin-shell-api';
+ * const output = await Command.create('echo', 'message').execute();
+ * assert(output.code === 0);
+ * assert(output.signal === null);
+ * assert(output.stdout === 'message');
+ * assert(output.stderr === '');
+ * ```
+ *
+ * @returns A promise resolving to the child process output.
+ */
+ async execute(): Promise> {
+ return new Promise((resolve, reject) => {
+ this.on('error', reject)
+
+ const stdout: O[] = []
+ const stderr: O[] = []
+ this.stdout.on('data', (line: O) => {
+ stdout.push(line)
+ })
+ this.stderr.on('data', (line: O) => {
+ stderr.push(line)
+ })
+
+ this.on('close', (payload: TerminatedPayload) => {
+ resolve({
+ code: payload.code,
+ signal: payload.signal,
+ stdout: this.collectOutput(stdout) as O,
+ stderr: this.collectOutput(stderr) as O
+ })
+ })
+
+ this.spawn().catch(reject)
+ })
+ }
+
+ /** @ignore */
+ private collectOutput(events: O[]): string | Uint8Array {
+ if (this.options.encoding === 'raw') {
+ return events.reduce((p, c) => {
+ return new Uint8Array([...p, ...(c as Uint8Array), 10])
+ }, new Uint8Array())
+ } else {
+ return events.join('\n')
+ }
+ }
+}
+
+/**
+ * Describes the event message received from the command.
+ */
+interface Event {
+ 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
+}
+
+/** Event payload type */
+type IOPayload = string | Uint8Array
+
+/** Events emitted by the child process. */
+type CommandEvent =
+ | Event<'Stdout', O>
+ | Event<'Stderr', O>
+ | 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
+ * import { open } from 'tauri-plugin-shell-api';
+ * // 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 `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`.
+ * @param openWith The app to open the file or URL with.
+ * Defaults to the system default application for the specified path type.
+ *
+ * @since 1.0.0
+ */
+async function open(path: string, openWith?: string): Promise {
+ return invoke('plugin:shell|open', {
+ path,
+ with: openWith
+ })
+}
+
+export { Command, Child, EventEmitter, open }
+export type {
+ IOPayload,
+ CommandEvents,
+ TerminatedPayload,
+ OutputEvents,
+ ChildProcess,
+ SpawnOptions
+}
diff --git a/plugins/shell/package.json b/plugins/shell/package.json
new file mode 100644
index 00000000..aa0f9f4b
--- /dev/null
+++ b/plugins/shell/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "tauri-plugin-shell-api",
+ "version": "0.0.0",
+ "license": "MIT or APACHE-2.0",
+ "authors": [
+ "Tauri Programme within The Commons Conservancy"
+ ],
+ "type": "module",
+ "browser": "dist-js/index.min.js",
+ "module": "dist-js/index.mjs",
+ "types": "dist-js/index.d.ts",
+ "exports": {
+ "import": "./dist-js/index.mjs",
+ "types": "./dist-js/index.d.ts",
+ "browser": "./dist-js/index.min.js"
+ },
+ "scripts": {
+ "build": "rollup -c"
+ },
+ "files": [
+ "dist-js",
+ "!dist-js/**/*.map",
+ "README.md",
+ "LICENSE"
+ ],
+ "devDependencies": {
+ "tslib": "^2.4.1"
+ },
+ "dependencies": {
+ "@tauri-apps/api": "^1.2.0"
+ }
+}
\ No newline at end of file
diff --git a/plugins/shell/rollup.config.mjs b/plugins/shell/rollup.config.mjs
new file mode 100644
index 00000000..6555e98b
--- /dev/null
+++ b/plugins/shell/rollup.config.mjs
@@ -0,0 +1,11 @@
+import { readFileSync } from "fs";
+
+import { createConfig } from "../../shared/rollup.config.mjs";
+
+export default createConfig({
+ input: "guest-js/index.ts",
+ pkg: JSON.parse(
+ readFileSync(new URL("./package.json", import.meta.url), "utf8")
+ ),
+ external: [/^@tauri-apps\/api/],
+});
diff --git a/plugins/shell/src/commands.rs b/plugins/shell/src/commands.rs
new file mode 100644
index 00000000..226022d5
--- /dev/null
+++ b/plugins/shell/src/commands.rs
@@ -0,0 +1,211 @@
+use std::{collections::HashMap, path::PathBuf, string::FromUtf8Error};
+
+use encoding_rs::Encoding;
+use serde::{Deserialize, Serialize};
+use tauri::{api::ipc::CallbackFn, Manager, Runtime, State, Window};
+
+use crate::{
+ open::Program,
+ process::{CommandEvent, TerminatedPayload},
+ scope::ExecuteArgs,
+ Shell,
+};
+
+type ChildId = u32;
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(tag = "event", content = "payload")]
+#[non_exhaustive]
+enum JSCommandEvent {
+ /// Stderr bytes until a newline (\n) or carriage return (\r) is found.
+ Stderr(Buffer),
+ /// Stdout bytes until a newline (\n) or carriage return (\r) is found.
+ Stdout(Buffer),
+ /// An error happened waiting for the command to finish or converting the stdout/stderr bytes to an UTF-8 string.
+ Error(String),
+ /// Command process terminated.
+ Terminated(TerminatedPayload),
+}
+
+fn get_event_buffer(line: Vec, encoding: EncodingWrapper) -> Result {
+ match encoding {
+ EncodingWrapper::Text(character_encoding) => match character_encoding {
+ Some(encoding) => Ok(Buffer::Text(
+ encoding.decode_with_bom_removal(&line).0.into(),
+ )),
+ None => String::from_utf8(line).map(Buffer::Text),
+ },
+ EncodingWrapper::Raw => Ok(Buffer::Raw(line)),
+ }
+}
+
+impl JSCommandEvent {
+ pub fn new(event: CommandEvent, encoding: EncodingWrapper) -> Self {
+ match event {
+ CommandEvent::Terminated(payload) => JSCommandEvent::Terminated(payload),
+ CommandEvent::Error(error) => JSCommandEvent::Error(error),
+ CommandEvent::Stderr(line) => get_event_buffer(line, encoding)
+ .map(JSCommandEvent::Stderr)
+ .unwrap_or_else(|e| JSCommandEvent::Error(e.to_string())),
+ CommandEvent::Stdout(line) => get_event_buffer(line, encoding)
+ .map(JSCommandEvent::Stdout)
+ .unwrap_or_else(|e| JSCommandEvent::Error(e.to_string())),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(untagged)]
+#[allow(missing_docs)]
+pub enum Buffer {
+ Text(String),
+ Raw(Vec),
+}
+
+#[derive(Debug, Copy, Clone)]
+pub enum EncodingWrapper {
+ Raw,
+ Text(Option<&'static Encoding>),
+}
+
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CommandOptions {
+ #[serde(default)]
+ sidecar: bool,
+ cwd: Option,
+ // by default we don't add any env variables to the spawned process
+ // but the env is an `Option` so when it's `None` we clear the env.
+ #[serde(default = "default_env")]
+ env: Option>,
+ // Character encoding for stdout/stderr
+ encoding: Option,
+}
+
+#[allow(clippy::unnecessary_wraps)]
+fn default_env() -> Option> {
+ Some(HashMap::default())
+}
+
+#[tauri::command]
+pub fn execute(
+ window: Window,
+ shell: State<'_, Shell>,
+ program: String,
+ args: ExecuteArgs,
+ on_event_fn: CallbackFn,
+ options: CommandOptions,
+) -> crate::Result {
+ let mut command = if options.sidecar {
+ let program = PathBuf::from(program);
+ let program_as_string = program.display().to_string();
+ let program_no_ext_as_string = program.with_extension("").display().to_string();
+ let configured_sidecar = window
+ .config()
+ .tauri
+ .bundle
+ .external_bin
+ .as_ref()
+ .and_then(|bins| {
+ bins.iter()
+ .find(|b| b == &&program_as_string || b == &&program_no_ext_as_string)
+ })
+ .cloned();
+ if let Some(sidecar) = configured_sidecar {
+ shell
+ .scope
+ .prepare_sidecar(&program.to_string_lossy(), &sidecar, args)?
+ } else {
+ return Err(crate::Error::SidecarNotAllowed(program));
+ }
+ } else {
+ match shell.scope.prepare(&program, args) {
+ Ok(cmd) => cmd,
+ Err(e) => {
+ #[cfg(debug_assertions)]
+ eprintln!("{e}");
+ return Err(crate::Error::ProgramNotAllowed(PathBuf::from(program)));
+ }
+ }
+ };
+ if let Some(cwd) = options.cwd {
+ command = command.current_dir(cwd);
+ }
+ if let Some(env) = options.env {
+ command = command.envs(env);
+ } else {
+ command = command.env_clear();
+ }
+ let encoding = match options.encoding {
+ Option::None => EncodingWrapper::Text(None),
+ Some(encoding) => match encoding.as_str() {
+ "raw" => EncodingWrapper::Raw,
+ _ => {
+ if let Some(text_encoding) = Encoding::for_label(encoding.as_bytes()) {
+ EncodingWrapper::Text(Some(text_encoding))
+ } else {
+ return Err(crate::Error::UnknownEncoding(encoding));
+ }
+ }
+ },
+ };
+
+ let (mut rx, child) = command.spawn()?;
+
+ let pid = child.pid();
+ shell.children.lock().unwrap().insert(pid, child);
+ let children = shell.children.clone();
+
+ tauri::async_runtime::spawn(async move {
+ while let Some(event) = rx.recv().await {
+ if matches!(event, crate::process::CommandEvent::Terminated(_)) {
+ children.lock().unwrap().remove(&pid);
+ };
+ let js_event = JSCommandEvent::new(event, encoding);
+ let js = tauri::api::ipc::format_callback(on_event_fn, &js_event)
+ .expect("unable to serialize CommandEvent");
+
+ let _ = window.eval(js.as_str());
+ }
+ });
+
+ Ok(pid)
+}
+
+#[tauri::command]
+pub fn stdin_write(
+ _window: Window,
+ shell: State<'_, Shell>,
+ pid: ChildId,
+ buffer: Buffer,
+) -> crate::Result<()> {
+ if let Some(child) = shell.children.lock().unwrap().get_mut(&pid) {
+ match buffer {
+ Buffer::Text(t) => child.write(t.as_bytes())?,
+ Buffer::Raw(r) => child.write(&r)?,
+ }
+ }
+ Ok(())
+}
+
+#[tauri::command]
+pub fn kill(
+ _window: Window,
+ shell: State<'_, Shell>,
+ pid: ChildId,
+) -> crate::Result<()> {
+ if let Some(child) = shell.children.lock().unwrap().remove(&pid) {
+ child.kill()?;
+ }
+ Ok(())
+}
+
+#[tauri::command]
+pub fn open(
+ _window: Window,
+ shell: State<'_, Shell>,
+ path: String,
+ with: Option,
+) -> crate::Result<()> {
+ shell.open(path, with)
+}
diff --git a/plugins/shell/src/error.rs b/plugins/shell/src/error.rs
new file mode 100644
index 00000000..968e70a6
--- /dev/null
+++ b/plugins/shell/src/error.rs
@@ -0,0 +1,32 @@
+use std::path::PathBuf;
+
+use serde::{Serialize, Serializer};
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(transparent)]
+ Io(#[from] std::io::Error),
+ #[error("current executable path has no parent")]
+ CurrentExeHasNoParent,
+ #[error("unknown program {0}")]
+ UnknownProgramName(String),
+ #[error(transparent)]
+ Scope(#[from] crate::scope::Error),
+ /// Sidecar not allowed by the configuration.
+ #[error("sidecar not configured under `tauri.conf.json > tauri > bundle > externalBin`: {0}")]
+ SidecarNotAllowed(PathBuf),
+ /// Program not allowed by the scope.
+ #[error("program not allowed on the configured shell scope: {0}")]
+ ProgramNotAllowed(PathBuf),
+ #[error("unknown encoding {0}")]
+ UnknownEncoding(String),
+}
+
+impl Serialize for Error {
+ fn serialize(&self, serializer: S) -> std::result::Result
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(self.to_string().as_ref())
+ }
+}
diff --git a/plugins/shell/src/lib.rs b/plugins/shell/src/lib.rs
new file mode 100644
index 00000000..26e6d932
--- /dev/null
+++ b/plugins/shell/src/lib.rs
@@ -0,0 +1,156 @@
+use std::{
+ collections::HashMap,
+ sync::{Arc, Mutex},
+};
+
+use process::{Command, CommandChild};
+use regex::Regex;
+use scope::{Scope, ScopeAllowedCommand, ScopeConfig};
+use tauri::{
+ plugin::{Builder, TauriPlugin},
+ utils::config::{ShellAllowedArg, ShellAllowedArgs, ShellAllowlistOpen, ShellAllowlistScope},
+ AppHandle, Manager, RunEvent, Runtime,
+};
+
+mod commands;
+mod error;
+mod open;
+pub mod process;
+mod scope;
+
+pub use error::Error;
+type Result = std::result::Result;
+type ChildStore = Arc>>;
+
+pub struct Shell {
+ #[allow(dead_code)]
+ app: AppHandle,
+ scope: Scope,
+ children: ChildStore,
+}
+
+impl Shell {
+ /// Creates a new Command for launching the given program.
+ pub fn command(&self, program: impl Into) -> Command {
+ Command::new(program)
+ }
+
+ /// Creates a new Command for launching the given sidecar program.
+ ///
+ /// A sidecar program is a embedded external binary in order to make your application work
+ /// or to prevent users having to install additional dependencies (e.g. Node.js, Python, etc).
+ pub fn sidecar(&self, program: impl Into) -> Result {
+ Command::new_sidecar(program)
+ }
+
+ /// Open a (url) path with a default or specific browser opening program.
+ ///
+ /// See [`crate::api::shell::open`] for how it handles security-related measures.
+ pub fn open(&self, path: String, with: Option) -> Result<()> {
+ open::open(&self.scope, path, with).map_err(Into::into)
+ }
+}
+
+pub trait ShellExt {
+ fn shell(&self) -> &Shell;
+}
+
+impl> ShellExt for T {
+ fn shell(&self) -> &Shell {
+ self.state::>().inner()
+ }
+}
+
+pub fn init() -> TauriPlugin {
+ Builder::new("shell")
+ .invoke_handler(tauri::generate_handler![
+ commands::execute,
+ commands::stdin_write,
+ commands::kill,
+ commands::open
+ ])
+ .setup(|app, _api| {
+ app.manage(Shell {
+ app: app.clone(),
+ children: Default::default(),
+ scope: Scope::new(
+ app,
+ shell_scope(
+ app.config().tauri.allowlist.shell.scope.clone(),
+ &app.config().tauri.allowlist.shell.open,
+ ),
+ ),
+ });
+ Ok(())
+ })
+ .on_event(|app, event| {
+ if let RunEvent::Exit = event {
+ let shell = app.state::>();
+ let children = {
+ let mut lock = shell.children.lock().unwrap();
+ std::mem::take(&mut *lock)
+ };
+ for child in children.into_values() {
+ let _ = child.kill();
+ }
+ }
+ })
+ .build()
+}
+
+fn shell_scope(scope: ShellAllowlistScope, open: &ShellAllowlistOpen) -> ScopeConfig {
+ let shell_scopes = get_allowed_clis(scope);
+
+ let shell_scope_open = match open {
+ ShellAllowlistOpen::Flag(false) => None,
+ ShellAllowlistOpen::Flag(true) => {
+ Some(Regex::new(r#"^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+"#).unwrap())
+ }
+ ShellAllowlistOpen::Validate(validator) => {
+ let validator =
+ Regex::new(validator).unwrap_or_else(|e| panic!("invalid regex {validator}: {e}"));
+ Some(validator)
+ }
+ _ => panic!("unknown shell open format, unable to prepare"),
+ };
+
+ ScopeConfig {
+ open: shell_scope_open,
+ scopes: shell_scopes,
+ }
+}
+
+fn get_allowed_clis(scope: ShellAllowlistScope) -> HashMap {
+ scope
+ .0
+ .into_iter()
+ .map(|scope| {
+ let args = match scope.args {
+ ShellAllowedArgs::Flag(true) => None,
+ ShellAllowedArgs::Flag(false) => Some(Vec::new()),
+ ShellAllowedArgs::List(list) => {
+ let list = list.into_iter().map(|arg| match arg {
+ ShellAllowedArg::Fixed(fixed) => scope::ScopeAllowedArg::Fixed(fixed),
+ ShellAllowedArg::Var { validator } => {
+ let validator = Regex::new(&validator)
+ .unwrap_or_else(|e| panic!("invalid regex {validator}: {e}"));
+ scope::ScopeAllowedArg::Var { validator }
+ }
+ _ => panic!("unknown shell scope arg, unable to prepare"),
+ });
+ Some(list.collect())
+ }
+ _ => panic!("unknown shell scope command, unable to prepare"),
+ };
+
+ (
+ scope.name,
+ ScopeAllowedCommand {
+ command: scope.command,
+ args,
+ sidecar: scope.sidecar,
+ },
+ )
+ })
+ .collect()
+}
diff --git a/plugins/shell/src/open.rs b/plugins/shell/src/open.rs
new file mode 100644
index 00000000..5cc50927
--- /dev/null
+++ b/plugins/shell/src/open.rs
@@ -0,0 +1,122 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+//! Types and functions related to shell.
+
+use serde::{Deserialize, Deserializer};
+
+use crate::scope::Scope;
+use std::str::FromStr;
+
+/// Program to use on the [`open()`] call.
+pub enum Program {
+ /// Use the `open` program.
+ Open,
+ /// Use the `start` program.
+ Start,
+ /// Use the `xdg-open` program.
+ XdgOpen,
+ /// Use the `gio` program.
+ Gio,
+ /// Use the `gnome-open` program.
+ GnomeOpen,
+ /// Use the `kde-open` program.
+ KdeOpen,
+ /// Use the `wslview` program.
+ WslView,
+ /// Use the `Firefox` program.
+ Firefox,
+ /// Use the `Google Chrome` program.
+ Chrome,
+ /// Use the `Chromium` program.
+ Chromium,
+ /// Use the `Safari` program.
+ Safari,
+}
+
+impl FromStr for Program {
+ type Err = super::Error;
+
+ fn from_str(s: &str) -> Result {
+ let p = match s.to_lowercase().as_str() {
+ "open" => Self::Open,
+ "start" => Self::Start,
+ "xdg-open" => Self::XdgOpen,
+ "gio" => Self::Gio,
+ "gnome-open" => Self::GnomeOpen,
+ "kde-open" => Self::KdeOpen,
+ "wslview" => Self::WslView,
+ "firefox" => Self::Firefox,
+ "chrome" | "google chrome" => Self::Chrome,
+ "chromium" => Self::Chromium,
+ "safari" => Self::Safari,
+ _ => return Err(crate::Error::UnknownProgramName(s.to_string())),
+ };
+ Ok(p)
+ }
+}
+
+impl<'de> Deserialize<'de> for Program {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: Deserializer<'de>,
+ {
+ let s = String::deserialize(deserializer)?;
+ Program::from_str(&s).map_err(|e| serde::de::Error::custom(e.to_string()))
+ }
+}
+
+impl Program {
+ pub(crate) fn name(self) -> &'static str {
+ match self {
+ Self::Open => "open",
+ Self::Start => "start",
+ Self::XdgOpen => "xdg-open",
+ Self::Gio => "gio",
+ Self::GnomeOpen => "gnome-open",
+ Self::KdeOpen => "kde-open",
+ Self::WslView => "wslview",
+
+ #[cfg(target_os = "macos")]
+ Self::Firefox => "Firefox",
+ #[cfg(not(target_os = "macos"))]
+ Self::Firefox => "firefox",
+
+ #[cfg(target_os = "macos")]
+ Self::Chrome => "Google Chrome",
+ #[cfg(not(target_os = "macos"))]
+ Self::Chrome => "google-chrome",
+
+ #[cfg(target_os = "macos")]
+ Self::Chromium => "Chromium",
+ #[cfg(not(target_os = "macos"))]
+ Self::Chromium => "chromium",
+
+ #[cfg(target_os = "macos")]
+ Self::Safari => "Safari",
+ #[cfg(not(target_os = "macos"))]
+ Self::Safari => "safari",
+ }
+ }
+}
+
+/// Opens path or URL with the program specified in `with`, or system default if `None`.
+///
+/// The path will be matched against the shell open validation regex, defaulting to `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`.
+/// A custom validation regex may be supplied in the config in `tauri > allowlist > scope > open`.
+///
+/// # Examples
+///
+/// ```rust,no_run
+/// use tauri_plugin_shell::ShellExt;
+/// tauri::Builder::default()
+/// .setup(|app| {
+/// // open the given URL on the system default browser
+/// app.shell().open("https://github.com/tauri-apps/tauri", None)?;
+/// Ok(())
+/// });
+/// ```
+pub fn open>(scope: &Scope, path: P, with: Option) -> crate::Result<()> {
+ scope.open(path.as_ref(), with).map_err(Into::into)
+}
diff --git a/plugins/shell/src/process/mod.rs b/plugins/shell/src/process/mod.rs
new file mode 100644
index 00000000..751d54d4
--- /dev/null
+++ b/plugins/shell/src/process/mod.rs
@@ -0,0 +1,504 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use std::{
+ collections::HashMap,
+ io::{BufReader, Write},
+ path::PathBuf,
+ process::{Command as StdCommand, Stdio},
+ sync::{Arc, RwLock},
+ thread::spawn,
+};
+
+#[cfg(unix)]
+use std::os::unix::process::ExitStatusExt;
+#[cfg(windows)]
+use std::os::windows::process::CommandExt;
+
+#[cfg(windows)]
+const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+const NEWLINE_BYTE: u8 = b'\n';
+
+use tauri::async_runtime::{block_on as block_on_task, channel, Receiver, Sender};
+
+pub use encoding_rs::Encoding;
+use os_pipe::{pipe, PipeReader, PipeWriter};
+use serde::Serialize;
+use shared_child::SharedChild;
+use tauri::utils::platform;
+
+/// Payload for the [`CommandEvent::Terminated`] command event.
+#[derive(Debug, Clone, Serialize)]
+pub struct TerminatedPayload {
+ /// Exit code of the process.
+ pub code: Option,
+ /// If the process was terminated by a signal, represents that signal.
+ pub signal: Option,
+}
+
+/// A event sent to the command callback.
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub enum CommandEvent {
+ /// Stderr bytes until a newline (\n) or carriage return (\r) is found.
+ Stderr(Vec),
+ /// Stdout bytes until a newline (\n) or carriage return (\r) is found.
+ Stdout(Vec),
+ /// An error happened waiting for the command to finish or converting the stdout/stderr bytes to an UTF-8 string.
+ Error(String),
+ /// Command process terminated.
+ Terminated(TerminatedPayload),
+}
+
+/// The type to spawn commands.
+#[derive(Debug)]
+pub struct Command {
+ program: String,
+ args: Vec,
+ env_clear: bool,
+ env: HashMap,
+ current_dir: Option,
+}
+
+/// Spawned child process.
+#[derive(Debug)]
+pub struct CommandChild {
+ inner: Arc,
+ stdin_writer: PipeWriter,
+}
+
+impl CommandChild {
+ /// Writes to process stdin.
+ pub fn write(&mut self, buf: &[u8]) -> crate::Result<()> {
+ self.stdin_writer.write_all(buf)?;
+ Ok(())
+ }
+
+ /// Sends a kill signal to the child.
+ pub fn kill(self) -> crate::Result<()> {
+ self.inner.kill()?;
+ Ok(())
+ }
+
+ /// Returns the process pid.
+ pub fn pid(&self) -> u32 {
+ self.inner.id()
+ }
+}
+
+/// Describes the result of a process after it has terminated.
+#[derive(Debug)]
+pub struct ExitStatus {
+ code: Option,
+}
+
+impl ExitStatus {
+ /// Returns the exit code of the process, if any.
+ pub fn code(&self) -> Option {
+ self.code
+ }
+
+ /// Returns true if exit status is zero. Signal termination is not considered a success, and success is defined as a zero exit status.
+ pub fn success(&self) -> bool {
+ self.code == Some(0)
+ }
+}
+
+/// The output of a finished process.
+#[derive(Debug)]
+pub struct Output {
+ /// The status (exit code) of the process.
+ pub status: ExitStatus,
+ /// The data that the process wrote to stdout.
+ pub stdout: Vec,
+ /// The data that the process wrote to stderr.
+ pub stderr: Vec,
+}
+
+fn relative_command_path(command: String) -> crate::Result {
+ match platform::current_exe()?.parent() {
+ #[cfg(windows)]
+ Some(exe_dir) => Ok(format!("{}\\{command}.exe", exe_dir.display())),
+ #[cfg(not(windows))]
+ Some(exe_dir) => Ok(format!("{}/{command}", exe_dir.display())),
+ None => Err(crate::Error::CurrentExeHasNoParent),
+ }
+}
+
+impl From for StdCommand {
+ fn from(cmd: Command) -> StdCommand {
+ let mut command = StdCommand::new(cmd.program);
+ command.args(cmd.args);
+ command.stdout(Stdio::piped());
+ command.stdin(Stdio::piped());
+ command.stderr(Stdio::piped());
+ if cmd.env_clear {
+ command.env_clear();
+ }
+ command.envs(cmd.env);
+ if let Some(current_dir) = cmd.current_dir {
+ command.current_dir(current_dir);
+ }
+ #[cfg(windows)]
+ command.creation_flags(CREATE_NO_WINDOW);
+ command
+ }
+}
+
+impl Command {
+ pub(crate) fn new>(program: S) -> Self {
+ Self {
+ program: program.into(),
+ args: Default::default(),
+ env_clear: false,
+ env: Default::default(),
+ current_dir: None,
+ }
+ }
+
+ pub(crate) fn new_sidecar>(program: S) -> crate::Result {
+ Ok(Self::new(relative_command_path(program.into())?))
+ }
+
+ /// Appends arguments to the command.
+ #[must_use]
+ pub fn args(mut self, args: I) -> Self
+ where
+ I: IntoIterator- ,
+ S: AsRef,
+ {
+ for arg in args {
+ self.args.push(arg.as_ref().to_string());
+ }
+ self
+ }
+
+ /// Clears the entire environment map for the child process.
+ #[must_use]
+ pub fn env_clear(mut self) -> Self {
+ self.env_clear = true;
+ self
+ }
+
+ /// Adds or updates multiple environment variable mappings.
+ #[must_use]
+ pub fn envs(mut self, env: HashMap) -> Self {
+ self.env = env;
+ self
+ }
+
+ /// Sets the working directory for the child process.
+ #[must_use]
+ pub fn current_dir(mut self, current_dir: PathBuf) -> Self {
+ self.current_dir.replace(current_dir);
+ self
+ }
+
+ /// Spawns the command.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,no_run
+ /// use tauri::api::process::{Command, CommandEvent};
+ /// tauri::async_runtime::spawn(async move {
+ /// let (mut rx, mut child) = Command::new("cargo")
+ /// .args(["tauri", "dev"])
+ /// .spawn()
+ /// .expect("Failed to spawn cargo");
+ ///
+ /// let mut i = 0;
+ /// while let Some(event) = rx.recv().await {
+ /// if let CommandEvent::Stdout(line) = event {
+ /// println!("got: {}", String::from_utf8(line).unwrap());
+ /// i += 1;
+ /// if i == 4 {
+ /// child.write("message from Rust\n".as_bytes()).unwrap();
+ /// i = 0;
+ /// }
+ /// }
+ /// }
+ /// });
+ /// ```
+ pub fn spawn(self) -> crate::Result<(Receiver, CommandChild)> {
+ let mut command: StdCommand = self.into();
+ let (stdout_reader, stdout_writer) = pipe()?;
+ let (stderr_reader, stderr_writer) = pipe()?;
+ let (stdin_reader, stdin_writer) = pipe()?;
+ command.stdout(stdout_writer);
+ command.stderr(stderr_writer);
+ command.stdin(stdin_reader);
+
+ let shared_child = SharedChild::spawn(&mut command)?;
+ let child = Arc::new(shared_child);
+ let child_ = child.clone();
+ let guard = Arc::new(RwLock::new(()));
+
+ //TODO commands().lock().unwrap().insert(child.id(), child.clone());
+
+ let (tx, rx) = channel(1);
+
+ spawn_pipe_reader(
+ tx.clone(),
+ guard.clone(),
+ stdout_reader,
+ CommandEvent::Stdout,
+ );
+ spawn_pipe_reader(
+ tx.clone(),
+ guard.clone(),
+ stderr_reader,
+ CommandEvent::Stderr,
+ );
+
+ spawn(move || {
+ let _ = match child_.wait() {
+ Ok(status) => {
+ let _l = guard.write().unwrap();
+ //TODO commands().lock().unwrap().remove(&child_.id());
+ block_on_task(async move {
+ tx.send(CommandEvent::Terminated(TerminatedPayload {
+ code: status.code(),
+ #[cfg(windows)]
+ signal: None,
+ #[cfg(unix)]
+ signal: status.signal(),
+ }))
+ .await
+ })
+ }
+ Err(e) => {
+ let _l = guard.write().unwrap();
+ block_on_task(async move { tx.send(CommandEvent::Error(e.to_string())).await })
+ }
+ };
+ });
+
+ Ok((
+ rx,
+ CommandChild {
+ inner: child,
+ stdin_writer,
+ },
+ ))
+ }
+
+ /// Executes a command as a child process, waiting for it to finish and collecting its exit status.
+ /// Stdin, stdout and stderr are ignored.
+ ///
+ /// # Examples
+ /// ```rust,no_run
+ /// use tauri::api::process::Command;
+ /// let status = Command::new("which").args(["ls"]).status().unwrap();
+ /// println!("`which` finished with status: {:?}", status.code());
+ /// ```
+ pub async fn status(self) -> crate::Result {
+ let (mut rx, _child) = self.spawn()?;
+ let mut code = None;
+ #[allow(clippy::collapsible_match)]
+ while let Some(event) = rx.recv().await {
+ if let CommandEvent::Terminated(payload) = event {
+ code = payload.code;
+ }
+ }
+ Ok(ExitStatus { code })
+ }
+
+ /// Executes the command as a child process, waiting for it to finish and collecting all of its output.
+ /// Stdin is ignored.
+ ///
+ /// # Examples
+ ///
+ /// ```rust,no_run
+ /// use tauri::api::process::Command;
+ /// let output = Command::new("echo").args(["TAURI"]).output().unwrap();
+ /// assert!(output.status.success());
+ /// assert_eq!(String::from_utf8(output.stdout).unwrap(), "TAURI");
+ /// ```
+ pub async fn output(self) -> crate::Result