mod mark_line;
mod sound;
use std::collections::HashMap;
use std::process::Stdio;
use std::time::Instant;
use par_term_config::{check_command_allowlist, check_command_denylist};
const MAX_TRIGGER_PROCESSES: usize = 10;
const PROCESS_CLEANUP_AGE_SECS: u64 = 300;
use par_term_emu_core_rust::terminal::ActionResult;
use crate::config::automation::TriggerActionConfig;
use super::window_state::WindowState;
type MarkLineEntry = (usize, Option<String>, Option<(u8, u8, u8)>);
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest).to_string_lossy().to_string();
}
path.to_string()
}
struct DispatchContext<'a> {
trigger_prompt_before_run: &'a HashMap<u64, bool>,
approved_this_frame: &'a std::collections::HashSet<u64>,
trigger_names: &'a HashMap<u64, String>,
trigger_split_percent: &'a HashMap<u64, u8>,
trigger_allowed_commands: &'a HashMap<u64, Vec<String>>,
}
impl WindowState {
pub(crate) fn check_trigger_actions(&mut self) {
let tab = if let Some(t) = self.tab_manager.active_tab() {
t
} else {
return;
};
let mut polls: Vec<(Vec<ActionResult>, usize)> = Vec::new();
let mut custom_vars: HashMap<String, String> = HashMap::new();
if let Ok(term) = tab.terminal.try_read() {
custom_vars.extend(term.custom_session_variables());
polls.push((term.poll_action_results(), term.scrollback_len()));
}
if let Some(pm) = tab.pane_manager() {
for pane in pm.all_panes() {
if let Ok(term) = pane.terminal.try_read() {
custom_vars.extend(term.custom_session_variables());
polls.push((term.poll_action_results(), term.scrollback_len()));
}
}
}
if !custom_vars.is_empty() {
let mut changed = false;
let mut vars = self.badge_state.variables_mut();
for (name, value) in &custom_vars {
let trimmed = value.trim();
if vars.custom.get(name).map(|v| v.as_str()) != Some(trimmed) {
log::debug!(
"Trigger SetVariable synced to badge: {}='{}'",
name,
trimmed
);
vars.custom.insert(name.clone(), trimmed.to_string());
changed = true;
}
}
drop(vars);
if changed {
self.badge_state.mark_dirty();
}
}
let mut approved_this_frame: std::collections::HashSet<u64> =
std::collections::HashSet::new();
if !self.trigger_state.approved_pending_actions.is_empty() {
let mut pre_approved: Vec<ActionResult> =
std::mem::take(&mut self.trigger_state.approved_pending_actions);
for action in &pre_approved {
let tid = match action {
ActionResult::RunCommand { trigger_id, .. }
| ActionResult::SendText { trigger_id, .. }
| ActionResult::SplitPane { trigger_id, .. } => Some(*trigger_id),
_ => None,
};
if let Some(id) = tid {
approved_this_frame.insert(id);
}
}
match polls.first_mut() {
Some((results, _)) => {
pre_approved.append(results);
*results = pre_approved;
}
None => polls.push((pre_approved, 0)),
}
}
if polls.iter().all(|(results, _)| results.is_empty()) {
return;
}
let trigger_prompt_before_run: std::collections::HashMap<u64, bool> =
tab.scripting.trigger_prompt_before_run.clone();
let trigger_names: std::collections::HashMap<u64, String> =
if let Ok(term) = tab.terminal.try_read() {
term.trigger_names()
} else {
std::collections::HashMap::new()
};
let trigger_split_percent: std::collections::HashMap<u64, u8> = trigger_names
.iter()
.filter_map(|(&id, name)| {
self.config
.load()
.automation
.triggers
.iter()
.find(|t| &t.name == name)
.and_then(|t| {
t.actions.iter().find_map(|a| {
if let TriggerActionConfig::SplitPane { split_percent, .. } = a {
Some((id, *split_percent))
} else {
None
}
})
})
})
.collect();
let trigger_allowed_commands: std::collections::HashMap<u64, Vec<String>> = trigger_names
.iter()
.filter_map(|(&id, name)| {
self.config
.load()
.automation
.triggers
.iter()
.find(|t| &t.name == name)
.and_then(|t| {
if t.allowed_commands.is_empty() {
None
} else {
Some((id, t.allowed_commands.clone()))
}
})
})
.collect();
let ctx = DispatchContext {
trigger_prompt_before_run: &trigger_prompt_before_run,
approved_this_frame: &approved_this_frame,
trigger_names: &trigger_names,
trigger_split_percent: &trigger_split_percent,
trigger_allowed_commands: &trigger_allowed_commands,
};
for (action_results, source_scrollback_len) in polls {
if action_results.is_empty() {
continue;
}
let mut pending_marks: HashMap<u64, Vec<MarkLineEntry>> = HashMap::new();
for action in action_results {
self.dispatch_trigger_action(action, &ctx, &mut pending_marks);
}
if !pending_marks.is_empty() {
self.apply_mark_line_results(pending_marks, source_scrollback_len);
}
}
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.scripting.trigger_rate_limiter.cleanup(60);
}
}
fn dispatch_trigger_action(
&mut self,
action: ActionResult,
ctx: &DispatchContext<'_>,
pending_marks: &mut HashMap<u64, Vec<MarkLineEntry>>,
) {
match action {
ActionResult::RunCommand {
trigger_id,
command,
args,
} => {
let command = expand_tilde(&command);
let args: Vec<String> = args.iter().map(|a| expand_tilde(a)).collect();
self.handle_run_command_action(trigger_id, command, args, ctx);
}
ActionResult::PlaySound {
trigger_id,
sound_id,
volume,
} => {
let sound_id = expand_tilde(&sound_id);
log::info!(
"Trigger {} firing PlaySound: '{}' at volume {}",
trigger_id,
sound_id,
volume
);
if sound_id == "bell" || sound_id.is_empty() {
if let Some(tab) = self.tab_manager.active_tab()
&& let Some(audio_bell) = tab.active_bell().audio
{
audio_bell.play(volume);
}
} else {
Self::play_sound_file(&sound_id, volume);
}
}
ActionResult::SendText {
trigger_id,
text,
delay_ms,
} => {
self.handle_send_text_action(trigger_id, text, delay_ms, ctx);
}
ActionResult::Notify {
trigger_id,
title,
message,
} => {
log::info!(
"Trigger {} firing Notify: '{}' - '{}'",
trigger_id,
title,
message
);
self.deliver_notification_force(&title, &message);
}
ActionResult::SplitPane {
trigger_id,
direction,
command,
focus_new_pane,
target,
source_pane_id,
} => {
self.handle_split_pane_action(
trigger_id,
direction,
command,
focus_new_pane,
target,
source_pane_id,
ctx,
);
}
ActionResult::MarkLine {
trigger_id,
row,
label,
color,
} => {
self.handle_mark_line_action(trigger_id, row, label, color, pending_marks);
}
}
}
fn handle_run_command_action(
&mut self,
trigger_id: u64,
command: String,
args: Vec<String>,
ctx: &DispatchContext<'_>,
) {
let prompt = ctx
.trigger_prompt_before_run
.get(&trigger_id)
.copied()
.unwrap_or(true);
if !prompt && !ctx.approved_this_frame.contains(&trigger_id) {
let trigger_name = ctx
.trigger_names
.get(&trigger_id)
.cloned()
.unwrap_or_else(|| format!("trigger #{}", trigger_id));
if self
.config
.load()
.unaccepted_risk_trigger_names
.contains(&trigger_name)
{
log::warn!(
"Trigger '{}' (id={}) RunCommand BLOCKED: \
`prompt_before_run: false` requires `i_accept_the_risk: true` \
to execute dangerous actions without confirmation. \
Add `i_accept_the_risk: true` to this trigger or set \
`prompt_before_run: true`.",
trigger_name,
trigger_id,
);
crate::debug_error!(
"TRIGGER",
"AUDIT RunCommand BLOCKED trigger_id={} trigger_name={} \
reason=missing_i_accept_the_risk",
trigger_id,
trigger_name,
);
return;
}
log::warn!(
"SECURITY: Trigger '{}' (id={}) executing RunCommand without \
confirmation (prompt_before_run: false). \
command='{}' args={:?}",
trigger_name,
trigger_id,
command,
args,
);
crate::debug_info!(
"TRIGGER",
"AUDIT RunCommand no-prompt trigger_id={} trigger_name={} \
command={} args={:?}",
trigger_id,
trigger_name,
command,
args,
);
}
if prompt
&& !self
.trigger_state
.always_allow_trigger_ids
.contains(&trigger_id)
&& !ctx.approved_this_frame.contains(&trigger_id)
{
let trigger_name = ctx
.trigger_names
.get(&trigger_id)
.cloned()
.unwrap_or_else(|| format!("trigger #{}", trigger_id));
let description = format!("Run command: {} {}", command, args.join(" "))
.trim()
.to_string();
self.trigger_state.pending_trigger_actions.push(
crate::app::window_state::PendingTriggerAction {
trigger_id,
trigger_name,
action: ActionResult::RunCommand {
trigger_id,
command,
args,
},
description,
target: None,
},
);
return;
}
if let Some(allowed) = ctx.trigger_allowed_commands.get(&trigger_id)
&& let Err(reason) = check_command_allowlist(&command, allowed)
{
log::error!(
"Trigger {} RunCommand DENIED by allowlist: {}",
trigger_id,
reason,
);
return;
}
if let Some(denied_pattern) = check_command_denylist(&command, &args) {
log::error!(
"Trigger {} RunCommand DENIED: '{}' matches denylist pattern '{}'",
trigger_id,
command,
denied_pattern,
);
return;
}
if !ctx.approved_this_frame.contains(&trigger_id)
&& let Some(tab) = self.tab_manager.active_tab_mut()
&& !tab
.scripting
.trigger_rate_limiter
.check_and_update(trigger_id)
{
log::warn!(
"Trigger {} RunCommand RATE-LIMITED: '{}' (too frequent)",
trigger_id,
command,
);
return;
}
log::info!(
"Trigger {} firing RunCommand: {} {:?}",
trigger_id,
command,
args
);
let now = Instant::now();
self.trigger_state
.trigger_spawned_processes
.retain(|_pid, spawn_time| {
now.duration_since(*spawn_time).as_secs() < PROCESS_CLEANUP_AGE_SECS
});
if self.trigger_state.trigger_spawned_processes.len() >= MAX_TRIGGER_PROCESSES {
log::warn!(
"Trigger {} RunCommand DENIED: max concurrent processes ({}) reached",
trigger_id,
MAX_TRIGGER_PROCESSES
);
return;
}
match std::process::Command::new(&command)
.args(&args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => {
let pid = child.id();
log::debug!("RunCommand spawned successfully (PID={})", pid);
crate::debug_info!(
"TRIGGER",
"AUDIT RunCommand trigger_id={} pid={} command={} args={:?}",
trigger_id,
pid,
command,
args
);
self.trigger_state
.trigger_spawned_processes
.insert(pid, Instant::now());
}
Err(e) => {
log::error!("RunCommand failed to spawn '{}': {}", command, e);
crate::debug_error!(
"TRIGGER",
"AUDIT RunCommand FAILED trigger_id={} command={} error={}",
trigger_id,
command,
e
);
}
}
}
fn handle_send_text_action(
&mut self,
trigger_id: u64,
text: String,
delay_ms: u64,
ctx: &DispatchContext<'_>,
) {
let prompt = ctx
.trigger_prompt_before_run
.get(&trigger_id)
.copied()
.unwrap_or(true);
if prompt
&& !self
.trigger_state
.always_allow_trigger_ids
.contains(&trigger_id)
&& !ctx.approved_this_frame.contains(&trigger_id)
{
let trigger_name = ctx
.trigger_names
.get(&trigger_id)
.cloned()
.unwrap_or_else(|| format!("trigger #{}", trigger_id));
let description = format!("Send text: '{}'", text);
self.trigger_state.pending_trigger_actions.push(
crate::app::window_state::PendingTriggerAction {
trigger_id,
trigger_name,
action: ActionResult::SendText {
trigger_id,
text,
delay_ms,
},
description,
target: None,
},
);
return;
}
if !ctx.approved_this_frame.contains(&trigger_id)
&& let Some(tab) = self.tab_manager.active_tab_mut()
&& !tab
.scripting
.trigger_rate_limiter
.check_and_update(trigger_id)
{
log::warn!(
"Trigger {} SendText RATE-LIMITED: '{}' (too frequent)",
trigger_id,
text,
);
return;
}
log::info!(
"Trigger {} firing SendText: '{}' (delay={}ms)",
trigger_id,
text,
delay_ms
);
crate::debug_info!(
"TRIGGER",
"AUDIT SendText trigger_id={} delay_ms={} text={:?}",
trigger_id,
delay_ms,
text
);
if let Some(tab) = self.tab_manager.active_tab() {
if self.route_mux_tab_write(tab, text.as_bytes()) {
return;
}
if delay_ms == 0 {
if let Ok(term) = tab.terminal.try_read()
&& let Err(e) = term.write(text.as_bytes())
{
log::error!("SendText write failed: {}", e);
}
} else {
let terminal = std::sync::Arc::clone(&tab.terminal);
let text_owned = text;
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
if let Ok(term) = terminal.try_read()
&& let Err(e) = term.write(text_owned.as_bytes())
{
log::error!("Delayed SendText write failed: {}", e);
}
});
}
}
}
#[allow(clippy::too_many_arguments)] fn handle_split_pane_action(
&mut self,
trigger_id: u64,
direction: par_term_emu_core_rust::terminal::TriggerSplitDirection,
command: Option<par_term_emu_core_rust::terminal::TriggerSplitCommand>,
focus_new_pane: bool,
target: par_term_emu_core_rust::terminal::TriggerSplitTarget,
source_pane_id: Option<u64>,
ctx: &DispatchContext<'_>,
) {
let prompt = ctx
.trigger_prompt_before_run
.get(&trigger_id)
.copied()
.unwrap_or(true);
if prompt
&& !self
.trigger_state
.always_allow_trigger_ids
.contains(&trigger_id)
&& !ctx.approved_this_frame.contains(&trigger_id)
{
let trigger_name = ctx
.trigger_names
.get(&trigger_id)
.cloned()
.unwrap_or_else(|| format!("trigger #{}", trigger_id));
let dir_str = match direction {
par_term_emu_core_rust::terminal::TriggerSplitDirection::Horizontal => "horizontal",
par_term_emu_core_rust::terminal::TriggerSplitDirection::Vertical => "vertical",
};
let description = format!("Split pane ({}) and run command", dir_str);
self.trigger_state.pending_trigger_actions.push(
crate::app::window_state::PendingTriggerAction {
trigger_id,
trigger_name,
action: ActionResult::SplitPane {
trigger_id,
direction,
command,
focus_new_pane,
target,
source_pane_id,
},
description,
target: None,
},
);
return;
}
if let Some(tab) = self.tab_manager.active_tab_mut()
&& !tab
.scripting
.trigger_rate_limiter
.check_and_update(trigger_id)
{
log::warn!(
"Trigger {} SplitPane RATE-LIMITED (too frequent)",
trigger_id,
);
return;
}
let pane_direction = match direction {
par_term_emu_core_rust::terminal::TriggerSplitDirection::Horizontal => {
crate::pane::SplitDirection::Horizontal
}
par_term_emu_core_rust::terminal::TriggerSplitDirection::Vertical => {
crate::pane::SplitDirection::Vertical
}
};
crate::debug_info!(
"TRIGGER",
"AUDIT SplitPane trigger_id={} direction={:?} focus_new={}",
trigger_id,
pane_direction,
focus_new_pane
);
let pct = ctx
.trigger_split_percent
.get(&trigger_id)
.copied()
.unwrap_or(66);
#[cfg(feature = "mux")]
{
let command_line = match &command {
Some(par_term_emu_core_rust::terminal::TriggerSplitCommand::SendText {
text,
..
}) => Some(text.clone()),
Some(par_term_emu_core_rust::terminal::TriggerSplitCommand::InitialCommand {
command: cmd_name,
args,
}) => {
Some(if args.is_empty() {
cmd_name.clone()
} else {
format!("{cmd_name} {}", args.join(" "))
})
}
None => None,
};
if self.split_pane_with_command_via_mux(
matches!(
direction,
par_term_emu_core_rust::terminal::TriggerSplitDirection::Vertical
),
command_line.as_deref(),
) {
return;
}
}
let new_pane_id = self.split_pane_direction(pane_direction, focus_new_pane, None, pct);
if let (Some(pane_id), Some(cmd)) = (new_pane_id, command) {
let (text, delay_ms) = match cmd {
par_term_emu_core_rust::terminal::TriggerSplitCommand::SendText {
text,
delay_ms,
} => (format!("{}\n", text), delay_ms),
par_term_emu_core_rust::terminal::TriggerSplitCommand::InitialCommand {
command: cmd_name,
args,
} => {
log::warn!(
"Trigger {} SplitPane InitialCommand not fully supported; \
sending as text",
trigger_id
);
let full = if args.is_empty() {
format!("{}\n", cmd_name)
} else {
format!("{} {}\n", cmd_name, args.join(" "))
};
(full, 200)
}
};
if let Some(tab) = self.tab_manager.active_tab()
&& let Some(pm) = tab.pane_manager()
&& let Some(pane) = pm.get_pane(pane_id)
{
let terminal = std::sync::Arc::clone(&pane.terminal);
std::thread::spawn(move || {
if delay_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
if let Ok(term) = terminal.try_read()
&& let Err(e) = term.write(text.as_bytes())
{
log::error!(
"SplitPane SendText write failed for pane {}: {}",
pane_id,
e
);
}
});
}
}
}
fn handle_mark_line_action(
&mut self,
trigger_id: u64,
row: usize,
label: Option<String>,
color: Option<(u8, u8, u8)>,
pending_marks: &mut HashMap<u64, Vec<MarkLineEntry>>,
) {
pending_marks
.entry(trigger_id)
.or_default()
.push((row, label, color));
}
}
#[cfg(all(test, feature = "mux"))]
mod write_routing_tests {
use super::*;
use crate::app::tmux_handler::pane_write::tests::mux_state_with_recorder;
use crate::config::Config;
use std::collections::{HashMap, HashSet};
#[test]
fn trigger_send_text_routes_to_the_daemon_pane() {
let (mut ws, sent, tab_id, pane) =
mux_state_with_recorder("trigger-send-text", Config::default());
let _ = (tab_id, pane);
let prompt_off: HashMap<u64, bool> = HashMap::from([(7, false)]);
let approved = HashSet::new();
let names: HashMap<u64, String> = HashMap::new();
let percents: HashMap<u64, u8> = HashMap::new();
let allowed: HashMap<u64, Vec<String>> = HashMap::new();
let ctx = DispatchContext {
trigger_prompt_before_run: &prompt_off,
approved_this_frame: &approved,
trigger_names: &names,
trigger_split_percent: &percents,
trigger_allowed_commands: &allowed,
};
ws.handle_send_text_action(7, "trigger-needle".to_string(), 0, &ctx);
let hex: String = b"trigger-needle"
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(
*sent.lock().unwrap(),
vec![format!("send-keys -t %0 -H {hex}")],
"trigger SendText must reach the daemon pane, not the hidden shell"
);
}
#[test]
fn trigger_split_pane_splits_daemon_side_and_types_the_command() {
use crate::app::tmux_handler::pane_write::tests::SplitReplyingTransport;
use par_term_emu_core_rust::terminal::{
TriggerSplitCommand, TriggerSplitDirection, TriggerSplitTarget,
};
let (mut ws, _plain, tab_id, pane) =
mux_state_with_recorder("trigger-split-mux", Config::default());
let (transport, sent) = SplitReplyingTransport::new(1);
ws.tmux_state.transport = Some(Box::new(transport));
let prompt_off: HashMap<u64, bool> = HashMap::from([(7, false)]);
let approved = HashSet::new();
let names: HashMap<u64, String> = HashMap::new();
let percents: HashMap<u64, u8> = HashMap::new();
let allowed: HashMap<u64, Vec<String>> = HashMap::new();
let ctx = DispatchContext {
trigger_prompt_before_run: &prompt_off,
approved_this_frame: &approved,
trigger_names: &names,
trigger_split_percent: &percents,
trigger_allowed_commands: &allowed,
};
ws.handle_split_pane_action(
7,
TriggerSplitDirection::Vertical,
Some(TriggerSplitCommand::SendText {
text: "echo trigger-split".to_string(),
delay_ms: 0,
}),
true,
TriggerSplitTarget::Active,
None,
&ctx,
);
let commands: Vec<String> = sent
.lock()
.unwrap()
.iter()
.filter(|c| !c.starts_with("set-environment"))
.cloned()
.collect();
assert_eq!(
commands,
vec![
"split-window -h -t %0".to_string(),
format!(
"send-keys -t %1 -l {}",
par_term_mux::quote_env_value("echo trigger-split")
),
"send-keys -t %1 Enter".to_string(),
],
"the split and the command must both land daemon-side"
);
assert_eq!(
ws.tab_manager
.get_tab(tab_id)
.and_then(|tab| tab.pane_manager().map(|pm| pm.all_panes().len())),
Some(1),
"no native pane may be created inside the mux tab"
);
assert_eq!(
ws.tmux_state.mux_focused_pane,
Some(1),
"the new daemon pane must be focused"
);
let _ = pane;
}
}