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}; pub mod components { pub(crate) mod smart_summary; pub(crate) use smart_summary::{ SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput }; pub(crate) mod post_editor; pub(crate) use post_editor::{ PostEditor, Input as PostEditorInput }; pub(crate) mod tag_pill; // pub(crate) use tag_pill::{TagPill, TagPillDelete}; pub mod signin; pub use signin::{SignIn, Output as SignInOutput}; } use components::{post_editor::Post, PostEditorInput}; pub mod secrets; pub mod micropub; pub mod util; pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; pub const CLIENT_ID_STR: &str = "https://kittybox.fireburn.ru/bowl/"; pub const VISIBILITY: [&str; 2] = ["public", "private"]; #[derive(Debug)] pub struct App { secret_schema: libsecret::Schema, http: soup::Session, submit_busy_guard: Option, // TODO: make this support multiple users micropub: Option, signin: AsyncController, post_editor: Controller>, } impl App { 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 // SAFETY: the representation of `glib::Error` is the same, since we're // building against the same C library, so while this is a safety crime, it's // probably a minor one. // // Additionally I'm ensuring the original wrapper is forgotten in case it has // a drop handler that decrements a reference counter or something. // // This is only a workaround for libsecret not being updated in time. .map_err(|e| unsafe { let ptr = e.as_ptr(); std::mem::forget(e); glib::translate::from_glib_full::<_, glib::Error>( // We can't name the original type here. #[allow(clippy::missing_transmute_annotations)] std::mem::transmute::<_, *mut glib::ffi::GError>(ptr) ) })?; if retrievables.is_empty() { Ok(None) } else { retrievables.sort_by_key(|s| s.created()); let iterable = retrievables.iter().rev(); for retrievable in iterable { let attrs = retrievable.attributes(); let attrs_ref = attrs .iter() .map(|(k, v)| (k.as_ref(), v.as_ref())) .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 }, }; let micropub = crate::micropub::Client::new( http.clone(), micropub_uri, retrievable.retrieve_secret_future().await // SAFETY: see above .map_err(|e| unsafe { let ptr = e.as_ptr(); std::mem::forget(e); glib::translate::from_glib_full::<_, glib::Error>( // We can't name the original type here. #[allow(clippy::missing_transmute_annotations)] std::mem::transmute::<_, *mut glib::ffi::GError>(ptr) ) })? .unwrap() .text() .unwrap() .to_string() ); // Skip the token if we can't access ?q=config if let Err(micropub::Error::Micropub(err)) = micropub.config().await { if err.error == kittybox_util::micropub::ErrorKind::NotAuthorized { continue; } } return Ok(Some(micropub)) } Ok(None) } } fn about() -> adw::AboutDialog { adw::AboutDialog::builder() .application_name("Bowl for Kittybox") .developer_name("Vika Shleina") .version(env!("CARGO_PKG_VERSION")) .website("https://kittybox.fireburn.ru/bowl/") .developers(vec!["Vika https://fireburn.ru"]) .license_type(gtk::License::Agpl30Only) .copyright("© 2024 Vika Shleina") .build() } } #[derive(Debug)] #[doc(hidden)] pub enum Input { SubmitButtonPressed, Authorize(Box), SignOut, PostEditor(Option), } relm4::new_action_group!(AppActionGroup, "app"); relm4::new_stateless_action!(AboutAction, AppActionGroup, "about"); relm4::new_stateless_action!(SignOutAction, AppActionGroup, "sign-out"); relm4::new_stateless_action!(PreferencesAction, AppActionGroup, "preferences"); #[relm4::component(pub async)] impl AsyncComponent for App { /// The type of the messages that this component can receive. type Input = Input; /// The type of the messages that this component can send. type Output = (); /// The type of data with which this component will be initialized. type Init = (); /// The type of the command outputs that this component can receive. type CommandOutput = (); menu! { main_menu: { "Sign out" => SignOutAction, "Preferences" => PreferencesAction, "About" => AboutAction, } } view! { #[root] root = adw::ApplicationWindow { #[iterate] add_css_class: [ #[cfg(debug_assertions)] "devel" ], #[watch] set_title: if model.micropub.is_none() { Some("Bowl – Sign in with your website") } else { Some("Bowl") }, adw::ToolbarView { add_top_bar = &adw::HeaderBar { pack_end = >k::MenuButton { #[wrap(Some)] set_popover = >k::PopoverMenu::from_model(Some(&main_menu)) {}, #[watch] set_visible: model.micropub.is_some(), set_icon_name: relm4_icons::icon_names::MENU, }, pack_end = >k::Button { set_icon_name: "document-send-symbolic", set_tooltip: "Send post", #[watch] set_visible: model.micropub.is_some(), #[watch] set_sensitive: model.submit_busy_guard.is_none(), connect_clicked => Self::Input::SubmitButtonPressed, }, }, #[transition = "Crossfade"] match model.micropub.as_ref() { Some(_) => model.post_editor.widget().clone(), None => model.signin.widget().clone(), } }, }, } fn init_loading_widgets(root: Self::Root) -> Option { root.set_size_request(360, 294); root.set_default_size(360, 640); let spinner = gtk::Spinner::builder() .spinning(true) .height_request(32) .width_request(32) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .build(); root.set_content(Some(&spinner)); Some(LoadingWidgets::new(root, spinner)) } /// Initialize the UI and model. async fn init( _init: Self::Init, window: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { let secret_schema = crate::secrets::get_schema(); let http = soup::Session::builder() .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 model = App { submit_busy_guard: None, http: http.clone(), micropub: state, secret_schema, post_editor: components::PostEditor::builder() .launch(None) .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)) ) }; let widgets = view_output!(); let input_sender = sender.input_sender().clone(); let weak_window = window.downgrade(); let about_action: RelmAction = RelmAction::new_stateless(move |_| { App::about().present(weak_window.upgrade().as_ref()); }); let preferences_action: RelmAction = RelmAction::new_stateless(move |_| { log::warn!("Ain't implemented yet!"); }); 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); action_group.add_action(sign_out_action); action_group.register_for_widget(&window); AsyncComponentParts { model, widgets } } async fn update( &mut self, message: Self::Input, _sender: AsyncComponentSender, _root: &Self::Root ) { match message { Input::SignOut => { if self.micropub.take().is_some() { libsecret::password_clear_future( Some(&self.secret_schema), Default::default(), ).await; self.micropub = None; } }, Input::Authorize(data) => { let mut attributes = std::collections::HashMap::new(); let _me = data.me.to_string(); let _micropub = data.micropub.to_string(); let scope = data.scope.to_string(); attributes.insert(secrets::ME, _me.as_str()); 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 .as_ref() .map(std::time::Duration::as_secs) .as_ref() .map(u64::to_string); if let Some(expires_in) = exp.as_deref() { attributes.insert(secrets::EXPIRES_IN, expires_in); } match libsecret::password_store_future( Some(&self.secret_schema), attributes.clone(), Some(libsecret::COLLECTION_DEFAULT), &format!("Micropub access token for {}", &data.me), &data.access_token ).await { Ok(()) => {}, Err(err) => log::error!("Failed to store access token to the secret store: {}", err), } if let Some(refresh_token) = data.refresh_token.as_deref() { attributes.insert(secrets::TOKEN_KIND, secrets::REFRESH_TOKEN); attributes.remove(secrets::EXPIRES_IN); match libsecret::password_store_future( Some(&self.secret_schema), attributes, Some(libsecret::COLLECTION_DEFAULT), &format!("Micropub refresh token for {}", &data.me), refresh_token ).await { Ok(()) => {}, Err(err) => log::error!("Failed to store refresh token to the secret store: {}", err), } } self.micropub = Some(crate::micropub::Client::new( self.http.clone(), data.micropub.clone(), data.access_token.clone() )); }, Input::SubmitButtonPressed => { if self.micropub.is_some() { self.submit_busy_guard = Some(relm4::main_adw_application().mark_busy()); 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(); 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)); } } } self.submit_busy_guard = None; }, } } }