use std::{
io::{BufRead, BufReader},
ops::Deref,
path::Path,
process::{Child, ChildStdout, Command},
sync::{Arc, Mutex},
};
use anyhow::{anyhow, bail, Context};
use mcvm_core::Paths;
use mcvm_shared::output::{MCVMOutput, MessageContents, MessageLevel};
use crate::{
hooks::Hook, output::OutputAction, plugin::DEFAULT_PROTOCOL_VERSION, plugin_debug_enabled,
};
pub static PLUGIN_DIR_TOKEN: &str = "${PLUGIN_DIR}";
pub static CUSTOM_CONFIG_ENV: &str = "MCVM_CUSTOM_CONFIG";
pub static DATA_DIR_ENV: &str = "MCVM_DATA_DIR";
pub static CONFIG_DIR_ENV: &str = "MCVM_CONFIG_DIR";
pub static PLUGIN_STATE_ENV: &str = "MCVM_PLUGIN_STATE";
pub static MCVM_VERSION_ENV: &str = "MCVM_VERSION";
pub static MCVM_PLUGIN_ENV: &str = "MCVM_PLUGIN";
pub static HOOK_VERSION_ENV: &str = "MCVM_HOOK_VERSION";
pub static PLUGIN_LIST_ENV: &str = "MCVM_PLUGIN_LIST";
pub struct HookCallArg<'a, H: Hook> {
pub cmd: &'a str,
pub arg: &'a H::Arg,
pub additional_args: &'a [String],
pub working_dir: Option<&'a Path>,
pub use_base64: bool,
pub custom_config: Option<String>,
pub state: Arc<Mutex<serde_json::Value>>,
pub paths: &'a Paths,
pub mcvm_version: Option<&'a str>,
pub plugin_id: &'a str,
pub plugin_list: &'a [String],
pub protocol_version: u16,
}
pub(crate) fn call<H: Hook>(
hook: &H,
arg: HookCallArg<'_, H>,
o: &mut impl MCVMOutput,
) -> anyhow::Result<HookHandle<H>>
where
H: Sized,
{
let _ = o;
let hook_arg = serde_json::to_string(arg.arg).context("Failed to serialize hook argument")?;
let cmd = arg.cmd.replace(
PLUGIN_DIR_TOKEN,
&arg.working_dir
.map(|x| x.to_string_lossy().to_string())
.unwrap_or_default(),
);
let mut cmd = Command::new(cmd);
cmd.args(arg.additional_args);
cmd.arg(hook.get_name());
cmd.arg(hook_arg);
if let Some(custom_config) = arg.custom_config {
cmd.env(CUSTOM_CONFIG_ENV, custom_config);
}
cmd.env(DATA_DIR_ENV, &arg.paths.data);
cmd.env(CONFIG_DIR_ENV, arg.paths.project.config_dir());
if let Some(mcvm_version) = arg.mcvm_version {
cmd.env(MCVM_VERSION_ENV, mcvm_version);
}
cmd.env(MCVM_PLUGIN_ENV, "1");
if let Some(working_dir) = arg.working_dir {
cmd.current_dir(working_dir);
}
cmd.env(HOOK_VERSION_ENV, H::get_version().to_string());
{
let lock = arg.state.lock().map_err(|x| anyhow!("{x}"))?;
if !lock.is_null() {
let state =
serde_json::to_string(lock.deref()).context("Failed to serialize plugin state")?;
cmd.env(PLUGIN_STATE_ENV, state);
}
}
let plugin_list = arg.plugin_list.join(",");
cmd.env(PLUGIN_LIST_ENV, plugin_list);
if plugin_debug_enabled() {
o.display(
MessageContents::Simple(format!("{cmd:?}")),
MessageLevel::Debug,
);
}
if H::get_takes_over() {
cmd.spawn().context("Failed to run hook command")?.wait()?;
Ok(HookHandle::constant(
H::Result::default(),
arg.plugin_id.to_string(),
))
} else {
cmd.stdout(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().unwrap();
let stdout_reader = BufReader::new(stdout);
let handle = HookHandle {
inner: HookHandleInner::Process {
child,
stdout: stdout_reader,
line_buf: String::new(),
result: None,
},
plugin_state: Some(arg.state),
use_base64: arg.use_base64,
protocol_version: arg.protocol_version,
plugin_id: arg.plugin_id.to_string(),
};
Ok(handle)
}
}
#[must_use]
pub struct HookHandle<H: Hook> {
inner: HookHandleInner<H>,
plugin_state: Option<Arc<Mutex<serde_json::Value>>>,
use_base64: bool,
protocol_version: u16,
plugin_id: String,
}
impl<H: Hook> HookHandle<H> {
pub fn constant(result: H::Result, plugin_id: String) -> Self {
Self {
inner: HookHandleInner::Constant(result),
plugin_state: None,
use_base64: true,
protocol_version: DEFAULT_PROTOCOL_VERSION,
plugin_id,
}
}
pub fn get_id(&self) -> &String {
&self.plugin_id
}
pub fn poll(&mut self, o: &mut impl MCVMOutput) -> anyhow::Result<bool> {
match &mut self.inner {
HookHandleInner::Process {
line_buf,
stdout,
result,
..
} => {
line_buf.clear();
let result_len = stdout.read_line(line_buf)?;
if result_len == 0 {
return Ok(true);
}
let line = line_buf.trim_end_matches("\r\n").trim_end_matches('\n');
let action =
OutputAction::deserialize(line, self.use_base64, self.protocol_version)
.context("Failed to deserialize plugin action")?;
let Some(action) = action else {
return Ok(false);
};
match action {
OutputAction::SetResult(new_result) => {
*result = Some(
serde_json::from_str(&new_result)
.context("Failed to deserialize hook result")?,
);
}
OutputAction::SetState(new_state) => {
let state = self
.plugin_state
.as_mut()
.context("Hook handle does not have a reference to persistent state")?;
let mut lock = state.lock().map_err(|x| anyhow!("{x}"))?;
*lock = new_state;
}
OutputAction::Text(text, level) => {
o.display_text(text, level);
}
OutputAction::Message(message) => {
o.display_message(message);
}
OutputAction::StartProcess => {
o.start_process();
}
OutputAction::EndProcess => {
o.end_process();
}
OutputAction::StartSection => {
o.start_section();
}
OutputAction::EndSection => {
o.end_section();
}
}
Ok(false)
}
HookHandleInner::Constant(..) => Ok(true),
}
}
pub fn result(mut self, o: &mut impl MCVMOutput) -> anyhow::Result<H::Result> {
if let HookHandleInner::Process { .. } = &self.inner {
loop {
let result = self.poll(o)?;
if result {
break;
}
}
}
match self.inner {
HookHandleInner::Constant(result) => Ok(result),
HookHandleInner::Process {
mut child, result, ..
} => {
let cmd_result = child.wait()?;
if !cmd_result.success() {
if let Some(exit_code) = cmd_result.code() {
bail!("Hook returned a non-zero exit code of {}", exit_code);
} else {
bail!("Hook returned a non-zero exit code");
}
}
let result = result.context("Plugin hook did not return a result")?;
Ok(result)
}
}
}
pub fn kill(self, o: &mut impl MCVMOutput) -> anyhow::Result<Option<H::Result>> {
let _ = o;
match self.inner {
HookHandleInner::Constant(result) => Ok(Some(result)),
HookHandleInner::Process {
mut child, result, ..
} => {
child.kill()?;
Ok(result)
}
}
}
}
enum HookHandleInner<H: Hook> {
Process {
child: Child,
line_buf: String,
stdout: BufReader<ChildStdout>,
result: Option<H::Result>,
},
Constant(H::Result),
}