Skip to content
This repository was archived by the owner on Mar 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 67 additions & 10 deletions src/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<u64> {
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 {
Expand All @@ -81,6 +111,7 @@ pub struct LiveBar {
out: Arc<Mutex<io::Stderr>>,
potato: bool,
output_display: String,
ansi_enabled: bool,
/// Terminal width from the previous render tick.
prev_cols: Arc<AtomicUsize>,
speed_history: Mutex<Vec<(Instant, u64)>>,
Expand All @@ -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),
Expand All @@ -115,26 +147,36 @@ impl LiveBar {
/// Print a log line — clears current bar line, prints msg, bar redraws next tick.
pub fn println(&self, msg: impl AsRef<str>) {
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();
}

pub fn finish(&self, msg: impl AsRef<str>) {
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();
}

pub fn start_draw_thread(self: &Arc<Self>) -> std::thread::JoinHandle<()> {
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 || {
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions src/scanner/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,11 +484,11 @@ fn annotate_reason(reason: &str, note: &str) -> String {
}
}

fn annotate_signal(existing: Option<String>, note: String) -> Option<String> {
fn annotate_signal(existing: Option<String>, note: String) -> String {
match existing {
Some(existing) if existing.contains(&note) => Some(existing),
Some(existing) => Some(format!("{existing}; {note}")),
None => Some(note),
Some(existing) if existing.contains(&note) => existing,
Some(existing) => format!("{existing}; {note}"),
None => note,
}
}

Expand All @@ -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, &note);
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");

Expand Down Expand Up @@ -563,7 +563,7 @@ pub(crate) fn apply_dns_evidence_adjustment(
.unwrap_or_default()
);
result.reason = annotate_reason(&result.reason, &note);
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!(
Expand Down
23 changes: 15 additions & 8 deletions src/scanner/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,32 @@ fn existing_path(path: impl Into<PathBuf>) -> Option<PathBuf> {

fn path_exts() -> Vec<String> {
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()
}
}

fn candidate_file_names(name: &str) -> Vec<String> {
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 {
Expand Down Expand Up @@ -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?;
Expand Down
16 changes: 2 additions & 14 deletions src/scanner/comparison.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down