use super::WindowState;
use crate::pane::PaneId;
use crate::tab::TabId;
use crate::terminal::TerminalManager;
use par_term_emu_core_rust::terminal::Urgency;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Instant;
use tokio::sync::RwLock;
const MAX_PENDING_NOTIFICATION_CLICKS: usize = 256;
const MAX_CLICK_TOKEN_REQUEUES: u32 = 600;
pub(crate) struct PendingNotificationClick {
tab_id: TabId,
pane_id: Option<PaneId>,
terminal: Weak<RwLock<TerminalManager>>,
osc_id: Option<String>,
wants_focus: bool,
wants_report: bool,
registered_at: Instant,
}
#[derive(Default)]
pub(crate) struct NotificationClickState {
pending: Vec<(u64, PendingNotificationClick)>,
}
static NEXT_CLICK_TOKEN: AtomicU64 = AtomicU64::new(1);
fn next_click_token() -> u64 {
NEXT_CLICK_TOKEN.fetch_add(1, Ordering::Relaxed)
}
static CLICK_TOKEN_REQUEUE: OnceLock<Mutex<Vec<(u64, u32)>>> = OnceLock::new();
fn click_token_requeue() -> &'static Mutex<Vec<(u64, u32)>> {
CLICK_TOKEN_REQUEUE.get_or_init(|| Mutex::new(Vec::new()))
}
struct CollectedNotification {
title: String,
message: String,
urgency: Urgency,
tab_id: TabId,
pane_id: Option<PaneId>,
terminal: Arc<RwLock<TerminalManager>>,
osc_id: Option<String>,
actions: Vec<String>,
}
impl WindowState {
pub(crate) fn check_notifications(&mut self) {
let mut notifications_to_send: Vec<CollectedNotification> = Vec::new();
for tab in self.tab_manager.tabs() {
let terminals: Vec<(Option<PaneId>, Arc<RwLock<TerminalManager>>)> = tab
.pane_manager
.as_ref()
.map(|pm| {
pm.all_panes()
.into_iter()
.map(|pane| (Some(pane.id), Arc::clone(&pane.terminal)))
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![(None, Arc::clone(&tab.terminal))]);
for (pane_id, terminal) in terminals {
if let Ok(term) = terminal.try_read()
&& term.has_notifications()
{
for notif in term.take_notifications() {
notifications_to_send.push(CollectedNotification {
title: notif.title,
message: notif.message,
urgency: notif.urgency,
tab_id: tab.id,
pane_id,
terminal: Arc::clone(&terminal),
osc_id: notif.id,
actions: notif.actions,
});
}
}
}
}
for n in notifications_to_send {
self.deliver_osc99_notification(
n.tab_id, n.pane_id, n.terminal, &n.title, &n.message, n.urgency, n.osc_id,
n.actions,
);
}
}
#[allow(clippy::too_many_arguments)]
fn deliver_osc99_notification(
&mut self,
tab_id: TabId,
pane_id: Option<PaneId>,
terminal: Arc<RwLock<TerminalManager>>,
title: &str,
message: &str,
urgency: Urgency,
osc_id: Option<String>,
actions: Vec<String>,
) {
if !title.is_empty() {
log::info!("=== Notification: {} ===", title);
log::info!("{}", message);
log::info!("===========================");
} else {
log::info!("=== Notification ===");
log::info!("{}", message);
log::info!("===================");
}
if self
.config
.load()
.notifications
.suppress_notifications_when_focused
&& self.focus_state.is_focused
{
log::debug!(
"Suppressing desktop notification (window is focused): {}",
title
);
return;
}
let mut wants_focus = true;
let mut wants_report = false;
for action in &actions {
match action.as_str() {
"focus" => wants_focus = true,
"-focus" => wants_focus = false,
"report" => wants_report = true,
"-report" => wants_report = false,
_ => {}
}
}
let identity = osc_id.as_ref().map(|id| format!("osc99-{tab_id}-{id}"));
let click_token = if wants_focus || wants_report {
let token = next_click_token();
self.register_notification_click(
token,
PendingNotificationClick {
tab_id,
pane_id,
terminal: Arc::downgrade(&terminal),
osc_id,
wants_focus,
wants_report,
registered_at: Instant::now(),
},
);
Some(token)
} else {
None
};
let platform_urgency = match urgency {
Urgency::Low => crate::platform::NotificationUrgency::Low,
Urgency::Normal => crate::platform::NotificationUrgency::Normal,
Urgency::Critical => crate::platform::NotificationUrgency::Critical,
};
crate::platform::deliver_desktop_notification_request(
&crate::platform::NotificationRequest {
title,
message,
timeout_ms: 3000,
urgency: platform_urgency,
identity: identity.as_deref(),
click_token,
},
);
}
fn register_notification_click(&mut self, token: u64, entry: PendingNotificationClick) {
let pending = &mut self.notification_click_state.pending;
pending.push((token, entry));
if pending.len() > MAX_PENDING_NOTIFICATION_CLICKS {
pending.remove(0);
}
}
pub(crate) fn check_notification_clicks(&mut self) {
let fresh = crate::platform::drain_notification_clicks()
.into_iter()
.map(|token| (token, 0u32));
let requeued = std::mem::take(
&mut *click_token_requeue()
.lock()
.unwrap_or_else(|e| e.into_inner()),
);
for (token, retries) in fresh.chain(requeued) {
if let Some(pos) = self
.notification_click_state
.pending
.iter()
.position(|(t, _)| *t == token)
{
let (_, entry) = self.notification_click_state.pending.remove(pos);
self.execute_notification_click(entry);
continue;
}
if retries + 1 < MAX_CLICK_TOKEN_REQUEUES {
click_token_requeue()
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((token, retries + 1));
} else {
log::debug!(
"Dropping notification click token {} after {} unclaimed re-queues",
token,
retries + 1
);
}
}
}
fn execute_notification_click(&mut self, entry: PendingNotificationClick) {
log::debug!(
"Handling notification click for tab {} (age: {:?}, focus={}, report={})",
entry.tab_id,
entry.registered_at.elapsed(),
entry.wants_focus,
entry.wants_report
);
if entry.wants_focus {
if let Some(window) = &self.window {
window.focus_window();
}
self.tab_manager.switch_to(entry.tab_id);
if let Some(pane_id) = entry.pane_id
&& let Some(tab) = self.tab_manager.get_tab_mut(entry.tab_id)
&& let Some(pm) = tab.pane_manager.as_mut()
{
pm.focus_pane(pane_id);
}
}
if entry.wants_report {
let report = format!(
"\x1b]99;i={};\x1b\\",
entry.osc_id.as_deref().unwrap_or("0")
);
match entry.terminal.upgrade() {
Some(terminal) => match terminal.try_read() {
Ok(term) => {
if let Err(e) = term.write_str(&report) {
log::debug!("Failed to write OSC 99 activation report: {}", e);
}
}
Err(_) => {
log::debug!("Skipped OSC 99 activation report: terminal lock contended")
}
},
None => log::debug!("Skipped OSC 99 activation report: terminal no longer exists"),
}
}
self.focus_state.needs_redraw = true;
self.request_redraw();
}
pub(crate) fn check_bell(&mut self) {
if self.config.load().notifications.notification_bell_sound == 0
&& !self.config.load().notifications.notification_bell_visual
&& !self.config.load().notifications.notification_bell_desktop
{
return;
}
let (current_bell_count, last_count) = {
let tab = if let Some(t) = self.tab_manager.active_tab() {
t
} else {
return;
};
let terminal = tab
.pane_manager
.as_ref()
.and_then(|pm| pm.focused_pane())
.map(|pane| std::sync::Arc::clone(&pane.terminal))
.unwrap_or_else(|| std::sync::Arc::clone(&tab.terminal));
if let Ok(term) = terminal.try_write() {
(term.bell_count(), tab.active_bell().last_count)
} else {
return;
}
};
if current_bell_count > last_count {
let bell_events = current_bell_count - last_count;
log::info!("Bell event detected ({} bell(s))", bell_events);
log::info!(
" Config: sound={}, visual={}, desktop={}",
self.config.load().notifications.notification_bell_sound,
self.config.load().notifications.notification_bell_visual,
self.config.load().notifications.notification_bell_desktop
);
if let Some(alert_cfg) = self
.config
.load()
.notifications
.alert_sounds
.get(&crate::config::AlertEvent::Bell)
{
if alert_cfg.enabled
&& alert_cfg.volume > 0
&& let Some(tab) = self.tab_manager.active_tab()
&& let Some(ref audio_bell) = tab.active_bell().audio
{
log::info!(
" Playing alert sound for bell at {}% volume",
alert_cfg.volume
);
audio_bell.play_alert(alert_cfg);
}
} else if self.config.load().notifications.notification_bell_sound > 0 {
if let Some(tab) = self.tab_manager.active_tab()
&& let Some(ref audio_bell) = tab.active_bell().audio
{
log::info!(
" Playing audio bell at {}% volume",
self.config.load().notifications.notification_bell_sound
);
audio_bell.play(self.config.load().notifications.notification_bell_sound);
} else {
log::warn!(" Audio bell requested but not initialized");
}
} else {
log::debug!(" Audio bell disabled (volume=0)");
}
if self.config.load().notifications.notification_bell_visual {
log::info!(" Triggering visual bell flash");
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_bell_mut().visual_flash = Some(std::time::Instant::now());
}
self.request_redraw();
} else {
log::debug!(" Visual bell disabled");
}
if self.config.load().notifications.notification_bell_desktop {
log::info!(" Sending desktop notification");
let message = if bell_events == 1 {
"Terminal bell".to_string()
} else {
format!("Terminal bell ({} events)", bell_events)
};
self.deliver_notification("Terminal", &message);
} else {
log::debug!(" Desktop notification disabled");
}
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_bell_mut().last_count = current_bell_count;
}
}
}
pub(crate) fn play_alert_sound(&self, event: crate::config::AlertEvent) {
if let Some(alert_cfg) = self.config.load().notifications.alert_sounds.get(&event)
&& alert_cfg.enabled
&& alert_cfg.volume > 0
&& let Some(tab) = self.tab_manager.active_tab()
&& let Some(ref audio_bell) = tab.active_bell().audio
{
log::info!(
"Playing alert sound for {:?} at {}% volume",
event,
alert_cfg.volume
);
audio_bell.play_alert(alert_cfg);
}
}
pub(crate) fn check_session_exit_notifications(&mut self) {
if !self.config.load().notifications.notification_session_ended {
return;
}
let mut notifications_to_send: Vec<(String, String)> = Vec::new();
for tab in self.tab_manager.tabs_mut() {
if tab.activity.exit_notified {
continue;
}
let has_exited = if let Ok(term) = tab.terminal.try_write() {
!term.is_running()
} else {
continue; };
if has_exited {
tab.activity.exit_notified = true;
let title = format!("Session Ended: {}", tab.title);
let message = "The shell process has exited".to_string();
log::info!("Session exit notification: {} has exited", tab.title);
notifications_to_send.push((title, message));
}
}
for (title, message) in notifications_to_send {
self.deliver_notification(&title, &message);
}
}
pub(crate) fn check_activity_idle_notifications(&mut self) {
if !self
.config
.load()
.notifications
.notification_activity_enabled
&& !self
.config
.load()
.notifications
.notification_silence_enabled
{
return;
}
let now = std::time::Instant::now();
let activity_threshold = std::time::Duration::from_secs(
self.config
.load()
.notifications
.notification_activity_threshold,
);
let silence_threshold = std::time::Duration::from_secs(
self.config
.load()
.notifications
.notification_silence_threshold,
);
let mut notifications_to_send: Vec<(String, String)> = Vec::new();
for tab in self.tab_manager.tabs_mut() {
let current_generation = if let Ok(term) = tab.terminal.try_write() {
term.update_generation()
} else {
continue; };
let time_since_activity = now.duration_since(tab.activity.last_activity_time);
if current_generation > tab.activity.last_seen_generation {
let was_idle = time_since_activity >= activity_threshold;
tab.activity.last_seen_generation = current_generation;
tab.activity.last_activity_time = now;
tab.activity.silence_notified = false;
if self
.config
.load()
.notifications
.notification_activity_enabled
&& was_idle
{
let title = format!("Activity in {}", tab.title);
let message = format!(
"Terminal output resumed after {} seconds of inactivity",
time_since_activity.as_secs()
);
log::info!(
"Activity notification: {} idle for {}s, now active",
tab.title,
time_since_activity.as_secs()
);
notifications_to_send.push((title, message));
}
} else {
if self
.config
.load()
.notifications
.notification_silence_enabled
&& !tab.activity.silence_notified
&& time_since_activity >= silence_threshold
{
tab.activity.silence_notified = true;
let title = format!("Silence in {}", tab.title);
let message =
format!("No output for {} seconds", time_since_activity.as_secs());
log::info!(
"Silence notification: {} silent for {}s",
tab.title,
time_since_activity.as_secs()
);
notifications_to_send.push((title, message));
}
}
}
for (title, message) in notifications_to_send {
self.deliver_notification(&title, &message);
}
}
pub(crate) fn deliver_notification_force(&self, title: &str, message: &str) {
self.deliver_notification_inner(title, message, true, Urgency::Normal);
}
pub(crate) fn deliver_notification(&self, title: &str, message: &str) {
self.deliver_notification_inner(title, message, false, Urgency::Normal);
}
fn deliver_notification_inner(
&self,
title: &str,
message: &str,
force: bool,
urgency: Urgency,
) {
if !title.is_empty() {
log::info!("=== Notification: {} ===", title);
log::info!("{}", message);
log::info!("===========================");
} else {
log::info!("=== Notification ===");
log::info!("{}", message);
log::info!("===================");
}
if !force
&& self
.config
.load()
.notifications
.suppress_notifications_when_focused
&& self.focus_state.is_focused
{
log::debug!(
"Suppressing desktop notification (window is focused): {}",
title
);
return;
}
let platform_urgency = match urgency {
Urgency::Low => crate::platform::NotificationUrgency::Low,
Urgency::Normal => crate::platform::NotificationUrgency::Normal,
Urgency::Critical => crate::platform::NotificationUrgency::Critical,
};
crate::platform::deliver_desktop_notification(title, message, 3000, platform_urgency);
}
}