Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
11fd606
add PLAN
AlexAxthelm Sep 15, 2026
c3c197b
feat: download episodes with a serial download queue
AlexAxthelm Sep 17, 2026
73af03e
feat: show live download progress
AlexAxthelm Sep 17, 2026
ba1a575
declutter episode row download states
AlexAxthelm Sep 17, 2026
35236c7
feat: resume interrupted downloads on launch
AlexAxthelm Sep 17, 2026
ce36954
move episode download action into the three-dots menu
AlexAxthelm Sep 18, 2026
84576d2
feat: swipe an episode right to download or delete
AlexAxthelm Sep 18, 2026
0d1a846
feat: cancel an in-flight or queued download
AlexAxthelm Sep 18, 2026
bf873e8
better label on swipe action
AlexAxthelm Sep 18, 2026
d33f907
feat: cancel a download from the episode detail page
AlexAxthelm Sep 18, 2026
e616900
fix: don't re-download an already-downloaded episode
AlexAxthelm Sep 19, 2026
2efc4fa
guard DownloadSessionDelegate state with a lock
AlexAxthelm Sep 19, 2026
53d38ac
rm PLAN
AlexAxthelm Sep 19, 2026
dd7eede
refactor: deliver download progress in order via a stream
AlexAxthelm Sep 19, 2026
16e143c
Merge branch 'main' into feat/downloads
AlexAxthelm Sep 19, 2026
90d4091
show an inline glyph for queued/failed/removed episodes
AlexAxthelm Sep 19, 2026
12b1d88
feat: surface the reason a download failed
AlexAxthelm Sep 19, 2026
41ede91
fix: serialize storage writes so status transitions can't reorder
AlexAxthelm Sep 20, 2026
a10f305
record a download's size from the delegate, not a re-stat
AlexAxthelm Sep 20, 2026
e8bbd82
docs: refresh ARCHITECTURE for the current core
AlexAxthelm Sep 20, 2026
a63cfc4
theme the download UI colors
AlexAxthelm Sep 20, 2026
2bffdc1
tidy the download cancel + error-overlay paths
AlexAxthelm Sep 20, 2026
fcf0348
docs: correct stale "no cancel yet" download-control comments
AlexAxthelm Sep 20, 2026
ae79d9a
fix: exclude downloaded audio from device backups
AlexAxthelm Sep 20, 2026
065387e
test: cover the DownloadManager shell
AlexAxthelm Sep 20, 2026
dd58386
test: cover the download-progress formatter
AlexAxthelm Sep 20, 2026
cc0c361
stop download-op failures from blanking the episode list
AlexAxthelm Sep 20, 2026
91b8f7f
docs: note the download queue's non-FIFO restore behavior
AlexAxthelm Sep 20, 2026
804a70f
drop the unused download_progress column and field
AlexAxthelm Sep 20, 2026
bcd97a5
test: cover progress throttle and delete-failure notice
AlexAxthelm Sep 20, 2026
2014a3e
don't let an unrelated persist clear the download notice
AlexAxthelm Sep 20, 2026
54fd034
perf: skip the head item's redundant Queued write on resume
AlexAxthelm Sep 21, 2026
e5af453
fix: show the download-failure notice on the episode detail page
AlexAxthelm Sep 21, 2026
e064898
docs: flag the refresh vs downloaded-size conflict in the episodes up…
AlexAxthelm Sep 21, 2026
11f0709
style: format
AlexAxthelm Sep 21, 2026
216918b
style: patterns
AlexAxthelm Sep 21, 2026
1b2e629
a11y: hide the decorative icon in the download notice banner
AlexAxthelm Sep 21, 2026
13e60d0
fix: scope the download-failure notice to its episode
AlexAxthelm Sep 21, 2026
d4633ad
fix: derive download filenames from a digest to avoid collisions
AlexAxthelm Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 42 additions & 21 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,27 @@ serialized Effects flow out. The core never touches UI or I/O directly.
pollux/
├── shared/ # Rust core (Crux app)
│ ├── src/
│ │ ├── app.rs # App trait impl: Event, Model, ViewModel, Effect
│ │ ├── app.rs # App trait impl: Event, update(), view()
│ │ ├── model.rs # Model — private core state
│ │ ├── view_model.rs # ViewModel — serializable projection for the shell
│ │ ├── effect.rs # Effect enum (Render / Storage / Http / Download)
│ │ ├── capabilities/ # Storage, Http, Download operation & result types
│ │ ├── domain/ # Episode, Subscription
│ │ ├── feed_parser.rs # RSS → Subscription + Episodes
│ │ ├── theme.rs # base16 themes → ThemeView
│ │ ├── ffi.rs # CoreFFI — the C/UniFFI/WASM bridge struct
│ │ ├── lib.rs # Crate root; re-exports; UniFFI scaffolding
│ │ └── bin/
│ │ └── codegen.rs # CLI binary: generates Swift/Kotlin/TS types
│ └── Cargo.toml
├── iOS/
│ ├── Pollux/ # SwiftUI app
│ │ ├── Pollux.swift # @main entry point
│ │ ├── ContentView.swift
│ │ └── core.swift # Core ObservableObject: drives CoreFFI
│ │ ├── Pollux.swift # @main entry point
│ │ ├── ContentView.swift # library + subscribe flow
│ │ ├── core.swift # Core ObservableObject: drives CoreFFI
│ │ ├── DatabaseManager.swift # Storage effect handler (GRDB/SQLite)
│ │ ├── DownloadManager.swift # Download effect handler (URLSession)
│ │ └── Theme.swift # ThemeView → SwiftUI colors
│ ├── generated/ # ← git-ignored; produced by `make typegen` + `make package`
│ │ ├── App/ # Swift type stubs (Event, ViewModel, Request, …)
│ │ └── Shared/ # Swift package wrapping the compiled Rust static lib
Expand Down Expand Up @@ -89,10 +99,10 @@ Event → update() → Command<Effect, Event>
| Type | Role |
|---|---|
| `Pollux` | Zero-sized struct implementing `crux_core::App` |
| `Event` | All actions the UI can trigger (`Increment`, `Decrement`, `Reset` today) |
| `Model` | Private application state (never leaves the core) |
| `ViewModel` | Public, serializable snapshot of state for the shell to render |
| `Effect` | Side-effects the shell must handle (`Render` today; later: network, storage, …) |
| `Event` | All inputs the shell feeds in — user actions (subscribe/refresh a feed, select a subscription, sort/download/cancel an episode, set the theme) and the async results of storage/HTTP/download effects |
| `Model` | Private application state (never leaves the core); see `model.rs` |
| `ViewModel` | Public, serializable snapshot of state for the shell to render; see `view_model.rs` |
| `Effect` | Side-effects the shell must handle: `Render`, `Storage` (SQLite via GRDB), `Http` (feed fetch), `Download` (episode audio); see `effect.rs` |

