From 6e6c0385931ffd6e8460e0a9e3a1be22d7f32ee2 Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 22 Jan 2025 17:37:38 +0200 Subject: [PATCH] separate handler, begin Titan input integration --- src/app/browser/window/tab/item/client.rs | 28 +- .../window/tab/item/client/driver/gemini.rs | 687 ++++++++++-------- 2 files changed, 381 insertions(+), 334 deletions(-) diff --git a/src/app/browser/window/tab/item/client.rs b/src/app/browser/window/tab/item/client.rs index 9cb9f726..66d5d774 100644 --- a/src/app/browser/window/tab/item/client.rs +++ b/src/app/browser/window/tab/item/client.rs @@ -3,16 +3,14 @@ mod feature; mod subject; use super::Page; -use crate::tool::format_bytes; use adw::TabPage; use driver::Driver; use feature::Feature; use gtk::{ gio::Cancellable, glib::{Uri, UriFlags}, - prelude::{CancellableExt, EditableExt, EntryExt, WidgetExt}, + prelude::{CancellableExt, EditableExt, EntryExt}, }; -use plurify::ns as plural; use std::{cell::Cell, rc::Rc}; use subject::Subject; @@ -86,26 +84,7 @@ impl Client { match result { // route by scheme Ok(uri) => match uri.scheme().as_str() { - "gemini" => driver.gemini.handle(uri, feature, cancellable), - "titan" => subject.page.input.set_new_titan(|data, label| { - // init data to send - const CHUNK: usize = 0x400; - let bytes_sent = 0; - let bytes_total = data.len(); - - // send by chunks for large content size - if bytes_total > CHUNK { - label.set_label(&format!( - "sent {}/{} {}", - format_bytes(bytes_sent), - format_bytes(bytes_total), - plural(bytes_sent, &["byte", "bytes", "bytes"]) - )); - } else { - label.set_visible(false); - } - todo!() - }), + "gemini" | "titan" => driver.gemini.handle(uri, feature, cancellable), scheme => { // no scheme match driver, complete with failure message let status = subject.page.content.to_status_failure(); @@ -155,7 +134,7 @@ impl Client { fn lookup( query: &str, cancellable: Cancellable, - callback: impl FnOnce(Feature, Cancellable, Result) + 'static, + callback: impl FnOnce(Rc, Cancellable, Result) + 'static, ) { use gtk::{ gio::{NetworkAddress, Resolver}, @@ -167,6 +146,7 @@ fn lookup( const TIMEOUT: u32 = 250; // ms let (feature, query) = Feature::parse(query.trim()); + let feature = Rc::new(feature); match Uri::parse(query, UriFlags::NONE) { Ok(uri) => callback(feature, cancellable, Ok(uri)), diff --git a/src/app/browser/window/tab/item/client/driver/gemini.rs b/src/app/browser/window/tab/item/client/driver/gemini.rs index b7fd3a25..e835bd36 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -1,10 +1,9 @@ use super::{Feature, Subject}; use ggemini::client::{ connection::response::{data::Text, meta::Status}, - Request, + Client, Request, }; use gtk::glib::{GString, UriFlags}; -use gtk::prelude::{EditableExt, FileExt}; use gtk::{ gdk::Texture, gdk_pixbuf::Pixbuf, @@ -12,12 +11,16 @@ use gtk::{ glib::{Priority, Uri}, prelude::{EntryExt, SocketClientExt}, }; +use gtk::{ + glib::Bytes, + prelude::{EditableExt, FileExt}, +}; use std::{cell::Cell, path::MAIN_SEPARATOR, rc::Rc, time::Duration}; /// Multi-protocol client API for `Page` object pub struct Gemini { /// Should be initiated once - client: Rc, + client: Rc, /// Validate redirection count by Gemini protocol specification redirects: Rc>, /// Handle target @@ -72,338 +75,402 @@ impl Gemini { // Actions - pub fn handle(&self, uri: Uri, feature: Feature, cancellable: Cancellable) { - self.client.request_async( - Request::gemini(uri.clone()), - Priority::DEFAULT, - cancellable.clone(), - // Search for user certificate match request - // * @TODO this feature does not support multi-protocol yet - match self - .subject.page - .profile - .identity - .gemini - .match_scope(&uri.to_string()) - { - Some(identity) => match identity.to_tls_certificate() { - Ok(certificate) => Some(certificate), - Err(_) => panic!(), // unexpected - }, - None => None, - }, - { - let uri = uri.clone(); + pub fn handle(&self, uri: Uri, feature: Rc, cancellable: Cancellable) { + use ggemini::client::connection::request::*; + + match uri.scheme().as_str() { + "gemini" => handle( + Request::Gemini(Gemini { uri }), + self.client.clone(), + self.subject.clone(), + self.redirects.clone(), + feature, + cancellable, + ), + "titan" => self.subject.page.input.set_new_titan({ + let client = self.client.clone(); let subject = self.subject.clone(); let redirects = self.redirects.clone(); - move |result| match result { - Ok(response) => { - match response.meta.status { - // https://geminiprotocol.net/docs/protocol-specification.gmi#input-expected - Status::Input | Status::SensitiveInput => { - let title = match response.meta.data { - Some(data) => data.to_string(), - None => Status::Input.to_string(), - }; - if matches!(response.meta.status, Status::SensitiveInput) { - subject.page.input.set_new_sensitive( - subject.page.tab_action.clone(), - uri, - Some(&title), - Some(1024), - ); - } else { - subject.page.input.set_new_response( - subject.page.tab_action.clone(), - uri, - Some(&title), - Some(1024), - ); - } - subject.page.title.replace(title.into()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); + move |data, _label| { + handle( + Request::Titan(Titan { + uri: uri.clone(), + data: Bytes::from(data), + mime: None, // @TODO + token: None, // @TODO + }), + client.clone(), + subject.clone(), + redirects.clone(), + feature.clone(), + cancellable.clone(), + ) + // init data to send + /* @TODO + use crate::tool::format_bytes; + use plurify::ns as plural; + + const CHUNK: usize = 0x400; + let bytes_sent = 0; + let bytes_total = data.len(); + + // send by chunks for large content size + if bytes_total > CHUNK { + label.set_label(&format!( + "sent {}/{} {}", + format_bytes(bytes_sent), + format_bytes(bytes_total), + plural(bytes_sent, &["byte", "bytes", "bytes"]) + )); + } else { + label.set_visible(false); + } + todo!()*/ + } + }), + _ => panic!(), // unexpected + } + } +} + +fn handle( + request: Request, + client: Rc, + subject: Rc, + redirects: Rc>, + feature: Rc, + cancellable: Cancellable, +) { + let uri = request.uri().clone(); + client.request_async( + request, + Priority::DEFAULT, + cancellable.clone(), + // Search for user certificate match request + // * @TODO this feature does not support multi-protocol yet + match subject + .page + .profile + .identity + .gemini + .match_scope(&uri.to_string()) + { + Some(identity) => match identity.to_tls_certificate() { + Ok(certificate) => Some(certificate), + Err(_) => panic!(), // unexpected + }, + None => None, + }, + { + let subject = subject.clone(); + let redirects = redirects.clone(); + move |result| match result { + Ok(response) => { + match response.meta.status { + // https://geminiprotocol.net/docs/protocol-specification.gmi#input-expected + Status::Input | Status::SensitiveInput => { + let title = match response.meta.data { + Some(data) => data.to_string(), + None => Status::Input.to_string(), + }; + if matches!(response.meta.status, Status::SensitiveInput) { + subject.page.input.set_new_sensitive( + subject.page.tab_action.clone(), + uri, + Some(&title), + Some(1024), + ); + } else { + subject.page.input.set_new_response( + subject.page.tab_action.clone(), + uri, + Some(&title), + Some(1024), + ); } - // https://geminiprotocol.net/docs/protocol-specification.gmi#status-20 - Status::Success => match feature { - Feature::Download => { - // Init download widget - let status = subject.page.content.to_status_download( - uri_to_title(&uri).trim_matches(MAIN_SEPARATOR), // grab default filename from base URI, - // format FS entities - &cancellable, - { - let cancellable = cancellable.clone(); - let stream = response.connection.stream(); - move |file, action| { - match file.replace( - None, - false, - gtk::gio::FileCreateFlags::NONE, - Some(&cancellable), - ) { - Ok(file_output_stream) => { - // Asynchronously read [IOStream](https://docs.gtk.org/gio/class.IOStream.html) - // to local [MemoryInputStream](https://docs.gtk.org/gio/class.MemoryInputStream.html) - // show bytes count in loading widget, validate max size for incoming data - // * no dependency of Gemini library here, feel free to use any other `IOStream` processor - ggemini::gio::file_output_stream::move_all_from_stream_async( - stream.clone(), - file_output_stream, - cancellable.clone(), - Priority::DEFAULT, - ( - 0x100000, // 1M bytes per chunk - None, // unlimited - 0, // initial totals - ), - ( - // on chunk - { - let action = action.clone(); - move |_, total| { - action.update.activate(&format!( - "Received {}...", - crate::tool::format_bytes(total) + subject.page.title.replace(title.into()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + } + // https://geminiprotocol.net/docs/protocol-specification.gmi#status-20 + Status::Success => match *feature { + Feature::Download => { + // Init download widget + let status = subject.page.content.to_status_download( + uri_to_title(&uri).trim_matches(MAIN_SEPARATOR), // grab default filename from base URI, + // format FS entities + &cancellable, + { + let cancellable = cancellable.clone(); + let stream = response.connection.stream(); + move |file, action| { + match file.replace( + None, + false, + gtk::gio::FileCreateFlags::NONE, + Some(&cancellable), + ) { + Ok(file_output_stream) => { + // Asynchronously read [IOStream](https://docs.gtk.org/gio/class.IOStream.html) + // to local [MemoryInputStream](https://docs.gtk.org/gio/class.MemoryInputStream.html) + // show bytes count in loading widget, validate max size for incoming data + // * no dependency of Gemini library here, feel free to use any other `IOStream` processor + ggemini::gio::file_output_stream::move_all_from_stream_async( + stream.clone(), + file_output_stream, + cancellable.clone(), + Priority::DEFAULT, + ( + 0x100000, // 1M bytes per chunk + None, // unlimited + 0, // initial totals + ), + ( + // on chunk + { + let action = action.clone(); + move |_, total| { + action.update.activate(&format!( + "Received {}...", + crate::tool::format_bytes(total) + )) + } + }, + // on complete + { + let action = action.clone(); + move |result| match result { + Ok((_, total)) => { + action.complete.activate(&format!( + "Saved to {} ({total} bytes total)", + file.parse_name() )) } - }, - // on complete - { - let action = action.clone(); - move |result| match result { - Ok((_, total)) => { - action.complete.activate(&format!( - "Saved to {} ({total} bytes total)", - file.parse_name() - )) - } - Err(e) => { - action.cancel.activate(&e.to_string()) - } + Err(e) => { + action.cancel.activate(&e.to_string()) } - }, - ), - ) - } - Err(e) => action.cancel.activate(&e.to_string()), + } + }, + ), + ) } + Err(e) => action.cancel.activate(&e.to_string()), } - }, - ); - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - }, - _ => match response.meta.mime { - Some(mime) => match mime.as_str() { - "text/gemini" => Text::from_stream_async( - response.connection.stream(), - Priority::DEFAULT, - cancellable.clone(), - move |result| match result { - Ok(text) => { - let widget = if matches!(feature, Feature::Source) { - subject.page.content.to_text_source(&text.to_string()) - } else { - subject.page.content.to_text_gemini(&uri, &text.to_string()) - }; - - // Connect `TextView` widget, update `search` model - subject.page.search.set(Some(widget.text_view)); - - // Update page meta - subject.page.title.replace(match widget.meta.title { - Some(title) => title.into(), // @TODO - None => uri_to_title(&uri), - }); - - // Deactivate loading indication - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - - // Update window components - subject.page.window_action - .find - .simple_action - .set_enabled(true); - } - Err(e) => { - let status = subject.page.content.to_status_failure(); - status.set_description(Some(&e.to_string())); - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - }, - }, - ), - "image/png" | "image/gif" | "image/jpeg" | "image/webp" => { - // Final image size unknown, show loading widget - let status = subject.page.content.to_status_loading( - Some(Duration::from_secs(1)), // show if download time > 1 second - ); - - // Asynchronously read [IOStream](https://docs.gtk.org/gio/class.IOStream.html) - // to local [MemoryInputStream](https://docs.gtk.org/gio/class.MemoryInputStream.html) - // show bytes count in loading widget, validate max size for incoming data - // * no dependency of Gemini library here, feel free to use any other `IOStream` processor - ggemini::gio::memory_input_stream::from_stream_async( - response.connection.stream(), - cancellable.clone(), - Priority::DEFAULT, - 0x400, // 1024 bytes per chunk, optional step for images download tracking - 0xA00000, // 10M bytes max to prevent memory overflow if server play with promises - move |_, total| - status.set_description(Some(&format!("Download: {total} bytes"))), - { - let subject = subject.clone(); - move |result| match result { - Ok((memory_input_stream, _)) => { - Pixbuf::from_stream_async( - &memory_input_stream, - Some(&cancellable), - move |result| { - // Process buffer data - match result { - Ok(buffer) => { - subject.page.title.replace(uri_to_title(&uri)); - subject.page.content - .to_image(&Texture::for_pixbuf(&buffer)); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - } - Err(e) => { - let status = subject.page.content.to_status_failure(); - status.set_description(Some(e.message())); - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - } - } - }, - ) - } - Err(e) => { - let status = subject.page.content.to_status_failure(); - status.set_description(Some(&e.to_string())); - - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - } - } - }, - ) } - mime => { - let status = subject.page - .content - .to_status_mime(mime, Some((&subject.page.tab_action, &uri))); - status.set_description(Some(&format!("Content type `{mime}` yet not supported"))); - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - }, }, - None => { - let status = subject.page.content.to_status_failure(); - status.set_description(Some("MIME type not found")); - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - }, - } + ); + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); }, - // https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection - // https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection - Status::PermanentRedirect | Status::Redirect => { - // Expected target URL in response meta - match response.meta.data { - Some(data) => { - match uri.parse_relative(data.as_str(), UriFlags::NONE) { - Ok(target) => { - let total = redirects.take() + 1; - - // Validate total redirects by protocol specification - if total > 5 { - let status = subject.page.content.to_status_failure(); - status.set_description(Some("Redirection limit reached")); - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - redirects.replace(0); // reset - - // Disallow external redirection - } else if uri.scheme() != target.scheme() - || uri.port() != target.port() - || uri.host() != target.host() { - let status = subject.page.content.to_status_failure(); - status.set_description(Some("External redirects not allowed by protocol specification")); - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - redirects.replace(0); // reset - // Valid + _ => match response.meta.mime { + Some(mime) => match mime.as_str() { + "text/gemini" => Text::from_stream_async( + response.connection.stream(), + Priority::DEFAULT, + cancellable.clone(), + move |result| match result { + Ok(text) => { + let widget = if matches!(*feature, Feature::Source) { + subject.page.content.to_text_source(&text.to_string()) } else { - if matches!(response.meta.status, Status::PermanentRedirect) { - subject.page.navigation - .request - .widget - .entry - .set_text(&uri.to_string()); - } - redirects.replace(total); - subject.page.tab_action.load.activate(Some(&target.to_string()), false); - } + subject.page.content.to_text_gemini(&uri, &text.to_string()) + }; + + // Connect `TextView` widget, update `search` model + subject.page.search.set(Some(widget.text_view)); + + // Update page meta + subject.page.title.replace(match widget.meta.title { + Some(title) => title.into(), // @TODO + None => uri_to_title(&uri), + }); + + // Deactivate loading indication + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + + // Update window components + subject.page.window_action + .find + .simple_action + .set_enabled(true); } Err(e) => { let status = subject.page.content.to_status_failure(); status.set_description(Some(&e.to_string())); subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + }, + }, + ), + "image/png" | "image/gif" | "image/jpeg" | "image/webp" => { + // Final image size unknown, show loading widget + let status = subject.page.content.to_status_loading( + Some(Duration::from_secs(1)), // show if download time > 1 second + ); + + // Asynchronously read [IOStream](https://docs.gtk.org/gio/class.IOStream.html) + // to local [MemoryInputStream](https://docs.gtk.org/gio/class.MemoryInputStream.html) + // show bytes count in loading widget, validate max size for incoming data + // * no dependency of Gemini library here, feel free to use any other `IOStream` processor + ggemini::gio::memory_input_stream::from_stream_async( + response.connection.stream(), + cancellable.clone(), + Priority::DEFAULT, + 0x400, // 1024 bytes per chunk, optional step for images download tracking + 0xA00000, // 10M bytes max to prevent memory overflow if server play with promises + move |_, total| + status.set_description(Some(&format!("Download: {total} bytes"))), + { + let subject = subject.clone(); + move |result| match result { + Ok((memory_input_stream, _)) => { + Pixbuf::from_stream_async( + &memory_input_stream, + Some(&cancellable), + move |result| { + // Process buffer data + match result { + Ok(buffer) => { + subject.page.title.replace(uri_to_title(&uri)); + subject.page.content + .to_image(&Texture::for_pixbuf(&buffer)); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + } + Err(e) => { + let status = subject.page.content.to_status_failure(); + status.set_description(Some(e.message())); + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + } + } + }, + ) + } + Err(e) => { + let status = subject.page.content.to_status_failure(); + status.set_description(Some(&e.to_string())); + + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + } + } + }, + ) + } + mime => { + let status = subject.page + .content + .to_status_mime(mime, Some((&subject.page.tab_action, &uri))); + status.set_description(Some(&format!("Content type `{mime}` yet not supported"))); + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + }, + }, + None => { + let status = subject.page.content.to_status_failure(); + status.set_description(Some("MIME type not found")); + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + }, + } + }, + // https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection + // https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection + Status::PermanentRedirect | Status::Redirect => { + // Expected target URL in response meta + match response.meta.data { + Some(data) => { + match uri.parse_relative(data.as_str(), UriFlags::NONE) { + Ok(target) => { + let total = redirects.take() + 1; + + // Validate total redirects by protocol specification + if total > 5 { + let status = subject.page.content.to_status_failure(); + status.set_description(Some("Redirection limit reached")); + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + redirects.replace(0); // reset + + // Disallow external redirection + } else if uri.scheme() != target.scheme() + || uri.port() != target.port() + || uri.host() != target.host() { + let status = subject.page.content.to_status_failure(); + status.set_description(Some("External redirects not allowed by protocol specification")); + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + redirects.replace(0); // reset + // Valid + } else { + if matches!(response.meta.status, Status::PermanentRedirect) { + subject.page.navigation + .request + .widget + .entry + .set_text(&uri.to_string()); + } + redirects.replace(total); + subject.page.tab_action.load.activate(Some(&target.to_string()), false); } } - } - None => { - let status = subject.page.content.to_status_failure(); - status.set_description(Some("Redirection target not found")); - subject.page.title.replace(status.title()); + Err(e) => { + let status = subject.page.content.to_status_failure(); + status.set_description(Some(&e.to_string())); + subject.page.title.replace(status.title()); + } } } - }, - // https://geminiprotocol.net/docs/protocol-specification.gmi#status-60 - Status::CertificateRequest | - // https://geminiprotocol.net/docs/protocol-specification.gmi#status-61-certificate-not-authorized - Status::CertificateUnauthorized | - // https://geminiprotocol.net/docs/protocol-specification.gmi#status-62-certificate-not-valid - Status::CertificateInvalid => { - let status = subject.page.content.to_status_identity(); - status.set_description(Some(&match response.meta.data { - Some(data) => data.to_string(), - None => response.meta.status.to_string(), - })); - - subject.page.title.replace(status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); + None => { + let status = subject.page.content.to_status_failure(); + status.set_description(Some("Redirection target not found")); + subject.page.title.replace(status.title()); + } } - status => { - let _status = subject.page.content.to_status_failure(); - _status.set_description(Some(&format!("Undefined status code `{status}`"))); - subject.page.title.replace(_status.title()); - subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); - subject.tab_page.set_loading(false); - }, + }, + // https://geminiprotocol.net/docs/protocol-specification.gmi#status-60 + Status::CertificateRequest | + // https://geminiprotocol.net/docs/protocol-specification.gmi#status-61-certificate-not-authorized + Status::CertificateUnauthorized | + // https://geminiprotocol.net/docs/protocol-specification.gmi#status-62-certificate-not-valid + Status::CertificateInvalid => { + let status = subject.page.content.to_status_identity(); + status.set_description(Some(&match response.meta.data { + Some(data) => data.to_string(), + None => response.meta.status.to_string(), + })); + + subject.page.title.replace(status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); } + status => { + let _status = subject.page.content.to_status_failure(); + _status.set_description(Some(&format!("Undefined status code `{status}`"))); + subject.page.title.replace(_status.title()); + subject.page.navigation.request.widget.entry.set_progress_fraction(0.0); + subject.tab_page.set_loading(false); + }, } - Err(e) => { - let status = subject.page.content.to_status_failure(); - status.set_description(Some(&e.to_string())); - subject.page.title.replace(status.title()); - }, } - }, - ) - } + Err(e) => { + let status = subject.page.content.to_status_failure(); + status.set_description(Some(&e.to_string())); + subject.page.title.replace(status.title()); + } + } + }, + ) } /// Helper function, extract readable title from [Uri](https://docs.gtk.org/glib/struct.Uri.html)