pageta-native/Sources/App/PagetaNativeApp.swift
Till JS 99967b5531 Theme-Pass Pilot: pageta verdrahtet (Unlock-Gating live)
appSlug "pageta" + ThemePassStore (@Observable): markiert beim Start die
eigene App als besucht (Keychain cross-app + Server best-effort) und holt
die triedApps-Union. PagetaThemeGallery reicht unlocked = allCases.filter{
isUnlocked(triedApps:)} + onLockedTap (öffnet mana-ev.ch/apps/<slug>) in die
geteilte Galerie. Baseline + paper(=pageta-Signatur) sofort frei, Rest gesperrt
bis die jeweilige App ausprobiert wird. Keychain-Pfad sofort aktiv; cross-device
nach mana-auth-Deploy. Build grün, on-device verifiziert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:32:26 +02:00

291 lines
9.6 KiB
Swift

import ManaAuthUI
import ManaCore
import ManaLLM
import ManaThemeUI
import ManaTokens
import OSLog
import SwiftUI
/// Logger-Namespace für Pageta-Native.
enum Log {
static let app = Logger(subsystem: "ev.mana.pageta", category: "app")
static let api = Logger(subsystem: "ev.mana.pageta", category: "api")
static let auth = Logger(subsystem: "ev.mana.pageta", category: "auth")
}
@main
struct PagetaNativeApp: App {
@State private var auth: AuthClient
@State private var authGate: ManaAuthGate
@State private var themePass: ThemePassStore
@State private var presentedRoute: PresentedRoute?
@State private var shareSignal = PendingShareSignal()
@State private var presentingSignUp = false
@State private var presentingForgot = false
@AppStorage(ManaAppearanceMode.storageKey) private var appearanceRaw: String = ManaAppearanceMode
.system.rawValue
@AppStorage(ManaTheme.storageKey) private var variantRaw: String = ManaTheme.pagetaDefault
.rawValue
@Environment(\.scenePhase) private var scenePhase
private let pendingShares = PendingShareStore()
private var appearance: ManaAppearanceMode {
ManaAppearanceMode(rawValue: appearanceRaw) ?? .system
}
/// Aktive Theme-Variant. Treibt Tint + Brand + `.manaTheme`-Environment.
/// Wechsel re-rendert den ganzen Baum (alle `PagetaTheme.*`-Reads).
private var theme: ManaTheme {
ManaTheme(rawValue: variantRaw) ?? .paper
}
init() {
// Lokale LLMs nutzen den geteilten App-Group-Container
// (group.ev.mana.models) für den HF-Cache — `configure` setzt
// die HF_HUB_CACHE-Env-Variable. Wenn das Entitlement fehlt
// (sollte nicht), fällt der Helper auf den App-eigenen
// Application-Support-Pfad zurück. Idempotent.
ManaLLM.configure()
let auth = AuthClient(config: AppConfig.manaAppConfig)
auth.bootstrap()
_auth = State(initialValue: auth)
_authGate = State(initialValue: ManaAuthGate(auth: auth))
_themePass = State(initialValue: ThemePassStore(auth: auth, config: AppConfig.manaAppConfig))
// π-11: Event-Sync-Coordinator initialisieren, sobald `auth`
// existiert. Configure ist idempotent + startet pull+drain im
// Hintergrund. User-owned-CRUD läuft ab jetzt gegen sync2.mana.how.
EventSyncCoordinator.configure(auth: auth)
Log.app
.info(
"Pageta starting — auth status: \(String(describing: auth.status), privacy: .public)"
)
}
var body: some Scene {
WindowGroup {
RootView()
.environment(auth)
.environment(authGate)
.environment(shareSignal)
.environment(themePass)
.task { await themePass.bootstrap() }
.tint(PagetaTheme.primary)
.preferredColorScheme(appearance.colorScheme)
.manaBrand(PagetaBrand.config(for: theme))
.manaTheme(theme)
.manaAuthGate(authGate) {
NavigationStack {
ManaLoginView(
auth: auth,
onSignUpTapped: { presentingSignUp = true },
onForgotTapped: { presentingForgot = true }
)
.manaBrand(PagetaBrand.config(for: theme))
.sheet(isPresented: $presentingSignUp) {
NavigationStack {
ManaSignUpView(
auth: auth,
sourceAppUrl: URL(string: "https://pageta.com/auth/verify"),
onDone: { presentingSignUp = false }
)
.manaBrand(PagetaBrand.config(for: theme))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { presentingSignUp = false }
}
}
}
}
.sheet(isPresented: $presentingForgot) {
NavigationStack {
ManaForgotPasswordView(
auth: auth,
resetUniversalLink: URL(
string: "https://pageta.com/auth/reset"
)!,
onDone: { presentingForgot = false }
)
.manaBrand(PagetaBrand.config(for: theme))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { presentingForgot = false }
}
}
}
}
}
}
.onChange(of: scenePhase, initial: true) { _, newPhase in
// `initial: true` feuert beim Cold-Launch, weitere
// Transitions zu .active fangen Foreground-Wechsel
// (ShareExtension → App-Switch zurück). Beides muss
// flushen — sonst sieht die Lese-Liste den frisch
// geteilten Artikel erst nach Termination + Restart.
guard newPhase == .active else { return }
Task {
await flushPendingShares()
await enrichPendingShares()
await syncWidgetSnapshot()
}
}
.onChange(of: auth.status) { _, newStatus in
// Local-First (E-4.5): Beim Login werden anonym lokal
// angelegte Markierungen/Notizen/Tags still in den
// Account geliftet (kein Sheet, transparentes Re-Tagging).
if case .signedIn = newStatus {
Task { await EventSyncCoordinator.shared?.claimOnSignIn() }
// Frisch eingeloggt: ausgeloggt geteilte Links jetzt
// flushen UND anreichern (Extraktion braucht eine Session).
// Der scenePhase-Trigger feuert beim In-App-Login NICHT
// (Scene bleibt .active), sonst hinge der gemerkte Artikel
// inhaltsleer bis zum nächsten Foreground-Wechsel.
Task {
await flushPendingShares()
await enrichPendingShares()
await syncWidgetSnapshot()
}
}
// Bei Logout den Sync stoppen (lokale Daten bleiben) und
// den App-Icon-Badge löschen, sonst bleibt die alte
// Ungelesen-Zahl des vorherigen Accounts am Icon hängen.
// Setting selbst (badge.enabled) bleibt erhalten — der
// User kann sich neu einloggen und findet seine Präferenz
// wieder vor.
if case .signedOut = newStatus {
EventSyncCoordinator.shared?.signOutSync()
Task { await BadgeManager.setBadge(count: 0) }
}
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL {
route(from: url, source: "universal-link")
}
}
.onOpenURL { url in
route(from: url, source: "scheme")
}
.sheet(item: $presentedRoute) { route in
NavigationStack {
switch route {
case let .article(id):
ArticleDetailView(articleId: id)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fertig") { presentedRoute = nil }
}
}
}
}
.environment(auth)
.environment(authGate)
.tint(PagetaTheme.primary)
}
#if os(macOS)
.frame(minWidth: 720, minHeight: 540)
#endif
}
#if os(macOS)
.defaultSize(width: 1024, height: 768)
.windowResizability(.contentMinSize)
#endif
#if os(macOS)
Settings {
SettingsView()
.environment(auth)
.environment(authGate)
.environment(themePass)
.tint(PagetaTheme.primary)
.preferredColorScheme(appearance.colorScheme)
.manaTheme(theme)
.manaBrand(PagetaBrand.config(for: theme))
}
#endif
}
private func route(from url: URL, source: String) {
guard let parsed = PagetaRoute.parse(url) else {
Log.app
.notice(
"Ignoring \(source) URL — no match: \(url.absoluteString, privacy: .public)"
)
return
}
switch parsed {
case let .article(id):
Log.app.info("Routing \(source) → article \(id, privacy: .public)")
presentedRoute = .article(id: id)
}
}
/// Synchronisiert das Widget-Snapshot mit dem Server. Schreibt
/// in App-Group-UserDefaults + triggert WidgetCenter-Reload.
/// Bei Network-Fehler bleibt der letzte Snapshot stehen.
private func syncWidgetSnapshot() async {
guard case .signedIn = auth.status else { return }
Log.app.info("Syncing widget snapshot")
await SnapshotSync(auth: auth).sync()
}
/// PendingShares aus der App-Group-Queue (ShareExtension hat sie
/// enqueued) lokal speichern. **Local-first:** läuft in jedem Auth-
/// Zustand — `saveSharedArticle` legt den Artikel sofort lokal an (auch
/// ausgeloggt) und reichert opportunistisch an. Die Extraktion holt
/// `enrichPendingArticles()` nach, sobald eine Session da ist.
///
/// Nach mindestens einem Save bumpt das `shareSignal`, damit
/// `LeseListeTabView` reloaded. Sonst sieht der User den frisch geteilten
/// Artikel erst beim nächsten manuellen Refresh.
@MainActor
private func flushPendingShares() async {
// Verwaiste HTML-Blobs wegwerfen (Shares, die nie erfolgreich
// extrahiert wurden — z. B. dauerhaft ausgeloggt geteilt).
SharedHTMLStore().prune()
let queue = pendingShares.currentQueue()
guard !queue.isEmpty else { return }
Log.app.info("Flushing \(queue.count, privacy: .public) pending share(s)")
let api = PagetaAPI(auth: auth)
var savedAny = false
for url in queue {
do {
try await api.saveSharedArticle(url: url)
pendingShares.remove(url)
savedAny = true
Log.app.info("Saved share: \(url.absoluteString, privacy: .public)")
} catch {
Log.app
.error(
"Flush failed for \(url.absoluteString, privacy: .public) — kept in queue: \(error.localizedDescription, privacy: .public)"
)
}
}
if savedAny {
shareSignal.bump()
}
}
/// Reichert lokal gespeicherte, inhaltsleere Artikel an, sobald die
/// Extraktion möglich ist (gültige Session). Läuft nach jedem Flush +
/// beim Login. Bumpt `shareSignal`, wenn etwas angereichert wurde, damit
/// die Liste die echten Titel/Inhalte statt der Host-Platzhalter zeigt.
@MainActor
private func enrichPendingShares() async {
let enriched = await PagetaAPI(auth: auth).enrichPendingArticles()
if enriched > 0 {
Log.app.info("Enriched \(enriched, privacy: .public) pending article(s)")
shareSignal.bump()
}
}
}
/// Wrapper, damit `sheet(item:)` Identifiable bekommt — PagetaRoute
/// selbst soll API-frei von SwiftUI bleiben (für Testbarkeit).
enum PresentedRoute: Identifiable, Equatable {
case article(id: String)
var id: String {
switch self {
case let .article(id): "article-\(id)"
}
}
}