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>
291 lines
9.6 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|