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);
36const PAGE_MODIFIER: usize = 10;
38const 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
45const 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 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 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 let Some((name, _appinfos)) = scan.apps.get_index(idx) {
449 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); 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:?}"); 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 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 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}