diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..4d5eda7 --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,420 @@ +# FeedTheMonkey v3 — Backlog + +A rewrite of FeedTheMonkey from Qt5/C++/QML to Rust + GTK4 + libadwaita. +The app is a FreshRSS client using the Google Reader compatible API. + +## Design principles + +- Idiomatic libadwaita: follows the GNOME HIG, not a port of the Qt UI +- Dark mode follows the system (AdwStyleManager), no in-app toggle +- Credentials stored in the system keyring (libsecret), not a config file +- App settings stored in GSettings + +## Tech stack + +- Language: Rust (stable) +- UI: GTK4 + libadwaita +- Web content: webkit6 crate (webkitgtk-6.0) +- UI templates: Blueprint (.blp) compiled at build time via blueprint-compiler +- HTTP: reqwest (async, TLS) +- JSON: serde + serde_json +- Async: tokio +- Credentials: libsecret crate +- Settings: GSettings (net.jeena.FeedTheMonkey schema) +- API: FreshRSS Google Reader compatible API + +## API reference + +Base URL: `https://rss.jeena.net/api/greader.php` + +| Purpose | Method | Path | Notes | +|---------------|--------|-----------------------------------------------|--------------------------------------------| +| Login | POST | /accounts/ClientLogin | Body: `Email=&Passwd=` → `Auth=` | +| Write token | GET | /reader/api/0/token | Header: `Authorization: GoogleLogin auth=` | +| Fetch unread | GET | /reader/api/0/stream/contents/reading-list | `?xt=user/-/state/com.google/read&n=200&output=json` | +| Mark read | POST | /reader/api/0/edit-tag | Body: `i=&a=user/-/state/com.google/read&T=` | +| Mark unread | POST | /reader/api/0/edit-tag | Body: `i=&r=user/-/state/com.google/read&T=` | + +### Article JSON → content.html field mapping + +| Google Reader field | JS template field | +|-----------------------|-------------------| +| title | title | +| origin.title | feed_title | +| canonical[0].href | link | +| published (unix ts) | updated | +| summary.content | content | +| author | author | +| unread | categories does NOT contain `user/-/state/com.google/read` | + +## UI structure + +``` +AdwApplicationWindow +└── AdwToastOverlay + └── AdwNavigationSplitView + ├── sidebar: AdwNavigationPage (title="FeedTheMonkey") + │ └── AdwToolbarView + │ ├── top: AdwHeaderBar + │ │ ├── start: GtkButton (view-refresh-symbolic) + │ │ │ → AdwSpinner while loading (via GtkStack) + │ │ └── end: GtkMenuButton (open-menu-symbolic, primary=True) + │ │ └── GMenu: Log Out, Keyboard Shortcuts, About + │ └── content: GtkScrolledWindow → GtkListView (ArticleRow) + │ or AdwStatusPage (empty / loading / error) + └── content: AdwNavigationPage (title = article title, dynamic) + └── AdwToolbarView (top-bar-style=raised) + ├── top: AdwHeaderBar (auto back button on narrow) + │ └── end: GtkMenuButton (view-more-symbolic) + │ └── GMenu: Mark Unread, Open in Browser + └── content: WebKitWebView (loads html/content.html) + or AdwStatusPage ("Select an article") +``` + +## GSettings schema + +Schema ID: `net.jeena.FeedTheMonkey` + +| Key | Type | Default | Notes | +|------------------|--------|---------|----------------------------| +| window-width | int | 900 | | +| window-height | int | 600 | | +| window-maximized | bool | false | | +| sidebar-width | int | 280 | | +| zoom-level | double | 1.0 | WebKitWebView zoom level | + +Credentials (libsecret, not GSettings): +- server-url +- username +- password + +--- + +## Epic 1 — Project scaffold + +Set up a working Rust + GTK4 + libadwaita project that opens a blank +AdwApplicationWindow. No logic, just the skeleton. + +### Stories + +**1.1** Create the `v3` git branch from `master`. + +**1.2** Write `Cargo.toml` with all dependencies: +- gtk4, libadwaita, webkit6, gio, glib +- reqwest (with rustls-tls feature, no native-tls) +- serde, serde_json +- tokio (full features) +- secret (libsecret) +- blueprint-compiler is a build-time tool, not a crate + +**1.3** Write `build.rs` that: +1. Runs `blueprint-compiler batch-compile` on all `.blp` files in `data/ui/` + to produce `.ui` files alongside them +2. Runs `glib-compile-resources` on `data/resources.gresource.xml` + to produce a compiled `.gresource` file, then registers it + +**1.4** Write `data/resources.gresource.xml` bundling: +- Compiled `.ui` files from `data/ui/` +- `html/content.html` and `html/content.css` + +**1.5** Write the GSettings schema `data/net.jeena.FeedTheMonkey.gschema.xml` +with the keys listed above. + +**1.6** Write `data/ui/window.blp` — a minimal `AdwApplicationWindow` +containing `AdwNavigationSplitView` with placeholder sidebar and content pages. + +**1.7** Write `src/main.rs` and `src/app.rs` — `AdwApplication` with +APP_ID `net.jeena.FeedTheMonkey`, activates the main window. + +**1.8** Write `src/window.rs` — `AdwApplicationWindow` GObject subclass +that loads the window Blueprint template and restores window size from GSettings. + +**1.9** Verify the app compiles and opens a blank window. + +--- + +## Epic 2 — Authentication + +Login dialog, credential storage, session management, logout. + +### Stories + +**2.1** Write `data/ui/login_dialog.blp` — an `AdwDialog` with: +- `AdwToolbarView` + `AdwHeaderBar` (title "Log In") +- `AdwClamp` → `AdwPreferencesGroup` containing: + - `AdwEntryRow` — Server URL (placeholder: `https://example.com`) + - `AdwEntryRow` — Username + - `AdwPasswordEntryRow` — Password + - `AdwButtonRow` — "Log In" (style: `.suggested-action`) + +**2.2** Write `src/login_dialog.rs` — `LoginDialog` GObject subclass +using the Blueprint template. Emits a `logged-in` signal on success. + +**2.3** Write `src/settings.rs` — libsecret helpers: +- `store_credentials(server_url, username, password)` +- `load_credentials() -> Option<(server_url, username, password)>` +- `clear_credentials()` + +**2.4** Write `src/api.rs` — `Api` struct with: +- `login(server_url, username, password) -> Result` + Calls `POST /accounts/ClientLogin`, parses `Auth=` line from response +- `fetch_write_token(auth_token) -> Result` + Calls `GET /reader/api/0/token` + +**2.5** Connect login dialog to `Api::login()`. On success: +- Store credentials via libsecret +- Store `AuthToken` and `WriteToken` in application state +- Close the dialog, trigger article fetch (Epic 3) + +**2.6** On app start: call `load_credentials()`. If found, call `Api::login()` +automatically and skip showing the login dialog. + +**2.7** Implement logout: clear libsecret credentials, discard tokens, +show the login dialog again. + +**2.8** Show login errors via `AdwAlertDialog` (title "Login Failed", +body = server error message, single "OK" button). + +--- + +## Epic 3 — Article fetching + +Fetch unread articles from the server and populate the list model. + +### Stories + +**3.1** Write `src/model.rs` — `Article` struct (plain Rust, not GObject): +``` +id: String +title: String +feed_title: String +author: String +link: String +published: i64 ← unix timestamp +content: String +excerpt: String ← plain text, first ~150 chars of content stripped of HTML +unread: bool +``` + +Also a GObject wrapper `ArticleObject` (implementing `glib::Object`) so +it can be stored in a `gio::ListStore`. + +**3.2** Add `Api::fetch_unread(auth_token) -> Result>` that: +- Calls `GET /reader/api/0/stream/contents/reading-list?xt=...&n=200&output=json` +- Deserializes the JSON response +- Maps Google Reader fields to `Article` struct +- Derives `unread` from `categories` not containing `user/-/state/com.google/read` + +**3.3** In `src/window.rs`: after successful login, call `fetch_unread()` in +a background tokio task and populate a `gio::ListStore`. + +**3.4** Show `AdwStatusPage` with `AdwSpinnerPaintable` and title "Loading…" +in the sidebar while fetching is in progress. + +**3.5** Show `AdwStatusPage` with icon `rss-symbolic` and title +"No unread articles" when the list is empty after a successful fetch. + +**3.6** Show `AdwStatusPage` with icon `network-error-symbolic` and title +"Could not load articles" (description = error message) on network/API error. +Include a "Try Again" button that re-triggers the fetch. + +**3.7** Implement reload: the refresh button in the sidebar header bar +re-runs `fetch_unread()`, replaces the list store contents, and scrolls +the list back to the top. + +--- + +## Epic 4 — Sidebar + +Article list view with custom rows, navigation, and split view behavior. + +### Stories + +**4.1** Write `data/ui/article_row.blp` — composite template for `ArticleRow`: +``` +GtkBox (orientation=vertical, margin=12, spacing=4) +├── GtkBox (orientation=horizontal, spacing=4) +│ ├── GtkLabel (id=feed_title, hexpand=True, xalign=0, .dim-label, small font) +│ └── GtkLabel (id=date, xalign=1, .dim-label, small font) +├── GtkLabel (id=title, xalign=0, wrap=True, lines=2) +└── GtkLabel (id=excerpt, xalign=0, .dim-label, small font, ellipsize=end, lines=1) +``` + +**4.2** Write `src/article_row.rs` — `ArticleRow` GObject subclass. +- Bind `ArticleObject` properties to labels +- Apply `.dim-label` style to the title label when article is read +- Format `published` timestamp as relative time ("2 hours ago", "Yesterday", etc.) + +**4.3** Set up `GtkListView` in the sidebar with a `GtkSignalListItemFactory` +that creates and binds `ArticleRow` widgets. + +**4.4** Connect list selection (`GtkSingleSelection`) to show the selected +article in the content pane and call `adw_navigation_split_view_set_show_content(true)` +on narrow screens. + +**4.5** Implement `next_article()` and `previous_article()` functions that +move the selection in the list view (used by keyboard shortcuts). + +**4.6** Persist and restore `sidebar-width` from GSettings using +`AdwNavigationSplitView::sidebar-width-fraction` or equivalent. + +--- + +## Epic 5 — Content pane + +WebKitWebView loading content.html, link handling, empty state. + +### Stories + +**5.1** Update `html/content.html`: +- Change `` from "TTRSS" to "FeedTheMonkey" +- Remove `setFont()` and `setNightmode()` JavaScript functions (no longer needed) +- Remove `"loading"` and `"empty"` string handling from `setArticle()` + (these states are now handled by `AdwStatusPage` in Rust) +- Remove the `"logout"` string handling +- Keep `setArticle(article)` for JSON object input +- Keep `checkKey()` and the keyboard event listener + +**5.2** Update `html/content.css`: +- Replace the `.nightmode` body class block with + `@media (prefers-color-scheme: dark) { ... }` +- Keep all existing styles, just change the selector + +**5.3** Write `src/content_view.rs` — wrapper around `webkit::WebView`: +- Loads `content.html` from GResource on init +- Exposes `set_article(article: &Article)` which calls `window.setArticle()` + via `WebView::evaluate_javascript()` +- Handles `decide-policy` signal: intercept `feedthemonkey:previous`, + `feedthemonkey:next`, `feedthemonkey:open` URIs and emit corresponding + GObject signals; intercept all other link clicks and open with `gtk::show_uri()` +- Exposes `zoom_level` property (get/set), used by Epic 8 + +**5.4** Show `AdwStatusPage` with icon `document-open-symbolic` and +title "No Article Selected" when nothing is selected in the sidebar. + +**5.5** Ensure WebKit loads the local `content.html` from GResource using +a custom URI scheme or `load_html()` with a base URI so that `content.css` +is resolved correctly. + +--- + +## Epic 6 — Read state + +Auto-mark-read, manual mark-unread, API sync. + +### Stories + +**6.1** Add `Api::mark_read(auth_token, write_token, item_id) -> Result<()>` +that POSTs to `/reader/api/0/edit-tag` with +`i=<id>&a=user/-/state/com.google/read&T=<write_token>`. + +**6.2** Add `Api::mark_unread(auth_token, write_token, item_id) -> Result<()>` +that POSTs to `/reader/api/0/edit-tag` with +`i=<id>&r=user/-/state/com.google/read&T=<write_token>`. + +**6.3** When the selected article changes (user navigates to next/previous), +mark the *previously selected* article as read via `Api::mark_read()`. +Update `ArticleObject::unread` to `false` so the row styling updates immediately +(optimistic update — no waiting for the API response). + +**6.4** Implement "Mark Unread" action (key `u`, content pane menu): +- Set `ArticleObject::unread` to `true` (optimistic update) +- Call `Api::mark_unread()` in the background +- Show an `AdwToast` "Marked as unread" in the `AdwToastOverlay` +- Set a guard flag so navigating away does not re-mark the article as read + +**6.5** Handle API errors from mark_read/mark_unread silently +(log to stderr, do not show a dialog — these are background operations). + +--- + +## Epic 7 — Keyboard shortcuts + +Full keyboard navigation matching the feature set of v2. + +### Stories + +**7.1** Register `GtkShortcutController` on the main window for all shortcuts: + +| Key(s) | Action | +|-----------------|-----------------------------------------| +| j, Right | Next article | +| k, Left | Previous article | +| Enter, n | Open current article in browser | +| r | Reload articles | +| u | Mark current article unread | +| Space, Page_Down| Scroll content down one page | +| Page_Up | Scroll content up one page | +| Home | Scroll content to top | +| End | Scroll content to bottom | +| Ctrl+plus | Zoom in | +| Ctrl+minus | Zoom out | +| Ctrl+0 | Reset zoom | +| F11 | Toggle fullscreen | +| Ctrl+W | Close window | +| Ctrl+Q | Quit application | + +**7.2** Write `data/ui/shortcuts.blp` — `AdwShortcutsDialog` documenting +all shortcuts, grouped by category (Navigation, Article, View, Application). + +**7.3** Connect the "Keyboard Shortcuts" menu item to show the shortcuts dialog. +Also register `F1` as an alias. + +--- + +## Epic 8 — Zoom + +WebView zoom with keyboard shortcuts and persistence. + +### Stories + +**8.1** Implement `zoom_in()`, `zoom_out()`, `zoom_reset()` in `content_view.rs`: +- `zoom_in`: multiply `WebView::zoom_level` by 1.1 +- `zoom_out`: divide by 1.1 +- `zoom_reset`: set to 1.0 + +**8.2** On zoom change, save the new zoom level to GSettings (`zoom-level` key). + +**8.3** On app start, read `zoom-level` from GSettings and apply to the WebView. + +--- + +## Epic 9 — Window state persistence + +Save and restore window size and sidebar width. + +### Stories + +**9.1** On window close, save `window-width`, `window-height`, `window-maximized` +to GSettings. + +**9.2** On window open, restore width, height, and maximized state from GSettings. + +**9.3** Save `sidebar-width` to GSettings when the sidebar is resized. +Restore on startup. + +--- + +## Epic 10 — Polish + +Final details for a shippable app. + +### Stories + +**10.1** Write `data/net.jeena.FeedTheMonkey.desktop` — `.desktop` file +with `Name=FeedTheMonkey`, `Exec=feedthemonkey`, `Icon=feedthemonkey`, +`Categories=Network;Feed;`. + +**10.2** Include the existing `misc/feedthemonkey.xpm` icon in the GResource +bundle and reference it in the `.desktop` file. + +**10.3** Implement `AdwAboutDialog` with app name, version, license (GPL-3.0), +website, and developer name. Connect to "About" menu item. + +**10.4** Handle the write token expiry: if an `edit-tag` call returns 401, +fetch a new write token and retry once. + +**10.5** Add `AdwAlertDialog` for logout confirmation ("Are you sure you want +to log out?" with "Log Out" destructive button and "Cancel"). + +**10.6** Graceful shutdown: cancel in-flight API requests when the window closes. diff --git a/README.md b/README.md index 05bdb5e..dcccb95 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,8 @@ # FeedTheMonkey -FeedTheMonkey is a desktop client for any server that implements the -[Greader API](https://github.com/theoldreader/api). - -It doesn't work as a standalone feed reader — it connects to a server to fetch articles and sync read state. - -When logging in, enter: - -- **FreshRSS**: `https://example.com/api/greader.php` -- **Miniflux**: `https://example.com` -- Other compatible servers: consult your server's documentation for the Greader API endpoint +FeedTheMonkey is a desktop client for [Tiny Tiny RSS](https://tt-rss.org). It doesn't +work as a standalone feed reader — it connects to a TT-RSS server via the Fever/Greader +API to fetch articles and sync read state. This is version 3, rewritten in Rust with GTK4 and libadwaita. diff --git a/data/icons/net.jeena.FeedTheMonkey.png b/data/icons/net.jeena.FeedTheMonkey.png deleted file mode 100644 index 006f24a..0000000 Binary files a/data/icons/net.jeena.FeedTheMonkey.png and /dev/null differ diff --git a/data/icons/net.jeena.FeedTheMonkey.svg b/data/icons/net.jeena.FeedTheMonkey.svg new file mode 100644 index 0000000..bab7b90 --- /dev/null +++ b/data/icons/net.jeena.FeedTheMonkey.svg @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128"> + <!-- Background rounded rectangle --> + <rect x="4" y="4" width="120" height="120" rx="28" ry="28" fill="#c0632a"/> + <rect x="4" y="4" width="120" height="120" rx="28" ry="28" fill="url(#bggrad)"/> + + <defs> + <radialGradient id="bggrad" cx="50%" cy="35%" r="65%"> + <stop offset="0%" stop-color="#d97a38"/> + <stop offset="100%" stop-color="#a04f1c"/> + </radialGradient> + <radialGradient id="facegrad" cx="50%" cy="40%" r="55%"> + <stop offset="0%" stop-color="#c8813a"/> + <stop offset="100%" stop-color="#8b5220"/> + </radialGradient> + <radialGradient id="eargrad" cx="50%" cy="40%" r="55%"> + <stop offset="0%" stop-color="#e8b060"/> + <stop offset="100%" stop-color="#c07830"/> + </radialGradient> + </defs> + + <!-- Ears --> + <ellipse cx="26" cy="68" rx="14" ry="16" fill="#8b5220"/> + <ellipse cx="26" cy="68" rx="9" ry="11" fill="url(#eargrad)"/> + <ellipse cx="102" cy="68" rx="14" ry="16" fill="#8b5220"/> + <ellipse cx="102" cy="68" rx="9" ry="11" fill="url(#eargrad)"/> + + <!-- Head --> + <ellipse cx="64" cy="64" rx="42" ry="44" fill="url(#facegrad)"/> + + <!-- Eyes --> + <ellipse cx="50" cy="55" rx="9" ry="10" fill="white"/> + <ellipse cx="78" cy="55" rx="9" ry="10" fill="white"/> + <circle cx="52" cy="57" r="5.5" fill="#1a1a1a"/> + <circle cx="80" cy="57" r="5.5" fill="#1a1a1a"/> + <circle cx="53.5" cy="55" r="1.8" fill="white"/> + <circle cx="81.5" cy="55" r="1.8" fill="white"/> + + <!-- Muzzle --> + <ellipse cx="64" cy="79" rx="22" ry="16" fill="#e8c08a"/> + + <!-- Nose --> + <ellipse cx="58" cy="72" rx="4" ry="2.5" fill="#6b3a10"/> + <ellipse cx="70" cy="72" rx="4" ry="2.5" fill="#6b3a10"/> + <line x1="64" y1="72" x2="64" y2="77" stroke="#6b3a10" stroke-width="1.5"/> + + <!-- Mouth / smile --> + <path d="M 50 82 Q 64 92 78 82" stroke="#6b3a10" stroke-width="2" fill="none" stroke-linecap="round"/> + + <!-- Teeth --> + <rect x="56" y="82" width="8" height="5" rx="1" fill="white"/> + <rect x="65" y="82" width="8" height="5" rx="1" fill="white"/> + + <!-- Eyebrows --> + <path d="M 42 46 Q 50 42 58 46" stroke="#6b3a10" stroke-width="2.5" fill="none" stroke-linecap="round"/> + <path d="M 70 46 Q 78 42 86 46" stroke="#6b3a10" stroke-width="2.5" fill="none" stroke-linecap="round"/> +</svg> diff --git a/data/ui/login_dialog.blp b/data/ui/login_dialog.blp index c009543..86028f0 100644 --- a/data/ui/login_dialog.blp +++ b/data/ui/login_dialog.blp @@ -16,12 +16,11 @@ template $LoginDialog : Adw.Dialog { margin-end: 12; Adw.PreferencesGroup { - description: _("FreshRSS: https://example.com/api/greader.php\nMiniflux: https://example.com"); - Adw.EntryRow server_url_row { 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/data/ui/login_dialog.ui b/data/ui/login_dialog.ui index e34ab42..ebb5d9a 100644 --- a/data/ui/login_dialog.ui +++ b/data/ui/login_dialog.ui @@ -22,8 +22,6 @@ corresponding .blp file and regenerate this file with blueprint-compiler. <property name="margin-end">12</property> <child> <object class="AdwPreferencesGroup"> - <property name="description" translatable="yes">FreshRSS: https://example.com/api/greader.php -Miniflux: https://example.com</property> <child> <object class="AdwEntryRow" id="server_url_row"> <property name="title" translatable="yes">Server URL</property> diff --git a/install.sh b/install.sh index 13a9868..02400c8 100755 --- a/install.sh +++ b/install.sh @@ -15,8 +15,8 @@ fi install -Dm755 "$BINARY" "$PREFIX/bin/feedthemonkey" install -Dm644 data/net.jeena.FeedTheMonkey.desktop \ "$PREFIX/share/applications/net.jeena.FeedTheMonkey.desktop" -install -Dm644 data/icons/net.jeena.FeedTheMonkey.png \ - "$PREFIX/share/icons/hicolor/256x256/apps/net.jeena.FeedTheMonkey.png" +install -Dm644 data/icons/net.jeena.FeedTheMonkey.svg \ + "$PREFIX/share/icons/hicolor/scalable/apps/net.jeena.FeedTheMonkey.svg" # Install GSettings schema install -Dm644 data/net.jeena.FeedTheMonkey.gschema.xml \ diff --git a/src/api.rs b/src/api.rs index d683e8f..312cd82 100644 --- a/src/api.rs +++ b/src/api.rs @@ -42,18 +42,14 @@ struct Summary { use crate::model::Article; -/// Return the candidate base URLs to try in order. -/// Miniflux serves the Greader API at the server root; -/// FreshRSS serves it at /api/greader.php. -fn candidate_base_urls(server_url: &str) -> Vec<String> { +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") { - vec![base.to_string()] + base.to_string() } else { - vec![ - base.to_string(), - format!("{base}/api/greader.php"), - ] + format!("{base}/api/greader.php") } } @@ -63,49 +59,47 @@ impl Api { username: &str, password: &str, ) -> Result<Self, String> { + let base = normalize_base_url(server_url); let client = Client::new(); - let candidates = candidate_base_urls(server_url); - let mut last_err = String::new(); + let url = format!("{base}/accounts/ClientLogin"); + let resp = client + .post(&url) + .form(&[("Email", username), ("Passwd", password)]) + .send() + .await + .map_err(|e| e.to_string())?; - for base in candidates { - let url = format!("{base}/accounts/ClientLogin"); - let resp = match client - .post(&url) - .form(&[("Email", username), ("Passwd", password)]) - .send() - .await - { - Ok(r) => r, - Err(e) => { last_err = e.to_string(); continue; } - }; + let status = resp.status(); + let body = resp.text().await.map_err(|e| e.to_string())?; - let status = resp.status(); - let body = resp.text().await.map_err(|e| e.to_string())?; - - if !status.is_success() { - last_err = format!("Login failed ({}): {}", status.as_u16(), human_error(&body, status.as_u16())); - continue; - } - - let auth_token = match body.lines().find_map(|l| l.strip_prefix("Auth=")) { - Some(t) => t.to_string(), - None => { - last_err = if looks_like_html(&body) { - format!( - "The server at {base} does not appear to be a \ - Greader API endpoint. Check your server URL." - ) - } else { - format!("Unexpected response from server: {}", body.trim()) - }; - continue; - } - }; - - return Ok(Self { client, server_url: base, auth_token }); + if !status.is_success() { + let msg = human_error(&body, status.as_u16()); + return Err(format!("Login failed ({}): {}", status.as_u16(), msg)); } - Err(last_err) + let auth_token = body + .lines() + .find_map(|l| l.strip_prefix("Auth=")) + .ok_or_else(|| { + // The server returned 200 but not the expected API response — + // most likely the URL points to a web page, not a FreshRSS API. + if looks_like_html(&body) { + format!( + "The server at {} does not appear to be a FreshRSS \ + Google Reader API endpoint. Check your server URL.", + base + ) + } else { + format!("Unexpected response from server: {}", body.trim()) + } + })? + .to_string(); + + Ok(Self { + client, + server_url: base, + auth_token, + }) } pub async fn fetch_write_token(&self) -> Result<String, String> {