`Model` and `ViewModel` are deliberately separate. `Model` is private and
mutable; `ViewModel` is a derived, serializable read-only projection.
Expand All @@ -108,9 +118,10 @@ mutable; `ViewModel` is a derived, serializable read-only projection.
effect
- `view() -> Vec<u8>` — get the current bincode-serialized `ViewModel`

The shell is responsible for deserializing effects and dispatching them. Simple
synchronous effects (like `Render`) are handled inline. Future async effects
(network, disk) will be dispatched to platform APIs and resolved via `resolve()`.
The shell is responsible for deserializing effects and dispatching them. The
synchronous `Render` effect is handled inline; the async `Storage`, `Http`, and
`Download` effects are dispatched to platform APIs and their results delivered
back via `resolve()`.

### iOS shell (`iOS/Pollux/core.swift`)

Expand All @@ -119,8 +130,10 @@ synchronous effects (like `Render`) are handled inline. Future async effects
1. Serializes the `Event` with bincode
2. Calls `CoreFFI.update(data:)`
3. Deserializes the returned `[Request]`
4. Dispatches each `Request` via `processEffect(_:)` — currently only
`.render`, which deserializes the `ViewModel` and publishes it
4. Dispatches each `Request` via `processEffect(_:)`: `.render` deserializes and
publishes the `ViewModel`; `.storage`, `.http`, and `.download` run their
async work and feed the result back through `resolve()`. Storage runs on a
single serial consumer so writes apply in submission order.

SwiftUI views observe `core.view` and re-render automatically.

