Compare commits

..

10 commits

Author SHA1 Message Date
8dc71214aa data: replace generated SVG icon with original PNG 2026-03-21 12:27:13 +00:00
dec1bfdc7e docs: remove completed backlog 2026-03-21 12:25:19 +00:00
e17f0c622b login: update compiled UI with server URL hint 2026-03-21 12:24:19 +00:00
88afb27a22 login: add server URL format hint to login dialog 2026-03-21 12:17:55 +00:00
f05eec6d53 docs: fix README intro formatting 2026-03-21 12:02:48 +00:00
68c4f6ccfe docs: clarify server URL format for FreshRSS and Miniflux 2026-03-21 12:01:48 +00:00
ff92499406 docs: name supported servers explicitly instead of implying universal detection 2026-03-21 11:59:00 +00:00
b49cc69c49 api: auto-detect Greader API path for Miniflux and FreshRSS
Miniflux serves the Greader API at the server root while FreshRSS uses
/api/greader.php. Instead of hardcoding the FreshRSS suffix, try the
URL as-is first (works for Miniflux) and fall back to appending
/api/greader.php (works for FreshRSS). The user just enters the server
URL without needing to know the API path.
2026-03-21 11:57:19 +00:00
82aabc080a docs: remove TT-RSS from compatible servers list 2026-03-21 11:53:36 +00:00
6afab6f421 docs: clarify app works with any Greader API server 2026-03-21 11:37:18 +00:00
8 changed files with 63 additions and 524 deletions

View file

@ -1,420 +0,0 @@
# 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=<token>` |
| 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=<id>&a=user/-/state/com.google/read&T=<token>` |
| Mark unread | POST | /reader/api/0/edit-tag | Body: `i=<id>&r=user/-/state/com.google/read&T=<token>` |
### 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<AuthToken>`
Calls `POST /accounts/ClientLogin`, parses `Auth=` line from response
- `fetch_write_token(auth_token) -> Result<WriteToken>`
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<Vec<Article>>` 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<ArticleObject>`.
**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 `<title>` 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.

View file

@ -1,8 +1,15 @@
# FeedTheMonkey # FeedTheMonkey
FeedTheMonkey is a desktop client for [Tiny Tiny RSS](https://tt-rss.org). It doesn't FeedTheMonkey is a desktop client for any server that implements the
work as a standalone feed reader — it connects to a TT-RSS server via the Fever/Greader [Greader API](https://github.com/theoldreader/api).
API to fetch articles and sync read state.
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
This is version 3, rewritten in Rust with GTK4 and libadwaita. This is version 3, rewritten in Rust with GTK4 and libadwaita.

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -1,57 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -16,11 +16,12 @@ template $LoginDialog : Adw.Dialog {
margin-end: 12; margin-end: 12;
Adw.PreferencesGroup { Adw.PreferencesGroup {
description: _("FreshRSS: https://example.com/api/greader.php\nMiniflux: https://example.com");
Adw.EntryRow server_url_row { Adw.EntryRow server_url_row {
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

@ -22,6 +22,8 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
<property name="margin-end">12</property> <property name="margin-end">12</property>
<child> <child>
<object class="AdwPreferencesGroup"> <object class="AdwPreferencesGroup">
<property name="description" translatable="yes">FreshRSS: https://example.com/api/greader.php
Miniflux: https://example.com</property>
<child> <child>
<object class="AdwEntryRow" id="server_url_row"> <object class="AdwEntryRow" id="server_url_row">
<property name="title" translatable="yes">Server URL</property> <property name="title" translatable="yes">Server URL</property>

View file

@ -15,8 +15,8 @@ fi
install -Dm755 "$BINARY" "$PREFIX/bin/feedthemonkey" install -Dm755 "$BINARY" "$PREFIX/bin/feedthemonkey"
install -Dm644 data/net.jeena.FeedTheMonkey.desktop \ install -Dm644 data/net.jeena.FeedTheMonkey.desktop \
"$PREFIX/share/applications/net.jeena.FeedTheMonkey.desktop" "$PREFIX/share/applications/net.jeena.FeedTheMonkey.desktop"
install -Dm644 data/icons/net.jeena.FeedTheMonkey.svg \ install -Dm644 data/icons/net.jeena.FeedTheMonkey.png \
"$PREFIX/share/icons/hicolor/scalable/apps/net.jeena.FeedTheMonkey.svg" "$PREFIX/share/icons/hicolor/256x256/apps/net.jeena.FeedTheMonkey.png"
# Install GSettings schema # Install GSettings schema
install -Dm644 data/net.jeena.FeedTheMonkey.gschema.xml \ install -Dm644 data/net.jeena.FeedTheMonkey.gschema.xml \

View file

@ -42,14 +42,18 @@ struct Summary {
use crate::model::Article; use crate::model::Article;
fn normalize_base_url(server_url: &str) -> String { /// 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> {
let base = server_url.trim_end_matches('/'); 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") { if base.ends_with("/api/greader.php") {
base.to_string() vec![base.to_string()]
} else { } else {
format!("{base}/api/greader.php") vec![
base.to_string(),
format!("{base}/api/greader.php"),
]
} }
} }
@ -59,47 +63,49 @@ impl Api {
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 candidates = candidate_base_urls(server_url);
let mut last_err = String::new();
for base in candidates {
let url = format!("{base}/accounts/ClientLogin"); let url = format!("{base}/accounts/ClientLogin");
let resp = client let resp = match client
.post(&url) .post(&url)
.form(&[("Email", username), ("Passwd", password)]) .form(&[("Email", username), ("Passwd", password)])
.send() .send()
.await .await
.map_err(|e| e.to_string())?; {
Ok(r) => r,
Err(e) => { last_err = e.to_string(); continue; }
};
let status = resp.status(); let status = resp.status();
let body = resp.text().await.map_err(|e| e.to_string())?; let body = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() { if !status.is_success() {
let msg = human_error(&body, status.as_u16()); last_err = format!("Login failed ({}): {}", status.as_u16(), human_error(&body, status.as_u16()));
return Err(format!("Login failed ({}): {}", status.as_u16(), msg)); continue;
} }
let auth_token = body let auth_token = match body.lines().find_map(|l| l.strip_prefix("Auth=")) {
.lines() Some(t) => t.to_string(),
.find_map(|l| l.strip_prefix("Auth=")) None => {
.ok_or_else(|| { last_err = if looks_like_html(&body) {
// 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!( format!(
"The server at {} does not appear to be a FreshRSS \ "The server at {base} does not appear to be a \
Google Reader API endpoint. Check your server URL.", Greader API endpoint. Check your server URL."
base
) )
} else { } else {
format!("Unexpected response from server: {}", body.trim()) format!("Unexpected response from server: {}", body.trim())
};
continue;
} }
})? };
.to_string();
Ok(Self { return Ok(Self { client, server_url: base, auth_token });
client, }
server_url: base,
auth_token, Err(last_err)
})
} }
pub async fn fetch_write_token(&self) -> Result<String, String> { pub async fn fetch_write_token(&self) -> Result<String, String> {