flash

Plugin cookbook

Graded, real Rust exemplars — read them in order. Each one is bundled, small, and idiomatic; copy its shape. The stdio protocol remains language-neutral, but Rust is the sole maintained SDK and implementation language for official plugins.

Rust (the default path)

  1. Command wrapper — Plugins/spotify (~85 LOC + unit tests). The smallest useful plugin: manifest commands + one on_command hook mapping subcommands to a CLI via run_command, with the argv plan kept pure and unit-tested. Command-only plugins are on-demand: the host spawns them at first use, not at startup.
  2. Commands + host RPCs + persistence — Plugins/marks (~200 LOC). Vim-style marks: flash marks set / flash marks jump, host.normal_mode_target and host.activate and host.open host RPCs (the app_control and open capabilities), JSON state persisted under share_dir(), and the SDK’s running-app snapshot to verify a saved process ID before activation.
  3. Push catalog via native APIs — Plugins/processes (~300 LOC). A sources plugin building its complete row set and pushing it with publish from on_start, refreshed by events alone (app launch/quit, flashlight open), backed by the host’s process table — plus cadences armed only while core:status.observed shows their segment. No readiness dance: initialize replies immediately and the catalog lands when ready.
  4. Event-driven catalog + actions + navigation — Plugins/browsers (~2,500 LOC). Browser tabs behind one per-bundle engine table: a shared AppleScript skeleton with per-dialect phrases for Chromium-family browsers and Safari, and for Firefox the host Accessibility broker (host.ax_snapshot / host.ax_perform, the accessibility capability) completed from a file the browser keeps on disk. Refreshes fan out per browser and publish on change; manifest actions go through on_action with the performed / unhandled / error trichotomy, what the plugin leaves unhandled falls back to its action_bindings, and every row’s navigation_url route restores through on_navigate.
  5. Query evaluator with background refresh — Plugins/answers (~1270 LOC). Three synchronous, CPU-only answer engines (calculator, colors, timezones) behind one evaluate via an ordered engine table; the ECB snapshot loads from disk in on_start and refreshes in the background through host.fetch (the network_fetch capability), atomically replacing state; query.prefixes = ["="] routing sends = input to the calculator engine alone.

Protocol conformance lives in the SDK workspace: Plugins/_flash_plugin_rust/protocol.json pins the wire constants, the wire/runtime test suites pin the framing and lifecycle behaviour, and the probe workspace member exercises them end to end over real stdio. ./Scripts/test-plugins.sh --lane all is the one-command gate.

Overriding what an app does for an action

No code needed: a manifest-only plugin (no exec) declares action_bindings, and its entries win over the defaults conventions for the apps it names. Say an app opens a tab with Cmd-Shift-N, closes one with a two-chord sequence, has a menu item for a new window and no print at all:

{
  "id": "example-app",
  "name": "Example App",
  "version": "0.1.0",
  "description": "What Example App does for Flash's actions",
  "action_bindings": {
    "tab_new": { "com.example.app": "cmd+shift+n" },
    "tab_close": { "com.example.app": ["cmd+k", "cmd+w"] },
    "window_new": { "com.example.app": { "menu": ["File", "New Window"] } },
    "app_print": { "com.example.app": false }
  }
}

Drop it in a directory, list it as file:<path> in [plugins] third_party, and t in that app now sends Cmd-Shift-N. A bundle-id entry beats any plugin-wide "" entry; to cover a family of apps with "" instead, scope the plugin with only_bundle_ids (or only_terminals) and, between plugins that both match, raise priority. The inspector’s Mappings page (“How actions resolve in ") shows which binding won and which plugin declared it.

The loop

./Scripts/new-plugin.sh myplugin "My Plugin" "What it does"   # Rust scaffold, zero-edit green
./Scripts/build-plugins.sh dev myplugin                        # per-id Rust hot build
tail -f ~/Library/Logs/Flash/flash.log                         # watcher restart + plugin logs
CARGO_TARGET_DIR=build/plugin-target cargo test --manifest-path Plugins/myplugin/Cargo.toml

Debugging is a first-class feature of the transport: run the plugin binary in a terminal and type NDJSON at it — {"id":1,"method":"initialize","params": {"protocol_version":1}} — no host required.

Contracts you must not break — the full statements live in docs/plugin-protocol.md and AGENTS.md: publish complete replacement catalogs, never deltas; on transient failure don’t publish (the host keeps last-good) and publish an authoritative [] only when the source is truly empty; keep evaluate synchronous and I/O-free; bound every refresh; reply to initialize immediately — startup work belongs in on_start.