format request, begin download feature implementation

This commit is contained in:
yggverse 2024-12-09 14:55:20 +02:00
parent 88b3adaf2d
commit 09f2a17f22
6 changed files with 533 additions and 187 deletions

View file

@ -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

View file

@ -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,12 +475,93 @@ 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
// 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 status message
download_status.set_label(&e.to_string());
download_status.set_css_classes(&["error"]);
// 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
@ -562,7 +645,7 @@ impl Page {
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)| {
move |_, total| {
// Update loading progress
status.set_description(
Some(&gformat!("Download: {total} bytes"))
@ -576,7 +659,7 @@ impl Page {
let update = update.clone();
let uri = uri.clone();
move |result| match result {
Ok(memory_input_stream) => {
Ok((memory_input_stream, _)) => {
Pixbuf::from_stream_async(
&memory_input_stream,
Some(&cancellable),
@ -653,6 +736,7 @@ impl Page {
update.activate(Some(&id));
},
}
}
},
// https://geminiprotocol.net/docs/protocol-specification.gmi#status-30-temporary-redirection
gemini::client::connection::response::meta::Status::Redirect |
@ -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

View file

@ -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`

View file

@ -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

View file

@ -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()
}

View file

@ -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())
}
}