use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt::Debug;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use anyhow::bail;
use nitro_shared::output::NitroOutput;
use serde::Serialize;
use serde::{Deserialize, Deserializer};
use tokio::sync::Mutex;
use crate::PluginPaths;
use crate::hook::Hook;
use crate::hook::PLUGIN_DIR_TOKEN;
use crate::hook::WASM_FILE_NAME;
use crate::hook::call::HookCallArg;
use crate::hook::call::HookCallContext;
use crate::hook::call::HookHandle;
use crate::hook::hooks::StartWorker;
use crate::hook::wasm::call_wasm;
use crate::hook::wasm::loader::WASMLoader;
use crate::host::PluginContext;
pub const NEWEST_PROTOCOL_VERSION: u16 = 3;
pub const DEFAULT_PROTOCOL_VERSION: u16 = 1;
pub static FILE_REPLACEMENT_TOKEN: &str = "$file:";
pub struct Plugin {
id: String,
pub manifest: PluginManifest,
custom_config: Option<String>,
working_dir: Option<PathBuf>,
persistence: Arc<Mutex<PluginPersistence>>,
}
impl Plugin {
pub fn new(id: String, manifest: PluginManifest) -> Self {
Self {
id,
manifest,
custom_config: None,
working_dir: None,
persistence: Arc::new(Mutex::new(PluginPersistence::new())),
}
}
pub fn get_id(&self) -> &String {
&self.id
}
pub fn get_manifest(&self) -> &PluginManifest {
&self.manifest
}
pub async fn call_hook<H: Hook>(
&self,
hook: &H,
arg: &H::Arg,
paths: &PluginPaths,
nitro_version: Option<&str>,
plugin_list: &[String],
wasm_loader: Arc<Mutex<WASMLoader>>,
context: Option<&Arc<dyn PluginContext>>,
o: &mut impl NitroOutput,
) -> anyhow::Result<Option<HookHandle<H>>> {
let Some(handler) = self.manifest.hooks.get(hook.get_name()) else {
return Ok(None);
};
self.call_hook_handler(
hook,
handler,
arg,
paths,
nitro_version,
plugin_list,
wasm_loader,
context,
o,
)
.await
}
async fn call_hook_handler<H: Hook>(
&self,
hook: &H,
handler: &HookHandler,
arg: &H::Arg,
paths: &PluginPaths,
nitro_version: Option<&str>,
plugin_list: &[String],
wasm_loader: Arc<Mutex<WASMLoader>>,
context: Option<&Arc<dyn PluginContext>>,
o: &mut impl NitroOutput,
) -> anyhow::Result<Option<HookHandle<H>>> {
match handler {
HookHandler::Wasm { .. } => {
let file = self
.working_dir
.as_ref()
.context("WASM handler without working dir")?
.join(WASM_FILE_NAME)
.to_string_lossy()
.to_string();
let subscriptions = HashSet::new();
let ctx = HookCallContext {
subscriptions: &subscriptions,
custom_config: self.custom_config.clone(),
nitro_version,
plugin_list,
global_context: context,
};
let arg = HookCallArg {
cmd: &file,
arg,
additional_args: &[],
working_dir: self.working_dir.as_deref(),
ctx,
use_base64: !self.manifest.raw_transfer,
persistence: self.persistence.clone(),
paths,
plugin_id: &self.id,
protocol_version: self
.manifest
.protocol_version
.unwrap_or(DEFAULT_PROTOCOL_VERSION),
wasm_loader,
};
call_wasm(hook, arg, o).await.map(Some)
}
HookHandler::Execute {
executable,
args,
priority: _,
subscriptions,
} => {
let ctx = HookCallContext {
subscriptions,
custom_config: self.custom_config.clone(),
nitro_version,
plugin_list,
global_context: context,
};
let arg = HookCallArg {
cmd: executable,
arg,
additional_args: args,
working_dir: self.working_dir.as_deref(),
ctx,
use_base64: !self.manifest.raw_transfer,
persistence: self.persistence.clone(),
paths,
plugin_id: &self.id,
protocol_version: self
.manifest
.protocol_version
.unwrap_or(DEFAULT_PROTOCOL_VERSION),
wasm_loader,
};
hook.call(arg, o).await.map(Some)
}
HookHandler::Constant {
constant,
priority: _,
} => {
let mut value = constant.clone();
replace_file_tokens(&mut value, &self.working_dir, false)?;
Ok(Some(HookHandle::constant(
serde_json::from_value(value)?,
self.id.clone(),
)))
}
HookHandler::File { file, priority: _ } => {
let Some(working_dir) = &self.working_dir else {
bail!("Plugin does not have a directory for the file hook handler to look in");
};
let path = working_dir.join(file);
let contents = std::fs::read_to_string(path)
.context("Failed to read hook result from file")?;
let result = match serde_json::from_str(&contents) {
Ok(result) => result,
Err(_) => serde_json::from_value(serde_json::Value::String(contents))
.context("Failed to deserialize hook result")?,
};
Ok(Some(HookHandle::constant(result, self.id.clone())))
}
HookHandler::Match {
property,
cases,
priority: _,
} => {
let arg2 = serde_json::to_value(arg)?;
let lhs = if let Some(property) = property {
let arg2 = arg2.as_object().context(
"Hook argument is not an object, so a property cannot be matched",
)?;
arg2.get(property)
.context("Property does not exist on hook argument")
.cloned()?
} else {
arg2
};
let lhs = serde_json::to_string(&lhs)?;
for (case, handler) in cases.iter() {
if &lhs == case {
return Box::pin(self.call_hook_handler(
hook,
handler,
arg,
paths,
nitro_version,
plugin_list,
wasm_loader,
context,
o,
))
.await;
}
}
Ok(None)
}
HookHandler::Native {
function,
priority: _,
} => {
let arg = serde_json::to_value(arg)
.context("Failed to serialize native hook argument")?;
let result = function
.call(arg)
.await
.context("Native hook handler failed")?;
let result = serde_json::from_value(result)
.context("Failed to deserialize native hook result")?;
Ok(Some(HookHandle::constant(result, self.id.clone())))
}
}
}
pub fn set_custom_config(&mut self, config: serde_json::Value) -> anyhow::Result<()> {
let serialized =
serde_json::to_string(&config).context("Failed to serialize custom plugin config")?;
self.custom_config = Some(serialized);
Ok(())
}
pub fn set_working_dir(&mut self, dir: PathBuf) {
self.working_dir = Some(dir);
}
pub async fn set_worker(&mut self, worker: HookHandle<StartWorker>) -> anyhow::Result<()> {
let mut lock = self.persistence.lock().await;
lock.worker = Some(worker);
Ok(())
}
pub fn get_hook_priority<H: Hook>(&self, hook: &H) -> HookPriority {
let Some(handler) = self.manifest.hooks.get(hook.get_name()) else {
return HookPriority::Any;
};
match handler {
HookHandler::Wasm { priority, .. }
| HookHandler::Execute { priority, .. }
| HookHandler::Constant { priority, .. }
| HookHandler::File { priority, .. }
| HookHandler::Match { priority, .. }
| HookHandler::Native { priority, .. } => *priority,
}
}
}
#[derive(Deserialize, Debug, Default)]
#[serde(default)]
pub struct PluginManifest {
pub id: Option<String>,
#[serde(flatten)]
pub meta: PluginMetadata,
pub version: Option<String>,
#[serde(alias = "mcvm_version")]
pub nitro_version: Option<String>,
pub hooks: HashMap<String, HookHandler>,
pub dependencies: Vec<String>,
pub install_message: Option<String>,
pub protocol_version: Option<u16>,
pub raw_transfer: bool,
pub supports_instance_creation: bool,
pub supports_template_creation: bool,
pub subcommands: HashMap<String, PluginProvidedSubcommand>,
}
impl PluginManifest {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Serialize, Deserialize, Default, Debug)]
#[serde(default)]
pub struct PluginMetadata {
pub name: Option<String>,
pub description: Option<String>,
pub documentation: Option<String>,
}
#[derive(Deserialize, Debug)]
#[serde(untagged)]
pub enum PluginProvidedSubcommand {
Global(String),
Specific {
supercommand: String,
description: String,
},
}
#[derive(Deserialize)]
#[serde(untagged)]
#[serde(rename_all = "snake_case")]
pub enum HookHandler {
Wasm {
wasm: bool,
#[serde(default)]
priority: HookPriority,
},
Execute {
executable: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
priority: HookPriority,
#[serde(default)]
subscriptions: HashSet<HookSubscription>,
},
Constant {
constant: serde_json::Value,
#[serde(default)]
priority: HookPriority,
},
File {
file: String,
#[serde(default)]
priority: HookPriority,
},
Match {
#[serde(default)]
property: Option<String>,
cases: HashMap<String, Box<HookHandler>>,
#[serde(default)]
priority: HookPriority,
},
Native {
#[serde(deserialize_with = "deserialize_native_function")]
function: Arc<dyn NativeHookHandler>,
#[serde(default)]
priority: HookPriority,
},
}
impl Debug for HookHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "HookHandler")
}
}
#[derive(Deserialize, PartialEq, PartialOrd, Eq, Ord, Default, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum HookPriority {
First,
#[default]
Any,
Last,
}
#[derive(Deserialize, PartialEq, Eq, Clone, Copy, Hash)]
#[serde(rename_all = "snake_case")]
pub enum HookSubscription {
Instances,
Templates,
}
fn deserialize_native_function<'de, D>(_: D) -> Result<Arc<dyn NativeHookHandler>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Arc::new(NoneHookHandler))
}
#[async_trait::async_trait]
pub trait NativeHookHandler: Send + Sync {
async fn call(&self, arg: serde_json::Value) -> anyhow::Result<serde_json::Value>;
}
struct NoneHookHandler;
#[async_trait::async_trait]
impl NativeHookHandler for NoneHookHandler {
async fn call(&self, arg: serde_json::Value) -> anyhow::Result<serde_json::Value> {
let _ = arg;
Ok(serde_json::Value::Null)
}
}
pub struct PluginPersistence {
pub state: serde_json::Value,
pub worker: Option<HookHandle<StartWorker>>,
}
impl Default for PluginPersistence {
fn default() -> Self {
Self::new()
}
}
impl PluginPersistence {
pub fn new() -> Self {
Self {
state: serde_json::Value::Null,
worker: None,
}
}
}
fn replace_file_tokens(
value: &mut serde_json::Value,
working_dir: &Option<PathBuf>,
test: bool,
) -> anyhow::Result<()> {
match value {
serde_json::Value::Array(values) => {
for value in values {
replace_file_tokens(value, working_dir, test)?;
}
}
serde_json::Value::Object(props) => {
for prop in props.values_mut() {
replace_file_tokens(prop, working_dir, test)?;
}
}
serde_json::Value::String(value) => {
if let Some(path) = value.strip_prefix(FILE_REPLACEMENT_TOKEN) {
if test {
*value = "test".into();
return Ok(());
}
let Some(working_dir) = working_dir else {
bail!("Plugin does not have a directory for the file hook handler to look in");
};
let path = working_dir.join(path);
let contents = std::fs::read_to_string(path)
.context("Failed to read hook result from file")?;
*value = contents;
}
if let Some(working_dir) = working_dir {
*value = value.replace(PLUGIN_DIR_TOKEN, &working_dir.to_string_lossy());
}
}
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_file_token_replacement() {
let mut json = json!([{
"foo": "bar",
"baz": format!("{FILE_REPLACEMENT_TOKEN}foobar")
}]);
replace_file_tokens(&mut json, &None, true).unwrap();
let expected = json!([{
"foo": "bar",
"baz": "test"
}]);
assert_eq!(json, expected);
}
}