rScoop is a GUI for Scoop, which is itself a CLI. Most of the work in writing rScoop is not the UI. It is the layer in between, the part that runs scoop install, scoop doctor, scoop update, and then has to figure out what their output actually meant.

Spawn a process, pipe stdout and stderr, keep the window responsive, decide what “succeeded” means when the exit code is 0 but the useful line was three screens up, cancel the whole thing when the user presses Cancel, and then do it again for the next command. And the one after that.

I pulled that layer out into a separate crate called Execra. It is now in rScoop v1.8.1, running the long-lived work behind installs, updates, cleanup, and VirusTotal scans.

That sounds more ambitious than it is. Execra is not a workflow engine and it is not a replacement for the CLI it wraps. It is a typed job runtime for Rust apps that run external commands and need to show humans what is happening without building a second private runtime inside the app.

In rScoop that meant deleting the old custom PowerShell command wrapper and update helper path, then letting the backend track actual jobs instead of a frontend-shaped idea of jobs. The practical result is less code, fewer stale UI states, clearer warnings, and cancellation that stops the underlying work instead of just changing what the interface says.

The narrow target is deliberate: Rust backends, especially Tauri apps, that need to build a GUI around a real command-line program. That is why the crate has a tauri feature flag instead of making every app write the same bridge by hand. Enable it and Execra becomes a Tauri plugin with app.execra(), event forwarding, cancellation, and recent-job access built in.

What it does

Execra gives you a Runtime. You hand it a Command. It gives you back a JobHandle.

use execra::{Command, Runtime};

let rt = Runtime::new();

let outcome = rt
    .spawn(Command::new("scoop").args(["install", "git"]))?
    .await;

outcome.into_result()?;

The handle is both the thing you can await and the thing you can observe.

let handle = rt.spawn(cmd)?;
let id = handle.id();
let mut events = handle.subscribe();

tokio::spawn(async move {
    while let Some(event) = events.next().await {
        // render, log, or forward it
        let _ = event;
    }
});

let outcome = handle.await;

That is the part I actually care about. The same spawn call can serve a headless caller that just wants the final verdict and a UI caller that needs a live stream of events. There is no separate “with UI” code path, no second runner that quietly diverges from the first one, and no app-wide current process handle that every button has to know about.

This is where the crate stopped being an architecture idea and started paying rent. rScoop now runs Scoop operations, cleanup, updates, and VirusTotal scans through the same operation pipeline. Running operations carry the real Execra job id. Cancelling an install, update, cleanup, or scan cancels that job. The UI does not have to fake it.

Everything else in the crate is a consequence of trying to make that one shape hold up in a real app.

Commands are commands

Command::new("git").args(["status", "--short"]) does not parse a shell string. It is program plus args. That matters because a runtime should not be guessing where your quoting rules begin and end.

If you want a shell, you ask for one explicitly:

Command::powershell("Get-ChildItem");
Command::cmd("dir");
Command::sh("ls -la");
Command::shell("echo hello");

That is less magical, which is the point. Most of the bugs in this layer are not interesting bugs. They are quoting bugs, cwd bugs, hidden-window bugs, timeout bugs, and “why did this work in the terminal but not in the app” bugs.

Process-group cancellation

scoop install git does not just spawn Scoop. Underneath it can spawn download and extraction helpers. If you kill only the process you started, the child work can keep going.

From the user’s perspective that is poisonous. They pressed Cancel, but the network keeps going and the disk keeps spinning, so the UI is technically updated and emotionally lying.

Execra cancels the job, not just the process leader. On Unix that means a process group with setpgid and signals sent to the group. On Windows that means assigning the child to a Win32 Job Object so the subtree can be torn down together.

Boring infrastructure. Easy to get wrong if every desktop app writes it once under pressure. Worth putting behind one method.

For rScoop this was one of the obvious wins. Cancel is no longer a local UI state transition that hopes the real command eventually notices. It is tied to the runtime job.

