mirror of
https://github.com/YGGverse/Yoda.git
synced 2025-08-26 06:21:58 +00:00
413 lines
18 KiB
Rust
413 lines
18 KiB
Rust
//! 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<Page>,
|
|
}
|
|
|
|
impl Nex {
|
|
pub fn init(page: &Rc<Page>) -> Self {
|
|
Self { page: page.clone() }
|
|
}
|
|
|
|
pub fn handle(
|
|
&self,
|
|
uri: Uri,
|
|
feature: Rc<Feature>,
|
|
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<Option<adw::StatusPage>> = RefCell::new(None);
|
|
let loading_total: Cell<usize> = 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::<IOStream>(),
|
|
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<usize>) {
|
|
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<Page>, Rc<Feature>, 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<Page>, 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::<IOStream>(),
|
|
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)
|
|
}
|