Prevent running multiple tele-tui instances with the same account by using advisory file locks (flock). Lock is acquired before raw mode so errors print to normal terminal. Account switching acquires new lock before releasing old. Also log set_tdlib_parameters errors via tracing instead of silently discarding them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
532 lines
22 KiB
Rust
532 lines
22 KiB
Rust
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 <name>` from CLI arguments.
|
||
fn parse_account_arg() -> Option<String> {
|
||
let args: Vec<String> = 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<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||
terminal: &mut Terminal<B>,
|
||
app: &mut App<T>,
|
||
) -> 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::<Update>();
|
||
|
||
// Запускаем 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<i32> = 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<String, String>)>();
|
||
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<T: tdlib::TdClientTrait>(app: &mut App<T>) -> 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
|
||
}
|