mod app;
mod config;
mod db;
mod debug_log;
mod image_render;
mod input;
mod keybindings;
mod link;
mod settings_profile;
mod setup;
mod signal;
mod domain;
mod theme;
mod ui;
use std::collections::HashMap;
use std::io;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::{
cursor::{Hide, MoveTo, RestorePosition, SavePosition, Show},
event::{self, EnableBracketedPaste, DisableBracketedPaste, EnableMouseCapture, DisableMouseCapture, Event, KeyEventKind},
execute, queue,
style::{Print, ResetColor, SetForegroundColor},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, BeginSynchronizedUpdate, EndSynchronizedUpdate},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Flex, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
Terminal,
};
use app::{App, InputMode, SendRequest};
use config::Config;
use setup::SetupResult;
use signal::client::SignalClient;
const POLL_TIMEOUT: Duration = Duration::from_millis(50);
#[cfg(unix)]
fn set_file_permissions(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn set_file_permissions(_path: &std::path::Path) {}
#[cfg(unix)]
fn set_dir_permissions(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
fn set_dir_permissions(_path: &std::path::Path) {}
#[tokio::main]
async fn main() -> Result<()> {
#[cfg(windows)]
unsafe {
extern "system" {
fn SetConsoleCtrlHandler(handler: usize, add: i32) -> i32;
}
SetConsoleCtrlHandler(0, 1);
}
let args: Vec<String> = std::env::args().collect();
let mut config_path: Option<&str> = None;
let mut account: Option<String> = None;
let mut force_setup = false;
let mut demo_mode = false;
let mut incognito = false;
let mut debug = false;
let mut debug_full = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-c" | "--config" => {
if i + 1 < args.len() {
config_path = Some(&args[i + 1]);
i += 2;
} else {
eprintln!("--config requires a path argument");
std::process::exit(1);
}
}
"-a" | "--account" => {
if i + 1 < args.len() {
account = Some(args[i + 1].clone());
i += 2;
} else {
eprintln!("--account requires a phone number");
std::process::exit(1);
}
}
"--setup" => {
force_setup = true;
i += 1;
}
"--demo" => {
demo_mode = true;
i += 1;
}
"--incognito" => {
incognito = true;
i += 1;
}
"--debug" => {
debug = true;
i += 1;
}
"--debug-full" => {
debug_full = true;
i += 1;
}
"--help" => {
eprintln!("siggy - Terminal Signal client");
eprintln!();
eprintln!("Usage: siggy [OPTIONS]");
eprintln!();
eprintln!("Options:");
eprintln!(" -a, --account <NUMBER> Phone number (E.164 format)");
eprintln!(" -c, --config <PATH> Config file path");
eprintln!(" --setup Run first-time setup wizard");
eprintln!(" --demo Launch with dummy data (no signal-cli needed)");
eprintln!(" --incognito No local message storage (in-memory only)");
eprintln!(" --debug Write debug log (PII redacted)");
eprintln!(" --debug-full Write debug log (full, unredacted)");
eprintln!(" --help Show this help");
std::process::exit(0);
}
_ => {
eprintln!("Unknown argument: {}", args[i]);
std::process::exit(1);
}
}
}
if debug_full {
debug_log::enable_full();
debug_log::log("=== siggy debug session started (full/unredacted) ===");
} else if debug {
debug_log::enable();
debug_log::log("=== siggy debug session started (PII redacted) ===");
}
let mut config = Config::load(config_path)?;
if let Some(acct) = account {
config.account = acct;
}
enable_raw_mode()?;
let mut stdout = io::stdout();
if config.mouse_enabled {
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
} else {
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_main_flow(&mut terminal, &mut config, force_setup, demo_mode, incognito).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste)?;
terminal.show_cursor()?;
if let Err(e) = result {
eprintln!("Error: {e:?}");
std::process::exit(1);
}
Ok(())
}
async fn run_main_flow(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
config: &mut Config,
force_setup: bool,
demo_mode: bool,
incognito: bool,
) -> Result<()> {
if demo_mode {
let database = db::Database::open_in_memory()?;
let demo_config = Config { account: "+15551234567".to_string(), ..Config::default() };
return run_app(terminal, MessagingBackend::Demo, &demo_config, database, false).await;
}
let mut setup_handled_linking = false;
if config.needs_setup() || force_setup {
match setup::run_setup(terminal, config, force_setup).await? {
SetupResult::Completed(new_config) => {
*config = *new_config;
setup_handled_linking = true;
}
SetupResult::Skipped => {}
SetupResult::Cancelled => {
return Ok(());
}
}
}
if !config.download_dir.exists() {
std::fs::create_dir_all(&config.download_dir)?;
set_dir_permissions(&config.download_dir);
}
let database = if incognito {
db::Database::open_in_memory()?
} else {
let db_dir = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("siggy");
if !db_dir.exists() {
let old_db_dir = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("signal-tui");
if old_db_dir.exists() {
let _ = std::fs::rename(&old_db_dir, &db_dir);
}
}
std::fs::create_dir_all(&db_dir)?;
set_dir_permissions(&db_dir);
let db_path = db_dir.join("siggy.db");
if !db_path.exists() {
let old_db_path = db_dir.join("signal-tui.db");
if old_db_path.exists() {
let _ = std::fs::rename(&old_db_path, &db_path);
}
}
set_file_permissions(&db_path);
db::Database::open(&db_path)?
};
if !setup_handled_linking {
match link::check_account_registered(config).await {
Ok(false) => {
match link::run_linking_flow(terminal, config).await {
Ok(link::LinkResult::Success) => {}
Ok(link::LinkResult::Cancelled) => {
return Ok(());
}
Err(e) => {
let msg = format!("{e}");
show_error_screen(terminal, "Device Linking Failed", &msg).await?;
return Ok(());
}
}
}
Ok(true) => {} Err(e) => {
debug_log::logf(format_args!("check_account_registered failed: {e}"));
}
}
}
let incognito_tmp_dir = if incognito {
let tmp = std::env::temp_dir().join(format!("siggy-incognito-{}", std::process::id()));
let _ = std::fs::create_dir_all(&tmp);
set_dir_permissions(&tmp);
config.download_dir = tmp.clone();
Some(tmp)
} else {
None
};
let signal_result = SignalClient::spawn(config).await;
let mut signal_client = match signal_result {
Ok(client) => client,
Err(e) => {
let msg = format!("{e}");
show_error_screen(terminal, "Failed to Start signal-cli", &msg).await?;
return Ok(());
}
};
let result = run_app(terminal, MessagingBackend::Signal(&mut signal_client), config, database, incognito).await;
signal_client.shutdown().await?;
if let Some(ref tmp) = incognito_tmp_dir {
let _ = std::fs::remove_dir_all(tmp);
}
result
}
async fn show_error_screen(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
title: &str,
message: &str,
) -> Result<()> {
let title = title.to_string();
let message = message.to_string();
loop {
let title = title.clone();
let message = message.clone();
terminal.draw(|frame| {
let area = frame.area();
let [_, content_area, _] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(12),
Constraint::Min(1),
])
.flex(Flex::Center)
.areas(area);
let [content] = Layout::horizontal([Constraint::Percentage(70)])
.flex(Flex::Center)
.areas(content_area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Red))
.title(format!(" {} ", title))
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
let inner = block.inner(content);
frame.render_widget(block, content);
let lines = vec![
Line::from(""),
Line::from(Span::styled(
format!(" {message}"),
Style::default().fg(Color::Red),
)),
Line::from(""),
Line::from(""),
Line::from(Span::styled(
" Check that signal-cli is installed and accessible.",
Style::default().fg(Color::Gray),
)),
Line::from(Span::styled(
" Run with --setup to reconfigure.",
Style::default().fg(Color::Gray),
)),
Line::from(""),
Line::from(Span::styled(
" Press any key to exit",
Style::default().fg(Color::DarkGray),
)),
];
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(paragraph, inner);
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
return Ok(());
}
}
}
}
}
fn ratatui_color_to_crossterm(c: Color) -> crossterm::style::Color {
match c {
Color::Reset => crossterm::style::Color::Reset,
Color::Black => crossterm::style::Color::Black,
Color::Red => crossterm::style::Color::DarkRed,
Color::Green => crossterm::style::Color::DarkGreen,
Color::Yellow => crossterm::style::Color::DarkYellow,
Color::Blue => crossterm::style::Color::DarkBlue,
Color::Magenta => crossterm::style::Color::DarkMagenta,
Color::Cyan => crossterm::style::Color::DarkCyan,
Color::Gray => crossterm::style::Color::Grey,
Color::DarkGray => crossterm::style::Color::DarkGrey,
Color::LightRed => crossterm::style::Color::Red,
Color::LightGreen => crossterm::style::Color::Green,
Color::LightYellow => crossterm::style::Color::Yellow,
Color::LightBlue => crossterm::style::Color::Blue,
Color::LightMagenta => crossterm::style::Color::Magenta,
Color::LightCyan => crossterm::style::Color::Cyan,
Color::White => crossterm::style::Color::White,
Color::Rgb(r, g, b) => crossterm::style::Color::Rgb { r, g, b },
Color::Indexed(i) => crossterm::style::Color::AnsiValue(i),
}
}
fn emit_osc8_links(
backend: &mut CrosstermBackend<io::Stdout>,
links: &[ui::LinkRegion],
link_color: Color,
) -> Result<()> {
if links.is_empty() {
return Ok(());
}
use crossterm::style::SetBackgroundColor;
use std::io::Write;
queue!(backend, SavePosition)?;
for link in links {
queue!(backend, MoveTo(link.x, link.y))?;
queue!(
backend,
SetForegroundColor(ratatui_color_to_crossterm(link_color))
)?;
if let Some(bg) = link.bg {
let ct_bg = ratatui_color_to_crossterm(bg);
queue!(backend, SetBackgroundColor(ct_bg))?;
}
let safe_url: String = link.url.chars().filter(|c| !c.is_control()).collect();
queue!(
backend,
Print(format!(
"\x1b]8;;{}\x07{}\x1b]8;;\x07",
safe_url, link.text
))
)?;
queue!(backend, ResetColor)?;
}
queue!(backend, RestorePosition)?;
backend.flush()?;
Ok(())
}
fn get_or_cache_png(
cache: &mut HashMap<String, (String, u32, u32)>,
path: &str,
cell_cols: u32,
cell_rows: u32,
) -> Option<String> {
if let Some(cached) = cache.get(path) {
return Some(cached.0.clone());
}
let data = image_render::encode_native_png(std::path::Path::new(path), cell_cols, cell_rows)?;
let b64 = data.0.clone();
cache.insert(path.to_string(), data);
Some(b64)
}
fn emit_native_images(
backend: &mut CrosstermBackend<io::Stdout>,
app: &mut App,
) -> Result<()> {
let protocol = app.image_protocol;
if protocol == image_render::ImageProtocol::Halfblock {
return Ok(());
}
use std::io::Write;
if protocol == image_render::ImageProtocol::Kitty {
let pending = std::mem::take(&mut app.kitty_pending_transmits);
if pending.is_empty() {
return Ok(());
}
for (id, path, cols, rows) in &pending {
let b64 = match get_or_cache_png(&mut app.native_image_cache, path, *cols as u32, *rows as u32) {
Some(b) => b,
None => continue,
};
let chunks: Vec<&[u8]> = b64.as_bytes().chunks(4096).collect();
for (i, chunk) in chunks.iter().enumerate() {
let m = if i == chunks.len() - 1 { 0 } else { 1 };
let chunk_str = std::str::from_utf8(chunk).unwrap_or("");
if i == 0 {
write!(
backend,
"\x1b_Gf=100,a=t,i={id},q=2,m={m};{chunk_str}\x1b\\",
)?;
} else {
write!(backend, "\x1b_Gm={m};{chunk_str}\x1b\\")?;
}
}
write!(
backend,
"\x1b_Ga=p,U=1,i={id},c={cols},r={rows},q=2\x1b\\",
)?;
app.kitty_transmitted.insert(*id);
}
backend.flush()?;
return Ok(());
}
if app.visible_images == app.prev_visible_images {
return Ok(());
}
if app.visible_images.is_empty() {
app.prev_visible_images.clear();
return Ok(());
}
let images = std::mem::take(&mut app.visible_images);
queue!(backend, SavePosition)?;
for img in &images {
let b64 = match get_or_cache_png(&mut app.native_image_cache, &img.path, img.width as u32, img.full_height as u32) {
Some(b) => b,
None => continue,
};
let b64 = if img.crop_top > 0 || img.height < img.full_height {
let crop_key = (img.path.clone(), img.crop_top, img.height);
if let Some(cached) = app.iterm2_crop_cache.get(&crop_key) {
cached.clone()
} else {
let px_h = app.native_image_cache.get(&img.path).map(|c| c.2).unwrap_or(0);
let cropped = image_render::crop_png_vertical(&b64, px_h, img.full_height, img.crop_top, img.height)
.unwrap_or(b64);
app.iterm2_crop_cache.insert(crop_key, cropped.clone());
cropped
}
} else {
b64
};
queue!(backend, MoveTo(img.x, img.y))?;
write!(
backend,
"\x1b]1337;File=inline=1;width={};height={};preserveAspectRatio=0:{b64}\x07",
img.width, img.height
)?;
}
queue!(backend, RestorePosition)?;
backend.flush()?;
app.prev_visible_images = images;
Ok(())
}
async fn dispatch_send(
signal_client: &mut SignalClient,
app: &mut App,
req: SendRequest,
) {
match req {
SendRequest::Message { recipient, body, is_group, local_ts_ms, mentions, attachment, quote_timestamp, quote_author, quote_body } => {
let attachments: Vec<std::path::PathBuf> = attachment.into_iter().collect();
let quote = match (quote_author, quote_timestamp, quote_body) {
(Some(author), Some(ts), Some(body_text)) => Some((author, ts, body_text)),
_ => None,
};
let att_refs: Vec<&std::path::Path> = attachments.iter().map(|p| p.as_path()).collect();
match signal_client.send_message(&recipient, &body, is_group, &mentions, &att_refs, quote.as_ref().map(|(a, t, b)| (a.as_str(), *t, b.as_str()))).await {
Ok(rpc_id) => {
debug_log::logf(format_args!("send: to={} ts={local_ts_ms}", debug_log::mask_phone(&recipient)));
app.pending_sends
.insert(rpc_id.clone(), (recipient.to_string(), local_ts_ms));
for path in &attachments {
if path.starts_with(&app.paste_temp_path) {
let sentinel = Instant::now()
+ Duration::from_secs(app::PASTE_CLEANUP_SENTINEL_SECS);
app.pending_paste_cleanups
.insert(rpc_id.clone(), (path.clone(), sentinel));
break;
}
}
}
Err(e) => {
app.status_message = format!("send error: {e}");
for path in &attachments {
if path.starts_with(&app.paste_temp_path) {
let _ = std::fs::remove_file(path);
}
}
}
}
}
SendRequest::Reaction {
conv_id, emoji, is_group, target_author, target_timestamp, remove,
} => {
if let Err(e) = signal_client
.send_reaction(&conv_id, is_group, &emoji, &target_author, target_timestamp, remove)
.await
{
app.status_message = format!("reaction error: {e}");
}
}
SendRequest::Edit { recipient, body, is_group, edit_timestamp, local_ts_ms, mentions, quote_timestamp, quote_author, quote_body } => {
let quote = match (quote_author, quote_timestamp, quote_body) {
(Some(author), Some(ts), Some(body_text)) => Some((author, ts, body_text)),
_ => None,
};
match signal_client.send_edit_message(&recipient, &body, is_group, edit_timestamp, &mentions, quote.as_ref().map(|(a, t, b)| (a.as_str(), *t, b.as_str()))).await {
Ok(rpc_id) => {
debug_log::logf(format_args!("edit: to={} ts={edit_timestamp}", debug_log::mask_phone(&recipient)));
app.pending_sends.insert(rpc_id, (recipient.to_string(), local_ts_ms));
}
Err(e) => {
app.status_message = format!("edit error: {e}");
}
}
}
SendRequest::RemoteDelete { recipient, is_group, target_timestamp } => {
if let Err(e) = signal_client.send_remote_delete(&recipient, is_group, target_timestamp).await {
app.status_message = format!("delete error: {e}");
}
}
SendRequest::Typing { recipient, is_group, stop } => {
let _ = signal_client.send_typing(&recipient, is_group, stop).await;
}
SendRequest::ReadReceipt { recipient, timestamps } => {
if let Err(e) = signal_client.send_read_receipt(&recipient, ×tamps).await {
debug_log::logf(format_args!("read receipt error: {e}"));
}
}
SendRequest::UpdateExpiration { conv_id, is_group, seconds } => {
let result = if is_group {
signal_client.send_update_group_expiration(&conv_id, seconds).await
} else {
signal_client.send_update_contact_expiration(&conv_id, seconds).await
};
if let Err(e) = result {
app.status_message = format!("expiration error: {e}");
} else if seconds == 0 {
app.status_message = "Disappearing messages disabled".to_string();
} else {
app.status_message = format!(
"Disappearing messages set to {}",
input::format_compact_duration(seconds),
);
}
}
SendRequest::CreateGroup { name } => {
if let Err(e) = signal_client.create_group(&name, &[]).await {
app.status_message = format!("create group error: {e}");
} else {
app.status_message = format!("Created group \"{}\"", name);
let _ = signal_client.list_groups().await;
}
}
SendRequest::AddGroupMembers { group_id, members } => {
if let Err(e) = signal_client.add_group_members(&group_id, &members).await {
app.status_message = format!("add member error: {e}");
} else {
let names: Vec<String> = members.iter()
.map(|m| app.contact_names.get(m).cloned().unwrap_or_else(|| m.clone()))
.collect();
app.status_message = format!("Added {}", names.join(", "));
let _ = signal_client.list_groups().await;
}
}
SendRequest::RemoveGroupMembers { group_id, members } => {
if let Err(e) = signal_client.remove_group_members(&group_id, &members).await {
app.status_message = format!("remove member error: {e}");
} else {
let names: Vec<String> = members.iter()
.map(|m| app.contact_names.get(m).cloned().unwrap_or_else(|| m.clone()))
.collect();
app.status_message = format!("Removed {}", names.join(", "));
let _ = signal_client.list_groups().await;
}
}
SendRequest::RenameGroup { group_id, name } => {
if let Err(e) = signal_client.rename_group(&group_id, &name).await {
app.status_message = format!("rename group error: {e}");
} else {
if let Some(conv) = app.conversations.get_mut(&group_id) {
conv.name = name.clone();
}
app.contact_names.insert(group_id.clone(), name.clone());
app.status_message = format!("Renamed group to \"{}\"", name);
let _ = signal_client.list_groups().await;
}
}
SendRequest::LeaveGroup { group_id } => {
if let Err(e) = signal_client.quit_group(&group_id).await {
app.status_message = format!("leave group error: {e}");
} else {
let name = app.conversations.get(&group_id)
.map(|c| c.name.clone())
.unwrap_or_else(|| group_id.clone());
app.conversations.remove(&group_id);
app.conversation_order.retain(|id| id != &group_id);
app.groups.remove(&group_id);
if app.active_conversation.as_ref() == Some(&group_id) {
app.active_conversation = None;
}
app.status_message = format!("Left group \"{}\"", name);
}
}
SendRequest::Block { recipient, is_group } => {
if let Err(e) = signal_client.block_contact(&recipient, is_group).await {
app.status_message = format!("block error: {e}");
}
}
SendRequest::Unblock { recipient, is_group } => {
if let Err(e) = signal_client.unblock_contact(&recipient, is_group).await {
app.status_message = format!("unblock error: {e}");
}
}
SendRequest::Pin { recipient, is_group, target_author, target_timestamp, pin_duration } => {
if let Err(e) = signal_client.send_pin_message(&recipient, is_group, &target_author, target_timestamp, pin_duration).await {
app.status_message = format!("pin error: {e}");
}
}
SendRequest::Unpin { recipient, is_group, target_author, target_timestamp } => {
if let Err(e) = signal_client.send_unpin_message(&recipient, is_group, &target_author, target_timestamp).await {
app.status_message = format!("unpin error: {e}");
}
}
SendRequest::PollCreate { recipient, is_group, question, options, allow_multiple, local_ts_ms } => {
match signal_client.send_poll_create(&recipient, is_group, &question, &options, allow_multiple).await {
Ok(rpc_id) => {
app.pending_sends.insert(rpc_id, (recipient, local_ts_ms));
}
Err(e) => {
app.status_message = format!("poll error: {e}");
}
}
}
SendRequest::PollVote { recipient, is_group, poll_author, poll_timestamp, option_indexes, vote_count } => {
if let Err(e) = signal_client.send_poll_vote(&recipient, is_group, &poll_author, poll_timestamp, &option_indexes, vote_count).await {
app.status_message = format!("vote error: {e}");
}
}
SendRequest::PollTerminate { recipient, is_group, poll_timestamp } => {
if let Err(e) = signal_client.send_poll_terminate(&recipient, is_group, poll_timestamp).await {
app.status_message = format!("end poll error: {e}");
}
}
SendRequest::MessageRequestResponse { recipient, is_group, response_type } => {
if let Err(e) = signal_client.send_message_request_response(&recipient, is_group, &response_type).await {
app.status_message = format!("message request error: {e}");
} else {
app.status_message = match response_type.as_str() {
"accept" => "Message request accepted".to_string(),
"delete" => "Message request deleted".to_string(),
_ => String::new(),
};
}
}
SendRequest::ListIdentities => {
let _ = signal_client.list_identities().await;
}
SendRequest::TrustIdentity { recipient, safety_number } => {
if let Err(e) = signal_client.trust_identity(&recipient, &safety_number).await {
app.status_message = format!("trust error: {e}");
} else {
app.status_message = format!("Verified {}", app.contact_names.get(&recipient).unwrap_or(&recipient));
let _ = signal_client.list_identities().await;
}
}
SendRequest::UpdateProfile { given_name, family_name, about, about_emoji } => {
if let Err(e) = signal_client.update_profile(&given_name, &family_name, &about, &about_emoji).await {
app.status_message = format!("profile error: {e}");
} else {
app.status_message = "Profile updated".to_string();
}
}
}
}
enum MessagingBackend<'a> {
Signal(&'a mut SignalClient),
Demo,
}
impl MessagingBackend<'_> {
async fn dispatch(&mut self, app: &mut App, req: SendRequest) {
if let MessagingBackend::Signal(sc) = self {
dispatch_send(sc, app, req).await;
}
}
fn drain_events(&mut self, app: &mut App) -> bool {
let MessagingBackend::Signal(sc) = self else { return false };
let mut changed = false;
loop {
match sc.event_rx.try_recv() {
Ok(ev) => {
app.handle_signal_event(ev);
changed = true;
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
if app.connection_error.is_none() {
let stderr = sc.stderr_output();
let exit_info = sc.try_child_exit();
let msg = if let Some(last_line) = stderr.lines().last().filter(|l| !l.is_empty()) {
format!("signal-cli: {last_line}")
} else if let Some(code) = exit_info {
match code {
Some(c) => format!("signal-cli exited with code {c}"),
None => "signal-cli killed by signal".to_string(),
}
} else {
"signal-cli disconnected".to_string()
};
debug_log::logf(format_args!("disconnect: {msg}"));
app.connection_error = Some(msg);
app.connected = false;
}
break;
}
Err(_) => break,
}
}
changed
}
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut backend: MessagingBackend<'_>,
config: &Config,
db: db::Database,
incognito: bool,
) -> Result<()> {
let mut app = App::new(config.account.clone(), db);
app.notify_direct = config.notify_direct;
app.notify_group = config.notify_group;
app.desktop_notifications = config.desktop_notifications;
app.notification_preview = config.notification_preview.clone();
app.clipboard_clear_seconds = config.clipboard_clear_seconds;
app.inline_images = config.inline_images;
app.show_link_previews = config.show_link_previews;
app.native_images = config.native_images;
app.incognito = incognito;
app.date_separators = config.date_separators;
app.show_receipts = config.show_receipts;
app.color_receipts = config.color_receipts;
app.nerd_fonts = config.nerd_fonts;
app.emoji_to_text = config.emoji_to_text;
app.show_reactions = config.show_reactions;
app.reaction_verbose = config.reaction_verbose;
app.send_read_receipts = config.send_read_receipts;
app.mouse_enabled = config.mouse_enabled;
app.sidebar_on_right = config.sidebar_on_right;
app.available_themes = theme::all_themes();
app.theme = theme::find_theme(&config.theme);
let mut kb = keybindings::find_profile(&config.keybinding_profile);
let overrides = keybindings::load_overrides();
kb.apply_overrides(&overrides);
app.keybindings = kb;
app.available_kb_profiles = keybindings::all_profile_names();
app.settings_profile_name = config.settings_profile.clone();
app.available_settings_profiles = settings_profile::all_settings_profiles();
app.load_from_db()?;
app.expiring_msg_count = app.conversations.values()
.flat_map(|c| &c.messages)
.filter(|m| m.expires_in_seconds > 0)
.count();
if let MessagingBackend::Signal(sc) = &mut backend {
app.set_connected();
app.sweep_expired_messages();
app.startup_status = "Syncing with primary device...".to_string();
let _ = sc.send_sync_request().await;
app.startup_status = "Loading contacts...".to_string();
let _ = sc.list_contacts().await;
app.startup_status = "Loading groups...".to_string();
let _ = sc.list_groups().await;
app.startup_status = "Loading identities...".to_string();
let _ = sc.list_identities().await;
} else {
app.is_demo = true;
app.connected = true;
app.loading = false;
app.status_message = "connected | demo mode".to_string();
app.populate_demo_data(chrono::Utc::now().date_naive());
}
let mut last_expiry_sweep = Instant::now();
let mut needs_redraw = true;
if config.mouse_enabled {
execute!(terminal.backend_mut(), EnableMouseCapture, EnableBracketedPaste)?;
} else {
execute!(terminal.backend_mut(), EnableBracketedPaste)?;
}
loop {
if needs_redraw {
queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
if app.native_images && app.active_conversation != app.prev_active_conversation {
app.prev_active_conversation = app.active_conversation.clone();
terminal.clear()?;
}
terminal.draw(|frame| ui::draw(frame, &mut app))?;
let has_post_draw = !app.link_regions.is_empty() || app.native_images;
if has_post_draw && app.mode == InputMode::Insert {
queue!(terminal.backend_mut(), Hide)?;
}
emit_osc8_links(terminal.backend_mut(), &app.link_regions, app.theme.link)?;
if app.native_images {
emit_native_images(terminal.backend_mut(), &mut app)?;
}
if has_post_draw && app.mode == InputMode::Insert {
queue!(terminal.backend_mut(), Show)?;
}
execute!(terminal.backend_mut(), EndSynchronizedUpdate)?;
needs_redraw = false;
}
if app.ensure_active_images() {
needs_redraw = true;
}
if app.loading {
app.spinner_tick = app.spinner_tick.wrapping_add(1);
needs_redraw = true;
}
if app.at_scroll_top {
app.load_more_messages();
needs_redraw = true;
}
let has_terminal_event = event::poll(POLL_TIMEOUT)?;
if has_terminal_event {
needs_redraw = true;
match event::read()? {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
continue;
}
if app.keybindings_capturing {
app.handle_keybinding_capture(key.modifiers, key.code);
} else if !app.handle_global_key(key.modifiers, key.code) {
let (overlay_handled, send_request) = app.handle_overlay_key(key.code);
if let Some(req) = send_request {
backend.dispatch(&mut app, req).await;
}
if !overlay_handled {
let send_request = match app.mode {
InputMode::Normal => app.handle_normal_key(key.modifiers, key.code),
InputMode::Insert => app.handle_insert_key(key.modifiers, key.code),
};
if let Some(req) = send_request {
backend.dispatch(&mut app, req).await;
}
}
}
}
Event::Mouse(mouse) => {
if let Some(req) = app.handle_mouse_event(mouse) {
backend.dispatch(&mut app, req).await;
}
}
Event::Paste(text) => {
if let Some(req) = app.handle_paste(text) {
backend.dispatch(&mut app, req).await;
}
}
Event::Resize(..) => {
app.clear_kitty_state();
}
_ => {}
}
}
if backend.drain_events(&mut app) {
needs_redraw = true;
}
for (recipient, timestamps) in std::mem::take(&mut app.pending_read_receipts) {
backend.dispatch(&mut app, SendRequest::ReadReceipt { recipient, timestamps }).await;
}
if app.typing.cleanup() {
needs_redraw = true;
}
if let Some(typing_stop) = app.check_typing_timeout() {
backend.dispatch(&mut app, typing_stop).await;
}
if let Some(typing_stop) = app.pending_typing_stop.take() {
backend.dispatch(&mut app, typing_stop).await;
}
if last_expiry_sweep.elapsed() >= Duration::from_secs(10) {
app.sweep_expired_messages();
last_expiry_sweep = Instant::now();
needs_redraw = true;
}
if app.pending_bell {
app.pending_bell = false;
execute!(terminal.backend_mut(), Print("\x07"))?;
}
app.check_clipboard_clear();
app.cleanup_paste_files();
if let Some(enabled) = app.pending_mouse_toggle.take() {
if enabled {
execute!(terminal.backend_mut(), EnableMouseCapture)?;
} else {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
}
}
let unread = app.total_unread();
let title = if unread > 0 {
format!("siggy ({unread})")
} else {
"siggy".to_string()
};
execute!(terminal.backend_mut(), crossterm::terminal::SetTitle(&title))?;
if app.should_quit {
break;
}
}
execute!(terminal.backend_mut(), crossterm::terminal::SetTitle(""))
.ok();
Ok(())
}