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