Events, not blobs

The runtime emits typed events:

Raw output still exists. Sometimes a log is the only honest artifact. But the UI should not have to scrape its own terminal pane to know that a package is downloading, that a diagnostic found a recommendation, or that a known error has a useful next action.

rScoop v1.8.1 uses that distinction for operation status. Results can now be success, warning, or error. That sounds small, but it fixes a class of dishonest states. Scoop’s “Running process detected, skip updating” is not a clean success. It is completed work with a warning attached. The operation bar, modal, completed history, background toast, and log footer can all render that as a warning instead of pretending everything was fine.

The important detail is that Execra does not pretend it knows every CLI. It hosts interpreters. The caller supplies the meaning.

pub trait Interpreter: Send {
    fn on_line(&mut self, ctx: &Context, line: &Line) -> Vec<InterpreterEvent>;
    fn on_exit(&mut self, ctx: &Context, exit: &ExitCode) -> Vec<InterpreterEvent>;
}

That contract is intentionally small. An interpreter sees decoded output lines and final exit state, keeps whatever state it needs, and emits metadata. It cannot move the job through runtime states. It cannot emit Finalized. If it breaks, the user’s process keeps running and the bad interpretation gets reported as metadata failure.

That last bit matters. The job is the user’s work. Interpretation is just how we explain it.

Findings are a real thing

scoop doctor is the example that made this feel necessary. A diagnostic can run successfully and still find problems. The exit code says “the diagnostic ran”. The output says “here is what you should fix”.

So Execra models findings separately from the final verdict. A finding can be info, recommendation, warning, or error. It can also carry a typed action: command, link, or instruction.

That gives a GUI something better than a text scrape. A command action can be a button. A link can be an anchor. An instruction can be copyable text. The final Outcome is still Succeeded, Failed, or Cancelled, but findings survive into that outcome so a successful diagnostic can still produce useful work for the UI.

The same idea shows up in VirusTotal scans. Missing API keys and detections are not the same kind of result as a clean scan, and forcing all of that through a boolean success flag made the UI worse. Running scans through the same Execra operation path gave rScoop one place to map exit codes, warnings, and log output.

Exited is not Finalized

Execra emits both Exited and Finalized.

That sounds like ceremony until you lose the one line you needed.

Process exit and pipe EOF are independent OS events. A runtime that calls its final callback as soon as wait() returns can classify the job before the last stdout or stderr line has been drained. With CLIs, the last line is often the line that tells you what really happened.

Execra waits for stdout EOF, stderr EOF, and process exit. Then it emits Exited, gives the interpreter one final on_exit call, computes the Outcome, and emits Finalized.

The order is boring on purpose:

  1. JobCreated
  2. JobStarted
  3. output and interpreter events
  4. Exited
  5. Interpreter::on_exit
  6. Finalized

I have learned to respect boring orderings that are written down.

Persistence is opt-in

Runtime::new() is in-memory. It does not open SQLite, create raw log directories, or start retention work.

let rt = execra::Runtime::new();

If you want history, raw logs, concurrency limits, retention, or grace-period tuning, you build that runtime explicitly:

let rt = execra::Runtime::builder()
    .history("./jobs.sqlite")
    .log_dir("./raw")
    .raw_output(execra::RawOutputPolicy::Persist)
    .max_concurrent(4)
    .build()?;

I like defaults that do not write to disk. A library should not surprise a toy script with a database just because the real app needs one.

The Tauri plugin

The GUI case is why this exists, so the Tauri path is not an example bolted onto the side. It is a feature-gated plugin.

tauri::Builder::default()
    .plugin(execra::tauri::init())
    .invoke_handler(tauri::generate_handler![run_tool, cancel, history])
    .run(tauri::generate_context!())
    .unwrap();

Commands get app.execra():

use execra::tauri::ExecraExt;

