Skip to main content

Crate whisker_plugin

Crate whisker_plugin 

Source
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:

  1. 1st-party plugins — modules inside whisker-cng that implement Plugin directly. The engine runs them in-process.
  2. 3rd-party plugin binaries — external crates that implement Plugin and call run_as_subprocess from their main. The engine spawns them and exchanges JSON over stdin/stdout.
  3. 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:

  1. Core fields seeded from Config by 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 via Operation::Override.
  2. 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 into app/build.gradle.kts)
    • pbxproj_ops: Vec<PbxprojOp> (AddResource / AddSource / SetBuildSetting / LinkSystemFramework materialised into the iOS xcodeproj)
    • extra_files: BTreeMap<PathBuf, FileEntry> (arbitrary files dropped into gen/, 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 set info_plist.CFBundleIdentifier — that’s a hard error unless one of them used override”)
  • 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§

AndroidManifest
AndroidProjectIr
In-memory representation of the Android host project plugins mutate. Same shape rationale as IosProjectIr.
AppMeta
Snapshot of the user-spelled Config values at pipeline entry. Intentionally flat + cloneable: anything plugins need from the app config has to surface here rather than pulling in the whole Config type, which keeps the wire format stable when Config grows.
FileEntry
GenerateContext
The mutable handle plugins receive in Plugin::apply. Wraps every IR the engine is currently materializing plus the running MutationJournal.
GradleDsl
IosProjectIr
In-memory representation of the iOS host project plugins mutate.
MetaDataEntry
MutationJournal
MutationRecord
PluginRequest
Stdin envelope for a 3rd-party plugin subprocess. The engine writes one of these as JSON to the plugin’s stdin, then reads a PluginResponse back from stdout. Single round trip per plugin per pipeline; the engine spawns one process per plugin.
PluginResponse
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
PbxprojOp
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.
PlistValue
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.
PluginConfig
Trait implemented by the typed config struct each plugin defines.

Functions§

run_as_subprocess
Drive a Plugin as a stdin/stdout JSON subprocess.