diff --git a/docs/roadmap.md b/docs/roadmap.md index 88ae96c..96f4b2e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -118,7 +118,7 @@ Near-term priorities are therefore: - Improve browser auto-detection so Windows, macOS, and Linux builds can find Chrome / Chromium / Edge more reliably. - Reduce terminal/UI variance by falling back cleanly when ANSI or VT sequences are not supported. - Keep browser-assisted confirmation behavior as consistent as practical across supported desktop platforms. - - Current status: partially implemented; browser auto-detection now checks env overrides, `PATH`, and common install locations across Windows, macOS, and Linux, but terminal capability fallback is still pending. + - Current status: completed for the current scope; browser auto-detection now checks env overrides, `PATH`, and common install locations across Windows, macOS, and Linux, and the progress UI falls back to plain text when ANSI / VT support is unavailable. ## Experimental diff --git a/src/progress.rs b/src/progress.rs index 6dd0232..d16bf11 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -5,6 +5,7 @@ //! * Written to stderr. Cursor hidden while active. ASCII-safe bar chars. use crossterm::terminal; +use std::io::IsTerminal; use std::io::{self, Write}; use std::sync::{ Arc, Mutex, @@ -67,6 +68,35 @@ fn tier(n: usize) -> &'static str { } } +fn stderr_supports_ansi() -> bool { + if !io::stderr().is_terminal() { + return false; + } + + #[cfg(windows)] + { + crossterm::ansi_support::supports_ansi() + } + + #[cfg(not(windows))] + { + std::env::var("TERM") + .map(|term| !term.eq_ignore_ascii_case("dumb")) + .unwrap_or(true) + } +} + +fn smoothed_speed(current_pos: u64, first_pos: u64, elapsed: Duration) -> Option { + let nanos = elapsed.as_nanos(); + if nanos == 0 { + return None; + } + + let delta = u128::from(current_pos.saturating_sub(first_pos)); + let rounded_per_sec = (delta * 1_000_000_000_u128 + nanos / 2) / nanos; + Some(u64::try_from(rounded_per_sec).unwrap_or(u64::MAX)) +} + // ─── public API ─────────────────────────────────────────────────────────────── pub struct LiveBar { @@ -81,6 +111,7 @@ pub struct LiveBar { out: Arc>, potato: bool, output_display: String, + ansi_enabled: bool, /// Terminal width from the previous render tick. prev_cols: Arc, speed_history: Mutex>, @@ -106,6 +137,7 @@ impl LiveBar { out: Arc::new(Mutex::new(io::stderr())), potato, output_display, + ansi_enabled: stderr_supports_ansi(), prev_cols: Arc::new(AtomicUsize::new(0)), speed_history: Mutex::new(Vec::with_capacity(32)), speed_value: AtomicU64::new(0), @@ -115,8 +147,12 @@ impl LiveBar { /// Print a log line — clears current bar line, prints msg, bar redraws next tick. pub fn println(&self, msg: impl AsRef) { let mut o = self.out.lock().unwrap(); - // Go up past the profile line, clear it, print msg, leave cursor for bar tick. - write!(o, "\x1b[1A\r\x1b[2K{}\n\n", msg.as_ref()).ok(); + if self.ansi_enabled { + // Go up past the profile line, clear it, print msg, leave cursor for bar tick. + write!(o, "\x1b[1A\r\x1b[2K{}\n\n", msg.as_ref()).ok(); + } else { + writeln!(o, "{}", msg.as_ref()).ok(); + } o.flush().ok(); } @@ -124,7 +160,11 @@ impl LiveBar { self.stopped.store(true, Ordering::SeqCst); std::thread::sleep(Duration::from_millis(150)); let mut o = self.out.lock().unwrap(); - write!(o, "\x1b[?25h\r\x1b[2K{}\n", msg.as_ref()).ok(); + if self.ansi_enabled { + write!(o, "\x1b[?25h\r\x1b[2K{}\n", msg.as_ref()).ok(); + } else { + writeln!(o, "\r{}", msg.as_ref()).ok(); + } o.flush().ok(); } @@ -132,9 +172,11 @@ impl LiveBar { let bar = Arc::clone(self); { let mut o = bar.out.lock().unwrap(); - // main.rs already printed a blank line (profile slot). - // Write one more \n to reserve the bar slot; hide cursor. - write!(o, "\n\x1b[?25l").ok(); + if bar.ansi_enabled { + // main.rs already printed a blank line (profile slot). + // Write one more \n to reserve the bar slot; hide cursor. + write!(o, "\n\x1b[?25l").ok(); + } o.flush().ok(); } std::thread::spawn(move || { @@ -147,7 +189,11 @@ impl LiveBar { loop { if bar.stopped.load(Ordering::Relaxed) { if let Ok(mut o) = bar.out.lock() { - write!(o, "\x1b[?25h\r\x1b[2K").ok(); + if bar.ansi_enabled { + write!(o, "\x1b[?25h\r\x1b[2K").ok(); + } else { + writeln!(o).ok(); + } o.flush().ok(); } break; @@ -159,6 +205,7 @@ impl LiveBar { }) } + #[allow(clippy::too_many_lines)] fn render(&self, spinner: &str) { let (cols, _) = terminal::size().unwrap_or((120, 30)); let cols = cols as usize; @@ -187,9 +234,11 @@ impl LiveBar { } if let Some(&(first_t, first_pos)) = history.first() { - let dt = now.saturating_duration_since(first_t).as_secs_f64(); - if dt >= 0.25 { - speed = (pos.saturating_sub(first_pos) as f64 / dt).round() as u64; + let dt = now.saturating_duration_since(first_t); + if dt >= Duration::from_millis(250) + && let Some(next_speed) = smoothed_speed(pos, first_pos, dt) + { + speed = next_speed; self.speed_value.store(speed, Ordering::Relaxed); } } @@ -264,6 +313,14 @@ impl LiveBar { let bar_raw = format!(" {sp_col} [{elapsed_str}] [{bar_col}{right_col}"); let bar_line = fit_to_width(&bar_raw, max_vis); + if !self.ansi_enabled { + let mut o = self.out.lock().unwrap(); + write!(o, "\r[{elapsed_str}] {pct:>3}% | {pos}/{} | ok={ok} blocked={blocked} dead={dead} | {speed}/s | ETA: {eta_str} ", self.total).ok(); + o.flush().ok(); + self.prev_cols.store(cols, Ordering::Relaxed); + return; + } + // ── erase old 2-line block, redraw ─────────────────────────── // // Both previous lines were truncated to (prev_cols - 1) columns. diff --git a/src/scanner/analysis.rs b/src/scanner/analysis.rs index fc6ae23..cc68ffc 100644 --- a/src/scanner/analysis.rs +++ b/src/scanner/analysis.rs @@ -484,11 +484,11 @@ fn annotate_reason(reason: &str, note: &str) -> String { } } -fn annotate_signal(existing: Option, note: String) -> Option { +fn annotate_signal(existing: Option, note: String) -> String { match existing { - Some(existing) if existing.contains(¬e) => Some(existing), - Some(existing) => Some(format!("{existing}; {note}")), - None => Some(note), + Some(existing) if existing.contains(¬e) => existing, + Some(existing) => format!("{existing}; {note}"), + None => note, } } @@ -513,7 +513,7 @@ pub(crate) fn apply_dns_evidence_adjustment( let kind = dns_failure_kind(network_evidence.dns.detail.as_deref()).unwrap_or("failed"); let note = format!("system DNS {kind} while DoH resolved"); result.reason = annotate_reason(&result.reason, ¬e); - result.evidence.signal = annotate_signal(result.evidence.signal.take(), note.clone()); + result.evidence.signal = Some(annotate_signal(result.evidence.signal.take(), note.clone())); let strong_dns_failure = matches!(kind, "nxdomain" | "servfail" | "timeout" | "refused"); @@ -563,7 +563,7 @@ pub(crate) fn apply_dns_evidence_adjustment( .unwrap_or_default() ); result.reason = annotate_reason(&result.reason, ¬e); - result.evidence.signal = annotate_signal(result.evidence.signal.take(), note); + result.evidence.signal = Some(annotate_signal(result.evidence.signal.take(), note)); if (tcp_failed || tls_failed) && matches!( diff --git a/src/scanner/browser.rs b/src/scanner/browser.rs index 39e2c79..deb3a8b 100644 --- a/src/scanner/browser.rs +++ b/src/scanner/browser.rs @@ -35,16 +35,17 @@ fn existing_path(path: impl Into) -> Option { fn path_exts() -> Vec { if cfg!(target_os = "windows") { - std::env::var_os("PATHEXT") - .map(|value| { + std::env::var_os("PATHEXT").map_or_else( + || vec![".exe".into(), ".cmd".into(), ".bat".into()], + |value| { value .to_string_lossy() .split(';') .filter(|ext| !ext.is_empty()) - .map(|ext| ext.to_ascii_lowercase()) + .map(str::to_ascii_lowercase) .collect() - }) - .unwrap_or_else(|| vec![".exe".into(), ".cmd".into(), ".bat".into()]) + }, + ) } else { Vec::new() } @@ -52,8 +53,14 @@ fn path_exts() -> Vec { fn candidate_file_names(name: &str) -> Vec { if cfg!(target_os = "windows") { - let lower = name.to_ascii_lowercase(); - let has_ext = lower.ends_with(".exe") || lower.ends_with(".cmd") || lower.ends_with(".bat"); + let has_ext = Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + ext.eq_ignore_ascii_case("exe") + || ext.eq_ignore_ascii_case("cmd") + || ext.eq_ignore_ascii_case("bat") + }); if has_ext { vec![name.to_string()] } else { @@ -267,7 +274,7 @@ pub(crate) async fn run_browser_dom_dump( }; let handler_task: JoinHandle<()> = - tokio::task::spawn(async move { while let Some(_) = handler.next().await {} }); + tokio::task::spawn(async move { while handler.next().await.is_some() {} }); let page_result = async { let page = browser.new_page("about:blank").await?; diff --git a/src/scanner/comparison.rs b/src/scanner/comparison.rs index 48ce90d..3c727ed 100644 --- a/src/scanner/comparison.rs +++ b/src/scanner/comparison.rs @@ -209,20 +209,7 @@ pub(crate) fn compare_result_pair(local: &ScanResult, control: &ScanResult) -> C verdict_label(local.verdict), verdict_label(control.verdict) ), - ComparisonDecision::NeedsReview => { - if local.routing_decision != RoutingDecision::DirectOk - && control.routing_decision != RoutingDecision::DirectOk - && control_blocked_is_weak - { - classify_needs_review_reason(local, control) - } else if control.routing_decision == RoutingDecision::DirectOk - && !control_supports_direct - { - classify_needs_review_reason(local, control) - } else { - classify_needs_review_reason(local, control) - } - } + ComparisonDecision::NeedsReview => classify_needs_review_reason(local, control), }; let reason = if network_notes.is_empty() { reason @@ -350,6 +337,7 @@ pub fn compare_with_control( comparisons } +#[allow(clippy::too_many_lines)] pub fn write_control_comparison_report( comparisons: &[ComparisonResult], output_path: &Path,