Lift von Hybrid (WKWebView für Lesen/Erkunden) auf fully-native ist beschlossen. Diese Phase entfernt die WebShell-Infrastruktur; das volle native Read-Surface folgt in η-2..η-5 nach docs/NATIVE_LIFT_PLAN.md. - ManaWebShell-Dep raus aus project.yml - Sources/Core/WebShell/CookieBridge.swift gelöscht - RootView auf vier native Tabs (Lesen + Erkunden = Platzhalter, Submit + Konto unverändert nativ) - DocComments in DeepLinkRouter / AppConfig / Account / Settings von WebView-Verweisen befreit - CLAUDE.md Invarianten von Hybrid auf η umgestellt (13 Invarianten, pure SwiftUI + Offline-first + SafariView-Ausnahme für Legal) - PLAN.md auf η-0 + Phasenübersicht η-0..η-10 - AppConfigTests.test_keychainService_matchesSharedGroup auf ManaSharedKeychainGroup aktualisiert (war drift seit Cross-App-SSO) Verifikation: - xcodebuild iOS-Simulator iPhone 16e: BUILD SUCCEEDED - nm ZitareNative | grep WKWebView: 0 Referenzen - otool -L: kein WebKit-Framework-Link - 20/20 Tests grün Cross-Repo-Follow-up (η-1 Blocker): - zitare/apps/zitare/ muss index-full.json + 7 Stammdaten-JSONs liefern - zitare/apps/api/ Volltext-Search-Endpoint bestätigen/ergänzen Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
306 lines
14 KiB
Markdown
306 lines
14 KiB
Markdown
# Native-Lift Plan — zitare-native (ζ → η)
|
||
|
||
**Stand: 2026-05-22 — Planung.** Drei Antworten von Till liegen vor
|
||
(Offline-first + lokal-first-Suche + SafariView für Legal). Dieser Plan
|
||
ersetzt die Hybrid-Strategie aus dem Greenfield-Playbook
|
||
[`../../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`](../../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md)
|
||
für die Read-Surfaces. Submit / ShareExt / Widget / Spotlight bleiben
|
||
in ihrer schon-nativen Form.
|
||
|
||
> SOT für die Status-Spur: dieses File + `PLAN.md`. Die ζ-Phasen sind
|
||
> historisch (ζ-0/ζ-1/ζ-2 erledigt), die η-Phasen sind der Lift.
|
||
|
||
## Was sich ändert vs. Hybrid
|
||
|
||
| Bereich | Vorher (ζ) | Nachher (η) |
|
||
|---|---|---|
|
||
| Lesen-Tab | `WebShellView` auf `zitare.com` | `QuoteFeedView` (SwiftUI), Daten aus SwiftData |
|
||
| Erkunden-Tab | `WebShellView` auf `zitare.com/explore` | `ExploreView` (SwiftUI) mit Facet-Filter |
|
||
| Quote-Detail | Web-Route `/q/<slug>` | `QuoteDetailView` (nativ), Deep-Link-Target |
|
||
| Author/Source/Thema | Web-Route | Native Detail-Views |
|
||
| Search | `Pagefind` im Web | SwiftData-FTS lokal + API-Fallback |
|
||
| Account | (heute schon Stub) | `AccountView` voll nativ, ManaAuthUI |
|
||
| Impressum/Datenschutz/Lizenz | Web-Routen | `SFSafariViewController`-Sheet auf `zitare.com/<page>` |
|
||
| `ManaWebShell`-Dep | benutzt | **wird komplett entfernt** |
|
||
| `CookieBridge.swift` | benötigt | **entfällt** (kein WebView, kein Cookie-SSO mehr) |
|
||
| Cookie-SSO `mana.access` → WebView | Phase 2.G | **gestrichen** |
|
||
| AASA `applinks` | `zitare.com` + `app.zitare.com` | bleibt; Routen jetzt nativ |
|
||
| Snapshot `index-min.json` | nur Meta (11 Quotes, Slugs+Tags) | **erweitert** auf Volltext + Author-/Source-/Themen-Stammdaten |
|
||
| Datenmodell | nur `SnapshotQuote` (Stub) | volles SwiftData-Schema gespiegelt zu zitare-DB |
|
||
|
||
## Architektonische Invarianten (η)
|
||
|
||
Diese ersetzen die alten Hybrid-Invarianten in `CLAUDE.md`:
|
||
|
||
1. **Pure SwiftUI für alle Surfaces.** Kein `WKWebView` mehr im App-
|
||
Binary. `ManaWebShell`-Dep raus aus `project.yml`.
|
||
2. **Offline-first Lesen.** SwiftData ist Primary-Store, API ist
|
||
Fallback bei Cache-Miss + Sync-Quelle. Flugmodus-Test ist Akzeptanz-
|
||
Kriterium für η-2.
|
||
3. **Quote-Korpus ist Snapshot.** Beim Launch + Background-Refresh
|
||
wird `index-full.json` gepullt, ETag-aware, in SwiftData persistiert,
|
||
per App-Group an Widget + ShareExt + Spotlight durchgereicht.
|
||
4. **Server gewinnt bei Schema-Konflikt.** SwiftData-Modelle sind eine
|
||
abgeleitete Spiegelung des Drizzle-Schemas in `zitare/apps/api/`.
|
||
Bei Drift: Server-Schema, dann Snapshot-Format, dann Native-Modell.
|
||
5. **Search lokal-first.** Lokale FTS aus SwiftData; bei < 3 Treffern
|
||
oder leerem Result Server-Fallback (`GET /api/v1/quotes?q=`).
|
||
6. **Legal-Seiten via SafariView.** Impressum / Datenschutz / Lizenz
|
||
öffnen `SFSafariViewController`. Das ist die **einzige** zugelassene
|
||
Browser-Brücke und nur für statisches Verein-Recht-Material.
|
||
7. **Schreiben bleibt Submit-only in v1.** Edit / Moderation / Flags-
|
||
Verwaltung kommen NICHT in die App. Bleiben Web (`app.zitare.com`).
|
||
Wer das braucht, öffnet Safari.
|
||
8. **Universal-Links auf `zitare.com` lösen ins native Surface.** AASA
|
||
bleibt, aber `onContinueUserActivity` routet auf SwiftUI-Views, nicht
|
||
in einen WebView-Tab.
|
||
9. **Keine Web-Re-Implementation von Web-Funktion.** Wenn eine Read-
|
||
Ansicht in der App fehlt, die im Web existiert: erst Web im SOT
|
||
prüfen, dann nativ nachziehen. Kein einseitiger Native-Vorlauf, der
|
||
das Web abhängt.
|
||
|
||
## Phasen-Plan
|
||
|
||
Erfolgskriterium ist je **eine** Sache. Nicht in die nächste Phase
|
||
arbeiten, bevor die vorherige geschlossen ist.
|
||
|
||
### η-0 — De-Hybrid (Vorbereitung)
|
||
|
||
- `CLAUDE.md` Invarianten 1, 2, 8 ersetzen durch die η-Liste oben.
|
||
- `ManaWebShell`-Dep aus `project.yml` entfernen.
|
||
- `Sources/Core/WebShell/CookieBridge.swift` löschen.
|
||
- `RootView.swift` auf vier native Tabs umstellen (Lesen / Erkunden /
|
||
Einreichen / Konto) — Lesen + Erkunden zeigen erstmal Platzhalter
|
||
(„wird in η-2/η-4 implementiert").
|
||
- `Features/Settings/SettingsView.swift` + `Features/Account/AccountView.swift`
|
||
von WebView-Verweisen befreien (Settings-„Hilfe → öffne Browser"
|
||
bleibt, aber als `SafariView`).
|
||
- Submit / ShareExt / Widget bleiben unangetastet.
|
||
|
||
**Erfolg:** Build grün auf iOS-Simulator, kein `WKWebView`-Symbol im
|
||
Binary (`nm | grep WKWebView` leer), App startet, alle vier Tabs
|
||
sichtbar, Lesen + Erkunden zeigen Platzhalter-View.
|
||
|
||
### η-1 — Snapshot v2 + SwiftData-Schema
|
||
|
||
**Cross-Repo: zitare-web muss liefern, bevor η-2 startet.**
|
||
|
||
Web-Repo-Aufgaben (`../zitare/apps/zitare/`):
|
||
- Build-Script erweitern: `index-full.json` mit allen Quote-Feldern
|
||
inkl. `text`, `language`, `lengthBucket`, `form`, `createdAt`, `author`,
|
||
`source`, `tags[]`, `themes[]`, plus Author-/Source-/Theme-/Place-/
|
||
Epoch-/Role-Stammdaten als eigene Collections (`authors.json`,
|
||
`sources.json`, `themes.json`, `places.json`, `epochs.json`,
|
||
`roles.json`).
|
||
- ETag-Header für jeden dieser Endpoints (Cache-Control: 1h).
|
||
- Stabile URL: `https://zitare.com/index-full.json` (Apex), nicht
|
||
über mana.how.
|
||
- Größen-Budget: Korpus < 5 MB unkomprimiert für die nächsten ~5000
|
||
Quotes (Pagination dann in η-5 nachgedacht, heute 11 Quotes).
|
||
|
||
Native-Aufgaben:
|
||
- SwiftData-Modelle in `Sources/Core/Snapshot/Models/`:
|
||
`SDQuote`, `SDAuthor`, `SDSource`, `SDTheme`, `SDPlace`, `SDEpoch`,
|
||
`SDRole`, `SDTag`. Relations 1:1 mit Drizzle.
|
||
- `SnapshotSync.swift`: lädt parallel die sieben Collections, diffed
|
||
gegen lokalen Store, persistiert atomar.
|
||
- App-Group-Container statt nur-App-Container, damit Widget +
|
||
ShareExt + Spotlight denselben Store lesen.
|
||
- ETag im UserDefaults persistieren, `If-None-Match` schicken.
|
||
|
||
**Erfolg:** Nach Cold-Start lädt App den vollen Korpus, SwiftData-
|
||
Store hat erwartete Row-Counts, ETag spart Bytes beim Reload.
|
||
|
||
### η-2 — Read-Core nativ (Heute, Quote-Detail, Author-Detail)
|
||
|
||
- `QuoteCard`-Komponente (Text, Author, Theme-Chips, lengthBucket).
|
||
- `HeuteView`: deterministisches Tages-Quote (Hash über Datum + Korpus-
|
||
Count). Match-Logik mit Widget abgleichen.
|
||
- `QuoteDetailView` für Deep-Link `/q/<slug>`: Vollansicht, Author-
|
||
Link, Source-Link, Theme-Chips, Quelle-Metadaten, Lizenz-Hinweis.
|
||
- `AuthorDetailView` für `/a/<slug>`: Bio (Name, Native-Name, Places,
|
||
Epochs, Roles), Quote-Liste des Authors.
|
||
- `DeepLinkRouter` umverdrahten: keine WebShell-Targets mehr, sondern
|
||
`NavigationPath`-Push.
|
||
|
||
**Erfolg:** Flugmodus, frische Install, App zeigt Heute-Quote +
|
||
Quote-Detail nach Universal-Link aus Mail-App.
|
||
|
||
### η-3 — Read-Browse (Source, Theme, Place, Role, Epoch, Language)
|
||
|
||
- Detail-Views je Entity mit Listing der zugehörigen Quotes.
|
||
- `SourceDetailView` für `/c/<slug>`.
|
||
- `ThemeListView`, `ThemeDetailView` für `/thema/<slug>`.
|
||
- `PlaceListView` (Regionen) für `/region/<slug>`.
|
||
- Analog Roles / Epochs / Languages.
|
||
- Konsistente `EntityListSection`-Komponente (Title, Count, Chip-Grid).
|
||
|
||
**Erfolg:** Alle Web-Deep-Link-Pattern aus `app-manifest.json` rufen
|
||
die richtige native View auf.
|
||
|
||
### η-4 — Explore + Filter
|
||
|
||
- `ExploreView` als kombinierte Filter-Oberfläche:
|
||
Sprache, Length-Bucket, Form, Theme, Region, Epoche, Role.
|
||
- Filter-State als `@Observable`-Klasse, persistierbar in
|
||
UserDefaults.
|
||
- Live-Result-Counter über SwiftData-`@Query` mit dynamischem
|
||
`Predicate`.
|
||
- iPad: NavigationSplitView mit Filter-Sidebar + Result-List.
|
||
|
||
**Erfolg:** Filter „Roman + DE + lang" zeigt korrekte Subset live,
|
||
ohne Server-Call.
|
||
|
||
### η-5 — Search (lokal + Server-Fallback)
|
||
|
||
- Lokale FTS:
|
||
- Bei Snapshot-Import: pro Quote `normalizedText` (lowercase,
|
||
diakritika-frei) ableiten, in `SDQuote.searchTokens` als
|
||
`[String]` (oder als `String` mit Space-Separator) speichern.
|
||
- Query-Splitting: Whitespace + Diakritika-Normalisierung.
|
||
- SwiftData-Predicate mit `contains` über `searchTokens`.
|
||
- Server-Fallback:
|
||
- Wenn lokaler Result-Count < 3 ODER Query-Länge < 3 Token:
|
||
parallel `GET /api/v1/quotes?q=<query>` (Endpoint muss in
|
||
zitare-api vorhanden sein — prüfen, ggf. ergänzen).
|
||
- Server-Results mit Marker „Online-Treffer" einsortieren, nicht
|
||
persistieren.
|
||
- UI: `SearchView` mit `searchable()`, Result-List mit Source-Marker
|
||
(lokal vs. online), Empty-State.
|
||
|
||
**Erfolg:** Begriff der existiert lokal kommt offline; seltener
|
||
Begriff bringt Online-Treffer dazu, der vom Sync nicht abgedeckt war.
|
||
|
||
### η-6 — Account nativ (vollständige Auth-Surface)
|
||
|
||
- `AccountView` heute hat Health-Status + Sign-In-Button als Stub.
|
||
- Voll bauen: Profil-Info, Gerätesitzungen, Sign-Out-Button, Link auf
|
||
Web-Konto-Verwaltung via SafariView.
|
||
- Eigene Submissions-Liste (`GET /api/v1/me/submissions`, falls
|
||
vorhanden — sonst Cross-Repo-Aufgabe).
|
||
- Keine WebView-Verlinkung mehr.
|
||
|
||
**Erfolg:** Sign-In + Sign-Out vollständig nativ, JWT im Keychain
|
||
verifizierbar.
|
||
|
||
### η-7 — Legal via SafariView
|
||
|
||
- `LegalSheet.swift`: `UIViewControllerRepresentable` um
|
||
`SFSafariViewController` (iOS) / `NSWorkspace.open` (macOS).
|
||
- Drei Links im Settings-Tab: Impressum, Datenschutz, Lizenz.
|
||
- macOS: öffnet System-Browser, kein SafariView dort.
|
||
|
||
**Erfolg:** Tap auf „Impressum" öffnet `https://zitare.com/impressum`
|
||
im SFSafariViewController-Sheet, nicht im App-WebView (gibt's nicht
|
||
mehr).
|
||
|
||
### η-8 — Submit + ShareExt + Spotlight an Snapshot v2 anpassen
|
||
|
||
- Submit-Author-Autocomplete: heute über `ZitareAPI.searchAuthors`
|
||
(live API). Jetzt erstmal lokal aus Snapshot (instant), bei < 3
|
||
Treffern Server-Fallback.
|
||
- ShareExt: sollte schon nativ sein, prüfen ob es WebShell-Imports
|
||
hat (`grep WebShell ShareExtension/`).
|
||
- Spotlight: jetzt mit Volltext indexieren statt nur Meta —
|
||
`searchableItem.contentDescription = quote.text.prefix(200)`.
|
||
|
||
**Erfolg:** Spotlight findet Quote nach Volltext-Fragment, nicht nur
|
||
nach Author/Theme.
|
||
|
||
### η-9 — Polish (iPad-Split, Accessibility, macOS-Layout)
|
||
|
||
- iPad: NavigationSplitView für Quote-Listen.
|
||
- macOS: Window-Layout, Sidebar-NavSplit statt TabBar.
|
||
- Dynamic Type, VoiceOver-Labels für QuoteCard, Theme-Sync mit
|
||
System-Dark-Mode (ohne JS-Bridge, weil ohne WebView).
|
||
- Haptics für Heute-Quote-Refresh.
|
||
|
||
**Erfolg:** Eine Woche Solo-Nutzung ohne offene Browser-Tabs zu
|
||
Zitare-Inhalten.
|
||
|
||
### η-10 — TestFlight
|
||
|
||
- App-Store-Connect: Build hochladen, Review-Notes, Screenshots.
|
||
- Apple-Dev-Portal-Blocker (App-Group) ist hier vermutlich
|
||
längst erledigt; falls nicht: nachziehen vor Upload.
|
||
|
||
**Erfolg:** TestFlight-Tester können Korpus lesen + Quote einreichen.
|
||
|
||
## Cross-Repo-Aufgaben
|
||
|
||
### `../zitare/apps/zitare/` (Web-Build)
|
||
|
||
- [ ] `index-full.json` Build-Script + Endpoint (η-1 Blocker)
|
||
- [ ] Stammdaten-Collections: `authors.json`, `sources.json`,
|
||
`themes.json`, `places.json`, `epochs.json`, `roles.json`,
|
||
`languages.json` (η-1 Blocker)
|
||
- [ ] ETag-Header auf allen Snapshot-Endpoints
|
||
- [ ] AASA bleibt wie heute (kein Change)
|
||
|
||
### `../zitare/apps/api/` (API)
|
||
|
||
- [ ] `GET /api/v1/quotes?q=...` Volltext-Search prüfen — wenn nicht
|
||
vorhanden, ergänzen (Drizzle + PG `to_tsvector` oder einfaches
|
||
`ilike` für Anfang). Search-Quality-Diskussion in η-5.
|
||
- [ ] `GET /api/v1/me/submissions` für AccountView prüfen, ggf.
|
||
ergänzen (η-6).
|
||
- [ ] Wire-Format-Check: Native-Codable-Structs gegen Hono-zod-
|
||
Response in η-2/η-3 abgleichen, Test-Fixtures aus echten
|
||
Server-Responses einchecken.
|
||
|
||
### `../mana/docs/playbooks/ZITARE_NATIVE_GREENFIELD.md`
|
||
|
||
- [ ] Status-Banner ergänzen: Read-Surface-Strategie revidiert
|
||
2026-05-22, jetzt fully-native; SOT für laufende Arbeit ist
|
||
dieses File.
|
||
|
||
### Apple Developer Portal
|
||
|
||
- [ ] App-Group `group.ev.mana.zitare` registrieren — falls noch
|
||
offen aus ζ-0/ζ-2. Selbe Blocker-Lage wie cards/memoro/moodlit.
|
||
|
||
## Offene Punkte
|
||
|
||
- **Snapshot-Größe-Skalierung.** Heute 11 Quotes, Plan ist < 5 MB
|
||
unkomprimiert. Ab welcher Korpus-Größe kippt das? Wahrscheinlich
|
||
> 5000 Quotes. Antwort: Pagination + Sync-Deltas in η-5+. Heute
|
||
irrelevant.
|
||
- **Multilingual FTS.** Lokale Search splittet nach Whitespace, ist
|
||
diakritika-frei. Stemming (dt + en + andere) ist nicht im Plan.
|
||
Falls Such-Qualität schwach: in η-5 nachschärfen oder als „kennen
|
||
wir, später" akzeptieren.
|
||
- **Quote-Text-Formatierung.** Hat ein Quote-Text Markdown-artige
|
||
Zeichen (Zeilenumbrüche, Hervorhebung)? Falls ja: in η-2 mit
|
||
`AttributedString` rendern. Web-SOT prüfen.
|
||
- **macOS-Layout.** Bisher steht macOS auf Apple-Dev-Portal-Blocker.
|
||
Sobald entblockt: SplitView statt TabBar. In η-9.
|
||
- **Submit-Pfad und Online-Pflicht.** Submit braucht Auth + API.
|
||
Wenn offline: queue + retry — ist heute schon so vorgesehen,
|
||
prüfen, ob die Queue noch passt nach SwiftData-Schema-Lift.
|
||
- **Schema-Versions-Tag im Snapshot.** SwiftData-Migrationen für
|
||
künftige Schema-Änderungen brauchen einen Versions-Marker. In η-1
|
||
mit eingeplant (`SchemaV1.swift`), aber Migrations-Strategie ist
|
||
„lokal löschen + neu sync" für v0.x → v1.0.
|
||
- **Was passiert mit `/feed.rss`?** Deep-Link-Pattern existiert.
|
||
Native-Antwort: SafariView oder „nicht in App"-Toast? In η-2
|
||
entscheiden.
|
||
- **`app.zitare.com` Universal-Link.** Heute AASA-Ziel. Wenn die
|
||
SPA dort wegfällt für native Nutzer, brauchen wir AASA dort
|
||
weiterhin (für Web-Fallback ja, für native Routing — als
|
||
identisch zu zitare.com behandeln)? In η-2 routen wir beide
|
||
Domains gleich, AASA bleibt redundant gepflegt.
|
||
- **Web-App auf `app.zitare.com` selbst.** Wenn native der primäre
|
||
Read-Pfad wird, wozu existiert die SPA dann noch? Antwort:
|
||
Browser-Nutzer + nicht-iOS/macOS-User + Moderation/Admin. Aber
|
||
Frage rechtfertigt sich, ob die SPA aktiv weiterentwickelt wird
|
||
oder einfriert. Nicht hier entscheiden, aber in `zitare/STATUS.md`
|
||
diskutieren.
|
||
|
||
## Was außerhalb bleibt
|
||
|
||
- **Push-Pipeline.** Widget reicht.
|
||
- **Eigener Pagefind-Klon.** Lokale FTS ist SwiftData-Predicate,
|
||
nicht Web-Pagefind portiert.
|
||
- **FSRS oder Lernen.** Nicht Zitare-Scope.
|
||
- **Crash-Reporting-SaaS.** OSLog only (Compliance).
|
||
- **Admin / Moderation / Edit in der App.** Bleiben Web.
|