mod accounts; mod app; mod audio; mod config; mod constants; mod formatting; mod input; #[cfg(feature = "images")] mod media; mod message_grouping; mod notifications; mod tdlib; mod types; mod ui; mod utils; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use tdlib_rs::enums::Update; use app::{App, AppScreen}; use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS}; use input::{handle_auth_input, handle_main_input}; use tdlib::AuthState; use utils::{disable_tdlib_logs, with_timeout_ignore}; /// Parses `--account ` from CLI arguments. fn parse_account_arg() -> Option { let args: Vec = std::env::args().collect(); let mut i = 1; while i < args.len() { if args[i] == "--account" && i + 1 < args.len() { return Some(args[i + 1].clone()); } i += 1; } None } #[tokio::main] async fn main() -> Result<(), io::Error> { // Загружаем переменные окружения из .env let _ = dotenvy::dotenv(); // Инициализируем tracing subscriber для логирования // Уровень логов можно настроить через переменную окружения RUST_LOG tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), ) .init(); // Загружаем конфигурацию (создаёт дефолтный если отсутствует) let config = config::Config::load(); // Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/ let accounts_config = accounts::load_or_create(); // Резолвим аккаунт из CLI или default let account_arg = parse_account_arg(); let (account_name, db_path) = accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| { eprintln!("Error: {}", e); std::process::exit(1); }); // Создаём директорию аккаунта если её нет let db_path = accounts::ensure_account_dir( account_arg .as_deref() .unwrap_or(&accounts_config.default_account), ) .unwrap_or(db_path); // Acquire per-account lock BEFORE raw mode (so error prints to normal terminal) let account_lock = accounts::acquire_lock( account_arg.as_deref().unwrap_or(&accounts_config.default_account), ) .unwrap_or_else(|e| { eprintln!("Error: {}", e); std::process::exit(1); }); // Отключаем логи TDLib ДО создания клиента disable_tdlib_logs(); // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // Ensure terminal restoration on panic let panic_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); panic_hook(info); })); // Create app state with account-specific db_path let mut app = App::new(config, db_path); app.current_account_name = account_name; app.account_lock = Some(account_lock); // Запускаем инициализацию TDLib в фоне (только для реального клиента) let client_id = app.td_client.client_id(); let api_id = app.td_client.api_id; let api_hash = app.td_client.api_hash.clone(); let db_path_str = app.td_client.db_path.to_string_lossy().to_string(); tokio::spawn(async move { if let Err(e) = tdlib_rs::functions::set_tdlib_parameters( false, // use_test_dc db_path_str, // database_directory "".to_string(), // files_directory "".to_string(), // database_encryption_key true, // use_file_database true, // use_chat_info_database true, // use_message_database false, // use_secret_chats api_id, api_hash, "en".to_string(), // system_language_code "Desktop".to_string(), // device_model "".to_string(), // system_version env!("CARGO_PKG_VERSION").to_string(), // application_version client_id, ) .await { tracing::error!("set_tdlib_parameters failed: {:?}", e); } }); let res = run_app(&mut terminal, &mut app).await; // Restore terminal disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; terminal.show_cursor()?; if let Err(err) = res { println!("Error: {:?}", err); } Ok(()) } async fn run_app( terminal: &mut Terminal, app: &mut App, ) -> io::Result<()> { // Флаг для остановки polling задачи let should_stop = Arc::new(AtomicBool::new(false)); let should_stop_clone = should_stop.clone(); // Канал для передачи updates из polling задачи в main loop let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::(); // Запускаем polling TDLib receive() в отдельной задаче let polling_handle = tokio::spawn(async move { while !should_stop_clone.load(Ordering::Relaxed) { // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг let result = tokio::task::spawn_blocking(tdlib_rs::receive).await; if let Ok(Some((update, _client_id))) = result { if update_tx.send(update).is_err() { break; // Канал закрыт, выходим } } } }); loop { // Обрабатываем updates от TDLib из канала (неблокирующе) let mut had_updates = false; while let Ok(update) = update_rx.try_recv() { app.td_client.handle_update(update); had_updates = true; } // Помечаем UI как требующий перерисовки если были обновления if had_updates { app.needs_redraw = true; } // Обрабатываем результаты фоновой загрузки фото #[cfg(feature = "images")] { use crate::tdlib::PhotoDownloadState; let mut got_photos = false; if let Some(ref mut rx) = app.photo_download_rx { while let Ok((file_id, result)) = rx.try_recv() { let new_state = match result { Ok(path) => PhotoDownloadState::Downloaded(path), Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()), }; for msg in app.td_client.current_chat_messages_mut() { if let Some(photo) = msg.photo_info_mut() { if photo.file_id == file_id { photo.download_state = new_state; got_photos = true; break; } } } } } if got_photos { app.needs_redraw = true; } } // Очищаем устаревший typing status if app.td_client.clear_stale_typing_status() { app.needs_redraw = true; } // Обрабатываем очередь сообщений для отметки как прочитанных if !app.td_client.pending_view_messages().is_empty() { app.td_client.process_pending_view_messages().await; } // Обрабатываем очередь user_id для загрузки имён if !app.td_client.pending_user_ids().is_empty() { app.td_client.process_pending_user_ids().await; } // Обновляем состояние экрана на основе auth_state let screen_changed = update_screen_state(app).await; if screen_changed { app.needs_redraw = true; } // Обновляем позицию воспроизведения голосового сообщения { let mut stop_playback = false; if let Some(ref mut playback) = app.playback_state { use crate::tdlib::PlaybackStatus; match playback.status { PlaybackStatus::Playing => { let prev_second = playback.position as u32; if let Some(last_tick) = app.last_playback_tick { let delta = last_tick.elapsed().as_secs_f32(); playback.position += delta; } app.last_playback_tick = Some(std::time::Instant::now()); // Проверяем завершение воспроизведения if playback.position >= playback.duration || app.audio_player.as_ref().is_some_and(|p| p.is_stopped()) { stop_playback = true; } // Перерисовка только при смене секунды (не 60 FPS) if playback.position as u32 != prev_second || stop_playback { app.needs_redraw = true; } } _ => { app.last_playback_tick = None; } } } if stop_playback { app.stop_playback(); app.last_playback_tick = None; } } // Рендерим только если есть изменения if app.needs_redraw { terminal.draw(|f| ui::render(f, app))?; app.needs_redraw = false; } // Используем poll с коротким таймаутом для быстрой реакции на ввод // 16ms ≈ 60 FPS потенциально, но рендерим только при изменениях if event::poll(Duration::from_millis(POLL_TIMEOUT_MS))? { match event::read()? { Event::Key(key) => { // Global quit command if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { // Graceful shutdown should_stop.store(true, Ordering::Relaxed); // Останавливаем воспроизведение голосового (убиваем ffplay) app.stop_playback(); // Закрываем TDLib клиент let _ = tdlib_rs::functions::close(app.td_client.client_id()).await; // Ждём завершения polling задачи (с таймаутом) with_timeout_ignore( Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle, ) .await; return Ok(()); } // Ctrl+A opens account switcher from any screen if key.code == KeyCode::Char('a') && key.modifiers.contains(KeyModifiers::CONTROL) && app.account_switcher.is_none() { app.open_account_switcher(); } else if app.account_switcher.is_some() { // Route to main input handler when account switcher is open handle_main_input(app, key).await; } else { match app.screen { AppScreen::Loading => { // В состоянии загрузки игнорируем ввод } AppScreen::Auth => handle_auth_input(app, key.code).await, AppScreen::Main => handle_main_input(app, key).await, } } // Любой ввод требует перерисовки app.needs_redraw = true; } Event::Resize(_, _) => { // При изменении размера терминала нужна перерисовка app.needs_redraw = true; } _ => {} } } // Process pending chat initialization (reply info, pinned, photos) if let Some(chat_id) = app.pending_chat_init.take() { // Загружаем недостающие reply info (игнорируем ошибки) with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()) .await; // Загружаем последнее закреплённое сообщение (игнорируем ошибки) with_timeout_ignore( Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id), ) .await; // Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно) #[cfg(feature = "images")] { use crate::tdlib::PhotoDownloadState; if app.config().images.auto_download_images && app.config().images.show_images { let photo_file_ids: Vec = app .td_client .current_chat_messages() .iter() .rev() .take(5) .filter_map(|msg| { msg.photo_info().and_then(|p| { matches!(p.download_state, PhotoDownloadState::NotDownloaded) .then_some(p.file_id) }) }) .collect(); if !photo_file_ids.is_empty() { let client_id = app.td_client.client_id(); let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<(i32, Result)>(); app.photo_download_rx = Some(rx); for file_id in photo_file_ids { let tx = tx.clone(); tokio::spawn(async move { let result = tokio::time::timeout(Duration::from_secs(5), async { match tdlib_rs::functions::download_file( file_id, 1, 0, 0, true, client_id, ) .await { Ok(tdlib_rs::enums::File::File(file)) if file.local.is_downloading_completed && !file.local.path.is_empty() => { Ok(file.local.path) } Ok(_) => Err("Файл не скачан".to_string()), Err(e) => Err(format!("{:?}", e)), } }) .await; let result = match result { Ok(r) => r, Err(_) => Err("Таймаут загрузки".to_string()), }; let _ = tx.send((file_id, result)); }); } } } } app.needs_redraw = true; } // Check pending account switch if let Some((account_name, new_db_path)) = app.pending_account_switch.take() { // 0. Acquire lock for new account before switching match accounts::acquire_lock(&account_name) { Ok(new_lock) => { // Release old lock if let Some(old_lock) = app.account_lock.take() { accounts::release_lock(old_lock); } app.account_lock = Some(new_lock); } Err(e) => { app.error_message = Some(e); continue; } } // 1. Stop playback app.stop_playback(); // 2. Recreate client (closes old, creates new, inits TDLib params) if let Err(e) = app.td_client.recreate_client(new_db_path).await { app.error_message = Some(format!("Ошибка переключения: {}", e)); continue; } // 3. Reset app state app.current_account_name = account_name.clone(); app.screen = AppScreen::Loading; // 4. Persist selected account as default for next launch let mut accounts_config = accounts::load_or_create(); accounts_config.default_account = account_name; if let Err(e) = accounts::save(&accounts_config) { tracing::warn!("Could not save default account: {}", e); } app.chats.clear(); app.selected_chat_id = None; app.chat_state = Default::default(); app.input_mode = Default::default(); app.status_message = Some("Переключение аккаунта...".to_string()); app.error_message = None; app.is_searching = false; app.search_query.clear(); app.message_input.clear(); app.cursor_position = 0; app.message_scroll_offset = 0; app.pending_chat_init = None; app.account_switcher = None; app.needs_redraw = true; } } } /// Возвращает true если состояние изменилось и требуется перерисовка async fn update_screen_state(app: &mut App) -> bool { use utils::with_timeout_ignore; let prev_screen = app.screen.clone(); let prev_status = app.status_message.clone(); let prev_error = app.error_message.clone(); let prev_chats_len = app.chats.len(); match &app.td_client.auth_state() { AuthState::WaitTdlibParameters => { app.screen = AppScreen::Loading; app.status_message = Some("Инициализация TDLib...".to_string()); } AuthState::WaitPhoneNumber | AuthState::WaitCode | AuthState::WaitPassword => { app.screen = AppScreen::Auth; app.is_loading = false; } AuthState::Ready => { if prev_screen != AppScreen::Main { app.screen = AppScreen::Main; app.is_loading = true; app.status_message = Some("Загрузка чатов...".to_string()); // Запрашиваем загрузку чатов с таймаутом (игнорируем ошибки) with_timeout_ignore(Duration::from_secs(5), app.td_client.load_chats(50)).await; } // Синхронизируем чаты из td_client в app if !app.td_client.chats().is_empty() { app.chats = app.td_client.chats().to_vec(); if app.chat_list_state.selected().is_none() && !app.chats.is_empty() { app.chat_list_state.select(Some(0)); } // Синхронизируем muted чаты для notifications app.td_client.sync_notification_muted_chats(); // Убираем статус загрузки когда чаты появились if app.is_loading { app.is_loading = false; app.status_message = None; } } } AuthState::Closed => { app.status_message = Some("Соединение закрыто".to_string()); } AuthState::Error(e) => { app.error_message = Some(e.clone()); } } // Проверяем, изменилось ли что-то app.screen != prev_screen || app.status_message != prev_status || app.error_message != prev_error || app.chats.len() != prev_chats_len }