flash

Rust plugin SDK (flash_plugin)

The blessed way to write a Flash plugin. One crate, one dependency line:

# Bundled plugins (in this repo):
flash_plugin = { path = "../_flash_plugin_rust" }

# External plugins — pin a rev, same as every third-party surface in Flash:
flash_plugin = { git = "https://github.com/aymericbeaumet/flash", rev = "<sha>" }

The proc-macro crate and the bounded-subprocess layer are internal details of flash_plugin; never name them. Scaffold a new bundled plugin with Scripts/new-plugin.sh <id> "<Name>" "<description>" — it generates the manifest, a hermetic standalone crate (path dep on the SDK, per-crate build profiles, the canonical clippy.toml copy, a committed Cargo.lock), and a working :<id> ping command, requiring zero manual edits to reach green.

Authoring shape

use flash_plugin::{CommandRequest, Context, PerformResponse, run};

struct MyPlugin;

flash_plugin::plugin!(MyPlugin); // reads manifest.json at compile time

impl FlashPlugin for MyPlugin {
    async fn on_command(&self, ctx: Context, command: CommandRequest) -> PerformResponse {
        match command.subcommand.as_str() {
            "ping" => PerformResponse::ok().message("pong"),
            other => PerformResponse::fail(format!("unknown subcommand: {other}")),
        }
    }
}

fn main() {
    run(MyPlugin);
}

plugin! generates a FlashPlugin trait whose required methods follow the manifest: warm sources ⇒ on_start (runs AFTER the immediate initialize reply; build the catalog and ctx.publish(...) when ready), live: true sources ⇒ on_search, query ⇒ a synchronous evaluate, commands / bangs ⇒ on_command, actions ⇒ on_action, navigation ⇒ on_navigate, hints ⇒ on_hints. Optional: on_event, on_resolve, on_shutdown (runs on stdin EOF before exit). Forgetting a required handler is a compile error, not a runtime surprise. On the wire, resolve/command/action/navigate all arrive as the single perform method — the SDK routes kinds to your handlers, and every one of them answers with the PerformResponse trichotomy: ok() (+ .target_pid() / .navigation_url() / .message()), unhandled() (“not my context” — the host may fall back), or fail(msg) (“mine, but it broke” — the host must not fall back). Tests read a built response back with is_ok() / is_unhandled() / error_message() / toast_message().

Context

Handed to every handler; cheap to clone. Key surface:

Async rules (enforced)

Each plugin uses one current-thread Tokio executor. Events preserve wire order and each interval callback is non-overlapping, but startup and request callbacks are independent tasks and may overlap; coordinate shared refreshes explicitly (RefreshGate::try_run keeps an interactive request from queueing behind slow background work). Async I/O and spawn_blocking still make progress without multiplying resident worker threads across every plugin process. One blocking syscall stalls every in-flight operation. Blocking I/O is banned outright by each crate’s clippy.toml (std::fs::*, std::process::Command) with no #[allow] escape: use tokio::fs, tokio::process, and tokio::time::timeout to bound one awaited operation. Scheduling is the host’s: tokio::time::sleep, sleep_until and interval are rejected by Scripts/check-guardrails.sh outside test code, along with any interval/after/wait/Settle::new call that does not name its PollPriority. The SDK builds the runtime in run() — never build your own. Do async startup work in on_start (it runs after the initialize reply, so nothing you do there can slow the handshake); resolve lazily with tokio::sync::OnceCell when needed. A synchronous evaluate body over 10 ms is a bug — the host warns at 40 ms round-trip against the 50 ms deadline.

The runtime owns finite admission queues, pinned in protocol.json:

EOF cancels request/event workers and gives shutdown callbacks plus output draining one shared 750-ms deadline. Handlers must still avoid blocking the executor. Wire PIDs are positive signed 32-bit integers; booleans and floating JSON numbers never stand in for integer identifiers. Perform failures emit only their failure outcome, even when success-only builder methods were chained. Optional wire fields accept null as absent. Hint targets require a nonempty id, the canonical nested frame, finite frame edges, and declared fields only. Perform navigation URLs must be absolute; invalid builder values become errors.

Testing

flash_plugin::testing::Harness drives handlers with no host process, no binaries, and no framing:

#[cfg(test)]
mod tests {
    use super::*;
    use flash_plugin::testing::Harness;

    #[tokio::test]
    async fn refresh_publishes_the_catalog() {
        let harness = Harness::new("processes");
        let ctx = harness.context();
        assert!(refresh_candidates(&ctx).await);
        assert!(!harness.drain_published_rows().is_empty());
    }
}

Harness::with_config injects a [plugin.<id>] settings object; drain() returns every emitted frame decoded to JSON (publishes, status, logs); drain_published_rows() shortcuts to the last catalog; drain_status() returns each status notification’s rendered segment map in order; set_running_applications seeds the app snapshot. Script the host side of an RPC with next_host_request(), which yields (id, method, params) of the plugin’s next host.* call, and reply_host(id, result), which wakes the awaiting handler with that result — spawn the handler as a task first so the test can answer while it waits. Add tokio to [dev-dependencies] for async tests.

flash_plugin::testing::WireHarness runs the real serve loop over in-memory NDJSON streams, so a test speaks to the plugin exactly as the host does: WireHarness::new(plugin) (or with_config), send(json!({…})), recv() for the next frame, recv_response(id) / recv_notification(method) to skip past unrelated frames, close_stdin() for the EOF shutdown signal, and finished() to await the loop and collect what it still wrote (shutdown logs included). Use Harness for plugin behaviour and WireHarness only where framing, request routing or shutdown ordering is what the test is about: the SDK’s own runtime tests and the probe crate (Plugins/_flash_plugin_rust/probe) hold the protocol conformance suite, so a plugin crate rarely needs one.

The SDK and the host XCTest suites consume the shared malformed and boundary corpus at Plugins/_flash_plugin_rust/fixtures/wire-values.fixture; Plugins/_flash_plugin_rust/protocol.json pins the constants both assert against.

If a plugin will not load, run :doctor. It checks manifest loading, runtime state, executable resolution, and whether the generated Seatbelt profile compiles. Use :plugins reload after correcting the problem or to restart a process parked by its restart budget.

Iteration loop

./Scripts/build-plugins.sh dev <id>   # build + sign + stage just this plugin

The staged mv -f lands as a rename in the plugin root; the host watches only that root directory (manifest.json and the binary live there — source edits under src/ change nothing the host loads) and restarts only that plugin (~300 ms debounce) while the other plugins keep their published catalogs. If watching is disabled, run :plugins reload. CARGO_TARGET_DIR=build/plugin-target cargo test --manifest-path Plugins/<id>/Cargo.toml needs no built binaries at all (always set CARGO_TARGET_DIR for manual cargo runs so no Plugins/<id>/target/ tree appears in the checkout). Debug any plugin by running its binary in a terminal and typing NDJSON at it — no host required.