corbel/src/bin/gui.rs

1053 lines
38 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! # CorbelPurge GUI
//!
//! iced 0.13 dashboard GUI for the CorbelPurge security pipeline.
//!
//! Only built when the `gui` feature is enabled:
//!
//! ```bash
//! cargo build --release --features gui --bin corbel-purge-gui
//! ```
//!
//! Layout (matches the design mockup):
//! - 48 px header brand + version + license badge
//! - Collapsible left panel PATHS / OPTIONS / CONSOLE sections
//! - 280 px right sidebar progress gauge, stats, RUN CONFIG
//! - 56 px footer action buttons + status bar
use std::path::PathBuf;
use corbel_purge::{Config, Pipeline, PipelineResult, ThreatClassification};
use iced::widget::{
button, column, container, row, scrollable, Space, text, text_input, toggler,
};
use iced::{
Alignment, Border, Color, Element, Length, Padding, Task, Theme,
};
use iced::widget::text::Style as TextStyle;
use iced::widget::button::Style as ButtonStyle;
// ---------------------------------------------------------------------------
// Colour palette
// ---------------------------------------------------------------------------
mod colors {
use super::Color;
pub fn bg() -> Color { Color::from([0.051, 0.051, 0.051]) }
pub fn panel() -> Color { Color::from([0.10, 0.10, 0.10 ]) }
pub fn panel_hi() -> Color { Color::from([0.13, 0.13, 0.13 ]) }
pub fn header_bg() -> Color { Color::from([0.07, 0.07, 0.07 ]) }
pub fn footer_bg() -> Color { Color::from([0.07, 0.07, 0.07 ]) }
pub fn gold() -> Color { Color::from([0.83, 0.69, 0.22 ]) }
pub fn white() -> Color { Color::from([0.92, 0.92, 0.92 ]) }
pub fn dim() -> Color { Color::from([0.55, 0.55, 0.58 ]) }
pub fn green() -> Color { Color::from([0.35, 0.85, 0.45 ]) }
pub fn red() -> Color { Color::from([0.90, 0.30, 0.30 ]) }
pub fn yellow() -> Color { Color::from([0.95, 0.75, 0.25 ]) }
pub fn teal() -> Color { Color::from([0.20, 0.65, 0.60 ]) }
pub fn input_bg() -> Color { Color::from([0.12, 0.12, 0.12 ]) }
pub fn border_dim() -> Color { Color::from([0.22, 0.22, 0.22 ]) }
pub fn console_bg() -> Color { Color::from([0.06, 0.06, 0.06 ]) }
}
// ---------------------------------------------------------------------------
// Style helpers (iced 0.13 closure-based API)
// ---------------------------------------------------------------------------
fn color_style(c: Color) -> impl Fn(&Theme) -> TextStyle {
move |_t: &Theme| TextStyle { color: Some(c) }
}
fn panel_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::panel().into()),
border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() },
..iced::widget::container::Style::default()
}
}
fn header_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::header_bg().into()),
..iced::widget::container::Style::default()
}
}
fn footer_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::footer_bg().into()),
border: Border { color: colors::border_dim(), width: 1.0, radius: 0.0.into() },
..iced::widget::container::Style::default()
}
}
fn console_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::console_bg().into()),
border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() },
..iced::widget::container::Style::default()
}
}
fn section_hdr_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::panel_hi().into()),
..iced::widget::container::Style::default()
}
}
/// iced 0.13 button style closures must accept `(&Theme, button::Status)`.
fn gold_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
background: Some(colors::gold().into()),
text_color: Color::BLACK,
border: Border { color: colors::gold(), width: 0.0, radius: 4.0.into() },
..ButtonStyle::default()
}
}
fn dim_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
background: Some(colors::panel_hi().into()),
text_color: colors::dim(),
border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() },
..ButtonStyle::default()
}
}
fn teal_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
background: Some(colors::teal().into()),
text_color: Color::BLACK,
border: Border { color: colors::teal(), width: 0.0, radius: 4.0.into() },
..ButtonStyle::default()
}
}
fn green_badge_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::green().into()),
border: Border { color: colors::green(), width: 1.0, radius: 3.0.into() },
..iced::widget::container::Style::default()
}
}
fn separator_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::border_dim().into()),
..iced::widget::container::Style::default()
}
}
/// Shorthand: coloured text.
///
/// `size` is `f32` to match iced 0.13's `Text::size` signature.
fn t(content: impl ToString, size: f32, color: Color) -> iced::widget::Text<'static> {
text(content.to_string()).size(size).style(color_style(color))
}
/// Shorthand: f32 padding (iced 0.13 Padding fields are f32).
fn pad(t: f32, r: f32, b: f32, l: f32) -> Padding {
Padding { top: t, right: r, bottom: b, left: l }
}
// ---------------------------------------------------------------------------
// Application
// ---------------------------------------------------------------------------
fn main() -> iced::Result {
iced::application("CorbelPurge", update, view)
.theme(|_| Theme::Dark)
.window_size((1100.0, 750.0))
.run()
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
#[derive(Debug, Default)]
struct CorbelGui {
input_path: String,
output_path: String,
preserve_format: bool,
strip_metadata: bool,
recursive: bool,
abort_on_threat: bool,
paths_open: bool,
options_open: bool,
console_open: bool,
about_open: bool,
console_lines: Vec<ConsoleLine>,
is_processing: bool,
last_result: Option<PipelineResult>,
cleansed_content: Option<String>,
error: Option<String>,
processed: u32,
cleaned: u32,
copied: u32,
errors: u32,
}
#[derive(Debug, Clone)]
struct ConsoleLine {
timestamp: String,
tag: String,
message: String,
is_error: bool,
}
// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
enum Message {
InputPathChanged(String),
OutputPathChanged(String),
BrowseInput,
BrowseOutput,
PreserveFormatToggled(bool),
StripMetadataToggled(bool),
RecursiveToggled(bool),
AbortOnThreatToggled(bool),
TogglePaths,
ToggleOptions,
ToggleConsole,
ToggleAbout,
CloseAbout,
StartProcessing,
StopProcessing,
ClearLog,
FilePickedInput(Option<PathBuf>),
FilePickedOutput(Option<PathBuf>),
ScanCompleted(Result<PipelineResult, String>),
OpenQuarantineFolder,
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
fn update(state: &mut CorbelGui, msg: Message) -> Task<Message> {
match msg {
Message::InputPathChanged(s) => { state.input_path = s; Task::none() }
Message::OutputPathChanged(s) => { state.output_path = s; Task::none() }
Message::BrowseInput => Task::perform(pick_file(), Message::FilePickedInput),
Message::BrowseOutput => Task::perform(pick_folder(), Message::FilePickedOutput),
Message::FilePickedInput(p) => { if let Some(p) = p { state.input_path = p.display().to_string(); } Task::none() }
Message::FilePickedOutput(p) => { if let Some(p) = p { state.output_path = p.display().to_string(); } Task::none() }
Message::PreserveFormatToggled(b) => { state.preserve_format = b; Task::none() }
Message::StripMetadataToggled(b) => { state.strip_metadata = b; Task::none() }
Message::RecursiveToggled(b) => { state.recursive = b; Task::none() }
Message::AbortOnThreatToggled(b) => { state.abort_on_threat = b; Task::none() }
Message::TogglePaths => { state.paths_open = !state.paths_open; Task::none() }
Message::ToggleOptions => { state.options_open = !state.options_open; Task::none() }
Message::ToggleConsole => { state.console_open = !state.console_open; Task::none() }
Message::ToggleAbout => { state.about_open = !state.about_open; Task::none() }
Message::CloseAbout => { state.about_open = false; Task::none() }
Message::ClearLog => { state.console_lines.clear(); state.processed = 0; state.cleaned = 0; state.copied = 0; state.errors = 0; Task::none() }
Message::StopProcessing => { state.is_processing = false; Task::none() }
Message::StartProcessing => {
if state.input_path.is_empty() {
state.console_lines.push(ConsoleLine {
timestamp: now_hms(),
tag: "ERROR".into(),
message: "No input path specified.".into(),
is_error: true,
});
state.errors += 1;
return Task::none();
}
state.is_processing = true;
state.error = None;
let path = PathBuf::from(&state.input_path);
let ws = if state.output_path.is_empty() { None } else { Some(PathBuf::from(&state.output_path)) };
let abort = state.abort_on_threat;
let pf = state.preserve_format;
state.console_lines.push(ConsoleLine {
timestamp: now_hms(),
tag: "INFO".into(),
message: format!("corbel-purge v{} starting...", env!("CARGO_PKG_VERSION")),
is_error: false,
});
Task::perform(run_pipeline(path, ws, abort, pf), Message::ScanCompleted)
}
Message::ScanCompleted(result) => {
state.is_processing = false;
state.processed += 1;
match result {
Ok(pr) => {
let malicious = pr.scan_report.malicious_count();
let total = pr.scan_report.findings.len();
let src = pr.source_path.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<memory>".into());
if malicious > 0 {
state.console_lines.push(ConsoleLine {
timestamp: now_hms(),
tag: "ERROR".into(),
message: format!("{} — {} finding(s) — cleaned", src, total),
is_error: true,
});
state.cleaned += 1;
state.errors += 1;
} else {
state.console_lines.push(ConsoleLine {
timestamp: now_hms(),
tag: "OK".into(),
message: format!("{} clean — copied", src),
is_error: false,
});
state.copied += 1;
}
if let Some(cp) = &pr.cleansed_path {
match std::fs::read_to_string(cp) {
Ok(c) => state.cleansed_content = Some(c),
Err(e) => state.error = Some(format!("read cleansed: {e}")),
}
}
state.last_result = Some(pr);
}
Err(e) => {
state.console_lines.push(ConsoleLine {
timestamp: now_hms(),
tag: "ERROR".into(),
message: e.clone(),
is_error: true,
});
state.errors += 1;
state.error = Some(e);
}
}
Task::none()
}
Message::OpenQuarantineFolder => {
if let Some(r) = &state.last_result {
if let Some(q) = &r.quarantine_path {
if let Some(parent) = q.parent() { open_dir(parent); }
}
}
Task::none()
}
}
}
// ---------------------------------------------------------------------------
// View
// ---------------------------------------------------------------------------
fn view(state: &CorbelGui) -> Element<'_, Message> {
let header = build_header();
let body = build_body(state);
let footer = build_footer(state);
let main = column![header, body, footer].spacing(0);
if state.about_open {
// About overlay takes over the view: dim backdrop + floating panel
// anchored top-right. The main UI is hidden behind the dim layer
// (iced 0.13 has no Stack widget for true non-modal overlays).
build_about_overlay()
} else {
main.into()
}
}
/// 48 px header.
fn build_header<'a>() -> Element<'a, Message> {
let brand = row![
t("[+]", 16.0, colors::gold()),
t("CORBELPURGE", 16.0, colors::gold()),
t(format!(" v{}", env!("CARGO_PKG_VERSION")), 12.0, colors::dim()),
t(" // defensive document research & cleaning", 12.0, colors::dim()),
].spacing(6.0);
let license_badge = container(t("GPL-3.0", 11.0, Color::BLACK))
.padding(pad(2.0, 8.0, 2.0, 8.0))
.style(green_badge_style());
let header_row = row![brand, row![].width(Length::Fill), license_badge]
.align_y(Alignment::Center)
.padding(pad(12.0, 16.0, 12.0, 16.0));
container(header_row)
.width(Length::Fill)
.height(48)
.center_y(Length::Shrink)
.style(header_style())
.into()
}
/// Main body: left panel + right sidebar.
fn build_body(state: &CorbelGui) -> Element<'_, Message> {
let left = build_left_panel(state);
let right = build_right_sidebar(state);
row![left, right].spacing(0).height(Length::Fill).into()
}
/// Collapsible left panel with PATHS / OPTIONS / CONSOLE.
fn build_left_panel(state: &CorbelGui) -> Element<'_, Message> {
let paths_section = collapsible_section(
"PATHS",
state.paths_open,
Message::TogglePaths,
build_paths_content(state),
);
let options_section = collapsible_section(
"OPTIONS",
state.options_open,
Message::ToggleOptions,
build_options_content(state),
);
let console_section = section_console(state);
let left_col = column![paths_section, options_section, console_section]
.spacing(4.0)
.width(Length::Fill);
container(left_col)
.width(Length::Fill)
.height(Length::Fill)
.padding(pad(6.0, 6.0, 6.0, 6.0))
.into()
}
fn build_paths_content(state: &CorbelGui) -> Element<'_, Message> {
let in_row = row![
t("IN", 12.0, colors::gold()),
row![].width(Length::Fill),
button(t("...", 14.0, colors::dim()))
.style(dim_btn_style())
.padding(pad(4.0, 4.0, 4.0, 4.0))
.on_press(Message::BrowseInput),
]
.spacing(8.0)
.align_y(Alignment::Center);
let in_input = text_input("/path/to/input", &state.input_path)
.on_input(Message::InputPathChanged)
.padding(pad(6.0, 6.0, 6.0, 6.0))
.size(12);
let out_row = row![
t("OUT", 12.0, colors::gold()),
row![].width(Length::Fill),
button(t("...", 14.0, colors::dim()))
.style(dim_btn_style())
.padding(pad(4.0, 4.0, 4.0, 4.0))
.on_press(Message::BrowseOutput),
]
.spacing(8.0)
.align_y(Alignment::Center);
let out_input = text_input("/path/to/output (workspace)", &state.output_path)
.on_input(Message::OutputPathChanged)
.padding(pad(6.0, 6.0, 6.0, 6.0))
.size(12);
column![in_row, in_input, out_row, out_input]
.spacing(6.0)
.padding(pad(8.0, 10.0, 8.0, 10.0))
.into()
}
fn build_options_content(state: &CorbelGui) -> Element<'_, Message> {
let t1 = toggler(state.preserve_format)
.label("PRESERVE FORMAT".to_string())
.on_toggle(Message::PreserveFormatToggled);
let t2 = toggler(state.strip_metadata)
.label("STRIP METADATA".to_string())
.on_toggle(Message::StripMetadataToggled);
let t3 = toggler(state.recursive)
.label("RECURSIVE".to_string())
.on_toggle(Message::RecursiveToggled);
let t4 = toggler(state.abort_on_threat)
.label("ABORT ON THREAT".to_string())
.on_toggle(Message::AbortOnThreatToggled);
column![t1, t2, t3, t4]
.spacing(8.0)
.padding(pad(8.0, 10.0, 8.0, 10.0))
.into()
}
/// Console section (always visible, larger).
fn section_console(state: &CorbelGui) -> Element<'_, Message> {
let chevron = if state.console_open { "-" } else { "+" };
let hdr = container(
button(
row![
t(format!("{} # CONSOLE", chevron), 12.0, colors::gold()),
]
.spacing(4.0)
.align_y(Alignment::Center),
)
.style(dim_btn_style())
.padding(pad(4.0, 8.0, 4.0, 8.0))
.on_press(Message::ToggleConsole),
)
.width(Length::Fill)
.style(section_hdr_style())
.padding(pad(0.0, 0.0, 0.0, 0.0));
let console_body: Element<'_, Message> = if state.console_open {
let mut lines_col = column![].spacing(2.0);
for line in &state.console_lines {
let tag_color = if line.is_error { colors::red() } else { colors::green() };
lines_col = lines_col.push(
row![
t(&line.timestamp, 11.0, colors::dim()),
t(" ", 11.0, colors::dim()),
t(format!("[{}]", line.tag), 11.0, tag_color),
t(format!(" {}", line.message), 11.0, colors::white()),
].spacing(0.0),
);
}
if state.console_lines.is_empty() {
lines_col = lines_col.push(
t("# ready.", 12.0, colors::dim()),
);
}
scrollable(lines_col)
.height(Length::Fill)
.width(Length::Fill)
.into()
} else {
column![].into()
};
column![hdr, console_body]
.spacing(0.0)
.width(Length::Fill)
.height(Length::FillPortion(3))
.into()
}
/// 280 px right sidebar.
fn build_right_sidebar(state: &CorbelGui) -> Element<'_, Message> {
let total = state.processed;
let gauge_text = build_gauge(total);
let processed_label = t(format!("{} PROCESSED", total), 11.0, colors::gold());
let stats = column![
stat_row("*", "CLEANED", state.cleaned, colors::gold()),
stat_row(">", "COPIED", state.copied, colors::green()),
stat_row("!", "ERRORS", state.errors, colors::red()),
]
.spacing(10.0)
.padding(pad(12.0, 14.0, 12.0, 14.0));
let in_display = if state.input_path.is_empty() { "(none)".to_string() } else { state.input_path.clone() };
let out_display = if state.output_path.is_empty() { "(cwd)".to_string() } else { state.output_path.clone() };
let mode = if state.preserve_format { "PreserveFormat" } else { "Markdown" };
let run_config = column![
t("RUN CONFIG", 11.0, colors::gold()),
t(format!("IN: {}", in_display), 10.0, colors::dim()),
t(format!("OUT: {}", out_display), 10.0, colors::dim()),
t(format!("MODE: {}", mode), 10.0, colors::dim()),
]
.spacing(4.0)
.padding(pad(8.0, 14.0, 12.0, 14.0));
let findings_block = if let Some(r) = &state.last_result {
let mut fcol = column![t("LAST SCAN", 11.0, colors::gold())].spacing(4.0);
for finding in &r.scan_report.findings {
let class_str = format!("{:?}", finding.classification);
let color = match finding.classification {
ThreatClassification::Malicious(_) => colors::red(),
ThreatClassification::Suspicious => colors::yellow(),
ThreatClassification::EducationalContent => colors::teal(),
ThreatClassification::Benign => colors::dim(),
};
fcol = fcol.push(row![
t("*", 10.0, color),
t(class_str, 9.0, color),
].spacing(4.0));
fcol = fcol.push(t(format!(" {}", finding.location), 9.0, colors::dim()));
// TODO #12: CVE badges — parse CVE tags from context_notes.
let cve_tags = extract_cve_tags(&finding.context_notes);
for tag in &cve_tags {
fcol = fcol.push(
container(t(tag, 8.0, Color::WHITE))
.padding(pad(1.0, 4.0, 1.0, 4.0))
.style(cve_badge_style()),
);
fcol = fcol.push(t(" ", 6.0, colors::dim()));
}
}
if r.scan_report.findings.is_empty() {
fcol = fcol.push(t("(no findings)", 10.0, colors::dim()));
}
container(fcol).padding(pad(8.0, 14.0, 8.0, 14.0)).width(Length::Fill)
} else {
container(column![]).width(Length::Fill)
};
let sidebar_col = column![
column![gauge_text, processed_label]
.spacing(4.0)
.align_x(Alignment::Center)
.padding(pad(16.0, 0.0, 8.0, 0.0)),
container("").height(1).width(Length::Fill).style(separator_style()),
stats,
container("").height(1).width(Length::Fill).style(separator_style()),
run_config,
container("").height(1).width(Length::Fill).style(separator_style()),
findings_block,
// TODO #4: EPUB/Markdown cleansed document viewer.
build_cleansed_viewer(state),
]
.spacing(0.0);
container(scrollable(sidebar_col))
.width(280)
.height(Length::Fill)
.style(panel_style())
.into()
}
/// 56 px footer.
fn build_footer(state: &CorbelGui) -> Element<'_, Message> {
let start_btn = button(
row![
t(">", 14.0, Color::BLACK),
t("START PROCESSING", 12.0, Color::BLACK),
]
.spacing(6.0)
.align_y(Alignment::Center),
)
.style(gold_btn_style())
.padding(pad(8.0, 16.0, 8.0, 16.0))
.on_press_maybe(if state.is_processing { None } else { Some(Message::StartProcessing) });
let stop_btn = button(t("STOP", 12.0, colors::dim()))
.style(dim_btn_style())
.padding(pad(8.0, 16.0, 8.0, 16.0))
.on_press_maybe(if state.is_processing { Some(Message::StopProcessing) } else { None });
let clear_btn = button(
row![
t("x", 13.0, Color::BLACK),
t("CLEAR LOG", 12.0, Color::BLACK),
]
.spacing(6.0)
.align_y(Alignment::Center),
)
.style(teal_btn_style())
.padding(pad(8.0, 16.0, 8.0, 16.0))
.on_press(Message::ClearLog);
let about_btn = button(
row![
t("i", 14.0, colors::dim()),
t("ABOUT / LICENSE", 12.0, colors::dim()),
]
.spacing(6.0)
.align_y(Alignment::Center),
)
.style(dim_btn_style())
.padding(pad(8.0, 16.0, 8.0, 16.0))
.on_press(Message::ToggleAbout);
let buttons = row![start_btn, stop_btn, clear_btn, about_btn]
.spacing(8.0)
.align_y(Alignment::Center);
let status_left = t(
format!("CorbelPurge v{} - Defensive Document Research & Cleaning Tool", env!("CARGO_PKG_VERSION")),
10.0,
colors::dim(),
);
let status_right = t("GPL-3.0-or-later | Jeremy Anderson", 10.0, colors::dim());
let status_row = row![status_left, row![].width(Length::Fill), status_right]
.align_y(Alignment::Center);
let footer_col = column![buttons, status_row]
.spacing(4.0)
.padding(pad(6.0, 16.0, 6.0, 16.0));
container(footer_col)
.width(Length::Fill)
.height(56)
.center_y(Length::Shrink)
.style(footer_style())
.into()
}
// ---------------------------------------------------------------------------
// Reusable widget builders
// ---------------------------------------------------------------------------
fn collapsible_section<'a>(
label: &str,
is_open: bool,
toggle_msg: Message,
content: Element<'a, Message>,
) -> Element<'a, Message> {
let chevron = if is_open { "-" } else { "+" };
let display_label = format!("{} {}", chevron, label);
let hdr = container(
button(t(&display_label, 12.0, colors::gold()))
.style(dim_btn_style())
.padding(pad(4.0, 8.0, 4.0, 8.0))
.on_press(toggle_msg),
)
.width(Length::Fill)
.style(section_hdr_style())
.padding(pad(0.0, 0.0, 0.0, 0.0));
if is_open {
column![hdr, content].spacing(0.0).width(Length::Fill).into()
} else {
column![hdr].width(Length::Fill).into()
}
}
fn stat_row(icon: &str, label: &str, count: u32, color: Color) -> Element<'static, Message> {
row![
t(icon, 16.0, color),
t(label, 11.0, colors::dim()),
row![].width(Length::Fill),
t(count.to_string(), 18.0, color),
]
.spacing(6.0)
.align_y(Alignment::Center)
.into()
}
// ---------------------------------------------------------------------------
// EPUB / Markdown cleansed document viewer (TODO #4 + #5)
// ---------------------------------------------------------------------------
fn build_cleansed_viewer(state: &CorbelGui) -> Element<'_, Message> {
let content = match &state.cleansed_content {
Some(c) => c,
None => return container(column![]).into(),
};
// Simple syntax highlighting for fenced code blocks (TODO #5).
// We highlight code block backgrounds with a darker panel.
let mut highlighted = String::new();
let mut in_code_block = false;
for line in content.lines() {
if line.starts_with("```") {
in_code_block = !in_code_block;
// Keep the fence line as-is.
highlighted.push_str(line);
highlighted.push('\n');
continue;
}
if in_code_block {
// Prefix code lines with a marker that the console-style
// dark background already provides visual distinction.
highlighted.push_str(line);
highlighted.push('\n');
} else {
// Regular Markdown text — pass through.
highlighted.push_str(line);
highlighted.push('\n');
}
}
let viewer_content = scrollable(
column![
t("CLEANSED DOCUMENT", 11.0, colors::gold()),
Space::with_height(4),
t(highlighted, 11.0, colors::white()),
]
.spacing(2.0)
)
.height(Length::FillPortion(4));
container(viewer_content)
.padding(pad(8.0, 10.0, 8.0, 10.0))
.width(Length::Fill)
.into()
}
// ---------------------------------------------------------------------------
// About / License overlay
// ---------------------------------------------------------------------------
//
// Floating info panel anchored to the top-right corner, modelled on the
// ferret about-panel screenshot. Visual style: dark panel, gold border,
// drop shadow, close (X) button in the top-right, structured metadata
// (title, version, description, author, website, license, tech stack,
// copyright).
//
// Implementation note: iced 0.13 has no Stack widget (true non-modal
// overlays landed in 0.14), so we emulate the floating-panel feel with
// a full-window dim backdrop. The panel sits in the top-right corner via
// a row + Space::Fill layout. Close is via the X button in the panel
// header; click-outside-to-close and ESC handling are left as future
// enhancements (would require upgrading to iced 0.14+ or adding a
// keyboard subscription).
fn build_about_panel() -> Element<'static, Message> {
// --- Header row: title + version ... [X] ---
let title_row = row![
t("CORBELPURGE", 18.0, colors::gold()),
t(format!(" v{}", env!("CARGO_PKG_VERSION")), 11.0, colors::dim()),
row![].width(Length::Fill),
button(t("X", 11.0, colors::gold()))
.style(about_close_btn_style())
.padding(pad(2.0, 6.0, 2.0, 6.0))
.on_press(Message::CloseAbout),
]
.spacing(6.0)
.align_y(Alignment::Center);
// --- Description ---
let description = t(
"Strict Rust document sanitizer & threat neutralizer\nfor PDF, EPUB, Markdown, and DOCX.",
11.0,
colors::white(),
);
// --- Metadata rows ---
let meta_row = |label: &str, value: &str, value_color: Color| -> Element<'static, Message> {
row![
t(format!("{}:", label), 10.0, colors::dim()),
t(value.to_string(), 10.0, value_color),
]
.spacing(6.0)
.align_y(Alignment::Center)
.into()
};
let author_row = meta_row("Author", "Jeremy Anderson", colors::white());
let website_row = meta_row("Website", env!("CARGO_PKG_REPOSITORY"), colors::gold());
let license_row = meta_row("License", env!("CARGO_PKG_LICENSE"), colors::white());
// --- Separator (thin horizontal rule) ---
let separator = container(Space::new(Length::Fill, 1.0))
.width(Length::Fill)
.style(separator_style());
// --- Footer: tech stack + copyright ---
let tech_stack = t(
"Built with Rust, iced 0.13, lopdf, pulldown-cmark, and zip.",
9.0,
colors::dim(),
);
let copyright = t(
"Copyright (c) 2026 Jeremy Anderson.",
9.0,
colors::dim(),
);
// --- Assemble panel ---
let panel_body = column![
title_row,
Space::with_height(8),
description,
Space::with_height(12),
author_row,
website_row,
license_row,
Space::with_height(10),
separator,
Space::with_height(8),
tech_stack,
copyright,
]
.spacing(2.0)
.width(320);
container(panel_body)
.style(about_panel_style())
.padding(pad(16.0, 18.0, 16.0, 18.0))
.width(320)
.into()
}
/// Full-window overlay: dim backdrop + About panel anchored to the top-right
/// corner.
///
/// Click-outside-to-close is sacrificed here because iced 0.13 lacks a true
/// Stack widget (added in 0.14) and wrapping the panel in a no-op button
/// would make the panel's own labels close the modal on click. The X button
/// in the panel header remains the close affordance; ESC handling can be
/// added later via a keyboard subscription.
fn build_about_overlay() -> Element<'static, Message> {
let layout = row![
Space::with_width(Length::Fill),
column![
Space::with_height(20.0),
build_about_panel(),
Space::with_height(Length::Fill),
]
.width(320)
.height(Length::Fill),
Space::with_width(20.0),
]
.align_y(Alignment::Start)
.height(Length::Fill)
.width(Length::Fill);
container(layout)
.width(Length::Fill)
.height(Length::Fill)
.style(about_dim_style())
.into()
}
fn build_gauge(processed: u32) -> Element<'static, Message> {
let bar_width = 16usize;
let filled = ((processed as usize).min(20) * bar_width) / 20;
let empty = bar_width - filled;
let bar_str = format!("{}{}", "#".repeat(filled), "-".repeat(empty));
column![
t(processed.to_string(), 36.0, colors::gold()),
t(bar_str, 10.0, colors::gold()),
]
.align_x(Alignment::Center)
.spacing(4.0)
.into()
}
// ---------------------------------------------------------------------------
// Background tasks
// ---------------------------------------------------------------------------
async fn pick_file() -> Option<PathBuf> {
rfd::AsyncFileDialog::new()
.add_filter("Documents", &["pdf", "epub", "md", "markdown", "docx"])
.set_title("Select input file")
.pick_file()
.await
.map(|h| h.path().to_path_buf())
}
async fn pick_folder() -> Option<PathBuf> {
rfd::AsyncFileDialog::new()
.set_title("Select output directory")
.pick_folder()
.await
.map(|h| h.path().to_path_buf())
}
async fn run_pipeline(
path: PathBuf,
workspace: Option<PathBuf>,
abort_on_threat: bool,
preserve_format: bool,
) -> Result<PipelineResult, String> {
let mut config = match workspace {
Some(p) => Config::with_workspace(p),
None => Config::default(),
};
config.abort_on_threat = abort_on_threat;
if preserve_format {
config.cleanse_mode = corbel_purge::CleanseMode::PreserveFormat;
}
let pipeline = Pipeline::with_config(config);
tokio::task::spawn_blocking(move || pipeline.run(&path))
.await
.map_err(|e| format!("task join error: {e}"))?
.map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// CVE badge helper (TODO #12)
// ---------------------------------------------------------------------------
/// Extract CVE tags (e.g. `[CVE-2017-11882: Equation Editor RCE]`)
/// from a context_notes string.
fn extract_cve_tags(notes: &str) -> Vec<String> {
let mut tags = Vec::new();
let mut search_from = 0;
while let Some(start) = notes[search_from..].find('[') {
let rest = &notes[search_from + start + 1..];
if let Some(end) = rest.find(']') {
let tag = rest[..end].trim();
if tag.starts_with("CVE-") || tag.starts_with("EPUB-") {
// Truncate long descriptions to just the CVE ID.
let short = tag.split(':').next().unwrap_or(tag);
tags.push(short.to_string());
}
search_from += start + 1 + end + 1;
} else {
break;
}
}
tags
}
/// Style for CVE badge containers.
fn cve_badge_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::teal().into()),
border: Border { color: colors::teal(), width: 1.0, radius: 3.0.into() },
..iced::widget::container::Style::default()
}
}
/// Dimmed backdrop behind the About overlay. Subtle alpha so the underlying
/// UI is still partially visible — matches the "floating panel" feel of the
/// ferret reference without requiring iced's Stack widget (added in 0.14).
fn about_dim_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.55).into()),
..iced::widget::container::Style::default()
}
}
/// Style for the About panel itself: dark panel background, gold border,
/// rounded corners — mirrors the ferret about-panel aesthetic.
fn about_panel_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|_t: &Theme| iced::widget::container::Style {
background: Some(colors::panel().into()),
border: Border { color: colors::gold(), width: 1.5, radius: 6.0.into() },
shadow: iced::Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.6),
offset: iced::Vector::new(0.0, 4.0),
blur_radius: 12.0,
},
..iced::widget::container::Style::default()
}
}
/// Style for the small close (X) button in the About panel header.
fn about_close_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
background: Some(colors::panel_hi().into()),
text_color: colors::gold(),
border: Border { color: colors::gold(), width: 1.0, radius: 3.0.into() },
..ButtonStyle::default()
}
}
// Note: a `about_backdrop_btn_style` (transparent, click-outside-to-close
// backdrop) was originally drafted for this overlay but is omitted because
// iced 0.13 cannot layer a button under a sibling widget without making
// the sibling's own clicks bubble up. Click-outside-to-close can be added
// later by upgrading to iced 0.14+ (which has a Stack widget) or by
// adding a keyboard subscription for the Escape key.
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn now_hms() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{:02}:{:02}:{:02}", h, m, s)
}
fn open_dir(path: &std::path::Path) {
#[cfg(target_os = "linux")] { let _ = std::process::Command::new("xdg-open").arg(path).spawn(); }
#[cfg(target_os = "macos")] { let _ = std::process::Command::new("open").arg(path).spawn(); }
#[cfg(target_os = "windows")] { let _ = std::process::Command::new("explorer").arg(path).spawn(); }
}