feat(shell): add plugin (#327)
parent
89fb40caac
commit
8ed00adaa0
@ -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"
|
@ -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: <text>Tauri is a rust project that enables developers to make secure
|
||||
and small desktop applications using a web frontend.
|
||||
</text>
|
||||
PackageComment: <text>The package includes the following libraries; see
|
||||
Relationship information.
|
||||
</text>
|
||||
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
|
@ -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
|
@ -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.
|
@ -0,0 +1,65 @@
|
||||

|
||||
|
||||
<!-- description -->
|
||||
|
||||
## 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]
|
||||
<!-- plugin here --> = { 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 <!-- plugin here -->
|
||||
# or
|
||||
npm add <!-- plugin here -->
|
||||
# or
|
||||
yarn add <!-- plugin here -->
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
First you need to register the core plugin with Tauri:
|
||||
|
||||
`src-tauri/src/main.rs`
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(<!-- plugin here -->)
|
||||
.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.
|
@ -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<string, string>
|
||||
/**
|
||||
* Character encoding for stdout/stderr
|
||||
*
|
||||
* @since 1.1.0
|
||||
* */
|
||||
encoding?: string
|
||||
}
|
||||
|
||||
/** @ignore */
|
||||
interface InternalSpawnOptions extends SpawnOptions {
|
||||
sidecar?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.0.0
|
||||
*/
|
||||
interface ChildProcess<O extends IOPayload> {
|
||||
/** 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<O extends IOPayload>(
|
||||
onEvent: (event: CommandEvent<O>) => void,
|
||||
program: string,
|
||||
args: string | string[] = [],
|
||||
options?: InternalSpawnOptions
|
||||
): Promise<number> {
|
||||
if (typeof args === 'object') {
|
||||
Object.freeze(args)
|
||||
}
|
||||
|
||||
return invoke<number>('plugin:shell|execute', {
|
||||
program,
|
||||
args,
|
||||
options,
|
||||
onEventFn: transformCallback(onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class EventEmitter<E extends Record<string, any>> {
|
||||
/** @ignore */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
private eventListeners: Record<keyof E, Array<(arg: any) => void>> =
|
||||
Object.create(null)
|
||||
|
||||
/**
|
||||
* Alias for `emitter.on(eventName, listener)`.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*/
|
||||
addListener<N extends keyof E>(
|
||||
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<N extends keyof E>(
|
||||
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<N extends keyof E>(
|
||||
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<N extends keyof E>(
|
||||
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<N extends keyof E>(
|
||||
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<N extends keyof E>(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<N extends keyof E>(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<N extends keyof E>(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<N extends keyof E>(
|
||||
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<N extends keyof E>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
return invoke('plugin:shell|kill', {
|
||||
cmd: 'killChild',
|
||||
pid: this.pid
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface CommandEvents {
|
||||
close: TerminatedPayload
|
||||
error: string
|
||||
}
|
||||
|
||||
interface OutputEvents<O extends IOPayload> {
|
||||
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<O extends IOPayload> extends EventEmitter<CommandEvents> {
|
||||
/** @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<OutputEvents<O>>()
|
||||
/** Event emitter for the `stderr`. Emits the `data` event. */
|
||||
readonly stderr = new EventEmitter<OutputEvents<O>>()
|
||||
|
||||
/**
|
||||
* @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<string>
|
||||
static create(
|
||||
program: string,
|
||||
args?: string | string[],
|
||||
options?: SpawnOptions & { encoding: 'raw' }
|
||||
): Command<Uint8Array>
|
||||
static create(
|
||||
program: string,
|
||||
args?: string | string[],
|
||||
options?: SpawnOptions
|
||||
): Command<string>
|
||||
|
||||
/**
|
||||
* 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<O extends IOPayload>(
|
||||
program: string,
|
||||
args: string | string[] = [],
|
||||
options?: SpawnOptions
|
||||
): Command<O> {
|
||||
return new Command(program, args, options)
|
||||
}
|
||||
|
||||
static sidecar(program: string, args?: string | string[]): Command<string>
|
||||
static sidecar(
|
||||
program: string,
|
||||
args?: string | string[],
|
||||
options?: SpawnOptions & { encoding: 'raw' }
|
||||
): Command<Uint8Array>
|
||||
static sidecar(
|
||||
program: string,
|
||||
args?: string | string[],
|
||||
options?: SpawnOptions
|
||||
): Command<string>
|
||||
|
||||
/**
|
||||
* 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<O extends IOPayload>(
|
||||
program: string,
|
||||
args: string | string[] = [],
|
||||
options?: SpawnOptions
|
||||
): Command<O> {
|
||||
const instance = new Command<O>(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<Child> {
|
||||
return execute<O>(
|
||||
(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<ChildProcess<O>> {
|
||||
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<Uint8Array>((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<T, V> {
|
||||
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<O extends IOPayload> =
|
||||
| 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<void> {
|
||||
return invoke('plugin:shell|open', {
|
||||
path,
|
||||
with: openWith
|
||||
})
|
||||
}
|
||||
|
||||
export { Command, Child, EventEmitter, open }
|
||||
export type {
|
||||
IOPayload,
|
||||
CommandEvents,
|
||||
TerminatedPayload,
|
||||
OutputEvents,
|
||||
ChildProcess,
|
||||
SpawnOptions
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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/],
|
||||
});
|
@ -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<u8>, encoding: EncodingWrapper) -> Result<Buffer, FromUtf8Error> {
|
||||
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<u8>),
|
||||
}
|
||||
|
||||
#[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<PathBuf>,
|
||||
// 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<HashMap<String, String>>,
|
||||
// Character encoding for stdout/stderr
|
||||
encoding: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn default_env() -> Option<HashMap<String, String>> {
|
||||
Some(HashMap::default())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn execute<R: Runtime>(
|
||||
window: Window<R>,
|
||||
shell: State<'_, Shell<R>>,
|
||||
program: String,
|
||||
args: ExecuteArgs,
|
||||
on_event_fn: CallbackFn,
|
||||
options: CommandOptions,
|
||||
) -> crate::Result<ChildId> {
|
||||
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<R: Runtime>(
|
||||
_window: Window<R>,
|
||||
shell: State<'_, Shell<R>>,
|
||||
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<R: Runtime>(
|
||||
_window: Window<R>,
|
||||
shell: State<'_, Shell<R>>,
|
||||
pid: ChildId,
|
||||
) -> crate::Result<()> {
|
||||
if let Some(child) = shell.children.lock().unwrap().remove(&pid) {
|
||||
child.kill()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open<R: Runtime>(
|
||||
_window: Window<R>,
|
||||
shell: State<'_, Shell<R>>,
|
||||
path: String,
|
||||
with: Option<Program>,
|
||||
) -> crate::Result<()> {
|
||||
shell.open(path, with)
|
||||
}
|
@ -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<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
@ -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<T> = std::result::Result<T, Error>;
|
||||
type ChildStore = Arc<Mutex<HashMap<u32, CommandChild>>>;
|
||||
|
||||
pub struct Shell<R: Runtime> {
|
||||
#[allow(dead_code)]
|
||||
app: AppHandle<R>,
|
||||
scope: Scope,
|
||||
children: ChildStore,
|
||||
}
|
||||
|
||||
impl<R: Runtime> Shell<R> {
|
||||
/// Creates a new Command for launching the given program.
|
||||
pub fn command(&self, program: impl Into<String>) -> 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<String>) -> Result<Command> {
|
||||
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<open::Program>) -> Result<()> {
|
||||
open::open(&self.scope, path, with).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ShellExt<R: Runtime> {
|
||||
fn shell(&self) -> &Shell<R>;
|
||||
}
|
||||
|
||||
impl<R: Runtime, T: Manager<R>> ShellExt<R> for T {
|
||||
fn shell(&self) -> &Shell<R> {
|
||||
self.state::<Shell<R>>().inner()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
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::<Shell<R>>();
|
||||
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<String, ScopeAllowedCommand> {
|
||||
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()
|
||||
}
|
@ -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<Self, Self::Err> {
|
||||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<P: AsRef<str>>(scope: &Scope, path: P, with: Option<Program>) -> crate::Result<()> {
|
||||
scope.open(path.as_ref(), with).map_err(Into::into)
|
||||
}
|
@ -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<i32>,
|
||||
/// If the process was terminated by a signal, represents that signal.
|
||||
pub signal: Option<i32>,
|
||||
}
|
||||
|
||||
/// 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<u8>),
|
||||
/// Stdout bytes until a newline (\n) or carriage return (\r) is found.
|
||||
Stdout(Vec<u8>),
|
||||
/// 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<String>,
|
||||
env_clear: bool,
|
||||
env: HashMap<String, String>,
|
||||
current_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Spawned child process.
|
||||
#[derive(Debug)]
|
||||
pub struct CommandChild {
|
||||
inner: Arc<SharedChild>,
|
||||
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<i32>,
|
||||
}
|
||||
|
||||
impl ExitStatus {
|
||||
/// Returns the exit code of the process, if any.
|
||||
pub fn code(&self) -> Option<i32> {
|
||||
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<u8>,
|
||||
/// The data that the process wrote to stderr.
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
fn relative_command_path(command: String) -> crate::Result<String> {
|
||||
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<Command> 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<S: Into<String>>(program: S) -> Self {
|
||||
Self {
|
||||
program: program.into(),
|
||||
args: Default::default(),
|
||||
env_clear: false,
|
||||
env: Default::default(),
|
||||
current_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_sidecar<S: Into<String>>(program: S) -> crate::Result<Self> {
|
||||
Ok(Self::new(relative_command_path(program.into())?))
|
||||
}
|
||||
|
||||
/// Appends arguments to the command.
|
||||
#[must_use]
|
||||
pub fn args<I, S>(mut self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
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<String, String>) -> 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<CommandEvent>, 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<ExitStatus> {
|
||||
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<Output> {
|
||||
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<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
|
||||
tx: Sender<CommandEvent>,
|
||||
guard: Arc<RwLock<()>>,
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
@ -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<String>),
|
||||
}
|
||||
|
||||
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<String> for ExecuteArgs {
|
||||
fn from(string: String) -> Self {
|
||||
Self::Single(string)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<String>> for ExecuteArgs {
|
||||
fn from(vec: Vec<String>) -> 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<Regex>,
|
||||
|
||||
/// All allowed commands, using their unique command name as the keys.
|
||||
pub scopes: HashMap<String, ScopeAllowedCommand>,
|
||||
}
|
||||
|
||||
/// 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<Vec<ScopeAllowedArg>>,
|
||||
|
||||
/// 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<R: Runtime, M: Manager<R>>(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<Command, Error> {
|
||||
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<Command, Error> {
|
||||
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<Command, Error> {
|
||||
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<Program>) -> 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)
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["guest-js/*.ts"]
|
||||
}
|
Loading…
Reference in new issue