move identity feature to request area

This commit is contained in:
yggverse 2025-01-29 15:13:35 +02:00
parent 78957ff85b
commit 2dc6154d54
32 changed files with 59 additions and 84 deletions

View file

@ -5,7 +5,7 @@ mod text;
use image::Image;
use text::Text;
use super::{ItemAction, WindowAction};
use super::{ItemAction, TabAction, WindowAction};
use adw::StatusPage;
use gtk::{
gdk::Paintable,
@ -19,6 +19,7 @@ use std::{rc::Rc, time::Duration};
pub struct Content {
window_action: Rc<WindowAction>,
item_action: Rc<ItemAction>,
tab_action: Rc<TabAction>,
pub g_box: Box,
}
@ -26,11 +27,18 @@ impl Content {
// Construct
/// Create new container for different components
pub fn build((window_action, item_action): (&Rc<WindowAction>, &Rc<ItemAction>)) -> Self {
pub fn build(
(window_action, tab_action, item_action): (
&Rc<WindowAction>,
&Rc<TabAction>,
&Rc<ItemAction>,
),
) -> Self {
Self {
g_box: Box::builder().orientation(Orientation::Vertical).build(),
window_action: window_action.clone(),
item_action: item_action.clone(),
tab_action: tab_action.clone(),
}
}
@ -90,7 +98,7 @@ impl Content {
/// * action removes previous children component from `Self`
pub fn to_status_identity(&self) -> StatusPage {
self.clean();
let status = status::identity::build(self.item_action.clone());
let status = status::identity::build((&self.tab_action, &self.item_action));
self.g_box.append(&status);
status
}

View file

@ -4,4 +4,4 @@ pub mod identity;
pub mod loading;
pub mod mime;
use super::ItemAction;
use super::{ItemAction, TabAction};

View file

@ -1,6 +1,6 @@
use crate::app::browser::window::tab::item::Action;
use super::{ItemAction, TabAction};
use adw::StatusPage;
use gtk::{prelude::ButtonExt, Align, Button};
use gtk::{prelude::ActionExt, Align, Button};
use std::rc::Rc;
// Defaults
@ -13,18 +13,16 @@ const DEFAULT_BUTTON_CLASS: &str = "suggested-action";
/// Create new default preset for `Identity`
/// [StatusPage](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.StatusPage.html)
pub fn build(action: Rc<Action>) -> StatusPage {
pub fn build((tab_action, item_action): (&Rc<TabAction>, &Rc<ItemAction>)) -> StatusPage {
// Init certificate selection
let button = &Button::builder()
.action_name(format!("{}.{}", tab_action.id, item_action.identity.name()))
.css_classes([DEFAULT_BUTTON_CLASS])
.label(DEFAULT_BUTTON_LABEL)
.tooltip_text(DEFAULT_BUTTON_TOOLTIP_TEXT)
.halign(Align::Center)
.build();
// Init events
button.connect_clicked(move |_| action.ident.activate());
// Init status page
StatusPage::builder()
.description(DEFAULT_DESCRIPTION)

View file

@ -1,6 +1,8 @@
mod database;
mod identity;
mod primary_icon;
use adw::prelude::AdwDialogExt;
use primary_icon::PrimaryIcon;
use super::{ItemAction, Profile};
@ -71,8 +73,25 @@ impl Request for Entry {
// Connect events
entry.connect_icon_release({
let item_action = item_action.clone();
let profile = profile.clone();
move |this, position| match position {
EntryIconPosition::Primary => item_action.ident.activate(), // @TODO PrimaryIcon impl
EntryIconPosition::Primary => {
if let Some(request) = this.uri() {
if ["gemini", "titan"].contains(&request.scheme().as_str()) {
return identity::default(&profile, &request, {
let item_action = item_action.clone();
let profile = profile.clone();
let this = this.clone();
move || {
this.update(&profile);
item_action.load.activate(Some(&this.text()), false);
} // on apply
})
.present(Some(this));
}
}
identity::unsupported().present(Some(this));
}
EntryIconPosition::Secondary => item_action.load.activate(Some(&this.text()), true),
_ => todo!(), // unexpected
}

View file

@ -0,0 +1,20 @@
mod default;
mod unsupported;
use adw::AlertDialog;
use default::Default;
use unsupported::Unsupported;
use super::Profile;
use gtk::glib::Uri;
use std::rc::Rc;
/// Create new identity widget for Gemini protocol match given URI
pub fn default(profile: &Rc<Profile>, request: &Uri, on_apply: impl Fn() + 'static) -> Default {
Default::build(profile, request, on_apply)
}
/// Create new identity widget for unknown request
pub fn unsupported() -> AlertDialog {
AlertDialog::unsupported()
}

View file

@ -0,0 +1,84 @@
mod widget;
use widget::{form::list::item::value::Value, Widget};
use super::Profile;
use gtk::{glib::Uri, prelude::IsA};
use std::rc::Rc;
pub struct Default {
// profile: Rc<Profile>,
widget: Rc<Widget>,
}
impl Default {
// Construct
/// Create new `Self` for given `Profile`
pub fn build(profile: &Rc<Profile>, request: &Uri, on_apply: impl Fn() + 'static) -> Self {
// Init widget
let widget = Rc::new(Widget::build(profile, request));
// Init events
widget.on_apply({
let profile = profile.clone();
let request = request.clone();
let widget = widget.clone();
move |response| {
// Get option match user choice
let option = match response {
Value::ProfileIdentityId(value) => Some(value),
Value::GuestSession => None,
Value::GeneratePem => Some(
profile
.identity
.make(None, &widget.form.name.value().unwrap())
.unwrap(), // @TODO handle
),
Value::ImportPem => Some(
profile
.identity
.add(&widget.form.file.pem.take().unwrap())
.unwrap(), // @TODO handle
),
};
// Apply auth
match option {
// Activate identity for `scope`
Some(profile_identity_id) => {
if profile
.identity
.auth
.apply(profile_identity_id, &request.to_string())
.is_err()
{
panic!() // unexpected @TODO
}
}
// Remove all identity auths for `scope`
None => {
if profile.identity.auth.remove(&request.to_string()).is_err() {
panic!() // unexpected @TODO
}
}
}
// Run callback function
on_apply()
}
});
// Return activated `Self`
Self {
// profile,
widget,
}
}
// Actions
/// Show dialog for parent [Widget](https://docs.gtk.org/gtk4/class.Widget.html)
pub fn present(&self, parent: Option<&impl IsA<gtk::Widget>>) {
self.widget.present(parent);
}
}

View file

@ -0,0 +1,112 @@
mod action;
pub mod form;
use action::Action as WidgetAction;
use form::{list::item::value::Value, Form};
use crate::Profile;
use adw::{
prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual},
AlertDialog, ResponseAppearance,
};
use gtk::{glib::Uri, prelude::IsA};
use std::rc::Rc;
// Defaults
const HEADING: &str = "Identity";
const BODY: &str = "Select identity certificate";
// Response variants
const RESPONSE_APPLY: (&str, &str) = ("apply", "Apply");
const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel");
// const RESPONSE_MANAGE: (&str, &str) = ("manage", "Manage");
// Select options
pub struct Widget {
// pub action: Rc<Action>,
pub form: Rc<Form>,
pub alert_dialog: AlertDialog,
}
impl Widget {
// Constructors
/// Create new `Self`
pub fn build(profile: &Rc<Profile>, request: &Uri) -> Self {
// Init actions
let action = Rc::new(WidgetAction::new());
// Init child container
let form = Rc::new(Form::build(&action, profile, request));
// Init main widget
let alert_dialog = AlertDialog::builder()
.heading(HEADING)
.body(BODY)
.close_response(RESPONSE_CANCEL.0)
.default_response(RESPONSE_APPLY.0)
.extra_child(&form.g_box)
.build();
// Set response variants
alert_dialog.add_responses(&[
RESPONSE_CANCEL,
// RESPONSE_MANAGE,
RESPONSE_APPLY,
]);
// Deactivate not implemented feature @TODO
// alert_dialog.set_response_enabled(RESPONSE_MANAGE.0, false);
// Decorate default response preset
alert_dialog.set_response_appearance(RESPONSE_APPLY.0, ResponseAppearance::Suggested);
/* contrast issue with Ubuntu orange accents
alert_dialog.set_response_appearance(RESPONSE_CANCEL.0, ResponseAppearance::Destructive); */
// Init events
action.update.connect_activate({
let form = form.clone();
let alert_dialog = alert_dialog.clone();
move || {
// Update child components
form.update();
// Deactivate apply button if the form values could not be processed
alert_dialog.set_response_enabled(RESPONSE_APPLY.0, form.is_applicable());
}
});
// Make initial update
action.update.activate();
// Return new activated `Self`
Self {
// action,
form,
alert_dialog,
}
}
// Actions
/// Callback wrapper to apply
/// [response](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/signal.AlertDialog.response.html)
pub fn on_apply(&self, callback: impl Fn(Value) + 'static) {
self.alert_dialog.connect_response(Some(RESPONSE_APPLY.0), {
let form = self.form.clone();
move |this, response| {
// Prevent double-click action
this.set_response_enabled(response, false);
// Result
callback(form.list.selected().value_enum())
}
});
}
/// Show dialog with new preset
pub fn present(&self, parent: Option<&impl IsA<gtk::Widget>>) {
self.alert_dialog.present(parent)
}
}

View file

@ -0,0 +1,26 @@
mod update;
use update::Update;
use std::rc::Rc;
/// [SimpleActionGroup](https://docs.gtk.org/gio/class.SimpleActionGroup.html) wrapper
pub struct Action {
pub update: Rc<Update>,
}
impl Default for Action {
fn default() -> Self {
Self::new()
}
}
impl Action {
// Constructors
/// Create new `Self`
pub fn new() -> Self {
Self {
update: Rc::new(Update::new()),
}
}
}

View file

@ -0,0 +1,39 @@
use gtk::{gio::SimpleAction, glib::uuid_string_random, prelude::ActionExt};
/// [SimpleAction](https://docs.gtk.org/gio/class.SimpleAction.html) wrapper for `Update` action
pub struct Update {
pub gobject: SimpleAction,
}
impl Default for Update {
fn default() -> Self {
Self::new()
}
}
impl Update {
// Constructors
/// Create new `Self`
pub fn new() -> Self {
Self {
gobject: SimpleAction::new(&uuid_string_random(), None),
}
}
// Actions
/// Emit [activate](https://docs.gtk.org/gio/signal.SimpleAction.activate.html) signal
/// with formatted for this action [Variant](https://docs.gtk.org/glib/struct.Variant.html) value
pub fn activate(&self) {
self.gobject.activate(None);
}
// Events
/// Define callback function for
/// [SimpleAction::activate](https://docs.gtk.org/gio/signal.SimpleAction.activate.html) signal
pub fn connect_activate(&self, callback: impl Fn() + 'static) {
self.gobject.connect_activate(move |_, _| callback());
}
}

View file

@ -0,0 +1,109 @@
mod drop;
mod exit;
mod file;
pub mod list;
mod name;
mod save;
use drop::Drop;
use exit::Exit;
use file::File;
use list::{item::value::Value, List};
use name::Name;
use save::Save;
use super::WidgetAction;
use crate::Profile;
use gtk::{glib::Uri, prelude::BoxExt, Box, Orientation};
use std::rc::Rc;
pub struct Form {
// pub action_widget: Rc<Action>,
pub drop: Rc<Drop>,
pub exit: Rc<Exit>,
pub file: Rc<File>,
pub list: Rc<List>,
pub name: Rc<Name>,
pub save: Rc<Save>,
pub g_box: Box,
request: Uri,
profile: Rc<Profile>,
}
impl Form {
// Constructors
/// Create new `Self`
pub fn build(widget_action: &Rc<WidgetAction>, profile: &Rc<Profile>, request: &Uri) -> Self {
// Init components
let list = Rc::new(List::build(widget_action, profile, request));
let file = Rc::new(File::build(widget_action));
let name = Rc::new(Name::build(widget_action));
let save = Rc::new(Save::build(profile, &list));
let drop = Rc::new(Drop::build(profile, &list));
let exit = Rc::new(Exit::build(widget_action, profile, &list, request));
// Init main container
let g_box = Box::builder().orientation(Orientation::Vertical).build();
g_box.append(&list.dropdown);
g_box.append(&name.entry);
g_box.append(&file.button);
g_box.append(&exit.button);
g_box.append(&drop.button);
g_box.append(&save.button);
// Return activated `Self`
Self {
// action_widget,
drop,
exit,
file,
list,
name,
save,
g_box,
request: request.clone(),
profile: profile.clone(),
}
}
// Actions
/// Get `Apply` button sensitivity to disable when it does not change anything
pub fn is_applicable(&self) -> bool {
match self.list.selected().value_enum() {
Value::GeneratePem => self.name.is_valid(),
Value::ImportPem => self.file.is_valid(),
_ => !self.list.selected().is_active(),
}
}
pub fn update(&self) {
// Get shared selected item value
let value = self.list.selected().value_enum();
// Begin children components update
self.name.update(matches!(value, Value::GeneratePem));
self.file.update(matches!(value, Value::ImportPem));
match value {
Value::ProfileIdentityId(profile_identity_id) => {
self.drop.update(true);
self.exit.update(
true,
self.profile
.identity
.auth
.is_matches(&self.request.to_string(), profile_identity_id),
);
self.save.update(true);
}
_ => {
self.drop.update(false);
self.exit.update(false, false);
self.save.update(false);
}
}
}
}

View file

@ -0,0 +1,111 @@
use super::list::{item::Value, List};
use crate::profile::Profile;
use adw::{
prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual},
AlertDialog, ResponseAppearance,
};
use gtk::{
prelude::{ButtonExt, WidgetExt},
Button,
};
use std::rc::Rc;
// Defaults
const LABEL: &str = "Delete";
const TOOLTIP_TEXT: &str = "Drop selected identity from profile";
const MARGIN: i32 = 8;
const HEADING: &str = "Delete";
const BODY: &str = "Delete selected identity from profile?";
const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel");
const RESPONSE_CONFIRM: (&str, &str) = ("confirm", "Confirm");
pub struct Drop {
pub button: Button,
}
impl Drop {
// Constructors
/// Create new `Self`
pub fn build(profile: &Rc<Profile>, list: &Rc<List>) -> Self {
// Init main widget
let button = Button::builder()
.label(LABEL)
.margin_top(MARGIN)
.tooltip_text(TOOLTIP_TEXT)
.visible(false)
.build();
// Init events
button.connect_clicked({
let button = button.clone();
let list = list.clone();
let profile = profile.clone();
move |_| {
match list.selected().value_enum() {
Value::ProfileIdentityId(profile_identity_id) => {
// Init sub-widget
let alert_dialog = AlertDialog::builder()
.heading(HEADING)
.body(BODY)
.close_response(RESPONSE_CANCEL.0)
.default_response(RESPONSE_CANCEL.0)
.build();
// Set response variants
alert_dialog.add_responses(&[RESPONSE_CANCEL, RESPONSE_CONFIRM]);
// Decorate default response preset
alert_dialog.set_response_appearance(
RESPONSE_CONFIRM.0,
ResponseAppearance::Suggested,
);
/* contrast issue with Ubuntu orange accents
alert_dialog.set_response_appearance(
RESPONSE_CANCEL.0,
ResponseAppearance::Destructive,
); */
// Connect confirmation event
alert_dialog.connect_response(Some(RESPONSE_CONFIRM.0), {
let button = button.clone();
let list = list.clone();
let profile = profile.clone();
move |_, _| match profile.identity.delete(profile_identity_id) {
Ok(_) => {
if list.remove(profile_identity_id).is_some() {
button.set_css_classes(&["success"]);
button.set_label("Identity successfully deleted")
} else {
button.set_css_classes(&["error"]);
button.set_label("List item not found")
}
}
Err(e) => {
button.set_css_classes(&["error"]);
button.set_label(&e.to_string())
}
}
});
// Show dialog
alert_dialog.present(Some(&button))
}
_ => todo!(), // unexpected
}
}
});
// Return activated `Self`
Self { button }
}
// Actions
pub fn update(&self, is_visible: bool) {
self.button.set_visible(is_visible)
}
}

View file

@ -0,0 +1,134 @@
use super::{
list::{item::Value, List},
WidgetAction,
};
use crate::Profile;
use adw::{
prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual},
AlertDialog, ResponseAppearance,
};
use gtk::{
glib::Uri,
prelude::{ButtonExt, WidgetExt},
Button,
};
use std::rc::Rc;
// Defaults
const LABEL: &str = "Disconnect";
const TOOLTIP_TEXT: &str = "Stop use selected identity everywhere";
const MARGIN: i32 = 8;
const HEADING: &str = "Disconnect";
const BODY: &str = "Stop use selected identity for all scopes?";
const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel");
const RESPONSE_CONFIRM: (&str, &str) = ("confirm", "Confirm");
pub struct Exit {
pub button: Button,
}
impl Exit {
// Constructors
/// Create new `Self`
pub fn build(
widget_action: &Rc<WidgetAction>,
profile: &Rc<Profile>,
list: &Rc<List>,
request: &Uri,
) -> Self {
// Init main widget
let button = Button::builder()
.label(LABEL)
.margin_top(MARGIN)
.tooltip_text(TOOLTIP_TEXT)
.visible(false)
.build();
// Init events
button.connect_clicked({
let button = button.clone();
let list = list.clone();
let profile = profile.clone();
let request = request.clone();
let widget_action = widget_action.clone();
move |_| {
// Get selected identity from holder
match list.selected().value_enum() {
Value::ProfileIdentityId(profile_identity_id) => {
// Init sub-widget
let alert_dialog = AlertDialog::builder()
.heading(HEADING)
.body(BODY)
.close_response(RESPONSE_CANCEL.0)
.default_response(RESPONSE_CANCEL.0)
.build();
// Set response variants
alert_dialog.add_responses(&[RESPONSE_CANCEL, RESPONSE_CONFIRM]);
// Decorate default response preset
alert_dialog.set_response_appearance(
RESPONSE_CONFIRM.0,
ResponseAppearance::Suggested,
);
/* contrast issue with Ubuntu orange accents
alert_dialog.set_response_appearance(
RESPONSE_CANCEL.0,
ResponseAppearance::Destructive,
); */
// Connect confirmation event
alert_dialog.connect_response(Some(RESPONSE_CONFIRM.0), {
let button = button.clone();
let list = list.clone();
let profile = profile.clone();
let request = request.clone();
let widget_action = widget_action.clone();
move |_, _| {
match profile.identity.auth.remove_ref(profile_identity_id) {
Ok(_) => {
match list.selected().update(&profile, &request.to_string())
{
Ok(_) => {
button.set_css_classes(&["success"]);
button
.set_label("Identity successfully disconnected")
}
Err(e) => {
button.set_css_classes(&["error"]);
button.set_label(&e.to_string())
}
}
}
Err(e) => {
button.set_css_classes(&["error"]);
button.set_label(&e.to_string())
}
}
widget_action.update.activate();
}
});
// Show dialog
alert_dialog.present(Some(&button))
}
_ => todo!(), // unexpected
}
}
});
// Return activated `Self`
Self { button }
}
// Actions
pub fn update(&self, is_visible: bool, is_sensitive: bool) {
self.button.set_visible(is_visible);
self.button.set_sensitive(is_sensitive);
}
}

View file

@ -0,0 +1,123 @@
use super::WidgetAction;
use gtk::{
gio::{Cancellable, ListStore, TlsCertificate},
glib::{gformat, GString},
prelude::{ButtonExt, FileExt, TlsCertificateExt, WidgetExt},
Button, FileDialog, FileFilter, Window,
};
use std::{cell::RefCell, rc::Rc};
const LABEL: &str = "Choose file..";
const TOOLTIP_TEXT: &str = "Import existing identity from file";
const MARGIN: i32 = 8;
pub struct File {
pub pem: Rc<RefCell<Option<GString>>>,
pub button: Button,
}
impl File {
// Constructors
/// Create new `Self`
pub fn build(widget_action: &Rc<WidgetAction>) -> Self {
// Init PEM
let pem = Rc::new(RefCell::new(None));
// Init main gobject
let button = Button::builder()
.label(LABEL)
.margin_top(MARGIN)
.tooltip_text(TOOLTIP_TEXT)
.visible(false)
.build();
// Init events
button.connect_clicked({
let widget_action = widget_action.clone();
let button = button.clone();
let pem = pem.clone();
move |_| {
// Lock open button (prevent double click)
button.set_sensitive(false);
// Init file filters related with PEM extension
let filters = ListStore::new::<FileFilter>();
let filter_all = FileFilter::new();
filter_all.add_pattern("*.*");
filter_all.set_name(Some("All"));
filters.append(&filter_all);
let filter_pem = FileFilter::new();
filter_pem.add_mime_type("application/x-x509-ca-cert");
filter_pem.set_name(Some("Certificate (*.pem)"));
filters.append(&filter_pem);
// Init file dialog
FileDialog::builder()
.filters(&filters)
.default_filter(&filter_pem)
.build()
.open(Window::NONE, Cancellable::NONE, {
let widget_action = widget_action.clone();
let button = button.clone();
let pem = pem.clone();
move |result| {
match result {
Ok(file) => match file.path() {
Some(path) => {
let filename = path.to_str().unwrap();
match TlsCertificate::from_file(filename) {
Ok(certificate) => {
pem.replace(to_pem(certificate));
button.set_css_classes(&["success"]);
button.set_label(filename)
}
Err(e) => {
button.set_css_classes(&["error"]);
button.set_label(e.message())
}
}
}
None => todo!(),
},
Err(e) => {
button.set_css_classes(&["warning"]);
button.set_label(e.message())
}
}
button.set_sensitive(true); // unlock
widget_action.update.activate()
}
});
}
});
// Return activated `Self`
Self { pem, button }
}
// Actions
/// Change visibility status
pub fn update(&self, is_visible: bool) {
self.button.set_visible(is_visible);
}
// Getters
pub fn is_valid(&self) -> bool {
self.pem.borrow().is_some()
}
}
// Private helpers
/// Convert [TlsCertificate](https://docs.gtk.org/gio/class.TlsCertificate.html) to GString
fn to_pem(certificate: TlsCertificate) -> Option<GString> {
let certificate_pem = certificate.certificate_pem()?;
let private_key_pem = certificate.private_key_pem()?;
Some(gformat!("{certificate_pem}{private_key_pem}"))
}

View file

@ -0,0 +1,207 @@
pub mod item;
use std::rc::Rc;
use item::Item;
use super::WidgetAction;
use crate::profile::Profile;
use gtk::{
gdk::Cursor,
gio::{
prelude::{Cast, CastNone},
ListStore,
},
glib::Uri,
prelude::{BoxExt, ListItemExt, ObjectExt, WidgetExt},
Align, Box, DropDown, Image, Label, ListItem, Orientation, SignalListItemFactory,
};
pub struct List {
pub dropdown: DropDown,
list_store: ListStore,
}
impl List {
// Constructors
/// Create new `Self`
pub fn build(widget_action: &Rc<WidgetAction>, profile: &Rc<Profile>, request: &Uri) -> Self {
// Init dropdown items
let guest_session = Item::new_guest_session();
let generate_pem = Item::new_generate_pem();
let import_pem = Item::new_import_pem();
// Init model
let list_store = ListStore::new::<Item>();
list_store.append(&guest_session);
list_store.append(&generate_pem);
list_store.append(&import_pem);
match profile.identity.database.records() {
Ok(identities) => {
let mut is_guest_session = true;
for identity in identities {
match Item::new_profile_identity_id(profile, identity.id, &request.to_string())
{
Ok(item) => {
if item.is_active() {
is_guest_session = false;
}
list_store.append(&item)
}
Err(_) => todo!(),
}
}
if is_guest_session {
guest_session.set_is_active(true);
}
}
Err(_) => todo!(),
}
// Setup item factory
// * wanted only to append items after `DropDown` init
let factory = SignalListItemFactory::new();
factory.connect_setup(|_, this| {
// Init widget for dropdown item
let child = Box::builder().orientation(Orientation::Vertical).build();
// Title
child.append(&Label::builder().halign(Align::Start).build());
// Subtitle
let subtitle = Box::builder()
.orientation(Orientation::Horizontal)
.css_classes(["caption", "dim-label"])
.halign(Align::Start)
.build();
subtitle.append(
&Image::builder()
.css_classes(["success"]) // @TODO toggle on certificate issues
.cursor(&Cursor::from_name("help", None).unwrap())
.icon_name("application-certificate-symbolic")
.margin_end(4)
.pixel_size(11)
.build(),
);
subtitle.append(&Label::new(None));
// Subtitle
child.append(&subtitle);
// Done
this.downcast_ref::<ListItem>()
.unwrap()
.set_child(Some(&child));
});
factory.connect_bind(|_, this| {
// Downcast requirements
let list_item = this.downcast_ref::<ListItem>().unwrap();
let item = list_item.item().and_downcast::<Item>().unwrap();
let child = list_item.child().and_downcast::<Box>().unwrap();
// Bind `title`
match child.first_child().and_downcast::<Label>() {
Some(label) => {
label.set_label(&item.title());
label.set_css_classes(if item.is_active() { &["accent"] } else { &[] });
item.bind_property("title", &label, "label").build(); // sync label
item.bind_property("is-active", &label, "css-classes")
.transform_to(|_, is_active| {
if is_active {
Some(vec!["accent".to_string()])
} else {
Some(vec![])
}
})
.build(); // sync class by status
}
None => todo!(),
};
// Bind `subtitle`
let subtitle = child.last_child().and_downcast::<Box>().unwrap();
match subtitle.last_child().and_downcast::<Label>() {
Some(label) => {
label.set_label(&item.subtitle());
item.bind_property("subtitle", &label, "label").build(); // sync
}
None => todo!(),
};
// Bind `tooltip`
match subtitle.first_child().and_downcast::<Image>() {
Some(tooltip) => {
tooltip.set_visible(!item.tooltip().is_empty());
tooltip.set_tooltip_markup(Some(&item.tooltip()));
item.bind_property("tooltip", &tooltip, "tooltip-markup")
.build(); // sync
}
None => todo!(),
};
});
// Init main widget
let dropdown = DropDown::builder()
.model(&list_store)
.selected(
list_store
.find_with_equal_func(|item| {
item.dynamic_cast_ref::<Item>().unwrap().is_active()
})
.unwrap_or_default(),
)
.factory(&factory)
.build();
// Connect events
dropdown.connect_selected_notify({
let widget_action = widget_action.clone();
move |_| widget_action.update.activate()
});
// Return activated `Self`
Self {
dropdown,
list_store,
}
}
// Actions
/// Find list item by `profile_identity_id`
/// * return `position` found
pub fn find(&self, profile_identity_id: i64) -> Option<u32> {
self.list_store.find_with_equal_func(|this| {
profile_identity_id == this.downcast_ref::<Item>().unwrap().value()
})
}
/// Remove list item by `profile_identity_id`
/// * return `position` of removed list item
pub fn remove(&self, profile_identity_id: i64) -> Option<u32> {
match self.find(profile_identity_id) {
Some(position) => {
self.list_store.remove(position);
Some(position)
}
None => todo!(),
}
}
// Getters
/// Get selected `Item` GObject
pub fn selected(&self) -> Item {
self.dropdown
.selected_item()
.and_downcast::<Item>()
.unwrap()
}
}

View file

@ -0,0 +1,155 @@
mod error;
mod imp;
mod is_active;
mod subtitle;
mod title;
mod tooltip;
pub mod value;
pub use error::Error;
pub use value::Value;
use crate::profile::Profile;
use gtk::{
gio::TlsCertificate,
glib::{self, Object},
};
use std::rc::Rc;
glib::wrapper! {
pub struct Item(ObjectSubclass<imp::Item>);
}
// C-type property `value` conversion for `Item`
// * values > 0 reserved for `profile_identity_id`
const G_VALUE_GENERATE_PEM: i64 = 0;
const G_VALUE_IMPORT_PEM: i64 = -1;
const G_VALUE_GUEST_SESSION: i64 = -2;
impl Item {
// Constructors
pub fn new_guest_session() -> Self {
Object::builder()
.property("value", G_VALUE_GUEST_SESSION)
.property("title", "Guest session")
.property("subtitle", "No identity for this request")
.build()
}
pub fn new_generate_pem() -> Self {
Object::builder()
.property("value", G_VALUE_GENERATE_PEM)
.property("title", "Create new")
.property("subtitle", "Generate long-term certificate")
.build()
}
pub fn new_import_pem() -> Self {
Object::builder()
.property("value", G_VALUE_IMPORT_PEM)
.property("title", "Import identity")
.property("subtitle", "Use existing certificate")
.build()
}
pub fn new_profile_identity_id(
profile: &Rc<Profile>,
profile_identity_id: i64,
auth_url: &str,
) -> Result<Self, Error> {
// Get PEM by ID
match profile.identity.memory.get(profile_identity_id) {
// Extract certificate details from PEM string
Ok(ref pem) => match TlsCertificate::from_pem(pem) {
// Collect certificate scopes for item
Ok(ref certificate) => {
let scope = &profile.identity.auth.scope(profile_identity_id);
Ok(Object::builder()
.property("value", profile_identity_id)
.property("title", title::new_for_profile_identity_id(certificate))
.property(
"subtitle",
subtitle::new_for_profile_identity_id(certificate, scope),
)
.property(
"tooltip",
tooltip::new_for_profile_identity_id(certificate, scope),
)
.property(
"is-active",
is_active::new_for_profile_identity_id(
profile,
profile_identity_id,
auth_url,
),
)
.build())
}
Err(e) => Err(Error::TlsCertificate(e)),
},
Err(_) => todo!(),
}
}
// Actions
/// Update properties for `Self` for given `Profile` and `auth_url`
pub fn update(&self, profile: &Rc<Profile>, auth_url: &str) -> Result<(), Error> {
// Update item depending on value type
match self.value_enum() {
Value::ProfileIdentityId(profile_identity_id) => {
// Get PEM by ID
match profile.identity.memory.get(profile_identity_id) {
// Extract certificate details from PEM string
Ok(ref pem) => match TlsCertificate::from_pem(pem) {
Ok(ref certificate) => {
// Get current scope
let scope = &profile.identity.auth.scope(profile_identity_id);
// Update properties
self.set_title(title::new_for_profile_identity_id(certificate));
self.set_subtitle(subtitle::new_for_profile_identity_id(
certificate,
scope,
));
self.set_tooltip(tooltip::new_for_profile_identity_id(
certificate,
scope,
));
self.set_is_active(is_active::new_for_profile_identity_id(
profile,
profile_identity_id,
auth_url,
));
// @TODO emit update request
}
Err(e) => return Err(Error::TlsCertificate(e)),
},
Err(_) => todo!(),
}
}
_ => {
// nothing to update yet..
}
}
Ok(()) // @TODO
}
// Getters
/// Get `Self` C-value as `Value`
pub fn value_enum(&self) -> Value {
match self.value() {
G_VALUE_GENERATE_PEM => Value::GeneratePem,
G_VALUE_GUEST_SESSION => Value::GuestSession,
G_VALUE_IMPORT_PEM => Value::ImportPem,
value => Value::ProfileIdentityId(value),
}
}
}

View file

@ -0,0 +1,16 @@
use std::fmt::{Display, Formatter, Result};
#[derive(Debug)]
pub enum Error {
TlsCertificate(gtk::glib::Error),
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter) -> Result {
match self {
Self::TlsCertificate(e) => {
write!(f, "TLS certificate error `{e}`")
}
}
}
}

View file

@ -0,0 +1,38 @@
//! Custom `GObject` implementation for dropdown
//! [ListStore](https://docs.gtk.org/gio/class.ListStore.html) menu item
use gtk::{
gio::subclass::prelude::{DerivedObjectProperties, ObjectImpl, ObjectImplExt, ObjectSubclass},
glib::{self, Object, Properties},
prelude::ObjectExt,
};
use std::cell::{Cell, RefCell};
#[derive(Properties, Default)]
#[properties(wrapper_type = super::Item)]
pub struct Item {
#[property(get, set)]
value: Cell<i64>,
#[property(get, set)]
title: RefCell<String>,
#[property(get, set)]
subtitle: RefCell<String>,
#[property(get, set)]
tooltip: RefCell<String>,
#[property(get, set)]
is_active: Cell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for Item {
const NAME: &'static str = "Item"; // @TODO make globally unique
type Type = super::Item;
type ParentType = Object;
}
#[glib::derived_properties]
impl ObjectImpl for Item {
fn constructed(&self) {
self.parent_constructed();
}
}

View file

@ -0,0 +1,13 @@
use crate::profile::Profile;
use std::rc::Rc;
pub fn new_for_profile_identity_id(
profile: &Rc<Profile>,
profile_identity_id: i64,
auth_url: &str,
) -> bool {
profile
.identity
.auth
.is_matches(auth_url, profile_identity_id) // @TODO direct call?
}

View file

@ -0,0 +1,20 @@
use gtk::{gio::TlsCertificate, prelude::TlsCertificateExt};
const DATE_FORMAT: &str = "%Y.%m.%d";
pub fn new_for_profile_identity_id(certificate: &TlsCertificate, scope: &[String]) -> String {
format!(
"{} - {} | scope: {}",
certificate
.not_valid_before()
.unwrap() // @TODO
.format(DATE_FORMAT)
.unwrap(),
certificate
.not_valid_after()
.unwrap() // @TODO
.format(DATE_FORMAT)
.unwrap(),
scope.len(),
)
}

View file

@ -0,0 +1,8 @@
use gtk::{gio::TlsCertificate, glib::gformat, prelude::TlsCertificateExt};
pub fn new_for_profile_identity_id(certificate: &TlsCertificate) -> String {
certificate
.subject_name()
.unwrap_or(gformat!("Unknown"))
.replace("CN=", "")
}

View file

@ -0,0 +1,37 @@
use gtk::{gio::TlsCertificate, prelude::TlsCertificateExt};
pub fn new_for_profile_identity_id(certificate: &TlsCertificate, scope: &[String]) -> String {
let mut tooltip = "<b>Certificate</b>\n".to_string();
if let Some(subject_name) = certificate.subject_name() {
tooltip.push_str(&format!("\n<small><b>subject</b>\n{subject_name}</small>"));
}
if let Some(issuer_name) = certificate.issuer_name() {
tooltip.push_str(&format!("\n<small><b>issuer</b>\n{issuer_name}</small>"));
}
if let Some(not_valid_before) = certificate.not_valid_before() {
if let Ok(timestamp) = not_valid_before.format_iso8601() {
tooltip.push_str(&format!("\n<small><b>valid after</b>\n{timestamp}</small>"));
}
}
if let Some(not_valid_after) = certificate.not_valid_after() {
if let Ok(timestamp) = not_valid_after.format_iso8601() {
tooltip.push_str(&format!(
"\n<small><b>valid before</b>\n{timestamp}</small>"
));
}
}
if !scope.is_empty() {
tooltip.push_str("\n\n<b>Scope</b>\n");
for path in scope {
tooltip.push_str(&format!("\n<small>{}</small>", path));
}
}
tooltip
}

View file

@ -0,0 +1,7 @@
#[derive(Debug)]
pub enum Value {
GeneratePem,
GuestSession,
ImportPem,
ProfileIdentityId(i64),
}

View file

@ -0,0 +1,66 @@
use super::WidgetAction;
use gtk::{
glib::GString,
prelude::{EditableExt, EntryExt, WidgetExt},
Entry,
};
use std::rc::Rc;
const PLACEHOLDER_TEXT: &str = "Identity name (required)";
const MARGIN: i32 = 8;
const MIN_LENGTH: u16 = 1;
const MAX_LENGTH: u16 = 36;
pub struct Name {
pub entry: Entry,
}
impl Name {
// Constructors
/// Create new `Self`
pub fn build(widget_action: &Rc<WidgetAction>) -> Self {
// Init main gobject
let entry = Entry::builder()
.margin_top(MARGIN)
.max_length(MAX_LENGTH as i32)
.placeholder_text(PLACEHOLDER_TEXT)
.visible(false)
.build();
// Init events
entry.connect_changed({
let widget_action = widget_action.clone();
move |_| widget_action.update.activate()
});
// Return activated `Self`
Self { entry }
}
// Actions
/// Change visibility status
/// * grab focus on `is_visible` is `true`
pub fn update(&self, is_visible: bool) {
self.entry.set_visible(is_visible);
if is_visible && self.entry.focus_child().is_none() {
self.entry.grab_focus();
}
}
// Getters
pub fn is_valid(&self) -> bool {
self.entry.text_length() >= MIN_LENGTH && self.entry.text_length() <= MAX_LENGTH
}
pub fn value(&self) -> Option<GString> {
let text = self.entry.text();
if text.is_empty() {
None
} else {
Some(text)
}
}
}

View file

@ -0,0 +1,144 @@
mod certificate;
use certificate::Certificate;
use super::list::{item::Value, List};
use crate::profile::Profile;
use gtk::{
gio::{Cancellable, FileCreateFlags, ListStore},
glib::Priority,
prelude::{ButtonExt, FileExt, OutputStreamExtManual, WidgetExt},
Button, FileDialog, FileFilter, Window,
};
use std::{path::MAIN_SEPARATOR, rc::Rc};
const LABEL: &str = "Export";
const TOOLTIP_TEXT: &str = "Export selected identity to file";
const MARGIN: i32 = 8;
pub struct Save {
pub button: Button,
}
impl Save {
// Constructors
/// Create new `Self`
pub fn build(profile: &Rc<Profile>, list: &Rc<List>) -> Self {
// Init main widget
let button = Button::builder()
.label(LABEL)
.margin_top(MARGIN)
.tooltip_text(TOOLTIP_TEXT)
.visible(false)
.build();
// Init events
button.connect_clicked({
let button = button.clone();
let list = list.clone();
let profile = profile.clone();
move |_| {
// Get selected identity from holder
match list.selected().value_enum() {
Value::ProfileIdentityId(profile_identity_id) => {
// Lock open button (prevent double click)
button.set_sensitive(false);
// Create PEM file based on option ID selected
match Certificate::new(profile.clone(), profile_identity_id) {
Ok(certificate) => {
// Init file filters related with PEM extension
let filters = ListStore::new::<FileFilter>();
let filter_all = FileFilter::new();
filter_all.add_pattern("*.*");
filter_all.set_name(Some("All"));
filters.append(&filter_all);
let filter_pem = FileFilter::new();
filter_pem.add_mime_type("application/x-x509-ca-cert");
filter_pem.set_name(Some("Certificate (*.pem)"));
filters.append(&filter_pem);
// Init file dialog
FileDialog::builder()
.default_filter(&filter_pem)
.filters(&filters)
.initial_name(format!(
"{}.pem",
certificate
.name
.trim_matches(MAIN_SEPARATOR)
.replace(MAIN_SEPARATOR, "-")
))
.build()
.save(Window::NONE, Cancellable::NONE, {
let button = button.clone();
move |result| {
match result {
Ok(file) => match file.replace(
None,
false,
FileCreateFlags::NONE,
Cancellable::NONE, // @TODO
) {
Ok(stream) => stream.write_async(
certificate.data,
Priority::DEFAULT,
Cancellable::NONE, // @TODO
{
let button = button.clone();
move |result| match result {
Ok(_) => {
button.set_css_classes(&[
"success",
]);
button.set_label(&format!(
"Saved to {}",
file.parse_name()
))
}
Err((_, e)) => {
button.set_css_classes(&[
"error",
]);
button.set_label(&e.to_string())
}
}
},
),
Err(e) => {
button.set_css_classes(&["error"]);
button.set_label(&e.to_string())
}
},
Err(e) => {
button.set_css_classes(&["warning"]);
button.set_label(e.message())
}
}
button.set_sensitive(true); // unlock
}
});
}
Err(e) => {
button.set_css_classes(&["error"]);
button.set_label(&e.to_string())
}
}
}
_ => todo!(), // unexpected
}
}
});
// Return activated `Self`
Self { button }
}
// Actions
pub fn update(&self, is_visible: bool) {
self.button.set_visible(is_visible)
}
}

View file

@ -0,0 +1,33 @@
mod error;
pub use error::Error;
use crate::profile::Profile;
use gtk::{gio::TlsCertificate, prelude::TlsCertificateExt};
use std::rc::Rc;
/// Certificate details holder for export to file action
pub struct Certificate {
pub data: String,
pub name: String,
}
impl Certificate {
// Constructors
/// Create new `Self`
pub fn new(profile: Rc<Profile>, profile_identity_id: i64) -> Result<Self, Error> {
match profile.identity.database.record(profile_identity_id) {
Ok(record) => match record {
Some(identity) => match TlsCertificate::from_pem(&identity.pem) {
Ok(certificate) => Ok(Self {
data: identity.pem,
name: certificate.subject_name().unwrap().replace("CN=", ""),
}),
Err(e) => Err(Error::TlsCertificate(e)),
},
None => Err(Error::NotFound(profile_identity_id)),
},
Err(e) => Err(Error::Database(e)),
}
}
}

View file

@ -0,0 +1,25 @@
use gtk::glib;
use std::fmt::{Display, Formatter, Result};
#[derive(Debug)]
pub enum Error {
Database(sqlite::Error),
NotFound(i64),
TlsCertificate(glib::Error),
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter) -> Result {
match self {
Self::Database(e) => {
write!(f, "Database error: {e}")
}
Self::NotFound(profile_identity_id) => {
write!(f, "Record for `{profile_identity_id}` not found")
}
Self::TlsCertificate(e) => {
write!(f, "TLS certificate error: {e}")
}
}
}
}

View file

@ -0,0 +1,44 @@
use adw::{
prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual},
AlertDialog,
};
const HEADING: &str = "Oops";
const BODY: &str = "Identity not supported for this request";
const RESPONSE_QUIT: (&str, &str) = ("close", "Close");
pub trait Unsupported {
fn unsupported() -> Self;
}
impl Unsupported for AlertDialog {
// Construct
/// Create new `Self`
fn unsupported() -> Self {
// Init gobject
let this = AlertDialog::builder()
.heading(HEADING)
.body(BODY)
.close_response(RESPONSE_QUIT.0)
.default_response(RESPONSE_QUIT.0)
.build();
// Set response variants
this.add_responses(&[RESPONSE_QUIT]);
// Decorate default response preset
/* contrast issue with Ubuntu orange accents
this.set_response_appearance(RESPONSE_QUIT.0, ResponseAppearance::Destructive); */
// Init events
this.connect_response(None, move |dialog, response| {
if response == RESPONSE_QUIT.0 {
dialog.close();
}
});
// Return new activated `Self`
this
}
}