zinc-wallet-cli 0.4.0

Agent-first Bitcoin + Ordinals CLI wallet with account-based taproot ordinals + native segwit payment addresses (optional human mode)
use std::io;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, Mutex};

use crossterm::{
    event::{self},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use ratatui_image::picker::Picker;
use ratatui_image::picker::ProtocolType;

use zinc_core::ZincWallet;

use crate::cli::Cli;
use crate::error::AppError;

use super::state::{DashboardEvent, SyncType};

pub struct TuiSetup {
    pub terminal: Terminal<CrosstermBackend<io::Stdout>>,
    pub event_tx: mpsc::Sender<DashboardEvent>,
    pub event_rx: mpsc::Receiver<DashboardEvent>,
    pub input_handle: tokio::task::JoinHandle<()>,
}

pub fn setup_tui(cli: &Cli) -> Result<(TuiSetup, Picker), AppError> {
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    enable_raw_mode()?;
    let backend = CrosstermBackend::new(stdout);
    let terminal = Terminal::new(backend)?;

    let (event_tx, event_rx) = mpsc::channel(1024);

    let picker = if cli.no_images {
        Picker::halfblocks()
    } else {
        Picker::from_query_stdio().unwrap_or(Picker::halfblocks())
    };

    let _ascii_mode = detect_ascii_mode(cli, &picker);

    let event_tx_clone = event_tx.clone();
    let input_handle = tokio::spawn(async move {
        loop {
            if event::poll(Duration::from_millis(100)).unwrap() {
                if let Ok(ev) = event::read() {
                    let _ = event_tx_clone.send(DashboardEvent::Input(ev)).await;
                }
            }
            let _ = event_tx_clone.send(DashboardEvent::Tick).await;
        }
    });

    Ok((
        TuiSetup {
            terminal,
            event_tx,
            event_rx,
            input_handle,
        },
        picker,
    ))
}

fn detect_ascii_mode(cli: &Cli, picker: &Picker) -> bool {
    let protocol = picker.protocol_type();
    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
    let is_reliable_iterm = matches!(
        term_program.as_str(),
        "iTerm.app" | "WezTerm" | "com.mitchellh.ghostty"
    );

    cli.ascii
        || protocol == ProtocolType::Halfblocks
        || (protocol == ProtocolType::Iterm2 && !is_reliable_iterm)
}

pub struct SyncTasks {
    pub balance_task: tokio::task::JoinHandle<()>,
    pub inscription_task: tokio::task::JoinHandle<()>,
}

pub fn spawn_sync_tasks(
    wallet: Arc<Mutex<ZincWallet>>,
    event_tx: mpsc::Sender<DashboardEvent>,
    esplora_url: String,
    ord_url: String,
    sync_tx: broadcast::Sender<()>,
) -> SyncTasks {
    let balance_task = spawn_balance_task(
        Arc::clone(&wallet),
        event_tx.clone(),
        esplora_url,
        sync_tx.subscribe(),
    );

    let inscription_task =
        spawn_inscription_task(Arc::clone(&wallet), event_tx, ord_url, sync_tx.subscribe());

    SyncTasks {
        balance_task,
        inscription_task,
    }
}

fn spawn_balance_task(
    wallet: Arc<Mutex<ZincWallet>>,
    event_tx: mpsc::Sender<DashboardEvent>,
    esplora_url: String,
    mut sync_rx: broadcast::Receiver<()>,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        loop {
            let _ = event_tx
                .send(DashboardEvent::SyncStarted(SyncType::Balance))
                .await;
            {
                let mut w = wallet.lock().await;
                if let Ok(Result::Ok(_)) =
                    tokio::time::timeout(Duration::from_secs(30), w.sync(&esplora_url)).await
                {
                    let balance = w.get_balance();
                    let confirmed = balance.total.confirmed.to_sat();
                    let pending = balance.total.trusted_pending.to_sat()
                        + balance.total.untrusted_pending.to_sat();
                    let _ = event_tx
                        .send(DashboardEvent::BalanceUpdated { confirmed, pending })
                        .await;
                }
            }
            let _ = event_tx
                .send(DashboardEvent::SyncFinished(SyncType::Balance))
                .await;

            match sync_rx.recv().await {
                Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {}
                Err(broadcast::error::RecvError::Closed) => break,
            }
        }
    })
}

fn spawn_inscription_task(
    wallet: Arc<Mutex<ZincWallet>>,
    event_tx: mpsc::Sender<DashboardEvent>,
    ord_url: String,
    mut sync_rx: broadcast::Receiver<()>,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        loop {
            let _ = event_tx
                .send(DashboardEvent::SyncStarted(SyncType::Inscriptions))
                .await;
            let mut w = wallet.lock().await;
            if let Ok(Result::Ok(_)) =
                tokio::time::timeout(Duration::from_secs(30), w.sync_ordinals(&ord_url)).await
            {
                let inscriptions = w.inscriptions().to_vec();
                let _ = event_tx
                    .send(DashboardEvent::InscriptionsUpdated(inscriptions.clone()))
                    .await;
            }
            drop(w);
            let _ = event_tx
                .send(DashboardEvent::SyncFinished(SyncType::Inscriptions))
                .await;

            tokio::select! {
                res = sync_rx.recv() => {
                    match res {
                        Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {}
                        Err(broadcast::error::RecvError::Closed) => break,
                    }
                },
                () = tokio::time::sleep(Duration::from_secs(60)) => {},
            }
        }
    })
}

pub async fn cleanup_tui(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<(), AppError> {
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    Ok(())
}