fix: tokio runtime, Enter-to-login, and server URL handling
Three bugs fixed: - No tokio reactor: glib::spawn_future_local does not provide a tokio context, so reqwest/hyper panicked at runtime. Introduce src/runtime.rs with a multi-thread tokio Runtime (init() called from main before the GTK app starts). runtime::spawn() posts the async result back to GTK via a tokio oneshot channel awaited by glib::spawn_future_local, which only polls a flag (no I/O). runtime::spawn_bg() is used for fire-and-forget background calls. - Enter key didn't submit login: connect_apply on AdwEntryRow only fires when show-apply-button is true. Switch to connect_entry_activated which fires on Return in all three login rows. - Wrong API URL: the app constructed /accounts/ClientLogin directly off the server host, yielding a 404. Add normalize_base_url() in api.rs that appends /api/greader.php when the URL doesn't already contain it, so users can enter just https://rss.example.com.
This commit is contained in:
parent
d157f3f244
commit
5dee5cc52b
6 changed files with 161 additions and 67 deletions
|
|
@ -20,6 +20,7 @@ template $LoginDialog : Adw.Dialog {
|
||||||
title: _("Server URL");
|
title: _("Server URL");
|
||||||
input-hints: no_spellcheck;
|
input-hints: no_spellcheck;
|
||||||
input-purpose: url;
|
input-purpose: url;
|
||||||
|
// e.g. https://rss.example.com — /api/greader.php is added automatically
|
||||||
}
|
}
|
||||||
|
|
||||||
Adw.EntryRow username_row {
|
Adw.EntryRow username_row {
|
||||||
|
|
|
||||||
16
src/api.rs
16
src/api.rs
|
|
@ -42,14 +42,26 @@ struct Summary {
|
||||||
|
|
||||||
use crate::model::Article;
|
use crate::model::Article;
|
||||||
|
|
||||||
|
fn normalize_base_url(server_url: &str) -> String {
|
||||||
|
let base = server_url.trim_end_matches('/');
|
||||||
|
// If the user entered just a host (or host/path) without the FreshRSS
|
||||||
|
// API suffix, append it automatically.
|
||||||
|
if base.ends_with("/api/greader.php") {
|
||||||
|
base.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{base}/api/greader.php")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Api {
|
impl Api {
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
|
let base = normalize_base_url(server_url);
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let url = format!("{}/accounts/ClientLogin", server_url.trim_end_matches('/'));
|
let url = format!("{base}/accounts/ClientLogin");
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.form(&[("Email", username), ("Passwd", password)])
|
.form(&[("Email", username), ("Passwd", password)])
|
||||||
|
|
@ -72,7 +84,7 @@ impl Api {
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
client,
|
||||||
server_url: server_url.trim_end_matches('/').to_string(),
|
server_url: base,
|
||||||
auth_token,
|
auth_token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ mod imp {
|
||||||
fn constructed(&self) {
|
fn constructed(&self) {
|
||||||
self.parent_constructed();
|
self.parent_constructed();
|
||||||
|
|
||||||
|
// Login button
|
||||||
let obj_weak = self.obj().downgrade();
|
let obj_weak = self.obj().downgrade();
|
||||||
self.login_button.connect_activated(move |_| {
|
self.login_button.connect_activated(move |_| {
|
||||||
if let Some(dialog) = obj_weak.upgrade() {
|
if let Some(dialog) = obj_weak.upgrade() {
|
||||||
|
|
@ -75,9 +76,20 @@ mod imp {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also trigger on Enter in password row
|
// Enter in any row submits the form (connect_entry_activated fires on Return)
|
||||||
|
for weak in [
|
||||||
|
self.server_url_row.downgrade(),
|
||||||
|
self.username_row.downgrade(),
|
||||||
|
] {
|
||||||
|
let obj_weak = self.obj().downgrade();
|
||||||
|
weak.upgrade().unwrap().connect_entry_activated(move |_| {
|
||||||
|
if let Some(dialog) = obj_weak.upgrade() {
|
||||||
|
dialog.imp().on_login_clicked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
let obj_weak2 = self.obj().downgrade();
|
let obj_weak2 = self.obj().downgrade();
|
||||||
self.password_row.connect_apply(move |_| {
|
self.password_row.connect_entry_activated(move |_| {
|
||||||
if let Some(dialog) = obj_weak2.upgrade() {
|
if let Some(dialog) = obj_weak2.upgrade() {
|
||||||
dialog.imp().on_login_clicked();
|
dialog.imp().on_login_clicked();
|
||||||
}
|
}
|
||||||
|
|
@ -87,14 +99,21 @@ mod imp {
|
||||||
|
|
||||||
impl LoginDialog {
|
impl LoginDialog {
|
||||||
fn on_login_clicked(&self) {
|
fn on_login_clicked(&self) {
|
||||||
let server_url = self.server_url_row.text().trim().to_string();
|
let raw_url = self.server_url_row.text().trim().to_string();
|
||||||
let username = self.username_row.text().trim().to_string();
|
let username = self.username_row.text().trim().to_string();
|
||||||
let password = self.password_row.text().to_string();
|
let password = self.password_row.text().to_string();
|
||||||
|
|
||||||
if server_url.is_empty() || username.is_empty() || password.is_empty() {
|
if raw_url.is_empty() || username.is_empty() || password.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepend https:// if no scheme given
|
||||||
|
let server_url = if raw_url.starts_with("http://") || raw_url.starts_with("https://") {
|
||||||
|
raw_url
|
||||||
|
} else {
|
||||||
|
format!("https://{raw_url}")
|
||||||
|
};
|
||||||
|
|
||||||
self.obj().close();
|
self.obj().close();
|
||||||
self.obj().emit_by_name::<()>("logged-in", &[&server_url, &username, &password]);
|
self.obj().emit_by_name::<()>("logged-in", &[&server_url, &username, &password]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ mod article_row;
|
||||||
mod credentials;
|
mod credentials;
|
||||||
mod login_dialog;
|
mod login_dialog;
|
||||||
mod model;
|
mod model;
|
||||||
|
mod runtime;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
|
|
@ -12,6 +13,10 @@ fn main() -> glib::ExitCode {
|
||||||
std::env::set_var("GSETTINGS_SCHEMA_DIR", env!("GSETTINGS_SCHEMA_DIR"));
|
std::env::set_var("GSETTINGS_SCHEMA_DIR", env!("GSETTINGS_SCHEMA_DIR"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the tokio multi-thread runtime before the GTK app so that
|
||||||
|
// reqwest/hyper can find it when API futures are spawned.
|
||||||
|
runtime::init();
|
||||||
|
|
||||||
let app = app::FeedTheMonkeyApp::new();
|
let app = app::FeedTheMonkeyApp::new();
|
||||||
app.run()
|
app.run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
src/runtime.rs
Normal file
50
src/runtime.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
static RT: OnceLock<Runtime> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
RT.get_or_init(|| {
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("failed to build tokio runtime")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn `future` on the tokio runtime. When it completes, `callback`
|
||||||
|
/// is invoked on the GLib main context (GTK main thread).
|
||||||
|
///
|
||||||
|
/// Works by routing the result through a tokio oneshot channel; the
|
||||||
|
/// receiving end is awaited by `glib::spawn_future_local`, which runs
|
||||||
|
/// on the GLib event loop but does no I/O, so it never needs tokio's
|
||||||
|
/// reactor itself.
|
||||||
|
pub fn spawn<F, T, C>(future: F, callback: C)
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = T> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
C: FnOnce(T) + 'static,
|
||||||
|
{
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel::<T>();
|
||||||
|
|
||||||
|
RT.get().expect("runtime not initialised").spawn(async move {
|
||||||
|
let result = future.await;
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The receive future only polls a mutex-protected flag — no I/O,
|
||||||
|
// so running it on the GLib event loop is fine.
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Ok(value) = rx.await {
|
||||||
|
callback(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire-and-forget: spawn on the tokio runtime, no callback.
|
||||||
|
pub fn spawn_bg<F>(future: F)
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
RT.get().expect("runtime not initialised").spawn(future);
|
||||||
|
}
|
||||||
129
src/window.rs
129
src/window.rs
|
|
@ -271,12 +271,7 @@ pub mod imp {
|
||||||
|
|
||||||
fn auto_login(&self) {
|
fn auto_login(&self) {
|
||||||
if let Some((server_url, username, password)) = credentials::load_credentials() {
|
if let Some((server_url, username, password)) = credentials::load_credentials() {
|
||||||
let win_weak = self.obj().downgrade();
|
self.do_login(server_url, username, password, false);
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
if let Some(win) = win_weak.upgrade() {
|
|
||||||
win.imp().do_login(server_url, username, password, false).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
self.show_login_dialog();
|
self.show_login_dialog();
|
||||||
}
|
}
|
||||||
|
|
@ -291,39 +286,49 @@ pub mod imp {
|
||||||
let username = args[2].get::<String>().unwrap();
|
let username = args[2].get::<String>().unwrap();
|
||||||
let password = args[3].get::<String>().unwrap();
|
let password = args[3].get::<String>().unwrap();
|
||||||
if let Some(win) = win_weak.upgrade() {
|
if let Some(win) = win_weak.upgrade() {
|
||||||
glib::spawn_future_local(async move {
|
win.imp().do_login(server_url, username, password, true);
|
||||||
win.imp().do_login(server_url, username, password, true).await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
});
|
});
|
||||||
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
|
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn do_login(
|
fn do_login(&self, server_url: String, username: String, password: String, store: bool) {
|
||||||
&self,
|
let win_weak = self.obj().downgrade();
|
||||||
server_url: String,
|
crate::runtime::spawn(
|
||||||
username: String,
|
async move { Api::login(&server_url, &username, &password).await
|
||||||
password: String,
|
.map(|api| (api, server_url, username, password)) },
|
||||||
store: bool,
|
move |result| {
|
||||||
) {
|
let Some(win) = win_weak.upgrade() else { return };
|
||||||
match Api::login(&server_url, &username, &password).await {
|
match result {
|
||||||
Ok(api) => {
|
Ok((api, server_url, username, password)) => {
|
||||||
if store {
|
if store {
|
||||||
credentials::store_credentials(&server_url, &username, &password);
|
credentials::store_credentials(&server_url, &username, &password);
|
||||||
|
}
|
||||||
|
// Fetch write token in background (non-critical)
|
||||||
|
let api_clone = api.clone();
|
||||||
|
let win_weak2 = win.downgrade();
|
||||||
|
crate::runtime::spawn(
|
||||||
|
async move { api_clone.fetch_write_token().await },
|
||||||
|
move |wt_result| {
|
||||||
|
if let Some(win) = win_weak2.upgrade() {
|
||||||
|
match wt_result {
|
||||||
|
Ok(wt) => *win.imp().write_token.borrow_mut() = Some(wt),
|
||||||
|
Err(e) => eprintln!("Write token error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*win.imp().api.borrow_mut() = Some(api);
|
||||||
|
win.imp().fetch_articles();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
win.imp().show_login_dialog();
|
||||||
|
win.imp().show_error_dialog("Login Failed", &e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match api.fetch_write_token().await {
|
},
|
||||||
Ok(wt) => *self.write_token.borrow_mut() = Some(wt),
|
);
|
||||||
Err(e) => eprintln!("Write token error: {e}"),
|
|
||||||
}
|
|
||||||
*self.api.borrow_mut() = Some(api);
|
|
||||||
self.fetch_articles().await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.show_login_dialog();
|
|
||||||
self.show_error_dialog("Login Failed", &e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_error_dialog(&self, title: &str, body: &str) {
|
fn show_error_dialog(&self, title: &str, body: &str) {
|
||||||
|
|
@ -364,42 +369,44 @@ pub mod imp {
|
||||||
// ── Fetch articles ────────────────────────────────────────────────────
|
// ── Fetch articles ────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn do_reload(&self) {
|
fn do_reload(&self) {
|
||||||
let win_weak = self.obj().downgrade();
|
self.fetch_articles();
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
if let Some(win) = win_weak.upgrade() {
|
|
||||||
win.imp().fetch_articles().await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_articles(&self) {
|
fn fetch_articles(&self) {
|
||||||
let api = self.api.borrow().clone();
|
let api = self.api.borrow().clone();
|
||||||
let Some(api) = api else { return };
|
let Some(api) = api else { return };
|
||||||
|
|
||||||
self.refresh_stack.set_visible_child_name("spinner");
|
self.refresh_stack.set_visible_child_name("spinner");
|
||||||
self.sidebar_content.set_visible_child_name("loading");
|
self.sidebar_content.set_visible_child_name("loading");
|
||||||
|
|
||||||
match api.fetch_unread().await {
|
let win_weak = self.obj().downgrade();
|
||||||
Ok(articles) => {
|
crate::runtime::spawn(
|
||||||
let store = self.article_store.borrow();
|
async move { api.fetch_unread().await },
|
||||||
let store = store.as_ref().unwrap();
|
move |result| {
|
||||||
store.remove_all();
|
let Some(win) = win_weak.upgrade() else { return };
|
||||||
for a in articles {
|
let imp = win.imp();
|
||||||
store.append(&ArticleObject::new(a));
|
match result {
|
||||||
|
Ok(articles) => {
|
||||||
|
let store = imp.article_store.borrow();
|
||||||
|
let store = store.as_ref().unwrap();
|
||||||
|
store.remove_all();
|
||||||
|
for a in articles {
|
||||||
|
store.append(&ArticleObject::new(a));
|
||||||
|
}
|
||||||
|
if store.n_items() == 0 {
|
||||||
|
imp.sidebar_content.set_visible_child_name("empty");
|
||||||
|
} else {
|
||||||
|
imp.sidebar_content.set_visible_child_name("list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
imp.error_status.set_description(Some(&e));
|
||||||
|
imp.sidebar_content.set_visible_child_name("error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if store.n_items() == 0 {
|
imp.refresh_stack.set_visible_child_name("button");
|
||||||
self.sidebar_content.set_visible_child_name("empty");
|
},
|
||||||
} else {
|
);
|
||||||
self.sidebar_content.set_visible_child_name("list");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.error_status.set_description(Some(&e));
|
|
||||||
self.sidebar_content.set_visible_child_name("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.refresh_stack.set_visible_child_name("button");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Read state ────────────────────────────────────────────────────────
|
// ── Read state ────────────────────────────────────────────────────────
|
||||||
|
|
@ -408,7 +415,7 @@ pub mod imp {
|
||||||
let api = self.api.borrow().clone();
|
let api = self.api.borrow().clone();
|
||||||
let wt = self.write_token.borrow().clone();
|
let wt = self.write_token.borrow().clone();
|
||||||
if let (Some(api), Some(wt)) = (api, wt) {
|
if let (Some(api), Some(wt)) = (api, wt) {
|
||||||
glib::spawn_future_local(async move {
|
crate::runtime::spawn_bg(async move {
|
||||||
if let Err(e) = api.mark_read(&wt, &item_id).await {
|
if let Err(e) = api.mark_read(&wt, &item_id).await {
|
||||||
eprintln!("mark_read error: {e}");
|
eprintln!("mark_read error: {e}");
|
||||||
}
|
}
|
||||||
|
|
@ -437,7 +444,7 @@ pub mod imp {
|
||||||
let wt = self.write_token.borrow().clone();
|
let wt = self.write_token.borrow().clone();
|
||||||
if let (Some(api), Some(wt)) = (api, wt) {
|
if let (Some(api), Some(wt)) = (api, wt) {
|
||||||
let id_clone = id.clone();
|
let id_clone = id.clone();
|
||||||
glib::spawn_future_local(async move {
|
crate::runtime::spawn_bg(async move {
|
||||||
if let Err(e) = api.mark_unread(&wt, &id_clone).await {
|
if let Err(e) = api.mark_unread(&wt, &id_clone).await {
|
||||||
eprintln!("mark_unread error: {e}");
|
eprintln!("mark_unread error: {e}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue