implement nex protocol driver

This commit is contained in:
yggverse 2025-06-26 17:02:18 +03:00
parent aafd3b5db3
commit 02eb8e4a71
7 changed files with 311 additions and 15 deletions

View File

@ -1,6 +1,6 @@
[package]
name = "Yoda"
version = "0.11.6"
version = "0.11.7"
edition = "2024"
license = "MIT"
readme = "README.md"

View File

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

View File

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

View File

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

View File

@ -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<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,
) {
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::<IOStream>(), (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::<IOStream>(),
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<usize>) {
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<Page>, Rc<Feature>, 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<Page>, 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()),
}
});
}

View File

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

View File

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