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 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.
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
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;
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 {

View file

@ -22,6 +22,8 @@ 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>

View file

@ -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.svg \
"$PREFIX/share/icons/hicolor/scalable/apps/net.jeena.FeedTheMonkey.svg"
install -Dm644 data/icons/net.jeena.FeedTheMonkey.png \
"$PREFIX/share/icons/hicolor/256x256/apps/net.jeena.FeedTheMonkey.png"
# Install GSettings schema
install -Dm644 data/net.jeena.FeedTheMonkey.gschema.xml \

View file

@ -42,14 +42,18 @@ struct Summary {
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('/');
// 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()
vec![base.to_string()]
} else {
format!("{base}/api/greader.php")
vec![
base.to_string(),
format!("{base}/api/greader.php"),
]
}
}
@ -59,47 +63,49 @@ impl Api {
username: &str,
password: &str,
) -> Result<Self, String> {
let base = normalize_base_url(server_url);
let client = Client::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())?;
let candidates = candidate_base_urls(server_url);
let mut last_err = String::new();
let status = resp.status();
let body = resp.text().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; }
};
if !status.is_success() {
let msg = human_error(&body, status.as_u16());
return Err(format!("Login failed ({}): {}", status.as_u16(), msg));
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 });
}
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,
})
Err(last_err)
}
pub async fn fetch_write_token(&self) -> Result<String, String> {