diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 8f6b2c4a0..eaf138b2e 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -14,6 +14,16 @@ use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; +mod dialog; +mod panel; + +#[cfg(test)] +use dialog::detail_lines; +use dialog::graphic_of; +pub(crate) use dialog::{appearance_labels, dialog}; +pub(crate) use panel::{CertAction, banner, cert_viewer, panel, signature_status_style}; +pub use panel::{CertTab, CertViewer, PanelAction, certificate_pem}; + /// A digital ID the app knows about (Acrobat: Digital ID files). The file stays where it is; /// its password is asked for at each signing. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -654,847 +664,6 @@ impl PdfCraftApp { } } -/// Draw the signing dialogs; returns `true` when the dialog should close. -pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { - let Some(step) = app.sign_draft.as_ref().map(|d| d.step) else { return true }; - match step { - SignStep::Choose => choose(ui, app, t), - SignStep::Configure => configure(ui, app, t), - SignStep::SignAs => sign_as(ui, app, t), - } -} - -fn title(ui: &mut egui::Ui, text: &str) { - ui.label(egui::RichText::new(tl!(text)).font(theme::semibold(18.0))); - ui.add_space(4.0); - ui.separator(); - ui.add_space(8.0); -} - -fn error(ui: &mut egui::Ui, err: &Option) { - if let Some(e) = err.as_ref().filter(|e| !e.is_empty()) { - ui.colored_label(Color32::from_rgb(0xD7, 0x37, 0x3F), tl!(e)); - ui.add_space(4.0); - } -} - -/// Draw the digital-ID chooser, including unusable certificates and removal confirmation. -fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { - title(ui, "Sign with a Digital ID"); - ui.label(tl!("Choose the digital ID that you want to use for signing:")); - ui.add_space(6.0); - let ids = app.digital_ids.clone(); - let Some(d) = app.sign_draft.as_mut() else { return true }; - let mut close = false; - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - for (i, e) in ids.iter().enumerate() { - let selected = d.selected == Some(i); - let usable = e.unusable.is_none(); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, usable, selected, &e.name)); - let fill = if selected { - t.accent_soft - } else if resp.hovered() && usable { - t.hover - } else { - Color32::TRANSPARENT - }; - ui.painter().rect_filled(rect, CornerRadius::same(6), fill); - // An unusable ID is greyed, with the reason where the issuer would be. - let (icon, name_color) = if usable { (t.accent, t.text) } else { (t.text_muted, t.text_muted) }; - icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, icon); - // Certificate names can be any length: cut both lines with "…" at the row's edge - // and show them whole on hover. - let line = |text: &str, font: egui::FontId, color: Color32| { - let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color); - job.wrap = egui::text::TextWrapping::truncate_at_width((rect.width() - 50.0).max(0.0)); - ui.painter().layout_job(job) - }; - let name = line(&e.name, theme::semibold(13.0), name_color); - let store = if e.path.starts_with("keychain:") { - tl!("Keychain · ").to_string() - } else if e.path.starts_with("windows:") { - tl!("Windows store · ").to_string() - } else { - String::new() - }; - let sub = match &e.unusable { - Some(reason) => format!("{store}{reason}"), - None => format!( - "{store}{email}{issued}{issuer}{expires}{date}", - email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, - issued = tl!("Issued by: "), - issuer = e.issuer, - expires = tl!(", Expires: "), - date = e.expires, - ), - }; - let details = line(&sub, theme::regular(11.5), t.text_muted); - let elided = name.elided || details.elided; - ui.painter().galley(rect.min + vec2(40.0, 9.0), name, name_color); - ui.painter().galley(rect.min + vec2(40.0, 28.0), details, t.text_muted); - let resp = if elided { resp.on_hover_text(format!("{}\n{sub}", e.name)) } else { resp }; - if !usable { - continue; - } - if resp.clicked() { - d.selected = Some(i); - d.confirm_remove = None; - } - if resp.double_clicked() { - d.step = SignStep::SignAs; - } - } - if ids.is_empty() { - ui.label(egui::RichText::new(tl!("No digital IDs yet.")).color(t.text_muted)); - } - }); - ui.add_space(10.0); - // Remove takes a digital ID file off the list after asking (#1396); OS key store IDs stay. - let mut remove = None; - if let Some(e) = d.confirm_remove.and_then(|i| ids.get(i)) { - ui.label(crate::i18n::fmt(tl!("Remove \"{name}\" from this list? The file itself is not deleted."), &[("name", &e.name)])); - ui.horizontal(|ui| { - if widgets::pill_button(ui, tl!("Remove"), true).clicked() { - remove = d.confirm_remove; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - d.confirm_remove = None; - } - }); - ui.add_space(10.0); - } - let removable = d.selected.filter(|&i| ids.get(i).is_some_and(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:"))); - ui.horizontal(|ui| { - if widgets::pill_button(ui, tl!("Configure New Digital ID"), false).clicked() { - d.step = SignStep::Configure; - d.error = None; - } - if d.confirm_remove.is_none() && ui.add_enabled_ui(removable.is_some(), |ui| widgets::pill_button(ui, tl!("Remove"), false)).inner.clicked() { - d.confirm_remove = removable; - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, tl!("Continue"), true)).inner.clicked() { - d.step = SignStep::SignAs; - d.error = None; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - }); - if let Some(i) = remove { - app.remove_digital_id(i); - } - close -} - -fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { - title(ui, "Configure a Digital ID for Signing"); - let Some(d) = app.sign_draft.as_mut() else { return true }; - let mut close = false; - let mut go = false; - // Browse… is desktop-only. - #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] - let mut browse = false; - widgets::radio_value(ui, &mut d.new_id.create, false, tl!("Use a Digital ID from a file")); - widgets::radio_value(ui, &mut d.new_id.create, true, tl!("Create a new Digital ID (self-signed, saved to a password-protected file)")); - ui.add_space(8.0); - let n = &mut d.new_id; - if n.create { - egui::Grid::new("new-id").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - for (label, value) in [ - (tl!("Name"), &mut n.name), - (tl!("Organizational Unit"), &mut n.unit), - (tl!("Organization Name"), &mut n.organization), - (tl!("Email Address"), &mut n.email), - ] { - let l = ui.label(label); - ui.add(crate::widgets::line(value).desired_width(260.0)).labelled_by(l.id); - ui.end_row(); - } - let l = ui.label(tl!("Country/Region")); - ui.add(crate::widgets::line(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id); - ui.end_row(); - ui.label(tl!("Key Algorithm")); - egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { - for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() { - ui.selectable_value(&mut n.key, i, *label); - } - }); - ui.end_row(); - let l = ui.label(tl!("Password")); - ui.add(crate::widgets::line(&mut n.password).password(true).desired_width(200.0)).labelled_by(l.id); - ui.end_row(); - let l = ui.label(tl!("Confirm Password")); - ui.add(crate::widgets::line(&mut n.confirm).password(true).desired_width(200.0)).labelled_by(l.id); - ui.end_row(); - }); - ui.label( - egui::RichText::new(tl!("Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others.")) - .small() - .color(t.text_muted), - ); - } else { - egui::Grid::new("id-file").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - let l = ui.label(tl!("File (.p12, .pfx)")); - ui.horizontal(|ui| { - ui.add(crate::widgets::line(&mut n.file).desired_width(220.0)).labelled_by(l.id); - #[cfg(not(target_arch = "wasm32"))] - if ui.button(tl!("Browse…")).clicked() { - browse = true; - } - }); - ui.end_row(); - let l = ui.label(tl!("Password")); - ui.add(crate::widgets::line(&mut n.file_password).password(true).desired_width(200.0)).labelled_by(l.id); - ui.end_row(); - }); - } - ui.add_space(6.0); - error(ui, &d.error); - ui.horizontal(|ui| { - if !app.digital_ids.is_empty() && widgets::pill_button(ui, tl!("Back"), false).clicked() { - d.step = SignStep::Choose; - d.error = None; - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, if d.new_id.create { tl!("Save") } else { tl!("Continue") }, true).clicked() { - go = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - }); - if go { - let create = app.sign_draft.as_ref().is_some_and(|d| d.new_id.create); - let result = if create { app.create_digital_id() } else { app.import_digital_id() }; - let Some(d) = app.sign_draft.as_mut() else { return true }; - match result { - Ok(i) => { - d.selected = Some(i); - d.step = SignStep::Choose; - d.error = None; - d.new_id = NewIdDraft::default(); - } - Err(e) => d.error = Some(e), - } - } - // The picker answers on a later frame, into the draft if the same dialog is still open. - #[cfg(not(target_arch = "wasm32"))] - if browse { - let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]); - let epoch = app.dialog_epoch(); - app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| { - // Not one closed meanwhile, or opened again since. - if app.dialog_epoch() == epoch - && let Some(d) = app.sign_draft.as_mut() - { - d.new_id.file = p.to_string_lossy().into_owned(); - } - }); - } - #[cfg(target_arch = "wasm32")] - let _ = browse; - close -} - -/// The appearance preview: Acrobat's standard layout (name left, details right). -/// The saved signature or image `g` stands for, given the saved Fill & Sign `signature`. -fn graphic_of(signature: Option<&SavedSig>, g: &SignGraphic) -> Option { - match g { - SignGraphic::Name => None, - SignGraphic::FillSign => signature.cloned(), - SignGraphic::Image(image) => Some(SavedSig::Image(image.clone())), - } -} - -/// The words of a visible signature's labels in `lang`, and whether they fell back to English. -/// -/// The labels are stamped into the document, so they are all in `lang` or all in English, never a -/// mix. English when `lang` is English; when it is written right to left (its catalog is kept in -/// display order, not the order a PDF stores text in); when one label has no translation; or when -/// the signature's font can't draw one (Chinese, Japanese, Cyrillic, Greek in Helvetica), which -/// signing would refuse. The English words are the sign crate's own, so they can't drift apart. -pub(crate) fn appearance_labels(lang: crate::i18n::Lang) -> (AppearanceLabels, bool) { - let english = AppearanceLabels::default(); - if lang == crate::i18n::Lang::EN { - return (english, false); - } - let said = |s: &str| (!lang.rtl() && crate::i18n::has(lang, s)).then(|| crate::i18n::tr(lang, s).to_string()); - let translated = (|| { - Some(AppearanceLabels { - signed_by: said(&english.signed_by)?, - distinguished_name: said(&english.distinguished_name)?, - reason: said(&english.reason)?, - location: said(&english.location)?, - date: said(&english.date)?, - }) - })(); - match translated.filter(|l| l.first_undrawable().is_none()) { - Some(labels) => (labels, false), - None => (english, true), - } -} - -/// The detail lines of a visible signature, as `sign::pdf` stamps them (the distinguished name -/// aside, which the dialog doesn't know before the ID is opened): the label above the name, then -/// the reason, location and date, each after its label. `reason` and `location` are as typed. -fn detail_lines(a: &Appearance, labels: &AppearanceLabels, name: &str, reason: &str, location: &str, date: &str) -> Vec { - let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() }; - let mut lines = Vec::new(); - if a.name { - if a.labels { - lines.push(labels.signed_by.clone()); - } - lines.push(name.to_string()); - } - if a.reason && !reason.trim().is_empty() { - lines.push(label(&labels.reason, reason.trim())); - } - if a.location && !location.trim().is_empty() { - lines.push(label(&labels.location, location.trim())); - } - if a.date { - lines.push(label(&labels.date, date)); - } - lines.retain(|l| !l.is_empty()); - lines -} - -/// The signature as it will look: the graphic (or the name) on the left, the details beside it -/// (`lines`, from [`detail_lines`] with the labels signing will stamp). -fn preview( - ui: &mut egui::Ui, - t: &Tokens, - name: &str, - a: &Appearance, - lines: &[String], - graphic: Option<&SavedSig>, - cache: &mut Option<(SavedSig, egui::TextureHandle)>, -) { - let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 96.0), egui::Sense::hover()); - let bg = if t.dark() { Color32::from_gray(0xF4) } else { Color32::WHITE }; - ui.painter().rect(rect, CornerRadius::same(4), bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - let inner = rect.shrink(10.0); - // As in the signature itself: a graphic with no details beside it fills the box. - let alone = graphic.is_some() && lines.is_empty(); - let half = Rect::from_min_size(inner.min, vec2(inner.width() * 0.5 - 6.0, inner.height())); - if let Some(g) = graphic { - crate::fill_sign::paint_saved(ui, if alone { inner } else { half }, g, cache, "sign-graphic"); - } else if a.name { - let galley = ui.painter().layout(name.to_string(), theme::regular(22.0), Color32::BLACK, half.width()); - ui.painter().galley(half.left_center() - vec2(0.0, galley.size().y / 2.0), galley, Color32::BLACK); - } - let x = if (a.name || graphic.is_some()) && !alone { inner.center().x + 6.0 } else { inner.left() }; - let galley = ui.painter().layout(lines.join("\n"), theme::regular(11.0), Color32::from_gray(0x20), inner.right() - x); - ui.painter().galley(egui::pos2(x, inner.center().y - galley.size().y / 2.0), galley, Color32::BLACK); -} - -fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { - let ids = app.digital_ids.clone(); - // The time as the signature will show it (signing takes the session clock then). - let date = sign::pdf::display_date(&app.session.signing_date()); - let Some(d) = app.sign_draft.as_mut() else { return true }; - let Some(entry) = d.selected.and_then(|i| ids.get(i)).cloned() else { - d.step = SignStep::Choose; - return false; - }; - title( - ui, - &crate::i18n::fmt( - tl!("{verb} as \"{name}\""), - &[("verb", if d.certify.is_some() { tl!("Certify") } else { tl!("Sign") }), ("name", &entry.name)], - ), - ); - let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:"); - let mut close = false; - let mut go = false; - #[cfg(not(target_arch = "wasm32"))] - let mut pick = false; - if d.rect.is_some() || d.field.is_some() { - ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(12.5))); - let graphic = graphic_of(app.signature.as_ref(), &d.graphic); - let (labels, in_english) = appearance_labels(crate::i18n::current()); - let lines = detail_lines(&d.appearance, &labels, &entry.name, &d.reason, &d.location, &date); - preview(ui, t, &entry.name, &d.appearance, &lines, graphic.as_ref(), &mut app.sign_graphic_preview); - if in_english && d.appearance.labels { - ui.label( - egui::RichText::new(tl!("The labels are in English: the signature's font can't draw them in this language.")) - .small() - .color(t.text_muted), - ); - } - ui.horizontal_wrapped(|ui| { - let a = &mut d.appearance; - ui.add(widgets::checkbox(&mut a.name, tl!("Name"))); - ui.add(widgets::checkbox(&mut a.date, tl!("Date"))); - ui.add(widgets::checkbox(&mut a.reason, tl!("Reason"))); - ui.add(widgets::checkbox(&mut a.location, tl!("Location"))); - ui.add(widgets::checkbox(&mut a.distinguished_name, tl!("Distinguished name"))); - ui.add(widgets::checkbox(&mut a.labels, tl!("Labels"))); - }); - // Acrobat: Configure Signature Appearance, Text, Draw or Image. - ui.horizontal_wrapped(|ui| { - ui.label(tl!("Graphic")); - widgets::radio_value(ui, &mut d.graphic, SignGraphic::Name, tl!("Signer's name")); - ui.add_enabled_ui(app.signature.is_some(), |ui| { - if ui.add(widgets::radio(d.graphic == SignGraphic::FillSign, tl!("Fill & Sign signature"))).clicked() { - d.graphic = SignGraphic::FillSign; - } - }); - ui.add_enabled_ui(d.image.is_some(), |ui| { - if ui.add(widgets::radio(matches!(d.graphic, SignGraphic::Image(_)), tl!("Image"))).clicked() - && let Some(image) = &d.image - { - d.graphic = SignGraphic::Image(image.clone()); - } - }); - #[cfg(not(target_arch = "wasm32"))] - if widgets::pill_button(ui, tl!("Choose image…"), false).clicked() { - pick = true; - } - }); - ui.add_space(6.0); - } else { - ui.label(egui::RichText::new(tl!("The signature will be invisible.")).color(t.text_muted)); - } - egui::Grid::new("sign-as").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - if d.certify.is_some() { - ui.label(tl!("Permitted actions after certifying")); - let mut p = d.certify.unwrap_or(2); - let label = |p: u8| match p { - 1 => tl!("No changes allowed"), - 3 => tl!("Annotations, form fill-in, and digital signatures"), - _ => tl!("Form fill-in and digital signatures"), - }; - egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { - for v in [1u8, 2, 3] { - ui.selectable_value(&mut p, v, label(v)); - } - }); - d.certify = Some(p); - ui.end_row(); - } - let l = ui.label(tl!("Reason")); - ui.add(crate::widgets::line(&mut d.reason).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id); - ui.end_row(); - let l = ui.label(tl!("Location")); - ui.add(crate::widgets::line(&mut d.location).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id); - ui.end_row(); - if in_os_key_store { - ui.label(""); - ui.label( - egui::RichText::new(if entry.path.starts_with("windows:") { - tl!("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it.") - } else { - tl!("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.") - }) - .small() - .color(t.text_muted), - ); - } else { - let l = ui.label(tl!("Digital ID password")); - let r = ui.add(crate::widgets::line(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - go = true; - } - } - ui.end_row(); - }); - ui.add_space(6.0); - error(ui, &d.error); - ui.horizontal(|ui| { - if widgets::pill_button(ui, tl!("Back"), false).clicked() { - d.step = SignStep::Choose; - d.error = None; - } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Sign"), true).clicked() { - go = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - }); - #[cfg(not(target_arch = "wasm32"))] - if pick { - app.pick_sign_graphic_image(); - } - if go { - match app.finish_signing() { - Ok(()) => { - app.sign_draft = None; - return true; - } - Err(e) => { - if let Some(d) = app.sign_draft.as_mut() { - d.error = Some(e); - } - } - } - } - close -} - -/// The message bar for signed documents: (icon, colour, template, argument). -/// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows -/// the UI language; names inside `argument` stay as they are. -pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, &'static str, String)> { - let signed: Vec<&SignatureInfo> = sigs.iter().filter(|s| s.signed).collect(); - if signed.is_empty() { - return None; - } - // A valid certification heads the bar only when every signature is valid: it says nothing - // about the signatures added after it, so their problems come first. - if let Some(c) = signed.iter().find(|s| s.certify.is_some()) - && signed.iter().all(|s| s.status == SignatureStatus::Valid) - { - let by = c.certificate.as_ref().map(|x| { - let org = x.subject.organization().map(|o| format!(", {o}")).unwrap_or_default(); - let who = format!("{}{org}", x.display_name()); - let (template, issuer) = match x.issuer.common_name() { - Some(issuer) => ("{who}, certificate issued by {issuer}.", issuer), - None => ("{who}, certificate issued by an unknown issuer.", ""), - }; - crate::i18n::fmt(tl!(template), &[("who", &who), ("issuer", issuer)]) - }); - return Some(("badge-check", Color32::from_rgb(0x14, 0x73, 0xE6), "Certified by {by}", by.unwrap_or_default())); - } - Some(if signed.iter().any(|s| s.status == SignatureStatus::Invalid) { - ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F), "At least one signature is invalid.", String::new()) - } else if signed.iter().all(|s| s.status == SignatureStatus::Valid) { - ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78), "Signed and all signatures are valid.", String::new()) - } else { - ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19), "At least one signature has problems.", String::new()) - }) -} - -fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) { - if !s.signed { - return ("pen-line", Color32::from_gray(0x80)); - } - signature_status_style(s.status) -} - -/// One look per signature verdict, shared by the Signatures panel and the page badges so -/// the two can't drift apart. -pub(crate) fn signature_status_style(status: SignatureStatus) -> (&'static str, Color32) { - match status { - SignatureStatus::Valid => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)), - SignatureStatus::Unknown => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)), - SignatureStatus::Invalid => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)), - } -} - -/// What the Signatures panel asks the app to do. -#[derive(Clone, Debug)] -pub enum PanelAction { - Validate, - GoTo(usize), - Trust(Box), - ViewSigned { - len: usize, - revision: usize, - }, - Sign(String), - ExportCertificate(Box), - /// Certificate Viewer for the signer's chain (signer first). - ViewCertificate(Vec), -} - -/// The Signatures panel body. -pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec) -> Option { - let mut action = None; - ui.horizontal(|ui| { - if widgets::pill_button(ui, tl!("Validate all"), false).clicked() { - action = Some(PanelAction::Validate); - } - }); - ui.add_space(6.0); - if sigs.is_empty() { - ui.add_space(24.0); - ui.vertical_centered(|ui| { - ui.add(icons::image("signature", 32.0, t.text_faint)); - ui.add_space(6.0); - ui.label(egui::RichText::new(tl!("This document has no signatures.")).color(t.text_muted)); - }); - return action; - } - let mut ordered: Vec<&SignatureInfo> = sigs.iter().collect(); - ordered.sort_by_key(|s| (!s.signed, s.revision)); - for s in ordered { - let open = expanded.contains(&s.field); - let (icon, color) = status_icon(s); - let (template, name) = match (s.signed, s.certify.is_some(), s.signer.as_deref()) { - (false, ..) => ("Unsigned signature field: {name}", s.field.as_str()), - (true, true, Some(signer)) => ("Rev. {rev}: Certified by {name}", signer), - (true, true, None) => ("Rev. {rev}: Certified by an unknown signer", ""), - (true, false, Some(signer)) => ("Rev. {rev}: Signed by {name}", signer), - (true, false, None) => ("Rev. {rev}: Signed by an unknown signer", ""), - }; - let head = crate::i18n::fmt(tl!(template), &[("rev", &s.revision.to_string()), ("name", name)]); - let resp = ui - .horizontal(|ui| { - let chevron = if open { "chevron-down" } else { "chevron-right" }; - let toggle = icons::button(ui, chevron, 20.0, false, if open { "Collapse" } else { "Expand" }).clicked(); - ui.add(icons::image(icon, 16.0, color)); - let l = ui.add(egui::Label::new(egui::RichText::new(&head).font(theme::semibold(12.5)).color(t.text)).sense(egui::Sense::click())); - toggle || l.clicked() - }) - .inner; - if resp { - if open { - expanded.retain(|f| f != &s.field); - } else { - expanded.push(s.field.clone()); - } - } - if !open { - continue; - } - egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 4, top: 2, bottom: 8 }).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 3.0; - if !s.signed { - if widgets::pill_button(ui, tl!("Sign this field…"), true).clicked() { - action = Some(PanelAction::Sign(s.field.clone())); - } - return; - } - ui.label( - egui::RichText::new(match s.status { - SignatureStatus::Valid => tl!("Signature is valid:"), - SignatureStatus::Unknown => tl!("Signature validity is UNKNOWN:"), - SignatureStatus::Invalid => tl!("Signature is INVALID:"), - }) - .font(theme::semibold(12.0)), - ); - for detail in &s.details { - // Each detail is a whole sentence of ours plus what the signature itself says; said - // here in the panel's language, with the signature's own words left as they are. - let line = detail.said(&crate::i18n::words); - ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).font(theme::regular(11.5)).color(t.text_muted)).wrap()); - } - ui.add_space(4.0); - ui.label(egui::RichText::new(tl!("Signature Details")).font(theme::semibold(12.0))); - let row = |ui: &mut egui::Ui, k: &str, v: &str| { - ui.add(egui::Label::new(egui::RichText::new(format!("{}: {v}", tl!(k))).font(theme::regular(11.5))).wrap()); - }; - if let Some(r) = &s.reason { - row(ui, "Reason", r); - } - if let Some(l) = &s.location { - row(ui, "Location", l); - } - if let Some(d) = &s.date { - row(ui, "Signing time", &sign::pdf::display_date(d)); - } - if let Some(a) = &s.algorithm { - row(ui, "Algorithm", a); - } - if let Some(c) = &s.certificate { - row(ui, "Signer", &c.subject.display()); - row(ui, "Issued by", &c.issuer.display()); - row(ui, "Valid", &format!("{} to {}", c.not_before, c.not_after)); - } - match s.page { - Some(p) => { - let link = ui.add( - egui::Label::new( - egui::RichText::new(crate::i18n::fmt( - tl!("Field: {field} on page {p}"), - &[("field", &s.field), ("p", &(p + 1).to_string())], - )) - .font(theme::regular(11.5)) - .color(t.accent_text) - .underline(), - ) - .sense(egui::Sense::click()), - ); - if link.clicked() { - action = Some(PanelAction::GoTo(p)); - } - } - None => row(ui, "Field", &s.field), - } - ui.add_space(4.0); - ui.horizontal_wrapped(|ui| { - // Trust exactly the signer shown above (s.certificate), not the chain top the - // document embedded: trusting an issuer or root is a separate, explicit step in - // the Certificate Viewer's Trust tab, which names that certificate. - if let Some(c) = s.certificate.as_ref() - && s.status == SignatureStatus::Unknown - && ui.button(tl!("Add to trusted certificates")).clicked() - { - action = Some(PanelAction::Trust(Box::new(c.clone()))); - } - if ui.button(tl!("View signed version")).clicked() { - action = Some(PanelAction::ViewSigned { len: s.signed_len, revision: s.revision }); - } - if let Some(c) = &s.certificate - && ui.button(tl!("Show certificate…")).clicked() - { - let mut chain = vec![c.clone()]; - chain.extend(s.chain.iter().filter(|x| x.raw != c.raw).cloned()); - action = Some(PanelAction::ViewCertificate(chain)); - } - if let Some(c) = &s.certificate - && ui.button(tl!("Export certificate…")).clicked() - { - action = Some(PanelAction::ExportCertificate(Box::new(c.clone()))); - } - }); - }); - } - action -} - -/// Certificate Viewer: a chain (the end certificate first) and the tab shown. -#[derive(Clone, Debug)] -pub struct CertViewer { - pub chain: Vec, - pub selected: usize, - pub tab: CertTab, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CertTab { - Summary, - Details, - Trust, -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02X}")).collect::>().join(" ") -} - -fn key_usage(bits: u16) -> String { - const NAMES: [&str; 9] = [ - "Digital Signature", - "Non-Repudiation", - "Key Encipherment", - "Data Encipherment", - "Key Agreement", - "Certificate Signing", - "CRL Signing", - "Encipher Only", - "Decipher Only", - ]; - let used: Vec<&str> = NAMES.iter().enumerate().filter(|(i, _)| bits & (1 << i) != 0).map(|(_, n)| tl!(*n)).collect(); - if used.is_empty() { tl!("None").into() } else { used.join(", ") } -} - -/// What the viewer asks for. -pub(crate) enum CertAction { - Trust(Box), - Export(Box), -} - -/// The Certificate Viewer dialog: the chain on the left, the selected certificate's tabs. -pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Certificate], t: &Tokens) -> (bool, Option) { - title(ui, "Certificate Viewer"); - ui.label(egui::RichText::new(tl!("This dialog shows the details of a certificate and its chain.")).color(t.text_muted)); - ui.add_space(8.0); - let mut action = None; - v.selected = v.selected.min(v.chain.len().saturating_sub(1)); - ui.horizontal_top(|ui| { - // The chain, root at the top (as Acrobat shows it). - ui.vertical(|ui| { - ui.set_width(200.0); - for (depth, i) in (0..v.chain.len()).rev().enumerate() { - let c = &v.chain[i]; - ui.horizontal(|ui| { - ui.add_space(depth as f32 * 12.0); - if ui.selectable_label(v.selected == i, c.display_name()).clicked() { - v.selected = i; - } - }); - } - }); - ui.add_space(16.0); - ui.vertical(|ui| { - ui.set_width(440.0); - let Some(c) = v.chain.get(v.selected).cloned() else { return }; - ui.horizontal(|ui| { - for (tab, label) in [(CertTab::Summary, tl!("Summary")), (CertTab::Details, tl!("Details")), (CertTab::Trust, tl!("Trust"))] { - if widgets::pill_button(ui, label, v.tab == tab).clicked() { - v.tab = tab; - } - } - }); - ui.add_space(8.0); - let grid = |ui: &mut egui::Ui, rows: Vec<(&str, String)>| { - egui::Grid::new(("cert-rows", v.tab as u8)).num_columns(2).spacing([12.0, 5.0]).show(ui, |ui| { - for (k, val) in rows { - ui.label(egui::RichText::new(tl_ctx!("certificate", k)).color(t.text_muted)); - ui.add(egui::Label::new(val).wrap()); - ui.end_row(); - } - }); - }; - match v.tab { - CertTab::Summary => grid( - ui, - vec![ - ("Issued to", c.subject.display()), - ("Issued by", c.issuer.display()), - ("Valid from", c.not_before.to_string()), - ("Valid to", c.not_after.to_string()), - ("Intended usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Any").to_string())), - ], - ), - CertTab::Details => grid( - ui, - vec![ - ("Version", "3".into()), - ("Serial number", c.serial_hex()), - ("Issuer", c.issuer.display()), - ("Subject", c.subject.display()), - ("Validity starts", c.not_before.to_string()), - ("Validity ends", c.not_after.to_string()), - ("Public key", c.public_key.describe()), - ("Basic constraints", tl!(if c.is_ca { "Certificate authority" } else { "End entity" }).to_string()), - ("Key usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Not present").to_string())), - ("Self-signed", if c.is_self_signed() { tl!("Yes").to_string() } else { tl!("No").to_string() }), - ("SHA-1 digest", hex(&sign::keys::DigestAlg::Sha1.digest(&[&c.raw]))), - ("SHA-256 digest", hex(&sign::keys::DigestAlg::Sha256.digest(&[&c.raw]))), - ], - ), - CertTab::Trust => { - let is_trusted = trusted.iter().any(|x| x.raw == c.raw); - let anchored = v.chain.iter().any(|x| trusted.iter().any(|y| y.raw == x.raw)); - ui.label(if is_trusted { - tl!("This certificate is in your list of trusted certificates.") - } else if anchored { - tl!("This certificate is trusted through a certificate above it in the chain.") - } else { - tl!("This certificate is not trusted. Signatures made with it show an unknown identity.") - }); - ui.add_space(8.0); - if !is_trusted && widgets::pill_button(ui, tl!("Add to Trusted Certificates"), false).clicked() { - action = Some(CertAction::Trust(Box::new(c.clone()))); - } - } - } - ui.add_space(8.0); - if widgets::pill_button(ui, tl!("Export…"), false).clicked() { - action = Some(CertAction::Export(Box::new(c.clone()))); - } - }); - }); - ui.add_space(10.0); - let mut close = false; - ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| close = widgets::pill_button(ui, tl!("OK"), true).clicked())); - (close, action) -} - -/// The text of an exported certificate (`.cer`, PEM). -pub fn certificate_pem(c: &Certificate) -> String { - sign::x509::to_pem(c) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/sign_ui/dialog.rs b/crates/ui-egui/src/sign_ui/dialog.rs new file mode 100644 index 000000000..4b327923d --- /dev/null +++ b/crates/ui-egui/src/sign_ui/dialog.rs @@ -0,0 +1,486 @@ +use super::*; + +/// Draw the signing dialogs; returns `true` when the dialog should close. +pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { + let Some(step) = app.sign_draft.as_ref().map(|d| d.step) else { return true }; + match step { + SignStep::Choose => choose(ui, app, t), + SignStep::Configure => configure(ui, app, t), + SignStep::SignAs => sign_as(ui, app, t), + } +} + +pub(super) fn title(ui: &mut egui::Ui, text: &str) { + ui.label(egui::RichText::new(tl!(text)).font(theme::semibold(18.0))); + ui.add_space(4.0); + ui.separator(); + ui.add_space(8.0); +} + +fn error(ui: &mut egui::Ui, err: &Option) { + if let Some(e) = err.as_ref().filter(|e| !e.is_empty()) { + ui.colored_label(Color32::from_rgb(0xD7, 0x37, 0x3F), tl!(e)); + ui.add_space(4.0); + } +} + +/// Draw the digital-ID chooser, including unusable certificates and removal confirmation. +fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { + title(ui, "Sign with a Digital ID"); + ui.label(tl!("Choose the digital ID that you want to use for signing:")); + ui.add_space(6.0); + let ids = app.digital_ids.clone(); + let Some(d) = app.sign_draft.as_mut() else { return true }; + let mut close = false; + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for (i, e) in ids.iter().enumerate() { + let selected = d.selected == Some(i); + let usable = e.unusable.is_none(); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, usable, selected, &e.name)); + let fill = if selected { + t.accent_soft + } else if resp.hovered() && usable { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(6), fill); + // An unusable ID is greyed, with the reason where the issuer would be. + let (icon, name_color) = if usable { (t.accent, t.text) } else { (t.text_muted, t.text_muted) }; + icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 15.0), vec2(20.0, 20.0)), "badge-check", 18.0, icon); + // Certificate names can be any length: cut both lines with "…" at the row's edge + // and show them whole on hover. + let line = |text: &str, font: egui::FontId, color: Color32| { + let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color); + job.wrap = egui::text::TextWrapping::truncate_at_width((rect.width() - 50.0).max(0.0)); + ui.painter().layout_job(job) + }; + let name = line(&e.name, theme::semibold(13.0), name_color); + let store = if e.path.starts_with("keychain:") { + tl!("Keychain · ").to_string() + } else if e.path.starts_with("windows:") { + tl!("Windows store · ").to_string() + } else { + String::new() + }; + let sub = match &e.unusable { + Some(reason) => format!("{store}{reason}"), + None => format!( + "{store}{email}{issued}{issuer}{expires}{date}", + email = if e.email.is_empty() { String::new() } else { format!("{} · ", e.email) }, + issued = tl!("Issued by: "), + issuer = e.issuer, + expires = tl!(", Expires: "), + date = e.expires, + ), + }; + let details = line(&sub, theme::regular(11.5), t.text_muted); + let elided = name.elided || details.elided; + ui.painter().galley(rect.min + vec2(40.0, 9.0), name, name_color); + ui.painter().galley(rect.min + vec2(40.0, 28.0), details, t.text_muted); + let resp = if elided { resp.on_hover_text(format!("{}\n{sub}", e.name)) } else { resp }; + if !usable { + continue; + } + if resp.clicked() { + d.selected = Some(i); + d.confirm_remove = None; + } + if resp.double_clicked() { + d.step = SignStep::SignAs; + } + } + if ids.is_empty() { + ui.label(egui::RichText::new(tl!("No digital IDs yet.")).color(t.text_muted)); + } + }); + ui.add_space(10.0); + // Remove takes a digital ID file off the list after asking (#1396); OS key store IDs stay. + let mut remove = None; + if let Some(e) = d.confirm_remove.and_then(|i| ids.get(i)) { + ui.label(crate::i18n::fmt(tl!("Remove \"{name}\" from this list? The file itself is not deleted."), &[("name", &e.name)])); + ui.horizontal(|ui| { + if widgets::pill_button(ui, tl!("Remove"), true).clicked() { + remove = d.confirm_remove; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + d.confirm_remove = None; + } + }); + ui.add_space(10.0); + } + let removable = d.selected.filter(|&i| ids.get(i).is_some_and(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:"))); + ui.horizontal(|ui| { + if widgets::pill_button(ui, tl!("Configure New Digital ID"), false).clicked() { + d.step = SignStep::Configure; + d.error = None; + } + if d.confirm_remove.is_none() && ui.add_enabled_ui(removable.is_some(), |ui| widgets::pill_button(ui, tl!("Remove"), false)).inner.clicked() { + d.confirm_remove = removable; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(d.selected.is_some(), |ui| widgets::pill_button(ui, tl!("Continue"), true)).inner.clicked() { + d.step = SignStep::SignAs; + d.error = None; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + }); + if let Some(i) = remove { + app.remove_digital_id(i); + } + close +} + +fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { + title(ui, "Configure a Digital ID for Signing"); + let Some(d) = app.sign_draft.as_mut() else { return true }; + let mut close = false; + let mut go = false; + // Browse… is desktop-only. + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] + let mut browse = false; + widgets::radio_value(ui, &mut d.new_id.create, false, tl!("Use a Digital ID from a file")); + widgets::radio_value(ui, &mut d.new_id.create, true, tl!("Create a new Digital ID (self-signed, saved to a password-protected file)")); + ui.add_space(8.0); + let n = &mut d.new_id; + if n.create { + egui::Grid::new("new-id").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (label, value) in [ + (tl!("Name"), &mut n.name), + (tl!("Organizational Unit"), &mut n.unit), + (tl!("Organization Name"), &mut n.organization), + (tl!("Email Address"), &mut n.email), + ] { + let l = ui.label(label); + ui.add(crate::widgets::line(value).desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + } + let l = ui.label(tl!("Country/Region")); + ui.add(crate::widgets::line(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id); + ui.end_row(); + ui.label(tl!("Key Algorithm")); + egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { + for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() { + ui.selectable_value(&mut n.key, i, *label); + } + }); + ui.end_row(); + let l = ui.label(tl!("Password")); + ui.add(crate::widgets::line(&mut n.password).password(true).desired_width(200.0)).labelled_by(l.id); + ui.end_row(); + let l = ui.label(tl!("Confirm Password")); + ui.add(crate::widgets::line(&mut n.confirm).password(true).desired_width(200.0)).labelled_by(l.id); + ui.end_row(); + }); + ui.label( + egui::RichText::new(tl!("Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others.")) + .small() + .color(t.text_muted), + ); + } else { + egui::Grid::new("id-file").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let l = ui.label(tl!("File (.p12, .pfx)")); + ui.horizontal(|ui| { + ui.add(crate::widgets::line(&mut n.file).desired_width(220.0)).labelled_by(l.id); + #[cfg(not(target_arch = "wasm32"))] + if ui.button(tl!("Browse…")).clicked() { + browse = true; + } + }); + ui.end_row(); + let l = ui.label(tl!("Password")); + ui.add(crate::widgets::line(&mut n.file_password).password(true).desired_width(200.0)).labelled_by(l.id); + ui.end_row(); + }); + } + ui.add_space(6.0); + error(ui, &d.error); + ui.horizontal(|ui| { + if !app.digital_ids.is_empty() && widgets::pill_button(ui, tl!("Back"), false).clicked() { + d.step = SignStep::Choose; + d.error = None; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, if d.new_id.create { tl!("Save") } else { tl!("Continue") }, true).clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + }); + if go { + let create = app.sign_draft.as_ref().is_some_and(|d| d.new_id.create); + let result = if create { app.create_digital_id() } else { app.import_digital_id() }; + let Some(d) = app.sign_draft.as_mut() else { return true }; + match result { + Ok(i) => { + d.selected = Some(i); + d.step = SignStep::Choose; + d.error = None; + d.new_id = NewIdDraft::default(); + } + Err(e) => d.error = Some(e), + } + } + // The picker answers on a later frame, into the draft if the same dialog is still open. + #[cfg(not(target_arch = "wasm32"))] + if browse { + let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]); + let epoch = app.dialog_epoch(); + app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| { + // Not one closed meanwhile, or opened again since. + if app.dialog_epoch() == epoch + && let Some(d) = app.sign_draft.as_mut() + { + d.new_id.file = p.to_string_lossy().into_owned(); + } + }); + } + #[cfg(target_arch = "wasm32")] + let _ = browse; + close +} + +/// The appearance preview: Acrobat's standard layout (name left, details right). +/// The saved signature or image `g` stands for, given the saved Fill & Sign `signature`. +pub(super) fn graphic_of(signature: Option<&SavedSig>, g: &SignGraphic) -> Option { + match g { + SignGraphic::Name => None, + SignGraphic::FillSign => signature.cloned(), + SignGraphic::Image(image) => Some(SavedSig::Image(image.clone())), + } +} + +/// The words of a visible signature's labels in `lang`, and whether they fell back to English. +/// +/// The labels are stamped into the document, so they are all in `lang` or all in English, never a +/// mix. English when `lang` is English; when it is written right to left (its catalog is kept in +/// display order, not the order a PDF stores text in); when one label has no translation; or when +/// the signature's font can't draw one (Chinese, Japanese, Cyrillic, Greek in Helvetica), which +/// signing would refuse. The English words are the sign crate's own, so they can't drift apart. +pub(crate) fn appearance_labels(lang: crate::i18n::Lang) -> (AppearanceLabels, bool) { + let english = AppearanceLabels::default(); + if lang == crate::i18n::Lang::EN { + return (english, false); + } + let said = |s: &str| (!lang.rtl() && crate::i18n::has(lang, s)).then(|| crate::i18n::tr(lang, s).to_string()); + let translated = (|| { + Some(AppearanceLabels { + signed_by: said(&english.signed_by)?, + distinguished_name: said(&english.distinguished_name)?, + reason: said(&english.reason)?, + location: said(&english.location)?, + date: said(&english.date)?, + }) + })(); + match translated.filter(|l| l.first_undrawable().is_none()) { + Some(labels) => (labels, false), + None => (english, true), + } +} + +/// The detail lines of a visible signature, as `sign::pdf` stamps them (the distinguished name +/// aside, which the dialog doesn't know before the ID is opened): the label above the name, then +/// the reason, location and date, each after its label. `reason` and `location` are as typed. +pub(super) fn detail_lines(a: &Appearance, labels: &AppearanceLabels, name: &str, reason: &str, location: &str, date: &str) -> Vec { + let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() }; + let mut lines = Vec::new(); + if a.name { + if a.labels { + lines.push(labels.signed_by.clone()); + } + lines.push(name.to_string()); + } + if a.reason && !reason.trim().is_empty() { + lines.push(label(&labels.reason, reason.trim())); + } + if a.location && !location.trim().is_empty() { + lines.push(label(&labels.location, location.trim())); + } + if a.date { + lines.push(label(&labels.date, date)); + } + lines.retain(|l| !l.is_empty()); + lines +} + +/// The signature as it will look: the graphic (or the name) on the left, the details beside it +/// (`lines`, from [`detail_lines`] with the labels signing will stamp). +fn preview( + ui: &mut egui::Ui, + t: &Tokens, + name: &str, + a: &Appearance, + lines: &[String], + graphic: Option<&SavedSig>, + cache: &mut Option<(SavedSig, egui::TextureHandle)>, +) { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 96.0), egui::Sense::hover()); + let bg = if t.dark() { Color32::from_gray(0xF4) } else { Color32::WHITE }; + ui.painter().rect(rect, CornerRadius::same(4), bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let inner = rect.shrink(10.0); + // As in the signature itself: a graphic with no details beside it fills the box. + let alone = graphic.is_some() && lines.is_empty(); + let half = Rect::from_min_size(inner.min, vec2(inner.width() * 0.5 - 6.0, inner.height())); + if let Some(g) = graphic { + crate::fill_sign::paint_saved(ui, if alone { inner } else { half }, g, cache, "sign-graphic"); + } else if a.name { + let galley = ui.painter().layout(name.to_string(), theme::regular(22.0), Color32::BLACK, half.width()); + ui.painter().galley(half.left_center() - vec2(0.0, galley.size().y / 2.0), galley, Color32::BLACK); + } + let x = if (a.name || graphic.is_some()) && !alone { inner.center().x + 6.0 } else { inner.left() }; + let galley = ui.painter().layout(lines.join("\n"), theme::regular(11.0), Color32::from_gray(0x20), inner.right() - x); + ui.painter().galley(egui::pos2(x, inner.center().y - galley.size().y / 2.0), galley, Color32::BLACK); +} + +fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { + let ids = app.digital_ids.clone(); + // The time as the signature will show it (signing takes the session clock then). + let date = sign::pdf::display_date(&app.session.signing_date()); + let Some(d) = app.sign_draft.as_mut() else { return true }; + let Some(entry) = d.selected.and_then(|i| ids.get(i)).cloned() else { + d.step = SignStep::Choose; + return false; + }; + title( + ui, + &crate::i18n::fmt( + tl!("{verb} as \"{name}\""), + &[("verb", if d.certify.is_some() { tl!("Certify") } else { tl!("Sign") }), ("name", &entry.name)], + ), + ); + let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:"); + let mut close = false; + let mut go = false; + #[cfg(not(target_arch = "wasm32"))] + let mut pick = false; + if d.rect.is_some() || d.field.is_some() { + ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(12.5))); + let graphic = graphic_of(app.signature.as_ref(), &d.graphic); + let (labels, in_english) = appearance_labels(crate::i18n::current()); + let lines = detail_lines(&d.appearance, &labels, &entry.name, &d.reason, &d.location, &date); + preview(ui, t, &entry.name, &d.appearance, &lines, graphic.as_ref(), &mut app.sign_graphic_preview); + if in_english && d.appearance.labels { + ui.label( + egui::RichText::new(tl!("The labels are in English: the signature's font can't draw them in this language.")) + .small() + .color(t.text_muted), + ); + } + ui.horizontal_wrapped(|ui| { + let a = &mut d.appearance; + ui.add(widgets::checkbox(&mut a.name, tl!("Name"))); + ui.add(widgets::checkbox(&mut a.date, tl!("Date"))); + ui.add(widgets::checkbox(&mut a.reason, tl!("Reason"))); + ui.add(widgets::checkbox(&mut a.location, tl!("Location"))); + ui.add(widgets::checkbox(&mut a.distinguished_name, tl!("Distinguished name"))); + ui.add(widgets::checkbox(&mut a.labels, tl!("Labels"))); + }); + // Acrobat: Configure Signature Appearance, Text, Draw or Image. + ui.horizontal_wrapped(|ui| { + ui.label(tl!("Graphic")); + widgets::radio_value(ui, &mut d.graphic, SignGraphic::Name, tl!("Signer's name")); + ui.add_enabled_ui(app.signature.is_some(), |ui| { + if ui.add(widgets::radio(d.graphic == SignGraphic::FillSign, tl!("Fill & Sign signature"))).clicked() { + d.graphic = SignGraphic::FillSign; + } + }); + ui.add_enabled_ui(d.image.is_some(), |ui| { + if ui.add(widgets::radio(matches!(d.graphic, SignGraphic::Image(_)), tl!("Image"))).clicked() + && let Some(image) = &d.image + { + d.graphic = SignGraphic::Image(image.clone()); + } + }); + #[cfg(not(target_arch = "wasm32"))] + if widgets::pill_button(ui, tl!("Choose image…"), false).clicked() { + pick = true; + } + }); + ui.add_space(6.0); + } else { + ui.label(egui::RichText::new(tl!("The signature will be invisible.")).color(t.text_muted)); + } + egui::Grid::new("sign-as").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + if d.certify.is_some() { + ui.label(tl!("Permitted actions after certifying")); + let mut p = d.certify.unwrap_or(2); + let label = |p: u8| match p { + 1 => tl!("No changes allowed"), + 3 => tl!("Annotations, form fill-in, and digital signatures"), + _ => tl!("Form fill-in and digital signatures"), + }; + egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { + for v in [1u8, 2, 3] { + ui.selectable_value(&mut p, v, label(v)); + } + }); + d.certify = Some(p); + ui.end_row(); + } + let l = ui.label(tl!("Reason")); + ui.add(crate::widgets::line(&mut d.reason).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + let l = ui.label(tl!("Location")); + ui.add(crate::widgets::line(&mut d.location).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id); + ui.end_row(); + if in_os_key_store { + ui.label(""); + ui.label( + egui::RichText::new(if entry.path.starts_with("windows:") { + tl!("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it.") + } else { + tl!("The key is in the macOS Keychain, which may ask to allow PdfCraft to use it.") + }) + .small() + .color(t.text_muted), + ); + } else { + let l = ui.label(tl!("Digital ID password")); + let r = ui.add(crate::widgets::line(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + go = true; + } + } + ui.end_row(); + }); + ui.add_space(6.0); + error(ui, &d.error); + ui.horizontal(|ui| { + if widgets::pill_button(ui, tl!("Back"), false).clicked() { + d.step = SignStep::Choose; + d.error = None; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Sign"), true).clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + }); + #[cfg(not(target_arch = "wasm32"))] + if pick { + app.pick_sign_graphic_image(); + } + if go { + match app.finish_signing() { + Ok(()) => { + app.sign_draft = None; + return true; + } + Err(e) => { + if let Some(d) = app.sign_draft.as_mut() { + d.error = Some(e); + } + } + } + } + close +} diff --git a/crates/ui-egui/src/sign_ui/panel.rs b/crates/ui-egui/src/sign_ui/panel.rs new file mode 100644 index 000000000..af5bfd77c --- /dev/null +++ b/crates/ui-egui/src/sign_ui/panel.rs @@ -0,0 +1,358 @@ +use super::dialog::title; +use super::*; + +/// The message bar for signed documents: (icon, colour, template, argument). +/// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows +/// the UI language; names inside `argument` stay as they are. +pub(crate) fn banner(sigs: &[SignatureInfo]) -> Option<(&'static str, Color32, &'static str, String)> { + let signed: Vec<&SignatureInfo> = sigs.iter().filter(|s| s.signed).collect(); + if signed.is_empty() { + return None; + } + // A valid certification heads the bar only when every signature is valid: it says nothing + // about the signatures added after it, so their problems come first. + if let Some(c) = signed.iter().find(|s| s.certify.is_some()) + && signed.iter().all(|s| s.status == SignatureStatus::Valid) + { + let by = c.certificate.as_ref().map(|x| { + let org = x.subject.organization().map(|o| format!(", {o}")).unwrap_or_default(); + let who = format!("{}{org}", x.display_name()); + let (template, issuer) = match x.issuer.common_name() { + Some(issuer) => ("{who}, certificate issued by {issuer}.", issuer), + None => ("{who}, certificate issued by an unknown issuer.", ""), + }; + crate::i18n::fmt(tl!(template), &[("who", &who), ("issuer", issuer)]) + }); + return Some(("badge-check", Color32::from_rgb(0x14, 0x73, 0xE6), "Certified by {by}", by.unwrap_or_default())); + } + Some(if signed.iter().any(|s| s.status == SignatureStatus::Invalid) { + ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F), "At least one signature is invalid.", String::new()) + } else if signed.iter().all(|s| s.status == SignatureStatus::Valid) { + ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78), "Signed and all signatures are valid.", String::new()) + } else { + ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19), "At least one signature has problems.", String::new()) + }) +} + +fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) { + if !s.signed { + return ("pen-line", Color32::from_gray(0x80)); + } + signature_status_style(s.status) +} + +/// One look per signature verdict, shared by the Signatures panel and the page badges so +/// the two can't drift apart. +pub(crate) fn signature_status_style(status: SignatureStatus) -> (&'static str, Color32) { + match status { + SignatureStatus::Valid => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)), + SignatureStatus::Unknown => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)), + SignatureStatus::Invalid => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)), + } +} + +/// What the Signatures panel asks the app to do. +#[derive(Clone, Debug)] +pub enum PanelAction { + Validate, + GoTo(usize), + Trust(Box), + ViewSigned { + len: usize, + revision: usize, + }, + Sign(String), + ExportCertificate(Box), + /// Certificate Viewer for the signer's chain (signer first). + ViewCertificate(Vec), +} + +/// The Signatures panel body. +pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec) -> Option { + let mut action = None; + ui.horizontal(|ui| { + if widgets::pill_button(ui, tl!("Validate all"), false).clicked() { + action = Some(PanelAction::Validate); + } + }); + ui.add_space(6.0); + if sigs.is_empty() { + ui.add_space(24.0); + ui.vertical_centered(|ui| { + ui.add(icons::image("signature", 32.0, t.text_faint)); + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("This document has no signatures.")).color(t.text_muted)); + }); + return action; + } + let mut ordered: Vec<&SignatureInfo> = sigs.iter().collect(); + ordered.sort_by_key(|s| (!s.signed, s.revision)); + for s in ordered { + let open = expanded.contains(&s.field); + let (icon, color) = status_icon(s); + let (template, name) = match (s.signed, s.certify.is_some(), s.signer.as_deref()) { + (false, ..) => ("Unsigned signature field: {name}", s.field.as_str()), + (true, true, Some(signer)) => ("Rev. {rev}: Certified by {name}", signer), + (true, true, None) => ("Rev. {rev}: Certified by an unknown signer", ""), + (true, false, Some(signer)) => ("Rev. {rev}: Signed by {name}", signer), + (true, false, None) => ("Rev. {rev}: Signed by an unknown signer", ""), + }; + let head = crate::i18n::fmt(tl!(template), &[("rev", &s.revision.to_string()), ("name", name)]); + let resp = ui + .horizontal(|ui| { + let chevron = if open { "chevron-down" } else { "chevron-right" }; + let toggle = icons::button(ui, chevron, 20.0, false, if open { "Collapse" } else { "Expand" }).clicked(); + ui.add(icons::image(icon, 16.0, color)); + let l = ui.add(egui::Label::new(egui::RichText::new(&head).font(theme::semibold(12.5)).color(t.text)).sense(egui::Sense::click())); + toggle || l.clicked() + }) + .inner; + if resp { + if open { + expanded.retain(|f| f != &s.field); + } else { + expanded.push(s.field.clone()); + } + } + if !open { + continue; + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 4, top: 2, bottom: 8 }).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 3.0; + if !s.signed { + if widgets::pill_button(ui, tl!("Sign this field…"), true).clicked() { + action = Some(PanelAction::Sign(s.field.clone())); + } + return; + } + ui.label( + egui::RichText::new(match s.status { + SignatureStatus::Valid => tl!("Signature is valid:"), + SignatureStatus::Unknown => tl!("Signature validity is UNKNOWN:"), + SignatureStatus::Invalid => tl!("Signature is INVALID:"), + }) + .font(theme::semibold(12.0)), + ); + for detail in &s.details { + // Each detail is a whole sentence of ours plus what the signature itself says; said + // here in the panel's language, with the signature's own words left as they are. + let line = detail.said(&crate::i18n::words); + ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).font(theme::regular(11.5)).color(t.text_muted)).wrap()); + } + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Signature Details")).font(theme::semibold(12.0))); + let row = |ui: &mut egui::Ui, k: &str, v: &str| { + ui.add(egui::Label::new(egui::RichText::new(format!("{}: {v}", tl!(k))).font(theme::regular(11.5))).wrap()); + }; + if let Some(r) = &s.reason { + row(ui, "Reason", r); + } + if let Some(l) = &s.location { + row(ui, "Location", l); + } + if let Some(d) = &s.date { + row(ui, "Signing time", &sign::pdf::display_date(d)); + } + if let Some(a) = &s.algorithm { + row(ui, "Algorithm", a); + } + if let Some(c) = &s.certificate { + row(ui, "Signer", &c.subject.display()); + row(ui, "Issued by", &c.issuer.display()); + row(ui, "Valid", &format!("{} to {}", c.not_before, c.not_after)); + } + match s.page { + Some(p) => { + let link = ui.add( + egui::Label::new( + egui::RichText::new(crate::i18n::fmt( + tl!("Field: {field} on page {p}"), + &[("field", &s.field), ("p", &(p + 1).to_string())], + )) + .font(theme::regular(11.5)) + .color(t.accent_text) + .underline(), + ) + .sense(egui::Sense::click()), + ); + if link.clicked() { + action = Some(PanelAction::GoTo(p)); + } + } + None => row(ui, "Field", &s.field), + } + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + // Trust exactly the signer shown above (s.certificate), not the chain top the + // document embedded: trusting an issuer or root is a separate, explicit step in + // the Certificate Viewer's Trust tab, which names that certificate. + if let Some(c) = s.certificate.as_ref() + && s.status == SignatureStatus::Unknown + && ui.button(tl!("Add to trusted certificates")).clicked() + { + action = Some(PanelAction::Trust(Box::new(c.clone()))); + } + if ui.button(tl!("View signed version")).clicked() { + action = Some(PanelAction::ViewSigned { len: s.signed_len, revision: s.revision }); + } + if let Some(c) = &s.certificate + && ui.button(tl!("Show certificate…")).clicked() + { + let mut chain = vec![c.clone()]; + chain.extend(s.chain.iter().filter(|x| x.raw != c.raw).cloned()); + action = Some(PanelAction::ViewCertificate(chain)); + } + if let Some(c) = &s.certificate + && ui.button(tl!("Export certificate…")).clicked() + { + action = Some(PanelAction::ExportCertificate(Box::new(c.clone()))); + } + }); + }); + } + action +} + +/// Certificate Viewer: a chain (the end certificate first) and the tab shown. +#[derive(Clone, Debug)] +pub struct CertViewer { + pub chain: Vec, + pub selected: usize, + pub tab: CertTab, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CertTab { + Summary, + Details, + Trust, +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect::>().join(" ") +} + +fn key_usage(bits: u16) -> String { + const NAMES: [&str; 9] = [ + "Digital Signature", + "Non-Repudiation", + "Key Encipherment", + "Data Encipherment", + "Key Agreement", + "Certificate Signing", + "CRL Signing", + "Encipher Only", + "Decipher Only", + ]; + let used: Vec<&str> = NAMES.iter().enumerate().filter(|(i, _)| bits & (1 << i) != 0).map(|(_, n)| tl!(*n)).collect(); + if used.is_empty() { tl!("None").into() } else { used.join(", ") } +} + +/// What the viewer asks for. +pub(crate) enum CertAction { + Trust(Box), + Export(Box), +} + +/// The Certificate Viewer dialog: the chain on the left, the selected certificate's tabs. +pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Certificate], t: &Tokens) -> (bool, Option) { + title(ui, "Certificate Viewer"); + ui.label(egui::RichText::new(tl!("This dialog shows the details of a certificate and its chain.")).color(t.text_muted)); + ui.add_space(8.0); + let mut action = None; + v.selected = v.selected.min(v.chain.len().saturating_sub(1)); + ui.horizontal_top(|ui| { + // The chain, root at the top (as Acrobat shows it). + ui.vertical(|ui| { + ui.set_width(200.0); + for (depth, i) in (0..v.chain.len()).rev().enumerate() { + let c = &v.chain[i]; + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 12.0); + if ui.selectable_label(v.selected == i, c.display_name()).clicked() { + v.selected = i; + } + }); + } + }); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_width(440.0); + let Some(c) = v.chain.get(v.selected).cloned() else { return }; + ui.horizontal(|ui| { + for (tab, label) in [(CertTab::Summary, tl!("Summary")), (CertTab::Details, tl!("Details")), (CertTab::Trust, tl!("Trust"))] { + if widgets::pill_button(ui, label, v.tab == tab).clicked() { + v.tab = tab; + } + } + }); + ui.add_space(8.0); + let grid = |ui: &mut egui::Ui, rows: Vec<(&str, String)>| { + egui::Grid::new(("cert-rows", v.tab as u8)).num_columns(2).spacing([12.0, 5.0]).show(ui, |ui| { + for (k, val) in rows { + ui.label(egui::RichText::new(tl_ctx!("certificate", k)).color(t.text_muted)); + ui.add(egui::Label::new(val).wrap()); + ui.end_row(); + } + }); + }; + match v.tab { + CertTab::Summary => grid( + ui, + vec![ + ("Issued to", c.subject.display()), + ("Issued by", c.issuer.display()), + ("Valid from", c.not_before.to_string()), + ("Valid to", c.not_after.to_string()), + ("Intended usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Any").to_string())), + ], + ), + CertTab::Details => grid( + ui, + vec![ + ("Version", "3".into()), + ("Serial number", c.serial_hex()), + ("Issuer", c.issuer.display()), + ("Subject", c.subject.display()), + ("Validity starts", c.not_before.to_string()), + ("Validity ends", c.not_after.to_string()), + ("Public key", c.public_key.describe()), + ("Basic constraints", tl!(if c.is_ca { "Certificate authority" } else { "End entity" }).to_string()), + ("Key usage", c.key_usage.map(key_usage).unwrap_or_else(|| tl!("Not present").to_string())), + ("Self-signed", if c.is_self_signed() { tl!("Yes").to_string() } else { tl!("No").to_string() }), + ("SHA-1 digest", hex(&sign::keys::DigestAlg::Sha1.digest(&[&c.raw]))), + ("SHA-256 digest", hex(&sign::keys::DigestAlg::Sha256.digest(&[&c.raw]))), + ], + ), + CertTab::Trust => { + let is_trusted = trusted.iter().any(|x| x.raw == c.raw); + let anchored = v.chain.iter().any(|x| trusted.iter().any(|y| y.raw == x.raw)); + ui.label(if is_trusted { + tl!("This certificate is in your list of trusted certificates.") + } else if anchored { + tl!("This certificate is trusted through a certificate above it in the chain.") + } else { + tl!("This certificate is not trusted. Signatures made with it show an unknown identity.") + }); + ui.add_space(8.0); + if !is_trusted && widgets::pill_button(ui, tl!("Add to Trusted Certificates"), false).clicked() { + action = Some(CertAction::Trust(Box::new(c.clone()))); + } + } + } + ui.add_space(8.0); + if widgets::pill_button(ui, tl!("Export…"), false).clicked() { + action = Some(CertAction::Export(Box::new(c.clone()))); + } + }); + }); + ui.add_space(10.0); + let mut close = false; + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| close = widgets::pill_button(ui, tl!("OK"), true).clicked())); + (close, action) +} + +/// The text of an exported certificate (`.cer`, PEM). +pub fn certificate_pem(c: &Certificate) -> String { + sign::x509::to_pem(c) +}