use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::PathBuf;
pub trait PluginConfig: Serialize + for<'de> Deserialize<'de> + Default {
const NAME: &'static str;
}
pub trait Plugin {
type Config: PluginConfig;
fn name(&self) -> &'static str {
<Self::Config as PluginConfig>::NAME
}
fn after(&self) -> &'static [&'static str] {
&[]
}
fn before(&self) -> &'static [&'static str] {
&[]
}
fn validate(&self, _config: &Self::Config) -> anyhow::Result<()> {
Ok(())
}
fn apply(&self, ctx: &mut GenerateContext, config: &Self::Config) -> anyhow::Result<()>;
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct GenerateContext {
pub app_meta: AppMeta,
#[serde(default)]
pub ios: Option<IosProjectIr>,
#[serde(default)]
pub android: Option<AndroidProjectIr>,
#[serde(default)]
pub journal: MutationJournal,
#[serde(default)]
pub app_crate_dir: Option<PathBuf>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AppMeta {
pub name: String,
pub version: String,
pub build_number: u32,
#[serde(default)]
pub ios_bundle_id: Option<String>,
#[serde(default)]
pub android_application_id: Option<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct IosProjectIr {
#[serde(default)]
pub app_name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub build_number: Option<u32>,
#[serde(default)]
pub bundle_id: Option<String>,
#[serde(default)]
pub scheme: Option<String>,
#[serde(default)]
pub deployment_target: Option<String>,
#[serde(default)]
pub info_plist: BTreeMap<String, PlistValue>,
#[serde(default)]
pub pbxproj_ops: Vec<PbxprojOp>,
#[serde(default)]
pub extra_files: BTreeMap<PathBuf, FileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum PlistValue {
String(String),
Integer(i64),
Real(f64),
Boolean(bool),
Array(Vec<PlistValue>),
Dict(BTreeMap<String, PlistValue>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PbxprojOp {
AddResource { path: PathBuf },
AddResourceFolder { path: PathBuf },
AddSource { path: PathBuf },
SetBuildSetting { key: String, value: String },
LinkSystemFramework { name: String },
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AndroidProjectIr {
#[serde(default)]
pub app_name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub build_number: Option<u32>,
#[serde(default)]
pub application_id: Option<String>,
#[serde(default)]
pub min_sdk: Option<u32>,
#[serde(default)]
pub target_sdk: Option<u32>,
#[serde(default)]
pub manifest: AndroidManifest,
#[serde(default)]
pub gradle: GradleDsl,
#[serde(default)]
pub extra_files: BTreeMap<PathBuf, FileEntry>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AndroidManifest {
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default)]
pub application_meta_data: Vec<MetaDataEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MetaDataEntry {
pub name: String,
pub value: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct GradleDsl {
#[serde(default)]
pub apply_plugins: Vec<String>,
#[serde(default)]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileEntry {
pub contents: String,
#[serde(default)]
pub contents_base64: Option<String>,
#[serde(default)]
pub mode: Option<u32>,
}
impl FileEntry {
pub fn text(contents: impl Into<String>) -> Self {
Self {
contents: contents.into(),
contents_base64: None,
mode: None,
}
}
pub fn binary(bytes: &[u8]) -> Self {
Self {
contents: String::new(),
contents_base64: Some(base64_encode(bytes)),
mode: None,
}
}
pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
match &self.contents_base64 {
Some(b64) => base64_decode(b64),
None => Ok(self.contents.clone().into_bytes()),
}
}
}
fn base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
if chunk.len() > 1 {
out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(ALPHABET[(n & 0x3f) as usize] as char);
} else {
out.push('=');
}
}
out
}
fn base64_decode(input: &str) -> anyhow::Result<Vec<u8>> {
fn val(c: u8) -> anyhow::Result<u32> {
Ok(match c {
b'A'..=b'Z' => (c - b'A') as u32,
b'a'..=b'z' => (c - b'a' + 26) as u32,
b'0'..=b'9' => (c - b'0' + 52) as u32,
b'+' => 62,
b'/' => 63,
other => anyhow::bail!("invalid base64 character: {other:#x}"),
})
}
let bytes = input.as_bytes();
let trimmed = bytes.iter().take_while(|&&c| c != b'=').count();
let padded = &bytes[..trimmed];
if !bytes[trimmed..].iter().all(|&c| c == b'=') {
anyhow::bail!("base64 padding `=` must only appear at the end");
}
let mut out = Vec::with_capacity(padded.len() / 4 * 3);
for chunk in padded.chunks(4) {
if chunk.len() == 1 {
anyhow::bail!("invalid base64 length");
}
let mut n = 0u32;
for (i, &c) in chunk.iter().enumerate() {
n |= val(c)? << (18 - 6 * i);
}
out.push((n >> 16) as u8);
if chunk.len() > 2 {
out.push((n >> 8) as u8);
}
if chunk.len() > 3 {
out.push(n as u8);
}
}
Ok(out)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Target {
Ios,
Android,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Operation {
Set,
Override,
ArrayPush { count: usize },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MutationRecord {
pub plugin: String,
pub target: Target,
pub path: String,
pub operation: Operation,
pub sequence_index: u64,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct MutationJournal {
pub records: Vec<MutationRecord>,
#[serde(default)]
pub next_sequence_index: u64,
}
impl MutationJournal {
pub fn record(&mut self, plugin: &str, target: Target, path: &str, operation: Operation) {
let seq = self.next_sequence_index;
self.next_sequence_index = seq + 1;
self.records.push(MutationRecord {
plugin: plugin.to_string(),
target,
path: path.to_string(),
operation,
sequence_index: seq,
});
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRequest {
pub name: String,
pub config: serde_json::Value,
pub context: GenerateContext,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResponse {
pub context: GenerateContext,
}
pub fn run_as_subprocess<P: Plugin>(plugin: P) -> anyhow::Result<()> {
let mut stdin_buf = String::new();
std::io::stdin()
.read_to_string(&mut stdin_buf)
.map_err(|e| anyhow::anyhow!("read PluginRequest from stdin: {e}"))?;
let request: PluginRequest = serde_json::from_str(&stdin_buf)
.map_err(|e| anyhow::anyhow!("decode PluginRequest JSON: {e}"))?;
if request.name != plugin.name() {
return Err(anyhow::anyhow!(
"plugin name mismatch: engine asked for `{}` but this binary serves `{}`",
request.name,
plugin.name(),
));
}
let config: P::Config = if request.config.is_null() {
Default::default()
} else {
serde_json::from_value(request.config)
.map_err(|e| anyhow::anyhow!("decode plugin config for `{}`: {e}", plugin.name()))?
};
plugin
.validate(&config)
.map_err(|e| anyhow::anyhow!("`{}`::validate: {e}", plugin.name()))?;
let mut ctx = request.context;
plugin
.apply(&mut ctx, &config)
.map_err(|e| anyhow::anyhow!("`{}`::apply: {e}", plugin.name()))?;
let response = PluginResponse { context: ctx };
let json = serde_json::to_string(&response)
.map_err(|e| anyhow::anyhow!("encode PluginResponse JSON: {e}"))?;
let mut stdout = std::io::stdout().lock();
stdout
.write_all(json.as_bytes())
.map_err(|e| anyhow::anyhow!("write PluginResponse to stdout: {e}"))?;
stdout
.write_all(b"\n")
.map_err(|e| anyhow::anyhow!("write trailing newline: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_context_round_trips_through_json() {
let mut ctx = GenerateContext {
app_meta: AppMeta {
name: "Demo".into(),
version: "1.0".into(),
build_number: 7,
ios_bundle_id: Some("rs.whisker.demo".into()),
android_application_id: Some("rs.whisker.demo".into()),
},
ios: Some(IosProjectIr::default()),
android: Some(AndroidProjectIr::default()),
journal: MutationJournal::default(),
app_crate_dir: None,
};
ctx.ios.as_mut().unwrap().info_plist.insert(
"CFBundleIdentifier".into(),
PlistValue::String("rs.whisker.demo".into()),
);
ctx.android
.as_mut()
.unwrap()
.manifest
.permissions
.push("android.permission.CAMERA".into());
ctx.journal.record(
"whisker-info-plist",
Target::Ios,
"info_plist.CFBundleIdentifier",
Operation::Set,
);
ctx.journal.record(
"whisker-permissions",
Target::Android,
"manifest.permissions",
Operation::ArrayPush { count: 1 },
);
let json = serde_json::to_string(&ctx).expect("serialize");
let back: GenerateContext = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.app_meta.name, "Demo");
assert_eq!(back.journal.records.len(), 2);
assert_eq!(back.journal.next_sequence_index, 2);
assert_eq!(
back.ios.unwrap().info_plist.get("CFBundleIdentifier"),
Some(&PlistValue::String("rs.whisker.demo".into())),
);
assert_eq!(
back.android.unwrap().manifest.permissions,
vec!["android.permission.CAMERA".to_string()],
);
}
#[test]
fn base64_round_trips_arbitrary_bytes() {
for input in [
&b""[..],
&b"f"[..],
&b"fo"[..],
&b"foo"[..],
&b"foob"[..],
&b"fooba"[..],
&b"foobar"[..],
&[0u8, 1, 2, 253, 254, 255][..],
] {
let encoded = base64_encode(input);
assert!(encoded.is_ascii(), "base64 must be ASCII: {encoded}");
let decoded = base64_decode(&encoded).expect("decode");
assert_eq!(decoded, input, "round trip for {input:?}");
}
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
}
#[test]
fn base64_decode_rejects_garbage() {
assert!(base64_decode("not valid!").is_err());
}
#[test]
fn file_entry_binary_round_trips_through_json() {
let raw = &[0x89u8, 0x50, 0x4e, 0x47, 0x00, 0xff];
let entry = FileEntry::binary(raw);
let json = serde_json::to_string(&entry).unwrap();
let back: FileEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.to_bytes().unwrap(), raw);
}
#[test]
fn file_entry_text_to_bytes_is_utf8() {
let entry = FileEntry::text("hello");
assert_eq!(entry.to_bytes().unwrap(), b"hello");
assert!(entry.contents_base64.is_none());
}
#[test]
fn file_entry_text_default_decodes_without_base64_field() {
let json = r#"{"contents":"old text"}"#;
let entry: FileEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.to_bytes().unwrap(), b"old text");
}
#[test]
fn sequence_indices_are_monotonic() {
let mut j = MutationJournal::default();
j.record("a", Target::Ios, "x", Operation::Set);
j.record("b", Target::Android, "y", Operation::Set);
j.record("a", Target::Ios, "z", Operation::ArrayPush { count: 3 });
let seqs: Vec<_> = j.records.iter().map(|r| r.sequence_index).collect();
assert_eq!(seqs, vec![0, 1, 2]);
assert_eq!(j.next_sequence_index, 3);
}
#[test]
fn pbxproj_ops_round_trip() {
let ops = vec![
PbxprojOp::AddResource {
path: "GoogleService-Info.plist".into(),
},
PbxprojOp::LinkSystemFramework {
name: "AVFoundation.framework".into(),
},
PbxprojOp::SetBuildSetting {
key: "SWIFT_VERSION".into(),
value: "5".into(),
},
];
let json = serde_json::to_string(&ops).unwrap();
let back: Vec<PbxprojOp> = serde_json::from_str(&json).unwrap();
assert_eq!(back, ops);
}
#[test]
fn plugin_request_envelope_round_trips() {
let req = PluginRequest {
name: "whisker-firebase".into(),
config: serde_json::json!({"googleServicePath": "ios/GoogleService.plist"}),
context: GenerateContext::default(),
};
let json = serde_json::to_string(&req).unwrap();
let back: PluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "whisker-firebase");
assert_eq!(back.config["googleServicePath"], "ios/GoogleService.plist");
}
struct Null;
#[derive(Default, Serialize, Deserialize)]
struct NullConfig {
#[allow(dead_code)]
flag: bool,
}
impl PluginConfig for NullConfig {
const NAME: &'static str = "null";
}
impl Plugin for Null {
type Config = NullConfig;
fn apply(&self, _ctx: &mut GenerateContext, _config: &Self::Config) -> anyhow::Result<()> {
Ok(())
}
}
#[test]
fn plugin_trait_default_methods_work() {
let p = Null;
assert_eq!(p.name(), "null");
assert!(p.after().is_empty());
assert!(p.before().is_empty());
let cfg = NullConfig::default();
p.validate(&cfg).unwrap();
let mut ctx = GenerateContext::default();
p.apply(&mut ctx, &cfg).unwrap();
}
fn run_with_pipes<P: Plugin>(plugin: P, input: &str) -> anyhow::Result<String> {
let request: PluginRequest = serde_json::from_str(input)?;
anyhow::ensure!(
request.name == plugin.name(),
"name mismatch: {} vs {}",
request.name,
plugin.name(),
);
let config: P::Config = serde_json::from_value(request.config)?;
plugin.validate(&config)?;
let mut ctx = request.context;
plugin.apply(&mut ctx, &config)?;
Ok(serde_json::to_string(&PluginResponse { context: ctx })?)
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct PermissionConfig {
permission: String,
}
impl PluginConfig for PermissionConfig {
const NAME: &'static str = "test-permission";
}
struct Permission;
impl Plugin for Permission {
type Config = PermissionConfig;
fn apply(&self, ctx: &mut GenerateContext, cfg: &PermissionConfig) -> anyhow::Result<()> {
let android = ctx.android.as_mut().ok_or_else(|| {
anyhow::anyhow!("test-permission requires android target enabled")
})?;
android.manifest.permissions.push(cfg.permission.clone());
ctx.journal.record(
PermissionConfig::NAME,
Target::Android,
"manifest.permissions",
Operation::ArrayPush { count: 1 },
);
Ok(())
}
}
#[test]
fn subprocess_happy_path_round_trip() {
let request = PluginRequest {
name: "test-permission".into(),
config: serde_json::json!({"permission": "android.permission.CAMERA"}),
context: GenerateContext {
android: Some(AndroidProjectIr::default()),
..Default::default()
},
};
let input = serde_json::to_string(&request).unwrap();
let output = run_with_pipes(Permission, &input).unwrap();
let response: PluginResponse = serde_json::from_str(&output).unwrap();
let android = response.context.android.expect("android should be present");
assert_eq!(
android.manifest.permissions,
vec!["android.permission.CAMERA".to_string()],
);
assert_eq!(response.context.journal.records.len(), 1);
assert_eq!(
response.context.journal.records[0].plugin,
"test-permission",
);
assert!(matches!(
response.context.journal.records[0].operation,
Operation::ArrayPush { count: 1 },
));
}
#[test]
fn subprocess_name_mismatch_is_an_error() {
let request = PluginRequest {
name: "some-other-plugin".into(),
config: serde_json::json!({"permission": "x"}),
context: GenerateContext::default(),
};
let input = serde_json::to_string(&request).unwrap();
let err = run_with_pipes(Permission, &input).unwrap_err();
assert!(err.to_string().contains("name mismatch"), "{err}");
}
#[test]
fn subprocess_apply_error_propagates() {
let request = PluginRequest {
name: "test-permission".into(),
config: serde_json::json!({"permission": "android.permission.CAMERA"}),
context: GenerateContext::default(),
};
let input = serde_json::to_string(&request).unwrap();
let err = run_with_pipes(Permission, &input).unwrap_err();
assert!(err.to_string().contains("requires android"), "{err}");
}
}