You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Enable episode download and storage, with simple queueing, and and delete/cancel operations. Controls accessible through list view, and on episode detail page
Download manager
Adds offline episode downloads to Pollux: a serial download queue with live
progress, cancel/delete, per-episode failure reasons, and resume-on-launch —
driven entirely from the shared Rust core with the iOS shell handling the actual
network + filesystem I/O.
Features
Serial download queue — one episode downloads at a time (model.downloading
download_queue); the rest wait FIFO. Queue entries carry the enclosure URL,
so a download can start without its feed being loaded.
Live byte progress — the shell reports received/total bytes while a download
runs; shown as a determinate bar (or an indeterminate spinner when the server
sends no Content-Length). Transient/in-memory — never persisted.
Cancel — cancels the in-flight URLSession task and flushes any partial
file; also drops a still-queued item. Available from the row menu, the leading
swipe, and the episode detail page.
Delete — removes a downloaded file (idempotent).
Resume on launch — episodes left Downloading/Queued by a previous session
are re-enqueued and resumed (partial files don't survive a quit, so each restarts
from scratch).
Failure reasons — the shell's specific error (HTTP status, disk error,
timeout) is surfaced beside Retry instead of a generic "failed".
Non-blocking op-failure notice — a failed delete or a persistence-write
failure shows a dismissible banner scoped to its episode, leaving the list and
its controls intact (unlike a list-load error).
The core owns when to download and the queue; the shell's DownloadManager
(an actor) carries out one operation at a time and reports the outcome.
Progress ordering — byte-progress callbacks are funnelled through an AsyncStream drained by a single consumer, so DownloadProgress events reach
the core in order; the terminal result lands only after the stream drains.
Serial storage — all storage writes go through one serial consumer so a
download's status transitions (Queued → Downloading → terminal) can't reorder
and leave a stale row.
URLSession detail — progress requires a session-level delegate (the async download(from:delegate:) convenience never fires didWriteData); the delegate
is @unchecked Sendable with all mutable state under an NSLock.
UI
Episode rows show an inline status glyph (downloaded / queued / failed / removed)
and a progress bar while downloading; the download action lives in the row's
three-dots menu and a leading swipe.
The episode detail page has working Download / Cancel / Delete controls and shows
the failure reason and op-failure notice.
All download UI uses the base16 themeColors palette.
Data model
UpdateDownloadState storage op persists status + file metadata (path, size).
Download size is recorded from the delegate's byte count, not a re-stat.
Removed the unused download_progress column and Episode field — progress is
transient, so it was dead end-to-end.
Download files live under Application Support/Downloads/, excluded from device
backups (re-downloadable), named by a SHA-256 digest of the episode id so
distinct ids can't collide.
Tests
Rust core: 81 unit tests covering queueing, serial ordering, progress overlay,
resume, cancel/delete, the failure-notice lifecycle, and the persist sequence.
Queue order across relaunch isn't persisted — resume rebuilds by pub date
(documented; tracked under a ROADMAP "Download queue management" item).
Feed refresh vs downloaded size — the episodes upsert would overwrite a
downloaded file's real size with the feed's advertised length; flagged with a note(refresh) at the query since there's no refresh feature yet.
The pre-download storage-cap check is a seam (always allows today) for the
Settings soft cap.
Migration
Pre-release: the download_progress column was removed by editing the v1_initial
migration rather than adding a v2, so existing local/simulator databases must be
wiped (delete the app from the simulator) to match the schema.
Add a download manager: episodes can be queued for download, fetched one at
a time, and deleted. The core owns the queue and status transitions; a new
Download shell capability performs the file I/O.
- shared: Download capability (fetch/delete), a Download effect, and an
UpdateDownloadState storage op. The core tracks a serial queue
(download_queue + downloading) and drives NotDownloaded -> Queued ->
Downloading -> Downloaded/Failed, persisting each transition and updating
the in-memory episode so the details page reflects it without a reload.
- iOS: DownloadManager fetches to Application Support/Downloads (stores a
relative path) with an idempotent delete; core.swift handles the Download
effect; DatabaseManager implements UpdateDownloadState (row mapping split
into DatabaseManagerRows.swift to stay under the file-length limit); the
episode row and detail page get a working download / delete / retry control.
Status-only for now (no live percentage) — the capability and the URLSession
seam are structured so live progress is an additive change. The storage
soft-cap is deferred to the Settings screen; device-full surfaces as a failed
download.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Report byte progress while an episode downloads: a percentage on the
episode row and a determinate bar with a size label ("12 MB / 37 MB") on
the detail page, falling back to an indeterminate indicator when the
server reports no size. Progress is transient runtime state — a partial
download can't resume — so it is kept in-memory only, never persisted.
- shared: replace the placeholder DownloadResult::Progress with an
Event::DownloadProgress carrying received/total bytes. The core stores
it in Model.active_download_progress (cleared when a download starts
or ends) and overlays it onto the active episode in view();
EpisodeSummary swaps its download_progress (u8 percent) for
received/total byte fields.
- iOS: byte progress comes from a session-level
URLSessionDownloadDelegate. The async download(from:delegate:)
convenience never fires didWriteData, so DownloadManager now uses a
dedicated URLSession + a continuation, moving the finished temp file
in didFinishDownloadingTo. core.swift forwards progress as
DownloadProgress events; updates are coalesced to whole-percent steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the downloaded indicator to a small filled-download glyph inline before
the date/duration instead of its own badge line, and replace that line with a
progress bar (or a spinner with byte count when the size is unknown) while an
episode is downloading — dropping the separate status line and the trailing
percentage so the row reads less busy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A download left mid-flight when the app quit was stranded: its status
stayed Downloading in the DB but nothing was running, so the row showed
a permanent spinner with no way to recover. On Started, query every
episode still marked Downloading/Queued (new ListPendingDownloads
storage op), rebuild the serial queue, and resume them from scratch
(partial files don't survive a quit). The download queue now carries the
enclosure URL per entry so a download can start without its feed's
episodes being loaded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn the row's stubbed ellipsis into a real menu whose first item is the
context-sensitive download action (Download / Retry Download / Delete
Download; a disabled indicator while queued/downloading/removed). Drop the
dedicated inline download button so the row is just play + ⋯, giving titles
more room. Download status stays inline (meta-line glyph and progress bar) —
only the action moved. Swipe shortcuts and configurable row layout are left
for later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the spec's default leading swipe action (short swipe-right) on
episode rows: Download when not downloaded, Retry when failed, Delete
when downloaded (destructive). Full-swipe commits Download/Retry but not
Delete, so a stray long swipe can't remove a file;
queued/downloading/removed rows have no swipe (no cancel yet). Fixed
behavior for now — configurable swipes arrive with the Settings screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a cancel action for the downloading/queued states (three-dots menu +
swipe). Cancelling a queued episode just drops it from the queue;
cancelling the active one asks the shell to cancel the URLSession task,
and the in-flight Download request then resolves as Cancelled —
resetting the episode to not-downloaded (not failed), clearing progress,
and draining the queue to the next item. The shell tracks the active
task so Cancel can reach it, and flushes any partial bytes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a cancel button beside the progress bar (and on the queued row) in
the episode detail view, so an in-flight or queued download can be
stopped without going back to the list. Delete was already available
there for a downloaded episode; this fills in the missing cancel
affordance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restrict DownloadEpisode to the NotDownloaded/Failed states; every other
state (Downloaded, Queued, Downloading, RemovedFromFeed, or an unknown id) is
now an explicit no-op, so a stray or repeated event can't re-fetch a file we
already have or double-enqueue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download delegate's continuation was written from the
DownloadManager actor (via attach) but read/cleared on URLSession's
delegate queue — an unsynchronized cross-thread access that @unchecked
Sendable hid. Guard all of the delegate's mutable state with an NSLock;
snapshot under the lock and resume the continuation outside it so
resuming the awaiting task can't deadlock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route byte progress through a single AsyncStream drained by one
consumer, instead of spawning a MainActor Task per callback (which could
reorder and made the displayed percentage briefly regress). Extract the
download effect handling into handleDownload(_:requestId:) to keep
processEffect within the body-length limit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moving the download action into the menu left queued, failed, and removed
episodes looking identical to a plain not-downloaded row. Add a small status
glyph before the date (clock / warning-in-error-color / xmark; the existing
download glyph for downloaded), so those states read at a glance. (Copilot #2.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep the shell's specific failure reason (disk full, HTTP status,
filesystem error) per episode instead of discarding it, and show it
beside Retry — on the detail page under "Download failed", and inline on
the row in place of the date. Stored in-memory and cleared when the
episode leaves the Failed state. (Copilot #3.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each storage effect was run in its own unstructured Task, so writes for one
episode (Queued → Downloading → terminal) could apply out of order — a fast
failure or cancel could persist its terminal state before the Downloading
write, leaving a stale status that re-downloads the episode after restart.
Funnel storage through a single serial AsyncStream consumer so ops execute in
submission order. (Copilot #1.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fileSize(at:) re-read the file's attributes after a completed download and
returned 0 if that read failed, recording size_bytes = 0 for a real file. Use
the delegate's totalBytesWritten (the completed file's byte count) instead, so
a transient stat failure can't understate storage usage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Current State" section still described the counter-app scaffolding.
Update it — plus the stale Event/Effect descriptions, the FFI/shell
dispatch notes, and the repo layout — to reflect the implemented
feed/library/download-manager core and base16 theming, and fix a "no no"
typo in the CI section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download UI used hardcoded system colors
(.green/.blue/.orange/.secondary) instead of the base16 themeColors the
merged theming system established, so it would render off-palette under
a custom theme (Solarized/Nord). Swap them for themeColors
(success/accent/warning/secondaryText); destructive Delete/Cancel keep
the idiomatic system red via their role.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address two review nits:
- Guard the failed-download reason overlay with is_empty() so a feed with no
failures skips a per-episode HashMap lookup on every view().
- Make the cancel flow easier to follow. notify_shell isn't viable here (the FFI
resolve() panics on a non-resolvable request and the shell resolves every
download request), so the Cancel request/ack stays — but DownloadFinished's
Cancelled arm is now documented as the sole owner of the state reset, and the
DownloadCanceled ack is an explicit no-op sink (dropping a dead, misleading
cancel-error-to-banner branch). Extract reset_to_not_downloaded, the identical
set-NotDownloaded + persist idiom shared by the cancel and delete paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The menu (EpisodeRow.downloadMenuItem) and leading-swipe
(SubscriptionDetailScreen.downloadSwipeButton) both grew a Cancel action for
queued/downloading episodes, but their doc comments still said cancellation
wasn't available. Describe the actual state-dependent behavior instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Downloaded episode files live under Application Support, which is backed
up to iCloud by default. Since the audio is reproducible
(re-downloadable from the feed), a large offline library would
needlessly consume the user's backup quota and inflate their backups.
Set isExcludedFromBackup on the Downloads directory when creating it;
the flag is best-effort so it never fails a download.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download shell (network + filesystem + cancellation) had no
automated coverage. Add test seams to DownloadManager.init — an
injectable storage root, URLSession configuration, and progress-report
interval, all defaulted so production call sites are unchanged — and a
DownloadManagerTests suite driven by a stub URLProtocol: successful
download (stored file + reported size), non-2xx failure with no leftover
file, in-flight cancellation cleanup, idempotent delete, and progress
delivery. The delegate's throttle interval is now injected rather than a
hardcoded constant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EpisodeFormatting.downloadProgress had no coverage despite the other formatters
being tested. Add cases for nil received bytes, a known total (fraction +
combined label), unknown and zero totals (received-only label, no fraction),
received exceeding total (fraction clamped to 1.0), and UInt64.max inputs (no
overflow via Int64 clamping). Byte labels are checked against a mirror of the
production .file formatter to stay locale-independent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed persistence write or file removal set detail_error — the list-load
error channel — so SubscriptionDetailScreen replaced the entire episode list
(and every download/retry/cancel control) with an error view, and later
successful writes never cleared it. Route these through a new transient,
non-blocking download_notice instead: it renders as a banner above the intact
list, and clears on a successful persist, any fresh download action, or a feed
switch. detail_error goes back to meaning only "the episode list failed to
load."
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
listPendingDownloads rebuilds the queue by pub_date on relaunch, so enqueue
order isn't preserved across restarts (and a Queued item can start before the
one that was actively Downloading). Document this as a known, accepted MVP
limitation at the query, and track the fix — a persisted enqueue order, active
item restored first — under a Phase 5 "Download queue management" roadmap item
alongside the downloads page / re-ordering / parallel-download work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Download progress became transient in-memory state (a partial download
can't resume across a restart), leaving download_progress dead end to
end: the Episode domain field was only ever written None and never read,
the ViewModel never carried it, and the DB
column/UpdateDownloadState.progress param only ever persisted NULL.
Remove it everywhere — Rust domain + storage op, regenerated Swift
types, the SQLite column (edited into v1_initial), and the shell
read/write paths — and delete the obsolete out-of-range-progress test.
Pre-release, so no migration: existing local databases must be wiped (delete the
app from the simulator) to match the updated v1 schema.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fill the gaps left by two earlier changes: assert the unknown-size
progress throttle caps reporting at the initial callback (a large
chunked body with no Content-Length and a long interval yields ≤1
report, total nil), and that a failed delete surfaces through
download_notice while leaving the row Downloaded and detail_error
untouched. Also cover the notice clearing on subscription switch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Handle StorageResult::Error instead of silently continuing. If this launch-time query fails after subscriptions load, persisted Queued/Downloading rows are never added to the in-memory manager; when their feed is opened they still render as pending, but both retry and cancel are unavailable/no-ops until another relaunch. Retry the query or move those rows into a recoverable state and surface the failure.
DownloadStatePersisted cleared download_notice on any successful state write,
so a concurrent download completing (its Downloaded persist) could dismiss an
unrelated episode's delete/persist-failure message before the user saw it —
deletes and cancels run independently of the serial download queue, so the
overlap is reachable. Only set the notice on Error now; it still clears on the
next user download action or a feed switch, so it stays transient without being
wiped out of band.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On launch, PendingDownloadsLoaded persisted Queued for every re-enqueued
episode, then maybe_start_next immediately re-persisted the head item as
Downloading — so the head's Queued write was written and instantly
superseded. Skip it for the item that starts this pass (guarded by
downloading.is_none()); items waiting behind it still normalize to
Queued. A test pins the persist sequence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
download_notice rendered only on the subscription list, so a delete or
cancel that failed from an episode's detail page (which has its own
Delete/Cancel controls) left the user with no feedback until they
navigated back. Extract the banner into a shared DownloadNoticeBanner
view and render it above the download controls on the detail page too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sert
The episodes upsert preserves download_status/local_path on conflict but
overwrites file_size_bytes with the feed's advertised enclosure length,
which would clobber the real on-disk size a completed download records.
No feed refresh exists yet so it can't trigger; leave a FIXME(refresh)
at the line so it's addressed when refresh lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The warning triangle in DownloadNoticeBanner is decorative — the message text
carries the meaning — so mark it accessibilityHidden to keep VoiceOver from
announcing the icon's name ahead of the message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The op-failure notice was subscription-wide and rendered on every
episode's detail page, so a delete/cancel that failed on episode A
appeared on episode B's page (the message names no episode, and
navigating between episodes doesn't clear it). Tag the notice with its
episode: download_notice becomes a DownloadNotice { episode_id, message
} through the model, ViewModel, and the DownloadStatePersisted event.
The detail page shows it only when it's about that episode; the list
still shows it for any.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fileName reduced an episode id to alphanumerics, mapping other
characters to "_", so distinct ids like `ep-1` and `ep_1` produced the
same file — the second download would overwrite the first, and deleting
either would remove the shared file. Ids are generated UUIDs today so it
can't collide in practice, but the helper accepted arbitrary strings.
Base the name on a SHA-256 digest of the id (keeping the URL extension)
so distinct ids always get distinct files; add a collision regression
test and stop the existing tests hard-coding the name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Handle failed pending-download loads without leaving episodes stuck
shared/src/app.rs:60
A failed ListPendingDownloads is silently treated as an empty queue, but the database rows remain Queued/Downloading. If their feed is opened later, the UI offers Cancel while CancelDownload cannot find the episode in downloading or download_queue, and DownloadEpisode refuses those statuses, leaving the episode stuck for the session. Retry/reconcile this load or provide a recovery path that resets or requeues those rows.
Correct documentation for the enabled download menu
iOS/Pollux/EpisodeRow.swift:7
This header still says the more-actions control is disabled, but moreMenu below is now an enabled download action. Update the documentation so it distinguishes the working download menu from the remaining playback placeholder.
Update documentation for the implemented swipe download shortcut
iOS/Pollux/EpisodeRow.swift:154
The PR adds a leading-swipe download shortcut in SubscriptionDetailScreen, so saying swipe shortcuts are a later addition now contradicts the implemented UI. Describe the menu as an additional action surface instead.
Fix docstring to reflect separate download progress tracking
shared/src/app.rs:576
This docstring still claims progress is updated by set_download_state, but progress was removed from Episode and is managed separately through active_download_progress. Remove that claim so the helper's contract matches its parameters and behavior.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Enable episode download and storage, with simple queueing, and and delete/cancel operations. Controls accessible through list view, and on episode detail page
Download manager
Adds offline episode downloads to Pollux: a serial download queue with live
progress, cancel/delete, per-episode failure reasons, and resume-on-launch —
driven entirely from the shared Rust core with the iOS shell handling the actual
network + filesystem I/O.
Features
model.downloadingdownload_queue); the rest wait FIFO. Queue entries carry the enclosure URL,so a download can start without its feed being loaded.
runs; shown as a determinate bar (or an indeterminate spinner when the server
sends no
Content-Length). Transient/in-memory — never persisted.URLSessiontask and flushes any partialfile; also drops a still-queued item. Available from the row menu, the leading
swipe, and the episode detail page.
Downloading/Queuedby a previous sessionare re-enqueued and resumed (partial files don't survive a quit, so each restarts
from scratch).
timeout) is surfaced beside Retry instead of a generic "failed".
failure shows a dismissible banner scoped to its episode, leaving the list and
its controls intact (unlike a list-load error).
How it works
shared/src/capabilities/download.rs):DownloadOperation(Download / Delete / Cancel) →DownloadResult(Completed / Deleted / Cancelled / Error).
DownloadManager(an actor) carries out one operation at a time and reports the outcome.
AsyncStreamdrained by a single consumer, soDownloadProgressevents reachthe core in order; the terminal result lands only after the stream drains.
download's status transitions (
Queued → Downloading → terminal) can't reorderand leave a stale row.
download(from:delegate:)convenience never firesdidWriteData); the delegateis
@unchecked Sendablewith all mutable state under anNSLock.UI
and a progress bar while downloading; the download action lives in the row's
three-dots menu and a leading swipe.
the failure reason and op-failure notice.
themeColorspalette.Data model
UpdateDownloadStatestorage op persists status + file metadata (path, size).Download size is recorded from the delegate's byte count, not a re-stat.
download_progresscolumn andEpisodefield — progress istransient, so it was dead end-to-end.
Application Support/Downloads/, excluded from devicebackups (re-downloadable), named by a SHA-256 digest of the episode id so
distinct ids can't collide.
Tests
resume, cancel/delete, the failure-notice lifecycle, and the persist sequence.
DownloadManagertests (stubURLProtocol) for success, non-2xx,cancellation cleanup, idempotent delete, progress delivery + throttle, and
filename collision-resistance;
EpisodeFormattingprogress-formatter tests.Known limitations / follow-ups
(documented; tracked under a ROADMAP "Download queue management" item).
downloaded file's real size with the feed's advertised length; flagged with a
note(refresh)at the query since there's no refresh feature yet.Settings soft cap.
Migration
Pre-release: the
download_progresscolumn was removed by editing thev1_initialmigration rather than adding a v2, so existing local/simulator databases must be
wiped (delete the app from the simulator) to match the schema.
🤖 Generated with Claude Code