Bläddra i källkod

Support sending raw byte data to the "data" event for child command's stdout and stderr (#5789)

Co-authored-by: Lucas Nogueira <lucas@tauri.studio>
Co-authored-by: Lucas Nogueira <lucas@tauri.app>
filip 2 år sedan
förälder
incheckning
509d4678b1

+ 6 - 0
.changes/raw-encoding.md

@@ -0,0 +1,6 @@
+---
+"api": minor
+"tauri": minor
+---
+
+Added `raw` encoding option to read stdout and stderr raw bytes.

+ 5 - 0
.changes/remove-shell-constructor.md

@@ -0,0 +1,5 @@
+---
+"api": minor
+---
+
+Removed shell's `Command` constructor and added the `Command.create` static function instead.

+ 1 - 1
core/tauri-build/src/mobile.rs

@@ -78,7 +78,7 @@ impl PluginBuilder {
           let tauri_library_path = std::env::var("DEP_TAURI_IOS_LIBRARY_PATH")
             .expect("missing `DEP_TAURI_IOS_LIBRARY_PATH` environment variable. Make sure `tauri` is a dependency of the plugin.");
 
-          let tauri_dep_path = &path.parent().unwrap().join(".tauri");
+          let tauri_dep_path = path.parent().unwrap().join(".tauri");
           create_dir_all(&tauri_dep_path).context("failed to create .tauri directory")?;
           copy_folder(
             Path::new(&tauri_library_path),

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
core/tauri/scripts/bundle.global.js


+ 99 - 46
core/tauri/src/api/process/command.rs

@@ -18,8 +18,10 @@ use std::os::windows::process::CommandExt;
 
 #[cfg(windows)]
 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+const NEWLINE_BYTE: u8 = b'\n';
 
 use crate::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;
@@ -54,14 +56,13 @@ pub struct TerminatedPayload {
 }
 
 /// A event sent to the command callback.
-#[derive(Debug, Clone, Serialize)]
-#[serde(tag = "event", content = "payload")]
+#[derive(Debug, Clone)]
 #[non_exhaustive]
 pub enum CommandEvent {
   /// Stderr bytes until a newline (\n) or carriage return (\r) is found.
-  Stderr(String),
+  Stderr(Vec<u8>),
   /// Stdout bytes until a newline (\n) or carriage return (\r) is found.
-  Stdout(String),
+  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.
@@ -76,7 +77,6 @@ pub struct Command {
   env_clear: bool,
   env: HashMap<String, String>,
   current_dir: Option<PathBuf>,
-  encoding: Option<&'static Encoding>,
 }
 
 /// Spawned child process.
@@ -129,9 +129,9 @@ pub struct Output {
   /// The status (exit code) of the process.
   pub status: ExitStatus,
   /// The data that the process wrote to stdout.
-  pub stdout: String,
+  pub stdout: Vec<u8>,
   /// The data that the process wrote to stderr.
-  pub stderr: String,
+  pub stderr: Vec<u8>,
 }
 
 fn relative_command_path(command: String) -> crate::Result<String> {
@@ -173,7 +173,6 @@ impl Command {
       env_clear: false,
       env: Default::default(),
       current_dir: None,
-      encoding: None,
     }
   }
 
@@ -219,13 +218,6 @@ impl Command {
     self
   }
 
-  /// Sets the character encoding for stdout/stderr.
-  #[must_use]
-  pub fn encoding(mut self, encoding: &'static Encoding) -> Self {
-    self.encoding.replace(encoding);
-    self
-  }
-
   /// Spawns the command.
   ///
   /// # Examples
@@ -241,7 +233,7 @@ impl Command {
   ///   let mut i = 0;
   ///   while let Some(event) = rx.recv().await {
   ///     if let CommandEvent::Stdout(line) = event {
-  ///       println!("got: {}", line);
+  ///       println!("got: {}", String::from_utf8(line).unwrap());
   ///       i += 1;
   ///       if i == 4 {
   ///         child.write("message from Rust\n".as_bytes()).unwrap();
@@ -252,7 +244,6 @@ impl Command {
   /// });
   /// ```
   pub fn spawn(self) -> crate::api::Result<(Receiver<CommandEvent>, CommandChild)> {
-    let encoding = self.encoding;
     let mut command: StdCommand = self.into();
     let (stdout_reader, stdout_writer) = pipe()?;
     let (stderr_reader, stderr_writer) = pipe()?;
@@ -275,14 +266,12 @@ impl Command {
       guard.clone(),
       stdout_reader,
       CommandEvent::Stdout,
-      encoding,
     );
     spawn_pipe_reader(
       tx.clone(),
       guard.clone(),
       stderr_reader,
       CommandEvent::Stderr,
-      encoding,
     );
 
     spawn(move || {
@@ -350,27 +339,28 @@ impl Command {
   /// use tauri::api::process::Command;
   /// let output = Command::new("echo").args(["TAURI"]).output().unwrap();
   /// assert!(output.status.success());
-  /// assert_eq!(output.stdout, "TAURI");
+  /// assert_eq!(String::from_utf8(output.stdout).unwrap(), "TAURI");
   /// ```
   pub fn output(self) -> crate::api::Result<Output> {
     let (mut rx, _child) = self.spawn()?;
 
     let output = crate::async_runtime::safe_block_on(async move {
       let mut code = None;
-      let mut stdout = String::new();
-      let mut stderr = String::new();
+      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.push_str(line.as_str());
-            stdout.push('\n');
+            stdout.extend(line);
+            stdout.push(NEWLINE_BYTE);
           }
           CommandEvent::Stderr(line) => {
-            stderr.push_str(line.as_str());
-            stderr.push('\n');
+            stderr.extend(line);
+            stderr.push(NEWLINE_BYTE);
           }
           CommandEvent::Error(_) => {}
         }
@@ -386,36 +376,25 @@ impl Command {
   }
 }
 
-fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
+fn spawn_pipe_reader<F: Fn(Vec<u8>) -> CommandEvent + Send + Copy + 'static>(
   tx: Sender<CommandEvent>,
   guard: Arc<RwLock<()>>,
   pipe_reader: PipeReader,
   wrapper: F,
-  character_encoding: Option<&'static Encoding>,
 ) {
   spawn(move || {
     let _lock = guard.read().unwrap();
     let mut reader = BufReader::new(pipe_reader);
 
-    let mut buf = Vec::new();
     loop {
-      buf.clear();
+      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 line = match character_encoding {
-            Some(encoding) => Ok(encoding.decode_with_bom_removal(&buf).0.into()),
-            None => String::from_utf8(buf.clone()),
-          };
-          block_on_task(async move {
-            let _ = match line {
-              Ok(line) => tx_.send(wrapper(line)).await,
-              Err(e) => tx_.send(CommandEvent::Error(e.to_string())).await,
-            };
-          });
+          let _ = block_on_task(async move { tx_.send(wrapper(buf)).await });
         }
         Err(e) => {
           let tx_ = tx.clone();
@@ -428,14 +407,34 @@ fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
 
 // tests for the commands functions.
 #[cfg(test)]
-mod test {
+mod tests {
   #[cfg(not(windows))]
   use super::*;
 
   #[cfg(not(windows))]
   #[test]
-  fn test_cmd_output() {
-    // create a command to run cat.
+  fn test_cmd_spawn_output() {
+    let cmd = Command::new("cat").args(["test/api/test.txt"]);
+    let (mut rx, _) = cmd.spawn().unwrap();
+
+    crate::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();
 
@@ -446,7 +445,7 @@ mod test {
             assert_eq!(payload.code, Some(0));
           }
           CommandEvent::Stdout(line) => {
-            assert_eq!(line, "This is a test doc!".to_string());
+            assert_eq!(String::from_utf8(line).unwrap(), "This is a test doc!");
           }
           _ => {}
         }
@@ -457,7 +456,32 @@ mod test {
   #[cfg(not(windows))]
   #[test]
   // test the failure case
-  fn test_cmd_fail() {
+  fn test_cmd_spawn_fail() {
+    let cmd = Command::new("cat").args(["test/api/"]);
+    let (mut rx, _) = cmd.spawn().unwrap();
+
+    crate::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();
 
@@ -468,11 +492,40 @@ mod test {
             assert_eq!(payload.code, Some(1));
           }
           CommandEvent::Stderr(line) => {
-            assert_eq!(line, "cat: test/api/: Is a directory".to_string());
+            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"
+    );
+  }
 }

+ 78 - 13
core/tauri/src/endpoints/shell.rs

@@ -5,10 +5,17 @@
 #![allow(unused_imports)]
 
 use super::InvokeContext;
-use crate::{api::ipc::CallbackFn, Runtime};
+use crate::{
+  api::{
+    ipc::CallbackFn,
+    process::{CommandEvent, TerminatedPayload},
+  },
+  Runtime,
+};
 #[cfg(shell_scope)]
 use crate::{Manager, Scopes};
-use serde::Deserialize;
+use encoding_rs::Encoding;
+use serde::{Deserialize, Serialize};
 use tauri_macros::{command_enum, module_command_handler, CommandModule};
 
 #[cfg(shell_scope)]
@@ -18,7 +25,7 @@ type ExecuteArgs = ();
 
 #[cfg(any(shell_execute, shell_sidecar))]
 use std::sync::{Arc, Mutex};
-use std::{collections::HashMap, path::PathBuf};
+use std::{collections::HashMap, path::PathBuf, string::FromUtf8Error};
 
 type ChildId = u32;
 #[cfg(any(shell_execute, shell_sidecar))]
@@ -31,13 +38,61 @@ fn command_child_store() -> &'static ChildStore {
   &STORE
 }
 
-#[derive(Debug, Clone, Deserialize)]
+#[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>),
+}
+
 #[allow(clippy::unnecessary_wraps)]
 fn default_env() -> Option<HashMap<String, String>> {
   Some(HashMap::default())
@@ -150,13 +205,22 @@ impl Cmd {
       } else {
         command = command.env_clear();
       }
-      if let Some(encoding) = options.encoding {
-        if let Some(encoding) = crate::api::process::Encoding::for_label(encoding.as_bytes()) {
-          command = command.encoding(encoding);
-        } else {
-          return Err(anyhow::anyhow!(format!("unknown encoding {encoding}")));
-        }
-      }
+      let encoding = match options.encoding {
+        Option::None => EncodingWrapper::Text(None),
+        Some(encoding) => match encoding.as_str() {
+          "raw" => EncodingWrapper::Raw,
+          _ => {
+            if let Some(text_encoding) =
+              crate::api::process::Encoding::for_label(encoding.as_bytes())
+            {
+              EncodingWrapper::Text(Some(text_encoding))
+            } else {
+              return Err(anyhow::anyhow!(format!("unknown encoding {encoding}")));
+            }
+          }
+        },
+      };
+
       let (mut rx, child) = command.spawn()?;
 
       let pid = child.pid();
@@ -166,8 +230,9 @@ impl Cmd {
         while let Some(event) = rx.recv().await {
           if matches!(event, crate::api::process::CommandEvent::Terminated(_)) {
             command_child_store().lock().unwrap().remove(&pid);
-          }
-          let js = crate::api::ipc::format_callback(on_event_fn, &event)
+          };
+          let js_event = JSCommandEvent::new(event, encoding);
+          let js = crate::api::ipc::format_callback(on_event_fn, &js_event)
             .expect("unable to serialize CommandEvent");
 
           let _ = context.window.eval(js.as_str());

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
examples/api/dist/assets/index.js


+ 2 - 2
examples/api/src/views/Shell.svelte

@@ -26,10 +26,10 @@
 
   function spawn() {
     child = null
-    const command = new Command(cmd, [...args, script], {
+    const command = Command.create(cmd, [...args, script], {
       cwd: cwd || null,
       env: _getEnv(),
-      encoding,
+      encoding: encoding || undefined,
     })
 
     command.on('close', (data) => {

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
tooling/api/docs/js-api.json


+ 146 - 47
tooling/api/src/shell.ts

@@ -40,7 +40,7 @@
  * 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.constructor | Command constructor}.
+ * - `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.
@@ -69,7 +69,7 @@
  * Usage:
  * ```typescript
  * import { Command } from '@tauri-apps/api/shell'
- * new Command('run-git-commit', ['commit', '-m', 'the commit message'])
+ * 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.
@@ -104,15 +104,15 @@ interface InternalSpawnOptions extends SpawnOptions {
 /**
  * @since 1.0.0
  */
-interface ChildProcess {
+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: string
+  stdout: O
   /** The data that the process wrote to `stderr`. */
-  stderr: string
+  stderr: O
 }
 
 /**
@@ -125,8 +125,8 @@ interface ChildProcess {
  * @param options Configuration for the process spawn.
  * @returns A promise resolving to the process id.
  */
-async function execute(
-  onEvent: (event: CommandEvent) => void,
+async function execute<O extends IOPayload>(
+  onEvent: (event: CommandEvent<O>) => void,
   program: string,
   args: string | string[] = [],
   options?: InternalSpawnOptions
@@ -150,10 +150,10 @@ async function execute(
 /**
  * @since 1.0.0
  */
-class EventEmitter<E extends string> {
+class EventEmitter<E extends Record<string, any>> {
   /** @ignore */
   // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
-  private eventListeners: Record<E, Array<(...args: any[]) => void>> =
+  private eventListeners: Record<keyof E, Array<(arg: any) => void>> =
     Object.create(null)
 
   /**
@@ -161,7 +161,10 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  addListener(eventName: E, listener: (...args: any[]) => void): this {
+  addListener<N extends keyof E>(
+    eventName: N,
+    listener: (arg: E[typeof eventName]) => void
+  ): this {
     return this.on(eventName, listener)
   }
 
@@ -170,7 +173,10 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  removeListener(eventName: E, listener: (...args: any[]) => void): this {
+  removeListener<N extends keyof E>(
+    eventName: N,
+    listener: (arg: E[typeof eventName]) => void
+  ): this {
     return this.off(eventName, listener)
   }
 
@@ -184,7 +190,10 @@ class EventEmitter<E extends string> {
    *
    * @since 1.0.0
    */
-  on(eventName: E, listener: (...args: any[]) => void): this {
+  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)
@@ -203,11 +212,14 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  once(eventName: E, listener: (...args: any[]) => void): this {
-    const wrapper = (...args: any[]): void => {
+  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(...args)
+      listener(arg)
     }
     return this.addListener(eventName, wrapper)
   }
@@ -218,7 +230,10 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  off(eventName: E, listener: (...args: any[]) => void): this {
+  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(
@@ -235,7 +250,7 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  removeAllListeners(event?: E): this {
+  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]
@@ -253,12 +268,12 @@ class EventEmitter<E extends string> {
    *
    * @returns `true` if the event had listeners, `false` otherwise.
    */
-  emit(eventName: E, ...args: any[]): boolean {
+  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(...args)
+      for (const listener of listeners) listener(arg)
       return true
     }
     return false
@@ -269,7 +284,7 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  listenerCount(eventName: E): number {
+  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
@@ -286,7 +301,10 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  prependListener(eventName: E, listener: (...args: any[]) => void): this {
+  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)
@@ -305,11 +323,14 @@ class EventEmitter<E extends string> {
    *
    * @since 1.1.0
    */
-  prependOnceListener(eventName: E, listener: (...args: any[]) => void): this {
-    const wrapper = (...args: any[]): void => {
+  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(...args)
+      listener(arg)
     }
     return this.prependListener(eventName, wrapper)
   }
@@ -333,7 +354,7 @@ class Child {
    * @example
    * ```typescript
    * import { Command } from '@tauri-apps/api/shell';
-   * const command = new Command('node');
+   * const command = Command.create('node');
    * const child = await command.spawn();
    * await child.write('message');
    * await child.write([0, 1, 2, 3, 4, 5]);
@@ -341,7 +362,7 @@ class Child {
    *
    * @returns A promise indicating the success or failure of the operation.
    */
-  async write(data: string | Uint8Array): Promise<void> {
+  async write(data: IOPayload): Promise<void> {
     return invokeTauriCommand({
       __tauriModule: 'Shell',
       message: {
@@ -369,13 +390,22 @@ class Child {
   }
 }
 
+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-apps/api/shell';
- * const command = new Command('node');
+ * const command = Command.create('node');
  * command.on('close', data => {
  *   console.log(`command finished with code ${data.code} and signal ${data.signal}`)
  * });
@@ -390,7 +420,7 @@ class Child {
  * @since 1.1.0
  *
  */
-class Command extends EventEmitter<'close' | 'error'> {
+class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
   /** @ignore Program to execute. */
   private readonly program: string
   /** @ignore Program arguments */
@@ -398,11 +428,12 @@ class Command extends EventEmitter<'close' | 'error'> {
   /** @ignore Spawn options. */
   private readonly options: InternalSpawnOptions
   /** Event emitter for the `stdout`. Emits the `data` event. */
-  readonly stdout = new EventEmitter<'data'>()
+  readonly stdout = new EventEmitter<OutputEvents<O>>()
   /** Event emitter for the `stderr`. Emits the `data` event. */
-  readonly stderr = new EventEmitter<'data'>()
+  readonly stderr = new EventEmitter<OutputEvents<O>>()
 
   /**
+   * @ignore
    * Creates a new `Command` instance.
    *
    * @param program The program name to execute.
@@ -410,7 +441,7 @@ class Command extends EventEmitter<'close' | 'error'> {
    * @param args Program arguments.
    * @param options Spawn options.
    */
-  constructor(
+  private constructor(
     program: string,
     args: string | string[] = [],
     options?: SpawnOptions
@@ -421,6 +452,50 @@ class Command extends EventEmitter<'close' | 'error'> {
     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-apps/api/shell';
+   * 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
@@ -433,12 +508,12 @@ class Command extends EventEmitter<'close' | 'error'> {
    * @param program The program to execute.
    * It must be configured on `tauri.conf.json > tauri > allowlist > shell > scope`.
    */
-  static sidecar(
+  static sidecar<O extends IOPayload>(
     program: string,
     args: string | string[] = [],
     options?: SpawnOptions
-  ): Command {
-    const instance = new Command(program, args, options)
+  ): Command<O> {
+    const instance = new Command<O>(program, args, options)
     instance.options.sidecar = true
     return instance
   }
@@ -449,7 +524,7 @@ class Command extends EventEmitter<'close' | 'error'> {
    * @returns A promise resolving to the child process handle.
    */
   async spawn(): Promise<Child> {
-    return execute(
+    return execute<O>(
       (event) => {
         switch (event.event) {
           case 'Error':
@@ -477,7 +552,7 @@ class Command extends EventEmitter<'close' | 'error'> {
    * @example
    * ```typescript
    * import { Command } from '@tauri-apps/api/shell';
-   * const output = await new Command('echo', 'message').execute();
+   * const output = await Command.create('echo', 'message').execute();
    * assert(output.code === 0);
    * assert(output.signal === null);
    * assert(output.stdout === 'message');
@@ -486,28 +561,42 @@ class Command extends EventEmitter<'close' | 'error'> {
    *
    * @returns A promise resolving to the child process output.
    */
-  async execute(): Promise<ChildProcess> {
+  async execute(): Promise<ChildProcess<O>> {
     return new Promise((resolve, reject) => {
       this.on('error', reject)
-      const stdout: string[] = []
-      const stderr: string[] = []
-      this.stdout.on('data', (line: string) => {
+
+      const stdout: O[] = []
+      const stderr: O[] = []
+      this.stdout.on('data', (line: O) => {
         stdout.push(line)
       })
-      this.stderr.on('data', (line: string) => {
+      this.stderr.on('data', (line: O) => {
         stderr.push(line)
       })
+
       this.on('close', (payload: TerminatedPayload) => {
         resolve({
           code: payload.code,
           signal: payload.signal,
-          stdout: stdout.join('\n'),
-          stderr: stderr.join('\n')
+          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')
+    }
+  }
 }
 
 /**
@@ -528,10 +617,13 @@ interface TerminatedPayload {
   signal: number | null
 }
 
+/** Event payload type */
+type IOPayload = string | Uint8Array
+
 /** Events emitted by the child process. */
-type CommandEvent =
-  | Event<'Stdout', string>
-  | Event<'Stderr', string>
+type CommandEvent<O extends IOPayload> =
+  | Event<'Stdout', O>
+  | Event<'Stderr', O>
   | Event<'Terminated', TerminatedPayload>
   | Event<'Error', string>
 
@@ -573,4 +665,11 @@ async function open(path: string, openWith?: string): Promise<void> {
 }
 
 export { Command, Child, EventEmitter, open }
-export type { ChildProcess, SpawnOptions }
+export type {
+  IOPayload,
+  CommandEvents,
+  TerminatedPayload,
+  OutputEvents,
+  ChildProcess,
+  SpawnOptions
+}

Vissa filer visades inte eftersom för många filer har ändrats