truth-seeker

← Home · ~/truth-seeker · updated yesterday

truth-seeker

Native iOS app that unifies Mark's three reading surfaces — the newsfeed digest, the daily arxiv paper pick, and the Feller study guide — into one installable app with offline reading, a home-screen widget, push, and a share-sheet "read later" target.

Status: P1 (MVP) in development. P2/P3 planned (see Phasing).

Scope

One app, three tabs, hybrid architecture: a native SwiftUI shell (tab bar, navigation, offline cache, widget, push, share-sheet) wrapping the rich web surfaces that webpage-server already serves, plus native SwiftUI for the one surface that has no web frontend (Papers).

The shell is what makes this a real app and not the PWA it replaces: a shipped .ipa with home-screen presence, offline snapshots, a widget, push, and share-sheet ingest. The reading views themselves reuse the existing, working web frontend instead of being rebuilt in Swift.

In scope (the app is a client): - Surface the three reading streams in one native shell. - Offline snapshot of the last-loaded News + Study pages. - Native Papers tab (the digest has no web surface today — this app gives it one).

Out of scope: - No rewrite of the three content backends. They stay exactly as they are (newsfeed pipeline, daily-paper-digest launchd job, Feller renderer). - No new server. The single backend is the existing webpage-server, extended by one endpoint (/api/papers). - Pure-native reading views (SwiftUI re-implementations of infinite scroll / swipe / streaming reader / a math renderer for Feller). Explicitly rejected for v1 — it's 3–4× the work and forks the frontend in two. Revisit per-tab in P3 only if a webview proves insufficient.

Architecture

iPhone — Truth Seeker (SwiftUI shell, bottom tab bar)
  ├─ News   → WKWebView  ──┐
  ├─ Study  → WKWebView  ──┤   HTTPS over Tailscale Funnel
  └─ Papers → native list ─┘            │
                                        ▼
                       webpage-server.py (:8899, existing)
                         ├─ /newsfeed.html            (existing)
                         ├─ /<feller-uuid>.html       (existing)
                         └─ GET /api/papers           (NEW, P1)
                                        │
                                        ▼
              existing content backends (unchanged):
              newsfeed pipeline · daily-paper-digest history.json · Feller renderer
Tab Surface How
News newsfeed.html WKWebView over the Funnel URL. Inherits swipe feedback, infinite scroll, streaming reader + ElevenLabs audio, accent theming — all free.
Papers daily arxiv picks Native SwiftUI list from GET /api/papers (reads history.json). Row = hook + date + source; tap opens the arxiv URL (in-app SFSafariViewController).
Study Feller Vol. II library Native SwiftUI NavigationStack (Book → Chapter → {Verbatim, Study Guide}); each leaf is a WKWebView loading an app-bundled HTML file with vendored KaTeX — fully offline, no Funnel. Native math rendering is a tar pit — deliberately avoided; KaTeX is bundled instead.

Offline: the shell snapshots the last successfully-loaded News and Study HTML (+ critical assets) to the app container and serves them when the network is down or the page fails to load. Papers caches the last /api/papers JSON.

Data flow is one-way pull (app → webpage-server). The only writes are the ones the web pages themselves already make (swipe feedback, TTS) via their embedded token — the native shell adds no new write paths in P1.

Study tab — native, offline, app-bundled

Reversal note: the Study tab was a WKWebView pointed at a hand-authored Funnel index page (Settings.studyPathac54e507-…html, linking the 6c01966b-… verbatim and 99cc1652-… study-guide leaves). That is gone. Study is now a native SwiftUI NavigationStack (Book → Chapter → leaf) whose leaves are HTML files compiled into the app and rendered with vendored KaTeX — zero network, works on a plane. Settings.studyPath/studyURL and the web-only study/verify.sh ship gate were removed; the ship gate is now the swift-testing StudyTests suite. The three Funnel UUID pages are no longer used by the app (they can be deleted from ~/www whenever convenient).

The hierarchy the app shows comes from a hand-authored manifest; the leaf bodies come from the render pipeline below:

Node Source of truth Notes
Manifest content/feller-vol-2/build/study-manifest.json Book → Chapter → leaf tree; decoded by StudyLibrary. Hand-authored — add a book/chapter/leaf here.
Verbatim content/feller-vol-2/ch01/chapter.md Feller Vol. II Ch. 1, page-faithful transcription (pp. 1–44). Rendered to build/feller-vol-2-verbatim.html.
Study Guide content/feller-vol-2/ch01/study-guide.md Concepts + worked intuition. Rendered to build/feller-vol-2-study-guide.html.

Study content pipeline

