crates/notify/src/delivery.rs
Rama main · réplica de lectura del repositorio original.
use std::io::Write;
use std::time::Duration;
use super::payload::{NotificationKind, NotificationPayload};
use super::{DeliveryOutcome, Method, NotificationGate};
/// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`,
/// `$LC_TERMINAL`, and `$TERM`.
///
/// Resolution table:
/// - `iTerm.app`, `WezTerm`, `Cmux` → `Osc9`
/// - `Ghostty` → `Ghostty` (OSC 777)
/// - `kitty` → `Kitty` (OSC 99)
/// - `$LC_TERMINAL` matches OSC-9 capable → `Osc9` (Cmux that sets LC_TERMINAL)
/// - `$TERM` contains `ghostty` → `Osc9` (cmux etc.)
/// - `$TERM` contains `kitty` → `Kitty`
/// - Unknown terminal → `Off` (never invent an audible fallback)
#[must_use]
pub fn resolve_method() -> Method {
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
match term_program.as_str() {
"iTerm.app" | "WezTerm" | "Cmux" => return Method::Osc9,
"Ghostty" => return Method::Ghostty,
"kitty" => return Method::Kitty,
_ => {}
}
// LC_TERMINAL fallback for terminals (e.g. Cmux) that set
// LC_TERMINAL instead of TERM_PROGRAM.
let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
match lc_terminal.as_str() {
"iTerm.app" | "Ghostty" | "WezTerm" | "Cmux" => return Method::Osc9,
_ => {}
}
// A banner selection must never invent audio. Windows users who want the
// system sound can explicitly select `method = "bel"` or a completion
// sound; unknown automatic transports fail closed.
if cfg!(target_os = "windows") {
return Method::Off;
}
if cfg!(target_os = "macos") {
return Method::MacOS;
}
// Ghostty-based terminals (cmux, etc.) may not set their own
// TERM_PROGRAM but do set TERM=xterm-ghostty. Likewise for Kitty.
let term = std::env::var("TERM").unwrap_or_default();
if term.contains("ghostty") {
Method::Osc9
} else if term.contains("kitty") {
Method::Kitty
} else {
Method::Off
}
}
/// Wrap an escape sequence for terminal multiplexer passthrough.
///
/// tmux intercepts escape sequences; DCS passthrough tunnels them to
/// the outer terminal unmodified. Every ESC inside the payload is
/// doubled so tmux does not interpret it as DCS end.
fn wrap_for_multiplexer(seq: &str, in_tmux: bool) -> String {
if in_tmux {
let escaped = seq.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{escaped}\x1b\\")
} else {
seq.to_string()
}
}
/// Build the raw escape bytes for the given method and message.
///
/// When `in_tmux` is `true`, OSC sequences are wrapped in DCS passthrough
/// so tmux forwards them to the outer terminal.
#[must_use]
fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
match method {
Method::Bel => vec![b'\x07'],
Method::Osc9 => {
let inner = format!("\x1b]9;{msg}\x07");
if in_tmux {
let escaped_inner = inner.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{escaped_inner}\x1b\\").into_bytes()
} else {
inner.into_bytes()
}
}
Method::Kitty => {
// Kitty notification: OSC 99 ; params ST
// ST terminator (ESC \) instead of BEL to avoid audible beep.
let title_seq = "\x1b]99;d=0:p=title\x1b\\";
let body_seq = format!("\x1b]99;p=body;{msg}\x1b\\");
let focus_seq = "\x1b]99;d=1:a=focus\x1b\\";
let combined = format!("{title_seq}{body_seq}{focus_seq}");
wrap_for_multiplexer(&combined, in_tmux).into_bytes()
}
Method::Ghostty => {
// Ghostty notification: OSC 777 ; notify ; title ; message BEL
let seq = format!("\x1b]777;notify;codewhale;{msg}\x07");
wrap_for_multiplexer(&seq, in_tmux).into_bytes()
}
// Auto and Off and MacOS should not reach build_escape.
Method::Auto | Method::Off | Method::MacOS => vec![],
}
}
/// All side effects sit behind injected sinks. A disallowed event reaches none.
#[allow(clippy::too_many_arguments)]
pub fn notify_with_sinks(
method: Method,
in_tmux: bool,
payload: &NotificationPayload,
threshold: Duration,
elapsed: Duration,
gate: NotificationGate,
attention_allowed: bool,
sink: &mut dyn Write,
decide_sound: &mut dyn FnMut(NotificationKind, bool) -> crate::sound_policy::SoundDecision,
audio: &mut dyn FnMut(
&crate::sound_policy::SoundCue,
&mut dyn Write,
) -> crate::audio::AudioOutcome,
native: &mut dyn FnMut(&NotificationPayload) -> DeliveryOutcome,
) -> DeliveryOutcome {
use crate::audio::AudioOutcome;
use crate::sound_policy::SoundDecision;
if !attention_allowed {
return DeliveryOutcome::SuppressedByAttention;
}
if elapsed < threshold {
return DeliveryOutcome::SuppressedByThreshold;
}
if method == Method::Off {
return DeliveryOutcome::SuppressedByMethod;
}
if !gate.allows(payload.kind()) {
return DeliveryOutcome::SuppressedByGate;
}
let effective = if method == Method::Auto {
resolve_method()
} else {
method
};
if effective == Method::Off {
return DeliveryOutcome::UnsupportedTransport;
}
let banner = match effective {
Method::MacOS => native(payload),
Method::Bel => DeliveryOutcome::Delivered(effective),
_ => {
let bytes = build_escape(effective, in_tmux, &payload.render_inline());
if bytes.is_empty() {
return DeliveryOutcome::UnsupportedTransport;
}
if sink.write_all(&bytes).and_then(|()| sink.flush()).is_err() {
return DeliveryOutcome::DeliveryFailed;
}
DeliveryOutcome::Delivered(effective)
}
};
if !matches!(
banner,
DeliveryOutcome::Delivered(_) | DeliveryOutcome::Dispatched(_)
) {
return banner;
}
// BEL is itself audio: select and emit one cue instead of adding a
// transport bell to the chosen sound. Never fall back after suppression.
match decide_sound(payload.kind(), effective == Method::Bel) {
SoundDecision::Suppress(_) if effective == Method::Bel => {
DeliveryOutcome::SuppressedBySound
}
SoundDecision::Suppress(_) => banner,
SoundDecision::Play(cue) => match audio(&cue, sink) {
AudioOutcome::Emitted => banner,
AudioOutcome::Dispatched if effective == Method::Bel => {
DeliveryOutcome::Dispatched(effective)
}
AudioOutcome::Dispatched => banner,
AudioOutcome::Busy if effective == Method::Bel => DeliveryOutcome::SuppressedBySound,
AudioOutcome::Unsupported if effective == Method::Bel => {
DeliveryOutcome::UnsupportedTransport
}
AudioOutcome::Failed if effective == Method::Bel => DeliveryOutcome::DeliveryFailed,
AudioOutcome::Busy => banner,
AudioOutcome::Unsupported | AudioOutcome::Failed => {
if matches!(banner, DeliveryOutcome::Dispatched(_)) {
DeliveryOutcome::DispatchedWithoutSound(effective)
} else {
DeliveryOutcome::DeliveredWithoutSound(effective)
}
}
},
}
}