//! https://nightfall.city/nex/info/specification.txt use super::{Feature, Page}; use crate::tool::{Format, uri_to_title}; use gtk::gio::{MemoryInputStream, SocketConnection}; 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::{ cell::{Cell, RefCell}, rc::Rc, }; 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, ) { { self.page .navigation .request .info .borrow_mut() .set_request(Some(uri.to_string())); } // copy once let path = uri.path(); let url = uri.to_string(); if path.is_empty() { // auto-append trailing slash to the root locations let mut r = uri.to_string(); r.push('/'); // apply the permanent redirection let mut i = self.page.navigation.request.info.take(); i.add_event("Canonicalize root request".to_string()); self.page .navigation .request .info .replace(i.into_permanent_redirect()); self.page.navigation.set_request(&r); self.page.item_action.load.activate(Some(&r), false, true); return; // prevents operation cancelled message on redirect } if is_snap_history { self.page.snap_history(); } let socket = SocketClient::new(); socket.set_proxy_resolver(self.page.profile.proxy.matches(&url).as_ref()); 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(&url, 1900, Some(&cancellable.clone()), { let p = self.page.clone(); move |result| match result { Ok(c) => { { use gtk::prelude::SocketConnectionExt; let mut i = p.navigation.request.info.borrow_mut(); i.set_socket(Some(( c.local_address().unwrap(), c.remote_address().unwrap(), ))); // * unwrap fails only on `connection.socket_connection.is_closed()` // panic as unexpected. } c.output_stream().write_all_async( format!("{path}\r\n"), Priority::DEFAULT, Some(&cancellable.clone()), move |r| match r { Ok((_, size, _)) => { // Is download feature request, // delegate this task to the separated handler function. if matches!(*feature, Feature::Download) { return download(c, (p, uri), cancellable); } // Navigate to the download gateway on content type is not supported if !is_renderable(&path) { p.content .to_status_mime(&path, Some((&p.item_action, &uri))); p.set_progress(0.0); c.close_async(Priority::DEFAULT, Some(&cancellable), { let p = p.clone(); move |r| { event( &p, match r { Ok(()) => "Disconnected".to_string(), Err(e) => e.to_string(), }, Some(size), ) } }); return; } // Is renderable types.. // Show loading status page if awaiting time > 1 second // * the RefCell is just to not init the loading widget before timeout and prevent bg blinks let loading: RefCell> = RefCell::new(None); let loading_total: Cell = Cell::new(0); // Nex is the header-less protocol, final content size is never known, // borrow ggemini::gio wrapper api to preload the buffer swap-safely, // by using the chunks controller. ggemini::gio::memory_input_stream::from_stream_async( c.clone().upcast::(), Priority::DEFAULT, cancellable.clone(), ggemini::gio::memory_input_stream::Size { chunk: 0x400, // 1024 bytes chunk limit: 0xA00000, // 10M limit total: 0, // initial totals }, ( { let p = p.clone(); move |_, t| { if loading_total.replace(t) > 102400 { let mut l = loading.borrow_mut(); match *l { Some(ref this) => { this.set_description(Some(&format!( "Preload: {}", t.bytes() ))) } None => { l.replace( p.content.to_status_loading(None), ); } } } } }, move |r| match r { Ok((m, s)) => { c.close_async( Priority::DEFAULT, Some(&cancellable), { let p = p.clone(); move |r| { event( &p, match r { Ok(()) => { "Disconnected".to_string() } Err(e) => e.to_string(), }, Some(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: String, s: Option) { let mut i = p.navigation.request.info.borrow_mut(); i.add_event(e).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, ) { let q = u.path(); if is_image(&q) { 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".to_string(), Some(s)) } Err(e) => failure(&p, &e.to_string()), }) } else if is_document(&q) { 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("/") { p.content.to_text_nex(&u, 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".to_string(), 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".to_string(), Some(s)) } Err(e) => failure(&p, &e.to_string()), }, Err((_, e)) => failure(&p, &e.to_string()), }) } Feature::Download => panic!(), // unexpected } } else { panic!() // unexpected } } fn download(s: SocketConnection, (p, u): (Rc, Uri), c: Cancellable) { use crate::tool::Format; use ggemini::gio::file_output_stream; event(&p, "Download begin".to_string(), 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().upcast::(), 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.to_string(), Some(total)); p.set_title(&t); a.update.activate(&t) } }, // on complete { let a = a.clone(); let p = p.clone(); let t = t.clone(); let c = c.clone(); let s = s.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); s.close_async(Priority::DEFAULT, Some(&c), { let p = p.clone(); move |r| { event( &p, match r { Ok(()) => "Disconnected".to_string(), Err(e) => e.to_string(), }, Some(total), ) } }) } Err(e) => a.cancel.activate(&e.to_string()), } }, ), ) } Err(e) => a.cancel.activate(&e.to_string()), } }); } fn is_image(q: &str) -> bool { q.ends_with(".gif") || q.ends_with(".jpeg") || q.ends_with(".jpg") || q.ends_with(".png") || q.ends_with(".webp") } fn is_document(q: &str) -> bool { q.ends_with(".txt") || q.ends_with(".log") || q.ends_with(".gmi") || q.ends_with(".gemini") || q.ends_with("/") || !q.contains(".") } fn is_renderable(q: &str) -> bool { is_document(q) || is_image(q) }