The Feller renderer lives here now (content/feller-vol-2/), promoted from ~/reading/feller-vol-2/. render.py turns a chapter/study-guide .md into a self-contained, offline HTML leaf:

cd content/feller-vol-2
python3 render.py --md ch01/chapter.md \
  --html build/feller-vol-2-verbatim.html \
  --title 'Feller Vol. II — Ch. 1 (Verbatim)' --header 'Chapter 1 · Verbatim' \
  --no-feedback-token
python3 render.py --md ch01/study-guide.md \
  --html build/feller-vol-2-study-guide.html \
  --title 'Feller Vol. II — Ch. 1 (Study Guide)' --header 'Chapter 1 · Study Guide' \
  --no-feedback-token
  • Vendored KaTeX 0.16.11 under content/feller-vol-2/katex/ (katex.min.css, katex.min.js, auto-render.min.js, fonts/*.woff2). render.py's KATEX_HEAD references these by relative path (katex/…) — no CDN — so the leaf renders math fully offline once the leaf sits beside katex/ in the bundle.
  • --no-feedback-token bakes an empty feedback token, which makes the Ask-Claude pill self-disable client-side (there's no Mac to POST to offline). --feedback-token <tok> overrides; the default still mints the rotating token.
  • The rendered leaves and study-manifest.json are committed (build/*), so a fresh checkout builds without a render step.

Updating Study content

  1. Edit the source .md under content/feller-vol-2/ch01/ (or add a chapter).
  2. Re-run the two render.py commands above → refreshes build/*.html.
  3. If the hierarchy changed (new chapter/leaf/book), edit build/study-manifest.json to match.
  4. ./content/feller-vol-2/sync-to-bundle.sh — copies build/* and katex/ into ios/TruthSeeker/Resources/Study/ (the folder-reference the app bundles).
  5. cd ios && ./test.shStudyTests is the ship gate (manifest decodes, every leaf is bundled, no remote src=/href=, KaTeX js/css/fonts bundled).
  6. Ship an OTA build (the leaves are in-bundle, so a content change needs a rebuild — unlike the old web-only deploy).

Backend addition (webpage-server)

One new endpoint, landed in the sibling webpage-server/ project (not here):

  • GET /api/papers → JSON array from ~/Library/Application Support/daily-paper-digest/history.json, newest first. Each entry: {date, arxiv_id, title, url, hook?}. Read-only, public (content is public arxiv links already), X-Robots-Tag: noindex. No token — consistent with the public HTML pages behind Funnel.

daily-paper-digest delivers its daily pick via an APNs push to the Papers tab (see Push notifications); bb-send is retained solely for the failure alert when all attempts are exhausted. /api/papers just exposes the history it already writes. (If a hook per paper is wanted in the list, that's a small additive write in paper-digest.sh — note as a P1 sub-task, not a blocker; the arxiv title is a fine fallback.)

iOS project layout

Mirrors Riff's ios/ conventions (xcodegen, xcconfig secrets, Makefile):

truth-seeker/
  README.md                 ← this spec (canonical)
  install.sh                ← symlinks the two deploy skills into ~/.claude/skills/
  verify.sh                 ← migration ship-gate (de-leak + skill cutover + build)
  content/
    feller-vol-2/
      ch01/                 ← chapter.md, study-guide.md, pages/*.tex (transcription source)
      katex/                ← vendored KaTeX 0.16.11 (css/js/auto-render + fonts/*.woff2)
      render.py             ← .md → self-contained offline HTML leaf (local KaTeX, --no-feedback-token)
      build/                ← rendered leaves + study-manifest.json (committed)
      sync-to-bundle.sh     ← copy build/* + katex/ → ios/.../Resources/Study/
  skills/
    truth-seeker-ota/       ← /truth-seeker-ota   skill (OTA dev build → Funnel page)
    truth-seeker-update/    ← /truth-seeker-update skill (cabled devicectl deploy)
  ios/
    project.yml             ← xcodegen; bundleIdPrefix: mark
    TruthSeeker.xcconfig            ← host URL etc. (gitignored)
    TruthSeeker.xcconfig.example    ← template (committed)
    Makefile                ← generate / build / deploy helpers
    ExportOptions.plist     ← TestFlight export (method=app-store-connect, P2+)
    ExportOptions-adhoc.plist  ← OTA export (development-signed; /truth-seeker-ota)
    TruthSeeker/            ← app sources, Info.plist, entitlements, assets
      Study/                ← native Study tab: manifest/library/list/leaf views
      Resources/Study/      ← folder-reference bundled into the app: leaves + katex/ (synced)
    TruthSeekerWidget/      ← widget extension (P2)
    TruthSeeker.xcodeproj/  ← generated by xcodegen (gitignored)
  • Bundle id mark.truthseeker; app group group.mark.truthseeker (widget data sharing, P2). Deployment target iOS 17, automatic signing, team 6C63UU27YB (same as Riff).
  • The Funnel base URL is injected via TruthSeeker.xcconfig ($(...) into Info.plist), read at runtime — never hard-coded into a distributed build (mirror Riff's /riff-publish secret guard; the .example ships a placeholder).
  • Standalone repo (extracted from ~/agents 2026-05-30). This is its own git repo at ~/truth-seeker. install.sh symlinks the two deploy skills (skills/truth-seeker-{ota,update}) into ~/.claude/skills/; verify.sh is the migration ship gate (it runs make sim). The unit suite (ios/test.sh, added with the push feature) is the per-change test gate — see Testing. The only residual coupling is the shared ~/agents/webpage-server, which serves the OTA payload at ~/www/truth-seeker-ota/ via its generic /<slug>-ota/ route (shared infra, like the day-trading /trade dashboard — not a leak).

Phasing

  • P1 (MVP) — app scaffold (mirror Riff), bottom tab bar, News + Study WKWebViews, native Papers tab, GET /api/papers in webpage-server, offline snapshot, app icon + launch screen, on-device deploy. This is the approvable first build.
  • P2 — home-screen widget (today's paper hook + unread newsfeed count via the app group), share-sheet extension → read-later queue, TestFlight publish. Push is done (see Push notifications): native APNs for the daily paper digest (→ Papers tab) and the newsfeed refresh (→ News tab), reusing Riff's apns.py.
  • P3 — polish: pull-to-refresh, native reading settings (font size), read- later surface; selectively nativize the News tab only if the webview ever feels insufficient.

Push notifications

Native APNs pushes for the two scheduled content events, each tap routing to the right tab. Reuses Riff's already-working push stack — same team-wide .p8 (6C63UU27YB), same apns.py client (imported, not copied) — only the apns-topic is mark.truthseeker.

Event Fired by Tap opens
New daily paper digest (~12:00) daily-paper-digest.sh shells out to the sender CLI after recording a fresh pick Papers tab
Newsfeed refresh (08:00 + 18:00) newsfeed.py _run_pipeline() calls the sender in-process after publish News tab

Up to 3 pushes/day (2 newsfeed + 1 paper) — intended; no throttle.

daily-paper-digest.sh ─CLI──┐
                            ├─► truth_seeker_push.py ─► APNsClient ─► APNs ─► iPhone ─► tap ─► tab
newsfeed.py ─import─────────┘            ▲
                                         │ reads
                       push-tokens.json (App Support)  ◄── POST /api/register-push ◄── app on launch
  • Server side lives in ~/agents (with the two producers), not this repo: ~/agents/truth-seeker-push/ (token_store.py + truth_seeker_push.py) and the POST /api/register-push endpoint on webpage-server. Token store: ~/Library/Application Support/truth-seeker/push-tokens.json (atomic, 64-hex validated, capped, self-prunes on APNs 410). Sender is best-effort — a push failure never breaks a producer.
  • iOS side (this repo): AppDelegate.swift (token capture + best-effort POST to /api/register-push, skipped when unchanged; banner + tap routing), NotificationRouter.swift (pure tab(for:) payload→tab map, unit-tested), TruthSeeker.entitlements (aps-environment = development), the inline entitlements: block in project.yml, and TruthSeekerApp/ContentView wiring (prompt on launch, badge clear on foreground, selectTab → tab).
  • development / sandbox, not production. The OTA build is dev-signed, so its token is only valid against the APNs sandbox host — the sender defaults to env="sandbox" to match. Flip both (aps-environment = production + sender --prod) only for a future TestFlight build. A dev token sent to the prod host silently never arrives — the #1 "push didn't show up" bug; check ~/Library/Logs/truth-seeker-push.log for the APNs status/reason.
  • One-time provisioning gate. mark.truthseeker had no Push capability, so the first signed/OTA build needs a one-time Xcode-GUI enable: open TruthSeeker.xcodeproj → target → Signing & Capabilities → + Capability → Push Notifications, let Xcode register push on the App ID + regenerate the managed profile, then resume headless OTA. Simulator unit tests don't need it.

Testing

ios/test.sh runs the swift-testing unit suite on the simulator (mirrors Riff's ios/test.sh). Green = exit 0 + ** TEST SUCCEEDED **.

cd ~/truth-seeker/ios && ./test.sh                 # default sim (iPhone 17 Pro)
SIM='iPhone 17' ./test.sh                           # override the sim
  • TruthSeekerTests/NotificationRouterTests covers the push payload→tab routing (NotificationRouter.tab(for:)) and guards that AppTab and ContentView.Tab keep identical raw values.
  • The server side has its own pytest suites under ~/agents (truth-seeker-push/tests/, webpage-server/tests/test_register_push.py).
  • verify.sh remains the migration/self-containment gate (make sim build).

Deploy

Two install paths, symmetric to how Riff's /riff-ota and /riff-update relate: an OTA path (off-LAN, the preferred remote install) and a cabled fallback (devicectl over USB).

OTA deploy path (/truth-seeker-ota)

/truth-seeker-ota builds an ad-hoc (method=development) .ipa and publishes .ipa + manifest.plist + install.html to ~/www/truth-seeker-ota/, served over the Tailscale Funnel. Install by opening the install page in Safari and tapping Install Truth Seeker — off-LAN, over cellular, no cable, no devicectl. (--release flips Debug→Release; --no-send skips the iMessage.)

  • Unlike Riff, the OTA build bakes the (public) FUNNEL_HOST and carries no secret, so there is no secret-less guard and no onboarding step — the OTA build is the production build, host included.
  • Open the install page in Safari, not the raw itms-services link (SpringBoard intercepts the raw link; tapping it in Messages/Mail does nothing).
  • Dev-profile expiry self-heals. A build signed with an expired development profile installs but won't launch; re-running /truth-seeker-ota re-signs.
  • webpage-server MIME/Range dependency: the manifest must be served as text/xml and the .ipa with Range/206 — the generic /<slug>-ota/... route (see webpage-server/README.md). Kick the server after a handler change.
  • ExportOptions-adhoc.plist (method=development) lives in ios/ alongside the TestFlight ExportOptions.plist (method=app-store-connect, P2).

Cabled fallback (/truth-seeker-update)

cd ~/truth-seeker/ios
xcodegen generate          # regenerate the project from project.yml
# build Debug, install to the iPhone over devicectl (USB reliable; the
# wireless tunnel drops even on wifi — keep a cable handy)
  • Build without the device attached; an auto-installer polls for a reachable phone (Riff pattern — reference_riff_deploy_install_channel).
  • Install-over, never clean-uninstall for an already-installed app (reference_riff_install_over_not_uninstall).
  • /truth-seeker-update mirrors /riff-update; it's the cabled fallback when the wireless tunnel is flaky (USB reliable).
  • TestFlight via altool + ExportOptions.plist is P2 (/riff-publish pattern), and only if Mark wants others to have it — personal use needs only the device install.

Config & secrets

  • TruthSeeker.xcconfig (gitignored) holds the Funnel host; .example is the committed template. Same split Riff uses for MAC_MINI_HOST / RIFF_SHARED_SECRET.
  • No new secrets in git. The repo .gitignore already blocks *.xcconfig-class patterns — confirm the truth-seeker xcconfig is covered when scaffolding.

Gotchas

  • iCloud Private Relay breaks Funnel pages (reference_icloud_private_relay_funnel). Both WiFi and cellular fail the same TLS error under Private Relay. The WKWebView surfaces will fail to load for the same reason Safari does — the offline snapshot is the mitigation, and a clear "can't reach your Mac" state beats a blank webview.
  • SourceKit/editor diagnostics are noise (reference_riff_sourcekit_noise). Trust xcodebuild, not editor squiggles.
  • codesign over SSH needs in-session keychain unlock (reference_riff_codesign_keychain_ssh) — security unlock-keychain -p inline in the same shell.
  • Mac pip3 is broken (libexpat) (reference_mac_pip_libexpat_broken). If any install.sh here grows a pip step, expect it to die — keep deploy paths pip-free (iOS build doesn't need it).
  • Trunk-based, no worktrees (user_trunk_based_no_worktrees). Build on the shared branch; do not spin up git worktrees for this.
  • App Store "minimum functionality" (4.2) — a pure webview wrapper gets rejected. The native tab bar, Papers tab, offline, widget, and share-sheet are what make this a legitimate app. (Irrelevant for personal device install; matters only if P2 goes to TestFlight/App Store.)

Dependencies

  • Existing, unchanged: webpage-server (backend), newsfeed, daily-paper-digest.
  • Vendored in-repo: the Feller renderer (content/feller-vol-2/, promoted from ~/reading/feller-vol-2/) and KaTeX 0.16.11 (content/feller-vol-2/katex/).
  • New: the iOS app target(s) under ios/, and the one /api/papers endpoint in webpage-server.
  • Toolchain: xcodegen, Xcode, a development-signed Apple account (team 6C63UU27YB), a USB cable for reliable device install. render.py needs Python 3 + the markdown package (already installed; do not pip).