Expand description
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 implementPlugindirectly. The engine runs them in-process. - 3rd-party plugin binaries — external crates that implement
Pluginand callrun_as_subprocessfrom theirmain. The engine spawns them and exchanges JSON over stdin/stdout. - The engine itself (
whisker-cng) — consumesPlugintrait objects, owns theGenerateContext, serializesPluginRequest/PluginResponseenvelopes 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
Configby 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.
Structs§
- Android
Manifest - Android
Project Ir - In-memory representation of the Android host project plugins
mutate. Same shape rationale as
IosProjectIr. - AppMeta
- Snapshot of the user-spelled
Configvalues at pipeline entry. Intentionally flat + cloneable: anything plugins need from the app config has to surface here rather than pulling in the wholeConfigtype, which keeps the wire format stable whenConfiggrows. - File
Entry - Generate
Context - The mutable handle plugins receive in
Plugin::apply. Wraps every IR the engine is currently materializing plus the runningMutationJournal. - Gradle
Dsl - IosProject
Ir - In-memory representation of the iOS host project plugins mutate.
- Meta
Data Entry - Mutation
Journal - Mutation
Record - Plugin
Request - Stdin envelope for a 3rd-party plugin subprocess. The engine
writes one of these as JSON to the plugin’s stdin, then reads a
PluginResponseback from stdout. Single round trip per plugin per pipeline; the engine spawns one process per plugin. - Plugin
Response - Stdout envelope. The subprocess returns the mutated context; the engine diffs the journal to confirm the subprocess didn’t forge sequence indices, then merges the new context back into the running pipeline state.
Enums§
- Operation
- Pbxproj
Op - Structural mutation request against the iOS xcodeproj. The engine replays these against its pbxproj template renderer; the renderer’s rules decide where (which group, which build phase) to materialize each op.
- Plist
Value - Tagged-union value for plist trees. Matches what the CoreFoundation property-list serializer accepts; the engine renders it to XML plist format.
- Target
Traits§
- Plugin
- What a plugin implements.
- Plugin
Config - Trait implemented by the typed config struct each plugin defines.
Functions§
- run_
as_ subprocess - Drive a
Pluginas a stdin/stdout JSON subprocess.