Whisker CNG plugin surface.
Every type and trait the plugin system depends on lives here. The crate covers three audiences with one published API:
- 1st-party plugins — modules inside
whisker-cngthat implement [Plugin] directly. The engine runs them in-process. - 3rd-party plugin binaries — external crates that implement
[
Plugin] and call [run_as_subprocess] from theirmain. The engine spawns them and exchanges JSON over stdin/stdout. - The engine itself (
whisker-cng) — consumes [Plugin] trait objects, owns the [GenerateContext], serializes [PluginRequest] / [PluginResponse] envelopes for the subprocess path.
Keeping all three on the same crate means a whisker-cng patch
bump doesn't force every 3rd-party plugin crate to rebuild — the
engine depends on whisker-plugin, not the other way around, so
the only churn that propagates is changes to this crate's API.
Writing a 3rd-party plugin
Implement [PluginConfig] on a Config struct (this gives the
plugin its name) and [Plugin] on a unit struct that owns the
apply logic, then call [run_as_subprocess] from main:
use whisker_plugin::{Operation, Plugin, PluginConfig, GenerateContext, PlistValue, Target};
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct DemoConfig {
bundle_suffix: String,
}
impl PluginConfig for DemoConfig {
const NAME: &'static str = "example-plugin";
}
struct Demo;
impl Plugin for Demo {
type Config = DemoConfig;
fn apply(&self, ctx: &mut GenerateContext, cfg: &DemoConfig) -> anyhow::Result<()> {
if let Some(ios) = ctx.ios.as_mut() {
let key = "CFBundleSuffix".to_string();
ios.info_plist.insert(key.clone(), PlistValue::String(cfg.bundle_suffix.clone()));
ctx.journal.record(
DemoConfig::NAME,
Target::Ios,
&format!("info_plist.{key}"),
Operation::Set,
);
}
Ok(())
}
}
fn main() -> anyhow::Result<()> {
whisker_plugin::run_as_subprocess(Demo)
}
What the IR covers
[IosProjectIr] and [AndroidProjectIr] each carry two
layers:
- Core fields seeded from
AppConfigby the engine before any plugin runs —app_name,version,bundle_id/application_id,scheme,deployment_target,min_sdk,target_sdk. A plugin can read them to make decisions or override them viaOperation::Override. - Plugin-additive fields that plugins push into:
info_plist: BTreeMap<String, PlistValue>(String/Boolean/Integer/Array<String>rendered)manifest.permissions: Vec<String>(dedup'd at render)manifest.application_meta_data: Vec<MetaDataEntry>gradle.apply_plugins: Vec<String>/gradle.dependencies: Vec<String>(raw Kotlin DSL lines emitted intoapp/build.gradle.kts)pbxproj_ops: Vec<PbxprojOp>(AddResource/AddSource/SetBuildSetting/LinkSystemFrameworkmaterialised into the iOS xcodeproj)extra_files: BTreeMap<PathBuf, FileEntry>(arbitrary files dropped intogen/, path-validated against..traversal)
Adding a new typed field is a non-breaking change when the
field is #[serde(default)]: older plugin binaries simply
don't touch it, the engine sees the default. Adding a required
field is a wire-format break.
Mutation journal
Plugins record every IR mutation by calling
[MutationJournal::record] on ctx.journal alongside the
mutation itself. The engine uses the resulting log to:
- Attribute conflicts ("plugin A and plugin B both
setinfo_plist.CFBundleIdentifier— that's a hard error unless one of them usedoverride") - Render a human-readable summary on
whisker generate --verbose - Diagnose 3rd-party plugins ("plugin foo mutated android.manifest.permissions[3] at sequence index 42, after plugin bar at 38")
Subprocess wire format
Stderr is reserved for human-readable diagnostics: log lines,
progress messages, anything the plugin wants to surface to the
user when whisker generate --verbose is in play. Stdout is
strictly the [PluginResponse] JSON envelope — anything else
there is a wire-format violation.