mod mark_line;
mod prettify;
mod sound;
use std::collections::HashMap;
use std::process::Stdio;
use std::time::Instant;
use par_term_config::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::{PRETTIFY_RELAY_PREFIX, PrettifyRelayPayload, 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>,
}
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 action_results, current_scrollback_len, custom_vars) =
if let Ok(term) = tab.terminal.try_write() {
let ar = term.poll_action_results();
let sl = term.scrollback_len();
let cv = term.custom_session_variables();
(ar, sl, cv)
} else {
return;
};
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> = self
.trigger_state
.approved_pending_actions
.drain(..)
.collect();
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);
}
}
pre_approved.extend(action_results);
action_results = pre_approved;
}
if action_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
.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 mut pending_marks: HashMap<u64, Vec<MarkLineEntry>> = HashMap::new();
let mut pending_prettify: Vec<(u64, usize, PrettifyRelayPayload)> = Vec::new();
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,
};
for action in action_results {
self.dispatch_trigger_action(action, &ctx, &mut pending_marks, &mut pending_prettify);
}
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.scripting.trigger_rate_limiter.cleanup(60);
}
if !pending_marks.is_empty() {
self.apply_mark_line_results(pending_marks, current_scrollback_len);
}
if !pending_prettify.is_empty() {
self.apply_prettify_triggers(pending_prettify, current_scrollback_len);
}
}
fn dispatch_trigger_action(
&mut self,
action: ActionResult,
ctx: &DispatchContext<'_>,
pending_marks: &mut HashMap<u64, Vec<MarkLineEntry>>,
pending_prettify: &mut Vec<(u64, usize, PrettifyRelayPayload)>,
) {
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(ref 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,
pending_prettify,
);
}
}
}
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
.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,
},
);
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,
},
);
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 delay_ms == 0 {
if let Ok(term) = tab.terminal.try_write()
&& 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_write()
&& 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,
},
);
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);
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_write()
&& 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_prettify: &mut Vec<(u64, usize, PrettifyRelayPayload)>,
) {
if let Some(ref lbl) = label
&& let Some(json) = lbl.strip_prefix(PRETTIFY_RELAY_PREFIX)
{
if let Ok(payload) = serde_json::from_str::<PrettifyRelayPayload>(json) {
pending_prettify.push((trigger_id, row, payload));
} else {
log::error!(
"Trigger {} prettify relay: invalid payload: {}",
trigger_id,
json
);
}
return;
}
pending_marks
.entry(trigger_id)
.or_default()
.push((row, label, color));
}
}