From 8ed00adaa085bbbd92216a2f66e3bc9d61f7c010 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Sun, 23 Apr 2023 11:39:48 -0700 Subject: [PATCH] feat(shell): add plugin (#327) --- Cargo.lock | 71 ++++ plugins/shell/Cargo.toml | 18 + plugins/shell/LICENSE.spdx | 20 + plugins/shell/LICENSE_APACHE-2.0 | 177 +++++++++ plugins/shell/LICENSE_MIT | 21 + plugins/shell/README.md | 65 +++ plugins/shell/guest-js/index.ts | 657 +++++++++++++++++++++++++++++++ plugins/shell/package.json | 32 ++ plugins/shell/rollup.config.mjs | 11 + plugins/shell/src/commands.rs | 211 ++++++++++ plugins/shell/src/error.rs | 32 ++ plugins/shell/src/lib.rs | 156 ++++++++ plugins/shell/src/open.rs | 122 ++++++ plugins/shell/src/process/mod.rs | 504 ++++++++++++++++++++++++ plugins/shell/src/scope.rs | 272 +++++++++++++ plugins/shell/tsconfig.json | 4 + pnpm-lock.yaml | 10 + 17 files changed, 2383 insertions(+) create mode 100644 plugins/shell/Cargo.toml create mode 100644 plugins/shell/LICENSE.spdx create mode 100644 plugins/shell/LICENSE_APACHE-2.0 create mode 100644 plugins/shell/LICENSE_MIT create mode 100644 plugins/shell/README.md create mode 100644 plugins/shell/guest-js/index.ts create mode 100644 plugins/shell/package.json create mode 100644 plugins/shell/rollup.config.mjs create mode 100644 plugins/shell/src/commands.rs create mode 100644 plugins/shell/src/error.rs create mode 100644 plugins/shell/src/lib.rs create mode 100644 plugins/shell/src/open.rs create mode 100644 plugins/shell/src/process/mod.rs create mode 100644 plugins/shell/src/scope.rs create mode 100644 plugins/shell/tsconfig.json 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 @@ +![{{plugin name}}](banner.jpg) + + + +## 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 { + let (mut rx, _child) = self.spawn()?; + + let mut code = None; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + code = payload.code; + } + CommandEvent::Stdout(line) => { + stdout.extend(line); + stdout.push(NEWLINE_BYTE); + } + CommandEvent::Stderr(line) => { + stderr.extend(line); + stderr.push(NEWLINE_BYTE); + } + CommandEvent::Error(_) => {} + } + } + Ok(Output { + status: ExitStatus { code }, + stdout, + stderr, + }) + } +} + +fn spawn_pipe_reader) -> CommandEvent + Send + Copy + 'static>( + tx: Sender, + guard: Arc>, + pipe_reader: PipeReader, + wrapper: F, +) { + spawn(move || { + let _lock = guard.read().unwrap(); + let mut reader = BufReader::new(pipe_reader); + + loop { + let mut buf = Vec::new(); + match tauri::utils::io::read_line(&mut reader, &mut buf) { + Ok(n) => { + if n == 0 { + break; + } + let tx_ = tx.clone(); + let _ = block_on_task(async move { tx_.send(wrapper(buf)).await }); + } + Err(e) => { + let tx_ = tx.clone(); + let _ = + block_on_task( + async move { tx_.send(CommandEvent::Error(e.to_string())).await }, + ); + } + } + } + }); +} + +// tests for the commands functions. +#[cfg(test)] +mod tests { + #[cfg(not(windows))] + use super::*; + + #[cfg(not(windows))] + #[test] + fn test_cmd_spawn_output() { + let cmd = Command::new("cat").args(["test/api/test.txt"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(0)); + } + CommandEvent::Stdout(line) => { + assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!"); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + fn test_cmd_spawn_raw_output() { + let cmd = Command::new("cat").args(["test/api/test.txt"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(0)); + } + CommandEvent::Stdout(line) => { + assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!"); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + // test the failure case + fn test_cmd_spawn_fail() { + let cmd = Command::new("cat").args(["test/api/"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(1)); + } + CommandEvent::Stderr(line) => { + assert_eq!( + String::from_utf8(line).unwrap(), + "cat: test/api/: Is a directory" + ); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + // test the failure case (raw encoding) + fn test_cmd_spawn_raw_fail() { + let cmd = Command::new("cat").args(["test/api/"]); + let (mut rx, _) = cmd.spawn().unwrap(); + + tauri::async_runtime::block_on(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Terminated(payload) => { + assert_eq!(payload.code, Some(1)); + } + CommandEvent::Stderr(line) => { + assert_eq!( + String::from_utf8(line).unwrap(), + "cat: test/api/: Is a directory" + ); + } + _ => {} + } + } + }); + } + + #[cfg(not(windows))] + #[test] + fn test_cmd_output_output() { + let cmd = Command::new("cat").args(["test/api/test.txt"]); + let output = cmd.output().unwrap(); + + assert_eq!(String::from_utf8(output.stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "This is a test doc!\n" + ); + } + + #[cfg(not(windows))] + #[test] + fn test_cmd_output_output_fail() { + let cmd = Command::new("cat").args(["test/api/"]); + let output = cmd.output().unwrap(); + + assert_eq!(String::from_utf8(output.stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(output.stderr).unwrap(), + "cat: test/api/: Is a directory\n" + ); + } +} diff --git a/plugins/shell/src/scope.rs b/plugins/shell/src/scope.rs new file mode 100644 index 00000000..871f3940 --- /dev/null +++ b/plugins/shell/src/scope.rs @@ -0,0 +1,272 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use crate::open::Program; +use crate::process::Command; +use crate::{Manager, Runtime}; + +use regex::Regex; + +use std::collections::HashMap; + +/// Allowed representation of `Execute` command arguments. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(untagged, deny_unknown_fields)] +#[non_exhaustive] +pub enum ExecuteArgs { + /// No arguments + None, + + /// A single string argument + Single(String), + + /// Multiple string arguments + List(Vec), +} + +impl ExecuteArgs { + /// Whether the argument list is empty or not. + pub fn is_empty(&self) -> bool { + match self { + Self::None => true, + Self::Single(s) if s.is_empty() => true, + Self::List(l) => l.is_empty(), + _ => false, + } + } +} + +impl From<()> for ExecuteArgs { + fn from(_: ()) -> Self { + Self::None + } +} + +impl From for ExecuteArgs { + fn from(string: String) -> Self { + Self::Single(string) + } +} + +impl From> for ExecuteArgs { + fn from(vec: Vec) -> Self { + Self::List(vec) + } +} + +/// Shell scope configuration. +#[derive(Debug, Clone)] +pub struct ScopeConfig { + /// The validation regex that `shell > open` paths must match against. + pub open: Option, + + /// All allowed commands, using their unique command name as the keys. + pub scopes: HashMap, +} + +/// A configured scoped shell command. +#[derive(Debug, Clone)] +pub struct ScopeAllowedCommand { + /// The shell command to be called. + pub command: std::path::PathBuf, + + /// The arguments the command is allowed to be called with. + pub args: Option>, + + /// If this command is a sidecar command. + pub sidecar: bool, +} + +/// A configured argument to a scoped shell command. +#[derive(Debug, Clone)] +pub enum ScopeAllowedArg { + /// A non-configurable argument. + Fixed(String), + + /// An argument with a value to be evaluated at runtime, must pass a regex validation. + Var { + /// The validation that the variable value must pass in order to be called. + validator: Regex, + }, +} + +impl ScopeAllowedArg { + /// If the argument is fixed. + pub fn is_fixed(&self) -> bool { + matches!(self, Self::Fixed(_)) + } +} + +/// Scope for filesystem access. +#[derive(Clone)] +pub struct Scope(ScopeConfig); + +/// All errors that can happen while validating a scoped command. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// At least one argument did not pass input validation. + #[error("The scoped command was called with the improper sidecar flag set")] + BadSidecarFlag, + + /// The sidecar program validated but failed to find the sidecar path. + #[error( + "The scoped sidecar command was validated, but failed to create the path to the command: {0}" + )] + Sidecar(String), + + /// The named command was not found in the scoped config. + #[error("Scoped command {0} not found")] + NotFound(String), + + /// A command variable has no value set in the arguments. + #[error( + "Scoped command argument at position {0} must match regex validation {1} but it was not found" + )] + MissingVar(usize, String), + + /// At least one argument did not pass input validation. + #[error("Scoped command argument at position {index} was found, but failed regex validation {validation}")] + Validation { + /// Index of the variable. + index: usize, + + /// Regex that the variable value failed to match. + validation: String, + }, + + /// The format of the passed input does not match the expected shape. + /// + /// This can happen from passing a string or array of strings to a command that is expecting + /// named variables, and vice-versa. + #[error("Scoped command {0} received arguments in an unexpected format")] + InvalidInput(String), + + /// A generic IO error that occurs while executing specified shell commands. + #[error("Scoped shell IO error: {0}")] + Io(#[from] std::io::Error), +} + +impl Scope { + /// Creates a new shell scope. + pub(crate) fn new>(manager: &M, mut scope: ScopeConfig) -> Self { + for cmd in scope.scopes.values_mut() { + if let Ok(path) = manager.path().parse(&cmd.command) { + cmd.command = path; + } + } + Self(scope) + } + + /// Validates argument inputs and creates a Tauri sidecar [`Command`]. + pub fn prepare_sidecar( + &self, + command_name: &str, + command_script: &str, + args: ExecuteArgs, + ) -> Result { + self._prepare(command_name, args, Some(command_script)) + } + + /// Validates argument inputs and creates a Tauri [`Command`]. + pub fn prepare(&self, command_name: &str, args: ExecuteArgs) -> Result { + self._prepare(command_name, args, None) + } + + /// Validates argument inputs and creates a Tauri [`Command`]. + pub fn _prepare( + &self, + command_name: &str, + args: ExecuteArgs, + sidecar: Option<&str>, + ) -> Result { + let command = match self.0.scopes.get(command_name) { + Some(command) => command, + None => return Err(Error::NotFound(command_name.into())), + }; + + if command.sidecar != sidecar.is_some() { + return Err(Error::BadSidecarFlag); + } + + let args = match (&command.args, args) { + (None, ExecuteArgs::None) => Ok(vec![]), + (None, ExecuteArgs::List(list)) => Ok(list), + (None, ExecuteArgs::Single(string)) => Ok(vec![string]), + (Some(list), ExecuteArgs::List(args)) => list + .iter() + .enumerate() + .map(|(i, arg)| match arg { + ScopeAllowedArg::Fixed(fixed) => Ok(fixed.to_string()), + ScopeAllowedArg::Var { validator } => { + let value = args + .get(i) + .ok_or_else(|| Error::MissingVar(i, validator.to_string()))? + .to_string(); + if validator.is_match(&value) { + Ok(value) + } else { + Err(Error::Validation { + index: i, + validation: validator.to_string(), + }) + } + } + }) + .collect(), + (Some(list), arg) if arg.is_empty() && list.iter().all(ScopeAllowedArg::is_fixed) => { + list.iter() + .map(|arg| match arg { + ScopeAllowedArg::Fixed(fixed) => Ok(fixed.to_string()), + _ => unreachable!(), + }) + .collect() + } + (Some(list), _) if list.is_empty() => Err(Error::InvalidInput(command_name.into())), + (Some(_), _) => Err(Error::InvalidInput(command_name.into())), + }?; + + let command_s = sidecar + .map(|s| { + std::path::PathBuf::from(s) + .components() + .last() + .unwrap() + .as_os_str() + .to_string_lossy() + .into_owned() + }) + .unwrap_or_else(|| command.command.to_string_lossy().into_owned()); + let command = if command.sidecar { + Command::new_sidecar(command_s).map_err(|e| Error::Sidecar(e.to_string()))? + } else { + Command::new(command_s) + }; + + Ok(command.args(args)) + } + + /// Open a path in the default (or specified) browser. + /// + /// The path is validated against the `tauri > allowlist > shell > open` validation regex, which + /// defaults to `^((mailto:\w+)|(tel:\w+)|(https?://\w+)).+`. + pub fn open(&self, path: &str, with: Option) -> Result<(), Error> { + // ensure we pass validation if the configuration has one + if let Some(regex) = &self.0.open { + if !regex.is_match(path) { + return Err(Error::Validation { + index: 0, + validation: regex.as_str().into(), + }); + } + } + + // The prevention of argument escaping is handled by the usage of std::process::Command::arg by + // the `open` dependency. This behavior should be re-confirmed during upgrades of `open`. + match with.map(Program::name) { + Some(program) => ::open::with(path, program), + None => ::open::that(path), + } + .map_err(Into::into) + } +} diff --git a/plugins/shell/tsconfig.json b/plugins/shell/tsconfig.json new file mode 100644 index 00000000..5098169a --- /dev/null +++ b/plugins/shell/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["guest-js/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52da7eea..40f7c693 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,16 @@ importers: specifier: ^2.4.1 version: 2.4.1 + plugins/shell: + dependencies: + '@tauri-apps/api': + specifier: ^1.2.0 + version: 1.2.0 + devDependencies: + tslib: + specifier: ^2.4.1 + version: 2.4.1 + plugins/single-instance/examples/vanilla: dependencies: '@tauri-apps/cli':