diff --git a/Cargo.toml b/Cargo.toml index 296b422e..3036c9c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.11.6" +version = "0.11.7" edition = "2024" license = "MIT" readme = "README.md" diff --git a/README.md b/README.md index 4764ce0f..d71a11d6 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ GTK 4 / Libadwaita client written in Rust * [x] Header options * [x] MIME * [x] Token -* [ ] [NEX](https://nightfall.city/nex/info/specification.txt) - useful for networks with build-in encryption (e.g. [Yggdrasil](https://yggdrasil-network.github.io)) +* [x] [NEX](https://nightfall.city/nex/info/specification.txt) - useful for networks with build-in encryption (e.g. [Yggdrasil](https://yggdrasil-network.github.io)) * [ ] [NPS](https://nightfall.city/nps/info/specification.txt) * [x] System * [x] `file://` - local files browser @@ -130,6 +130,7 @@ GTK 4 / Libadwaita client written in Rust #### Text * [x] `text/gemini` * [x] `text/plain` + * [ ] `text/nex` #### Images * [x] `image/gif` diff --git a/src/app/browser/window/tab/item/client.rs b/src/app/browser/window/tab/item/client.rs index ea5b01de..eb102627 100644 --- a/src/app/browser/window/tab/item/client.rs +++ b/src/app/browser/window/tab/item/client.rs @@ -75,6 +75,9 @@ impl Client { .gemini .handle(uri, feature, cancellable, is_snap_history) } + "nex" => driver + .nex + .handle(uri, feature, cancellable, is_snap_history), scheme => { // no scheme match driver, complete with failure message let status = page.content.to_status_failure(); diff --git a/src/app/browser/window/tab/item/client/driver.rs b/src/app/browser/window/tab/item/client/driver.rs index 8d1337c2..277a6ff9 100644 --- a/src/app/browser/window/tab/item/client/driver.rs +++ b/src/app/browser/window/tab/item/client/driver.rs @@ -1,15 +1,18 @@ mod file; mod gemini; +mod nex; use super::{Feature, Page}; use file::File; use gemini::Gemini; +use nex::Nex; use std::rc::Rc; /// Different protocols implementation pub struct Driver { pub file: File, pub gemini: Gemini, + pub nex: Nex, } impl Driver { @@ -20,6 +23,7 @@ impl Driver { Driver { file: File::init(page), gemini: Gemini::init(page), + nex: Nex::init(page), } } } diff --git a/src/app/browser/window/tab/item/client/driver/nex.rs b/src/app/browser/window/tab/item/client/driver/nex.rs new file mode 100644 index 00000000..cfcf82c9 --- /dev/null +++ b/src/app/browser/window/tab/item/client/driver/nex.rs @@ -0,0 +1,283 @@ +//! https://nightfall.city/nex/info/specification.txt + +use super::{Feature, Page}; +use gtk::gio::MemoryInputStream; +use gtk::prelude::{ + Cast, IOStreamExt, InputStreamExtManual, OutputStreamExtManual, SocketClientExt, +}; +use gtk::{ + gdk::Texture, + gdk_pixbuf::Pixbuf, + gio::{Cancellable, IOStream, SocketClient, SocketClientEvent, SocketProtocol}, + glib::{Priority, Uri}, +}; +use sourceview::prelude::FileExt; +use std::{rc::Rc, time::Duration}; + +pub struct Nex { + page: Rc, +} + +impl Nex { + pub fn init(page: &Rc) -> Self { + Self { page: page.clone() } + } + + pub fn handle( + &self, + uri: Uri, + feature: Rc, + cancellable: Cancellable, + is_snap_history: bool, + ) { + if is_snap_history { + self.page.snap_history(); + } + + { + self.page + .navigation + .request + .info + .borrow_mut() + .set_request(Some(uri.to_string())); + } + + let socket = SocketClient::new(); + socket.set_protocol(SocketProtocol::Tcp); + socket.set_timeout(30); // @TODO optional + + socket.connect_event({ + let p = self.page.clone(); + move |_, e, _, _| { + let mut i = p.navigation.request.info.borrow_mut(); + p.set_progress(match e { + // 0.1 reserved for handle begin + SocketClientEvent::Resolving => { + i.add_event("Resolving".to_string()); + 0.2 + } + SocketClientEvent::Resolved => { + i.add_event("Resolved".to_string()); + 0.3 + } + SocketClientEvent::Connecting => { + i.add_event("Connecting".to_string()); + 0.4 + } + SocketClientEvent::Connected => { + i.add_event("Connected".to_string()); + 0.5 + } + SocketClientEvent::ProxyNegotiating => { + i.add_event("Proxy negotiating".to_string()); + 0.6 + } + SocketClientEvent::ProxyNegotiated => { + i.add_event("Proxy negotiated".to_string()); + 0.7 + } + SocketClientEvent::TlsHandshaking => { + i.add_event("TLS handshaking".to_string()); + 0.8 + } + SocketClientEvent::TlsHandshaked => { + i.add_event("TLS handshaked".to_string()); + 0.9 + } + SocketClientEvent::Complete => { + i.add_event("Receiving".to_string()); + 1.0 + } + _ => panic!(), + }) + } + }); + + socket.connect_to_uri_async(&uri.to_string(), 1900, Some(&cancellable.clone()), { + let p = self.page.clone(); + move |result| match result { + Ok(c) => { + c.output_stream().write_all_async( + format!("{}\r\n", uri.path()), + Priority::DEFAULT, + Some(&cancellable.clone()), + move |r| match r { + Ok(_) => { + // Is download feature request + if matches!(*feature, Feature::Download) { + return download(c.upcast::(), (p, uri), cancellable); + } + + // Handle renderable types.. + // Show loading status if handle time > 1 second + let status = + p.content.to_status_loading(Some(Duration::from_secs(1))); + + // Nex is the header-less protocol, final content size is never known, + // borrow ggemini::gio wrapper api to preload it safely by chunks + ggemini::gio::memory_input_stream::from_stream_async( + c.upcast::(), + Priority::DEFAULT, + cancellable.clone(), + ggemini::gio::memory_input_stream::Size { + chunk: 0x400, // 1024 bytes chunk + limit: 0xA00000, // 10M limit + total: 0, // initial totals + }, + ( + move |_, t| { + status.set_description(Some(&format!( + "Preload: {t} bytes" + ))) + }, + move |r| match r { + Ok((m, s)) => { + render((m, s), (p, feature, uri), cancellable) + } + Err(e) => failure(&p, &e.to_string()), + }, + ), + ) + } + Err((_, e)) => failure(&p, &e.to_string()), + }, + ) + } + Err(e) => failure(&p, &e.to_string()), + } + }) + } +} + +fn event(p: &Page, e: &str, s: Option) { + let mut i = p.navigation.request.info.borrow_mut(); + i.add_event(e.to_string()).set_size(s); + p.navigation.request.update_secondary_icon(&i) +} + +fn failure(p: &Page, d: &str) { + let s = p.content.to_status_failure(); + s.set_description(Some(d)); + p.set_progress(0.0); + p.set_title(&s.title()) +} + +fn render( + (m, s): (MemoryInputStream, usize), + (p, f, u): (Rc, Rc, Uri), + c: Cancellable, +) { + use crate::tool::uri_to_title; + let q = u.to_string(); + if q.ends_with(".gif") + || q.ends_with(".jpeg") + || q.ends_with(".jpg") + || q.ends_with(".png") + || q.ends_with(".webp") + { + p.window_action.find.simple_action.set_enabled(false); + Pixbuf::from_stream_async(&m, Some(&c), move |r| match r { + Ok(b) => { + p.set_title(&uri_to_title(&u)); + p.content.to_image(&Texture::for_pixbuf(&b)); + p.set_progress(0.0); + event(&p, "Completed", Some(s)) + } + Err(e) => failure(&p, &e.to_string()), + }) + } else { + p.window_action.find.simple_action.set_enabled(true); + match *f { + Feature::Default | Feature::Source => { + m.read_all_async(vec![0; s], Priority::DEFAULT, Some(&c), move |r| match r { + Ok((b, s, ..)) => match std::str::from_utf8(&b) { + Ok(d) => { + let t = if matches!(*f, Feature::Source) { + p.content.to_text_source(d) + } else if q.ends_with(".gmi") || q.ends_with(".gemini") { + p.content.to_text_gemini(&u, d) + } else { + p.content.to_text_plain(d) + }; + event(&p, "Parsed", Some(s)); + p.search.set(Some(t.text_view)); + p.set_title(&match t.meta.title { + Some(t) => t.into(), // @TODO + None => uri_to_title(&u), + }); + p.set_progress(0.0); + event(&p, "Completed", Some(s)) + } + Err(e) => failure(&p, &e.to_string()), + }, + Err((_, e)) => failure(&p, &e.to_string()), + }) + } + Feature::Download => panic!(), // unexpected + } + } +} + +fn download(s: IOStream, (p, u): (Rc, Uri), c: Cancellable) { + use crate::tool::Format; + use ggemini::gio::file_output_stream; + event(&p, "Download begin", None); + let t = crate::tool::uri_to_title(&u) + .trim_matches(std::path::MAIN_SEPARATOR) + .to_string(); + p.content.to_status_download(&t, &c, { + let c = c.clone(); + let p = p.clone(); + let t = t.clone(); + move |f, a| match f.replace(None, false, gtk::gio::FileCreateFlags::NONE, Some(&c)) { + Ok(file_output_stream) => { + file_output_stream::from_stream_async( + s.clone(), + file_output_stream, + c.clone(), + Priority::DEFAULT, + file_output_stream::Size { + chunk: 0x100000, // 1M bytes per chunk + limit: None, // unlimited + total: 0, // initial totals + }, + ( + // on chunk + { + let a = a.clone(); + let p = p.clone(); + move |_, total| { + const T: &str = "Received"; + let t = format!("{T} {}...", total.bytes()); + event(&p, T, Some(total)); + p.set_title(&t); + a.update.activate(&t) + } + }, + // on complete + { + let a = a.clone(); + let p = p.clone(); + let t = t.clone(); + move |result| match result { + Ok((_, total)) => { + a.complete.activate(&format!( + "Saved to {} ({} total)", + f.parse_name(), + total.bytes() + )); + p.set_progress(0.0); + p.set_title(&t); + event(&p, "Completed", Some(total)) + } + Err(e) => a.cancel.activate(&e.to_string()), + } + }, + ), + ) + } + Err(e) => a.cancel.activate(&e.to_string()), + } + }); +} diff --git a/src/app/browser/window/tab/item/page/navigation/request.rs b/src/app/browser/window/tab/item/page/navigation/request.rs index f9aa7ec1..6719aa13 100644 --- a/src/app/browser/window/tab/item/page/navigation/request.rs +++ b/src/app/browser/window/tab/item/page/navigation/request.rs @@ -309,7 +309,10 @@ fn update_primary_icon(entry: &Entry, profile: &Profile) { entry.first_child().unwrap().remove_css_class("success"); // @TODO handle match primary_icon::from(&entry.text()) { - PrimaryIcon::Download { name, tooltip } | PrimaryIcon::File { name, tooltip } => { + PrimaryIcon::Download { name, tooltip } + | PrimaryIcon::File { name, tooltip } + | PrimaryIcon::Source { name, tooltip } + | PrimaryIcon::Nex { name, tooltip } => { entry.set_primary_icon_activatable(false); entry.set_primary_icon_sensitive(false); entry.set_primary_icon_name(Some(name)); @@ -332,12 +335,6 @@ fn update_primary_icon(entry: &Entry, profile: &Profile) { entry.set_primary_icon_name(Some(name)); entry.set_primary_icon_tooltip_text(Some(tooltip)); } - PrimaryIcon::Source { name, tooltip } => { - entry.set_primary_icon_activatable(false); - entry.set_primary_icon_sensitive(false); - entry.set_primary_icon_name(Some(name)); - entry.set_primary_icon_tooltip_text(Some(tooltip)); - } } } @@ -415,10 +412,7 @@ fn prefix_less(entry: &Entry) -> GString { /// Try get current request value as [Uri](https://docs.gtk.org/glib/struct.Uri.html) /// * `strip_prefix` on parse fn uri(entry: &Entry) -> Option { - match Uri::parse(&prefix_less(entry), UriFlags::NONE) { - Ok(uri) => Some(uri), - _ => None, - } + Uri::parse(&prefix_less(entry), UriFlags::NONE).ok() } /// Try build home [Uri](https://docs.gtk.org/glib/struct.Uri.html) for `Self` diff --git a/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs b/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs index bd051162..c6b5fd1a 100644 --- a/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs +++ b/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs @@ -23,6 +23,10 @@ pub enum PrimaryIcon<'a> { name: &'a str, tooltip: (&'a str, &'a str), }, + Nex { + name: &'a str, + tooltip: &'a str, + }, } pub fn from(request: &str) -> PrimaryIcon { @@ -49,20 +53,27 @@ pub fn from(request: &str) -> PrimaryIcon { }; } - if prefix.starts_with("gemini:") { + if prefix.starts_with("gemini://") { return PrimaryIcon::Gemini { name: "channel-secure-symbolic", tooltip: ("Guest session", "User session"), }; } - if prefix.starts_with("titan:") { + if prefix.starts_with("titan://") { return PrimaryIcon::Titan { name: "document-send-symbolic", tooltip: ("Guest titan input", "User titan input"), }; } + if prefix.starts_with("nex://") { + return PrimaryIcon::Nex { + name: "network-server-symbolic", + tooltip: "Nex protocol connection", + }; + } + PrimaryIcon::Search { name: "system-search-symbolic", tooltip: "Choose default search provider",