From 5dee5cc52bf4a217cefef0d8842a7692afbd7fb4 Mon Sep 17 00:00:00 2001 From: Jeena Date: Fri, 20 Mar 2026 12:17:27 +0000 Subject: [PATCH] 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. --- data/ui/login_dialog.blp | 1 + src/api.rs | 16 ++++- src/login_dialog.rs | 27 ++++++-- src/main.rs | 5 ++ src/runtime.rs | 50 +++++++++++++++ src/window.rs | 129 +++++++++++++++++++++------------------ 6 files changed, 161 insertions(+), 67 deletions(-) create mode 100644 src/runtime.rs diff --git a/data/ui/login_dialog.blp b/data/ui/login_dialog.blp index 4a53858..86028f0 100644 --- a/data/ui/login_dialog.blp +++ b/data/ui/login_dialog.blp @@ -20,6 +20,7 @@ template $LoginDialog : Adw.Dialog { title: _("Server URL"); input-hints: no_spellcheck; input-purpose: url; + // e.g. https://rss.example.com — /api/greader.php is added automatically } Adw.EntryRow username_row { diff --git a/src/api.rs b/src/api.rs index 36521b3..c9a0e71 100644 --- a/src/api.rs +++ b/src/api.rs @@ -42,14 +42,26 @@ struct Summary { 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 { pub async fn login( server_url: &str, username: &str, password: &str, ) -> Result { + let base = normalize_base_url(server_url); let client = Client::new(); - let url = format!("{}/accounts/ClientLogin", server_url.trim_end_matches('/')); + let url = format!("{base}/accounts/ClientLogin"); let resp = client .post(&url) .form(&[("Email", username), ("Passwd", password)]) @@ -72,7 +84,7 @@ impl Api { Ok(Self { client, - server_url: server_url.trim_end_matches('/').to_string(), + server_url: base, auth_token, }) } diff --git a/src/login_dialog.rs b/src/login_dialog.rs index 24e1024..ca856d0 100644 --- a/src/login_dialog.rs +++ b/src/login_dialog.rs @@ -68,6 +68,7 @@ mod imp { fn constructed(&self) { self.parent_constructed(); + // Login button let obj_weak = self.obj().downgrade(); self.login_button.connect_activated(move |_| { 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(); - self.password_row.connect_apply(move |_| { + self.password_row.connect_entry_activated(move |_| { if let Some(dialog) = obj_weak2.upgrade() { dialog.imp().on_login_clicked(); } @@ -87,14 +99,21 @@ mod imp { impl LoginDialog { 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 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; } + // 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().emit_by_name::<()>("logged-in", &[&server_url, &username, &password]); } diff --git a/src/main.rs b/src/main.rs index 57e96dc..81409cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod article_row; mod credentials; mod login_dialog; mod model; +mod runtime; mod window; fn main() -> glib::ExitCode { @@ -12,6 +13,10 @@ fn main() -> glib::ExitCode { 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(); app.run() } diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..10b07d1 --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,50 @@ +use std::sync::OnceLock; +use tokio::runtime::Runtime; + +static RT: OnceLock = 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(future: F, callback: C) +where + F: std::future::Future + Send + 'static, + T: Send + 'static, + C: FnOnce(T) + 'static, +{ + let (tx, rx) = tokio::sync::oneshot::channel::(); + + 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(future: F) +where + F: std::future::Future + Send + 'static, +{ + RT.get().expect("runtime not initialised").spawn(future); +} diff --git a/src/window.rs b/src/window.rs index 1b7730a..ed015aa 100644 --- a/src/window.rs +++ b/src/window.rs @@ -271,12 +271,7 @@ pub mod imp { fn auto_login(&self) { if let Some((server_url, username, password)) = credentials::load_credentials() { - let win_weak = self.obj().downgrade(); - glib::spawn_future_local(async move { - if let Some(win) = win_weak.upgrade() { - win.imp().do_login(server_url, username, password, false).await; - } - }); + self.do_login(server_url, username, password, false); } else { self.show_login_dialog(); } @@ -291,39 +286,49 @@ pub mod imp { let username = args[2].get::().unwrap(); let password = args[3].get::().unwrap(); if let Some(win) = win_weak.upgrade() { - glib::spawn_future_local(async move { - win.imp().do_login(server_url, username, password, true).await; - }); + win.imp().do_login(server_url, username, password, true); } None }); dialog.present(Some(win.upcast_ref::())); } - pub async fn do_login( - &self, - server_url: String, - username: String, - password: String, - store: bool, - ) { - match Api::login(&server_url, &username, &password).await { - Ok(api) => { - if store { - credentials::store_credentials(&server_url, &username, &password); + fn do_login(&self, server_url: String, username: String, password: String, store: bool) { + let win_weak = self.obj().downgrade(); + crate::runtime::spawn( + async move { Api::login(&server_url, &username, &password).await + .map(|api| (api, server_url, username, password)) }, + move |result| { + let Some(win) = win_weak.upgrade() else { return }; + match result { + Ok((api, server_url, username, password)) => { + if store { + 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) { @@ -364,42 +369,44 @@ pub mod imp { // ── Fetch articles ──────────────────────────────────────────────────── fn do_reload(&self) { - let win_weak = self.obj().downgrade(); - glib::spawn_future_local(async move { - if let Some(win) = win_weak.upgrade() { - win.imp().fetch_articles().await; - } - }); + self.fetch_articles(); } - pub async fn fetch_articles(&self) { + fn fetch_articles(&self) { let api = self.api.borrow().clone(); let Some(api) = api else { return }; self.refresh_stack.set_visible_child_name("spinner"); self.sidebar_content.set_visible_child_name("loading"); - match api.fetch_unread().await { - Ok(articles) => { - let store = self.article_store.borrow(); - let store = store.as_ref().unwrap(); - store.remove_all(); - for a in articles { - store.append(&ArticleObject::new(a)); + 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) => { + 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 { - 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"); + imp.refresh_stack.set_visible_child_name("button"); + }, + ); } // ── Read state ──────────────────────────────────────────────────────── @@ -408,7 +415,7 @@ pub mod imp { let api = self.api.borrow().clone(); let wt = self.write_token.borrow().clone(); 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 { eprintln!("mark_read error: {e}"); } @@ -437,7 +444,7 @@ pub mod imp { let wt = self.write_token.borrow().clone(); if let (Some(api), Some(wt)) = (api, wt) { 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 { eprintln!("mark_unread error: {e}"); }