mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
refactor(ui-egui): split sign_ui.rs into sign_ui/
New modules: dialog, panel.
This commit is contained in:
+10
-841
@@ -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<String>) {
|
||||
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<SavedSig> {
|
||||
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<String> {
|
||||
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<Certificate>),
|
||||
ViewSigned {
|
||||
len: usize,
|
||||
revision: usize,
|
||||
},
|
||||
Sign(String),
|
||||
ExportCertificate(Box<Certificate>),
|
||||
/// Certificate Viewer for the signer's chain (signer first).
|
||||
ViewCertificate(Vec<Certificate>),
|
||||
}
|
||||
|
||||
/// The Signatures panel body.
|
||||
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec<String>) -> Option<PanelAction> {
|
||||
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<Certificate>,
|
||||
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::<Vec<_>>().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<Certificate>),
|
||||
Export(Box<Certificate>),
|
||||
}
|
||||
|
||||
/// 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<CertAction>) {
|
||||
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::*;
|
||||
|
||||
@@ -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<String>) {
|
||||
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<SavedSig> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
@@ -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<Certificate>),
|
||||
ViewSigned {
|
||||
len: usize,
|
||||
revision: usize,
|
||||
},
|
||||
Sign(String),
|
||||
ExportCertificate(Box<Certificate>),
|
||||
/// Certificate Viewer for the signer's chain (signer first).
|
||||
ViewCertificate(Vec<Certificate>),
|
||||
}
|
||||
|
||||
/// The Signatures panel body.
|
||||
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expanded: &mut Vec<String>) -> Option<PanelAction> {
|
||||
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<Certificate>,
|
||||
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::<Vec<_>>().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<Certificate>),
|
||||
Export(Box<Certificate>),
|
||||
}
|
||||
|
||||
/// 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<CertAction>) {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user