use std::{collections::VecDeque, env::consts::EXE_SUFFIX, sync::Arc, time::Instant};
use anyhow::{Context, anyhow, bail};
use nitro_shared::{
no_window,
output::{MessageContents, NitroOutput},
};
use tokio::{
io::AsyncWriteExt,
process::{Child, ChildStdin, ChildStdout, Command},
sync::Mutex,
};
use crate::{
hook::{
CONFIG_DIR_ENV, CUSTOM_CONFIG_ENV, DATA_DIR_ENV, EXE_EXTENSION_TOKEN, HOOK_VERSION_ENV,
Hook, INSTANCE_LIST_ENV, NITRO_PLUGIN_ENV, NITRO_VERSION_ENV, PLUGIN_DIR_TOKEN,
PLUGIN_LIST_ENV, PLUGIN_STATE_ENV, TEMPLATE_LIST_ENV,
call::{HookCallArg, HookHandle},
},
input_output::{CommandResult, InputAction, OutputAction},
plugin::{HookSubscription, PluginPersistence},
plugin_debug_enabled,
try_read::TryLineReader,
};
const READ_BUF_SIZE: usize = 32768;
pub(crate) async fn call_executable<H: Hook + Sized>(
hook: &H,
arg: HookCallArg<'_, H>,
o: &mut impl NitroOutput,
) -> anyhow::Result<HookHandle<H>> {
let _ = o;
let hook_arg = serde_json::to_string(arg.arg).context("Failed to serialize hook argument")?;
let plugin_dir = arg
.working_dir
.map(|x| x.to_string_lossy().to_string())
.unwrap_or_default();
let cmd = arg.cmd.replace(PLUGIN_DIR_TOKEN, &plugin_dir);
let cmd = cmd.replace(EXE_EXTENSION_TOKEN, EXE_SUFFIX);
let mut cmd = Command::new(cmd);
for arg in arg.additional_args {
cmd.arg(arg.replace(PLUGIN_DIR_TOKEN, &plugin_dir));
}
cmd.arg(hook.get_name());
cmd.arg(hook_arg);
if let Some(custom_config) = arg.ctx.custom_config {
cmd.env(CUSTOM_CONFIG_ENV, custom_config);
}
cmd.env(DATA_DIR_ENV, &arg.paths.data_dir);
cmd.env(CONFIG_DIR_ENV, &arg.paths.config_dir);
if let Some(nitro_version) = arg.ctx.nitro_version {
cmd.env(NITRO_VERSION_ENV, nitro_version);
}
cmd.env(NITRO_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.persistence.lock().await;
if !lock.state.is_null() {
let state =
serde_json::to_string(&lock.state).context("Failed to serialize plugin state")?;
cmd.env(PLUGIN_STATE_ENV, state);
}
}
let plugin_list = arg.ctx.plugin_list.join(",");
cmd.env(PLUGIN_LIST_ENV, plugin_list);
if let Some(context) = arg.ctx.global_context {
if arg.ctx.subscriptions.contains(&HookSubscription::Instances) {
cmd.env(
INSTANCE_LIST_ENV,
serde_json::to_string(&context.get_instances())?,
);
}
if arg.ctx.subscriptions.contains(&HookSubscription::Templates) {
cmd.env(
TEMPLATE_LIST_ENV,
serde_json::to_string(&context.get_templates())?,
);
}
}
no_window!(cmd);
if plugin_debug_enabled() {
o.display(MessageContents::Simple(format!("{cmd:?}")));
}
if H::get_takes_over() {
cmd.spawn()
.context("Failed to run hook command")?
.wait()
.await?;
Ok(HookHandle::constant(
H::Result::default(),
arg.plugin_id.to_string(),
))
} else {
cmd.stdout(std::process::Stdio::piped());
cmd.stdin(std::process::Stdio::piped());
let handle_inner = ExecutableHookHandle {
inner: ExecutableHookHandleInner::NotStarted(cmd),
use_base64: arg.use_base64,
protocol_version: arg.protocol_version,
plugin_id: arg.plugin_id.to_string(),
};
let handle =
HookHandle::executable(handle_inner, arg.plugin_id.to_string(), arg.persistence);
Ok(handle)
}
}
pub(super) struct ExecutableHookHandle<H: Hook> {
pub use_base64: bool,
pub protocol_version: u16,
pub plugin_id: String,
inner: ExecutableHookHandleInner<H>,
}
enum ExecutableHookHandleInner<H: Hook> {
NotStarted(Command),
Started {
child: Child,
stdout: TryLineReader<ChildStdout>,
stdin: ChildStdin,
result: Option<H::Result>,
},
}
impl<H: Hook> ExecutableHookHandle<H> {
pub async fn ensure_started(
&mut self,
plugin_persistence: &mut Option<Arc<Mutex<PluginPersistence>>>,
command_results: &mut VecDeque<CommandResult>,
start_time: &mut Option<Instant>,
o: &mut impl NitroOutput,
) -> anyhow::Result<()> {
if let ExecutableHookHandleInner::NotStarted(..) = &self.inner {
self.poll(plugin_persistence, command_results, start_time, o)
.await?;
}
Ok(())
}
pub async fn poll(
&mut self,
plugin_persistence: &mut Option<Arc<Mutex<PluginPersistence>>>,
command_results: &mut VecDeque<CommandResult>,
start_time: &mut Option<Instant>,
o: &mut impl NitroOutput,
) -> anyhow::Result<bool> {
match &mut self.inner {
ExecutableHookHandleInner::NotStarted(command) => {
let mut child = command.spawn().context("Failed to spawn command")?;
let stdout = child.stdout.take().unwrap();
let stdout = TryLineReader::new(stdout, READ_BUF_SIZE);
let stdin = child.stdin.take().unwrap();
*start_time = if std::env::var("NITRO_PLUGIN_PROFILE").is_ok_and(|x| x == "1") {
Some(Instant::now())
} else {
None
};
self.inner = ExecutableHookHandleInner::Started {
child,
stdout,
stdin,
result: None,
};
Ok(false)
}
ExecutableHookHandleInner::Started {
stdout,
stdin,
result,
..
} => {
let lines = stdout.lines().await?;
let Some(lines) = lines else {
return Ok(true);
};
let persistence = plugin_persistence
.as_mut()
.context("Hook handle does not have a reference to persistent plugin data")?;
let mut persistence_lock = persistence.lock().await;
if let Some(worker) = &mut persistence_lock.worker {
while let Some(result) = worker.pop_command_result() {
let action = InputAction::CommandResult(result)
.serialize(self.protocol_version)
.context("Failed to serialize input action")?;
stdin
.write(action.as_bytes())
.await
.context("Failed to write input action to plugin")?;
}
}
for line in lines {
let action =
OutputAction::deserialize(&line, self.use_base64, self.protocol_version)
.context("Failed to deserialize plugin action")?;
let Some(action) = action else {
if let Some(message) = line.strip_prefix("$_") {
println!("{message}");
}
continue;
};
match action {
OutputAction::SetResult(new_result) => {
let new_result = if self.protocol_version < 3 {
let string: String = serde_json::from_value(new_result)
.context("Failed to deserialize hook result")?;
serde_json::from_str(&string)
.context("Failed to deserialize hook result")?
} else {
serde_json::from_value(new_result)
.context("Failed to deserialize hook result")?
};
*result = Some(new_result);
return Ok(true);
}
OutputAction::SetError(error) => {
return Err(anyhow!(
"Plugin '{}' returned an error: {error}",
self.plugin_id
));
}
OutputAction::SetState(new_state) => {
persistence_lock.state = new_state;
}
OutputAction::RunWorkerCommand { command, payload } => {
let worker = persistence_lock.worker.as_mut().context(
"Command was called on plugin worker, but the worker was not started",
)?;
worker
.send_input_action(InputAction::Command { command, payload })
.await
.context("Failed to send command to worker")?;
}
OutputAction::SetCommandResult(result) => {
command_results.push_back(result);
}
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)
}
}
}
pub async fn kill(self) -> anyhow::Result<Option<H::Result>> {
if let ExecutableHookHandleInner::Started {
mut child, result, ..
} = self.inner
{
child.kill().await?;
Ok(result)
} else {
Ok(None)
}
}
pub async fn result(self) -> anyhow::Result<H::Result> {
let ExecutableHookHandleInner::Started {
mut child, result, ..
} = self.inner
else {
bail!("Result method called before executable hook was polled or started");
};
let cmd_result = child.wait().await?;
if !cmd_result.success() {
if let Some(exit_code) = cmd_result.code() {
bail!(
"Hook from plugin '{}' returned a non-zero exit code of {}",
self.plugin_id,
exit_code
);
} else {
bail!(
"Hook from plugin '{}' returned a non-zero exit code",
self.plugin_id
);
}
}
let result = result.with_context(|| {
format!(
"Plugin hook for plugin '{}' did not return a result",
self.plugin_id
)
})?;
Ok(result)
}
pub async fn send_input_action(&mut self, action: InputAction) -> anyhow::Result<()> {
if let ExecutableHookHandleInner::Started { stdin, .. } = &mut self.inner {
let action = action
.serialize(self.protocol_version)
.context("Failed to serialize input action")?;
stdin
.write(action.as_bytes())
.await
.context("Failed to write input action to plugin")?;
}
Ok(())
}
}