spytrap_adb/
tui.rs

1use crate::errors::*;
2use crate::ioc::{Repository, RepositoryContent, Suspicion, SuspicionLevel};
3use crate::scan;
4use crate::utils;
5use crossterm::event::EventStream;
6use crossterm::event::{KeyEvent, KeyModifiers};
7use crossterm::{
8    event::{Event, KeyCode},
9    execute,
10    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
11};
12use forensic_adb::{AndroidStorageInput, DeviceInfo, Host};
13use indexmap::IndexMap;
14use ratatui::{
15    Frame, Terminal,
16    backend::{Backend, CrosstermBackend},
17    layout::{Alignment, Constraint, Direction, Layout},
18    style::{Color, Modifier, Style},
19    text::{Line, Span, Text},
20    widgets::{Block, Borders, List, ListItem, Paragraph},
21};
22use std::cmp::Ordering;
23use std::collections::BTreeSet;
24use std::convert::Infallible;
25use std::io;
26use std::io::Stdout;
27use std::iter::Chain;
28use std::slice::Iter;
29use std::time::Duration;
30use tokio::sync::mpsc;
31use tokio::task::JoinSet;
32use tokio::time;
33use tokio_stream::StreamExt;
34
35const DARK_GREY: Color = Color::Rgb(0x3b, 0x3b, 0x3b);
36/// Number of items to navigate with page up/down keys
37const PAGE_MODIFIER: usize = 10;
38/// The number of lines used by the spytrap-adb UI around the scroll view
39const SCROLL_CHROME_HEIGHT: usize = 6;
40
41const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
42const ACTIVITY_TICK_INTERVAL: Duration = Duration::from_millis(100);
43const ACTIVITY: &[&str] = &[".", "o", "O", "°", " ", " ", "°", "O", "o", ".", " ", " "];
44
45/// Check for updates if this many seconds elapsed since last successful update check
46const DATABASE_UPDATE_CHECK_INTERVAL: i64 = 60 * 60 * 3;
47
48#[derive(Debug)]
49pub enum Message {
50    Suspicion(Suspicion),
51    App { name: String, sus: Suspicion },
52    StartDownload,
53    ScanTick,
54    ScanEnded,
55    DownloadTick,
56    DownloadEnded(Option<Repository>),
57    DeviceRefreshTick,
58    DatabaseUpdateAvailable(bool),
59}
60
61#[derive(Debug)]
62pub enum TimerCmd {
63    Start(Duration),
64    Stop,
65}
66
67impl TimerCmd {
68    pub async fn recv(rx: &mut mpsc::Receiver<TimerCmd>) -> Result<TimerCmd> {
69        let cmd = rx.recv().await.context("Channel has closed")?;
70        Ok(cmd)
71    }
72}
73
74#[derive(Debug, PartialEq, Default)]
75pub struct SavedCursor {
76    offset: usize,
77    cursor: usize,
78    interval: Option<Duration>,
79}
80
81pub struct App {
82    adb_host: Host,
83    repository: Repository,
84    events_tx: mpsc::Sender<Message>,
85    events_rx: mpsc::Receiver<Message>,
86    timer_tx: mpsc::Sender<TimerCmd>,
87    timer_rx: Option<mpsc::Receiver<TimerCmd>>,
88    current_timer: Option<Duration>,
89    devices: Vec<DeviceInfo>,
90    offset: usize,
91    cursor: usize,
92    /// the previous cursor positions before switching into a different scroll-view
93    cursor_backtrace: Vec<SavedCursor>,
94    scan: Option<Scan>,
95    download: Option<Download>,
96}
97
98impl App {
99    pub fn new(adb_host: Host, repository: Repository) -> Self {
100        let (events_tx, events_rx) = mpsc::channel(5);
101        let (timer_tx, timer_rx) = mpsc::channel(5);
102        Self {
103            adb_host,
104            repository,
105            events_tx,
106            events_rx,
107            timer_tx,
108            timer_rx: Some(timer_rx),
109            current_timer: None,
110            devices: Vec::new(),
111            offset: 0,
112            cursor: 0,
113            cursor_backtrace: vec![],
114            scan: None,
115            download: None,
116        }
117    }
118
119    pub async fn init(&mut self) -> Result<()> {
120        let devices = self
121            .adb_host
122            .devices::<Vec<_>>()
123            .await
124            .map_err(|e| anyhow!("Failed to list devices from adb: {}", e))?;
125        self.devices = devices;
126        self.start_timer(DEVICE_REFRESH_INTERVAL).await?;
127
128        if let Some(content) = &self.repository.content {
129            if !content.update_available
130                && utils::now() > content.last_update_check + DATABASE_UPDATE_CHECK_INTERVAL
131            {
132                debug!("Haven't checked for database updates in a while, checking now...");
133                let tx = self.events_tx.clone();
134                let repo = self.repository.clone();
135                let content = content.clone();
136                tokio::spawn(async move {
137                    if let Err(err) = Self::run_update_availability_check(&repo, &content, tx).await
138                    {
139                        warn!("Failed to check for updates: {err:#}");
140                    }
141                });
142            }
143        } else {
144            debug!("No existing database present, starting download...");
145            self.events_tx.send(Message::StartDownload).await.ok();
146        }
147
148        Ok(())
149    }
150
151    async fn run_update_availability_check(
152        repo: &Repository,
153        content: &RepositoryContent,
154        tx: mpsc::Sender<Message>,
155    ) -> Result<()> {
156        let branch = repo.query_latest_branch().await?;
157
158        let update_available = content.git_commit != branch.sha;
159        tx.send(Message::DatabaseUpdateAvailable(update_available))
160            .await
161            .ok();
162
163        Ok(())
164    }
165
166    async fn start_timer(&mut self, interval: Duration) -> Result<()> {
167        self.timer_tx.send(TimerCmd::Start(interval)).await?;
168        self.current_timer = Some(interval);
169        Ok(())
170    }
171
172    async fn stop_timer(&mut self) -> Result<()> {
173        self.timer_tx.send(TimerCmd::Stop).await?;
174        self.current_timer = None;
175        Ok(())
176    }
177
178    /// The number of visible lines in the current active view
179    pub fn view_length(&self) -> usize {
180        if let Some(scan) = &self.scan {
181            scan.findings.len()
182                + scan.apps.len()
183                + scan
184                    .apps
185                    .iter()
186                    .map(|(name, values)| {
187                        if scan.expanded.contains(name) {
188                            values.len()
189                        } else {
190                            0
191                        }
192                    })
193                    .sum::<usize>()
194        } else {
195            self.devices.len()
196        }
197    }
198
199    pub async fn save_cursor(&mut self) -> Result<()> {
200        self.cursor_backtrace.push(SavedCursor {
201            offset: self.offset,
202            cursor: self.cursor,
203            interval: self.current_timer,
204        });
205        self.offset = 0;
206        self.cursor = 0;
207        self.stop_timer().await?;
208        Ok(())
209    }
210
211    pub fn key_up(&mut self) {
212        self.cursor = self.cursor.saturating_sub(1);
213        if self.cursor < self.offset {
214            self.offset = self.cursor;
215        }
216    }
217
218    pub fn key_down<B: Backend>(&mut self, terminal: &Terminal<B>) -> Result<()> {
219        let max = self.view_length().saturating_sub(1);
220
221        if self.cursor < max {
222            self.cursor += 1;
223            self.recalculate_scroll_offset(terminal)?;
224        }
225
226        Ok(())
227    }
228
229    pub fn recalculate_scroll_offset<B: Backend>(&mut self, terminal: &Terminal<B>) -> Result<()> {
230        let scroll_height = terminal.size()?.height as usize - SCROLL_CHROME_HEIGHT;
231        if self.cursor - self.offset > scroll_height {
232            self.offset = self.cursor - scroll_height;
233        }
234        Ok(())
235    }
236
237    pub async fn refresh_devices(&mut self) -> Result<()> {
238        let devices = self
239            .adb_host
240            .devices::<Vec<_>>()
241            .await
242            .map_err(|e| anyhow!("Failed to list devices from adb: {}", e))?;
243        self.devices = devices;
244        if self.devices.get(self.cursor).is_none() {
245            self.cursor = match self.devices.len() {
246                0 => 0,
247                n => n - 1,
248            };
249        }
250        Ok(())
251    }
252}
253
254#[derive(Debug, Default)]
255pub struct Spinner {
256    idx: usize,
257}
258
259impl Spinner {
260    pub fn activity_tick(&mut self) {
261        self.idx += 1;
262        self.idx %= ACTIVITY.len();
263    }
264
265    pub fn render(&self) -> Span {
266        Span::styled(
267            ACTIVITY[self.idx],
268            Style::default()
269                .fg(Color::LightGreen)
270                .add_modifier(Modifier::BOLD),
271        )
272    }
273}
274
275#[derive(Debug, Default)]
276pub struct Download {
277    spinner: Spinner,
278    cancel: Option<mpsc::Sender<Infallible>>,
279}
280
281#[derive(Debug, Default)]
282pub struct Scan {
283    findings: Vec<Suspicion>,
284    apps: IndexMap<String, AppInfos>,
285    expanded: BTreeSet<String>,
286    spinner: Spinner,
287    cancel: Option<mpsc::Sender<Infallible>>,
288}
289
290#[derive(Debug, PartialEq, Eq, Default)]
291pub struct AppInfos {
292    high: Vec<Suspicion>,
293    medium: Vec<Suspicion>,
294    low: Vec<Suspicion>,
295}
296
297impl AppInfos {
298    pub fn push(&mut self, item: Suspicion) {
299        match item.level {
300            SuspicionLevel::High => self.high.push(item),
301            SuspicionLevel::Medium => self.medium.push(item),
302            SuspicionLevel::Low => self.low.push(item),
303            SuspicionLevel::Good => (),
304        }
305    }
306
307    pub fn is_empty(&self) -> bool {
308        self.high.is_empty() && self.medium.is_empty() && self.low.is_empty()
309    }
310
311    pub fn len(&self) -> usize {
312        self.high.len() + self.medium.len() + self.low.len()
313    }
314
315    pub fn iter(
316        &self,
317    ) -> Chain<Chain<Iter<'_, Suspicion>, Iter<'_, Suspicion>>, Iter<'_, Suspicion>> {
318        self.high
319            .iter()
320            .chain(self.medium.iter())
321            .chain(self.low.iter())
322    }
323}
324
325impl Ord for AppInfos {
326    fn cmp(&self, other: &Self) -> Ordering {
327        Ordering::Equal
328            .then(self.high.len().cmp(&other.high.len()))
329            .then(self.medium.len().cmp(&other.medium.len()))
330            .then(self.low.len().cmp(&other.low.len()))
331    }
332}
333
334impl PartialOrd for AppInfos {
335    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
336        Some(self.cmp(other))
337    }
338}
339
340pub enum Action {
341    Shutdown,
342    Clear,
343}
344
345pub async fn run_scan(
346    adb_host: Host,
347    repo: Repository,
348    device: DeviceInfo,
349    events_tx: mpsc::Sender<Message>,
350) -> Result<()> {
351    let device = adb_host
352        .clone()
353        .device_or_default(Some(&device.serial), AndroidStorageInput::Auto)
354        .await
355        .with_context(|| anyhow!("Failed to access device: {:?}", device.serial))?;
356
357    let rules = repo.parse_rules().context("Failed to load rules")?;
358    scan::run(
359        &device,
360        &rules,
361        &scan::Settings { skip_apps: false },
362        &mut scan::ScanNotifier::Channel(events_tx),
363    )
364    .await?;
365
366    Ok(())
367}
368
369pub async fn run_download(mut repo: Repository) -> Result<Repository> {
370    repo.download_ioc_db()
371        .await
372        .context("Failed to download stalkerware-indicators yaml files")?;
373    Ok(repo)
374}
375
376pub async fn handle_key<B: Backend>(
377    terminal: &Terminal<B>,
378    app: &mut App,
379    event: Event,
380) -> Result<Option<Action>> {
381    match event {
382        Event::Key(KeyEvent {
383            code: KeyCode::Esc,
384            modifiers: KeyModifiers::NONE,
385            ..
386        })
387        | Event::Key(KeyEvent {
388            code: KeyCode::Char('c'),
389            modifiers: KeyModifiers::CONTROL,
390            ..
391        })
392        | Event::Key(KeyEvent {
393            code: KeyCode::Char('q'),
394            modifiers: KeyModifiers::NONE,
395            ..
396        }) => {
397            if let Some(tx) = app.download.take() {
398                drop(tx);
399            } else if let Some(tx) = app.scan.as_mut().and_then(|s| s.cancel.take()) {
400                drop(tx);
401            } else if app.scan.take().is_none() {
402                println!("Exiting...");
403                return Ok(Some(Action::Shutdown));
404            } else {
405                let saved = app.cursor_backtrace.pop().unwrap_or_default();
406                app.offset = saved.offset;
407                app.cursor = saved.cursor;
408                if let Some(interval) = saved.interval {
409                    app.start_timer(interval).await?;
410                } else {
411                    app.stop_timer().await?;
412                }
413            }
414        }
415        Event::Key(KeyEvent {
416            code: KeyCode::Char('Q'),
417            modifiers: KeyModifiers::SHIFT,
418            ..
419        }) => {
420            println!("Exiting...");
421            return Ok(Some(Action::Shutdown));
422        }
423        Event::Key(KeyEvent {
424            code: KeyCode::Enter,
425            modifiers: KeyModifiers::NONE,
426            ..
427        }) => {
428            if let Some(scan) = &mut app.scan {
429                if let Some(mut idx) = app.cursor.checked_sub(scan.findings.len()) {
430                    let mut offset = 0;
431                    for (i, (name, appinfos)) in scan.apps.iter().enumerate() {
432                        let height = if scan.expanded.contains(name) {
433                            appinfos.len() + 1
434                        } else {
435                            1
436                        };
437
438                        if offset + height > idx {
439                            idx = i;
440                            app.cursor = offset + scan.findings.len();
441                            break;
442                        } else {
443                            offset += height;
444                        }
445                    }
446
447                    // if there is an item under the cursor
448                    if let Some((name, _appinfos)) = scan.apps.get_index(idx) {
449                        // toggle the app on the `expanded` list
450                        if !scan.expanded.remove(name) {
451                            scan.expanded.insert(name.clone());
452                        }
453                    }
454                }
455            } else if let Some(device) = app.devices.get(app.cursor) {
456                let adb_host = app.adb_host.clone();
457                let repo = app.repository.clone();
458                let device = device.clone();
459                let events_tx = app.events_tx.clone();
460
461                let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
462                tokio::spawn(async move {
463                    let mut interval = time::interval(ACTIVITY_TICK_INTERVAL);
464                    let scan = run_scan(adb_host, repo, device, events_tx.clone());
465                    tokio::pin!(scan);
466
467                    loop {
468                        tokio::select! {
469                            _ = cancel_rx.recv() => {
470                                debug!("Scan has been canceled");
471                                events_tx.send(Message::ScanEnded).await.ok();
472                                break;
473                            }
474                            ret = &mut scan => {
475                                debug!("Scan has completed: {:?}", ret); // TODO print errors in UI
476                                events_tx.send(Message::ScanEnded).await.ok();
477                                break;
478                            }
479                            _ = interval.tick() => {
480                                events_tx.send(Message::ScanTick).await.ok();
481                            }
482                        }
483                    }
484                });
485                app.scan = Some(Scan {
486                    cancel: Some(cancel_tx),
487                    ..Default::default()
488                });
489                app.save_cursor().await?;
490            }
491        }
492        Event::Key(KeyEvent {
493            code: KeyCode::Up,
494            modifiers: KeyModifiers::NONE,
495            ..
496        }) => {
497            app.key_up();
498        }
499        Event::Key(KeyEvent {
500            code: KeyCode::Down,
501            modifiers: KeyModifiers::NONE,
502            ..
503        }) => {
504            app.key_down(terminal)?;
505        }
506        Event::Key(KeyEvent {
507            code: KeyCode::PageUp,
508            modifiers: KeyModifiers::NONE,
509            ..
510        }) => {
511            for _ in 0..PAGE_MODIFIER {
512                app.key_up();
513            }
514        }
515        Event::Key(KeyEvent {
516            code: KeyCode::PageDown,
517            modifiers: KeyModifiers::NONE,
518            ..
519        }) => {
520            for _ in 0..PAGE_MODIFIER {
521                app.key_down(terminal)?;
522            }
523        }
524        Event::Key(KeyEvent {
525            code: KeyCode::Home,
526            modifiers: KeyModifiers::NONE,
527            ..
528        }) => {
529            app.offset = 0;
530            app.cursor = 0;
531        }
532        Event::Key(KeyEvent {
533            code: KeyCode::End,
534            modifiers: KeyModifiers::NONE,
535            ..
536        }) => {
537            let max = app.view_length().saturating_sub(1);
538            app.cursor = max;
539            app.recalculate_scroll_offset(terminal)?;
540        }
541        Event::Key(KeyEvent {
542            code: KeyCode::Char('r'),
543            modifiers: KeyModifiers::CONTROL,
544            ..
545        }) => {
546            app.events_tx.send(Message::StartDownload).await.ok();
547        }
548        Event::Key(KeyEvent {
549            code: KeyCode::Char('l'),
550            modifiers: KeyModifiers::CONTROL,
551            ..
552        }) => {
553            return Ok(Some(Action::Clear));
554        }
555        Event::Resize(_columns, _rows) => {
556            app.recalculate_scroll_offset(terminal)?;
557        }
558        _ => (),
559    }
560    Ok(None)
561}
562
563async fn run_timer(mut rx: mpsc::Receiver<TimerCmd>, tx: mpsc::Sender<Message>) -> Result<()> {
564    let mut next_interval = None;
565    loop {
566        let interval = if let Some(interval) = next_interval.take() {
567            interval
568        } else {
569            match TimerCmd::recv(&mut rx).await? {
570                TimerCmd::Start(time) => time,
571                TimerCmd::Stop => continue,
572            }
573        };
574        let mut timer = time::interval(interval);
575        loop {
576            tokio::select! {
577                cmd = TimerCmd::recv(&mut rx) => {
578                    match cmd? {
579                        TimerCmd::Start(time) => {
580                            next_interval = Some(time);
581                            break;
582                        }
583                        TimerCmd::Stop => break,
584                    }
585                }
586                _ = timer.tick() => {
587                    tx.send(Message::DeviceRefreshTick).await?;
588                }
589            }
590        }
591    }
592}
593
594pub async fn run<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
595    let mut stream = EventStream::new();
596
597    let mut tasks = JoinSet::new();
598
599    let timer_rx = app.timer_rx.take().context("Timer already started")?;
600    tasks.spawn(run_timer(timer_rx, app.events_tx.clone()));
601
602    loop {
603        terminal.draw(|f| ui(f, app))?;
604
605        tokio::select! {
606            event = stream.next() => {
607                let Some(event) = event else { break };
608                let event = event.context("Failed to read terminal input")?;
609                match handle_key(terminal, app, event).await? {
610                    Some(Action::Shutdown) => break,
611                    Some(Action::Clear) => {
612                        terminal.clear()?;
613                    },
614                    None => (),
615                }
616            }
617            event = app.events_rx.recv() => {
618                let Some(event) = event else { break };
619                debug!("Received message from channel: event={event:?}");
620                match event {
621                    Message::Suspicion(sus) => {
622                        if let Some(scan) = &mut app.scan {
623                            scan.findings.push(sus);
624                            scan.findings.sort_by(|a, b| {
625                                a.level.cmp(&b.level)
626                                    .reverse()
627                                    .then(a.description.cmp(&b.description))
628                            });
629                        }
630                    }
631                    Message::App { name, sus } => {
632                        if let Some(scan) = &mut app.scan {
633                            scan.apps.entry(name).or_default().push(sus);
634                            scan.apps.sort_by(|k1, v1, k2, v2| {
635                                v1.cmp(v2)
636                                    .reverse()
637                                    .then(k1.cmp(k2))
638                            });
639                        }
640                    }
641                    Message::StartDownload => {
642                        let events_tx = app.events_tx.clone();
643                        let repo = app.repository.clone();
644
645                        let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
646                        tokio::spawn(async move {
647                            let mut interval = time::interval(ACTIVITY_TICK_INTERVAL);
648                            let download = run_download(repo);
649                            tokio::pin!(download);
650
651                            loop {
652                                tokio::select! {
653                                    _ = cancel_rx.recv() => {
654                                        debug!("Download has been canceled");
655                                        events_tx.send(Message::DownloadEnded(None)).await.ok();
656                                        break;
657                                    }
658                                    ret = &mut download => {
659                                        let repo = match ret {
660                                            Ok(repo) => {
661                                                debug!("Download has completed");
662                                                Some(repo)
663                                            }
664                                            Err(err) => {
665                                                error!("Download has failed: {err:?}"); // TODO print errors in UI
666                                                None
667                                            }
668                                        };
669                                        events_tx.send(Message::DownloadEnded(repo)).await.ok();
670                                        break;
671                                    }
672                                    _ = interval.tick() => {
673                                        events_tx.send(Message::DownloadTick).await.ok();
674                                    }
675                                }
676                            }
677                        });
678                        app.download = Some(Download {
679                            cancel: Some(cancel_tx),
680                            ..Default::default()
681                        });
682                    }
683                    Message::ScanTick => {
684                        if let Some(scan) = &mut app.scan {
685                            scan.spinner.activity_tick();
686                        }
687                    }
688                    Message::ScanEnded => {
689                        if let Some(scan) = &mut app.scan {
690                            scan.cancel.take();
691                        }
692                    }
693                    Message::DownloadTick => {
694                        if let Some(download) = &mut app.download {
695                            download.spinner.activity_tick();
696                        }
697                    }
698                    Message::DownloadEnded(repo) => {
699                        app.download.take();
700                        if let Some(repo) = repo {
701                            app.repository = repo;
702                        }
703                    }
704                    Message::DeviceRefreshTick => {
705                        app.refresh_devices().await?;
706                    }
707                    Message::DatabaseUpdateAvailable(update_available) => {
708                        if let Some(content) = &mut app.repository.content {
709                            content.last_update_check = utils::now();
710                            content.update_available = update_available;
711                            app.repository.write_database_file()
712                                .await
713                                .context("Failed to write database file")?;
714                        }
715                    }
716                }
717            }
718            res = tasks.join_next() => {
719                bail!("Task has crashed: {res:?}");
720            }
721        }
722    }
723
724    Ok(())
725}
726
727fn cursor<'a, T: IntoIterator<Item = Span<'a>>>(msg: T, selected: bool) -> (Line<'a>, Style) {
728    let mut style = Style::default();
729    if selected {
730        style = style.bg(DARK_GREY);
731    }
732
733    let mut row = vec![Span::styled(
734        if selected { " > " } else { "   " },
735        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
736    )];
737    row.extend(msg);
738
739    (Line::from(row), style)
740}
741
742pub fn ui(f: &mut Frame<'_>, app: &App) {
743    let white = Style::default().fg(Color::White).bg(Color::Black);
744
745    let chunks = Layout::default()
746        .direction(Direction::Vertical)
747        .constraints(
748            [
749                Constraint::Length(1),
750                Constraint::Length(1),
751                Constraint::Min(1),
752                Constraint::Length(1),
753            ]
754            .as_ref(),
755        )
756        .split(f.area());
757
758    f.render_widget(render_help_widget(app), chunks[0]);
759    f.render_widget(Block::default().style(white), chunks[1]);
760    f.render_widget(render_app_widget(app), chunks[2]);
761    f.render_widget(render_statusline_widget(app), chunks[3]);
762}
763
764fn render_help_widget(app: &App) -> Paragraph {
765    let white = Style::default().fg(Color::White).bg(Color::Black);
766    let mut text = Vec::new();
767
768    if let Some(scan) = &app.scan {
769        if scan.cancel.is_some() {
770            text.push(scan.spinner.render());
771            text.push(Span::raw(" scanning - "));
772        }
773    }
774
775    if let Some(download) = &app.download {
776        if download.cancel.is_some() {
777            text.push(download.spinner.render());
778            text.push(Span::raw(" downloading - "));
779        }
780    }
781
782    if text.is_empty() {
783        text.push(Span::raw("idle - "));
784    }
785
786    text.extend([
787        Span::raw("Press "),
788        Span::styled("ESC", Style::default().add_modifier(Modifier::BOLD)),
789        Span::raw(" to exit - "),
790        Span::raw(concat!(
791            env!("CARGO_PKG_NAME"),
792            " v",
793            env!("CARGO_PKG_VERSION")
794        )),
795    ]);
796    let text = Text::from(Line::from(text));
797    Paragraph::new(text)
798        .style(white)
799        .alignment(Alignment::Right)
800}
801
802fn render_app_widget(app: &App) -> List {
803    let white = Style::default().fg(Color::White).bg(Color::Black);
804
805    if let Some(scan) = &app.scan {
806        let mut list = Vec::new();
807        let mut i = 0;
808
809        for sus in &scan.findings {
810            let selected = i == app.cursor;
811            let row = sus.to_terminal();
812            let (content, style) = cursor(row, selected);
813            list.push(ListItem::new(content).style(style));
814            i += 1;
815        }
816
817        for (name, findings) in &scan.apps {
818            let selected = i == app.cursor;
819            let is_expanded = scan.expanded.contains(name);
820
821            let mut row = Vec::new();
822            row.push(Span::styled(
823                if is_expanded { "[-]" } else { "[+]" },
824                Style::default().add_modifier(Modifier::BOLD),
825            ));
826            row.push(Span::raw(format!(" App {name:?} (")));
827
828            let mut details = Vec::new();
829            if !findings.high.is_empty() {
830                details.push(Span::styled(
831                    format!("{} high", findings.high.len()),
832                    SuspicionLevel::High.terminal_color(),
833                ));
834            }
835
836            if !findings.medium.is_empty() {
837                details.push(Span::styled(
838                    format!("{} medium", findings.medium.len()),
839                    SuspicionLevel::Medium.terminal_color(),
840                ));
841            }
842
843            if !findings.low.is_empty() {
844                details.push(Span::styled(
845                    format!("{} low", findings.low.len()),
846                    SuspicionLevel::Low.terminal_color(),
847                ));
848            }
849
850            for (i, value) in details.into_iter().enumerate() {
851                if i > 0 {
852                    row.push(Span::raw(", "));
853                }
854                row.push(value);
855            }
856
857            row.push(Span::raw(")"));
858
859            let (content, style) = cursor(row, selected);
860            list.push(ListItem::new(content).style(style));
861
862            i += 1;
863
864            // show app details if expanded
865            if is_expanded {
866                for sus in findings.iter() {
867                    let selected = i == app.cursor;
868                    let mut row = vec![Span::raw("    ")];
869                    row.extend(sus.to_terminal());
870                    let (content, style) = cursor(row, selected);
871                    list.push(ListItem::new(content).style(style));
872                    i += 1;
873                }
874            }
875        }
876
877        // scrolling
878        let list = list.into_iter().skip(app.offset);
879
880        let title = Span::styled("Findings", white.add_modifier(Modifier::BOLD));
881        List::new(list).block(
882            Block::default()
883                .borders(Borders::ALL)
884                .style(white)
885                .border_style(Style::default().fg(Color::Green))
886                .title(title),
887        )
888    } else {
889        let devices: Vec<ListItem> = app
890            .devices
891            .iter()
892            .enumerate()
893            .map(|(i, device)| {
894                let selected = i == app.cursor;
895
896                let msg = format!(
897                    "{:30} device={:?}, model={:?}, product={:?}",
898                    device.serial,
899                    utils::human_option_str(device.info.get("device")),
900                    utils::human_option_str(device.info.get("model")),
901                    utils::human_option_str(device.info.get("product")),
902                );
903
904                let (content, style) = cursor([Span::raw(msg)], selected);
905                ListItem::new(content).style(style)
906            })
907            .collect();
908
909        let title = Span::styled("Connected devices", white.add_modifier(Modifier::BOLD));
910        List::new(devices).block(
911            Block::default()
912                .borders(Borders::ALL)
913                .style(white)
914                .border_style(Style::default().fg(Color::Green))
915                .title(title),
916        )
917    }
918}
919
920fn render_statusline_widget(app: &App) -> Paragraph {
921    let white = Style::default().fg(Color::White).bg(Color::Black);
922    let green = Style::default().fg(Color::Green).bg(Color::Black);
923    let yellow = Style::default().fg(Color::Yellow).bg(Color::Black);
924    let mut text = Vec::new();
925
926    if let Some(content) = &app.repository.content {
927        text.push(Span::raw("ioc-git:"));
928        text.push(Span::styled(&content.git_commit, green));
929        text.push(Span::raw(" released:"));
930        text.push(Span::styled(
931            utils::format_datetime(content.released),
932            green,
933        ));
934
935        if content.update_available {
936            text.push(Span::styled(
937                " (database update available, press ctrl+R)",
938                yellow,
939            ));
940        }
941    }
942
943    let text = Text::from(Line::from(text));
944    Paragraph::new(text)
945        .style(white)
946        .alignment(Alignment::Right)
947}
948
949pub fn setup() -> Result<Terminal<CrosstermBackend<Stdout>>> {
950    enable_raw_mode()?;
951    let mut stdout = io::stdout();
952    execute!(stdout, EnterAlternateScreen)?;
953    let backend = CrosstermBackend::new(stdout);
954    let terminal = Terminal::new(backend)?;
955    Ok(terminal)
956}
957
958pub fn cleanup(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
959    disable_raw_mode()?;
960    execute!(terminal.backend_mut(), LeaveAlternateScreen,)?;
961    terminal.show_cursor()?;
962    Ok(())
963}