From 21bc90512beda86b09e7adbae2fa84e78c84dadb Mon Sep 17 00:00:00 2001 From: Vika Date: Wed, 9 Apr 2025 20:23:55 +0300 Subject: cargo fmt --- src/components/post_editor.rs | 216 ++++++++++++--------- src/components/preferences.rs | 72 ++++--- src/components/signin.rs | 251 +++++++++++++++---------- src/components/smart_summary.rs | 158 +++++++++------- src/components/tag_pill.rs | 4 +- src/lib.rs | 406 ++++++++++++++++++++++++---------------- src/main.rs | 12 +- src/micropub.rs | 50 +++-- src/secrets.rs | 6 +- src/util.rs | 8 +- 10 files changed, 715 insertions(+), 468 deletions(-) (limited to 'src') diff --git a/src/components/post_editor.rs b/src/components/post_editor.rs index d08685a..021ba91 100644 --- a/src/components/post_editor.rs +++ b/src/components/post_editor.rs @@ -1,11 +1,16 @@ -use gettextrs::*; use crate::components::tag_pill::*; use adw::prelude::*; +use gettextrs::*; use glib::translate::IntoGlib; -use relm4::{factory::FactoryVecDeque, gtk, prelude::{Controller, DynamicIndex}, Component, ComponentParts, ComponentSender, RelmWidgetExt}; #[cfg(feature = "smart-summary")] use relm4::prelude::ComponentController; +use relm4::{ + factory::FactoryVecDeque, + gtk, + prelude::{Controller, DynamicIndex}, + Component, ComponentParts, ComponentSender, RelmWidgetExt, +}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)] #[enum_type(name = "MicropubVisibility")] @@ -18,7 +23,7 @@ impl std::fmt::Display for Visibility { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Public => "public", - Self::Private => "private" + Self::Private => "private", }) } } @@ -29,7 +34,7 @@ pub struct Post { pub summary: Option, pub tags: Vec, pub content: String, - pub visibility: Visibility + pub visibility: Visibility, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -39,39 +44,36 @@ pub struct PostConversionSettings { impl Post { pub fn into_mf2(self, settings: PostConversionSettings) -> microformats::types::Item { - use microformats::types::{Item, Class, KnownClass, PropertyValue, Fragment}; + use microformats::types::{Class, Fragment, Item, KnownClass, PropertyValue}; let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]); if let Some(name) = self.name { - mf2.properties.insert( - "name".to_owned(), vec![PropertyValue::Plain(name)] - ); + mf2.properties + .insert("name".to_owned(), vec![PropertyValue::Plain(name)]); } if let Some(summary) = self.summary { - mf2.properties.insert( - "summary".to_owned(), - vec![PropertyValue::Plain(summary)] - ); + mf2.properties + .insert("summary".to_owned(), vec![PropertyValue::Plain(summary)]); } if !self.tags.is_empty() { mf2.properties.insert( "category".to_string(), - self.tags.into_iter().map(PropertyValue::Plain).collect() + self.tags.into_iter().map(PropertyValue::Plain).collect(), ); } mf2.properties.insert( "visibility".to_string(), - vec![PropertyValue::Plain(self.visibility.to_string())] + vec![PropertyValue::Plain(self.visibility.to_string())], ); let content = if settings.send_html_directly { PropertyValue::Fragment(Fragment { html: self.content.clone(), value: self.content, - lang: None + lang: None, }) } else { PropertyValue::Plain(self.content) @@ -86,24 +88,35 @@ impl Post { #[tracker::track] #[derive(Debug)] pub(crate) struct PostEditor { - #[no_eq] smart_summary_busy_guard: Option, + #[no_eq] + smart_summary_busy_guard: Option, sending: bool, - #[do_not_track] #[allow(dead_code)] spell_checker: spelling::Checker, - #[do_not_track] spelling_adapter: spelling::TextBufferAdapter, - - #[do_not_track] name_buffer: gtk::EntryBuffer, - #[do_not_track] summary_buffer: gtk::EntryBuffer, - #[do_not_track] content_buffer: sourceview5::Buffer, - - #[do_not_track] pending_tag_buffer: gtk::EntryBuffer, - #[do_not_track] tags: relm4::factory::FactoryVecDeque, + #[do_not_track] + #[allow(dead_code)] + spell_checker: spelling::Checker, + #[do_not_track] + spelling_adapter: spelling::TextBufferAdapter, + + #[do_not_track] + name_buffer: gtk::EntryBuffer, + #[do_not_track] + summary_buffer: gtk::EntryBuffer, + #[do_not_track] + content_buffer: sourceview5::Buffer, + + #[do_not_track] + pending_tag_buffer: gtk::EntryBuffer, + #[do_not_track] + tags: relm4::factory::FactoryVecDeque, visibility: Visibility, - #[do_not_track] narrow_layout: gtk::BoxLayout, + #[do_not_track] + narrow_layout: gtk::BoxLayout, #[cfg(feature = "smart-summary")] - #[do_not_track] smart_summary: Controller, - _err: std::marker::PhantomData + #[do_not_track] + smart_summary: Controller, + _err: std::marker::PhantomData, } impl PostEditor { @@ -120,13 +133,17 @@ impl PostEditor { #[allow(clippy::manual_non_exhaustive)] // false positive pub enum Input { #[cfg(feature = "smart-summary")] - #[doc(hidden)] SmartSummary(crate::components::smart_summary::Output), - #[doc(hidden)] VisibilitySelected(Visibility), - #[doc(hidden)] AddTagFromBuffer, - #[doc(hidden)] RemoveTag(DynamicIndex), + #[doc(hidden)] + SmartSummary(crate::components::smart_summary::Output), + #[doc(hidden)] + VisibilitySelected(Visibility), + #[doc(hidden)] + AddTagFromBuffer, + #[doc(hidden)] + RemoveTag(DynamicIndex), Submit, SubmitDone(glib::Uri), - SubmitError(E) + SubmitError(E), } #[relm4::component(pub)] @@ -340,7 +357,7 @@ impl Component for Post fn init( init: Self::Init, root: Self::Root, - sender: ComponentSender + sender: ComponentSender, ) -> ComponentParts { #[cfg(feature = "smart-summary")] let (http, init) = init; @@ -352,17 +369,13 @@ impl Component for Post smart_summary_busy_guard: None, sending: false, - spelling_adapter: spelling::TextBufferAdapter::new( - &content_buffer, - &spell_checker - ), + spelling_adapter: spelling::TextBufferAdapter::new(&content_buffer, &spell_checker), spell_checker, name_buffer: gtk::EntryBuffer::default(), summary_buffer: gtk::EntryBuffer::default(), content_buffer, - pending_tag_buffer: gtk::EntryBuffer::default(), tags: FactoryVecDeque::builder() .launch({ @@ -375,10 +388,9 @@ impl Component for Post listbox.set_layout_manager(Some(layout)); listbox }) - .forward( - sender.input_sender(), - |del: TagPillDelete| Input::RemoveTag(del.0) - ), + .forward(sender.input_sender(), |del: TagPillDelete| { + Input::RemoveTag(del.0) + }), visibility: Visibility::Public, narrow_layout: gtk::BoxLayout::builder() @@ -403,15 +415,15 @@ impl Component for Post model.spelling_adapter.set_enabled(true); - widgets.visibility_selector.set_expression(Some( - gtk::ClosureExpression::new::( + widgets + .visibility_selector + .set_expression(Some(gtk::ClosureExpression::new::( [] as [gtk::Expression; 0], glib::closure::RustClosure::new(|v| { let list_item = v[0].get::().unwrap(); Some(gettext(list_item.name().as_str()).into()) - }) - ) - )); + }), + ))); if let Some(post) = init { if let Some(name) = post.name { @@ -422,55 +434,68 @@ impl Component for Post } let mut tags = model.tags.guard(); - post.tags.into_iter().for_each(|t| { tags.push_back(t.into_boxed_str()); }); + post.tags.into_iter().for_each(|t| { + tags.push_back(t.into_boxed_str()); + }); model.content_buffer.set_text(&post.content); - widgets.visibility_selector.set_selected( - visibility_model.find_position(post.visibility.into_glib()) - ); + widgets + .visibility_selector + .set_selected(visibility_model.find_position(post.visibility.into_glib())); model.visibility = post.visibility; } ComponentParts { model, widgets } } - fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: ComponentSender, root: &Self::Root) { + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: Self::Input, + sender: ComponentSender, + root: &Self::Root, + ) { self.reset(); match msg { #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Start) => { widgets.content_textarea.set_sensitive(false); if self.content_buffer.char_count() == 0 { - let _ = self.smart_summary.sender().send( - crate::components::SmartSummaryInput::Cancel - ); + let _ = self + .smart_summary + .sender() + .send(crate::components::SmartSummaryInput::Cancel); } else { let text = self.content_buffer.text( &self.content_buffer.start_iter(), &self.content_buffer.end_iter(), - false + false, ); - self.set_smart_summary_busy_guard( - Some(relm4::main_adw_application().mark_busy()) - ); - if self.smart_summary.sender().send( - crate::components::SmartSummaryInput::Text(text.into()) - ).is_ok() { + self.set_smart_summary_busy_guard(Some( + relm4::main_adw_application().mark_busy(), + )); + if self + .smart_summary + .sender() + .send(crate::components::SmartSummaryInput::Text(text.into())) + .is_ok() + { self.summary_buffer.set_text(""); } } widgets.content_textarea.set_sensitive(true); - }, + } #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Chunk(text)) => { - self.summary_buffer.insert_text(self.summary_buffer.length(), text); - }, + self.summary_buffer + .insert_text(self.summary_buffer.length(), text); + } #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Done) => { self.set_smart_summary_busy_guard(None); - }, + } #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Error(err)) => { self.set_smart_summary_busy_guard(None); @@ -479,44 +504,51 @@ impl Component for Post toast.set_timeout(0); toast.set_priority(adw::ToastPriority::High); root.add_toast(toast); - }, + } Input::VisibilitySelected(vis) => { log::debug!("Changed visibility: {}", vis); self.visibility = vis; - }, + } Input::AddTagFromBuffer => { let tag = String::from(self.pending_tag_buffer.text()); if !tag.is_empty() { - self.tags.guard().push_back( - tag.into_boxed_str() - ); + self.tags.guard().push_back(tag.into_boxed_str()); self.pending_tag_buffer.set_text(""); } - }, + } Input::RemoveTag(idx) => { self.tags.guard().remove(idx.current_index()); - }, + } Input::Submit => { self.sending = true; let post = if self.content_buffer.char_count() > 0 { Some(Post { name: if self.name_buffer.length() > 0 { Some(self.name_buffer.text().into()) - } else { None }, + } else { + None + }, summary: if self.summary_buffer.length() > 0 { Some(self.summary_buffer.text().into()) - } else { None }, + } else { + None + }, tags: self.tags.iter().map(|t| t.0.clone().into()).collect(), - content: self.content_buffer.text( - &self.content_buffer.start_iter(), - &self.content_buffer.end_iter(), - false - ).into(), + content: self + .content_buffer + .text( + &self.content_buffer.start_iter(), + &self.content_buffer.end_iter(), + false, + ) + .into(), visibility: self.visibility, }) - } else { None }; + } else { + None + }; let _ = sender.output(post); - }, + } Input::SubmitDone(location) => { self.name_buffer.set_text(""); self.summary_buffer.set_text(""); @@ -528,18 +560,22 @@ impl Component for Post gtk::UriLauncher::new(&location.to_string()).launch( None::<&adw::ApplicationWindow>, None::<&gio::Cancellable>, - glib::clone!(#[weak] toast, move |result| { - if let Err(err) = result { - log::warn!("Error opening post URI: {}", err); - } else { - toast.dismiss() + glib::clone!( + #[weak] + toast, + move |result| { + if let Err(err) = result { + log::warn!("Error opening post URI: {}", err); + } else { + toast.dismiss() + } } - }) + ), ); }); root.add_toast(toast); - }, + } Input::SubmitError(err) => { let toast = adw::Toast::new(&gettext!("Error sending post: {}", err)); toast.set_timeout(0); diff --git a/src/components/preferences.rs b/src/components/preferences.rs index 67075a2..2b3e640 100644 --- a/src/components/preferences.rs +++ b/src/components/preferences.rs @@ -1,7 +1,7 @@ use gettextrs::*; -use gio::prelude::*; use adw::prelude::*; +use gio::prelude::*; use relm4::prelude::*; pub struct Preferences { @@ -19,7 +19,9 @@ impl ComposerPreferencesWidgets { fn new(settings: &gio::Settings) -> Self { let page = adw::PreferencesPage::builder() .title(gettext("Post composer")) - .description(gettext("Settings for composing new posts and editing existing ones.")) + .description(gettext( + "Settings for composing new posts and editing existing ones.", + )) .icon_name("editor-symbolic") .build(); let general_group = adw::PreferencesGroup::builder() @@ -32,20 +34,21 @@ impl ComposerPreferencesWidgets { let widgets = Self { page, general_group, - send_html_directly + send_html_directly, }; let schema = settings.settings_schema().unwrap(); #[expect(clippy::single_element_loop)] - for (row, key, property) in [ - (widgets.send_html_directly.upcast_ref::(), "send-html-directly", "active"), - ] { + for (row, key, property) in [( + widgets + .send_html_directly + .upcast_ref::(), + "send-html-directly", + "active", + )] { let key_data = schema.key(key); - settings.bind(key, row, property) - .get() - .set() - .build(); + settings.bind(key, row, property).get().set().build(); row.set_title(&gettext(key_data.summary().unwrap())); row.set_tooltip_markup(key_data.description().map(gettext).as_deref()); } @@ -62,7 +65,7 @@ struct LanguageModelPreferencesWidgets { general_group: adw::PreferencesGroup, llm_endpoint: adw::EntryRow, smart_summary_show_warning: adw::SwitchRow, - + smart_summary_group: adw::PreferencesGroup, smart_summary_model: adw::EntryRow, smart_summary_system_prompt: adw::EntryRow, @@ -112,24 +115,45 @@ impl LanguageModelPreferencesWidgets { smart_summary_model, smart_summary_system_prompt, smart_summary_prompt_prefix, - smart_summary_prompt_suffix + smart_summary_prompt_suffix, }; let schema = settings.settings_schema().unwrap(); for (row, key, property) in [ - (widgets.llm_endpoint.upcast_ref::(), "llm-endpoint", "text"), - (widgets.smart_summary_show_warning.upcast_ref::<_>(), "smart-summary-show-warning", "active"), - (widgets.smart_summary_model.upcast_ref::<_>(), "smart-summary-model", "text"), - (widgets.smart_summary_system_prompt.upcast_ref::<_>(), "smart-summary-system-prompt", "text"), - (widgets.smart_summary_prompt_prefix.upcast_ref::<_>(), "smart-summary-prompt-prefix", "text"), - (widgets.smart_summary_prompt_suffix.upcast_ref::<_>(), "smart-summary-prompt-suffix", "text"), + ( + widgets.llm_endpoint.upcast_ref::(), + "llm-endpoint", + "text", + ), + ( + widgets.smart_summary_show_warning.upcast_ref::<_>(), + "smart-summary-show-warning", + "active", + ), + ( + widgets.smart_summary_model.upcast_ref::<_>(), + "smart-summary-model", + "text", + ), + ( + widgets.smart_summary_system_prompt.upcast_ref::<_>(), + "smart-summary-system-prompt", + "text", + ), + ( + widgets.smart_summary_prompt_prefix.upcast_ref::<_>(), + "smart-summary-prompt-prefix", + "text", + ), + ( + widgets.smart_summary_prompt_suffix.upcast_ref::<_>(), + "smart-summary-prompt-suffix", + "text", + ), ] { let key_data = schema.key(key); - settings.bind(key, row, property) - .get() - .set() - .build(); + settings.bind(key, row, property).get().set().build(); row.set_title(&gettext(key_data.summary().unwrap())); row.set_tooltip_markup(key_data.description().map(gettext).as_deref()); } @@ -179,9 +203,7 @@ impl Component for Preferences { root.connect_closed(glib::clone!( #[strong(rename_to = settings)] model.settings, - move |_| { - settings.apply() - } + move |_| { settings.apply() } )); ComponentParts { model, widgets } diff --git a/src/components/signin.rs b/src/components/signin.rs index f2b5313..972ab43 100644 --- a/src/components/signin.rs +++ b/src/components/signin.rs @@ -2,7 +2,9 @@ use gettextrs::*; use std::cell::RefCell; use adw::prelude::*; -use kittybox_indieauth::{AuthorizationRequest, AuthorizationResponse, Error as IndieauthError, GrantResponse, Metadata}; +use kittybox_indieauth::{ + AuthorizationRequest, AuthorizationResponse, Error as IndieauthError, GrantResponse, Metadata, +}; use relm4::prelude::*; use soup::prelude::{ServerExt, ServerExtManual, SessionExt}; @@ -33,7 +35,7 @@ pub struct SignIn { state: kittybox_indieauth::State, code_verifier: kittybox_indieauth::PKCEVerifier, micropub_uri: Option, - metadata: Option + metadata: Option, } #[derive(Debug, thiserror::Error)] @@ -67,7 +69,10 @@ pub enum Input { Callback(Result), } -pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metadata, glib::Uri), Error> { +pub async fn get_metadata( + http: soup::Session, + url: glib::Uri, +) -> Result<(Metadata, glib::Uri), Error> { // Fire off a speculative request at the well-known URI. This could // improve UX by parallelizing how we query the user's website. // @@ -75,15 +80,13 @@ pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metada // RECOMMENDED though optional according to IndieAuth specification // ยงย 4.1.1, so we could use that if it's there to speed up the // process. - let metadata = relm4::spawn_local( - SignIn::well_known_metadata(http.clone(), url.clone()) - ); + let metadata = relm4::spawn_local(SignIn::well_known_metadata(http.clone(), url.clone())); let msg = soup::Message::from_uri("GET", &url); let body = http.send_future(&msg, glib::Priority::DEFAULT).await?; let mf2 = microformats::from_reader( std::io::BufReader::new(body.into_read()), - url.to_string().parse().unwrap() + url.to_string().parse().unwrap(), )?; let rels = mf2.rels.by_rels(); @@ -98,7 +101,7 @@ pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metada // The new versions are superior by providing more features that // were previously proprietary extensions, and are more clearer in // general. - return Err(Error::MetadataNotFound) + return Err(Error::MetadataNotFound); }; let micropub_uri = if let Some(url) = rels @@ -108,26 +111,33 @@ pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metada { glib::Uri::parse(url.as_str(), glib::UriFlags::NONE).unwrap() } else { - return Err(Error::MicropubLinkNotFound) + return Err(Error::MicropubLinkNotFound); }; if let Ok(Some(metadata)) = metadata.await { Ok((metadata, micropub_uri)) } else { let msg = soup::Message::from_uri("GET", &metadata_url); - msg.request_headers().unwrap().append("Accept", "application/json"); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { + msg.request_headers() + .unwrap() + .append("Accept", "application/json"); + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { Ok(body) if msg.status() == soup::Status::Ok => { let metadata = serde_json::from_slice(&body)?; Ok((metadata, micropub_uri)) - }, + } Ok(_) => Err(Error::MetadataEndpointFailed(msg.status())), Err(err) => Err(err.into()), } } } -fn callback_handler(sender: AsyncComponentSender) -> impl Fn(&soup::Server, &soup::ServerMessage, &str, std::collections::HashMap<&str, &str>) { +fn callback_handler( + sender: AsyncComponentSender, +) -> impl Fn(&soup::Server, &soup::ServerMessage, &str, std::collections::HashMap<&str, &str>) { move |server, msg, _, _| { let server = ObjectExt::downgrade(server); let sender = sender.clone(); @@ -148,7 +158,7 @@ fn callback_handler(sender: AsyncComponentSender) -> impl Fn(&soup::Serv msg.set_response( Some("text/plain; charset=\"utf-8\""), soup::MemoryUse::Static, - gettext("Thank you! This window can now be closed.").as_bytes() + gettext("Thank you! This window can now be closed.").as_bytes(), ); msg.connect_finished(move |_| { sender.input(Input::Callback(Ok(response.take().unwrap()))); @@ -157,7 +167,7 @@ fn callback_handler(sender: AsyncComponentSender) -> impl Fn(&soup::Serv soup::prelude::ServerExt::disconnect(&server); } }); - }, + } Err(err) => { msg.set_status(400, soup::Status::phrase(400).as_deref()); if let Ok(err) = serde_urlencoded::from_str::(q.as_str()) { @@ -191,15 +201,21 @@ impl SignIn { kittybox_indieauth::Scopes::new(vec![ kittybox_indieauth::Scope::Profile, kittybox_indieauth::Scope::Create, - kittybox_indieauth::Scope::Media + kittybox_indieauth::Scope::Media, ]) } - fn bail_out(&mut self, widgets: &mut ::Widgets, sender: AsyncComponentSender, err: Error) { - widgets.toasts.add_toast(adw::Toast::builder() - .title(err.to_string()) - .priority(adw::ToastPriority::High) - .build() + fn bail_out( + &mut self, + widgets: &mut ::Widgets, + sender: AsyncComponentSender, + err: Error, + ) { + widgets.toasts.add_toast( + adw::Toast::builder() + .title(err.to_string()) + .priority(adw::ToastPriority::High) + .build(), ); // Reset all the state for the component for security reasons. self.busy_guard = None; @@ -212,31 +228,45 @@ impl SignIn { } async fn well_known_metadata(http: soup::Session, url: glib::Uri) -> Option { - let well_known = url.parse_relative( - Self::WELL_KNOWN_METADATA_ENDPOINT_PATH, - glib::UriFlags::NONE - ).unwrap(); + let well_known = url + .parse_relative( + Self::WELL_KNOWN_METADATA_ENDPOINT_PATH, + glib::UriFlags::NONE, + ) + .unwrap(); // Speculatively check for metadata at the well-known path let msg = soup::Message::from_uri("GET", &well_known); - msg.request_headers().unwrap().append("Accept", "application/json"); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { - Ok(body) if msg.status() == soup::Status::Ok => { - match serde_json::from_slice(&body) { - Ok(metadata) => { - log::info!("Speculative metadata request successful: {:#?}", metadata); - Some(metadata) - }, - Err(err) => { - log::warn!("Parsing OAuth2 metadata from {} failed: {}", well_known, err); - None - } + msg.request_headers() + .unwrap() + .append("Accept", "application/json"); + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { + Ok(body) if msg.status() == soup::Status::Ok => match serde_json::from_slice(&body) { + Ok(metadata) => { + log::info!("Speculative metadata request successful: {:#?}", metadata); + Some(metadata) + } + Err(err) => { + log::warn!( + "Parsing OAuth2 metadata from {} failed: {}", + well_known, + err + ); + None } }, Ok(_) => { - log::warn!("Speculative request to {} returned {:?} ({})", well_known, msg.status(), msg.reason_phrase().unwrap()); + log::warn!( + "Speculative request to {} returned {:?} ({})", + well_known, + msg.status(), + msg.reason_phrase().unwrap() + ); None - }, + } Err(err) => { log::warn!("Speculative request to {} failed: {}", well_known, err); @@ -344,7 +374,13 @@ impl AsyncComponent for SignIn { std::future::ready(AsyncComponentParts { model, widgets }) } - async fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: AsyncComponentSender, _root: &Self::Root) { + async fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: Self::Input, + sender: AsyncComponentSender, + _root: &Self::Root, + ) { match msg { Input::Start => { self.busy_guard = Some(relm4::main_adw_application().mark_busy()); @@ -356,30 +392,25 @@ impl AsyncComponent for SignIn { None => { self.me_buffer.insert_text(0, "https://"); url = self.me_buffer.text().into(); - }, + } Some(scheme) => { if scheme != "https" && scheme != "http" { - return self.bail_out( - widgets, sender, - Error::WrongScheme - ); + return self.bail_out(widgets, sender, Error::WrongScheme); } - }, + } } let url = match glib::Uri::parse(url.as_str(), glib::UriFlags::SCHEME_NORMALIZE) { Ok(url) => url, Err(err) => { - return self.bail_out( - widgets, sender, - err.into() - ); - }, + return self.bail_out(widgets, sender, err.into()); + } }; - let (metadata, micropub_uri) = match get_metadata(self.http.clone(), url.clone()).await { - Ok((metadata, micropub_uri)) => (metadata, micropub_uri), - Err(err) => return self.bail_out(widgets, sender, err) - }; + let (metadata, micropub_uri) = + match get_metadata(self.http.clone(), url.clone()).await { + Ok((metadata, micropub_uri)) => (metadata, micropub_uri), + Err(err) => return self.bail_out(widgets, sender, err), + }; let auth_request = AuthorizationRequest { response_type: kittybox_indieauth::ResponseType::Code, @@ -387,15 +418,17 @@ impl AsyncComponent for SignIn { redirect_uri: REDIRECT_URI.parse().unwrap(), state: self.state.clone(), code_challenge: kittybox_indieauth::PKCEChallenge::new( - &self.code_verifier, kittybox_indieauth::PKCEMethod::S256 + &self.code_verifier, + kittybox_indieauth::PKCEMethod::S256, ), scope: Some(Self::scopes()), - me: Some(url.to_str().parse().unwrap()) + me: Some(url.to_str().parse().unwrap()), }; let auth_url = { let mut url = metadata.authorization_endpoint.clone(); - url.query_pairs_mut().extend_pairs(auth_request.as_query_pairs()); + url.query_pairs_mut() + .extend_pairs(auth_request.as_query_pairs()); url }; @@ -407,40 +440,57 @@ impl AsyncComponent for SignIn { server.add_handler(None, callback_handler(sender.clone())); match server.listen_local(60000, soup::ServerListenOptions::empty()) { Ok(()) => server, - Err(err) => return self.bail_out(widgets, sender, err.into()) + Err(err) => return self.bail_out(widgets, sender, err.into()), } }); - if let Err(err) = gtk::UriLauncher::new(auth_url.as_str()).launch_future( - None::<&adw::ApplicationWindow> - ).await { - return self.bail_out(widgets, sender, err.into()) + if let Err(err) = gtk::UriLauncher::new(auth_url.as_str()) + .launch_future(None::<&adw::ApplicationWindow>) + .await + { + return self.bail_out(widgets, sender, err.into()); }; self.busy_guard = None; self.update_view(widgets, sender); - }, + } Input::Callback(Ok(res)) => { // Immediately drop the event if we didn't take a server. - if self.callback_server.take().is_none() { return; } + if self.callback_server.take().is_none() { + return; + } self.busy_guard = Some(relm4::main_adw_application().mark_busy()); let metadata = self.metadata.take().unwrap(); let micropub_uri = self.micropub_uri.take().unwrap(); if res.state != self.state { - return self.bail_out(widgets, sender, IndieauthError { - kind: kittybox_indieauth::ErrorKind::InvalidRequest, - msg: Some(gettext("state doesn't match what we remember, ceremony aborted")), - error_uri: None, - }.into()) + return self.bail_out( + widgets, + sender, + IndieauthError { + kind: kittybox_indieauth::ErrorKind::InvalidRequest, + msg: Some(gettext( + "state doesn't match what we remember, ceremony aborted", + )), + error_uri: None, + } + .into(), + ); } if res.iss != metadata.issuer { - return self.bail_out(widgets, sender, IndieauthError { - kind: kittybox_indieauth::ErrorKind::InvalidRequest, - msg: Some(gettext("issuer doesn't match what we remember, ceremony aborted")), - error_uri: None, - }.into()) + return self.bail_out( + widgets, + sender, + IndieauthError { + kind: kittybox_indieauth::ErrorKind::InvalidRequest, + msg: Some(gettext( + "issuer doesn't match what we remember, ceremony aborted", + )), + error_uri: None, + } + .into(), + ); } let code = res.code; @@ -450,21 +500,28 @@ impl AsyncComponent for SignIn { redirect_uri: REDIRECT_URI.parse().unwrap(), code_verifier: std::mem::replace( &mut self.code_verifier, - kittybox_indieauth::PKCEVerifier::new() - ) + kittybox_indieauth::PKCEVerifier::new(), + ), }; - let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE).unwrap(); + let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE) + .unwrap(); let msg = soup::Message::from_uri("POST", &url); let headers = msg.request_headers().unwrap(); headers.append("Accept", "application/json"); msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string(token_grant).unwrap().into_bytes() - )) + serde_urlencoded::to_string(token_grant) + .unwrap() + .into_bytes(), + )), ); - match self.http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { + match self + .http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { Ok(body) if msg.status() == soup::Status::Ok => { match serde_json::from_slice::(&body) { Ok(GrantResponse::ProfileUrl(_)) => unreachable!(), @@ -476,16 +533,16 @@ impl AsyncComponent for SignIn { state: _, expires_in, profile, - refresh_token + refresh_token, }) => { let _ = sender.output(Output { - me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(), + me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE) + .unwrap(), scope: scope.unwrap_or_else(Self::scopes), micropub: micropub_uri, - userinfo: metadata.userinfo_endpoint - .map(|u| glib::Uri::parse( - u.as_str(), glib::UriFlags::NONE - ).unwrap()), + userinfo: metadata.userinfo_endpoint.map(|u| { + glib::Uri::parse(u.as_str(), glib::UriFlags::NONE).unwrap() + }), access_token, refresh_token, expires_in: expires_in.map(std::time::Duration::from_secs), @@ -493,22 +550,18 @@ impl AsyncComponent for SignIn { }); self.busy_guard = None; self.update_view(widgets, sender); - }, + } Err(err) => self.bail_out(widgets, sender, err.into()), } + } + Ok(body) => match serde_json::from_slice::(&body) { + Ok(err) => self.bail_out(widgets, sender, err.into()), + Err(err) => self.bail_out(widgets, sender, err.into()), }, - Ok(body) => { - match serde_json::from_slice::(&body) { - Ok(err) => self.bail_out(widgets, sender, err.into()), - Err(err) => self.bail_out(widgets, sender, err.into()) - } - }, - Err(err) => self.bail_out(widgets, sender, err.into()) + Err(err) => self.bail_out(widgets, sender, err.into()), } - }, - Input::Callback(Err(err)) => { - self.bail_out(widgets, sender, err) - }, + } + Input::Callback(Err(err)) => self.bail_out(widgets, sender, err), } } diff --git a/src/components/smart_summary.rs b/src/components/smart_summary.rs index de6eb91..e876195 100644 --- a/src/components/smart_summary.rs +++ b/src/components/smart_summary.rs @@ -1,10 +1,14 @@ #![cfg(feature = "smart-summary")] +use adw::prelude::*; use futures::AsyncBufReadExt; +use gettextrs::*; use gio::prelude::SettingsExtManual; +use relm4::{ + gtk, + prelude::{Component, ComponentParts}, + ComponentSender, +}; use soup::prelude::*; -use adw::prelude::*; -use gettextrs::*; -use relm4::{gtk, prelude::{Component, ComponentParts}, ComponentSender}; // All of this is incredibly minimalist. // This should be expanded later. @@ -23,7 +27,7 @@ pub(crate) struct OllamaChunk { #[derive(Debug, serde::Deserialize)] pub(crate) struct OllamaError { - error: String + error: String, } impl std::error::Error for OllamaError {} impl std::fmt::Display for OllamaError { @@ -43,12 +47,11 @@ impl From for Result { fn from(val: OllamaResult) -> Self { match val { OllamaResult::Ok(chunk) => Ok(chunk), - OllamaResult::Err(err) => Err(err) + OllamaResult::Err(err) => Err(err), } } } - #[derive(Debug, Default)] pub(crate) struct SmartSummaryButton { task: Option>, @@ -65,41 +68,48 @@ impl SmartSummaryButton { ) { let settings = gio::Settings::new(crate::APPLICATION_ID); // We shouldn't let the user record a bad setting anyway. - let endpoint = glib::Uri::parse( - &settings.string("llm-endpoint"), - glib::UriFlags::NONE, - ).unwrap(); + let endpoint = + glib::Uri::parse(&settings.string("llm-endpoint"), glib::UriFlags::NONE).unwrap(); let model = settings.get::("smart-summary-model"); let system_prompt = settings.get::("smart-summary-system-prompt"); let prompt_prefix = settings.get::("smart-summary-prompt-prefix"); let mut prompt_suffix = settings.get::("smart-summary-prompt-suffix"); - let endpoint = endpoint.parse_relative("./api/generate", glib::UriFlags::NONE).unwrap(); + let endpoint = endpoint + .parse_relative("./api/generate", glib::UriFlags::NONE) + .unwrap(); log::debug!("endpoint: {}, model: {}", endpoint, model); log::debug!("system prompt: {}", system_prompt); - let msg = soup::Message::from_uri( - "POST", - &endpoint - ); + let msg = soup::Message::from_uri("POST", &endpoint); if !prompt_suffix.is_empty() { prompt_suffix = String::from("\n\n") + &prompt_suffix; } - msg.set_request_body_from_bytes(Some("application/json"), - Some(&glib::Bytes::from_owned(serde_json::to_vec(&OllamaRequest { - model, system: system_prompt, prompt: format!("{}\n\n{}{}", prompt_prefix, text, prompt_suffix), - }).unwrap())) + msg.set_request_body_from_bytes( + Some("application/json"), + Some(&glib::Bytes::from_owned( + serde_json::to_vec(&OllamaRequest { + model, + system: system_prompt, + prompt: format!("{}\n\n{}{}", prompt_prefix, text, prompt_suffix), + }) + .unwrap(), + )), ); let mut stream = match http.send_future(&msg, glib::Priority::DEFAULT).await { Ok(stream) => stream.into_async_buf_read(128), Err(err) => { let _ = sender.send(Err(err.into())); - return + return; } }; - log::debug!("response: {:?} ({})", msg.status(), msg.reason_phrase().unwrap_or_default()); + log::debug!( + "response: {:?} ({})", + msg.status(), + msg.reason_phrase().unwrap_or_default() + ); let mut buffer = Vec::with_capacity(2048); const DELIM: u8 = b'\n'; loop { @@ -107,28 +117,36 @@ impl SmartSummaryButton { Ok(len) => len, Err(err) => { let _ = sender.send(Err(err.into())); - return + return; } }; - log::debug!("Got chunk ({} bytes): {}", len, String::from_utf8_lossy(&buffer)); - let response: Result = serde_json::from_slice(&buffer[..len]); + log::debug!( + "Got chunk ({} bytes): {}", + len, + String::from_utf8_lossy(&buffer) + ); + let response: Result = + serde_json::from_slice(&buffer[..len]); match response.map(Result::from) { - Ok(Ok(OllamaChunk { response: chunk, done })) => { + Ok(Ok(OllamaChunk { + response: chunk, + done, + })) => { if !chunk.is_empty() { sender.emit(Ok(chunk)); } if done { sender.emit(Ok(String::new())); - return + return; } - }, + } Ok(Err(err)) => { sender.emit(Err(err.into())); - return + return; } Err(err) => { sender.emit(Err(err.into())); - return + return; } } buffer.truncate(0); @@ -146,13 +164,15 @@ pub(crate) enum Error { #[allow(private_interfaces)] Ollama(#[from] OllamaError), #[error("i/o error: {0}")] - Io(#[from] std::io::Error) + Io(#[from] std::io::Error), } #[derive(Debug)] pub(crate) enum Input { - #[doc(hidden)] ButtonPressed, - #[doc(hidden)] WarningAccepted, + #[doc(hidden)] + ButtonPressed, + #[doc(hidden)] + WarningAccepted, Text(String), Cancel, } @@ -163,7 +183,7 @@ pub(crate) enum Output { Chunk(String), Done, - Error(Error) + Error(Error), } #[relm4::component(pub(crate))] @@ -198,7 +218,7 @@ impl Component for SmartSummaryButton { fn init( init: Self::Init, root: Self::Root, - sender: ComponentSender + sender: ComponentSender, ) -> ComponentParts { let model = Self { http: init, @@ -209,12 +229,7 @@ impl Component for SmartSummaryButton { ComponentParts { model, widgets } } - fn update( - &mut self, - msg: Self::Input, - sender: ComponentSender, - _root: &Self::Root - ) { + fn update(&mut self, msg: Self::Input, sender: ComponentSender, _root: &Self::Root) { match msg { Input::Cancel => { self.waiting = false; @@ -222,23 +237,29 @@ impl Component for SmartSummaryButton { log::debug!("Parent component asked us to cancel."); task.abort(); } else { - log::warn!("Parent component asked us to cancel, but we're not running a task."); + log::warn!( + "Parent component asked us to cancel, but we're not running a task." + ); } - }, + } Input::ButtonPressed => { let settings = gio::Settings::new(crate::APPLICATION_ID); if !settings.get::("smart-summary-show-warning") { self.update(Input::WarningAccepted, sender, _root) } else { // TODO: show warning dialog - let skip_warning_checkbox = gtk::CheckButton::with_label( - &gettext("Show this warning next time") - ); + let skip_warning_checkbox = + gtk::CheckButton::with_label(&gettext("Show this warning next time")); - settings.bind( - "smart-summary-show-warning", - &skip_warning_checkbox, "active" - ).get().set().build(); + settings + .bind( + "smart-summary-show-warning", + &skip_warning_checkbox, + "active", + ) + .get() + .set() + .build(); let dialog = adw::AlertDialog::builder() .heading(gettext("LLMs can be deceiving")) @@ -251,44 +272,51 @@ impl Component for SmartSummaryButton { .build(); dialog.add_responses(&[ ("close", &gettext("Cancel")), - ("continue", &gettext("Proceed")) + ("continue", &gettext("Proceed")), ]); dialog.choose( &_root.root().unwrap(), None::<&gio::Cancellable>, glib::clone!( - #[strong] sender, + #[strong] + sender, move |res| if res.as_str() == "continue" { sender.input(Input::WarningAccepted); } - )) + ), + ) + } + } + Input::WarningAccepted => { + if let Ok(()) = sender.output(Output::Start) { + self.waiting = true; + log::debug!("Requesting text to summarize from parent component..."); + // TODO: set timeout in case parent component never replies + // This shouldn't happen, but I feel like we should handle this case. } - }, - Input::WarningAccepted => if let Ok(()) = sender.output(Output::Start) { - self.waiting = true; - log::debug!("Requesting text to summarize from parent component..."); - // TODO: set timeout in case parent component never replies - // This shouldn't happen, but I feel like we should handle this case. - }, + } Input::Text(text) => { log::debug!("Would generate summary for the following text:\n{}", text); log::debug!("XDG_DATA_DIRS={:?}", std::env::var("XDG_DATA_DIRS")); let sender = sender.command_sender().clone(); - relm4::spawn_local(Self::summarize( - sender, self.http.clone(), text - )); + relm4::spawn_local(Self::summarize(sender, self.http.clone(), text)); } } } - fn update_cmd(&mut self, msg: Self::CommandOutput, sender: ComponentSender, _root: &Self::Root) { + fn update_cmd( + &mut self, + msg: Self::CommandOutput, + sender: ComponentSender, + _root: &Self::Root, + ) { match msg { Ok(chunk) if chunk.is_empty() => { self.task = None; self.waiting = false; let _ = sender.output(Output::Done); - }, + } Err(err) => { self.task = None; self.waiting = false; @@ -296,7 +324,7 @@ impl Component for SmartSummaryButton { } Ok(chunk) => { let _ = sender.output(Output::Chunk(chunk)); - }, + } } } } diff --git a/src/components/tag_pill.rs b/src/components/tag_pill.rs index 0dc9117..89b35af 100644 --- a/src/components/tag_pill.rs +++ b/src/components/tag_pill.rs @@ -70,8 +70,6 @@ impl FactoryComponent for TagPill { root.append(&label); root.append(&button); - Self::Widgets { - label, button - } + Self::Widgets { label, button } } } diff --git a/src/lib.rs b/src/lib.rs index fd4b51c..84379cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,16 @@ -use gettextrs::*; use adw::prelude::*; -use libsecret::prelude::{RetrievableExtManual, RetrievableExt}; -use relm4::{actions::{RelmAction, RelmActionGroup}, gtk, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; +use gettextrs::*; +use libsecret::prelude::{RetrievableExt, RetrievableExtManual}; +use relm4::{ + actions::{RelmAction, RelmActionGroup}, + gtk, + loading_widgets::LoadingWidgets, + prelude::{ + AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, + ComponentController, Controller, + }, + AsyncComponentSender, Component, RelmWidgetExt, +}; pub mod icons { include!(concat!(env!("OUT_DIR"), "/icons.rs")); @@ -11,29 +20,30 @@ pub mod components { pub(crate) mod smart_summary; #[cfg(feature = "smart-summary")] pub(crate) use smart_summary::{ - SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput + Input as SmartSummaryInput, Output as SmartSummaryOutput, SmartSummaryButton, }; pub(crate) mod post_editor; - pub(crate) use post_editor::{ - PostEditor, Input as PostEditorInput - }; + pub(crate) use post_editor::{Input as PostEditorInput, PostEditor}; pub(crate) mod tag_pill; // pub(crate) use tag_pill::{TagPill, TagPillDelete} pub mod signin; - pub use signin::{SignIn, Output as SignInOutput, Error as SignInError}; + pub use signin::{Error as SignInError, Output as SignInOutput, SignIn}; pub mod preferences; pub use preferences::Preferences; } -use components::{post_editor::{Post, PostConversionSettings}, PostEditorInput}; +use components::{ + post_editor::{Post, PostConversionSettings}, + PostEditorInput, +}; use soup::prelude::SessionExt; -pub mod secrets; pub mod micropub; +pub mod secrets; pub mod util; pub const APPLICATION_ID: &str = env!("APP_ID"); pub const CLIENT_ID_STR: &str = "https://kittybox.fireburn.ru/bowl/"; @@ -53,7 +63,11 @@ pub struct App { } impl App { - async fn authorize(schema: &libsecret::Schema, http: soup::Session, data: Box) -> Result { + async fn authorize( + schema: &libsecret::Schema, + http: soup::Session, + data: Box, + ) -> Result { let mut attributes = std::collections::HashMap::new(); let me = data.me.to_string(); let _micropub = data.micropub.to_string(); @@ -62,7 +76,8 @@ impl App { attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN); attributes.insert(secrets::MICROPUB, _micropub.as_str()); attributes.insert(secrets::SCOPE, scope.as_str()); - let exp = data.expires_in + let exp = data + .expires_in .as_ref() .map(std::time::Duration::as_secs) .as_ref() @@ -76,13 +91,15 @@ impl App { attributes.clone(), Some(libsecret::COLLECTION_DEFAULT), &gettext!("Micropub access token for {}", &data.me), - &data.access_token - ).await { - Ok(()) => {}, + &data.access_token, + ) + .await + { + Ok(()) => {} Err(err) => { log::error!("Failed to store access token to the secret store: {}", err); - return Err(err) - }, + return Err(err); + } } if let Some(refresh_token) = data.refresh_token.as_deref() { attributes.insert(secrets::TOKEN_KIND, secrets::REFRESH_TOKEN); @@ -92,28 +109,42 @@ impl App { attributes, Some(libsecret::COLLECTION_DEFAULT), &format!("Micropub refresh token for {}", &data.me), - refresh_token - ).await { - Ok(()) => {}, + refresh_token, + ) + .await + { + Ok(()) => {} Err(err) => { log::error!("Failed to store refresh token to the secret store: {}", err); - return Err(err) - }, + return Err(err); + } } } Ok(micropub::Client::new( - http.clone(), data.micropub.clone(), data.access_token.clone(), me + http.clone(), + data.micropub.clone(), + data.access_token.clone(), + me, )) } - async fn refresh_token(schema: &libsecret::Schema, http: soup::Session, me: String) -> Result, glib::Error> { - let mut retrievables = libsecret::password_search_future(Some(schema), { - let mut attrs = std::collections::HashMap::default(); - attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN); - attrs.insert(crate::secrets::ME, &me); - attrs - }, libsecret::SearchFlags::ALL).await?; + async fn refresh_token( + schema: &libsecret::Schema, + http: soup::Session, + me: String, + ) -> Result, glib::Error> { + let mut retrievables = libsecret::password_search_future( + Some(schema), + { + let mut attrs = std::collections::HashMap::default(); + attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN); + attrs.insert(crate::secrets::ME, &me); + attrs + }, + libsecret::SearchFlags::ALL, + ) + .await?; if retrievables.is_empty() { Ok(None) @@ -127,7 +158,9 @@ impl App { .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let token = retrievable.retrieve_secret_future().await? + let token = retrievable + .retrieve_secret_future() + .await? .unwrap() .text() .unwrap() @@ -135,30 +168,40 @@ impl App { let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?; - let (metadata, micropub_uri) = match crate::components::signin::get_metadata(http.clone(), url).await { - Ok(res) => res, - Err(err) => { - tracing::warn!("failed to fetch metadata to refresh an expired token: {}", err); - return Ok(None) - } - }; + let (metadata, micropub_uri) = + match crate::components::signin::get_metadata(http.clone(), url).await { + Ok(res) => res, + Err(err) => { + tracing::warn!( + "failed to fetch metadata to refresh an expired token: {}", + err + ); + return Ok(None); + } + }; let grant = kittybox_indieauth::GrantRequest::RefreshToken { - refresh_token: token, client_id: CLIENT_ID_STR.parse().unwrap(), scope: None + refresh_token: token, + client_id: CLIENT_ID_STR.parse().unwrap(), + scope: None, }; - let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE).unwrap(); + let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE) + .unwrap(); let msg = soup::Message::from_uri("POST", &url); let headers = msg.request_headers().unwrap(); headers.append("Accept", "application/json"); msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string(grant).unwrap().into_bytes() - )) + serde_urlencoded::to_string(grant).unwrap().into_bytes(), + )), ); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { Ok(body) if msg.status() == soup::Status::Ok => { match serde_json::from_slice::(&body) { Ok(kittybox_indieauth::GrantResponse::ProfileUrl(_)) => unreachable!(), @@ -170,81 +213,96 @@ impl App { state: _, expires_in, profile, - refresh_token + refresh_token, }) => { if refresh_token.is_some() { // Get rid of the old refresh token. - let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; + let _ = + libsecret::password_clear_future(Some(schema), attrs_ref) + .await; }; let micropub = Self::authorize( - schema, http, Box::new(components::SignInOutput { - me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(), + schema, + http, + Box::new(components::SignInOutput { + me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE) + .unwrap(), scope: scope.unwrap_or_else(components::SignIn::scopes), micropub: micropub_uri, - userinfo: metadata.userinfo_endpoint - .map(|u| glib::Uri::parse( - u.as_str(), glib::UriFlags::NONE - ).unwrap()), + userinfo: metadata.userinfo_endpoint.map(|u| { + glib::Uri::parse(u.as_str(), glib::UriFlags::NONE) + .unwrap() + }), access_token, refresh_token, expires_in: expires_in.map(std::time::Duration::from_secs), profile, - }) - ).await?; + }), + ) + .await?; - return Ok(Some(micropub)) - }, + return Ok(Some(micropub)); + } Err(err) => { tracing::warn!("failed to refresh token for {}: failed to parse grant response: {}", me, err); - return Ok(None) - }, - } - }, - Ok(body) => { - match serde_json::from_slice::(&body) { - Ok(err) => { - tracing::warn!("failed to refresh token for {}: token endpoint error: {}", me, err); - continue; - }, - Err(err) => { - tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err); - tracing::warn!("token endpoint response verbatim follows:\n{}", String::from_utf8_lossy(&body)); - return Ok(None) + return Ok(None); } } + } + Ok(body) => match serde_json::from_slice::(&body) { + Ok(err) => { + tracing::warn!( + "failed to refresh token for {}: token endpoint error: {}", + me, + err + ); + continue; + } + Err(err) => { + tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err); + tracing::warn!( + "token endpoint response verbatim follows:\n{}", + String::from_utf8_lossy(&body) + ); + return Ok(None); + } }, Err(err) => return Err(err), }; - } unreachable!() } } - async fn revoke_token(http: soup::Session, me: String, token: String) -> Result, components::SignInError> { - let url = glib::Uri::parse( - me.as_str(), - glib::UriFlags::SCHEME_NORMALIZE - )?; + async fn revoke_token( + http: soup::Session, + me: String, + token: String, + ) -> Result, components::SignInError> { + let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?; let (metadata, _) = crate::components::signin::get_metadata(http.clone(), url).await?; let endpoint = match metadata.revocation_endpoint { Some(endpoint) => match metadata.revocation_endpoint_auth_methods_supported { - Some(methods) => if methods.iter().any(|i| matches!(i, kittybox_indieauth::RevocationEndpointAuthMethod::None)) { - glib::Uri::parse(endpoint.as_str(), glib::UriFlags::NONE).unwrap() - } else { - tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); - return Ok(None) - }, + Some(methods) => { + if methods.iter().any(|i| { + matches!(i, kittybox_indieauth::RevocationEndpointAuthMethod::None) + }) { + glib::Uri::parse(endpoint.as_str(), glib::UriFlags::NONE).unwrap() + } else { + tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); + return Ok(None); + } + } None => { tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); - return Ok(None) + return Ok(None); } }, None => { tracing::warn!("couldn't revoke token: revocation endpoint not found"); - return Ok(None) + return Ok(None); } }; let msg = soup::Message::from_uri("POST", &endpoint); @@ -253,37 +311,55 @@ impl App { msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string( - kittybox_indieauth::TokenRevocationRequest { token } - ).unwrap().into_bytes() - )) + serde_urlencoded::to_string(kittybox_indieauth::TokenRevocationRequest { token }) + .unwrap() + .into_bytes(), + )), ); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { - Ok(_) if msg.status() == soup::Status::Ok => { - Ok(Some(())) - }, + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { + Ok(_) if msg.status() == soup::Status::Ok => Ok(Some(())), Ok(body) => { - tracing::warn!("couldn't revoke token: revocation endpoint returned non-200: {:?}", msg.status()); + tracing::warn!( + "couldn't revoke token: revocation endpoint returned non-200: {:?}", + msg.status() + ); match serde_json::from_slice::(&body) { Ok(err) => tracing::warn!("revocation endpoint returned an error: {}", err), - Err(_) => tracing::warn!("couldn't parse revocation endpoint error, response verbatim follows:\n{}", String::from_utf8_lossy(&body)) + Err(_) => tracing::warn!( + "couldn't parse revocation endpoint error, response verbatim follows:\n{}", + String::from_utf8_lossy(&body) + ), } Ok(None) - }, + } Err(err) => { - tracing::warn!("couldn't revoke token: error contacting revocation endpoint: {:?}", err); + tracing::warn!( + "couldn't revoke token: error contacting revocation endpoint: {:?}", + err + ); Err(err.into()) } } } - async fn get_login_state(schema: &libsecret::Schema, http: soup::Session) -> Result, glib::Error> { - let mut retrievables = libsecret::password_search_future(Some(schema), { - let mut attrs = std::collections::HashMap::default(); - attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); - attrs - }, libsecret::SearchFlags::ALL).await?; + async fn get_login_state( + schema: &libsecret::Schema, + http: soup::Session, + ) -> Result, glib::Error> { + let mut retrievables = libsecret::password_search_future( + Some(schema), + { + let mut attrs = std::collections::HashMap::default(); + attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); + attrs + }, + libsecret::SearchFlags::ALL, + ) + .await?; if retrievables.is_empty() { Ok(None) @@ -298,26 +374,27 @@ impl App { .collect(); let micropub_uri = match attrs .get(crate::secrets::MICROPUB) - .and_then(|v| glib::Uri::parse( - v, glib::UriFlags::NONE - ).ok()) { - Some(uri) => uri, - None => { - let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; - continue - }, - }; + .and_then(|v| glib::Uri::parse(v, glib::UriFlags::NONE).ok()) + { + Some(uri) => uri, + None => { + let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; + continue; + } + }; let me = attrs.get(crate::secrets::ME).unwrap().to_string(); let micropub = crate::micropub::Client::new( http.clone(), micropub_uri, - retrievable.retrieve_secret_future().await? + retrievable + .retrieve_secret_future() + .await? .unwrap() .text() .unwrap() .to_string(), - me.clone() + me.clone(), ); // Skip the token if we can't access ?q=config @@ -327,21 +404,22 @@ impl App { // Token may have expired. See if we have a refresh token and renew. let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; - match Self::refresh_token( - schema, http.clone(), - me.clone() - ).await { + match Self::refresh_token(schema, http.clone(), me.clone()).await { Ok(None) => continue, Err(err) => { - tracing::warn!("error refreshing Micropub token for {}: {}", &me, err); - continue - }, - Ok(Some(micropub)) => return Ok(Some(micropub)) + tracing::warn!( + "error refreshing Micropub token for {}: {}", + &me, + err + ); + continue; + } + Ok(Some(micropub)) => return Ok(Some(micropub)), } } } - return Ok(Some(micropub)) + return Ok(Some(micropub)); } Ok(None) @@ -461,12 +539,17 @@ impl AsyncComponent for App { ) -> AsyncComponentParts { let secret_schema = crate::secrets::get_schema(); let http = soup::Session::builder() - .user_agent(concat!(env!("CARGO_PKG_NAME"),"/",env!("CARGO_PKG_VERSION")," ")) + .user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION"), + " " + )) .build(); - let state = App::get_login_state( - &secret_schema, http.clone() - ).await.unwrap(); + let state = App::get_login_state(&secret_schema, http.clone()) + .await + .unwrap(); let model = App { submit_busy_guard: None, @@ -487,13 +570,13 @@ impl AsyncComponent for App { .forward(sender.input_sender(), Self::Input::PostEditor) }, signin: components::SignIn::builder() - .launch((glib::Uri::parse( - CLIENT_ID_STR, glib::UriFlags::NONE - ).unwrap(), http)) - .forward( - sender.input_sender(), - |o| Self::Input::Authorize(Box::new(o)) - ) + .launch(( + glib::Uri::parse(CLIENT_ID_STR, glib::UriFlags::NONE).unwrap(), + http, + )) + .forward(sender.input_sender(), |o| { + Self::Input::Authorize(Box::new(o)) + }), }; let widgets = view_output!(); @@ -504,20 +587,18 @@ impl AsyncComponent for App { App::about().present(weak_window.upgrade().as_ref()); }); let weak_window = window.downgrade(); - let preferences_action: RelmAction = RelmAction::new_stateless(move |_| { - // This could be built as an action that sends an input to open preferences. - // - // But I find this an acceptable alternative. - let mut prefs = components::Preferences::builder() - .launch(()) - .detach(); - - prefs.emit(weak_window.upgrade().map(|w| w.upcast())); - prefs.detach_runtime(); - }); - let sign_out_action: RelmAction = RelmAction::new_stateless(move |_| { - input_sender.emit(Input::SignOut) - }); + let preferences_action: RelmAction = + RelmAction::new_stateless(move |_| { + // This could be built as an action that sends an input to open preferences. + // + // But I find this an acceptable alternative. + let mut prefs = components::Preferences::builder().launch(()).detach(); + + prefs.emit(weak_window.upgrade().map(|w| w.upcast())); + prefs.detach_runtime(); + }); + let sign_out_action: RelmAction = + RelmAction::new_stateless(move |_| input_sender.emit(Input::SignOut)); let mut action_group: RelmActionGroup = RelmActionGroup::new(); action_group.add_action(about_action); action_group.add_action(preferences_action); @@ -527,12 +608,11 @@ impl AsyncComponent for App { AsyncComponentParts { model, widgets } } - async fn update( &mut self, message: Self::Input, _sender: AsyncComponentSender, - _root: &Self::Root + _root: &Self::Root, ) { match message { Input::SignOut => { @@ -540,37 +620,43 @@ impl AsyncComponent for App { let _ = libsecret::password_clear_future( Some(&self.secret_schema), Default::default(), - ).await; - let _ = Self::revoke_token( - self.http.clone(), micropub.me, micropub.access_token - ).await; + ) + .await; + let _ = + Self::revoke_token(self.http.clone(), micropub.me, micropub.access_token) + .await; } - }, + } Input::Authorize(data) => { - if let Ok(micropub) = Self::authorize(&self.secret_schema, self.http.clone(), data).await { + if let Ok(micropub) = + Self::authorize(&self.secret_schema, self.http.clone(), data).await + { self.micropub = Some(micropub); } - }, + } Input::SubmitButtonPressed => { if self.micropub.is_some() { self.submit_busy_guard = Some(relm4::main_adw_application().mark_busy()); // TODO: too easy to deadlock here, refactor to take a channel self.post_editor.emit(PostEditorInput::Submit); }; - }, + } Input::PostEditor(None) => { self.submit_busy_guard = None; } Input::PostEditor(Some(post)) => { if let Some(micropub) = self.micropub.as_ref() { let mf2 = post.into_mf2(PostConversionSettings { - send_html_directly: self.settings.get("send-html-directly") + send_html_directly: self.settings.get("send-html-directly"), }); - log::debug!("Submitting post: {:#}", serde_json::to_string(&mf2).unwrap()); + log::debug!( + "Submitting post: {:#}", + serde_json::to_string(&mf2).unwrap() + ); match micropub.send_post(mf2).await { Ok(uri) => { self.post_editor.emit(PostEditorInput::SubmitDone(uri)); - }, + } Err(err) => { log::warn!("Error sending post: {}", err); self.post_editor.emit(PostEditorInput::SubmitError(err)); @@ -578,7 +664,7 @@ impl AsyncComponent for App { } } self.submit_busy_guard = None; - }, + } } } } diff --git a/src/main.rs b/src/main.rs index 989516a..536563e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,8 @@ static GLIB_LOGGER: glib::GlibLogger = glib::GlibLogger::new( ); fn main() { - gettextrs::bindtextdomain( - env!("CARGO_PKG_NAME"), - env!("LOCALEDIR") - ).expect("failed to bind text domain"); + gettextrs::bindtextdomain(env!("CARGO_PKG_NAME"), env!("LOCALEDIR")) + .expect("failed to bind text domain"); gettextrs::bind_textdomain_codeset(env!("CARGO_PKG_NAME"), "UTF-8").unwrap(); gettextrs::textdomain(env!("CARGO_PKG_NAME")).unwrap(); @@ -20,7 +18,8 @@ fn main() { spelling::init(); let app = relm4::RelmApp::new(bowl::APPLICATION_ID); - relm4::set_global_css("/* CSS for Bowl */ + relm4::set_global_css( + "/* CSS for Bowl */ .tag-pill button { min-height: 30px; min-width: 30px; @@ -28,7 +27,8 @@ fn main() { .tag-pill label { font-variant-caps: small-caps; } -"); +", + ); app.run_async::(()); } diff --git a/src/micropub.rs b/src/micropub.rs index b2f1e73..f87feb7 100644 --- a/src/micropub.rs +++ b/src/micropub.rs @@ -1,5 +1,5 @@ +pub use kittybox_util::micropub::{Config, Error as MicropubError, QueryType}; use soup::prelude::*; -pub use kittybox_util::micropub::{Error as MicropubError, Config, QueryType}; #[derive(Debug)] pub struct Client { @@ -19,7 +19,7 @@ pub enum Error { #[error("micropub error: {0}")] Micropub(#[from] MicropubError), #[error("micropub server did not return a location: header")] - NoLocationHeader + NoLocationHeader, } impl Client { @@ -34,18 +34,21 @@ impl Client { pub async fn config(&self) -> Result { let uri = glib::Uri::parse(&self.micropub, glib::UriFlags::NONE).unwrap(); - let uri = super::util::append_query( - &uri, [("q".to_string(), "config".to_string())] - ); - + let uri = super::util::append_query(&uri, [("q".to_string(), "config".to_string())]); + let exch = soup::Message::from_uri("GET", &uri); let headers = exch.request_headers().expect("SoupMessage with no headers"); // TODO: create a SoupAuth subclass that allows pasting in a token headers.append("Authorization", &format!("Bearer {}", self.access_token)); - let body = self.http.send_and_read_future(&exch, glib::Priority::DEFAULT).await?; + let body = self + .http + .send_and_read_future(&exch, glib::Priority::DEFAULT) + .await?; if exch.status() == soup::Status::Unauthorized { - return Err(MicropubError::from(kittybox_util::micropub::ErrorKind::NotAuthorized).into()) + return Err( + MicropubError::from(kittybox_util::micropub::ErrorKind::NotAuthorized).into(), + ); } Ok(serde_json::from_slice(&body)?) @@ -57,22 +60,32 @@ impl Client { let headers = exch.request_headers().expect("SoupMessage with no headers"); headers.append("Authorization", &format!("Bearer {}", self.access_token)); - exch.set_request_body_from_bytes(Some("application/json"), - Some(&glib::Bytes::from_owned(serde_json::to_vec(&post).unwrap())) + exch.set_request_body_from_bytes( + Some("application/json"), + Some(&glib::Bytes::from_owned(serde_json::to_vec(&post).unwrap())), ); - let body = self.http.send_and_read_future(&exch, glib::Priority::DEFAULT).await?; + let body = self + .http + .send_and_read_future(&exch, glib::Priority::DEFAULT) + .await?; match exch.status() { soup::Status::Created | soup::Status::Accepted => { - let response_headers = exch.response_headers().expect("Successful SoupMessage with no response headers"); - let location = response_headers.one("Location").ok_or(Error::NoLocationHeader)?; + let response_headers = exch + .response_headers() + .expect("Successful SoupMessage with no response headers"); + let location = response_headers + .one("Location") + .ok_or(Error::NoLocationHeader)?; Ok(glib::Uri::parse(&location, glib::UriFlags::NONE)?) - }, - soup::Status::InternalServerError | soup::Status::BadGateway | soup::Status::ServiceUnavailable => { + } + soup::Status::InternalServerError + | soup::Status::BadGateway + | soup::Status::ServiceUnavailable => { todo!("micropub server is down") - }, + } soup::Status::Unauthorized => { Err(MicropubError::from(kittybox_util::micropub::ErrorKind::NotAuthorized).into()) } @@ -80,7 +93,10 @@ impl Client { let error = match serde_json::from_slice::(&body) { Ok(error) => error, Err(err) => { - tracing::debug!("Error serializing body: {}", String::from_utf8_lossy(&body)); + tracing::debug!( + "Error serializing body: {}", + String::from_utf8_lossy(&body) + ); Err(err)? } }; diff --git a/src/secrets.rs b/src/secrets.rs index fa74aa5..7763e5f 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -15,5 +15,9 @@ pub fn get_schema() -> libsecret::Schema { attrs.insert(EXPIRES_IN, libsecret::SchemaAttributeType::Integer); attrs.insert(SCOPE, libsecret::SchemaAttributeType::String); - libsecret::Schema::new("org.indieweb.indieauth.BearerCredential", libsecret::SchemaFlags::NONE, attrs) + libsecret::Schema::new( + "org.indieweb.indieauth.BearerCredential", + libsecret::SchemaFlags::NONE, + attrs, + ) } diff --git a/src/util.rs b/src/util.rs index c3d5bd7..83d8e2b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,7 +1,8 @@ use std::borrow::Cow; pub fn append_query(uri: &glib::Uri, q: impl IntoIterator) -> glib::Uri { - let mut oq: Vec<(Cow<'static, str>, Cow<'static, str>)> = uri.query() + let mut oq: Vec<(Cow<'static, str>, Cow<'static, str>)> = uri + .query() .map(|q| serde_urlencoded::from_str(&q).unwrap()) .unwrap_or_default(); oq.extend(q.into_iter().map(|(k, v)| (k.into(), v.into()))); @@ -13,7 +14,10 @@ pub fn append_query(uri: &glib::Uri, q: impl IntoIterator Result<(), glib::Error> { - let uri = glib::Uri::parse("https://fireburn.ru/.kittybox/micropub?test=a", glib::UriFlags::NONE)?; + let uri = glib::Uri::parse( + "https://fireburn.ru/.kittybox/micropub?test=a", + glib::UriFlags::NONE, + )?; let q = [ ("q".to_owned(), "config".to_owned()), ("awoo".to_owned(), "nya".to_owned()), -- cgit 1.4.1