diff --git a/README.md b/README.md index 77a07132..acde35cb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ GTK 4 / Libadwaita client written in Rust * [ ] [Audio](#audio) * [ ] [Video](#video) * [ ] Certificates -* [ ] Downloads +* [x] Downloads * [ ] History * [ ] Proxy * [ ] Session @@ -95,8 +95,10 @@ GTK 4 / Libadwaita client written in Rust * [ ] [NPS](https://nightfall.city/nps/info/specification.txt) * [ ] Localhost * [ ] `file://` - local file browser -* [ ] System - * [ ] `config:` - low-level key/value settings editor +* [ ] Request mode + * [ ] `about:` + * [ ] `config` - low-level key/value settings editor + * [x] `download:` - save current request to file * [x] `source:` - page source viewer (by [sourceview5](https://crates.io/crates/sourceview5)) ### Media types diff --git a/src/app/browser/window/tab/item/page.rs b/src/app/browser/window/tab/item/page.rs index 93f32e59..a493e45a 100644 --- a/src/app/browser/window/tab/item/page.rs +++ b/src/app/browser/window/tab/item/page.rs @@ -5,6 +5,7 @@ mod error; mod input; mod meta; mod navigation; +mod request; mod widget; use client::Client; @@ -13,6 +14,7 @@ use error::Error; use input::Input; use meta::{Meta, Status}; use navigation::Navigation; +use request::Request; use widget::Widget; use crate::app::browser::{ @@ -23,12 +25,12 @@ use crate::Profile; use gtk::{ gdk::Texture, gdk_pixbuf::Pixbuf, - gio::SocketClientEvent, + gio::{Cancellable, SocketClientEvent}, glib::{ gformat, GString, Priority, Regex, RegexCompileFlags, RegexMatchFlags, Uri, UriFlags, UriHideFlags, }, - prelude::{EditableExt, SocketClientExt}, + prelude::{CancellableExt, EditableExt, FileExt, SocketClientExt, WidgetExt}, }; use sqlite::Transaction; use std::{rc::Rc, time::Duration}; @@ -188,19 +190,13 @@ impl Page { } // Return value from redirection holder - redirect.request() + Request::from(&redirect.request()) } else { // Reset redirect counter as request value taken from user input self.meta.unset_redirect_count(); // Return value from navigation entry - self.navigation.request.widget.entry.text() - }; - - // Detect source view mode, return `request` string prepared for route - let (request, is_source) = match request.strip_prefix("source:") { - Some(postfix) => (GString::from(postfix), true), - None => (request, false), + Request::from(&self.navigation.request.widget.entry.text()) }; // Update @@ -208,12 +204,20 @@ impl Page { self.browser_action.update.activate(Some(&id)); // Route by request - match Uri::parse(&request, UriFlags::NONE) { - Ok(uri) => { + match request { + Request::Default(ref uri) | Request::Download(ref uri) | Request::Source(ref uri) => { // Route by scheme match uri.scheme().as_str() { "file" => todo!(), - "gemini" => self.load_gemini(uri, is_history, is_source), + "gemini" => { + let (uri, is_download, is_source) = match request { + Request::Default(uri) => (uri, false, false), + Request::Download(uri) => (uri, true, false), + Request::Source(uri) => (uri, false, true), + _ => panic!(), + }; + self.load_gemini(uri, is_download, is_source, is_history) + } scheme => { // Define common data let status = Status::Failure; @@ -228,9 +232,7 @@ impl Page { self.content .to_status_failure() .set_title(title) - .set_description(Some( - gformat!("Protocol `{scheme}` not supported").as_str(), - )); + .set_description(Some(&format!("Scheme `{scheme}` not supported"))); // Update meta self.meta.set_status(status).set_title(title); @@ -240,21 +242,24 @@ impl Page { } } } - Err(_) => { + Request::Search(ref query) => { // Try interpret URI manually if Regex::match_simple( r"^[^\/\s]+\.[\w]{2,}", - request.clone(), + query, RegexCompileFlags::DEFAULT, RegexMatchFlags::DEFAULT, ) { // Seems request contain some host, try append default scheme - let request = gformat!("gemini://{request}"); - // Make sure new request conversable to valid URI - match Uri::parse(&request, UriFlags::NONE) { - Ok(_) => { + // * make sure new request conversable to valid URI + match Uri::parse(&format!("gemini://{query}"), UriFlags::NONE) { + Ok(uri) => { // Update navigation entry - self.navigation.request.widget.entry.set_text(&request); + self.navigation + .request + .widget + .entry + .set_text(&uri.to_string()); // Load page (without history record) self.load(false); @@ -265,19 +270,16 @@ impl Page { } } else { // Plain text given, make search request to default provider - let request = gformat!( + self.navigation.request.widget.entry.set_text(&format!( "gemini://tlgs.one/search?{}", - Uri::escape_string(&request, None, false) - ); - - // Update navigation entry - self.navigation.request.widget.entry.set_text(&request); + Uri::escape_string(query, None, false) + )); // Load page (without history record) self.load(false); } } - }; // Uri::parse + }; } pub fn update(&self) { @@ -385,7 +387,7 @@ impl Page { // Private helpers // @TODO move outside - fn load_gemini(&self, uri: Uri, is_history: bool, is_source: bool) { + fn load_gemini(&self, uri: Uri, is_download: bool, is_source: bool, is_history: bool) { // Init shared clones let cancellable = self.client.cancellable(); let update = self.browser_action.update.clone(); @@ -473,52 +475,227 @@ impl Page { }, // https://geminiprotocol.net/docs/protocol-specification.gmi#status-20 gemini::client::connection::response::meta::Status::Success => { - // Add history record if is_history { snap_history(navigation.clone()); } + if is_download { + // Update meta + meta.set_status(Status::Success).set_title("Download"); - // Route by MIME - match response.meta.mime { - Some(gemini::client::connection::response::meta::Mime::TextGemini) => { - // Read entire input stream to buffer - gemini::client::connection::response::data::Text::from_stream_async( - response.connection.stream(), - Priority::DEFAULT, - cancellable.clone(), - { - let content = content.clone(); - let id = id.clone(); - let meta = meta.clone(); - let update = update.clone(); - let uri = uri.clone(); - move |result|{ - match result { - Ok(buffer) => { - // Set children component, - // extract title from meta parsed - let title = if is_source { - content.to_text_source( - &buffer.data - ); - uri_to_title(&uri) - } else { - match content.to_text_gemini( - &uri, - &buffer.data - ).meta.title { - Some(meta_title) => meta_title, - None => uri_to_title(&uri) + // Update window + update.activate(Some(&id)); + + // Init download widget + content.to_status_download( + &uri_to_title(&uri), // grab default filename + &cancellable, + { + let cancellable = cancellable.clone(); + move |file, download_status| { + match file.replace( + None, + false, + gtk::gio::FileCreateFlags::NONE, + Some(&cancellable) + ) { + Ok(file_output_stream) => { + gemini::gio::file_output_stream::move_all_from_stream_async( + response.connection.stream(), + file_output_stream, + cancellable.clone(), + Priority::DEFAULT, + ( + 0x400, // 1024 bytes per chunk + None, // unlimited + 0 // initial totals + ), + ( + { + let download_status = download_status.clone(); + move |_, total| { + // Update loading progress + download_status.set_label( + &format!("Download: {total} bytes") + ); + } + }, + { + let cancellable = cancellable.clone(); + move |result| match result { + Ok((_, total)) => { + // Update loading progress + download_status.set_label( + &format!("Download completed ({total} bytes total)") + ); + } + Err(e) => { + // cancel uncompleted async operations + // * this will also toggle download widget actions + cancellable.cancel(); + + // update status message + download_status.set_label(&e.to_string()); + download_status.set_css_classes(&["error"]); + + // cleanup + let _ = file.delete(Cancellable::NONE); // @TODO + } + } } - }; + ) + ); + }, + Err(e) => { + // cancel uncompleted async operations + // * this will also toggle download widget actions + cancellable.cancel(); - // Update page meta - meta.set_status(Status::Success) - .set_title(&title); + // update status message + download_status.set_label(&e.to_string()); + download_status.set_css_classes(&["error"]); - // Update window components - update.activate(Some(&id)); + // cleanup + let _ = file.delete(Cancellable::NONE); // @TODO + } + } + } + } + ); + } else { // browse + match response.meta.mime { + Some(gemini::client::connection::response::meta::Mime::TextGemini) => { + // Read entire input stream to buffer + gemini::client::connection::response::data::Text::from_stream_async( + response.connection.stream(), + Priority::DEFAULT, + cancellable.clone(), + { + let content = content.clone(); + let id = id.clone(); + let meta = meta.clone(); + let update = update.clone(); + let uri = uri.clone(); + move |result|{ + match result { + Ok(buffer) => { + // Set children component, + // extract title from meta parsed + let title = if is_source { + content.to_text_source( + &buffer.data + ); + uri_to_title(&uri) + } else { + match content.to_text_gemini( + &uri, + &buffer.data + ).meta.title { + Some(meta_title) => meta_title, + None => uri_to_title(&uri) + } + }; + + // Update page meta + meta.set_status(Status::Success) + .set_title(&title); + + // Update window components + update.activate(Some(&id)); + } + Err(reason) => { + // Define common data + let status = Status::Failure; + let title = "Oops"; + let description = reason.to_string(); + + // Update widget + content + .to_status_failure() + .set_title(title) + .set_description(Some(&description)); + + // Update meta + meta.set_status(status) + .set_title(title); + + // Update window + update.activate(Some(&id)); + }, } + } + } + ); + }, + Some( + gemini::client::connection::response::meta::Mime::ImagePng | + gemini::client::connection::response::meta::Mime::ImageGif | + gemini::client::connection::response::meta::Mime::ImageJpeg | + gemini::client::connection::response::meta::Mime::ImageWebp + ) => { + // Final image size unknown, show loading widget + let status = content.to_status_loading( + Some(Duration::from_secs(1)) // show if download time > 1 second + ); + + // Asynchronously move `InputStream` data from `SocketConnection` into the local `MemoryInputStream` + // this action allows to count the bytes for loading widget and validate max size for incoming data + gemini::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| { + // Update loading progress + status.set_description( + Some(&gformat!("Download: {total} bytes")) + ); + }, + { + let cancellable = cancellable.clone(); + let content = content.clone(); + let id = id.clone(); + let meta = meta.clone(); + let update = update.clone(); + let uri = uri.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) => { + // Update page meta + meta.set_status(Status::Success) + .set_title(uri_to_title(&uri).as_str()); + + // Update page content + content.to_image(&Texture::for_pixbuf(&buffer)); + + // Update window components + update.activate(Some(&id)); + } + Err(reason) => { + // Define common data + let status = Status::Failure; + let title = "Oops"; + + // Update widget + content + .to_status_failure() + .set_title(title) + .set_description(Some(reason.message())); + + // Update meta + meta.set_status(status) + .set_title(title); + } + } + } + ); + }, Err(reason) => { // Define common data let status = Status::Failure; @@ -534,124 +711,31 @@ impl Page { // Update meta meta.set_status(status) .set_title(title); - - // Update window - update.activate(Some(&id)); - }, + } + } } - } - } - ); - }, - Some( - gemini::client::connection::response::meta::Mime::ImagePng | - gemini::client::connection::response::meta::Mime::ImageGif | - gemini::client::connection::response::meta::Mime::ImageJpeg | - gemini::client::connection::response::meta::Mime::ImageWebp - ) => { - // Final image size unknown, show loading widget - let status = content.to_status_loading( - Some(Duration::from_secs(1)) // show if download time > 1 second - ); - - // Asynchronously move `InputStream` data from `SocketConnection` into the local `MemoryInputStream` - // this action allows to count the bytes for loading widget and validate max size for incoming data - gemini::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)| { - // Update loading progress - status.set_description( - Some(&gformat!("Download: {total} bytes")) ); - }, - { - let cancellable = cancellable.clone(); - let content = content.clone(); - let id = id.clone(); - let meta = meta.clone(); - let update = update.clone(); - let uri = uri.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) => { - // Update page meta - meta.set_status(Status::Success) - .set_title(uri_to_title(&uri).as_str()); + }, + _ => { + // Define common data + let status = Status::Failure; + let title = "Oops"; + let description = gformat!("Content type not supported"); - // Update page content - content.to_image(&Texture::for_pixbuf(&buffer)); + // Update widget + content + .to_status_failure() + .set_title(title) + .set_description(Some(&description)); - // Update window components - update.activate(Some(&id)); - } - Err(reason) => { - // Define common data - let status = Status::Failure; - let title = "Oops"; + // Update meta + meta.set_status(status) + .set_title(title); - // Update widget - content - .to_status_failure() - .set_title(title) - .set_description(Some(reason.message())); - - // Update meta - meta.set_status(status) - .set_title(title); - } - } - } - ); - }, - Err(reason) => { - // Define common data - let status = Status::Failure; - let title = "Oops"; - let description = reason.to_string(); - - // Update widget - content - .to_status_failure() - .set_title(title) - .set_description(Some(&description)); - - // Update meta - meta.set_status(status) - .set_title(title); - } - } - } - ); - }, - _ => { - // Define common data - let status = Status::Failure; - let title = "Oops"; - let description = gformat!("Content type not supported"); - - // Update widget - content - .to_status_failure() - .set_title(title) - .set_description(Some(&description)); - - // Update meta - meta.set_status(status) - .set_title(title); - - // Update window - update.activate(Some(&id)); - }, + // Update window + update.activate(Some(&id)); + }, + } } }, // https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection @@ -816,7 +900,7 @@ impl Page { .set_title(title) .set_description(Some(&match response.meta.data { Some(data) => data.value, - None => gformat!("Status code yet not supported"), + None => gformat!("Status code not supported"), })); // Update meta diff --git a/src/app/browser/window/tab/item/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index ea1df705..aa73e719 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -9,9 +9,10 @@ use text::Text; use crate::app::browser::window::{tab::item::Action as TabAction, Action as WindowAction}; use gtk::{ gdk::Paintable, + gio::{Cancellable, File}, glib::Uri, prelude::{BoxExt, IsA, WidgetExt}, - Box, Orientation, + Box, Label, Orientation, }; use std::{rc::Rc, time::Duration}; @@ -45,6 +46,21 @@ impl Content { image } + /// Set new `content::Status` component for `Self` with new `status::Download` preset + /// + /// * action removes previous children component from `Self` + pub fn to_status_download( + &self, + initial_filename: &str, + cancellable: &Cancellable, + on_choose: impl Fn(File, Label) + 'static, + ) -> Status { + self.clean(); + let status = Status::new_download(initial_filename, cancellable, on_choose); + self.gobject.append(status.gobject()); + status + } + /// Set new `content::Status` component for `Self` with new `status::Failure` preset /// /// * action removes previous children component from `Self` diff --git a/src/app/browser/window/tab/item/page/content/status.rs b/src/app/browser/window/tab/item/page/content/status.rs index f81487a1..e3cd1983 100644 --- a/src/app/browser/window/tab/item/page/content/status.rs +++ b/src/app/browser/window/tab/item/page/content/status.rs @@ -1,9 +1,14 @@ +mod download; mod failure; mod identity; mod loading; use crate::app::browser::window::tab::item::Action; use adw::StatusPage; +use gtk::{ + gio::{Cancellable, File}, + Label, +}; use std::{rc::Rc, time::Duration}; pub struct Status { @@ -13,6 +18,17 @@ pub struct Status { impl Status { // Constructors + /// Create new download preset + pub fn new_download( + initial_filename: &str, + cancellable: &Cancellable, + on_choose: impl Fn(File, Label) + 'static, + ) -> Self { + Self { + gobject: download::new(initial_filename, cancellable, on_choose), + } + } + /// Create new failure preset /// /// Useful as placeholder widget for error handlers diff --git a/src/app/browser/window/tab/item/page/content/status/download.rs b/src/app/browser/window/tab/item/page/content/status/download.rs new file mode 100644 index 00000000..0afd8daa --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/status/download.rs @@ -0,0 +1,194 @@ +use adw::StatusPage; +use gtk::{ + gio::{Cancellable, File}, + prelude::{BoxExt, ButtonExt, CancellableExt, WidgetExt}, + Align, + Box, + Button, + FileDialog, + FileLauncher, + Label, + Orientation, + Spinner, // use adw::Spinner; @TODO adw 1.6 / ubuntu 24.10+ + Window, +}; +use std::rc::Rc; + +const MARGIN: i32 = 16; +const SPINNER_SIZE: i32 = 32; // 16-64 + +/// Create new [StatusPage](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.StatusPage.html) +/// with progress indication and UI controls +/// * applies callback function once on destination [File](https://docs.gtk.org/gio/iface.File.html) selected +/// * requires external IOStream read/write implementation, depending of protocol driver in use +pub fn new( + initial_filename: &str, + cancellable: &Cancellable, + on_choose: impl Fn(File, Label) + 'static, +) -> StatusPage { + // Init file chooser dialog + let dialog = FileDialog::builder().initial_name(initial_filename).build(); + + // Init file launcher dialog + let file_launcher = FileLauncher::new(File::NONE); + + // Init spinner component + let spinner = Spinner::builder() + .height_request(SPINNER_SIZE) + .visible(false) + .width_request(SPINNER_SIZE) + .build(); + + // Init `status` feature + // * indicates current download state in text label + let status = Label::builder() + .label("Choose location to download") + .margin_top(MARGIN) + .build(); + + // Init `cancel` feature + // * applies shared `Cancellable` + let cancel = Button::builder() + .css_classes(["error"]) + .halign(Align::Center) + .label("Cancel") + .margin_top(MARGIN) + .visible(false) + .build(); + + cancel.connect_clicked({ + let cancellable = cancellable.clone(); + let spinner = spinner.clone(); + let status = status.clone(); + move |this| { + // apply cancellable + cancellable.cancel(); + + // deactivate `spinner` + spinner.set_visible(false); + spinner.stop(); + + // update `status` + status.set_css_classes(&["warning"]); + status.set_label("Operation cancelled"); + + // hide self + this.set_visible(false); + } + }); + + // Init `open` feature + // * open selected file on download complete + let open = Button::builder() + .css_classes(["accent"]) + .halign(Align::Center) + .label("Open") + .margin_top(MARGIN) + .visible(false) + .build(); + + open.connect_clicked({ + let file_launcher = file_launcher.clone(); + let status = status.clone(); + move |this| { + this.set_sensitive(false); // lock + file_launcher.launch(Window::NONE, Cancellable::NONE, { + let status = status.clone(); + let this = this.clone(); + move |result| { + if let Err(ref e) = result { + status.set_css_classes(&["error"]); + status.set_label(e.message()) + } + this.set_sensitive(true); // unlock + } + }) + } + }); + + // Init `choose` feature + // * select file destination for download + let choose = Button::builder() + .css_classes(["accent"]) + .halign(Align::Center) + .label("Choose..") + .margin_top(MARGIN) + .build(); + + choose.connect_clicked({ + // init shared references + let cancel = cancel.clone(); + let dialog = dialog.clone(); + let file_launcher = file_launcher.clone(); + let spinner = spinner.clone(); + let status = status.clone(); + let on_choose = Rc::new(on_choose); + move |this| { + // lock choose button to prevent double click + this.set_sensitive(false); + dialog.save(Window::NONE, Cancellable::NONE, { + // delegate shared references + let cancel = cancel.clone(); + let file_launcher = file_launcher.clone(); + let spinner = spinner.clone(); + let status = status.clone(); + let this = this.clone(); + let on_choose = on_choose.clone(); + move |result| { + this.set_sensitive(true); // unlock + match result { + Ok(file) => { + // update destination file + file_launcher.set_file(Some(&file)); + + // update `status` + status.set_css_classes(&[]); + status.set_label("Loading..."); + + // show `cancel` button + cancel.set_visible(true); + + // show `spinner` + spinner.set_visible(true); + spinner.start(); + + // hide self + this.set_visible(false); + + // callback + on_choose(file, status) + } + Err(e) => { + // update destination file + file_launcher.set_file(File::NONE); + + // update `spinner` + spinner.set_visible(false); + spinner.stop(); + + // update `status` + status.set_css_classes(&["warning"]); + status.set_label(e.message()) + } + } + } + }); + } + }); + + // Init main container + let child = Box::builder().orientation(Orientation::Vertical).build(); + + child.append(&spinner); + child.append(&status); + child.append(&cancel); + child.append(&choose); + child.append(&open); + + // Done + StatusPage::builder() + .child(&child) + .icon_name("document-save-symbolic") + .title("Download") + .build() +} diff --git a/src/app/browser/window/tab/item/page/request.rs b/src/app/browser/window/tab/item/page/request.rs new file mode 100644 index 00000000..87adf20e --- /dev/null +++ b/src/app/browser/window/tab/item/page/request.rs @@ -0,0 +1,34 @@ +use gtk::glib::{Uri, UriFlags}; + +/// Request type for `Page` with optional value parsed +pub enum Request { + Default(Uri), + Download(Uri), + Source(Uri), + Search(String), +} + +impl Request { + // Constructors + + /// Create new `Self` from `request` string + pub fn from(request: &str) -> Self { + if let Some(postfix) = request.strip_prefix("source:") { + if let Ok(uri) = Uri::parse(postfix, UriFlags::NONE) { + return Self::Source(uri); + } + } + + if let Some(postfix) = request.strip_prefix("download:") { + if let Ok(uri) = Uri::parse(postfix, UriFlags::NONE) { + return Self::Download(uri); + } + } + + if let Ok(uri) = Uri::parse(request, UriFlags::NONE) { + return Self::Default(uri); + } + + Self::Search(request.to_string()) + } +}