diff --git a/docs/plans/2026-02-20-ios-next-steps.md b/docs/plans/2026-02-20-ios-next-steps.md
index 6f3381f4..f628b3fc 100644
--- a/docs/plans/2026-02-20-ios-next-steps.md
+++ b/docs/plans/2026-02-20-ios-next-steps.md
@@ -10,7 +10,7 @@ Based on the existing build spec (`2026-02-12-ios-companion-app.md`), here's the
- **Existing spec**: A complete Expo + WebView architecture is designed for the iOS companion app.
- **Viewer**: Self-contained HTML/JS viewer (~400KB) already works in any WebView.
- **Architecture**: Fetch bridge intercepts `/api/*` calls → routes to an Expo native Swift module → reads local `.md` files.
-- **Scope**: Read-only companion app. No sync, no accounts, no server.
+- **Scope**: Reader + on-device sync. No accounts, no server. Users can read articles synced from desktop OR fetch new articles directly on iOS.
> **Note**: The iOS app's Swift code (the `FolderAccessModule` Expo native module) is completely separate from the desktop app's Tauri/Rust shell. The desktop app does not use Swift.
@@ -18,7 +18,11 @@ Based on the existing build spec (`2026-02-12-ios-companion-app.md`), here's the
## How Articles Get to Your Phone
-The iOS app is a **reader only** — the desktop PullRead app (Tauri) does all the RSS syncing and article extraction. The `.md` files need to reach your phone via a **shared folder** using a cloud or sync service.
+The iOS app supports **two modes**: reading articles synced from the desktop, and fetching new articles directly on the device. In both cases, articles live as `.md` files in a shared folder.
+
+**Mode 1 — Desktop sync (passive):** The desktop PullRead app (Tauri) does the RSS syncing and article extraction. The `.md` files reach your phone via a cloud/sync service (iCloud Drive, Dropbox, etc.).
+
+**Mode 2 — On-device sync (active):** The iOS app can also fetch RSS feeds and extract articles directly, using the same pure-JS pipeline as the desktop. This runs in the foreground when you tap "Sync Now". Articles written to the shared folder also sync back to desktop.
### How iOS folder access works
diff --git a/docs/plans/seed-ios-app.md b/docs/plans/seed-ios-app.md
new file mode 100644
index 00000000..cd07a0bc
--- /dev/null
+++ b/docs/plans/seed-ios-app.md
@@ -0,0 +1,807 @@
+# PullRead iOS App — Build Specification
+
+> **Purpose**: Drop this file into a fresh LLM session and say "build this." It contains everything needed to create the PullRead iOS app from scratch.
+
+---
+
+## What Is PullRead?
+
+PullRead is an RSS reader that saves articles as local markdown files. The desktop app (macOS, Tauri + Bun) fetches RSS feeds, extracts article content, converts it to markdown, and saves `.md` files to a local folder. A self-contained HTML viewer (~400KB, 14 JS modules + CSS inlined) renders articles in a browser-like reading experience.
+
+The iOS app reuses that same viewer inside a WebView, with a native Swift module for folder access and an optional on-device sync engine.
+
+---
+
+## Architecture
+
+```
+┌──────────────────────────────────────────────────┐
+│ WebView (react-native-webview) │
+│ │
+│ Loads viewer.html (400KB self-contained HTML) │
+│ Viewer JS calls fetch('/api/files'), etc. │
+│ │
+│ Injected JS overrides window.fetch(): │
+│ - /api/* → postMessage to React Native │
+│ - External URLs → original fetch (pass through) │
+│ │
+│ React Native resolves via injectJavaScript() │
+│ → window.__resolveApiFetch(id, status, body) │
+└────────────────────┬─────────────────────────────┘
+ │ postMessage / injectJavaScript
+ ▼
+┌──────────────────────────────────────────────────┐
+│ React Native (Expo) │
+│ │
+│ api-handler.ts routes requests: │
+│ - GET /api/files → native module reads folder │
+│ - GET /api/file?name=X → native module reads md │
+│ - GET /api/config → { configured: true } │
+│ - POST /api/sync-now → on-device sync engine │
+│ │
+│ Native module (Swift): │
+│ - iOS folder picker (UIDocumentPickerVC) │
+│ - Security-scoped bookmarks (persist access) │
+│ - File enumeration + frontmatter parsing │
+│ - File writing (for on-device sync) │
+│ │
+│ Sync engine (TypeScript, same libs as desktop): │
+│ - fast-xml-parser → fetch → Readability → │
+│ Turndown → write .md to shared folder │
+│ - WKWebView cookie auth for paywalled sites │
+└──────────────────────────────────────────────────┘
+```
+
+**Why this works:** The viewer separates data from rendering. All data comes via `fetch('/api/*')`. Injected JS intercepts these and routes them to React Native, which reads/writes local files via a native Swift module.
+
+---
+
+## Project Structure
+
+```
+pullread-mobile/
+├── app/
+│ ├── _layout.tsx — Expo Router root layout
+│ ├── index.tsx — Welcome screen ("Choose Folder")
+│ ├── reader.tsx — Full-screen WebView reader
+│ └── settings.tsx — Feed config, sync, site logins
+├── lib/
+│ ├── fetch-bridge.ts — JS injected into WebView to intercept fetch()
+│ ├── api-handler.ts — Routes API requests to native module
+│ ├── viewer-html.ts — Loads the viewer HTML asset
+│ └── sync/
+│ ├── feed.ts — RSS/Atom feed parsing
+│ ├── extractor.ts — Article extraction
+│ ├── writer.ts — Markdown file generation
+│ └── url-tracker.ts — AsyncStorage deduplication
+├── modules/
+│ └── folder-access/
+│ ├── index.ts — TypeScript interface
+│ ├── expo-module.config.json
+│ └── ios/
+│ └── FolderAccessModule.swift
+├── assets/
+│ └── viewer.html — Pre-built embedded viewer
+├── app.config.ts
+├── package.json
+└── tsconfig.json
+```
+
+---
+
+## Dependencies
+
+```json
+{
+ "name": "pullread-mobile",
+ "version": "1.0.0",
+ "main": "expo-router/entry",
+ "dependencies": {
+ "expo": "~52.0.0",
+ "expo-router": "~4.0.0",
+ "expo-status-bar": "~2.0.0",
+ "expo-asset": "~11.0.0",
+ "expo-file-system": "~18.0.0",
+ "react": "18.3.1",
+ "react-native": "0.76.0",
+ "react-native-webview": "13.12.0",
+ "react-native-safe-area-context": "4.14.0",
+ "react-native-screens": "~4.4.0",
+ "@react-native-async-storage/async-storage": "2.1.0",
+ "@mozilla/readability": "^0.6.0",
+ "fast-xml-parser": "^5.3.3",
+ "linkedom": "^0.18.12",
+ "turndown": "^7.2.2"
+ },
+ "devDependencies": {
+ "@types/react": "~18.3.0",
+ "@types/turndown": "^5.0.5",
+ "typescript": "~5.3.0"
+ }
+}
+```
+
+---
+
+## app.config.ts
+
+```typescript
+import { ExpoConfig } from 'expo/config';
+
+const config: ExpoConfig = {
+ name: 'PullRead',
+ slug: 'pullread-mobile',
+ version: '1.0.0',
+ orientation: 'default',
+ scheme: 'pullread',
+ userInterfaceStyle: 'automatic',
+ ios: {
+ bundleIdentifier: 'com.pullread.mobile',
+ supportsTablet: true,
+ infoPlist: {
+ LSSupportsOpeningDocumentsInPlace: true,
+ UISupportsDocumentBrowser: true,
+ },
+ },
+ plugins: ['./modules/folder-access'],
+};
+
+export default config;
+```
+
+---
+
+## Source Files
+
+### `app/_layout.tsx`
+
+```tsx
+import { Stack } from 'expo-router';
+
+export default function Layout() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+### `app/index.tsx`
+
+```tsx
+import { useEffect, useState } from 'react';
+import { View, Text, TouchableOpacity, StyleSheet, useColorScheme } from 'react-native';
+import { router } from 'expo-router';
+import { StatusBar } from 'expo-status-bar';
+import FolderAccess from '../modules/folder-access';
+
+export default function WelcomeScreen() {
+ const isDark = useColorScheme() === 'dark';
+ const [checking, setChecking] = useState(true);
+
+ useEffect(() => {
+ FolderAccess.restoreFolder()
+ .then((name: string | null) => {
+ if (name) router.replace('/reader');
+ else setChecking(false);
+ })
+ .catch(() => setChecking(false));
+ }, []);
+
+ const handlePickFolder = async () => {
+ try {
+ const folderName = await FolderAccess.pickFolder();
+ if (folderName) router.replace('/reader');
+ } catch (e) {
+ console.error('Folder pick failed:', e);
+ }
+ };
+
+ if (checking) return ;
+
+ return (
+
+
+ PullRead
+
+ Read your PullRead articles on iOS.{'\n'}
+ Point this app at a folder of markdown files.
+
+
+ Choose Folder
+
+
+ Works with iCloud Drive, Dropbox, Syncthing,{'\n'}or any Files provider.
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32, backgroundColor: '#fff' },
+ dark: { backgroundColor: '#1a1a1a' },
+ title: { fontSize: 36, fontWeight: '700', marginBottom: 12, color: '#000' },
+ subtitle: { fontSize: 16, textAlign: 'center', marginBottom: 32, lineHeight: 24, color: '#444' },
+ hint: { fontSize: 13, textAlign: 'center', marginTop: 20, color: '#888', lineHeight: 20 },
+ textLight: { color: '#fff' },
+ textMuted: { color: '#999' },
+ button: { backgroundColor: '#2563eb', paddingHorizontal: 28, paddingVertical: 14, borderRadius: 10 },
+ buttonText: { color: '#fff', fontSize: 17, fontWeight: '600' },
+});
+```
+
+### `app/reader.tsx`
+
+```tsx
+import { useRef, useState, useEffect } from 'react';
+import { View, StyleSheet, Linking } from 'react-native';
+import { WebView, WebViewMessageEvent } from 'react-native-webview';
+import { StatusBar } from 'expo-status-bar';
+import { FETCH_BRIDGE_JS } from '../lib/fetch-bridge';
+import { handleApiRequest } from '../lib/api-handler';
+import { getViewerHtml } from '../lib/viewer-html';
+
+export default function ReaderScreen() {
+ const webViewRef = useRef(null);
+ const [html, setHtml] = useState(null);
+
+ useEffect(() => { getViewerHtml().then(setHtml); }, []);
+
+ const onMessage = async (event: WebViewMessageEvent) => {
+ let msg;
+ try { msg = JSON.parse(event.nativeEvent.data); } catch { return; }
+ if (msg.type !== 'api') return;
+
+ const { id, url, method } = msg;
+ const result = await handleApiRequest(url, method);
+ const escaped = JSON.stringify(result.body);
+ webViewRef.current?.injectJavaScript(
+ `window.__resolveApiFetch(${id},${result.status},${JSON.stringify(result.contentType)},${escaped});true;`
+ );
+ };
+
+ if (!html) return ;
+
+ return (
+
+
+ {
+ if (request.url === 'about:blank' || request.url.startsWith('about:srcdoc')) return true;
+ if (request.url.startsWith('http://') || request.url.startsWith('https://')) {
+ Linking.openURL(request.url);
+ return false;
+ }
+ return true;
+ }}
+ originWhitelist={['*']}
+ javaScriptEnabled={true}
+ domStorageEnabled={true}
+ allowsInlineMediaPlayback={true}
+ contentMode="mobile"
+ textInteractionEnabled={true}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: '#fff' },
+ webview: { flex: 1 },
+});
+```
+
+### `lib/fetch-bridge.ts`
+
+Injected **before** viewer JS executes. Overrides `window.fetch` so `/api/*` goes through `postMessage`.
+
+```typescript
+export const FETCH_BRIDGE_JS = `
+(function() {
+ var _originalFetch = window.fetch;
+ var _pending = {};
+ var _nextId = 0;
+
+ window.fetch = function(input, init) {
+ var url = typeof input === 'string' ? input : (input && input.url ? input.url : '');
+ var method = (init && init.method) ? init.method.toUpperCase() : 'GET';
+
+ if (url.startsWith('/api/') || url.startsWith('/favicons/')) {
+ return new Promise(function(resolve, reject) {
+ var id = ++_nextId;
+ _pending[id] = { resolve: resolve, reject: reject };
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ type: 'api', id: id, url: url, method: method,
+ body: (init && init.body) ? init.body : null
+ }));
+ setTimeout(function() {
+ if (_pending[id]) { delete _pending[id]; reject(new Error('Timeout: ' + url)); }
+ }, 30000);
+ });
+ }
+ return _originalFetch.apply(this, arguments);
+ };
+
+ window.__resolveApiFetch = function(id, status, contentType, body) {
+ var p = _pending[id];
+ if (!p) return;
+ delete _pending[id];
+ p.resolve(new Response(body, { status: status, headers: { 'Content-Type': contentType } }));
+ };
+
+ window.PR_IOS = true;
+})();
+true;
+`;
+```
+
+**Critical**: Must use `injectedJavaScriptBeforeContentLoaded` — the viewer calls `fetch('/api/files')` during init.
+
+### `lib/api-handler.ts`
+
+```typescript
+import FolderAccess from '../modules/folder-access';
+
+interface ApiResponse { status: number; contentType: string; body: string; }
+
+export async function handleApiRequest(url: string, method: string): Promise {
+ if (method !== 'GET') return json(405, { error: 'Read-only viewer' });
+
+ try {
+ if (url === '/api/files') return json(200, await FolderAccess.listMarkdownFiles());
+ if (url.startsWith('/api/file?name=')) {
+ const name = decodeURIComponent(url.split('name=')[1]);
+ if (name.includes('..') || name.includes('/')) return json(400, { error: 'Invalid filename' });
+ return { status: 200, contentType: 'text/plain', body: await FolderAccess.readFile(name) };
+ }
+ if (url === '/api/files-changed') return json(200, { changedAt: await FolderAccess.getFolderModTime() });
+
+ // CRITICAL: configured:true prevents onboarding wizard
+ if (url === '/api/config') return json(200, { feeds: {}, configured: true });
+ if (url === '/api/tts-settings') return json(200, { provider: 'browser' });
+ if (url === '/api/sync-status') return json(200, { syncInterval: 'manual' });
+ if (url === '/api/highlights' || url.startsWith('/api/highlights?')) return json(200, {});
+ if (url === '/api/notes') return json(200, {});
+ if (url.startsWith('/api/notes?')) return json(200, { annotations: [], tags: [], isFavorite: false });
+ if (url === '/api/notebooks') return json(200, []);
+ if (url === '/api/settings') return json(200, {});
+ if (url.startsWith('/favicons/')) return { status: 404, contentType: 'text/plain', body: '' };
+ return json(404, { error: 'Not found' });
+ } catch (e: any) {
+ return json(500, { error: e.message || 'Internal error' });
+ }
+}
+
+function json(status: number, data: any): ApiResponse {
+ return { status, contentType: 'application/json', body: JSON.stringify(data) };
+}
+```
+
+### `lib/viewer-html.ts`
+
+```typescript
+import { Asset } from 'expo-asset';
+import * as FileSystem from 'expo-file-system';
+
+let cached: string | null = null;
+
+export async function getViewerHtml(): Promise {
+ if (cached) return cached;
+ const asset = Asset.fromModule(require('../assets/viewer.html'));
+ await asset.downloadAsync();
+ if (!asset.localUri) throw new Error('Failed to load viewer.html asset');
+ cached = await FileSystem.readAsStringAsync(asset.localUri);
+ return cached;
+}
+```
+
+### `modules/folder-access/index.ts`
+
+```typescript
+import { requireNativeModule } from 'expo-modules-core';
+
+export interface FileMeta {
+ filename: string;
+ title: string;
+ url: string;
+ domain: string;
+ bookmarked: string;
+ feed: string;
+ author: string;
+ mtime: string;
+ hasSummary: boolean;
+ summaryProvider: string;
+ summaryModel: string;
+ excerpt: string;
+ image: string;
+ enclosureUrl: string;
+ enclosureType: string;
+ enclosureDuration: string;
+}
+
+interface FolderAccessModule {
+ pickFolder(): Promise;
+ restoreFolder(): Promise;
+ listMarkdownFiles(): Promise;
+ readFile(filename: string): Promise;
+ writeFile(filename: string, content: string): Promise;
+ getFolderModTime(): Promise;
+ clearFolder(): Promise;
+}
+
+export default requireNativeModule('FolderAccess');
+```
+
+### `modules/folder-access/expo-module.config.json`
+
+```json
+{ "platforms": ["ios"], "ios": { "modules": ["FolderAccessModule"] } }
+```
+
+### `modules/folder-access/ios/FolderAccessModule.swift`
+
+```swift
+import ExpoModulesCore
+import UIKit
+import UniformTypeIdentifiers
+
+public class FolderAccessModule: Module {
+ private static let bookmarkKey = "pullread_folder_bookmark"
+
+ public func definition() -> ModuleDefinition {
+ Name("FolderAccess")
+
+ AsyncFunction("pickFolder") { (promise: Promise) in
+ DispatchQueue.main.async {
+ guard let windowScene = UIApplication.shared.connectedScenes
+ .compactMap({ $0 as? UIWindowScene }).first,
+ let rootVC = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController else {
+ promise.reject("NO_VC", "No root view controller found")
+ return
+ }
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
+ picker.allowsMultipleSelection = false
+ let delegate = PickerDelegate { url in
+ guard let url = url else { promise.resolve(nil as String?); return }
+ do {
+ let bookmark = try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil)
+ UserDefaults.standard.set(bookmark, forKey: FolderAccessModule.bookmarkKey)
+ promise.resolve(url.lastPathComponent)
+ } catch { promise.reject("BOOKMARK", error.localizedDescription) }
+ }
+ objc_setAssociatedObject(picker, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN)
+ picker.delegate = delegate
+ rootVC.present(picker, animated: true)
+ }
+ }
+
+ AsyncFunction("restoreFolder") { () -> String? in
+ guard let url = Self.resolveBookmark() else { return nil }
+ return url.lastPathComponent
+ }
+
+ AsyncFunction("listMarkdownFiles") { () -> [[String: Any]] in
+ guard let folderURL = Self.resolveBookmark() else {
+ throw NSError(domain: "FolderAccess", code: 1, userInfo: [NSLocalizedDescriptionKey: "No folder selected"])
+ }
+ let fm = FileManager.default
+ let keys: [URLResourceKey] = [.contentModificationDateKey, .isRegularFileKey]
+ guard let items = try? fm.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: keys) else { return [] }
+
+ let isoFormatter = ISO8601DateFormatter()
+ var results: [[String: Any]] = []
+
+ for item in items {
+ guard item.pathExtension == "md" else { continue }
+ guard let values = try? item.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { continue }
+
+ let filename = item.lastPathComponent
+ let mtime = values.contentModificationDate ?? Date(timeIntervalSince1970: 0)
+
+ guard let handle = try? FileHandle(forReadingFrom: item) else { continue }
+ let headData = handle.readData(ofLength: 3072)
+ handle.closeFile()
+ guard let head = String(data: headData, encoding: .utf8) else { continue }
+
+ let meta = Self.parseFrontmatter(head)
+
+ var image = ""
+ if let fmEndRange = head.range(of: "\n---\n") {
+ let body = String(head[fmEndRange.upperBound...])
+ if let match = body.range(of: #"!\[.*?\]\((https?://[^)]+)\)"#, options: .regularExpression) {
+ let full = String(body[match])
+ if let lp = full.firstIndex(of: "("), let rp = full.lastIndex(of: ")") {
+ image = String(full[full.index(after: lp)..