Expand All @@ -143,8 +156,9 @@ make generate-project → xcodegen --spec iOS/project.yml
regenerates iOS/Pollux.xcodeproj
```

`make ios-build` runs all three in order. Run it whenever `shared/src/app.rs`
changes (new events, new view model fields).
`make ios-build` runs all three in order. Run it whenever the core's public
types change — new `Event`/`Effect` variants, `ViewModel` fields, or capability
operation/result types in `shared/src/`.

Type serialization uses **bincode** (compact binary) at the FFI boundary and
**serde** for all types. `facet` is used to drive type generation.
Expand Down Expand Up @@ -174,7 +188,7 @@ GitHub Actions runs on push and PR to `main`:
- **Rust checks**: `cargo check`, `cargo test`, `cargo clippy -D warnings`,
`cargo fmt --check`, `cargo check --locked`
- **Swift checks**: `swiftlint lint --strict`, `swiftformat --lint`
- **Admin**: forbidden-pattern scan (no no lint suppressions, no `unsafe`, no
- **Admin**: forbidden-pattern scan (no lint suppressions, no `unsafe`, no
force-unwrap calls, no force-try/cast in Swift, etc.)

All CI targets in `.github/workflows/` are reusable `workflow_call` workflows
Expand All @@ -184,14 +198,21 @@ composed by `rust.yml`, `swift.yml`, and `admin.yml`.

## Current State

The core is a counter app (Increment / Decrement / Reset). That is scaffolding
only — the real domain model has not been implemented yet. Feature intent and
priorities are documented in `docs/features/`; the implementation order is in
`docs/ROADMAP.md`.
The MVP foundation is in place: RSS feed parsing and subscribing, a library and
per-feed episode list, and an offline **download manager** (serial queue, live
progress, resume-on-launch, cancel). A base16 **theming** system is wired
through the `ViewModel`. State lives in the Rust core; the iOS shell handles
SQLite (GRDB), HTTP, downloads (URLSession), and rendering.

Still to come (see `docs/ROADMAP.md`): audio playback, playlists (the core
differentiator), a Settings screen, and feed refresh. Sync is out of scope for
MVP and v1.0. Feature intent and priorities live in `docs/features/`.

When adding a new domain feature, the typical change surface is:

1. Extend `Event`, `Model`, `ViewModel`, and/or `Effect` in `shared/src/app.rs`
1. Extend the core types in `shared/src/` — `Event`/`update()`/`view()` in
`app.rs`, plus `model.rs`, `view_model.rs`, `effect.rs`, and `capabilities/`
as needed
2. Run `make ios-build` to regenerate Swift types and rebuild the Xcode project
3. Update SwiftUI views in `iOS/Pollux/` to consume the new `ViewModel` fields
and emit the new `Event` variants
3 changes: 3 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ Rounding out MVP to a complete, shippable state.
- **Storage UI**: usage breakdown by subscription, limit configuration
- **Subscription unsubscribe**: warning with episode count, hard delete,
playlist re-evaluation on removal
- **Download queue management**: downloads page, user re-ordering, and a
persisted enqueue order so the serial queue restores FIFO across relaunch
(today it rebuilds by pub date); groundwork for parallel downloads
- **Subscription-level download rules**: cascading defaults (global → feed)
- **Settings completion**: all known settings wired up, cascading defaults
pattern throughout
Expand Down
201 changes: 60 additions & 141 deletions iOS/Pollux/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ actor DatabaseManager {
t.column("playback_status", .text).notNull().defaults(to: "Unplayed")
t.column("playback_position_secs", .integer).check(sql: "playback_position_secs >= 0")
t.column("download_status", .text).notNull().defaults(to: "NotDownloaded")
t.column("download_progress", .integer).check(sql: "download_progress BETWEEN 0 AND 100")
t.column("is_flagged", .boolean).notNull().defaults(to: false)
t.column("file_size_bytes", .integer).check(sql: "file_size_bytes >= 0")
t.column("local_path", .text)
Expand All @@ -82,6 +81,9 @@ actor DatabaseManager {

@discardableResult
func execute(_ operation: StorageOperation) async throws -> StorageResult {
// Split by domain so neither dispatcher trips the cyclomatic-complexity
// limit as operations are added. Subscription/feed ops here; episode ops in
// `executeEpisode`.
switch operation {
case .listSubscriptions:
try listSubscriptions()
Expand All @@ -91,18 +93,35 @@ actor DatabaseManager {
try await upsertSubscription(sub)
case let .deleteSubscription(id):
try await deleteSubscription(id: id)
case let .upsertFeedWithEpisodes(subscription, episodes):
try await upsertFeedWithEpisodes(subscription: subscription, episodes: episodes)
default:
try await executeEpisode(operation)
}
}

/// Episode-scoped storage operations. `execute` routes everything it does not
/// handle here; the `default` is unreachable (every remaining case is covered).
private func executeEpisode(_ operation: StorageOperation) async throws -> StorageResult {
switch operation {
case let .upsertEpisode(episode):
try await upsertEpisode(episode)
case let .getEpisode(id):
try getEpisode(id: id)
case let .listEpisodesBySubscription(subscriptionId):
try listEpisodesBySubscription(subscriptionId: subscriptionId)
case .listPendingDownloads:
try listPendingDownloads()
case let .getEpisodeByFeedGuid(subscriptionId, feedGuid):
try getEpisodeByFeedGuid(subscriptionId: subscriptionId, feedGuid: feedGuid)
case let .updatePlaybackStatus(episodeId, status, positionSecs):
try await updatePlaybackStatus(episodeId: episodeId, status: status, positionSecs: positionSecs)
case let .upsertFeedWithEpisodes(subscription, episodes):
try await upsertFeedWithEpisodes(subscription: subscription, episodes: episodes)
case let .updateDownloadState(episodeId, status, localPath, sizeBytes):
try await updateDownloadState(
episodeId: episodeId, status: status, localPath: localPath, sizeBytes: sizeBytes,
)
default:
fatalError("executeEpisode received a non-episode operation: \(operation)")
}
}

Expand Down Expand Up @@ -191,6 +210,26 @@ actor DatabaseManager {
return .episodes(rows.map(Self.episode(from:)))
}

private func listPendingDownloads() throws -> StorageResult {
// Known limitation: enqueue order isn't persisted, so a relaunch rebuilds the
// queue by pub_date rather than the order the user queued them (and may start a
// previously-Queued item before the one that was actively Downloading). Fine for
// the serial MVP; revisit when the download queue gains explicit ordering — a
// persisted sequence column, restoring the active item first — alongside the
// downloads page / re-ordering / parallel-downloads work (see ROADMAP Phase 5).
let rows = try db.read { db -> [Row] in
try Row.fetchAll(
db,
sql: """
SELECT * FROM episodes
WHERE download_status IN ('Downloading', 'Queued')
ORDER BY pub_date
""",
Comment thread
AlexAxthelm marked this conversation as resolved.
Resolved
)
}
return .episodes(rows.map(Self.episode(from:)))
}

private func getEpisodeByFeedGuid(subscriptionId: String, feedGuid: String) throws -> StorageResult {
let row = try db.read { db -> Row? in
try Row.fetchOne(
Expand Down Expand Up @@ -220,146 +259,26 @@ actor DatabaseManager {
}
return .success
}
}

// MARK: - Row mapping + status conversion

private extension DatabaseManager {
static func upsertSubscriptionRow(_ subscription: Subscription, db: Database) throws {
try db.execute(
sql: """
INSERT INTO subscriptions
(id, feed_url, title, artwork_url, description, last_refreshed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(feed_url) DO UPDATE SET
title = excluded.title,
artwork_url = excluded.artwork_url,
description = excluded.description,
last_refreshed = excluded.last_refreshed
""",
arguments: [
subscription.id, subscription.feedUrl, subscription.title,
subscription.artworkUrl, subscription.description,
subscription.lastRefreshed, subscription.createdAt,
],
)
}

static func upsertEpisodeRow(_ episode: Episode, subscriptionId: String, db: Database) throws {
let playbackStr = playbackStatusString(episode.playbackStatus)
let downloadStr = downloadStatusString(episode.downloadStatus)
let downloadProgress = episode.downloadProgress.map { Int32($0) }
try db.execute(
sql: """
INSERT INTO episodes
(id, feed_guid, subscription_id, title, description, pub_date,
duration_secs, enclosure_url, artwork_url, playback_status,
playback_position_secs, download_status, download_progress,
is_flagged, file_size_bytes, local_path)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(subscription_id, feed_guid) DO UPDATE SET
title = excluded.title,
description = excluded.description,
enclosure_url = excluded.enclosure_url,
artwork_url = excluded.artwork_url,
pub_date = excluded.pub_date,
duration_secs = excluded.duration_secs,
file_size_bytes = excluded.file_size_bytes
""",
arguments: [
episode.id, episode.feedGuid, subscriptionId,
episode.title, episode.description,
episode.pubDate, episode.durationSecs,
episode.enclosureUrl, episode.artworkUrl,
playbackStr, episode.playbackPositionSecs,
downloadStr, downloadProgress,
episode.isFlagged,
episode.fileSizeBytes.flatMap { Int64(exactly: $0) },
episode.localPath,
],
)
}

static func subscription(from row: Row) -> Subscription {
Subscription(
id: row["id"],
feedUrl: row["feed_url"],
title: row["title"],
artworkUrl: row["artwork_url"],
description: row["description"],
lastRefreshed: row["last_refreshed"],
createdAt: row["created_at"],
)
}

static func episode(from row: Row) -> Episode {
// Checked conversions: a stored value outside the target type's range
// (written by a later schema or external tooling) reads back as nil
// rather than trapping or silently wrapping — matching the write path,
// which stores NULL on overflow (see fileSizeBytes in upsertEpisodeRow).
Episode(
id: row["id"],
feedGuid: row["feed_guid"],
subscriptionId: row["subscription_id"],
title: row["title"],
description: row["description"],
pubDate: row["pub_date"],
durationSecs: (row["duration_secs"] as Int64?).flatMap { UInt32(exactly: $0) },
enclosureUrl: row["enclosure_url"],
artworkUrl: row["artwork_url"],
playbackStatus: playbackStatus(from: row["playback_status"]),
playbackPositionSecs: (row["playback_position_secs"] as Int64?).flatMap { UInt32(exactly: $0) },
downloadStatus: downloadStatus(from: row["download_status"]),
downloadProgress: (row["download_progress"] as Int32?).flatMap { UInt8(exactly: $0) },
isFlagged: row["is_flagged"],
fileSizeBytes: (row["file_size_bytes"] as Int64?).map { UInt64(bitPattern: $0) },
localPath: row["local_path"],
)
}

static func playbackStatusString(_ status: PlaybackStatus) -> String {
switch status {
case .unplayed: "Unplayed"
case .inProgress: "InProgress"
case .played: "Played"
}
}

static func playbackStatus(from string: String) -> PlaybackStatus {
switch string {
case "Unplayed": .unplayed
case "InProgress": .inProgress
case "Played": .played
default: fatalError(
"Unknown PlaybackStatus in DB: '\(string)' — add a case to "
+ "playbackStatus(from:) and playbackStatusString(_:)",
)
}
}

static func downloadStatusString(_ status: DownloadStatus) -> String {
switch status {
case .notDownloaded: "NotDownloaded"
case .queued: "Queued"
case .downloading: "Downloading"
case .downloaded: "Downloaded"
case .failed: "Failed"
case .removedFromFeed: "RemovedFromFeed"
}
}

static func downloadStatus(from string: String) -> DownloadStatus {
switch string {
case "NotDownloaded": .notDownloaded
case "Queued": .queued
case "Downloading": .downloading
case "Downloaded": .downloaded
case "Failed": .failed
case "RemovedFromFeed": .removedFromFeed
default: fatalError(
"Unknown DownloadStatus in DB: '\(string)' — add a case to "
+ "downloadStatus(from:) and downloadStatusString(_:)",
/// Persists a download-state transition. `localPath`/`sizeBytes` are written
/// verbatim — passing nil clears the column to NULL, matching the core, which
/// sends nil to wipe stale file metadata on delete/failure. Size overflow stores
/// NULL (as in `upsertEpisodeRow`) rather than trapping.
private func updateDownloadState(
episodeId: String, status: DownloadStatus, localPath: String?, sizeBytes: UInt64?,
) async throws -> StorageResult {
let statusStr = Self.downloadStatusString(status)
let size = sizeBytes.flatMap { Int64(exactly: $0) }
try await db.write { db in
try db.execute(
sql: """
UPDATE episodes
SET download_status = ?, local_path = ?, file_size_bytes = ?
WHERE id = ?
""",
arguments: [statusStr, localPath, size, episodeId],
)
}
return .success
}
}
Loading
Loading