#[tauri::command]
fn run_tool(app: tauri::AppHandle, args: Vec<String>) -> Result<execra::JobId, String> {
    app.execra()
        .task(execra::Command::new("scrcpy").args(args))
        .channel("scrcpy:log")
        .spawn_tracked()
        .map_err(|e| e.to_string())
}

#[tauri::command]
fn cancel(app: tauri::AppHandle, id: execra::JobId) -> Result<(), String> {
    app.execra().cancel(id).map_err(|e| e.to_string())
}

.channel(name) forwards serialized events to the frontend on one Tauri event channel. The payload is the same typed Event enum. One event shape, one channel, frontend code matches on kind.

If the Rust backend also needs to mirror state, TaskBuilder has typed hooks like on_created, on_output, on_interpreter_error, and on_finalized. Those hooks observe the same event stream as the frontend instead of inventing a parallel state system.

For apps that want persisted history, the plugin takes a pre-built runtime:

tauri::Builder::default()
    .plugin(execra::tauri::init_with(
        execra::Runtime::builder()
            .history("./jobs.sqlite")
            .max_concurrent(2)
            .build()
            .expect("open Execra runtime"),
    ));

This is the split I wanted in rScoop: frontend code gets a stable event stream, backend code can keep its own state honest, and the process runtime remains one runtime instead of one runtime per screen.

It also made the surrounding app behavior less stale. After installs, updates, and scans, rScoop can refresh installed packages, holds, updates, and versioned package state together instead of falling back to heavier cold-start-style reloads. Search got cleaner too: warm searches can reuse parsed manifests and binary aliases, and bucket changes invalidate the right cache using the configured Scoop path.

What it is not

Execra is not a shell parser. It is not a task DAG. It is not a distributed runner. It does not ship a catalogue of built-in interpreters for every tool I happen to use this month.

Callers decide what to run. Callers chain jobs with .await. Callers write interpreters for the CLIs they understand.

That boundary is important because the abstraction gets worse the moment the runtime starts pretending it knows your domain.

What changed in rScoop

The rScoop v1.8.1 migration is the reason I am comfortable writing about this as more than an internal refactor.

Execra removed the old PowerShell command wrapper and update helper modules. Installs, updates, cleanup, bucket work, automatic package updates, and VirusTotal scans now go through the same operation path. That one change made a lot of smaller fixes possible because operation state stopped being scattered.

Doctor’s outdated-cache cleanup now uses the same version-aware cleanup path as automatic cleanup. It removes stale cache downloads, not every cache file it can see. Cleanup output streams into the operation log with deleted-file counts and per-file failures, so partial errors are visible instead of disappearing.

Operation results now have enough shape to say success, warning, or error. Scoop’s “running process detected, skip updating” case becomes a warning. VirusTotal detections and missing API keys can be mapped as real operation states. The operation bar, modal, completed history, background toast, and log viewer footer all get to reflect that without each one inventing its own rule.

Search and refresh got cleaner too. Warm search can cache parsed manifests and binary aliases instead of just paths. Bucket changes invalidate the configured Scoop path, not a fallback. After installs and updates, rScoop refreshes installed packages, holds, updates, and versioned package state together without going through the heavier cold-start path.

That is the part that matters to me. The crate did not just make the code look neater. It reduced custom plumbing and made the app harder to lie with.

Why it exists

Most non-trivial desktop apps that wrap a CLI eventually hit the same problems. Tauri apps wrapping Git. Electron apps wrapping ffmpeg. GUI installers wrapping package managers. They all end up with some version of “spawn the process, parse the output, decide if it worked, kill the subtree on Cancel, persist enough to survive a restart, keep the UI honest while it runs”.

The plumbing is not interesting, and getting it right is. The gap between those two things is where otherwise decent desktop apps leak weird behavior onto their users.

Execra exists because rScoop needed this layer to be real. If another Tauri app wrapping a CLI can use it too, good. But the first test already happened: rScoop shipped it, the old plumbing got smaller, cancellation got honest, warnings got visible, and the UI stopped having to pretend it knew more than the process runtime did.