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:
Jeena 2026-03-20 12:17:27 +00:00
parent d157f3f244
commit 5dee5cc52b
6 changed files with 161 additions and 67 deletions

View file

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

View file

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

View file

@ -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]);
} }

View file

@ -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
View 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);
}

View file

@ -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);
} }
match api.fetch_write_token().await { // Fetch write token in background (non-critical)
Ok(wt) => *self.write_token.borrow_mut() = Some(wt), 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}"), Err(e) => eprintln!("Write token error: {e}"),
} }
*self.api.borrow_mut() = Some(api); }
self.fetch_articles().await; },
);
*win.imp().api.borrow_mut() = Some(api);
win.imp().fetch_articles();
} }
Err(e) => { Err(e) => {
self.show_login_dialog(); win.imp().show_login_dialog();
self.show_error_dialog("Login Failed", &e); win.imp().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();
crate::runtime::spawn(
async move { api.fetch_unread().await },
move |result| {
let Some(win) = win_weak.upgrade() else { return };
let imp = win.imp();
match result {
Ok(articles) => { Ok(articles) => {
let store = self.article_store.borrow(); let store = imp.article_store.borrow();
let store = store.as_ref().unwrap(); let store = store.as_ref().unwrap();
store.remove_all(); store.remove_all();
for a in articles { for a in articles {
store.append(&ArticleObject::new(a)); store.append(&ArticleObject::new(a));
} }
if store.n_items() == 0 { if store.n_items() == 0 {
self.sidebar_content.set_visible_child_name("empty"); imp.sidebar_content.set_visible_child_name("empty");
} else { } else {
self.sidebar_content.set_visible_child_name("list"); imp.sidebar_content.set_visible_child_name("list");
} }
} }
Err(e) => { Err(e) => {
self.error_status.set_description(Some(&e)); imp.error_status.set_description(Some(&e));
self.sidebar_content.set_visible_child_name("error"); imp.sidebar_content.set_visible_child_name("error");
} }
} }
imp.refresh_stack.set_visible_child_name("button");
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}");
} }