Repository navigation
Releases: VirInvictus/Viaduct
Release list
## v4.4.0: the roadmap harvest (2026-10-24)
v4.4.0: the roadmap harvest (2026-10-24)
The four standing roadmap candidates, built in one pass per Brandon's call that this project's roadmap work runs here rather than being handed off. 301 tests; clippy -D warnings clean.
- Added: unread-count display modes (NNW
c71be4919,6f7830904): a new Appearance preference, "Unread counts" (Numbers / Dots / Hidden), applies everywhere an unread count renders: feed, folder, smart-feed, and aggregated group rows. Dot mode shows a bullet for unread > 0; zero unread shows nothing in every mode, as it always has. - Added: the accessible label on every badge always carries the number, so dot and hidden modes still announce "N unread" to screen readers (NNW
f74e6e220,813ae7b33). - Added: "Update Inoreader Credentials…" in the primary menu (NNW
263ec2683's update path for username/password accounts). The dialog pre-fills from the Secret Service, verifies the token against the Reader API before storing, and a failed verification leaves the stored credentials untouched. - Added:
feed:andfeeds:subscription links open viaduct (NNW8b261521f's reader interop, our desktop shape): the desktop file registers the schemes, a running instance receives the URL over D-Bus, and the Add Feed dialog opens pre-filled and focused. Both real-world URL shapes normalize (feed://host/pathand RFC 7464'sfeed:https://…); thex-netnewswire-feedscheme deliberately stays unclaimed. - Added: the status bar (inspired by NNW's restored Mac status bar,
2ce51a4d3+ follow-ups): a persistent thin bar under the panes showing "Last refreshed 17:42" (from the DB at startup, wall clock at cycle end; "Never refreshed" on a fresh install), the live per-feed progress during a cycle (absorbing the old auto-hiding strip), and the total unread count on the right. The completion toast is unchanged.
## v4.3.2: MacRumors cleanup, magnet links (2026-10-22)
v4.3.2: MacRumors cleanup, magnet links (2026-10-22)
Two ports from the October 22 NetNewsWire sync (a3e61ddc5 to 63aa234df, +54 commits). 297 tests; clippy -D warnings clean.
- Fixed: MacRumors feeds render without their walls of blank lines. MacRumors puts a
<br/>after every line of its feed HTML, including inside lists and tables, where each<br>becomes a blank line (and in tables the parser piles them above the table as a huge gap). A second per-feed rendering special case removes<br>s that sit right after list/table structural tags for macrumors.com feeds, matching the feed URL or the home page; a<br>after an opening<li>/<td>/<th>is content and stays, and<br>s in legal between-blocks positions are kept (NNW9e21f5c09). - Added:
magnet:links in articles open (NNW1307ca04e, Mac-only upstream; viaduct is the desktop analog). The link allowlist was http/https/mailto since v3.4.0;magnetjoins it case-insensitively. With no torrent handler registered the open simply fails and logs;file://and every other custom scheme stay blocked.
## v4.3.1: proxied Slashdot, numeric titles (2026-10-11)
v4.3.1: proxied Slashdot, numeric titles (2026-10-11)
Two ports from the October 11 NetNewsWire sync (7cd2e4f31 to a3e61ddc5, +33 commits). 292 tests; clippy -D warnings clean.
- Fixed: a Slashdot feed subscribed through a proxy such as FeedBurner now gets its paragraph-separation fix. The special case matched only the feed URL, which for proxied feeds is the proxy's domain; the feed's home page URL is matched too (NNW
a44cfe13c), carried through a newfeed_home_page_urlfield besidefeed_url. - Fixed: folder-view title sorts order numbers numerically: "Chapter 2" before "Chapter 10" (NNW
2dcb8641f). The merge comparator's key zero-pads digit runs. Feed views still sort lexicographically inside SQLite (no numeric collation exists there; recorded divergence, invisible for any realistic title).
## v4.3.0: the bug-hunt harvest (2026-10-09)
v4.3.0: the bug-hunt harvest (2026-10-09)
Six fixes from Brandon's seeded-bench bug hunt against the running app, plus the NetNewsWire read-filter parity the hunt surfaced. 290 tests; clippy -D warnings clean.
- Fixed: sidebar titles truncated far too early, from five stacked causes: a recycled row's stale feed stamp let a late favicon paint into a group header's invisible icon slot and ate its width; the homogeneous icon stack paid hidden-page widths; avatars' natural size followed the favicon's intrinsic pixels instead of the 24px slot; the desktop GTK theme rendered tree-expander indent icons at 16px instead of 8px (now owned in the app stylesheet); and section headers were allowed to ellipsize at all. Headers never truncate now, rows carry full-name tooltips, and the sidebar header bar no longer renders the ellipsized window title.
- Changed: timeline titles always render complete, wrapping onto as many lines as they need. No ellipsis, no width caps.
- Changed: selected rows use Conservatory's lifted-selection idiom: raised surface with a 2px accent edge on the leading side, replacing the solid dragonRed wash that read as permanent garish chrome.
- Fixed: Preferences rendered four empty group husks when the GSettings schema couldn't resolve, silently and with no log output.
settings()now falls back to resolving the schema explicitly from the source tree'sgschemas.compiled(theGSETTINGS_SCHEMA_DIRenv path fails silently under env overrides and init-order surprises, which is also what disabled refresh-on-startup in test launches), the failure paths warn, and the no-schema dialog is one honest explanation instead of husks. - Added: first-run timeline population. Startup lands on the first feed instead of the always-empty "Smart Feeds" header, a first sync auto-populates the timeline, and a selected-but-empty feed now says "No articles yet, hit Sync Now" instead of the misleading "Select a feed" advice.
- Added: the "Show read articles" toggle in the timeline sort menu (NNW
readFilterparity, thetimeline-show-readGSetting, default on). Off hides read articles from feed and folder timelines via new read-filtered fetch paths (a statuses-PK correlated subquery; missing status rows count as unread, per NNW). Mark-read operations still fetch everything regardless; the smart feeds keep their own semantics.
## v4.2.2: icons that survive any icon theme (2026-10-09)
v4.2.2: icons that survive any icon theme (2026-10-09)
The Conservatory audit ported home. With the desktop's icon theme out of reach (a throwaway-XDG launch, which is exactly this repo's GUI-test recipe, or any theme that ships none of the stock names), every requested glyph that GTK's compiled-in set doesn't carry rendered as the image-missing box: the sidebar header's mark-all-read button, the timeline sort menu, the article pane's undo / reader / send-to / font buttons, and the attachment type glyphs. The five bundled viaduct-* sidebar glyphs were never affected (the v4.0.2 runtime install lands in the XDG hicolor tree every theme falls through to). 289 tests; clippy -D warnings clean.
- Fixed: the ten stock symbolics the chrome requests now ride a GResource compiled into the binary:
data/icons/viaduct.gresource.xmlis compiled bybuild.rsviaglib-compile-resourcesand registered inmain.rsbefore the first widget (the Amberol pattern, as adopted by Conservatory). The bundle is the fallthrough backstop only: a theme that carries a name keeps rendering its own glyph, so kora-style desktops are pixel-identical to before. Artwork: unmodified copies fromadwaita-icon-theme(LGPL-3.0, recorded inATTRIBUTIONS.md), and a unit test pins the manifest against the audited name list so the bundle and the code can't drift apart.
## v4.2.1: CJK text-autospace, a Tiqoe Dark sync, and the vir-gtk 1.4.3 wave (2026-10-09)
v4.2.1: CJK text-autospace, a Tiqoe Dark sync, and the vir-gtk 1.4.3 wave (2026-10-09)
Two ports from the October 9 NetNewsWire sync (8c322c287 to 7cd2e4f31, +231 commits), plus the sitting vir-gtk consumer wave. 288 tests; clippy -D warnings clean.
- Added:
text-autospace: normalon the article title, dateline, and body classes (NNW22162140b): on WebKit builds with the property, mixed CJK and Latin text gets the customary hair space between scripts instead of the scripts jamming together. Upstream ships the rule in its base article stylesheet (Shared/Article Rendering/stylesheet.css), which we don't bundle, so it rides the pane override stylesheet; where the property is unsupported the declaration drops out inertly. - Changed: the bundled Tiqoe Dark theme is synced to upstream's latest (NNW
34e4d0683comment typo,558a7cac9PBS Newshour funding-invite hide for Reader View,1da8eb594overflow-x: autofor WordPress math blocks and SVG wrappers): byte-identical with upstream. The other seven themes verified unchanged this window. - Changed: vir-gtk adopted at 1.4.3 (consumer wave, lock bump only): a presented
Alertnow keeps answering after the caller drops the Alert value; the response state anchors to the dialog window, where 1.4.2 let it die with the struct and left fire-and-forget dialogs with dead buttons (found live in Quire's discard guard). No Viaduct code changes;data/cargo-sources.jsonregenerated against the moved lock.
## v4.2.0: the Add Feed UX pair (2026-10-02)
v4.2.0: the Add Feed UX pair (2026-10-02)
The two Add Feed dialog refinements from the October 2 sync's deferred list (NNW b4361413f #4221, 7ea15d7f7 #3758, plus 8795b7926's wording pass). The existing discovery flow is untouched. 288 tests; clippy -D warnings clean.
- Added: the dialog's folder picker opens on the sidebar's current selection. A selected feed preselects its containing folder (
SidebarView::selected_add_feed_folder: feed → tree-parent folder, the port ofcontainerForNode), a selected folder preselects itself, and everything else (standalone feed, smart feeds, group rows, no selection) opens on top-level. Upstream'ssubstituteContainerIfNeededbranch has no counterpart here: viaduct's account model has no.disallowFeedInRootFolderbehavior (local and Inoreader both allow top-level feeds), so the account container maps to "None" (top-level); NNW's last-used-default fallback for smart-feed rows maps to the same, since the dialog keeps no last-used memory. The selection is read live at dialog-open (ViaductWindow::selected_add_feed_folder_public, beside thelist_folder_names_publicprecedent), so nothing new is stored on the window. - Added: subscribing to an already-subscribed feed now says where it lives instead of proceeding into the silent dedupe. The dialog checks the in-memory OPML at two sites mirroring upstream's two call sites: the entered URL before discovery runs (a short-circuit with no network round trip), and the resolved feed URL after discovery (redirects and HTML link resolution can land on a URL the pasted one doesn't show). A hit produces "Can’t add this feed because you’ve already added it in “News” and “Reading”." (folder names sorted, upstream's
formatted(.list(type: .and))shape,8795b7926's "added" wording); a feed that lives only at top level yields the generic "already added it." text, exactly upstream, becauseexistingContainers(withFeed:)'s account entry contributes no folder name to the list. The lookup isexisting_feed_with_url(exact URL match; standalone feeds first, then folders: the same walkAccount::remove_feeduses).Account::add_feeditself is unchanged and still silently dedupes for its other callers, so the welcome dialog's suggested-feed path is untouched, and the dialog stays open with the status row carrying the message. - Tests: 7 new, all on the pure logic. Folder derivation (
initial_add_feed_folder): a folder selects itself, a feed selects its containing folder, and the non-container rows (standalone feed with no folder parent, smart feeds, group rows) fall back to top-level. Message path:existing_feed_with_urlacross standalone / folder-nested / absent,containing_folder_nameslisting every container a duplicated URL lives in (sorted, and empty for a top-level-only feed), the one / two / four-item list formatting, and the generic-vs-named text split.
## v4.1.0: Sort Articles By, and real body text for RSS (2026-10-02)
v4.1.0: Sort Articles By, and real body text for RSS (2026-10-02)
The "Sort Articles By" mode set from the October 2 sync's deferred list (NNW 2ccc95721, 70c3ec809, 24688d50e), plus the ingest change it needs to be true for the dominant feed formats: RSS and Atom items now derive a plain-text body at parse time, which also lights up full-text search and BodyContains smart feeds for those feeds. The unread-count single-flight perf port rides in this release too (it landed between releases). 281 tests; clippy -D warnings clean.
- Added: the timeline header sort menu gains a "Sort by title" section: A to Z / Z to A beside the existing newest/oldest date modes. Upstream rebuilt its sort options as a Mail-style menu over five fields (date, feed, title, unread, starred); ours is the two-field slice this repo's scope ports, rendered as two labeled sections of radio items because GTK menu models are static where AppKit's direction items re-word themselves per field. Untitled articles sort by the start of their body text, porting
70c3ec809's displayed-title key: trimmed title, then a 300-character plain-text excerpt, then summary, compared case-insensitively; title ties break newest-first in both directions, as upstream hardcodes. Per-window persistence (24688d50e) maps to one global setting here (single-window app): the existingtimeline-sort-orderGSetting enum appendstitle-ascending/title-descending, so stored values for the original nicks survive the upgrade, and the window's read-fresh-on-every-reload plumbing picks the new modes up unchanged. Known divergences, recorded onSortOrder:COLLATE NOCASEfolds ASCII case only (SQLite has no locale-aware collation), and the timeline cap applies in sort order (the first/last N titles), exactly as it always has for oldest-first. - Added: ingest-time plain text.
parser::html::strip_html_to_text(hand-rolled, no-regex, the same tokenizer rules as the metadata scanner: script/style raw text and comments can neither leak nor forge markup, tag boundaries emit one space so words never fuse across elements, whitespace runs collapse, 64K-character cap) runs over every RSS item body and Atom entry body at parse time, and the result stores ascontent_text. Two pre-existing gaps close with it: FTS search indexestitle, content_text, so body-word search over RSS/Atom previously matched titles only; and BodyContains smart feeds could never match those bodies. Migration cost: the first refresh after upgrading re-diffs every article once (the diff compares the full row andcontent_textis now populated; status rows are preserved) and the database grows by the stripped text. - Changed: the title sort key is identical across views: the SQL
ORDER BYexpression (single-feed views) and the Rust merge comparator (folder views) trim the same six ASCII whitespace characters and trim before the 300-character cut, so an article orders the same whether its view sorts inside SQLite or across merged chunks. The merge now computes the allocating key once per article (sort_by_cached_key). - Changed: unread-count refreshes run one round at a time.
SidebarView::refresh_unread_countsis single-flight with a dirty-flag re-run (the Rust twin of upstream'sfetchingAllUnreadCounts/needsRefetchAllUnreadCountsflags): a request arriving while a round runs only raises the dirty flag, and each finished round runs at most one follow-up, so a burst of N status mutations (every keyboard navigation marks its article read) collapses from N pooled queries plus N tree walks to one round plus at most one more. The follow-up is honored on the error path too, so a failed round can't strand the badges stale. - Tests: 20 new across parser (stripper unit tests; RSS/Atom derivation pins), database (title ordering, excerpt/summary/NULL fallback, the 300-char cap, tie direction, joined-query and cross-feed merge execution, SQL/Rust key parity between feed and folder views), sidebar (UnreadCountFlight burst-collapse semantics), and preferences (every schema nick maps to its
SortOrder).
## v4.0.7: the server-response excerpt (2026-10-02)
v4.0.7: the server-response excerpt (2026-10-02)
Third port from the October 2 sync's deferred list: NNW 49dbebf67, HTTP error responses carry up to 500 characters of their body into the error logs, because the body usually says what the server didn't like. 261 tests; clippy -D warnings clean.
- Changed: when a feed fetch answers with an HTTP error, the
RUST_LOGwarning and the Activity Log row now show what the server said, not just the code: "Feed HTTP 503 … -- server response: gateway timeout" in the log, "HTTP 503 · server response: gateway timeout" in the dialog. Upstream attaches the excerpt at its shared HTTP-validation layer (WebserviceError.httpErrorgainedresponseBody); ours is captured on the feed-fetch path, where any status outside the valid 200–399 window reads only a capped 8 KB prefix of the error body (the full response is never buffered, and the feed body stays empty on those statuses). The excerpt is strict-UTF-8 decoded (a binary body yields no excerpt, matching upstream'sString(data:encoding:)nil), whitespace-collapsed through a byte-level port of RSCore'scollapsingWhitespace(runs become one space, ends trimmed, non-ASCII untouched), and cut at 500 characters on a char boundary; a multi-byte character split by the read cap is trimmed rather than failing the decode. - Changed: the Inoreader path gained the same treatment.
NetworkError::HttpStatusnow carriesresponse_body(status_errorreads the same capped prefix), and the "Sync failed" activity detail appends-- server response: …exactly where upstream'sAccountError.messagedoes. Classification logic everywhere keys on the status only, never the body: the 429 pause, the 401/403 write-token retry, and every other status branch are untouched. - Fixed (edge): an error response whose body previously tripped the 10 MB feed cap, or whose read died mid-body, surfaced as a network error with no recorded status. It now surfaces as the HTTP error it is, with
last_response_codeandlast_check_daterecorded. Pinned byintegration_http_error_excerpt.rs(collapsed 503 page, empty 500 body, and a >8 KB body) plus unit tests for the collapse, the char-boundary cut, and the non-UTF-8 reject.
## v4.0.6: the instapaper_ignore strip (2026-10-02)
v4.0.6: the instapaper_ignore strip (2026-10-02)
One more port from the October 2 sync's deferred list: NNW d55c93376 (#3501), elements marked class="instapaper_ignore" are stripped from article bodies at render time. 254 tests; clippy -D warnings clean.
- Fixed: navigational chrome that feeds mark
class="instapaper_ignore"(the hook Instapaper's own read-later stripper honors; The Atlantic's pullquote asides are the canonical case) rendered in full. Upstream removes those elements from the rendered DOM in main.js, which never runs here: the article pane is JS-off behind the CSP, and ammonia strips theclassattribute during sanitize. The intent ports as a pre-render transform applied inrender_themedbetweenextract_body_fragmentand sanitize. The scanner is hand-rolled in the module's no-regex discipline and matches upstream'squerySelectorAll(".instapaper_ignore")semantics: the class attribute's whitespace-separated tokens must include the exactinstapaper_ignoretoken (case-sensitively), with the first class attribute winning per HTML5's duplicate-attribute rule. Removal takes the whole subtree: same-name nesting is depth-tracked to the matching close tag; comments and script/style/textarea/title raw text cannot forge or hide markup (a</div>inside a script string doesn't end a wrapping div); void elements end at the tag; an explicit/>ends the element (a deliberate divergence from HTML5, which ignores that solidus: honoring it can only under-remove, never drag siblings out). Malformed markup is left untouched rather than dragged out with the chrome: an ignorable element with no findable close (an unclosed<p>/<li>relying on implied end tags included), an unterminated tag, or an unterminated quoted attribute stays exactly as it arrived. Pinned by 15 unit tests covering the pullquote shape, token exactness, nesting, raw text, comments, void/self-closing tags, case handling, and the malformed cases.