Compare commits
10 commits
ed10ba1310
...
8dc71214aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dc71214aa | |||
| dec1bfdc7e | |||
| e17f0c622b | |||
| 88afb27a22 | |||
| f05eec6d53 | |||
| 68c4f6ccfe | |||
| ff92499406 | |||
| b49cc69c49 | |||
| 82aabc080a | |||
| 6afab6f421 |
8 changed files with 63 additions and 524 deletions
420
BACKLOG.md
420
BACKLOG.md
|
|
@ -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.
|
||||
13
README.md
13
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
BIN
data/icons/net.jeena.FeedTheMonkey.png
Normal file
BIN
data/icons/net.jeena.FeedTheMonkey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
|
|
@ -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 |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
88
src/api.rs
88
src/api.rs
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue