draft ggemini 0.11.0 version api

This commit is contained in:
yggverse 2024-11-27 04:20:56 +02:00
parent 5152d915d1
commit 88a37c545b
4 changed files with 404 additions and 609 deletions

View File

@ -17,7 +17,7 @@ features = ["v1_6"]
[dependencies.gemini]
package = "ggemini"
version = "0.10.0"
version = "0.11.0"
[dependencies.gemtext]
package = "ggemtext"

View File

@ -135,10 +135,10 @@ impl Tab {
// Register dynamically created tab components in the HashMap index
self.index
.borrow_mut()
.insert(item.id().clone(), item.clone());
.insert(item.id.clone(), item.clone());
item.page()
.navigation()
item.page
.navigation
.request()
.widget()
.gobject()
@ -163,7 +163,7 @@ impl Tab {
if let Some(page) = self.widget.page(page_position) {
if let Some(id) = page.keyword() {
if let Some(item) = self.index.borrow().get(&id) {
return match item.page().bookmark() {
return match item.page.bookmark() {
Ok(result) => Ok(result),
Err(_) => Err(Error::Bookmark),
};
@ -182,7 +182,7 @@ impl Tab {
if let Some(page) = self.widget.page(page_position) {
if let Some(id) = page.keyword() {
if let Some(item) = self.index.borrow().get(&id) {
item.page().home();
item.page.home();
}
}
}
@ -192,7 +192,7 @@ impl Tab {
if let Some(page) = self.widget.page(page_position) {
if let Some(id) = page.keyword() {
if let Some(item) = self.index.borrow().get(&id) {
item.page().history_back();
item.page.history_back();
}
}
}
@ -202,7 +202,7 @@ impl Tab {
if let Some(page) = self.widget.page(page_position) {
if let Some(id) = page.keyword() {
if let Some(item) = self.index.borrow().get(&id) {
item.page().history_forward();
item.page.history_forward();
}
}
}
@ -213,7 +213,7 @@ impl Tab {
if let Some(page) = self.widget.page(page_position) {
if let Some(id) = page.keyword() {
if let Some(item) = self.index.borrow().get(&id) {
item.page().reload();
item.page.reload();
}
}
}
@ -231,10 +231,10 @@ impl Tab {
item.update();
// Update tab title on loading indicator inactive
if !item.page().is_loading() {
item.widget()
if !item.page.is_loading() {
item.widget
.gobject()
.set_title(item.page().meta().title().as_str())
.set_title(item.page.meta.title().as_str())
}
}
// Update all tabs on ID not found @TODO change initial update method
@ -244,10 +244,10 @@ impl Tab {
item.update();
// Update tab title on loading indicator inactive
if !item.page().is_loading() {
item.widget()
if !item.page.is_loading() {
item.widget
.gobject()
.set_title(item.page().meta().title().as_str())
.set_title(item.page.meta.title().as_str())
}
}
}
@ -299,7 +299,7 @@ impl Tab {
// Register dynamically created tab item in the HashMap index
self.index
.borrow_mut()
.insert(item.id().clone(), item.clone());
.insert(item.id.clone(), item.clone());
}
}
Err(e) => return Err(e.to_string()),
@ -327,10 +327,10 @@ impl Tab {
item.save(
transaction,
&id,
&self.widget.gobject().page_position(item.widget().gobject()),
&item.widget().gobject().is_pinned(),
&item.widget().gobject().is_selected(),
&item.widget().gobject().needs_attention(),
&self.widget.gobject().page_position(item.widget.gobject()),
&item.widget.gobject().is_pinned(),
&item.widget.gobject().is_selected(),
&item.widget.gobject().needs_attention(),
)?;
}
}

View File

@ -24,10 +24,10 @@ use std::rc::Rc;
pub struct Item {
// Auto-generated unique item ID
// useful as widget name in GTK actions callback
id: GString,
pub id: GString,
// Components
page: Rc<Page>,
widget: Rc<Widget>,
pub page: Rc<Page>,
pub widget: Rc<Widget>,
}
impl Item {
@ -57,7 +57,7 @@ impl Item {
let widget = Rc::new(Widget::new(
id.as_str(),
tab_view,
page.widget().gobject(),
page.widget.gobject(),
None,
position,
(is_pinned, is_selected, is_attention),
@ -66,11 +66,7 @@ impl Item {
// Init events
if let Some(text) = request {
page.navigation()
.request()
.widget()
.gobject()
.set_text(&text);
page.navigation.request().widget().gobject().set_text(&text);
if is_load {
page.load(true);
@ -83,7 +79,7 @@ impl Item {
let parent = tab_view.clone().upcast::<gtk::Widget>();
move || {
// Request should match valid URI for all drivers supported
if let Some(uri) = page.navigation().request().uri() {
if let Some(uri) = page.navigation.request().uri() {
// Rout by scheme
if uri.scheme().to_lowercase() == "gemini" {
return identity::new_gemini(profile.clone(), actions.1.clone(), uri)
@ -100,11 +96,7 @@ impl Item {
let page = page.clone();
move |request, is_history| {
if let Some(text) = request {
page.navigation()
.request()
.widget()
.gobject()
.set_text(&text);
page.navigation.request().widget().gobject().set_text(&text);
}
page.load(is_history);
}
@ -120,7 +112,7 @@ impl Item {
self.page.update();
// Update tab loading indicator
self.widget.gobject().set_loading(self.page().is_loading());
self.widget.gobject().set_loading(self.page.is_loading());
}
pub fn clean(
@ -222,20 +214,6 @@ impl Item {
Ok(())
}
// Getters
pub fn id(&self) -> &GString {
&self.id
}
pub fn page(&self) -> &Rc<Page> {
&self.page
}
pub fn widget(&self) -> &Rc<Widget> {
&self.widget
}
}
// Tools

View File

@ -21,18 +21,12 @@ use crate::Profile;
use gtk::{
gdk::Texture,
gdk_pixbuf::Pixbuf,
gio::{
Cancellable, IOStream, NetworkAddress, SocketClient, SocketClientEvent, SocketConnectable,
SocketProtocol, TlsCertificate, TlsClientConnection,
},
gio::{Cancellable, SocketClientEvent, TlsCertificate, TlsClientConnection},
glib::{
gformat, Bytes, GString, Priority, Regex, RegexCompileFlags, RegexMatchFlags, Uri,
UriFlags, UriHideFlags,
},
prelude::{
CancellableExt, Cast, EditableExt, IOStreamExt, IsA, OutputStreamExt, SocketClientExt,
TlsConnectionExt,
gformat, GString, Priority, Regex, RegexCompileFlags, RegexMatchFlags, Uri, UriFlags,
UriHideFlags,
},
prelude::{CancellableExt, Cast, EditableExt, SocketClientExt, TlsConnectionExt},
};
use sqlite::Transaction;
use std::{cell::RefCell, rc::Rc, time::Duration};
@ -45,11 +39,11 @@ pub struct Page {
browser_action: Rc<BrowserAction>,
tab_action: Rc<TabAction>,
// Components
navigation: Rc<Navigation>,
content: Rc<Content>,
input: Rc<Input>,
meta: Rc<Meta>,
widget: Rc<Widget>,
pub navigation: Rc<Navigation>,
pub content: Rc<Content>,
pub input: Rc<Input>,
pub meta: Rc<Meta>,
pub widget: Rc<Widget>,
}
impl Page {
@ -107,7 +101,7 @@ impl Page {
.toggle(self.navigation.request().widget().gobject().text().as_str())
{
Ok(result) => Ok(result),
Err(_) => Err(Error::Bookmark),
Err(_) => Err(Error::Bookmark), // @TODO
};
self.update();
result
@ -405,53 +399,11 @@ impl Page {
}
}
pub fn meta(&self) -> &Rc<Meta> {
&self.meta
}
pub fn navigation(&self) -> &Rc<Navigation> {
&self.navigation
}
pub fn widget(&self) -> &Rc<Widget> {
&self.widget
}
// Private helpers
// @TODO move outside
fn load_gemini(&self, uri: Uri, is_history: bool) {
// Stream wrapper for TLS connections
fn auth(
connection: impl IsA<IOStream>,
connectable: impl IsA<SocketConnectable>,
certificate: Option<TlsCertificate>,
) -> impl IsA<IOStream> {
if let Some(certificate) = certificate {
// https://geminiprotocol.net/docs/protocol-specification.gmi#the-use-of-tls
let tls_connection =
TlsClientConnection::new(&connection, Some(&connectable)).unwrap(); // @TODO handle
// https://geminiprotocol.net/docs/protocol-specification.gmi#client-certificates
tls_connection.set_certificate(&certificate);
// @TODO handle exceptions
// https://geminiprotocol.net/docs/protocol-specification.gmi#closing-connections
tls_connection.set_require_close_notify(true);
// @TODO host validation
// https://geminiprotocol.net/docs/protocol-specification.gmi#tls-server-certificate-validation
tls_connection.connect_accept_certificate(move |_, _, _| true);
// Take encrypted I/O stream
tls_connection.upcast::<IOStream>()
} else {
// Take default I/O stream
connection.upcast::<IOStream>()
}
}
// Init shared objects (async)
// Init shared clones
let cancellable = self.cancellable.borrow().clone();
let update = self.browser_action.update().clone();
let tab_action = self.tab_action.clone();
@ -460,12 +412,9 @@ impl Page {
let id = self.id.clone();
let input = self.input.clone();
let meta = self.meta.clone();
let url = uri.clone().to_str();
// Init socket
let client = SocketClient::new();
client.set_protocol(SocketProtocol::Tcp);
client.set_timeout(10); // @TODO
let client = gemini::Client::new();
// Return PEM string match request
let certificate = match self
@ -479,16 +428,16 @@ impl Page {
},
None => {
// Use unauthorized (random) TLS connection
client.set_tls(true);
client.socket.set_tls(true);
None
}
};
// Listen for connection status updates
client.connect_event({
let update = update.clone();
client.socket.connect_event({
let id = id.clone();
let meta = meta.clone();
let update = update.clone();
move |_, event, _, stream| {
meta.set_status(match event {
SocketClientEvent::Resolving => Status::Resolving,
@ -497,8 +446,7 @@ impl Page {
SocketClientEvent::Connected => Status::Connected,
SocketClientEvent::ProxyNegotiating => Status::ProxyNegotiating,
SocketClientEvent::ProxyNegotiated => Status::ProxyNegotiated,
// This case have effect only for unauthorized (random) TLS connection
// * see `fn auth` above to handle custom certificates
// This match have effect only for unauthorized (random) TLS connection
SocketClientEvent::TlsHandshaking => {
// Handle certificate errors @TODO
// https://geminiprotocol.net/docs/protocol-specification.gmi#tls-server-certificate-validation
@ -517,75 +465,47 @@ impl Page {
}
});
// Implement shared [SocketConnectable](https://docs.gtk.org/gio/iface.SocketConnectable.html) interface
// * required also on `auth` step ([SNI](https://geminiprotocol.net/docs/protocol-specification.gmi#server-name-indication))
let connectable = NetworkAddress::new(
&uri.host().unwrap(),
if uri.port().is_positive() {
uri.port() as u16
} else {
1965
},
);
// Create connection
client.connect_async(
&connectable.clone(),
Some(&cancellable.clone()),
move |connect| match connect {
Ok(connection) => {
// Encrypt stream using authorization TLS
let stream = auth(connection, connectable, certificate);
// Send request
stream.output_stream().write_bytes_async(
&Bytes::from(gformat!("{url}\r\n").as_bytes()),
Priority::DEFAULT,
Some(&cancellable.clone()),
move |request| match request {
Ok(_) => {
// Read meta from input stream
gemini::client::response::Meta::from_stream_async(
stream.clone(),
Some(Priority::DEFAULT),
client.request_async(
uri.clone(),
None,
Some(cancellable.clone()),
move |result| match result
{
certificate,
move |result| match result {
Ok(response) => {
// Route by status
match response.status() {
match response.meta.status {
// https://geminiprotocol.net/docs/protocol-specification.gmi#input-expected
gemini::client::response::meta::Status::Input |
gemini::client::response::meta::Status::SensitiveInput => {
// Format response
let status = Status::Input;
let title = match response.data() {
Some(data) => data.value().as_str(),
None => "Input expected",
let title = match response.meta.data {
Some(data) => data.value,
None => gformat!("Input expected"),
};
// Toggle input form variant
match response.status() {
match response.meta.status {
gemini::client::response::meta::Status::SensitiveInput =>
input.set_new_sensitive(
tab_action,
uri,
Some(title),
tab_action.clone(),
uri.clone(),
Some(&title),
Some(1024),
),
_ =>
input.set_new_response(
tab_action,
uri,
Some(title),
tab_action.clone(),
uri.clone(),
Some(&title),
Some(1024),
),
}
// Update meta
meta.set_status(status)
.set_title(title);
.set_title(&title);
// Update page
update.activate(Some(&id));
@ -594,24 +514,30 @@ impl Page {
gemini::client::response::meta::Status::Success => {
// Add history record
if is_history {
snap_history(navigation);
snap_history(navigation.clone());
}
// Route by MIME
match response.mime() {
match response.meta.mime {
Some(gemini::client::response::meta::Mime::TextGemini) => {
// Read entire input stream to buffer
gemini::client::response::data::Text::from_stream_async(
stream,
response.connection.stream(),
Some(Priority::DEFAULT),
Some(cancellable.clone()),
{
let content = content.clone();
let id = id.clone();
let meta = meta.clone();
let update = update.clone();
let uri = uri.clone();
move |result|{
match result {
Ok(buffer) => {
// Set children component
let text_gemini = content.to_text_gemini(
&uri,
buffer.data()
&buffer.data
);
let title = match text_gemini.meta_title() {
@ -626,24 +552,17 @@ impl Page {
// Update window components
update.activate(Some(&id));
}
Err((reason, message)) => {
Err(reason) => {
// Define common data
let status = Status::Failure;
let title = "Oops";
let description = match reason {
gemini::client::response::data::text::Error::InputStream => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Undefined connection error")
} ,
gemini::client::response::data::text::Error::BufferOverflow => gformat!("Buffer overflow"),
gemini::client::response::data::text::Error::Decode => gformat!("Buffer decode error"),
};
let description = reason.to_string();
// Update widget
content
.to_status_failure()
.set_title(title)
.set_description(Some(description.as_str()));
.set_description(Some(&description));
// Update meta
meta.set_status(status)
@ -654,6 +573,7 @@ impl Page {
},
}
}
}
);
},
Some(
@ -670,7 +590,7 @@ impl Page {
// Asynchronously move `InputStream` data from `SocketConnection` into the local `MemoryInputStream`
// this action allows to count the bytes for loading widget and validate max size for incoming data
gemini::gio::memory_input_stream::from_stream_async(
stream,
response.connection.stream(),
Some(cancellable.clone()),
Priority::DEFAULT,
0x400, // 1024 bytes per chunk, optional step for images download tracking
@ -681,6 +601,13 @@ impl Page {
Some(&gformat!("Download: {total} bytes"))
);
},
{
let cancellable = cancellable.clone();
let content = content.clone();
let id = id.clone();
let meta = meta.clone();
let update = update.clone();
let uri = uri.clone();
move |result| match result {
Ok(memory_input_stream) => {
Pixbuf::from_stream_async(
@ -734,29 +661,16 @@ impl Page {
content
.to_status_failure()
.set_title(title)
.set_description(Some(description.as_str()));
.set_description(Some(&description));
// Update meta
meta.set_status(status)
.set_title(title);
}
},
}
}
);
},
/* @TODO stream or download
Some(
ClientMime::AudioFlac | ClientMime::AudioMpeg | ClientMime::AudioOgg
) => {
// Update page meta
meta.borrow_mut().status = Some(Status::Success);
meta.borrow_mut().title = Some(gformat!("Stream"));
// Update page content
// content.to_stream();
// Update window components
update.activate(Some(&id));
}, */
_ => {
// Define common data
let status = Status::Failure;
@ -767,7 +681,7 @@ impl Page {
content
.to_status_failure()
.set_title(title)
.set_description(Some(description.as_str()));
.set_description(Some(&description));
// Update meta
meta.set_status(status)
@ -783,14 +697,14 @@ impl Page {
// https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection
gemini::client::response::meta::Status::PermanentRedirect => {
// Extract redirection URL from response data
match response.data() {
match response.meta.data {
Some(unresolved_url) => {
// New URL from server MAY to be relative (according to the protocol specification),
// resolve to absolute URI gobject using current request as the base for parser:
// https://docs.gtk.org/glib/type_func.Uri.resolve_relative.html
match Uri::resolve_relative(
Some(&uri.to_string()),
unresolved_url.value(),
unresolved_url.value.as_str(),
UriFlags::NONE,
) {
Ok(resolved_url) => {
@ -836,7 +750,7 @@ impl Page {
UriHideFlags::FRAGMENT | UriHideFlags::QUERY
),
// Set follow policy based on status code
matches!(response.status(), gemini::client::response::meta::Status::PermanentRedirect),
matches!(response.meta.status, gemini::client::response::meta::Status::PermanentRedirect),
)
.set_status(Status::Redirect) // @TODO is this status really wanted?
.set_title("Redirect");
@ -901,21 +815,21 @@ impl Page {
// Add history record
if is_history {
snap_history(navigation);
snap_history(navigation.clone());
}
// Update widget
content
.to_status_identity()
.set_title(title)
.set_description(match response.data() {
Some(data) => Some(data.value().as_str()),
None => match response.status() {
gemini::client::response::meta::Status::CertificateUnauthorized => Some("Certificate not authorized"),
gemini::client::response::meta::Status::CertificateInvalid => Some("Certificate not valid"),
_ => Some("Client certificate required")
.set_description(Some(&match response.meta.data {
Some(data) => data.value,
None => match response.meta.status {
gemini::client::response::meta::Status::CertificateUnauthorized => gformat!("Certificate not authorized"),
gemini::client::response::meta::Status::CertificateInvalid => gformat!("Certificate not valid"),
_ => gformat!("Client certificate required")
},
});
}));
// Update meta
meta.set_status(status)
@ -931,16 +845,16 @@ impl Page {
// Add history record
if is_history {
snap_history(navigation);
snap_history(navigation.clone());
}
// Update widget
content
.to_status_failure()
.set_title(title)
.set_description(Some(match response.data() {
Some(data) => data.value().as_str(),
None => "Status code yet not supported", // @TODO
.set_description(Some(&match response.meta.data {
Some(data) => data.value,
None => gformat!("Status code yet not supported"),
}));
// Update meta
@ -952,67 +866,22 @@ impl Page {
}
}
},
Err((reason, message)) => {
Err(reason) => {
// Define common data
let status = Status::Failure;
let title = "Oops";
let description = match reason {
// Common
gemini::client::response::meta::Error::InputStream => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Input stream reading error")
},
gemini::client::response::meta::Error::Protocol => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Incorrect protocol")
},
// Status
gemini::client::response::meta::Error::StatusDecode => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Could not detect status code")
},
gemini::client::response::meta::Error::StatusUndefined => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Status code yet not supported")
},
gemini::client::response::meta::Error::StatusProtocol => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Incorrect status code protocol")
},
// Data
gemini::client::response::meta::Error::DataDecode => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Incorrect data encoding")
},
gemini::client::response::meta::Error::DataProtocol => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Incorrect data protocol")
},
// MIME
gemini::client::response::meta::Error::MimeDecode => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Incorrect MIME encoding")
},
gemini::client::response::meta::Error::MimeProtocol => match message {
Some(error) => gformat!("{error}"),
None => gformat!("Incorrect MIME protocol")
},
gemini::client::response::meta::Error::MimeUndefined => match message {
Some(error) => gformat!("{error}"),
None => gformat!("MIME type yet not supported (by library)")
},
};
let description = reason.to_string();
// Add history record
if is_history {
snap_history(navigation);
snap_history(navigation.clone());
}
// Update widget
content
.to_status_failure()
.set_title(title)
.set_description(Some(description.as_str()));
.set_description(Some(&description));
// Update meta
meta.set_status(status)
@ -1024,58 +893,6 @@ impl Page {
}
);
}
Err(reason) => {
// Define common data
let status = Status::Failure;
let title = "Oops";
// Add history record
if is_history {
snap_history(navigation);
}
// Update widget
content
.to_status_failure()
.set_title(title)
.set_description(Some(reason.message()));
// Update meta
meta.set_status(status)
.set_title(title);
// Update window
update.activate(Some(&id));
},
},
);
}
Err(reason) => {
// Define common data
let status = Status::Failure;
let title = "Oops";
// Add history record
if is_history {
snap_history(navigation);
}
// Update widget
content
.to_status_failure()
.set_title(title)
.set_description(Some(reason.message()));
// Update meta
meta.set_status(status)
.set_title(title);
// Update window
update.activate(Some(&id));
},
},
);
}
}
// Tools