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}; pub mod components { #[cfg(feature = "smart-summary")] pub(crate) mod smart_summary; #[cfg(feature = "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}; pub mod preferences; pub use preferences::Preferences; } 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?; 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? .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(gettext("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: { &gettext("Sign out") => SignOutAction, &gettext("Preferences") => PreferencesAction, &gettext("About") => AboutAction, } } view! { #[root] root = adw::ApplicationWindow { #[iterate] add_css_class: [ #[cfg(debug_assertions)] "devel" ], #[watch] set_title: if model.micropub.is_none() { Some(gettext("Bowl - Sign in with your website")) } else { Some(gettext("Bowl")) }.as_deref(), 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: &gettext("Publish"), #[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: { #[cfg(feature = "smart-summary")] let init = (http.clone(), None); #[cfg(not(feature = "smart-summary"))] let init = None; components::PostEditor::builder() .launch(init) .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 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 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() { let _ = 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), &gettext!("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; }, } } }