use std::collections::VecDeque;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use clap::Parser;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::symbols::border;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Terminal;
use serde::Deserialize;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use xphone::{Call, CallState, EndReason, ExtensionState, Phone};
#[cfg(feature = "video-display")]
use minifb::{Window, WindowOptions};
#[cfg(feature = "video-display")]
use openh264::decoder::Decoder as H264Decoder;
#[cfg(feature = "video-display")]
use openh264::formats::YUVSource;
const ACCENT: Color = Color::Rgb(100, 149, 237); const ACCENT_DIM: Color = Color::Rgb(60, 90, 150);
const GREEN: Color = Color::Rgb(80, 200, 120);
const RED: Color = Color::Rgb(220, 80, 80);
const YELLOW: Color = Color::Rgb(230, 190, 60);
const MAGENTA: Color = Color::Rgb(180, 120, 220);
const DIM: Color = Color::Rgb(100, 100, 110);
const SURFACE: Color = Color::Rgb(30, 30, 36);
const BAR_BG: Color = Color::Rgb(40, 40, 50);
#[derive(Parser)]
#[command(name = "sipcli", about = "Interactive SIP client TUI")]
struct Cli {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
server: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long)]
pass: Option<String>,
#[arg(long, default_value = "udp")]
transport: String,
#[arg(long, default_value_t = 5060)]
port: u16,
#[arg(long)]
local_ip: Option<String>,
#[arg(long)]
stun: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct Profile {
server: Option<String>,
user: Option<String>,
pass: Option<String>,
transport: Option<String>,
port: Option<u16>,
stun: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ProfileFile {
profiles: std::collections::HashMap<String, Profile>,
}
fn load_profile(name: &str) -> Result<Profile, String> {
let home = dirs_next::home_dir().ok_or("cannot find home directory")?;
let path = home.join(".sipcli.yaml");
let data =
std::fs::read_to_string(&path).map_err(|e| format!("cannot read ~/.sipcli.yaml: {}", e))?;
let file: ProfileFile =
serde_yaml::from_str(&data).map_err(|e| format!("invalid ~/.sipcli.yaml: {}", e))?;
file.profiles.get(name).cloned().ok_or_else(|| {
let available: Vec<_> = file.profiles.keys().collect();
format!("profile {:?} not found (available: {:?})", name, available)
})
}
const HISTORY_FILE: &str = ".sipcli_history";
const HISTORY_MAX: usize = 500;
fn history_path() -> Option<std::path::PathBuf> {
dirs_next::home_dir().map(|h| h.join(HISTORY_FILE))
}
fn load_history() -> Vec<String> {
let path = match history_path() {
Some(p) => p,
None => return Vec::new(),
};
match std::fs::read_to_string(&path) {
Ok(data) => data.lines().map(|l| l.to_string()).collect(),
Err(_) => Vec::new(),
}
}
fn save_history(history: &[String]) {
let path = match history_path() {
Some(p) => p,
None => return,
};
let start = if history.len() > HISTORY_MAX {
history.len() - HISTORY_MAX
} else {
0
};
let _ = std::fs::write(&path, history[start..].join("\n") + "\n");
}
struct TrackedCall {
call: Arc<Call>,
label: String, status: String, }
struct AppState {
reg_status: String,
calls: Vec<TrackedCall>,
selected: usize, events: Vec<String>,
debug_logs: Vec<String>,
input: String,
error: String,
quitting: bool,
echo_active: Arc<AtomicBool>,
speaker_active: Arc<AtomicBool>,
mic_active: Arc<AtomicBool>,
history: Vec<String>,
history_pos: Option<usize>,
history_draft: String,
blf: Vec<(String, ExtensionState)>,
}
impl AppState {
fn new() -> Self {
AppState {
reg_status: "disconnected".into(),
calls: Vec::new(),
selected: 0,
events: Vec::new(),
debug_logs: Vec::new(),
input: String::new(),
error: String::new(),
quitting: false,
echo_active: Arc::new(AtomicBool::new(false)),
speaker_active: Arc::new(AtomicBool::new(true)),
mic_active: Arc::new(AtomicBool::new(false)),
history: Vec::new(),
history_pos: None,
history_draft: String::new(),
blf: Vec::new(),
}
}
fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
match self.history_pos {
None => {
self.history_draft = self.input.clone();
self.history_pos = Some(self.history.len() - 1);
self.input = self.history.last().cloned().unwrap_or_default();
}
Some(pos) if pos > 0 => {
self.history_pos = Some(pos - 1);
self.input = self.history[pos - 1].clone();
}
_ => {}
}
}
fn history_down(&mut self) {
match self.history_pos {
None => {}
Some(pos) => {
if pos + 1 < self.history.len() {
self.history_pos = Some(pos + 1);
self.input = self.history[pos + 1].clone();
} else {
self.history_pos = None;
self.input = self.history_draft.clone();
self.history_draft.clear();
}
}
}
}
fn push_event(&mut self, msg: String) {
self.events.push(msg);
}
fn push_debug(&mut self, msg: String) {
self.debug_logs.push(msg);
}
fn find_call_idx(&self, call_id: &str) -> Option<usize> {
self.calls
.iter()
.position(|tc| tc.call.call_id() == call_id)
}
fn remove_call(&mut self, call_id: &str) {
if let Some(idx) = self.find_call_idx(call_id) {
self.calls.remove(idx);
if self.selected >= self.calls.len() && !self.calls.is_empty() {
self.selected = self.calls.len() - 1;
}
}
}
}
type SharedState = Arc<Mutex<AppState>>;
struct TuiLayer {
state: SharedState,
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for TuiLayer {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = StringVisitor::default();
event.record(&mut visitor);
let level = *event.metadata().level();
let prefix = match level {
tracing::Level::ERROR => "ERR",
tracing::Level::WARN => "WRN",
tracing::Level::INFO => "INF",
_ => "DBG",
};
let line = format!("{} {}", prefix, visitor.0);
if let Ok(mut st) = self.state.lock() {
st.push_debug(line);
}
}
}
#[derive(Default)]
struct StringVisitor(String);
impl tracing::field::Visit for StringVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{:?}", value);
} else if !self.0.is_empty() {
self.0.push_str(&format!(" {}={:?}", field.name(), value));
} else {
self.0 = format!("{}={:?}", field.name(), value);
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.0 = value.to_string();
} else if !self.0.is_empty() {
self.0.push_str(&format!(" {}={}", field.name(), value));
} else {
self.0 = format!("{}={}", field.name(), value);
}
}
}
fn wire_phone_events(phone: &Phone, state: &SharedState) {
let s = Arc::clone(state);
phone.on_registered(move || {
let mut st = s.lock().unwrap();
st.reg_status = "registered".into();
st.push_event("registered with server".into());
});
let s = Arc::clone(state);
phone.on_unregistered(move || {
let mut st = s.lock().unwrap();
st.reg_status = "unregistered".into();
st.push_event("registration lost".into());
});
let s = Arc::clone(state);
phone.on_error(move |err| {
let mut st = s.lock().unwrap();
st.reg_status = "error".into();
st.push_event(format!("ERROR {}", err));
});
let s = Arc::clone(state);
phone.on_call_state(move |call, cs| {
let did = call.remote_did();
let name = call_state_name(cs);
let mut st = s.lock().unwrap();
st.push_event(format!("[{}] {}", did, name));
let cid = call.call_id();
if let Some(idx) = st.find_call_idx(&cid) {
st.calls[idx].status = name;
}
});
let s = Arc::clone(state);
phone.on_call_ended(move |call, reason| {
let did = call.remote_did();
let cid = call.call_id();
let mut st = s.lock().unwrap();
st.push_event(format!("[{}] ended: {}", did, end_reason_name(reason)));
st.remove_call(&cid);
});
let s = Arc::clone(state);
phone.on_call_dtmf(move |call, digit| {
let did = call.remote_did();
let mut st = s.lock().unwrap();
st.push_event(format!("[{}] DTMF recv: {}", did, digit));
});
let s = Arc::clone(state);
phone.on_message(move |msg| {
let sender = extract_sip_user(&msg.from);
let mut st = s.lock().unwrap();
st.push_event(format!("MSG from {}: {}", sender, msg.body));
});
let s = Arc::clone(state);
phone.on_subscription_error(move |uri, err| {
let mut st = s.lock().unwrap();
st.push_event(format!("BLF ERROR {}: {}", uri, err));
});
let s = Arc::clone(state);
phone.on_incoming(move |call| {
let from = call.from();
let from_name = call.from_name();
let display = if from_name.is_empty() {
from.clone()
} else {
format!("{} ({})", from_name, from)
};
wire_call_events(&call, &s);
let mut st = s.lock().unwrap();
st.push_event(format!("[{}] incoming from {}", from, display));
st.calls.push(TrackedCall {
call,
label: from,
status: "ringing".into(),
});
if st.calls.len() == 1 {
st.selected = 0;
}
});
}
fn wire_call_events(call: &Arc<Call>, state: &SharedState) {
let did = call.remote_did();
let s = Arc::clone(state);
let did2 = did.clone();
call.on_hold(move || {
let mut st = s.lock().unwrap();
st.push_event(format!("[{}] held by remote", did2));
});
let s = Arc::clone(state);
call.on_resume(move || {
let mut st = s.lock().unwrap();
st.push_event(format!("[{}] resumed by remote", did));
});
#[cfg(feature = "video-display")]
{
let call_for_video = Arc::clone(call);
let s = Arc::clone(state);
let did3 = call.remote_did();
call.on_video(move || {
let mut st = s.lock().unwrap();
st.push_event(format!("[{}] video upgrade accepted", did3));
drop(st);
start_video_handler(&call_for_video);
});
let s2 = Arc::clone(state);
let did4 = call.remote_did();
call.on_video_request(move |req| {
let mut st = s2.lock().unwrap();
st.push_event(format!("[{}] video upgrade requested — accepting", did4));
drop(st);
req.accept();
});
}
}
fn start_audio_handler(
call: &Arc<Call>,
echo_flag: Arc<AtomicBool>,
speaker_flag: Arc<AtomicBool>,
mic_flag: Arc<AtomicBool>,
) {
let pcm_rx = match call.pcm_reader() {
Some(rx) => rx,
None => {
tracing::warn!("audio: no pcm_reader available — audio handler not started");
return;
}
};
let pcm_tx = call.pcm_writer();
tracing::info!(
has_pcm_tx = pcm_tx.is_some(),
"audio: starting audio handler thread"
);
std::thread::Builder::new()
.name("audio-handler".into())
.spawn(move || {
let speaker_ctx = setup_speaker_stream(Arc::clone(&speaker_flag));
tracing::info!(
speaker_available = speaker_ctx.is_some(),
"audio: speaker stream ready"
);
if let Some(ref tx) = pcm_tx {
setup_mic_stream(Arc::clone(&mic_flag), tx.clone());
tracing::info!("audio: mic stream ready");
}
let echo_delay_frames = 10usize;
let mut echo_buffer: VecDeque<Vec<i16>> = VecDeque::new();
let mut frame_count: u64 = 0;
loop {
match pcm_rx.recv_timeout(Duration::from_millis(100)) {
Ok(frame) => {
frame_count += 1;
if frame_count <= 3 || frame_count.is_multiple_of(500) {
tracing::debug!(
frame_count,
frame_len = frame.len(),
echo = echo_flag.load(Ordering::Relaxed),
speaker = speaker_flag.load(Ordering::Relaxed),
"audio: PCM frame received"
);
}
if speaker_flag.load(Ordering::Relaxed) {
if let Some((ref ring_buf, ref ratio)) = speaker_ctx {
let mut buf = ring_buf.lock().unwrap();
for &s in &frame {
let f = s as f32 / 32768.0;
for _ in 0..*ratio {
buf.push_back(f);
}
}
let cap = *ratio * 8000 * 2;
while buf.len() > cap {
buf.pop_front();
}
}
}
if echo_flag.load(Ordering::Relaxed) {
echo_buffer.push_back(frame);
if echo_buffer.len() > echo_delay_frames {
if let Some(delayed) = echo_buffer.pop_front() {
if let Some(ref tx) = pcm_tx {
let _ = tx.try_send(delayed);
}
}
}
} else {
echo_buffer.clear();
}
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
tracing::info!(frame_count, "audio: PCM channel disconnected, stopping");
return;
}
}
}
})
.expect("failed to spawn audio handler");
}
#[allow(clippy::type_complexity)]
fn setup_speaker_stream(active: Arc<AtomicBool>) -> Option<(Arc<Mutex<VecDeque<f32>>>, usize)> {
let host = cpal::default_host();
let device = host.default_output_device()?;
let config = device.default_output_config().ok()?;
let sample_rate = config.sample_rate().0 as usize;
let channels = config.channels() as usize;
let ratio = (sample_rate / 8000).max(1);
let ring_buf: Arc<Mutex<VecDeque<f32>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(sample_rate * 2)));
let buf_cb = Arc::clone(&ring_buf);
let stream = device
.build_output_stream(
&config.into(),
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
if !active.load(Ordering::Relaxed) {
data.fill(0.0);
return;
}
let mut buf = buf_cb.lock().unwrap();
for frame in data.chunks_mut(channels) {
let sample = buf.pop_front().unwrap_or(0.0);
for s in frame.iter_mut() {
*s = sample;
}
}
},
|_err| {},
None,
)
.ok()?;
stream.play().ok()?;
Box::leak(Box::new(stream));
Some((ring_buf, ratio))
}
fn setup_mic_stream(active: Arc<AtomicBool>, pcm_tx: crossbeam_channel::Sender<Vec<i16>>) {
let host = cpal::default_host();
let device = match host.default_input_device() {
Some(d) => d,
None => {
tracing::warn!("audio: no input device found — mic not available");
return;
}
};
let config = match device.default_input_config() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"audio: failed to get input config: {} — mic not available",
e
);
return;
}
};
let sample_rate = config.sample_rate().0 as usize;
let channels = config.channels() as usize;
let downsample_ratio = (sample_rate / 8000).max(1);
const FRAME_SIZE: usize = 160;
let frame_buf: Arc<Mutex<Vec<i16>>> = Arc::new(Mutex::new(Vec::with_capacity(FRAME_SIZE)));
let buf_cb = Arc::clone(&frame_buf);
let stream = match device.build_input_stream(
&config.into(),
move |data: &[f32], _: &cpal::InputCallbackInfo| {
if !active.load(Ordering::Relaxed) {
return;
}
let mut buf = buf_cb.lock().unwrap();
for chunk in data.chunks(channels * downsample_ratio) {
let mut sum = 0.0f32;
for sample_val in chunk.iter().take(channels) {
sum += sample_val;
}
let sample = sum / channels as f32;
let pcm = (sample * 32767.0).clamp(-32768.0, 32767.0) as i16;
buf.push(pcm);
if buf.len() >= FRAME_SIZE {
let frame: Vec<i16> = buf.drain(..FRAME_SIZE).collect();
let _ = pcm_tx.try_send(frame);
}
}
},
|err| {
tracing::error!("audio: mic stream error: {}", err);
},
None,
) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"audio: failed to build input stream: {} — mic not available",
e
);
return;
}
};
if let Err(e) = stream.play() {
tracing::warn!("audio: failed to start input stream: {}", e);
return;
}
Box::leak(Box::new(stream));
}
#[cfg(feature = "video-display")]
fn start_video_handler(call: &Arc<Call>) {
let video_rx = match call.video_reader() {
Some(rx) => rx,
None => return,
};
let codec = call.video_codec();
if codec != Some(xphone::VideoCodec::H264) {
tracing::warn!(
"video: codec {:?} not supported for display, skipping",
codec
);
return;
}
let remote = call.remote_did();
std::thread::Builder::new()
.name("video-handler".into())
.spawn(move || {
let mut decoder = match H264Decoder::new() {
Ok(d) => d,
Err(e) => {
tracing::error!("video: failed to create H.264 decoder: {}", e);
return;
}
};
let mut window: Option<Window> = None;
let mut fb: Vec<u32> = Vec::new();
let mut rgb_buf: Vec<u8> = Vec::new();
let mut cur_w: usize = 0;
let mut cur_h: usize = 0;
loop {
match video_rx.recv_timeout(Duration::from_millis(16)) {
Ok(frame) => {
let yuv = match decoder.decode(&frame.data) {
Ok(Some(yuv)) => yuv,
Ok(None) => continue,
Err(e) => {
tracing::debug!("video: decode error: {}", e);
continue;
}
};
let (w, h) = yuv.dimensions();
if w == 0 || h == 0 {
continue;
}
if w != cur_w || h != cur_h {
cur_w = w;
cur_h = h;
fb.resize(w * h, 0);
rgb_buf.resize(w * h * 3, 0);
let title = format!("Video - {}", remote);
let opts = WindowOptions {
resize: true,
..WindowOptions::default()
};
match Window::new(&title, w, h, opts) {
Ok(win) => window = Some(win),
Err(e) => {
tracing::error!("video: failed to create window: {}", e);
return;
}
}
}
yuv.write_rgb8(&mut rgb_buf);
rgb8_to_argb32(&rgb_buf, &mut fb);
if let Some(ref mut win) = window {
if !win.is_open() {
tracing::info!("video: window closed by user");
break;
}
let _ = win.update_with_buffer(&fb, cur_w, cur_h);
}
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
if let Some(ref mut win) = window {
if !win.is_open() {
break;
}
win.update();
}
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
}
}
tracing::info!("video: handler stopped for {}", remote);
})
.expect("failed to spawn video handler");
}
#[cfg(feature = "video-display")]
fn rgb8_to_argb32(rgb: &[u8], out: &mut [u32]) {
for (chunk, pixel) in rgb.chunks_exact(3).zip(out.iter_mut()) {
*pixel = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
}
}
fn call_state_name(s: CallState) -> String {
match s {
CallState::Idle => "idle".into(),
CallState::Ringing => "ringing".into(),
CallState::Dialing => "dialing".into(),
CallState::RemoteRinging => "ringing remote".into(),
CallState::EarlyMedia => "early media".into(),
CallState::Active => "active".into(),
CallState::OnHold => "on hold".into(),
CallState::Ended => "ended".into(),
}
}
fn end_reason_name(r: EndReason) -> String {
match r {
EndReason::Local => "local hangup".into(),
EndReason::Remote => "remote hangup".into(),
EndReason::Timeout => "media timeout".into(),
EndReason::Error => "error".into(),
EndReason::Transfer => "transferred".into(),
EndReason::TransferFailed => "transfer failed".into(),
EndReason::Rejected => "rejected".into(),
EndReason::Cancelled => "cancelled".into(),
}
}
fn extract_sip_user(from: &str) -> String {
let display = if let Some(lt) = from.find('<') {
let name = from[..lt].trim().trim_matches('"').trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
} else {
None
};
let user = from
.find("sip:")
.map(|i| &from[i + 4..])
.and_then(|s| s.split('@').next())
.filter(|u| !u.is_empty())
.map(|u| u.to_string());
match (display, user) {
(Some(d), Some(u)) => format!("{} ({})", d, u),
(Some(d), None) => d,
(None, Some(u)) => u,
(None, None) => from.to_string(),
}
}
fn parse_call_num(arg: &str) -> Option<usize> {
arg.trim().parse::<usize>().ok()
}
fn resolve_call(st: &AppState, num: Option<usize>) -> Option<Arc<Call>> {
let idx = match num {
Some(n) if n >= 1 && n <= st.calls.len() => n - 1,
Some(_) => return None,
None => st.selected,
};
st.calls.get(idx).map(|tc| Arc::clone(&tc.call))
}
fn do_dial(state: &SharedState, phone: &Phone, target: &str, video: bool) {
if target.is_empty() {
let cmd = if video { "vdial" } else { "dial" };
state.lock().unwrap().error = format!("usage: {} <target>", cmd);
return;
}
let label = if video { "video-dialing" } else { "dialing" };
state
.lock()
.unwrap()
.push_event(format!("{} {}...", label, target));
let phone = phone.clone();
let s = Arc::clone(state);
let target = target.to_string();
let echo_flag = Arc::clone(&state.lock().unwrap().echo_active);
let speaker_flag = Arc::clone(&state.lock().unwrap().speaker_active);
let mic_flag = Arc::clone(&state.lock().unwrap().mic_active);
std::thread::spawn(move || {
let mut opts = xphone::DialOptions {
timeout: Duration::from_secs(30),
..Default::default()
};
if video {
opts.video = true;
opts.video_codecs = vec![xphone::VideoCodec::H264];
}
match phone.dial(&target, opts) {
Ok(call) => {
wire_call_events(&call, &s);
start_audio_handler(
&call,
Arc::clone(&echo_flag),
Arc::clone(&speaker_flag),
Arc::clone(&mic_flag),
);
let mut st = s.lock().unwrap();
let msg = if video {
format!("[{}] video connected", target)
} else {
format!("[{}] connected", target)
};
st.push_event(msg);
let is_first = st.calls.is_empty();
st.calls.push(TrackedCall {
call,
label: target,
status: "active".into(),
});
if is_first {
st.selected = 0;
}
}
Err(e) => {
let mut st = s.lock().unwrap();
st.push_event(format!("ERROR dial failed: {}", e));
}
}
});
}
fn exec_command(state: &SharedState, phone: &Phone, input: &str) {
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.is_empty() {
return;
}
let cmd = parts[0].to_lowercase();
let arg = if parts.len() > 1 {
parts[1..].join(" ")
} else {
String::new()
};
match cmd.as_str() {
"quit" | "q" | "exit" => {
{
let st = state.lock().unwrap();
for tc in &st.calls {
let _ = tc.call.end();
}
}
state.lock().unwrap().quitting = true;
let _ = phone.disconnect();
}
"dial" | "d" => {
do_dial(state, phone, &arg, false);
}
#[cfg(feature = "video-display")]
"vdial" | "vd" => {
do_dial(state, phone, &arg, true);
}
"accept" | "a" => {
let num = parse_call_num(&arg);
let echo_flag = Arc::clone(&state.lock().unwrap().echo_active);
let speaker_flag = Arc::clone(&state.lock().unwrap().speaker_active);
let mic_flag = Arc::clone(&state.lock().unwrap().mic_active);
call_action(state, "accept", num, move |c| {
let result = c.accept();
if result.is_ok() {
start_audio_handler(c, echo_flag, speaker_flag, mic_flag);
#[cfg(feature = "video-display")]
start_video_handler(c);
}
result
});
}
"reject" => {
let num = parse_call_num(&arg);
call_action(state, "reject", num, |c| c.reject(486, "Busy Here"));
}
"hangup" | "h" => {
let num = parse_call_num(&arg);
call_action(state, "hangup", num, |c| c.end());
}
"hold" => {
let num = parse_call_num(&arg);
call_action(state, "hold", num, |c| c.hold());
}
"resume" => {
let num = parse_call_num(&arg);
call_action(state, "resume", num, |c| c.resume());
}
"mute" => {
let num = parse_call_num(&arg);
call_action(state, "mute", num, |c| c.mute());
}
"unmute" => {
let num = parse_call_num(&arg);
call_action(state, "unmute", num, |c| c.unmute());
}
#[cfg(feature = "video-display")]
"video" => {
let num = parse_call_num(&arg);
call_action(state, "video", num, |c| {
c.add_video(&[xphone::VideoCodec::H264], 10000, 20000)
});
}
"dtmf" => {
if arg.is_empty() {
state.lock().unwrap().error = "usage: dtmf <digits>".into();
return;
}
let st = state.lock().unwrap();
let call = match resolve_call(&st, None) {
Some(c) => c,
None => {
drop(st);
state.lock().unwrap().error = "no active call".into();
return;
}
};
drop(st);
for ch in arg.chars() {
if let Err(e) = call.send_dtmf(&ch.to_string()) {
state.lock().unwrap().error = format!("dtmf error: {}", e);
return;
}
}
state
.lock()
.unwrap()
.push_event(format!("DTMF sent: {}", arg));
}
"transfer" | "xfer" => {
if arg.is_empty() {
state.lock().unwrap().error = "usage: transfer <target>".into();
return;
}
let label = format!("transfer to {}", arg);
let target = arg.clone();
call_action(state, &label, None, move |c| c.blind_transfer(&target));
}
"echo" => {
let st = state.lock().unwrap();
let flag = Arc::clone(&st.echo_active);
let mic = Arc::clone(&st.mic_active);
let prev = flag.load(Ordering::Relaxed);
flag.store(!prev, Ordering::Relaxed);
if !prev {
mic.store(false, Ordering::Relaxed);
}
drop(st);
let label = if !prev { "ON" } else { "OFF" };
let mut st = state.lock().unwrap();
st.push_event(format!("echo: {}", label));
if !prev {
st.push_event("mic: OFF (echo takes priority)".into());
}
}
"speaker" => {
let st = state.lock().unwrap();
let flag = Arc::clone(&st.speaker_active);
let prev = flag.load(Ordering::Relaxed);
flag.store(!prev, Ordering::Relaxed);
drop(st);
let label = if !prev { "ON" } else { "OFF" };
state
.lock()
.unwrap()
.push_event(format!("speaker: {}", label));
}
"mic" => {
let st = state.lock().unwrap();
let mic = Arc::clone(&st.mic_active);
let echo = Arc::clone(&st.echo_active);
let prev = mic.load(Ordering::Relaxed);
mic.store(!prev, Ordering::Relaxed);
if !prev {
echo.store(false, Ordering::Relaxed);
}
drop(st);
let label = if !prev { "ON" } else { "OFF" };
let mut st = state.lock().unwrap();
st.push_event(format!("mic: {}", label));
if !prev {
st.push_event("echo: OFF (mic takes priority)".into());
}
}
"message" | "msg" => {
if parts.len() < 3 {
state.lock().unwrap().error = "usage: msg <target> <text>".into();
return;
}
let target = parts[1].to_string();
let body = parts[2..].join(" ");
let phone = phone.clone();
let s = Arc::clone(state);
std::thread::spawn(move || match phone.send_message(&target, &body) {
Ok(()) => {
s.lock()
.unwrap()
.push_event(format!("MSG to {}: {}", target, body));
}
Err(e) => {
s.lock().unwrap().error = format!("message error: {}", e);
}
});
}
"watch" | "w" => {
if arg.is_empty() {
state.lock().unwrap().error = "usage: watch <extension>".into();
return;
}
let ext = arg.clone();
let s = Arc::clone(state);
let s2 = Arc::clone(state);
match phone.watch(&ext, move |status, _prev| {
let mut st = s2.lock().unwrap();
if let Some(entry) = st.blf.iter_mut().find(|(e, _)| *e == status.extension) {
entry.1 = status.state;
}
st.push_event(format!("BLF {} → {}", status.extension, status.state));
}) {
Ok(()) => {
let mut st = s.lock().unwrap();
if !st.blf.iter().any(|(e, _)| *e == ext) {
st.blf.push((ext.clone(), ExtensionState::Unknown));
}
st.push_event(format!("watching {}", ext));
}
Err(e) => {
s.lock().unwrap().error = format!("watch error: {}", e);
}
}
}
"unwatch" | "uw" => {
if arg.is_empty() {
state.lock().unwrap().error = "usage: unwatch <extension>".into();
return;
}
match phone.unwatch(&arg) {
Ok(()) => {
let mut st = state.lock().unwrap();
st.blf.retain(|(e, _)| *e != arg);
st.push_event(format!("unwatched {}", arg));
}
Err(e) => {
state.lock().unwrap().error = format!("unwatch error: {}", e);
}
}
}
other if other.parse::<usize>().is_ok() => {
let n: usize = other.parse().unwrap();
let mut st = state.lock().unwrap();
if n >= 1 && n <= st.calls.len() {
st.selected = n - 1;
} else {
st.error = format!("no call #{}", n);
}
}
_ => {
state.lock().unwrap().error = format!("unknown command: {}", cmd);
}
}
}
fn call_action<F>(state: &SharedState, name: &str, num: Option<usize>, action: F)
where
F: FnOnce(&Arc<Call>) -> xphone::Result<()>,
{
let st = state.lock().unwrap();
let call = resolve_call(&st, num);
drop(st);
if let Some(ref call) = call {
let name = name.to_string();
if let Err(e) = action(call) {
state
.lock()
.unwrap()
.push_event(format!("ERROR {}: {}", name, e));
}
} else {
let msg = match num {
Some(n) => format!("no call #{}", n),
None => "no active call".into(),
};
state.lock().unwrap().error = msg;
}
}
fn reg_status_style(status: &str) -> (Span<'_>, Span<'_>) {
let (indicator, color) = match status {
"registered" => ("●", GREEN),
"registering" => ("◌", YELLOW),
"error" | "failed" => ("●", RED),
_ => ("○", DIM),
};
(
Span::styled(format!(" {} ", indicator), Style::default().fg(color)),
Span::styled(
status,
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
)
}
fn call_status_color(status: &str) -> Color {
match status {
"active" => GREEN,
"ringing" | "ringing remote" | "dialing" | "early media" => YELLOW,
"on hold" => MAGENTA,
_ => DIM,
}
}
fn call_status_indicator(status: &str) -> &str {
match status {
"active" => "●",
"ringing" | "ringing remote" | "dialing" | "early media" => "◌",
"on hold" => "◊",
_ => "○",
}
}
fn blf_indicator(state: ExtensionState) -> (&'static str, Color) {
match state {
ExtensionState::Available => ("●", GREEN),
ExtensionState::Ringing => ("◌", YELLOW),
ExtensionState::OnThePhone => ("●", RED),
ExtensionState::Offline => ("○", DIM),
ExtensionState::Unknown => ("○", DIM),
}
}
fn event_style(line: &str) -> Style {
let content = if line.starts_with('[') {
line.find("] ").map_or(line, |i| &line[i + 2..])
} else {
line
};
if content.starts_with("ERROR") {
Style::default().fg(RED)
} else if content.starts_with("ended:") {
Style::default().fg(ACCENT)
} else if content.starts_with("incoming") {
Style::default().fg(YELLOW).add_modifier(Modifier::BOLD)
} else if content.starts_with("active") || content.starts_with("connected") {
Style::default().fg(GREEN)
} else if content.starts_with("DTMF") {
Style::default().fg(MAGENTA)
} else if content.starts_with("dialing")
|| content.starts_with("ringing")
|| content.starts_with("early media")
{
Style::default().fg(YELLOW)
} else if content.starts_with("held") || content.starts_with("resumed") {
Style::default().fg(MAGENTA)
} else if content.starts_with("registered") {
Style::default().fg(GREEN)
} else {
Style::default().fg(Color::White)
}
}
fn debug_style(line: &str) -> Style {
if line.starts_with("ERR") {
Style::default().fg(RED)
} else if line.starts_with("WRN") {
Style::default().fg(YELLOW)
} else if line.starts_with("INF") {
Style::default().fg(ACCENT)
} else {
Style::default().fg(DIM)
}
}
fn draw(f: &mut ratatui::Frame, state: &SharedState) {
let st = state.lock().unwrap();
let area = f.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(6), Constraint::Length(5), ])
.split(area);
let (reg_dot, reg_text) = reg_status_style(&st.reg_status);
let status_spans = vec![
Span::styled(" REG", Style::default().fg(DIM)),
reg_dot,
reg_text,
];
let status_line = Line::from(status_spans);
let status_block = Paragraph::new(status_line).block(
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(ACCENT_DIM))
.title(Span::styled(
" sipcli (rust) ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(BAR_BG)),
);
f.render_widget(status_block, outer[0]);
let panel_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(outer[1]);
let calls_height = (st.calls.len() as u16 + 2).clamp(3, 8); let blf_height = if st.blf.is_empty() {
0
} else {
(st.blf.len() as u16 + 2).clamp(3, 6)
};
let left_constraints: Vec<Constraint> = if blf_height > 0 {
vec![
Constraint::Length(calls_height),
Constraint::Length(blf_height),
Constraint::Min(4),
]
} else {
vec![Constraint::Length(calls_height), Constraint::Min(4)]
};
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(left_constraints)
.split(panel_chunks[0]);
let call_lines: Vec<Line> = if st.calls.is_empty() {
vec![Line::from(Span::styled(
" (no calls)",
Style::default().fg(DIM),
))]
} else {
st.calls
.iter()
.enumerate()
.map(|(i, tc)| {
let selected = i == st.selected;
let color = call_status_color(&tc.status);
let indicator = call_status_indicator(&tc.status);
let marker = if selected { ">" } else { " " };
let num = i + 1;
Line::from(vec![
Span::styled(
format!(" {}", marker),
Style::default()
.fg(if selected { GREEN } else { DIM })
.add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(format!("#{} ", num), Style::default().fg(DIM)),
Span::styled(format!("{} ", indicator), Style::default().fg(color)),
Span::styled(
&tc.label,
Style::default().fg(Color::White).add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(format!(" {}", tc.status), Style::default().fg(color)),
])
})
.collect()
};
let calls_panel = Paragraph::new(call_lines).block(
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(ACCENT_DIM))
.title(Span::styled(
" Calls ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(SURFACE)),
);
f.render_widget(calls_panel, left_chunks[0]);
let events_idx = if !st.blf.is_empty() {
let blf_lines: Vec<Line> = st
.blf
.iter()
.map(|(ext, state)| {
let (indicator, color) = blf_indicator(*state);
Line::from(vec![
Span::styled(format!(" {} ", indicator), Style::default().fg(color)),
Span::styled(ext.as_str(), Style::default().fg(Color::White)),
Span::styled(format!(" {}", state), Style::default().fg(color)),
])
})
.collect();
let blf_panel = Paragraph::new(blf_lines).block(
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(ACCENT_DIM))
.title(Span::styled(
" BLF ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(SURFACE)),
);
f.render_widget(blf_panel, left_chunks[1]);
2 } else {
1 };
let event_lines: Vec<Line> = st
.events
.iter()
.map(|e| Line::from(Span::styled(e.as_str(), event_style(e))))
.collect();
let events_panel = Paragraph::new(event_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(ACCENT_DIM))
.title(Span::styled(
" Events ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(SURFACE)),
)
.wrap(Wrap { trim: false })
.scroll((
scroll_offset(
st.events.len(),
left_chunks[events_idx].height.saturating_sub(2) as usize,
),
0,
));
f.render_widget(events_panel, left_chunks[events_idx]);
let debug_lines: Vec<Line> = st
.debug_logs
.iter()
.map(|d| Line::from(Span::styled(d.as_str(), debug_style(d))))
.collect();
let debug_panel = Paragraph::new(debug_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(ACCENT_DIM))
.title(Span::styled(
" SIP Debug ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(SURFACE)),
)
.wrap(Wrap { trim: false })
.scroll((
scroll_offset(
st.debug_logs.len(),
panel_chunks[1].height.saturating_sub(2) as usize,
),
0,
));
f.render_widget(debug_panel, panel_chunks[1]);
let cmd_title = if st.error.is_empty() {
Span::styled(
" Command ",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
)
} else {
Span::styled(
format!(" Command -- {} ", st.error),
Style::default().fg(RED).add_modifier(Modifier::BOLD),
)
};
#[cfg(feature = "video-display")]
let help_text =
"dial(d) vdial(vd) accept(a) reject hangup(h) hold resume mute unmute video dtmf transfer(xfer) msg watch(w) unwatch(uw) echo speaker mic quit(q)";
#[cfg(not(feature = "video-display"))]
let help_text =
"dial(d) accept(a) reject hangup(h) hold resume mute unmute dtmf transfer(xfer) msg watch(w) unwatch(uw) echo speaker mic quit(q)";
let cmd_lines = vec![
Line::from(vec![
Span::styled(
" > ",
Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
),
Span::styled(&st.input, Style::default().fg(Color::White)),
Span::styled(
"_",
Style::default()
.fg(GREEN)
.add_modifier(Modifier::SLOW_BLINK),
),
]),
Line::default(),
Line::from(Span::styled(
format!(" {}", help_text),
Style::default().fg(DIM),
)),
];
let cmd_block = Paragraph::new(cmd_lines).block(
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(ACCENT_DIM))
.title(cmd_title)
.style(Style::default().bg(SURFACE)),
);
f.render_widget(cmd_block, outer[2]);
}
fn scroll_offset(total_lines: usize, visible: usize) -> u16 {
if total_lines > visible {
(total_lines - visible) as u16
} else {
0
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let mut server = String::new();
let mut user = String::new();
let mut pass = String::new();
let mut transport = cli.transport.clone();
let mut port = cli.port;
let mut stun_server: Option<String> = None;
if let Some(ref profile_name) = cli.profile {
let p = load_profile(profile_name)
.map_err(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
})
.unwrap();
if let Some(s) = p.server {
server = s;
}
if let Some(u) = p.user {
user = u;
}
if let Some(pw) = p.pass {
pass = pw;
}
if let Some(t) = p.transport {
transport = t;
}
if let Some(pt) = p.port {
port = pt;
}
if p.stun.is_some() {
stun_server = p.stun;
}
}
if let Some(s) = cli.server {
server = s;
}
if let Some(u) = cli.user {
user = u;
}
if let Some(p) = cli.pass {
pass = p;
}
if server.is_empty() || user.is_empty() {
eprintln!("Usage: sipcli --profile <name>");
eprintln!(" sipcli --server <host> --user <username> [--pass <password>] [--transport udp|tcp|tls]");
eprintln!();
eprintln!("Profiles are loaded from ~/.sipcli.yaml. Flags override profile values.");
std::process::exit(1);
}
let mut cfg = xphone::Config {
username: user.clone(),
password: pass.clone(),
host: server.clone(),
port,
transport,
rtp_port_min: 10000,
rtp_port_max: 20000,
..Default::default()
};
if let Some(ip) = cli.local_ip {
cfg.local_ip = ip;
}
if cli.stun.is_some() {
cfg.stun_server = cli.stun;
} else if stun_server.is_some() {
cfg.stun_server = stun_server;
}
let phone = Phone::new(cfg);
let mut app = AppState::new();
app.history = load_history();
let state: SharedState = Arc::new(Mutex::new(app));
let tui_layer = TuiLayer {
state: Arc::clone(&state),
};
tracing_subscriber::registry().with(tui_layer).init();
wire_phone_events(&phone, &state);
enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
{
let phone = phone.clone();
let s = Arc::clone(&state);
std::thread::spawn(move || {
{
let mut st = s.lock().unwrap();
st.push_event(format!("connecting to {} as {}...", phone.host(), user));
st.reg_status = "registering".into();
}
if let Err(e) = phone.connect() {
let mut st = s.lock().unwrap();
st.push_event(format!("ERROR connect: {}", e));
st.reg_status = "failed".into();
}
});
}
loop {
terminal.draw(|f| draw(f, &state))?;
if state.lock().unwrap().quitting {
break;
}
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
{
let st = state.lock().unwrap();
for tc in &st.calls {
let _ = tc.call.end();
}
}
state.lock().unwrap().quitting = true;
let _ = phone.disconnect();
break;
}
KeyCode::Enter => {
let input = {
let mut st = state.lock().unwrap();
st.error.clear();
let input = st.input.trim().to_string();
st.input.clear();
st.history_pos = None;
st.history_draft.clear();
if !input.is_empty() {
st.history.push(input.clone());
}
input
};
if !input.is_empty() {
exec_command(&state, &phone, &input);
}
if state.lock().unwrap().quitting {
break;
}
}
KeyCode::Backspace => {
state.lock().unwrap().input.pop();
}
KeyCode::Char(c) => {
state.lock().unwrap().input.push(c);
}
KeyCode::Up => {
state.lock().unwrap().history_up();
}
KeyCode::Down => {
state.lock().unwrap().history_down();
}
KeyCode::Esc => {
state.lock().unwrap().input.clear();
}
_ => {}
}
}
}
}
save_history(&state.lock().unwrap().history);
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
println!("Bye!");
Ok(())
}